881 lines
37 KiB
Markdown
881 lines
37 KiB
Markdown
# 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” 상태로 잘못 합치지 않기 위한 의도적인 비용이다.
|