feat: 기능 추가 과정중
This commit is contained in:
@@ -0,0 +1,880 @@
|
||||
# VD-13: Client cache scope, persistence와 탭 간 일관성 경계
|
||||
|
||||
- 상태: Accepted — staged implementation required
|
||||
- 결정일: 2026-07-28
|
||||
- 관련 결정: VD-10, VD-11
|
||||
- 상세 설계:
|
||||
`docs/architecture/client-cache-and-storage.md`
|
||||
- current status ledger:
|
||||
`docs/architecture/browser-data-capability-completion-ledger.md`
|
||||
- 재검토:
|
||||
account/tenant switching, query persistence, SSR 또는 offline mutation을
|
||||
제품 capability로 선택할 때
|
||||
|
||||
## 1. 배경
|
||||
|
||||
TanStack Query memory cache, Web Storage, IndexedDB와 BroadcastChannel은 모두
|
||||
client state에 관여하지만 같은 authority, 수명과 commit point를 갖지 않는다.
|
||||
|
||||
- TanStack Query memory cache는 현재 JavaScript runtime의 server-state projection다.
|
||||
- `localStorage`와 `sessionStorage`는 작은 preference/control record를 위한
|
||||
동기식 browser storage다.
|
||||
- IndexedDB는 transaction, index와 durable structured record를 제공한다.
|
||||
- BroadcastChannel과 `storage` event는 같은 storage partition 안의 best-effort
|
||||
notification이다.
|
||||
- SSR dehydration과 browser persistence hydration은 서로 다른 source에서 생성된
|
||||
cache projection을 합치는 별도 protocol이다.
|
||||
|
||||
현재 skeleton은 memory QueryClient, 두 개의 등록 Web Storage key와
|
||||
invalidate-only cross-tab runtime을 production bootstrap에 조립한다. domain-neutral
|
||||
IndexedDB runtime은 source와 native contract test가 있지만 product dataset 없이
|
||||
bootstrap에서 제외돼 있다. IndexedDB query persister, durable namespace epoch,
|
||||
session/account-scoped QueryClient lifecycle과 SSR hydration은 아직 구현되지
|
||||
않았다.
|
||||
|
||||
이 차이를 숨긴 채 “client cache가 구현됐다”고 표현하면 다음 문제가 생긴다.
|
||||
|
||||
- logout 뒤 old account의 cache나 늦은 async result가 새 account 화면에 나타남
|
||||
- best-effort invalidation event를 authorization 또는 server commit으로 오인함
|
||||
- 여러 tab의 full cache snapshot이 서로 오래된 record를 다시 살림
|
||||
- browser persistence가 최신 SSR payload를 덮음
|
||||
- Web Storage memory fallback 성공과 durable write 성공을 구분하지 못함
|
||||
- query cache를 offline command repository처럼 사용해 unsynced user data를
|
||||
eviction으로 잃음
|
||||
|
||||
## 2. 표준 capability 상태
|
||||
|
||||
이 결정과 상세 설계는 다음 상태만 사용한다.
|
||||
|
||||
| 상태 | 의미 |
|
||||
| --- | --- |
|
||||
| `COMPOSED` | 구현·계약·test가 있고 production bootstrap이 실제 생성·소비한다. |
|
||||
| `AVAILABLE_NOT_COMPOSED` | reusable runtime과 test가 있지만 production bootstrap에서 생성하지 않는다. |
|
||||
| `DESIGNED_NOT_IMPLEMENTED` | 경계와 invariant는 승인됐지만 실행 코드가 없다. |
|
||||
| `NOT_SELECTED` | 제품 요구·owner·policy가 승인되지 않아 설치 대상이 아니다. |
|
||||
| `PLATFORM_LIMITED` | browser/platform이 요구 의미를 cross-browser로 보장하지 못한다. |
|
||||
|
||||
`AVAILABLE_NOT_COMPOSED`와 `NOT_SELECTED`는 같은 말이 아니다. 전자는 reusable
|
||||
runtime의 구현 상태고, 후자는 제품 capability 선택 상태다. 하나의 capability에
|
||||
두 축이 필요하면 “reference runtime”과 “product selection”을 별도 행으로 쓴다.
|
||||
|
||||
### 2.1 현재 상태
|
||||
|
||||
| capability | 현재 상태 | 현재 보장 |
|
||||
| --- | --- | --- |
|
||||
| TanStack Query memory runtime | `COMPOSED` | runtime별 QueryClient, finite inactive GC, retry owner, query AbortSignal |
|
||||
| registered Web Storage | `COMPOSED` | `COLOR_SCHEME`, `CHUNK_RELOAD_GUARD`만 strict codec/envelope로 사용 |
|
||||
| invalidate-only cross-tab runtime | `COMPOSED` | versioned topic, BroadcastChannel 우선, localStorage pulse fallback |
|
||||
| generic IndexedDB repository/maintenance runtime | `AVAILABLE_NOT_COMPOSED` | CAS, idempotency, transaction complete, policy binding, bounded lifecycle/migration |
|
||||
| session/account-scoped QueryClient lifecycle | `DESIGNED_NOT_IMPLEMENTED` | 없음; 현재 cache epoch는 release ID만 포함 |
|
||||
| strict query policy/key codec | `DESIGNED_NOT_IMPLEMENTED` | 현재 object key order canonicalization만 존재 |
|
||||
| IndexedDB query persistence facade | `DESIGNED_NOT_IMPLEMENTED` | persistence는 registry에서 강제로 disabled |
|
||||
| durable namespace invalidation ledger | `DESIGNED_NOT_IMPLEMENTED` | 없음 |
|
||||
| product query persistence | `NOT_SELECTED` | persist 대상 query/owner가 없음 |
|
||||
| SSR dehydration/hydration | `NOT_SELECTED` | 현재 runtime은 client SPA composition |
|
||||
| exactly-once cross-tab delivery | `PLATFORM_LIMITED` | BroadcastChannel/storage event는 acknowledgement를 제공하지 않음 |
|
||||
| browser storage non-eviction guarantee | `PLATFORM_LIMITED` | persist 요청도 user-agent eviction을 절대 금지하지 않음 |
|
||||
|
||||
## 3. 결정
|
||||
|
||||
### 3.1 하나의 cache/storage abstraction으로 합치지 않는다
|
||||
|
||||
다음 경계를 유지한다.
|
||||
|
||||
```text
|
||||
server response
|
||||
-> feature application result
|
||||
-> query inbound adapter
|
||||
-> scope-owned TanStack QueryClient
|
||||
|
||||
small approved preference/control value
|
||||
-> registered Web Storage facade
|
||||
-> exact localStorage/sessionStorage key
|
||||
|
||||
optional reconstructable query projection
|
||||
-> query persistence facade
|
||||
-> query-specific stable wire codec
|
||||
-> governance-bound IndexedDB runtime
|
||||
|
||||
committed mutation
|
||||
-> local namespace invalidation
|
||||
-> optional durable namespace epoch commit
|
||||
-> best-effort cross-tab hint
|
||||
```
|
||||
|
||||
QueryClient, native `Storage`, `IDBDatabase`, BroadcastChannel, dehydrated TanStack
|
||||
types와 physical key/store/index 이름을 application/domain에 노출하지 않는다.
|
||||
|
||||
### 3.2 source of truth와 authority
|
||||
|
||||
1. 일반 server state와 authorization의 source of truth는 서버다.
|
||||
2. memory cache와 persisted query record는 재구성 가능한 projection이다.
|
||||
3. cache hit, persisted restore와 invalidation event는 authorization proof가 아니다.
|
||||
4. 모든 protected network request는 현재 session credential과 server
|
||||
authorization을 다시 통과한다.
|
||||
5. remote invalidation event는 `invalidate`만 요청할 수 있다. `remove`, `clear`,
|
||||
logout, account deletion과 credential revocation authority를 갖지 않는다.
|
||||
6. unsynced command, local-first draft와 user-authored offline data는 query
|
||||
persistence에 저장하지 않는다. feature-specific IndexedDB repository와
|
||||
sync use case가 소유한다.
|
||||
|
||||
## 4. session/account/release scope
|
||||
|
||||
### 4.1 immutable scope snapshot
|
||||
|
||||
composition의 session authority는 다음 의미를 갖는 immutable snapshot을 발급한다.
|
||||
구현 type과 field name은 이 의미를 보존해야 한다.
|
||||
|
||||
```ts
|
||||
type CacheScopeSnapshot = Readonly<{
|
||||
protocolVersion: 1;
|
||||
authorityToken: string;
|
||||
partitionToken: string;
|
||||
sessionEpoch: string;
|
||||
accountEpoch: string;
|
||||
releaseEpoch: string;
|
||||
generation: number;
|
||||
}>;
|
||||
```
|
||||
|
||||
- 모든 token은 registry/session authority가 발급한 충분한 entropy의 opaque
|
||||
identifier다.
|
||||
- email, account/tenant/user ID, domain ID, access token과 낮은 entropy identifier의
|
||||
단순 hash를 사용하지 않는다.
|
||||
- `generation`은 현재 page runtime에서 단조 증가하는 local lifecycle fence다.
|
||||
backend entity revision이나 wire ordering으로 사용하지 않는다.
|
||||
- `sessionEpoch`는 sign-in, re-auth, credential owner 교체 때 바뀐다.
|
||||
- `accountEpoch`는 account/tenant switch, logout, account deletion 때 바뀐다.
|
||||
- `releaseEpoch`는 query-key, mapper, codec 또는 persistence wire compatibility가
|
||||
깨질 때 바뀐다.
|
||||
- scope object와 nested policy는 construction 때 copy/freeze한다. async operation은
|
||||
시작 시 exact snapshot과 generation을 캡처한다.
|
||||
|
||||
### 4.2 profile별 scope projection
|
||||
|
||||
모든 query가 account token을 key에 넣지는 않는다. registry가 분류에 따라 다음을
|
||||
고정한다.
|
||||
|
||||
| scope | binding |
|
||||
| --- | --- |
|
||||
| `ORIGIN_SHARED` | release epoch와 origin-shared token |
|
||||
| `ACCOUNT_BOUND` | partition token, account epoch, release epoch |
|
||||
| `SESSION_BOUND` | partition token, account epoch, session epoch, release epoch |
|
||||
|
||||
- `PUBLIC`만 `ORIGIN_SHARED`를 사용할 수 있다.
|
||||
- `INTERNAL`은 제품 authority가 origin-shared public semantics를 증명하지 않는 한
|
||||
`ACCOUNT_BOUND` 이상이다.
|
||||
- `PERSONAL`은 `ACCOUNT_BOUND` 이상이고 persistence에는 explicit approval,
|
||||
bounded retention과 logout purge가 필요하다.
|
||||
- `CONFIDENTIAL`은 query persistence가 금지되고 필요한 순간의 memory
|
||||
`SESSION_BOUND`만 허용한다.
|
||||
- credential은 memory query data, persistence, query key와 invalidation wire
|
||||
모두에서 금지한다.
|
||||
|
||||
### 4.3 composite cache epoch
|
||||
|
||||
cross-tab `cacheEpoch`는 raw token을 연결한 문자열이 아니라 선택된 scope projection과
|
||||
protocol major의 opaque compatibility fingerprint다. receiver는 exact equality만
|
||||
검사하고 원래 account/session/release 의미 값을 wire에서 복원하지 않는다.
|
||||
|
||||
현재 `release.<releaseId>`만 사용하는 값은 transitional implementation이다.
|
||||
account-dependent query를 production에 설치하기 전에 composite scope fingerprint로
|
||||
교체한다.
|
||||
|
||||
## 5. QueryClient lifecycle와 late-result fence
|
||||
|
||||
### 5.1 runtime state
|
||||
|
||||
scope-owned query runtime은 다음 terminal lifecycle을 갖는다.
|
||||
|
||||
```text
|
||||
CREATING
|
||||
-> ACTIVE
|
||||
-> FENCING
|
||||
-> DISPOSING
|
||||
-> DISPOSED
|
||||
```
|
||||
|
||||
- `ACTIVE`만 신규 query/mutation/cache update를 admission한다.
|
||||
- scope transition이 시작되면 먼저 `FENCING`으로 바꾸고 generation을 올린다.
|
||||
- `DISPOSING`에서 old query를 cancel하고 provider/controller를 detach한 뒤
|
||||
QueryClient를 clear한다.
|
||||
- old cross-tab channel, persistence writer/connection, timer와 listener를 닫는다.
|
||||
- exact old Web Storage key/IndexedDB partition purge는 policy와 authority를
|
||||
통과한 bounded lifecycle operation으로 수행한다.
|
||||
- 새 scope는 새 QueryClient와 새 coordinator를 만든다. old client를 재사용해
|
||||
key prefix만 바꾸지 않는다.
|
||||
- dispose와 scope transition은 idempotent하다.
|
||||
|
||||
### 5.2 query fence
|
||||
|
||||
query execution은 TanStack의 AbortSignal과 scope generation을 모두 캡처한다.
|
||||
|
||||
1. 시작 전 runtime이 `ACTIVE`인지 확인한다.
|
||||
2. application request에 AbortSignal을 전달한다.
|
||||
3. 완료 시 captured generation과 current generation을 비교한다.
|
||||
4. mismatch면 성공/실패 모두 새 cache/UI에 적용하지 않고 `STALE_RESULT`로
|
||||
폐기한다.
|
||||
5. query cancellation 실패가 scope clear를 막지 않게 하되 safe diagnostic을
|
||||
남긴다.
|
||||
|
||||
### 5.3 mutation fence
|
||||
|
||||
frontend abort는 이미 서버에 도달한 mutation을 되돌리지 않는다.
|
||||
|
||||
- 시작 전 admission과 generation을 확인한다.
|
||||
- server commit 전 cancellation은 transport의 idempotency/cancellation 계약을
|
||||
따른다.
|
||||
- server 결과가 old generation에서 돌아오면 새 cache에 optimistic result,
|
||||
invalidation 또는 success UI를 적용하지 않는다.
|
||||
- server side effect의 authoritative 결과는 새 scope에서 정상 revalidation한다.
|
||||
- mutation success 후 local invalidation 실패나 hint publish 실패가 이미 committed
|
||||
server mutation을 실패로 바꾸지 않는다.
|
||||
- conflict resolution은 backend revision/ETag/idempotency 계약과 feature policy가
|
||||
소유한다. query cache는 business merge authority가 아니다.
|
||||
|
||||
### 5.4 session owner 연결
|
||||
|
||||
production bootstrap은 auth/session owner subscription을 query lifecycle에
|
||||
연결한다. 단순 `authenticated` boolean만으로 account identity를 추론하지 않는다.
|
||||
owner는 opaque scope snapshot 또는 이를 발급할 authority를 제공해야 한다.
|
||||
|
||||
다른 tab의 logout은 cache invalidation event에 의존하지 않는다. 각 tab의 auth
|
||||
owner가 credential/session 변화를 독립적으로 감지하고 local lifecycle을
|
||||
실행해야 한다.
|
||||
|
||||
## 6. strict query scope/persistence registry와 key codec
|
||||
|
||||
### 6.1 registry
|
||||
|
||||
모든 installed query namespace는 immutable scope/persistence profile을 갖는다.
|
||||
freshness, GC, refetch, retry, result budget, pagination과 conditional policy의
|
||||
유일한 source of truth는 VD-25 `ServerStateProfile`이다.
|
||||
|
||||
```ts
|
||||
type QueryScopePersistencePolicy = Readonly<{
|
||||
policyId: string;
|
||||
namespace: readonly [string, number];
|
||||
keySchemaVersion: number;
|
||||
classification: "PUBLIC" | "INTERNAL" | "PERSONAL" | "CONFIDENTIAL";
|
||||
scope: "ORIGIN_SHARED" | "ACCOUNT_BOUND" | "SESSION_BOUND";
|
||||
persistence:
|
||||
| Readonly<{ kind: "MEMORY_ONLY" }>
|
||||
| Readonly<{
|
||||
kind: "INDEXEDDB";
|
||||
profileId: string;
|
||||
maxAgeMs: number;
|
||||
maxEntryBytes: number;
|
||||
}>;
|
||||
crossTab: "NONE" | "INVALIDATE";
|
||||
invalidationTopics: readonly Readonly<{
|
||||
topicId: string;
|
||||
topicVersion: number;
|
||||
}>[];
|
||||
}>;
|
||||
```
|
||||
|
||||
construction은 최소 다음을 검증한다.
|
||||
|
||||
- namespace/topic/policy ID가 closed syntax와 unique version을 가짐
|
||||
- persistence가 classification, scope, max age와 맞음
|
||||
- `NONE`은 topic 0개, `INVALIDATE`는 namespace당 unique topic 1..8개
|
||||
- `(topicId, topicVersion)` 하나는 최대 32개 namespace에 fan-out하며 global
|
||||
topic→namespace set과 namespace→topic set이 서로 exact inverse
|
||||
- profile과 nested allowlist를 deep snapshot/freeze함
|
||||
- VD-25 query definition/`ServerStateProfile`과 join했을 때 owner,
|
||||
classification, scope, namespace, persistence와 invalidation topic set/version이
|
||||
일치함
|
||||
|
||||
composition은 `QueryDefinition -> QueryScopePersistencePolicy ->
|
||||
ServerStateProfile`을 exact ID로 join한 뒤에만 TanStack option을 만든다. global
|
||||
QueryClient default는 안전 baseline일 뿐이고 installed query의 정책 증거가
|
||||
아니다.
|
||||
|
||||
### 6.2 query key wire subset
|
||||
|
||||
query key factory의 canonical input은 다음만 허용한다.
|
||||
|
||||
- `null`, boolean, finite number, bounded string
|
||||
- 위 값의 dense array
|
||||
- own enumerable data property만 가진 plain/null-prototype object
|
||||
|
||||
다음을 fail-closed로 거절한다.
|
||||
|
||||
- cycle/shared exotic graph
|
||||
- `undefined`, `BigInt`, symbol, function, accessor
|
||||
- `NaN`, infinity, negative zero를 구분하지 않는 암묵 변환
|
||||
- Date, RegExp, Map, Set, class/DOM/native object
|
||||
- File, Blob, ArrayBuffer와 typed array
|
||||
- sparse array
|
||||
- `__proto__`, `prototype`, `constructor` key
|
||||
- 허용 depth/node/part/string/serialized-byte ceiling 초과
|
||||
|
||||
구현 절대 상한:
|
||||
|
||||
| 항목 | 상한 |
|
||||
| --- | ---: |
|
||||
| installed query profile | 256 |
|
||||
| query key top-level part | 16 |
|
||||
| canonical value depth | 8 |
|
||||
| canonical value node | 256 |
|
||||
| 단일 string UTF-8 | 1,024 bytes |
|
||||
| 전체 canonical key UTF-8 | 4,096 bytes |
|
||||
|
||||
제품 profile은 더 낮출 수 있지만 이 상한을 높이려면 ADR amendment와
|
||||
memory/telemetry cardinality evidence가 필요하다.
|
||||
|
||||
normative key layout:
|
||||
|
||||
```text
|
||||
[
|
||||
"query",
|
||||
keySchemaVersion,
|
||||
scopeFingerprint,
|
||||
namespaceName,
|
||||
namespaceVersion,
|
||||
queryDefinitionVersion,
|
||||
canonicalSemanticInput
|
||||
]
|
||||
```
|
||||
|
||||
VD-25는 이 배열을 재정의하지 않고 마지막 두 field의 의미와 pagination
|
||||
projection만 소유한다. query function이 의존하는 모든 non-secret input을
|
||||
포함하되 URL 전체, bearer
|
||||
token, email, filename, human-readable personal label을 넣지 않는다. domain entity
|
||||
identity가 필요하면 backend/product contract가 발급한 opaque ID와 bounded codec을
|
||||
사용한다.
|
||||
|
||||
### 6.3 memory pressure
|
||||
|
||||
`gcTime`은 inactive retention이지 active cache hard cap이 아니다.
|
||||
|
||||
- gateway/mapper가 response count/byte ceiling을 검증한다.
|
||||
- binary, native object와 unbounded collection을 query cache에 넣지 않는다.
|
||||
- cache entry/active/inactive와 estimated payload를 safe bucket으로 관측한다.
|
||||
- hard eviction controller는 joined VD-25 profile별 정책으로만 설치한다.
|
||||
- memory pressure를 이유로 active personal data를 arbitrary global timer로
|
||||
삭제하지 않는다. scope lifecycle의 remove/clear와 일반 eviction을 구분한다.
|
||||
|
||||
## 7. Web Storage contract
|
||||
|
||||
### 7.1 registered key policy
|
||||
|
||||
Web Storage는 registered small value 전용이다.
|
||||
|
||||
```ts
|
||||
type WebStorageDefinition<Value> = Readonly<{
|
||||
logicalName: string;
|
||||
backend: "localStorage" | "sessionStorage";
|
||||
scope: "ORIGIN_SHARED" | "OPAQUE_PARTITION" | "TAB";
|
||||
classification: "PUBLIC_PREFERENCE" | "OPAQUE_CONTROL";
|
||||
schemaVersion: number;
|
||||
maxSerializedBytes: number;
|
||||
retention:
|
||||
| Readonly<{ kind: "SESSION" }>
|
||||
| Readonly<{ kind: "TTL"; maxAgeMs: number }>
|
||||
| Readonly<{ kind: "EXPLICIT_DELETE" }>;
|
||||
valueCodec: string;
|
||||
migration:
|
||||
| Readonly<{ kind: "DISCARD" }>
|
||||
| Readonly<{ kind: "ADJACENT"; migrationId: string }>;
|
||||
quotaFallback: "MEMORY" | "NO_PERSIST" | "FEATURE_DISABLE";
|
||||
logoutAction: "KEEP" | "PURGE_PARTITION";
|
||||
}>;
|
||||
```
|
||||
|
||||
구현 절대 상한:
|
||||
|
||||
| 항목 | 상한 |
|
||||
| --- | ---: |
|
||||
| registered persistent key | 64 |
|
||||
| key별 serialized value | 16,384 bytes |
|
||||
| 한 sweep에서 검사할 key | 16 |
|
||||
| 한 read에서 migration step | 2 |
|
||||
|
||||
현재 두 key는 각각 더 좁은 codec을 유지한다. `COLOR_SCHEME`은 public
|
||||
origin-shared preference이고 `CHUNK_RELOAD_GUARD`는 tab session control이다.
|
||||
server response, credential, signed URL, File/Blob, large draft와 queue를 Web
|
||||
Storage에 넣지 않는다.
|
||||
|
||||
### 7.2 physical identity와 envelope
|
||||
|
||||
physical key는 application/environment, scope kind, opaque partition 또는 tab
|
||||
instance, logical key, schema version에서 결정적으로 파생한다. account/user ID를
|
||||
포함하거나 origin 전체 key를 열거하지 않는다.
|
||||
|
||||
partition-aware 새 envelope는 기존 v1 세 필드의 의미를 변경하지 않고 새
|
||||
envelope version으로 도입한다.
|
||||
|
||||
```ts
|
||||
type BrowserStorageEnvelopeV2 = Readonly<{
|
||||
envelopeVersion: 2;
|
||||
schemaVersion: number;
|
||||
scopeFingerprint: string;
|
||||
writtenAtEpochMs: number;
|
||||
expiresAtEpochMs: number | null;
|
||||
value: unknown;
|
||||
}>;
|
||||
```
|
||||
|
||||
- exact field set, schema, scope, codec, written/expiry time 순으로 검증한다.
|
||||
- TTL expiry는 write time과 registry max age에서 계산하며 caller가 직접 주지 않는다.
|
||||
- 비정상적으로 먼 expiry, future write time과 clock skew는 fail-closed miss다.
|
||||
- corrupt/expired/future/wrong-scope record는 exact key만 best-effort 제거한다.
|
||||
- cleanup 실패는 validated miss를 raw exception으로 바꾸지 않는다.
|
||||
- memory overlay도 exact envelope와 TTL/scope validation을 공유한다.
|
||||
|
||||
### 7.3 read/write outcome
|
||||
|
||||
stored `undefined`와 miss를 암묵적으로 합치지 않는다.
|
||||
|
||||
```ts
|
||||
type WebStorageReadResult<Value> =
|
||||
| Readonly<{ ok: true; state: "HIT"; value: Value; durability: "PERSISTED" | "MEMORY_ONLY" }>
|
||||
| Readonly<{ ok: true; state: "MISS" }>
|
||||
| Readonly<{ ok: false; error: ClientStorageFailure }>;
|
||||
|
||||
type WebStorageWriteResult =
|
||||
| Readonly<{ ok: true; durability: "PERSISTED" }>
|
||||
| Readonly<{ ok: true; durability: "MEMORY_ONLY"; degraded: true }>
|
||||
| Readonly<{ ok: false; error: ClientStorageFailure }>;
|
||||
```
|
||||
|
||||
memory fallback이 current runtime에서 승인된 성공이면 `ok: true`와
|
||||
`MEMORY_ONLY`를 반환한다. durable write가 필수인 key는 fallback을 성공으로
|
||||
가장하지 않는다.
|
||||
|
||||
### 7.4 migration, quota와 sweep
|
||||
|
||||
- migration은 registry에 등록된 deterministic adjacent version만 실행한다.
|
||||
- migration callback은 network/native storage/telemetry side effect 없이 bounded
|
||||
pure codec으로 동작한다.
|
||||
- future version과 unsupported old version은 `DISCARD` policy에서 miss다.
|
||||
- `QuotaExceededError`이면 reconstructable exact key cleanup 뒤 동일 idempotent
|
||||
write를 최대 한 번 재시도한다.
|
||||
- origin 전체 `clear()`와 arbitrary LRU key enumeration을 금지한다.
|
||||
- TTL은 visibility rule이므로 boot/idle/focus 중 registry-owned bounded sweep을
|
||||
별도로 수행한다.
|
||||
- logout/account switch는 exact partition key만 purge한다. public origin-shared
|
||||
preference를 지우지 않는다.
|
||||
- `sessionStorage` opener snapshot을 authority로 사용하지 않는다. tab-local
|
||||
control에는 새 tab instance와 `noopener` policy를 적용한다.
|
||||
|
||||
## 8. optional IndexedDB query persistence
|
||||
|
||||
### 8.1 selection
|
||||
|
||||
query persistence reference facade의 현재 상태는
|
||||
`DESIGNED_NOT_IMPLEMENTED`, product selection은 `NOT_SELECTED`다. 단순 warm-start
|
||||
기대만으로 자동 설치하지 않는다.
|
||||
|
||||
다음 조건을 모두 충족한 query만 등록한다.
|
||||
|
||||
- server-authoritative이며 재구성 가능함
|
||||
- stable query-key와 payload codec이 있음
|
||||
- classification/scope/retention owner 승인
|
||||
- entry/dataset/restore byte와 count budget이 있음
|
||||
- logout/account deletion/release busting이 정의됨
|
||||
- measured offline/warm-start 가치가 있음
|
||||
- three-engine native contract와 rollback evidence가 있음
|
||||
|
||||
### 8.2 stable record, raw TanStack snapshot 금지
|
||||
|
||||
full QueryClient snapshot이나 library-private object를 그대로 저장하지 않는다.
|
||||
|
||||
```ts
|
||||
type PersistedQueryRecord = Readonly<{
|
||||
recordVersion: 1;
|
||||
queryHash: string;
|
||||
encodedQueryKey: unknown;
|
||||
policyId: string;
|
||||
scopeFingerprint: string;
|
||||
releaseEpoch: string;
|
||||
namespaceEpoch: number;
|
||||
dataUpdatedAtEpochMs: number;
|
||||
persistedAtEpochMs: number;
|
||||
expiresAtEpochMs: number;
|
||||
payloadCodecVersion: number;
|
||||
payload: unknown;
|
||||
measuredBytes: number;
|
||||
revision: number;
|
||||
}>;
|
||||
```
|
||||
|
||||
- approved successful query data만 저장한다.
|
||||
- error, pending state, mutation, function, Promise, AbortSignal, native/binary
|
||||
object, credential와 capability를 저장하지 않는다.
|
||||
- query key와 payload를 각각 strict codec으로 검증한다.
|
||||
- generic IndexedDB runtime의 opaque scope/policy binding, transaction complete,
|
||||
CAS, byte budget, migration, lifecycle와 failure mapping을 재사용한다.
|
||||
|
||||
### 8.3 구현 상한
|
||||
|
||||
reference facade의 기본 절대 상한:
|
||||
|
||||
| 항목 | 상한 |
|
||||
| --- | ---: |
|
||||
| persisted query record | 1,024 |
|
||||
| 단일 encoded entry | 512 KiB |
|
||||
| query persistence dataset | 32 MiB |
|
||||
| 한 restore record | 256 |
|
||||
| 한 restore decoded bytes | 8 MiB |
|
||||
| boot restore deadline | 2,000 ms |
|
||||
| max age | 7 days |
|
||||
| write debounce | 250–2,000 ms |
|
||||
|
||||
제품 policy는 더 낮출 수 있다. 상한 확대는 memory/quota/startup-latency evidence와
|
||||
ADR amendment가 필요하다.
|
||||
|
||||
### 8.4 durable namespace epoch
|
||||
|
||||
full snapshot last-write-wins를 금지한다. 기본 writer model은 shared per-query
|
||||
record + monotonic namespace epoch다.
|
||||
|
||||
```ts
|
||||
type DurableCacheLedger = Readonly<{
|
||||
ledgerVersion: 1;
|
||||
scopeFingerprint: string;
|
||||
releaseEpoch: string;
|
||||
namespaces: Readonly<Record<string, number>>;
|
||||
revision: number;
|
||||
}>;
|
||||
```
|
||||
|
||||
- mutation invalidation은 namespace epoch를 같은 IndexedDB transaction에서
|
||||
증가시킨 뒤 cross-tab hint를 publish한다.
|
||||
- persisted record의 namespace epoch가 ledger보다 작으면 hydrate하지 않는다.
|
||||
- record write는 current ledger epoch와 revision을 CAS 검증한다.
|
||||
- BroadcastChannel sequence나 wall clock을 global durable ordering으로 사용하지
|
||||
않는다.
|
||||
- localStorage read-modify-write counter와 best-effort leader election을 correctness
|
||||
fence로 쓰지 않는다.
|
||||
- ledger commit 뒤 hint를 publish한다. hint가 먼저 나가면 receiver가 commit 전
|
||||
record를 읽을 수 있다.
|
||||
|
||||
### 8.5 restore와 hydration order
|
||||
|
||||
1. bounded deadline으로 IndexedDB를 연다.
|
||||
2. immutable dataset/scope/release binding을 검증한다.
|
||||
3. ledger와 record schema/codec/TTL/byte cap을 검증한다.
|
||||
4. approved profile과 current namespace epoch만 decode한다.
|
||||
5. current memory/SSR state와 precedence를 적용한다.
|
||||
6. hydrate 뒤 normal stale/refetch policy를 실행한다.
|
||||
|
||||
wrong scope, expired, busted와 corrupt reconstructable record는 cache miss로
|
||||
격하하고 exact bounded cleanup한다. persistence unavailable/blocked/timeout은
|
||||
제품이 optional로 선택했다면 memory+network `ONLINE_ONLY`로 fail open한다.
|
||||
offline-required workflow를 query persistence로 가장하지 않는다.
|
||||
|
||||
### 8.6 writer lifecycle
|
||||
|
||||
- cache events는 bounded debounce/coalescing한다.
|
||||
- writer 하나에서 concurrent save를 serialize하고 superseded write를 버린다.
|
||||
- `pagehide`/`beforeunload` transaction 완료를 보장으로 간주하지 않는다.
|
||||
- 정상 runtime 중 주기적으로 commit하고 unload flush는 보조 수단이다.
|
||||
- dispose는 timer를 취소하고 connection/listener를 닫는다.
|
||||
- 아직 transaction complete가 아닌 write를 persisted success로 기록하지 않는다.
|
||||
|
||||
## 9. cross-tab invalidation
|
||||
|
||||
### 9.1 authority
|
||||
|
||||
cross-tab wire는 payload/query-key-free invalidate hint만 전달한다.
|
||||
|
||||
- query state/data replication 금지
|
||||
- authorization/logout/server commit 증명 금지
|
||||
- distributed lock/leader election 금지
|
||||
- exactly-once/ordered delivery 주장 금지
|
||||
- offline command 전송 금지
|
||||
|
||||
remote hint는 registry topic을 local namespace로 해석해 active query를
|
||||
invalidate/refetch한다. inactive query는 다음 mount/focus/freshness 정책에서
|
||||
revalidate한다. remote hint는 `removeQueries`, `clear()` 또는 session transition을
|
||||
직접 실행하지 않는다.
|
||||
|
||||
### 9.2 transport와 source validation
|
||||
|
||||
```text
|
||||
BroadcastChannel
|
||||
-> construction/post failure
|
||||
-> registered localStorage pulse + storage event
|
||||
-> failure/unavailable
|
||||
-> DEGRADED_LOCAL_ONLY + normal stale/focus/reconnect
|
||||
```
|
||||
|
||||
- current 2,048-byte exact wire envelope와 bounded TTL/dedupe/source tracking을
|
||||
유지한다.
|
||||
- topic registry 수에도 query profile과 같은 256개 절대 상한을 적용한다.
|
||||
- localStorage fallback key를 Web Storage control registry에 등록한다.
|
||||
- receiver는 exact key, exact `storageArea === localStorage`, exact composite
|
||||
cache epoch와 event codec을 검증한다.
|
||||
- `sessionStorage`를 cross-tab fallback으로 사용하지 않는다.
|
||||
- publisher는 local invalidation을 직접 수행한다.
|
||||
- publish success는 receiver acknowledgement가 아니다.
|
||||
- BroadcastChannel과 storage 양쪽 delivery는 event ID로 dedupe한다.
|
||||
- per-source sequence gap은 global order 증명이 아니라 “hint를 잃었을 수 있음”을
|
||||
나타낸다.
|
||||
|
||||
### 9.3 lost hint
|
||||
|
||||
query persistence가 꺼져 있으면 finite stale time, focus/reconnect와 manual refresh가
|
||||
eventual revalidation을 제공한다. persistence가 켜져 있으면 visibility/focus와
|
||||
sequence gap에서 durable namespace ledger를 bounded refresh한다.
|
||||
|
||||
즉시 global consistency가 업무 invariant라면 browser bus만으로 충족하지 않는다.
|
||||
backend revision/ETag, server push stream 또는 feature sync protocol을 추가한다.
|
||||
|
||||
## 10. SSR 선택 경계
|
||||
|
||||
현재 SSR product capability는 `NOT_SELECTED`다. browser-only code가 있다는 이유로
|
||||
SSR support가 구현됐다고 주장하지 않는다.
|
||||
|
||||
SSR을 선택하면 별도 implementation gate에서 다음을 모두 구현한다.
|
||||
|
||||
1. HTTP request마다 새 QueryClient를 생성하고 response 뒤 폐기한다.
|
||||
2. server process에서 Web Storage, IndexedDB와 BroadcastChannel에 접근하지 않는다.
|
||||
3. approved successful query만 dehydrate한다.
|
||||
4. serialized state를 HTML context에 안전하게 escape하고 byte/count cap을 적용한다.
|
||||
5. browser의 최신 SSR payload가 old persisted projection보다 우선한다.
|
||||
6. persisted state merge는 missing approved query만 복원하거나 explicit server
|
||||
revision을 비교한다.
|
||||
7. browser storage read 때문에 initial server/client markup이 달라지지 않게
|
||||
hydration-safe bootstrap 단계에서 restore한다.
|
||||
8. request A의 QueryClient/data/scope가 request B에 공유되지 않는 test를 둔다.
|
||||
|
||||
SSR support와 IndexedDB query persistence는 서로 독립 선택이다.
|
||||
|
||||
## 11. privacy와 encryption
|
||||
|
||||
- credential, token, signed URL, authorization header, password와 crypto key는
|
||||
memory query key/data, Web Storage, query persistence와 invalidation wire에
|
||||
넣지 않는다.
|
||||
- logical/physical key, query key/hash input, payload, account/user ID, URL과 native
|
||||
exception message/stack을 telemetry에 보내지 않는다.
|
||||
- 같은 origin JavaScript가 ciphertext와 key를 모두 읽을 수 있는 client-side
|
||||
encryption은 XSS authorization boundary가 아니다.
|
||||
- external/non-extractable key lifecycle과 compliance requirement가 있는 제품은
|
||||
encryption을 defense-in-depth로 별도 선택할 수 있지만, 금지 classification을
|
||||
허용하는 근거가 되지 않는다.
|
||||
- logout purge는 confidentiality의 유일한 방어가 아니다. wrong-scope binding은
|
||||
crash로 old bytes가 남아도 새 runtime이 읽지 못하게 해야 한다.
|
||||
|
||||
## 12. failure와 observability
|
||||
|
||||
failure는 최소 operation, closed code, retry owner, effect certainty와 fallback을
|
||||
표현한다.
|
||||
|
||||
- `ABORTED`와 `DEADLINE_EXCEEDED`를 구분한다.
|
||||
- IndexedDB transaction `complete`만 `APPLIED`다.
|
||||
- Broadcast publish success의 remote effect는 `UNKNOWN`이다.
|
||||
- memory fallback과 persisted success를 구분한다.
|
||||
- optional persistence failure는 `ONLINE_ONLY`로 degrade할 수 있다.
|
||||
- scope mismatch/corruption/future version은 raw record를 반환하지 않는다.
|
||||
- diagnostics failure가 query, storage, lifecycle와 cleanup을 실패시키지 않는다.
|
||||
|
||||
safe metric:
|
||||
|
||||
- memory active/inactive/estimated-byte bucket
|
||||
- Web Storage hit/miss/degraded/quota bucket
|
||||
- persistence restore success/miss/busted/corrupt/deadline bucket
|
||||
- scope reset duration/cleanup-incomplete
|
||||
- invalidation publish/receive/drop/duplicate/gap/coalesced bucket
|
||||
- listener/channel/connection leak count
|
||||
|
||||
## 13. implementation gate
|
||||
|
||||
### Gate 0 — 상태와 문서
|
||||
|
||||
- 이 ADR과 상세 설계가 current/target 상태를 분리한다.
|
||||
- capability catalog, runbook과 test evidence의 상태가 같은 taxonomy를 사용한다.
|
||||
- 구현되지 않은 target type을 current API처럼 문서화하지 않는다.
|
||||
|
||||
### Gate 1 — strict registry와 codec
|
||||
|
||||
- query policy registry와 query key closed codec 구현
|
||||
- profile/key absolute ceiling 구현
|
||||
- Web Storage per-key cap, HIT/MISS/durability result 구현
|
||||
- current v1 key의 discard/upgrade 전략 확정
|
||||
- hostile/cyclic/oversize/property-accessor test 통과
|
||||
|
||||
이 gate는 scope lifecycle을 자동 활성화하지 않는다.
|
||||
|
||||
### Gate 2 — scope-owned QueryClient lifecycle
|
||||
|
||||
- session authority scope snapshot contract 구현
|
||||
- auth owner subscription과 local generation fence 구현
|
||||
- old query cancel/provider detach/client clear/dispose 구현
|
||||
- late query/mutation result 폐기 구현
|
||||
- account switch/logout exact partition cleanup 구현
|
||||
- two-account and lost-event tests 통과
|
||||
|
||||
account-dependent query promotion은 이 gate 전 금지한다.
|
||||
|
||||
### Gate 3 — cross-tab scope hardening
|
||||
|
||||
- composite cache epoch 구현
|
||||
- registered localStorage pulse와 storageArea 검증 구현
|
||||
- browser production coordinator E2E와 bfcache/StrictMode leak test
|
||||
- Chromium/Firefox/WebKit 동일 case evidence
|
||||
|
||||
### Gate 4 — optional query persistence reference runtime
|
||||
|
||||
- stable query record codec와 IndexedDB facade 구현
|
||||
- durable namespace ledger/CAS/commit-before-hint 구현
|
||||
- bounded restore/write/dispose 구현
|
||||
- wrong-scope/TTL/release/migration/quota/blocked test
|
||||
- production bootstrap import와 DB open이 없는 module-inventory/removal gate
|
||||
|
||||
완료 뒤에도 product selection은 `NOT_SELECTED`이고 reference 상태만
|
||||
`AVAILABLE_NOT_COMPOSED`로 바뀐다.
|
||||
|
||||
### Gate 5 — product composition
|
||||
|
||||
- measured requirement와 owner 승인
|
||||
- exact query profile/persistence allowlist/retention/budget 등록
|
||||
- account/logout/backend conflict contract 승인
|
||||
- disabled → canary → enabled traffic admission
|
||||
- rollback, cleanup-only release와 operational drill
|
||||
|
||||
### Gate 6 — SSR 또는 offline workflow
|
||||
|
||||
각 capability를 별도 선택하고 별도 gate를 통과한다.
|
||||
|
||||
- SSR: request isolation, safe dehydration, precedence와 hydration test
|
||||
- offline mutation: feature repository, server idempotency/revision/sync protocol,
|
||||
conflict/export/recovery UX
|
||||
|
||||
query persistence gate 통과가 SSR/offline workflow 통과를 의미하지 않는다.
|
||||
|
||||
## 14. test와 promotion evidence
|
||||
|
||||
### 14.1 deterministic
|
||||
|
||||
- independent QueryClient per runtime/scope
|
||||
- session/account/release transition과 late result
|
||||
- query key hostile value/ceiling/canonical equality
|
||||
- Web Storage HIT/MISS/durability, TTL, migration, quota, cleanup, partition
|
||||
- IndexedDB transaction complete, CAS, ledger epoch와 concurrent writer
|
||||
- hint commit ordering, duplicate/self/stale/gap/coalescing
|
||||
- diagnostics redaction와 dispose leak 0
|
||||
|
||||
### 14.2 real browser
|
||||
|
||||
Chromium, Firefox와 WebKit에서 같은 case set을 실행한다.
|
||||
|
||||
- native BroadcastChannel two-page delivery
|
||||
- localStorage fallback과 exact storageArea
|
||||
- account switch 중 in-flight query
|
||||
- event loss 뒤 local auth lifecycle
|
||||
- IndexedDB concurrent writer/blocked/versionchange/restore deadline
|
||||
- bfcache/pagehide/StrictMode listener·connection cleanup
|
||||
- sessionStorage tab/opener semantics
|
||||
- N-1 release reader와 incompatible buster
|
||||
|
||||
현재 native transport spec이 존재해도 production QueryClient lifecycle 전체와
|
||||
세 engine promotion artifact가 없으면 Gate 3 완료로 보지 않는다.
|
||||
|
||||
### 14.3 promotion artifact
|
||||
|
||||
artifact는 engine/browser version/OS image/build/release ID/contract suite
|
||||
version/pass/fail/skip/실행 시각을 보존한다. fake/jsdom 통과를 native provider
|
||||
통과로 보고하지 않는다. WebKit system dependency 부족은 capability skip이 아니라
|
||||
promotion evidence 미충족이다.
|
||||
|
||||
## 15. rollout과 rollback
|
||||
|
||||
### 15.1 rollout
|
||||
|
||||
1. strict registry/codec을 기존 behavior 뒤 shadow validation으로 배포한다.
|
||||
2. scope lifecycle을 single-account environment에서 먼저 관측한다.
|
||||
3. account switch/logout fault test 뒤 account-dependent query를 허용한다.
|
||||
4. optional persistence는 source와 test만 추가하고 production composition은
|
||||
계속 끈다.
|
||||
5. product selection 뒤 read-only restore/shadow write를 먼저 검증한다.
|
||||
6. 작은 cohort에서 write/restore/quota/blocked/rollback drill을 수행한다.
|
||||
7. error budget과 N-1 compatibility를 확인한 뒤 확대한다.
|
||||
|
||||
### 15.2 kill switch
|
||||
|
||||
서로 독립적으로 끌 수 있어야 한다.
|
||||
|
||||
- persistence restore off
|
||||
- persistence write off
|
||||
- cross-tab publish off
|
||||
- cross-tab receive off
|
||||
- offline mutation admission off
|
||||
|
||||
memory QueryClient와 정상 server fetch는 유지한다. account/session local lifecycle은
|
||||
security boundary이므로 best-effort invalidation kill switch와 함께 끄지 않는다.
|
||||
|
||||
### 15.3 rollback
|
||||
|
||||
1. 신규 persistence write/restore admission을 중지한다.
|
||||
2. writer/timer/channel/listener/DB connection을 dispose한다.
|
||||
3. scope fence와 memory QueryClient clear는 유지한다.
|
||||
4. rollback bundle이 future record/schema를 miss/online-only로 처리하게 한다.
|
||||
5. cleanup-only compatible release에서 exact owned partition을 bounded purge한다.
|
||||
6. retention/rollback window 뒤 registry/adapter/dependency를 제거한다.
|
||||
|
||||
schema version을 내리거나 origin 전체 `localStorage.clear()`/
|
||||
`indexedDB.deleteDatabase()`를 자동 실행하지 않는다. unsynced user-authored data는
|
||||
export/sync 확인 없이 query-cache cleanup으로 삭제하지 않는다.
|
||||
|
||||
## 16. 완료 기준
|
||||
|
||||
- [ ] session/account/release scope가 QueryClient, key와 event에 binding된다.
|
||||
- [ ] scope transition은 admission fence, cancel, detach, clear, dispose와 새
|
||||
QueryClient 생성으로 완료된다.
|
||||
- [ ] old generation query/mutation result가 새 scope UI/cache를 변경하지 않는다.
|
||||
- [ ] strict query scope/persistence registry와 closed key codec이 모든 absolute
|
||||
ceiling을 강제하고 VD-25 profile과 exact join된다.
|
||||
- [ ] Web Storage가 per-key cap, HIT/MISS, durability, partition, logout,
|
||||
migration과 bounded sweep을 구현한다.
|
||||
- [ ] localStorage invalidation fallback이 registered key와 exact storageArea를
|
||||
검증한다.
|
||||
- [ ] Chromium/Firefox/WebKit production coordinator/account lifecycle evidence가
|
||||
있다.
|
||||
- [ ] optional query persistence reference facade는 stable record와 durable
|
||||
namespace ledger를 사용하고 production 미선택 시 zero side effect다.
|
||||
- [ ] persisted mutation/error/native object/credential이 없음을 negative test가
|
||||
증명한다.
|
||||
- [ ] SSR을 선택한 경우 request isolation과 hydration precedence가 증명된다.
|
||||
- [ ] offline mutation을 선택한 경우 backend idempotency/revision/sync와
|
||||
conflict/recovery UX가 별도 계약으로 증명된다.
|
||||
- [ ] rollout/kill-switch/rollback/removal artifact가 보존된다.
|
||||
|
||||
현재 이 체크리스트는 완료 선언이 아니라 implementation gate다. 각 행을 실제
|
||||
source, deterministic test, native evidence와 composition inventory로 증명하기
|
||||
전에는 완료로 바꾸지 않는다.
|
||||
|
||||
## 17. 선택하지 않은 대안
|
||||
|
||||
- module singleton QueryClient
|
||||
- account switch에서 query key prefix만 교체
|
||||
- BroadcastChannel logout event를 lifecycle authority로 사용
|
||||
- arbitrary query key와 raw TanStack cache snapshot persistence
|
||||
- localStorage full-cache snapshot 또는 monotonic counter
|
||||
- browser persistence를 offline command queue로 사용
|
||||
- same-origin client encryption을 credential authorization boundary로 사용
|
||||
- origin 전체 storage clear를 quota/logout/rollback 복구로 사용
|
||||
- fake browser test만으로 production promotion
|
||||
|
||||
## 18. 결과
|
||||
|
||||
장점:
|
||||
|
||||
- account/session boundary와 best-effort invalidation의 권한 차이가 명확하다.
|
||||
- persistence를 선택하지 않은 제품에는 DB open/listener/bundle side effect가 없다.
|
||||
- query key, Web Storage와 IndexedDB의 migration/retention을 독립적으로 검증한다.
|
||||
- old tab/snapshot이 invalidated data를 되살리는 경로를 durable epoch로 닫는다.
|
||||
- SSR, offline workflow와 query warm-start를 서로 독립 선택할 수 있다.
|
||||
|
||||
비용:
|
||||
|
||||
- scope authority와 QueryClient remount lifecycle이 필요하다.
|
||||
- registry/codec/historical fixture와 multi-page browser test가 늘어난다.
|
||||
- optional persistence를 설치하는 제품은 IndexedDB migration, quota와 cleanup
|
||||
runbook을 운영해야 한다.
|
||||
|
||||
이 비용은 cache hit, durable restore, cross-tab hint와 server truth를 하나의
|
||||
“cached” 상태로 잘못 합치지 않기 위한 의도적인 비용이다.
|
||||
Reference in New Issue
Block a user