From 99b81cb59ca7c9f36ba286eb139f15424007b9dd Mon Sep 17 00:00:00 2001 From: Gerard Date: Wed, 13 May 2026 16:06:40 +0200 Subject: [PATCH 1/5] feat(cached-adapter-store): scaffold fs-cached-adapter-store package (Phase 2 of cached-store protocol campaign) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Higher-order factory wrapping @script-development/fs-adapter-store with a hash-bumping cache-check that suppresses redundant retrieveAll GETs at source. Architecture locks (Commander 2026-05-13): - Higher-order factory composition (Design A); internally calls createAdapterStoreModule(config); adapter-store is unmodified. - Minimal options surface: CachedAdapterStoreOptions = {cacheKey: string}. - Strict v1. version prefix; non-v1 values treated as no-signal. - Wrapper response middleware body wrapped in try/catch (fs-http does NOT isolate middleware throws per Surveyor 2026-05-13). - localHash persisted to storageService ONLY AFTER inner.retrieveAll succeeds — never on response middleware receipt. - retrieveById is passthrough (no caching in v1). - In-flight deduplication on retrieveAll. - Idempotent response middleware registration per HttpService instance. Tests: 69 passing across 3 spec files. 100% line/branch/function coverage. 97.10% mutation score (2 equivalent mutants on try/catch returns; both fall through to subsequent guards that produce the same null outcome). Peer deps: @script-development/fs-adapter-store ^0.1.0, fs-http ^0.1.0||^0.2.0||^0.3.0, fs-storage ^0.1.0, vue ^3.5.33. Cascade-tax registration: CLAUDE.md updated to list fs-cached-adapter-store as a cascade peer of fs-http, fs-adapter-store, and fs-storage. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 37 +- package-lock.json | 26 + packages/cached-adapter-store/README.md | 90 ++ packages/cached-adapter-store/package.json | 60 ++ .../src/cached-adapter-store.ts | 231 ++++++ packages/cached-adapter-store/src/index.ts | 2 + packages/cached-adapter-store/src/types.ts | 18 + .../cached-adapter-store/stryker.config.mjs | 11 + .../tests/cached-adapter-store.spec.ts | 780 ++++++++++++++++++ .../cached-adapter-store/tests/parser.spec.ts | 212 +++++ .../cached-adapter-store/tests/types.spec.ts | 47 ++ packages/cached-adapter-store/tsconfig.json | 1 + .../cached-adapter-store/tsdown.config.ts | 3 + .../cached-adapter-store/vitest.config.ts | 12 + 14 files changed, 1515 insertions(+), 15 deletions(-) create mode 100644 packages/cached-adapter-store/README.md create mode 100644 packages/cached-adapter-store/package.json create mode 100644 packages/cached-adapter-store/src/cached-adapter-store.ts create mode 100644 packages/cached-adapter-store/src/index.ts create mode 100644 packages/cached-adapter-store/src/types.ts create mode 100644 packages/cached-adapter-store/stryker.config.mjs create mode 100644 packages/cached-adapter-store/tests/cached-adapter-store.spec.ts create mode 100644 packages/cached-adapter-store/tests/parser.spec.ts create mode 100644 packages/cached-adapter-store/tests/types.spec.ts create mode 100644 packages/cached-adapter-store/tsconfig.json create mode 100644 packages/cached-adapter-store/tsdown.config.ts create mode 100644 packages/cached-adapter-store/vitest.config.ts diff --git a/CLAUDE.md b/CLAUDE.md index 3132c18..40105a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,20 +29,21 @@ Shared frontend service packages monorepo under the `@script-development` npm sc Consumer territories must apply per-call timeouts at instantiation OR rely on the 30000 ms default. See `docs/packages/http.md#timeout` for usage. -## Packages (10) - -| Package | Vue | Description | -| ---------------- | --- | ---------------------------------------------------------------------------------------------------------------- | -| fs-http | No | HTTP service factory with middleware architecture | -| fs-storage | No | localStorage service factory with prefix namespacing | -| fs-helpers | No | Tree-shakeable utilities: deep copy, type guards, case conversion | -| fs-theme | Yes | Reactive dark/light mode with storage persistence | -| fs-loading | Yes | Loading state service with HTTP middleware | -| fs-adapter-store | Yes | Reactive adapter-store pattern with CRUD resource adapters | -| fs-toast | Yes | Component-agnostic toast queue (FIFO) | -| fs-dialog | Yes | Component-agnostic dialog stack (LIFO) with error middleware | -| fs-translation | Yes | Type-safe reactive i18n with dot-notation keys | -| fs-router | Yes | Type-safe router service factory with CRUD navigation, middleware pipeline, and custom components for Vue Router | +## Packages (11) + +| Package | Vue | Description | +| ----------------------- | --- | ---------------------------------------------------------------------------------------------------------------- | +| fs-http | No | HTTP service factory with middleware architecture | +| fs-storage | No | localStorage service factory with prefix namespacing | +| fs-helpers | No | Tree-shakeable utilities: deep copy, type guards, case conversion | +| fs-theme | Yes | Reactive dark/light mode with storage persistence | +| fs-loading | Yes | Loading state service with HTTP middleware | +| fs-adapter-store | Yes | Reactive adapter-store pattern with CRUD resource adapters | +| fs-cached-adapter-store | Yes | Higher-order factory wrapping fs-adapter-store with hash-bumping cache-check that suppresses redundant GETs | +| fs-toast | Yes | Component-agnostic toast queue (FIFO) | +| fs-dialog | Yes | Component-agnostic dialog stack (LIFO) with error middleware | +| fs-translation | Yes | Type-safe reactive i18n with dot-notation keys | +| fs-router | Yes | Type-safe router service factory with CRUD navigation, middleware pipeline, and custom components for Vue Router | ## Conventions @@ -61,7 +62,7 @@ Two packages share an internal direct-dep on `string-ts`: `fs-helpers` (`deepCam ## Versioning Discipline (Pre-1.0) -While packages remain pre-1.0, npm caret semantics treat every minor bump as breaking (`^0.1.0` matches only `0.1.x`). Each `fs-http` minor bump cascades into peer-range widenings on `fs-loading` and `fs-adapter-store`. The cascade is mechanical, not avoidable on npm. +While packages remain pre-1.0, npm caret semantics treat every minor bump as breaking (`^0.1.0` matches only `0.1.x`). Each `fs-http` minor bump cascades into peer-range widenings on `fs-loading`, `fs-adapter-store`, and `fs-cached-adapter-store`. The cascade is mechanical, not avoidable on npm. Per-bump checklist: @@ -71,6 +72,12 @@ Per-bump checklist: 4. Regenerate `package-lock.json` and verify every `node_modules/@script-development/*` resolves to the workspace (`"resolved": "packages/*"`, `"link": true`). No nested registry copies anywhere in the lock. 5. CI passing `npm ci` is necessary but not sufficient — inspect the lock for nested copies after every cross-minor bump. +Cascade peers as of 2026-05-13: + +- An `fs-http` minor bump cascades to: `fs-loading`, `fs-adapter-store`, `fs-cached-adapter-store`. +- An `fs-adapter-store` minor bump cascades to: `fs-cached-adapter-store`. +- An `fs-storage` minor bump cascades to: `fs-adapter-store`, `fs-cached-adapter-store`. + This tax disappears once packages reach 1.0. The `workspace:*` protocol is **not** an option on npm (npm 11+ rejects it as `EUNSUPPORTEDPROTOCOL`); it is a pnpm/yarn feature. ## Commands diff --git a/package-lock.json b/package-lock.json index 2382d93..55c6e08 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3372,6 +3372,10 @@ "resolved": "packages/adapter-store", "link": true }, + "node_modules/@script-development/fs-cached-adapter-store": { + "resolved": "packages/cached-adapter-store", + "link": true + }, "node_modules/@script-development/fs-dialog": { "resolved": "packages/dialog", "link": true @@ -10228,6 +10232,28 @@ "vue": "^3.5.33" } }, + "packages/cached-adapter-store": { + "name": "@script-development/fs-cached-adapter-store", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@script-development/fs-adapter-store": "^0.1.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0", + "@script-development/fs-storage": "^0.1.0", + "axios": "^1.16.0", + "happy-dom": "^20.9.0", + "vue": "^3.5.33" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@script-development/fs-adapter-store": "^0.1.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0", + "@script-development/fs-storage": "^0.1.0", + "vue": "^3.5.33" + } + }, "packages/dialog": { "name": "@script-development/fs-dialog", "version": "0.2.0", diff --git a/packages/cached-adapter-store/README.md b/packages/cached-adapter-store/README.md new file mode 100644 index 0000000..0246187 --- /dev/null +++ b/packages/cached-adapter-store/README.md @@ -0,0 +1,90 @@ +# @script-development/fs-cached-adapter-store + +Higher-order factory wrapping `@script-development/fs-adapter-store` with a hash-bumping cache-check that suppresses redundant `retrieveAll` GETs at source. + +The wrapper is a sibling to `fs-adapter-store`; it does not modify it. Adapter-store consumers who do not opt in are unaffected. + +## Install + +```bash +npm install @script-development/fs-cached-adapter-store +``` + +Peer dependencies: `@script-development/fs-adapter-store`, `@script-development/fs-http`, `@script-development/fs-storage`, `vue`. + +## Usage + +```ts +import {createCachedAdapterStoreModule} from '@script-development/fs-cached-adapter-store'; + +const lanesStore = createCachedAdapterStoreModule( + { + domainName: `projects/${projectId}/lanes`, + adapter: makeLaneAdapterForProject(projectId), + httpService, + storageService, + loadingService, + broadcast: makeLaneBroadcastForProject(projectId), + }, + {cacheKey: `projects/${projectId}/lanes`}, +); +``` + +The returned module has the **same shape** as `createAdapterStoreModule`'s `StoreModuleForAdapter` — a drop-in replacement at every call site. + +## Options + +```ts +type CachedAdapterStoreOptions = {cacheKey: string}; +``` + +Intentionally minimal for v1. There is no `staleAfterMs`, no `onMissingServerHash`, no `hashExtractor`, no `hashStorageKey`, no `legacyHeaderName`. If you find yourself wanting one of these, the protocol probably isn't right for your situation — open a discussion before adding a knob. + +## Protocol + +The wrapper listens for an `x-fs-cache-hashes` HTTP response header. The expected value shape is: + +``` +x-fs-cache-hashes: v1. +``` + +where the JSON is a flat `{cacheKey: hashString}` map. The wrapper: + +1. Parses the header on every response that carries it. +2. Updates an in-memory `currentServerHash` for each `cacheKey` matching a registered wrapper instance. +3. At `retrieveAll()` time, compares the **local hash** (hydrated from `storageService` at construction) against `currentServerHash`. If both are non-null and equal, the inner `retrieveAll()` is skipped entirely. +4. After every successful inner `retrieveAll()`, the current server hash is snapshotted into both the in-memory local hash and `storageService` — never before. + +The strict `v1.` version prefix is non-negotiable. A header value not starting with `v1.` is treated as no-signal (fallthrough to fetch). This is intentional: every response stamped with this header is contractually opting into the v1 wire format. + +The wrapper does NOT wrap `retrieveById` in v1 — that method is passed through unchanged. The 429 incident that motivated this package is driven by `retrieveAll`; per-id caching is future work. + +## Operational notes + +### 1. Tenancy is the consumer's responsibility + +The wrapper does not model tenants. Tenant-scoping of the persisted hash is achieved entirely through the `storageService` prefix the consumer territory supplies. For Kendo, this means the tenant-scoped `storageService` factory naturally prefixes the hash storage key. For Emmie's DB-per-tenant subdomain model, each subdomain is its own browser origin and localStorage is naturally origin-scoped. Either way: the wrapper inherits whatever isolation the consumer's `storageService` provides. + +### 2. Cancellation is fs-http's responsibility + +The wrapper does not own `AbortSignal` threading. If `fs-http` exposes a `signal` surface and `fs-adapter-store` passes it through to `retrieveAll`, the wrapper inherits cancellation for free. As of v0.1.0, fs-http does not document `signal` on its request methods; the wrapper acknowledges that a rapid re-mount may complete a now-irrelevant fetch. This is no worse than the unwrapped adapter-store, and the in-flight deduplication mitigates the worst case (two overlapping fetches). The fs-http gap is tracked at war-room enforcement queue #62. + +### 3. Backend bump semantics live in Actions + +Per war-room ADR-0011 (Action Class Architecture, cross-project), the backend must bump the hash inside the same database transaction as the write that motivates it. Observer-driven bumps fired after the writing transaction commits are forbidden by this protocol — they introduce a race window where a client refetches and sees pre-write state. + +## Wrapper invariants + +The wrapper is designed against `fs-http`'s response-middleware contract as documented in the 2026-05-13 Surveyor middleware-invariants report: + +- **Throw isolation.** fs-http does not isolate middleware throws — a synchronous throw inside a middleware aborts response delivery to the caller. The wrapper's response middleware body is wrapped in `try/catch` so a malformed header (un-decodable URI, malformed JSON) cannot poison the caller's request. +- **In-flight deduplication.** Two `retrieveAll()` calls in rapid succession invoke the inner `retrieveAll` exactly once and resolve from the same underlying promise. +- **Idempotent middleware registration.** Multiple wrapper instances sharing one `httpService` register exactly one response middleware between them. Header parsing happens once per response, regardless of how many wrappers are listening. + +## Compatibility + +Pre-1.0; peer ranges are explicit. See the territory's "Versioning Discipline (Pre-1.0)" section for the caret-cascade discipline. + +## License + +MIT diff --git a/packages/cached-adapter-store/package.json b/packages/cached-adapter-store/package.json new file mode 100644 index 0000000..6e0e260 --- /dev/null +++ b/packages/cached-adapter-store/package.json @@ -0,0 +1,60 @@ +{ + "name": "@script-development/fs-cached-adapter-store", + "version": "0.1.0", + "description": "Higher-order factory wrapping @script-development/fs-adapter-store with hash-bumping cache-check that suppresses redundant retrieveAll GETs at source", + "homepage": "https://packages.script.nl/packages/cached-adapter-store", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/script-development/fs-packages.git", + "directory": "packages/cached-adapter-store" + }, + "files": [ + "dist" + ], + "type": "module", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + } + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "build": "tsdown", + "lint:pkg": "publint && attw --pack", + "test": "vitest run", + "test:coverage": "vitest run --coverage", + "test:mutation": "stryker run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "@script-development/fs-adapter-store": "^0.1.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0", + "@script-development/fs-storage": "^0.1.0", + "axios": "^1.16.0", + "happy-dom": "^20.9.0", + "vue": "^3.5.33" + }, + "peerDependencies": { + "@script-development/fs-adapter-store": "^0.1.0", + "@script-development/fs-http": "^0.1.0 || ^0.2.0 || ^0.3.0", + "@script-development/fs-storage": "^0.1.0", + "vue": "^3.5.33" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/packages/cached-adapter-store/src/cached-adapter-store.ts b/packages/cached-adapter-store/src/cached-adapter-store.ts new file mode 100644 index 0000000..1b5d177 --- /dev/null +++ b/packages/cached-adapter-store/src/cached-adapter-store.ts @@ -0,0 +1,231 @@ +import type { + Adapted, + AdapterStoreConfig, + Item, + NewAdapted, + StoreModuleForAdapter, +} from '@script-development/fs-adapter-store'; +import type {HttpService, ResponseMiddlewareFunc} from '@script-development/fs-http'; +import type {Ref} from 'vue'; + +import {createAdapterStoreModule} from '@script-development/fs-adapter-store'; +import {ref} from 'vue'; + +import type {CachedAdapterStoreOptions} from './types'; + +/** + * Convenience alias for the response shape passed to a response middleware. + * Inferred via `Parameters[0]` so we never import + * `AxiosResponse` directly from `axios` — axios's CJS d.ts ships its types + * under a nested namespace (`axios.AxiosResponse`), and a direct named + * import fails rolldown's d.cts emission. Routing through fs-http's + * already-published `ResponseMiddlewareFunc` sidesteps the issue. + */ +type Response = Parameters[0]; + +/** + * Wire-protocol response header. The backend stamps this on any response that + * carries a freshness signal. Value shape: `v1.${urlencoded JSON}` where the + * JSON is a flat `{cacheKey: hashString}` map. Anything not starting with + * `v1.` is treated as no-signal (strict version-prefix policy — see + * Architecture Lock #5 in the Engineer deployment order, 2026-05-13). + */ +const HEADER_NAME = 'x-fs-cache-hashes'; +const VERSION_PREFIX = 'v1.'; + +/** + * Module-local debug log prefix. Consumer territories can grep for this in + * their browser console to observe wrapper-side decisions (no-signal + * fallthroughs, malformed-header parses, etc.). We deliberately do not call + * `console.error` — middleware-body errors are *swallowed* on purpose, and an + * `error`-level log would imply the request failed when it did not. + */ +const LOG_PREFIX = '[fs-cached-adapter-store]'; + +/** + * Shared registry across all wrapper instances. Tracks which + * `HttpService` instances have already had a response middleware registered + * for header parsing. Without this, two wrapper-factory calls sharing one + * `httpService` would parse each response twice. + * + * The registry maps `HttpService` -> a map keyed by `cacheKey` -> setter + * for the corresponding `currentServerHash` ref. When a new wrapper is + * constructed: + * - If its `httpService` is already registered, append the new cacheKey + * setter to the existing map. + * - Otherwise, install a single response middleware on the `httpService` + * that iterates the map and routes hash updates to the right setter. + */ +type CacheKeySetter = (hash: string) => void; +const httpServiceRegistry = new WeakMap>(); + +/** + * Synchronous, exception-safe parse of the `x-fs-cache-hashes` header. + * Returns the flat `{cacheKey: hash}` map on success, or `null` on any + * failure mode (missing header, missing prefix, decode error, JSON parse + * error, structurally wrong shape). The function never throws. + * + * Per Architecture Lock #10 (Engineer deployment order 2026-05-13) and the + * Surveyor middleware-invariants verdict (2026-05-13 §Q7/Q8/Q9), fs-http + * does NOT isolate middleware throws — a throw inside our middleware aborts + * the caller's entire request. The wrapper owns that isolation discipline, + * so this function is the throw boundary. + * + * Exported for direct unit testing. The parser's per-branch behavior is + * difficult to discriminate from the outside (the wrapper's outer try/catch + * swallows any throw the parser would emit), so mutation tests target this + * function directly via its exported handle. Not part of the package's + * public API surface — kept internal to the file via `index.ts`'s re-export + * boundary. + */ +export const parseCacheHashHeader = (response: Response): Record | null => { + const headers = response.headers as Record | undefined; + const raw = headers?.[HEADER_NAME]; + if (typeof raw !== 'string') return null; + if (!raw.startsWith(VERSION_PREFIX)) return null; + const payload = raw.slice(VERSION_PREFIX.length); + let decoded: string; + try { + decoded = decodeURIComponent(payload); + } catch { + return null; + } + let parsed: unknown; + try { + parsed = JSON.parse(decoded); + } catch { + return null; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return null; + const map: Record = {}; + for (const [key, value] of Object.entries(parsed as Record)) { + if (typeof value !== 'string') return null; + map[key] = value; + } + return map; +}; + +/** + * Wraps {@link createAdapterStoreModule} with a hash-bumping cache-check that + * suppresses redundant `retrieveAll` GETs at the source. + * + * On every response carrying `x-fs-cache-hashes`, the wrapper updates its + * in-memory `currentServerHash`. At `retrieveAll()` time, it compares + * `localHash` (hydrated from `storageService` at construction) against + * `currentServerHash`; if both are non-null and equal, the inner + * `retrieveAll()` is skipped entirely. After every successful inner + * `retrieveAll()`, the current server hash is snapshotted into both the + * in-memory `localHash` and `storageService` — never before, so a failed + * round-trip cannot leave a persisted hash that doesn't match `state`. + * + * All wrapper concerns (in-flight deduplication, idempotent middleware + * registration across stores sharing one `httpService`, exception-safe + * header parsing) are baked into the factory. Adapter-store is unmodified. + * + * @see Architecture Locks 1-11 in + * `orders/fs-packages/fs-cached-adapter-store-scaffold-engineer-deployment.md` + * for the v1 scope-shaping decisions Commander made on 2026-05-13. + */ +export const createCachedAdapterStoreModule = < + T extends Item, + E extends Adapted = Adapted, + N extends NewAdapted = NewAdapted, +>( + config: AdapterStoreConfig, + options: CachedAdapterStoreOptions, +): StoreModuleForAdapter => { + const {cacheKey} = options; + const {httpService, storageService} = config; + const hashStorageKey = `${cacheKey}.cache-hash`; + + const inner = createAdapterStoreModule(config); + + const initialPersistedHash = storageService.get(hashStorageKey, null); + const localHash: Ref = ref(initialPersistedHash); + const currentServerHash: Ref = ref(null); + + const setCurrentHash: CacheKeySetter = (hash: string) => { + currentServerHash.value = hash; + }; + + // Idempotent middleware registration. The registry is keyed by + // HttpService instance, so multiple wrapper-factory calls sharing one + // httpService produce ONE response middleware that fans out to all + // registered cacheKey setters — not N middlewares parsing the same + // header N times. + // + // We treat httpService as Pick<...>-typed in config, but the registry + // needs a stable WeakMap key. Cast to HttpService is safe: the wrapper + // requires registerResponseMiddleware on the same instance, and the + // shape Pick captures is a subset of the live object. + const httpServiceAsRegistryKey = httpService as HttpService; + const existingSetters = httpServiceRegistry.get(httpServiceAsRegistryKey); + if (existingSetters) { + existingSetters.set(cacheKey, setCurrentHash); + } else { + const setters = new Map(); + setters.set(cacheKey, setCurrentHash); + httpServiceRegistry.set(httpServiceAsRegistryKey, setters); + httpServiceAsRegistryKey.registerResponseMiddleware((response: Response) => { + try { + const map = parseCacheHashHeader(response); + if (map === null) return; + for (const [key, hash] of Object.entries(map)) { + const setter = setters.get(key); + if (setter) setter(hash); + } + } catch (error) { + // Defense-in-depth. parseCacheHashHeader is exception-safe by + // construction, but a future change to the parser or a + // pathological Response shape (e.g., a getter that throws on + // `response.headers`) must NOT abort the caller's request. + // fs-http does not isolate middleware throws — this catch is + // the wrapper's contract with its consumers. + // eslint-disable-next-line no-console + console.debug(`${LOG_PREFIX} response middleware caught error`, error); + } + }); + } + + let inflight: Promise | null = null; + + const retrieveAll = async (): Promise => { + if (inflight) return inflight; + // Skip only when both are populated and equal. The `localHash !== null` + // clause is load-bearing: without it, a fresh wrapper with no + // persisted hash AND no server signal would have + // `localHash === currentServerHash === null` → skip → empty state + // forever. The `=== currentServerHash.value` comparison then handles + // the populated-equality case AND implicitly rejects + // `currentServerHash === null` (since localHash is non-null). + if (localHash.value !== null && localHash.value === currentServerHash.value) { + return; + } + inflight = (async () => { + try { + await inner.retrieveAll(); + // Persist after success only. The local hash must always + // match the data currently in state — persisting on response + // middleware receipt would race a cold-start re-mount that + // skipped the fetch and rendered empty state. + const serverHashSnapshot = currentServerHash.value; + if (serverHashSnapshot !== null) { + localHash.value = serverHashSnapshot; + storageService.put(hashStorageKey, serverHashSnapshot); + } + } finally { + inflight = null; + } + })(); + return inflight; + }; + + return { + getAll: inner.getAll, + getById: inner.getById, + getOrFailById: inner.getOrFailById, + generateNew: inner.generateNew, + retrieveById: inner.retrieveById, + retrieveAll, + }; +}; diff --git a/packages/cached-adapter-store/src/index.ts b/packages/cached-adapter-store/src/index.ts new file mode 100644 index 0000000..4cb78cf --- /dev/null +++ b/packages/cached-adapter-store/src/index.ts @@ -0,0 +1,2 @@ +export {createCachedAdapterStoreModule} from './cached-adapter-store'; +export type {CachedAdapterStoreOptions} from './types'; diff --git a/packages/cached-adapter-store/src/types.ts b/packages/cached-adapter-store/src/types.ts new file mode 100644 index 0000000..9ecb724 --- /dev/null +++ b/packages/cached-adapter-store/src/types.ts @@ -0,0 +1,18 @@ +/** + * Options for {@link createCachedAdapterStoreModule}. + * + * Intentionally minimal in v1: only `cacheKey` is exposed. No `staleAfterMs`, + * no `onMissingServerHash`, no `hashExtractor`, no `hashStorageKey`, no + * `legacyHeaderName`. The hash-or-fetch protocol is the contract; opting out + * of any leg of it requires re-thinking the protocol itself, which is a + * separate concern. + */ +export type CachedAdapterStoreOptions = { + /** + * The cache key used both as the lookup key inside the `x-fs-cache-hashes` + * response header AND as the localStorage key for the persisted hash + * (`${cacheKey}.cache-hash`). The backend is expected to stamp this exact + * key in the header value whenever the underlying data changes. + */ + cacheKey: string; +}; diff --git a/packages/cached-adapter-store/stryker.config.mjs b/packages/cached-adapter-store/stryker.config.mjs new file mode 100644 index 0000000..cea608c --- /dev/null +++ b/packages/cached-adapter-store/stryker.config.mjs @@ -0,0 +1,11 @@ +/** @type {import('@stryker-mutator/api/core').PartialStrykerOptions} */ +export default { + testRunner: 'vitest', + vitest: {configFile: 'vitest.config.ts'}, + mutate: ['src/**/*.ts', '!src/**/types.ts'], + thresholds: {high: 95, low: 90, break: 90}, + reporters: ['clear-text', 'progress'], + incremental: true, + incrementalFile: '.stryker-incremental.json', + cleanTempDir: 'always', +}; diff --git a/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts b/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts new file mode 100644 index 0000000..cba0daa --- /dev/null +++ b/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts @@ -0,0 +1,780 @@ +// @vitest-environment happy-dom +import type { + Adapted, + Adapter, + AdapterStoreConfig, + AdapterStoreModule, + Item, + NewAdapted, +} from '@script-development/fs-adapter-store'; +import type { + HttpService, + RequestMiddlewareFunc, + ResponseErrorMiddlewareFunc, + ResponseMiddlewareFunc, + UnregisterMiddleware, +} from '@script-development/fs-http'; +import type {LoadingService} from '@script-development/fs-loading'; +import type {StorageService} from '@script-development/fs-storage'; +import type {AxiosResponse} from 'axios'; +import type {Ref} from 'vue'; + +import {beforeEach, describe, expect, it, vi} from 'vitest'; +import {ref} from 'vue'; + +import {createCachedAdapterStoreModule} from '../src/cached-adapter-store'; + +type TestStorageService = Pick; +type TestLoadingService = Pick; + +interface TestItem extends Item { + id: number; + name: string; +} + +type TestNew = Omit; +type TestAdapted = Adapted & {tag: () => string}; +type TestNewAdapted = NewAdapted & {tag: () => string}; + +function makeTestAdapter(storeModule: AdapterStoreModule): TestNewAdapted; +function makeTestAdapter(storeModule: AdapterStoreModule, resourceGetter: () => TestItem): TestAdapted; +function makeTestAdapter( + _storeModule: AdapterStoreModule, + resourceGetter?: () => TestItem, +): TestAdapted | TestNewAdapted { + if (resourceGetter) { + const adapted = {} as TestAdapted; + const source = resourceGetter(); + for (const key of Object.keys(source)) { + Object.defineProperty(adapted, key, { + get: () => resourceGetter()[key as keyof TestItem], + enumerable: true, + configurable: false, + }); + } + Object.defineProperty(adapted, 'mutable', { + value: ref({...resourceGetter()}) as Ref, + enumerable: true, + configurable: false, + writable: false, + }); + Object.defineProperty(adapted, 'reset', {value: vi.fn(), enumerable: true}); + Object.defineProperty(adapted, 'update', {value: vi.fn(), enumerable: true}); + Object.defineProperty(adapted, 'patch', {value: vi.fn(), enumerable: true}); + Object.defineProperty(adapted, 'delete', {value: vi.fn(), enumerable: true}); + Object.defineProperty(adapted, 'tag', {value: () => `adapted-${resourceGetter().id}`, enumerable: true}); + return adapted; + } + return { + name: '', + mutable: ref({name: ''}) as Ref, + reset: vi.fn(), + create: vi.fn(), + tag: () => 'new-adapted', + } as unknown as TestNewAdapted; +} + +type FakeHttpService = HttpService & { + deliver: (response: AxiosResponse) => void; + getResponseMiddlewares: () => ResponseMiddlewareFunc[]; +}; + +const makeFakeHttpService = (): FakeHttpService => { + const responseMiddlewares: ResponseMiddlewareFunc[] = []; + const requestMiddlewares: RequestMiddlewareFunc[] = []; + const responseErrorMiddlewares: ResponseErrorMiddlewareFunc[] = []; + const unregisterFrom = (array: T[], item: T): UnregisterMiddleware => { + return () => { + const index = array.indexOf(item); + if (index > -1) array.splice(index, 1); + }; + }; + const service: FakeHttpService = { + getRequest: vi.fn(), + postRequest: vi.fn(), + putRequest: vi.fn(), + patchRequest: vi.fn(), + deleteRequest: vi.fn(), + downloadRequest: vi.fn(), + previewRequest: vi.fn(), + streamRequest: vi.fn(), + registerRequestMiddleware: (fn: RequestMiddlewareFunc) => { + requestMiddlewares.push(fn); + return unregisterFrom(requestMiddlewares, fn); + }, + registerResponseMiddleware: (fn: ResponseMiddlewareFunc) => { + responseMiddlewares.push(fn); + return unregisterFrom(responseMiddlewares, fn); + }, + registerResponseErrorMiddleware: (fn: ResponseErrorMiddlewareFunc) => { + responseErrorMiddlewares.push(fn); + return unregisterFrom(responseErrorMiddlewares, fn); + }, + deliver: (response: AxiosResponse) => { + for (const middleware of responseMiddlewares) middleware(response); + }, + getResponseMiddlewares: () => responseMiddlewares, + }; + return service; +}; + +const makeResponse = (headers: Record): AxiosResponse => + ({data: null, status: 200, statusText: 'OK', headers, config: {} as unknown}) as unknown as AxiosResponse; + +const encodeHashHeader = (map: Record): string => `v1.${encodeURIComponent(JSON.stringify(map))}`; + +const makeConfig = ( + httpService: FakeHttpService, + storageService: TestStorageService, + loadingService: TestLoadingService, + domainName = 'lanes', +): AdapterStoreConfig => ({ + domainName, + adapter: makeTestAdapter as Adapter, + httpService, + storageService, + loadingService, +}); + +const makeStorageService = (initial: Record = {}): TestStorageService => { + const store: Record = {...initial}; + return { + get: vi.fn((key: string, defaultValue?: T): T | undefined => { + if (key in store) return store[key] as T; + return defaultValue; + }) as TestStorageService['get'], + put: vi.fn((key: string, value: unknown) => { + store[key] = value; + }), + }; +}; + +/** + * Reusable test rig that proves "would skip if currentServerHash === 'X' OR would + * fetch otherwise". Tests that need a mutation-discriminating no-signal assertion + * persist 'X' to storage AND seed the malformed response with a payload that + * would parse to {lanes: 'X'} ONLY if the parser is broken. After delivery, we + * call retrieveAll and assert whether httpService.getRequest fired. + * + * If the parser correctly rejects → currentServerHash null → fetch (1 call). + * If the parser incorrectly accepts → currentServerHash 'X' === localHash 'X' + * → skip (0 calls). The assertion `getRequest.toHaveBeenCalledTimes(1)` + * discriminates between these two outcomes. + */ +const setupMalformDiscriminator = (cacheKey = 'lanes') => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({[`${cacheKey}.cache-hash`]: 'X'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService, cacheKey), + {cacheKey}, + ); + return {httpService, storageService, store}; +}; + +describe('createCachedAdapterStoreModule', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('public API surface', () => { + it('returns all StoreModuleForAdapter methods including wrapped retrieveAll', () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + expect(typeof store.retrieveAll).toBe('function'); + expect(typeof store.retrieveById).toBe('function'); + expect(typeof store.getOrFailById).toBe('function'); + expect(typeof store.generateNew).toBe('function'); + expect(store.getAll).toBeDefined(); + expect(typeof store.getById).toBe('function'); + }); + + it('passes through retrieveById unchanged', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({ + data: {id: 5, name: 'Lane 5'}, + } as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + await store.retrieveById(5); + + expect(httpService.getRequest).toHaveBeenCalledWith('lanes/5'); + expect(store.getById(5).value?.tag()).toBe('adapted-5'); + }); + }); + + describe('skip-or-fetch decision', () => { + it('fetches on cold start (no localHash, no currentServerHash)', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + await store.retrieveAll(); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + expect(httpService.getRequest).toHaveBeenCalledWith('lanes'); + }); + + it('fetches when localHash is set but currentServerHash is still null (no signal received)', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({'lanes.cache-hash': 'abc'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + await store.retrieveAll(); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('fetches when localHash is null but currentServerHash is set (cold start with signal)', async () => { + // Discriminates the `localHash !== null` clause. If that clause were + // flipped to `true`, then localHash (null) === currentServerHash + // ('abc') would be false → still fetch. But if the comparison were + // mutated to use `!==` instead of `===`, this case would skip. So + // we keep this test as a baseline; the equality-flip is killed + // elsewhere via the all-null short-circuit assertion. + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'server-only'})})); + await store.retrieveAll(); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('fetches when localHash and currentServerHash differ', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({'lanes.cache-hash': 'old-hash'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'new-hash'})})); + + await store.retrieveAll(); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('skips when localHash and currentServerHash are equal', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({'lanes.cache-hash': 'matching-hash'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'matching-hash'})})); + + await store.retrieveAll(); + + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + + it('skips return undefined (not the inflight promise) on a synchronous hit', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({'lanes.cache-hash': 'h'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'h'})})); + + await expect(store.retrieveAll()).resolves.toBeUndefined(); + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + }); + + describe('persist-after-success timing', () => { + it('persists localHash to storageService only after inner.retrieveAll succeeds', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'server-hash'})})); + expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', expect.anything()); + + await store.retrieveAll(); + + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'server-hash'); + }); + + it('does NOT persist localHash when inner.retrieveAll rejects', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockRejectedValue(new Error('network died')); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'server-hash'})})); + + await expect(store.retrieveAll()).rejects.toThrow('network died'); + + expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', expect.anything()); + }); + + it('does NOT persist when retrieveAll succeeds but no server hash has been received yet', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + await store.retrieveAll(); + + // Discriminating assertion: if `if (serverHashSnapshot !== null)` + // were mutated to `if (true)`, storageService.put would be called + // with ('lanes.cache-hash', null). We assert it was NEVER called + // with that key at all. + expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', expect.anything()); + expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', null); + }); + + it('captures the current server hash at success time, not at receipt time', async () => { + // If a later response carries a different hash AFTER retrieveAll + // returns but the snapshot was taken correctly, the persisted hash + // matches the data that was actually retrieved. + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'A'})})); + await store.retrieveAll(); + + // Persisted hash matches the in-memory currentServerHash at success time. + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'A'); + }); + }); + + describe('in-flight deduplication', () => { + it('invokes inner.retrieveAll exactly once when called twice in rapid succession', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + let resolveGet!: (value: AxiosResponse) => void; + const pending = new Promise>((resolve) => { + resolveGet = resolve; + }); + vi.mocked(httpService.getRequest).mockReturnValue(pending); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + const first = store.retrieveAll(); + const second = store.retrieveAll(); + + resolveGet({data: []} as AxiosResponse); + await Promise.all([first, second]); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('allows a fresh fetch after the previous inflight call settles', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + await store.retrieveAll(); + await store.retrieveAll(); + + expect(httpService.getRequest).toHaveBeenCalledTimes(2); + }); + + it('clears inflight on rejection so subsequent retries can fire', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest) + .mockRejectedValueOnce(new Error('first failed')) + .mockResolvedValueOnce({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + await expect(store.retrieveAll()).rejects.toThrow('first failed'); + await store.retrieveAll(); + + expect(httpService.getRequest).toHaveBeenCalledTimes(2); + }); + }); + + describe('idempotent middleware registration', () => { + it('registers exactly one response middleware when multiple wrappers share an httpService', () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService, 'lanes'), + {cacheKey: 'lanes'}, + ); + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService, 'teams'), + {cacheKey: 'teams'}, + ); + + expect(httpService.getResponseMiddlewares()).toHaveLength(1); + }); + + it('still updates currentServerHash for every registered cacheKey when sharing one middleware', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({ + 'lanes.cache-hash': 'lanes-hash', + 'teams.cache-hash': 'teams-hash', + }); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + + const laneStore = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService, 'lanes'), + {cacheKey: 'lanes'}, + ); + const teamStore = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService, 'teams'), + {cacheKey: 'teams'}, + ); + + httpService.deliver( + makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'lanes-hash', teams: 'teams-hash'})}), + ); + + await laneStore.retrieveAll(); + await teamStore.retrieveAll(); + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + + it('ignores unregistered cacheKeys without affecting any setter', async () => { + // Pins the `if (setter)` lookup in the iteration. If that check + // were flipped to `if (true)`, calling `undefined(hash)` would + // throw, but the wrapper's try/catch would swallow it. The + // discriminating assertion: a registered store's currentServerHash + // must be SET correctly even when an unregistered key arrives + // alongside it in the same response. + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({'lanes.cache-hash': 'matching'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService, 'lanes'), + {cacheKey: 'lanes'}, + ); + // 'unknown' is NOT a registered cacheKey on this httpService. + // The middleware must skip it (setters.get returns undefined) and + // STILL process 'lanes'. + httpService.deliver( + makeResponse({'x-fs-cache-hashes': encodeHashHeader({unknown: 'whatever', lanes: 'matching'})}), + ); + + await store.retrieveAll(); + + // Registered key was applied → skip. + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + + it('registers a fresh middleware for a different httpService instance', () => { + const httpA = makeFakeHttpService(); + const httpB = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + + createCachedAdapterStoreModule( + makeConfig(httpA, storageService, loadingService, 'lanes'), + {cacheKey: 'lanes'}, + ); + createCachedAdapterStoreModule( + makeConfig(httpB, storageService, loadingService, 'lanes'), + {cacheKey: 'lanes'}, + ); + + expect(httpA.getResponseMiddlewares()).toHaveLength(1); + expect(httpB.getResponseMiddlewares()).toHaveLength(1); + }); + }); + + describe('parser branches (mutation-discriminating)', () => { + // Each test uses setupMalformDiscriminator: + // - storage has localHash = 'X' + // - retrieveAll's `getRequest` is mocked + // - if parser correctly REJECTS the input → currentServerHash null + // → fetch (1 call to getRequest) + // - if parser INCORRECTLY accepts and sets currentServerHash = 'X' + // → skip (0 calls) + // The assertion `toHaveBeenCalledTimes(1)` therefore pins the rejection + // behavior of each parser branch. + + it('rejects header value when not a string (e.g., array form)', async () => { + const {httpService, store} = setupMalformDiscriminator(); + const response = { + data: null, + status: 200, + statusText: 'OK', + config: {}, + headers: {'x-fs-cache-hashes': ['v1.something'] as unknown as string}, + } as unknown as AxiosResponse; + httpService.deliver(response); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects header value missing the v1. prefix', async () => { + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeURIComponent(JSON.stringify({lanes: 'X'}))})); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects header value with wrong version prefix (v2.)', async () => { + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver( + makeResponse({'x-fs-cache-hashes': `v2.${encodeURIComponent(JSON.stringify({lanes: 'X'}))}`}), + ); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects malformed URI sequence after the v1. prefix', async () => { + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': 'v1.%E0%A4%A'})); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects malformed JSON after v1. prefix (truncated brace)', async () => { + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":"X"')}`})); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects valid JSON that parses to null', async () => { + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('null')}`})); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects valid JSON array (not an object)', async () => { + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('["lanes","X"]')}`})); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects valid JSON primitive (string/number)', async () => { + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('"X"')}`})); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects valid JSON object where a value is not a string', async () => { + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":42}')}`})); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects absent x-fs-cache-hashes header entirely', async () => { + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({})); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects absent response.headers object', async () => { + const {httpService, store} = setupMalformDiscriminator(); + const responseWithoutHeaders = { + data: null, + status: 200, + statusText: 'OK', + config: {}, + } as unknown as AxiosResponse; + httpService.deliver(responseWithoutHeaders); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects valid v1. payload missing the wrapper-registered cacheKey', async () => { + // Pins the `if (map === null) return` early-return and the + // setter-key lookup: the map has well-formed keys but NOT 'lanes', + // so the setter for 'lanes' never fires. + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({teams: 'X', users: 'X'})})); + + await store.retrieveAll(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('ACCEPTS valid v1. payload with matching cacheKey and value (positive control)', async () => { + // Positive control matching the negative tests above. With + // localHash 'X' persisted and a valid v1. payload {lanes: 'X'}, + // the parser correctly accepts → currentServerHash = 'X' → + // localHash === currentServerHash → skip → 0 calls. This + // discriminates "parser rejects everything" mutations from + // "parser accepts everything" mutations. + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'X'})})); + + await store.retrieveAll(); + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + }); + + describe('exception-safe response middleware (Architecture Lock #10)', () => { + it('5a: malformed v1 prefix → no throw; consumer caller-resolves cleanly', async () => { + const {httpService, store} = setupMalformDiscriminator(); + expect(() => + httpService.deliver( + makeResponse({'x-fs-cache-hashes': `v2.${encodeURIComponent(JSON.stringify({lanes: 'X'}))}`}), + ), + ).not.toThrow(); + + await expect(store.retrieveAll()).resolves.toBeUndefined(); + }); + + it('5b: malformed JSON → no throw', async () => { + const {httpService, store} = setupMalformDiscriminator(); + expect(() => + httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":"X"')}`})), + ).not.toThrow(); + + await expect(store.retrieveAll()).resolves.toBeUndefined(); + }); + + it('5c: valid JSON but missing the cacheKey → no throw, no state change', async () => { + const {httpService, store} = setupMalformDiscriminator(); + expect(() => + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({teams: 'X', users: 'X'})})), + ).not.toThrow(); + + await expect(store.retrieveAll()).resolves.toBeUndefined(); + }); + + it('5-success: valid v1. header for our cacheKey → currentServerHash updated; localHash persisted after retrieveAll succeeds', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'fresh-hash'})})); + expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', 'fresh-hash'); + + await store.retrieveAll(); + + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'fresh-hash'); + }); + + it('parser-null path returns early without entering the iteration (no debug log on no-signal)', () => { + // Discriminating line 156's `if (map === null) return`. If the + // guard were flipped, the iteration body would execute against a + // null map and Object.entries(null) would throw, caught by the + // outer try/catch and surfaced as a debug log. We assert the + // debug log was NOT called on a clean no-signal response. + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined); + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + // Deliver a response WITHOUT the cache header — parser returns null. + httpService.deliver(makeResponse({'content-type': 'application/json'})); + + expect(debugSpy).not.toHaveBeenCalled(); + }); + + it('pathological response.headers (getter throws) → middleware catches and does not abort the chain', () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + const debugSpy = vi.spyOn(console, 'debug').mockImplementation(() => undefined); + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + const response = {data: null, status: 200, statusText: 'OK', config: {}} as unknown as AxiosResponse; + Object.defineProperty(response, 'headers', { + get: () => { + throw new Error('headers getter exploded'); + }, + }); + + expect(() => httpService.deliver(response)).not.toThrow(); + expect(debugSpy).toHaveBeenCalledWith( + '[fs-cached-adapter-store] response middleware caught error', + expect.any(Error), + ); + }); + }); +}); diff --git a/packages/cached-adapter-store/tests/parser.spec.ts b/packages/cached-adapter-store/tests/parser.spec.ts new file mode 100644 index 0000000..ec9bc01 --- /dev/null +++ b/packages/cached-adapter-store/tests/parser.spec.ts @@ -0,0 +1,212 @@ +import type {AxiosResponse} from 'axios'; + +import {describe, expect, it} from 'vitest'; + +import {parseCacheHashHeader} from '../src/cached-adapter-store'; + +/** + * Direct tests for the internal parser. The wrapper's outer try/catch + * swallows any throw the parser would emit, which makes it impossible to + * distinguish "parser returned null" from "parser threw" via the wrapper's + * external surface. These tests pin the per-branch return value directly, + * giving Stryker observable mutation-discriminating output for each guard + * in the parser body. + */ + +const makeResponse = (headers: unknown): AxiosResponse => + ({data: null, status: 200, statusText: 'OK', config: {}, headers}) as unknown as AxiosResponse; + +const encode = (obj: unknown): string => `v1.${encodeURIComponent(JSON.stringify(obj))}`; + +describe('parseCacheHashHeader', () => { + describe('happy path', () => { + it('parses a well-formed v1. header into a flat map', () => { + const response = makeResponse({'x-fs-cache-hashes': encode({lanes: 'h1', teams: 'h2'})}); + expect(parseCacheHashHeader(response)).toEqual({lanes: 'h1', teams: 'h2'}); + }); + + it('parses an empty map', () => { + const response = makeResponse({'x-fs-cache-hashes': encode({})}); + expect(parseCacheHashHeader(response)).toEqual({}); + }); + + it('parses a single-key map', () => { + const response = makeResponse({'x-fs-cache-hashes': encode({lanes: 'only'})}); + expect(parseCacheHashHeader(response)).toEqual({lanes: 'only'}); + }); + }); + + describe('header presence guards', () => { + it('returns null when response.headers is undefined', () => { + const response = {data: null, status: 200, statusText: 'OK', config: {}} as unknown as AxiosResponse; + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when the header key is absent', () => { + const response = makeResponse({'content-type': 'application/json'}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when the header value is not a string (e.g., array)', () => { + const response = makeResponse({'x-fs-cache-hashes': ['v1.something']}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when the header value is undefined', () => { + const response = makeResponse({'x-fs-cache-hashes': undefined}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when the header value is a number', () => { + const response = makeResponse({'x-fs-cache-hashes': 42}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + }); + + describe('version prefix guards', () => { + it('returns null when the value lacks any prefix', () => { + const response = makeResponse({'x-fs-cache-hashes': encodeURIComponent(JSON.stringify({lanes: 'x'}))}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when the prefix is incomplete (v1 without the dot)', () => { + const response = makeResponse({ + 'x-fs-cache-hashes': `v1${encodeURIComponent(JSON.stringify({lanes: 'x'}))}`, + }); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when the prefix is a higher version (v2.)', () => { + const response = makeResponse({ + 'x-fs-cache-hashes': `v2.${encodeURIComponent(JSON.stringify({lanes: 'x'}))}`, + }); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when the prefix is empty before the dot (.)', () => { + const response = makeResponse({ + 'x-fs-cache-hashes': `.${encodeURIComponent(JSON.stringify({lanes: 'x'}))}`, + }); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + }); + + describe('decode guards', () => { + it('returns null when the payload contains a malformed URI sequence', () => { + const response = makeResponse({'x-fs-cache-hashes': 'v1.%E0%A4%A'}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns a valid map when the payload contains percent-encoded characters', () => { + const response = makeResponse({ + 'x-fs-cache-hashes': `v1.${encodeURIComponent(JSON.stringify({'projects/10/lanes': 'h'}))}`, + }); + expect(parseCacheHashHeader(response)).toEqual({'projects/10/lanes': 'h'}); + }); + }); + + describe('JSON parse guards', () => { + it('returns null when JSON is truncated (unclosed brace)', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":"x"')}`}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when JSON is garbage', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('not json at all')}`}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + }); + + describe('structural shape guards (line 83 — disjunctive triple)', () => { + it('returns null when JSON parses to null (parsed === null clause)', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('null')}`}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when JSON parses to a string (typeof !== object clause)', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('"a string"')}`}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when JSON parses to a number (typeof !== object clause)', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('42')}`}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when JSON parses to a boolean (typeof !== object clause)', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('true')}`}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when JSON parses to an array (Array.isArray clause)', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('["lanes","x"]')}`}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when JSON parses to an empty array', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('[]')}`}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('ACCEPTS a non-null, non-array object (positive control for line 83)', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":"x"}')}`}); + expect(parseCacheHashHeader(response)).toEqual({lanes: 'x'}); + }); + }); + + describe('value-type guard (line 86)', () => { + it('returns null when a value is a number', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":42}')}`}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when a value is null', () => { + const response = makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":null}')}`}); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when a value is a nested object', () => { + const response = makeResponse({ + 'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":{"nested":"x"}}')}`, + }); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('returns null when one value is a string and another is not', () => { + const response = makeResponse({ + 'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":"ok","teams":42}')}`, + }); + expect(parseCacheHashHeader(response)).toBeNull(); + }); + + it('ACCEPTS all-string values (positive control for line 86)', () => { + const response = makeResponse({ + 'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":"h1","teams":"h2"}')}`, + }); + expect(parseCacheHashHeader(response)).toEqual({lanes: 'h1', teams: 'h2'}); + }); + }); + + describe('exception-safety (the parser must never throw)', () => { + it('does not throw when response.headers getter throws', () => { + // This case is caught at the wrapper level (outer try/catch in + // the middleware body), but we verify here that the parser's own + // contract is no-throw — i.e., it does not introduce additional + // throw paths beyond what the caller handles. The parser reads + // `response.headers` once at the top; a throwing getter will + // throw inside the parser, and the wrapper's outer try/catch + // catches it. The parser does NOT promise to swallow this — it + // promises to not throw from its own internal logic. + // This test documents that the parser's contract is honored: a + // pathological headers getter cannot make the parser produce a + // false-positive map. + const response = {} as AxiosResponse; + Object.defineProperty(response, 'headers', { + get: () => { + throw new Error('boom'); + }, + }); + expect(() => parseCacheHashHeader(response)).toThrow('boom'); + }); + }); +}); diff --git a/packages/cached-adapter-store/tests/types.spec.ts b/packages/cached-adapter-store/tests/types.spec.ts new file mode 100644 index 0000000..9939523 --- /dev/null +++ b/packages/cached-adapter-store/tests/types.spec.ts @@ -0,0 +1,47 @@ +import type { + Adapted, + AdapterStoreConfig, + Item, + NewAdapted, + StoreModuleForAdapter, +} from '@script-development/fs-adapter-store'; + +import {describe, expectTypeOf, it} from 'vitest'; + +import type {CachedAdapterStoreOptions} from '../src/types'; + +import {createCachedAdapterStoreModule} from '../src/cached-adapter-store'; + +/** + * Type-only assertions. These prove the wrapper's drop-in-compatibility + * invariant: a value produced by `createCachedAdapterStoreModule` + * is assignable to `StoreModuleForAdapter`. If this ever breaks + * (e.g., the wrapper accidentally widens or narrows the return type), + * vitest's `expectTypeOf` will surface the regression at typecheck time + * via the spec file's tsc pass. + */ +interface DemoItem extends Item { + id: number; + name: string; +} + +type DemoAdapted = Adapted; +type DemoNewAdapted = NewAdapted; + +describe('createCachedAdapterStoreModule type surface', () => { + it('returns StoreModuleForAdapter', () => { + expectTypeOf(createCachedAdapterStoreModule) + .parameter(0) + .toEqualTypeOf>(); + expectTypeOf(createCachedAdapterStoreModule) + .parameter(1) + .toEqualTypeOf(); + expectTypeOf(createCachedAdapterStoreModule).returns.toEqualTypeOf< + StoreModuleForAdapter + >(); + }); + + it('CachedAdapterStoreOptions has only cacheKey (no staleAfterMs, etc.)', () => { + expectTypeOf().toEqualTypeOf<{cacheKey: string}>(); + }); +}); diff --git a/packages/cached-adapter-store/tsconfig.json b/packages/cached-adapter-store/tsconfig.json new file mode 100644 index 0000000..850007d --- /dev/null +++ b/packages/cached-adapter-store/tsconfig.json @@ -0,0 +1 @@ +{"extends": "../../tsconfig.base.json", "compilerOptions": {"outDir": "dist", "rootDir": "src"}, "include": ["src"]} diff --git a/packages/cached-adapter-store/tsdown.config.ts b/packages/cached-adapter-store/tsdown.config.ts new file mode 100644 index 0000000..d4ab38f --- /dev/null +++ b/packages/cached-adapter-store/tsdown.config.ts @@ -0,0 +1,3 @@ +import {defineConfig} from 'tsdown'; + +export default defineConfig({entry: ['src/index.ts'], format: ['esm', 'cjs'], dts: true, clean: true, sourcemap: true}); diff --git a/packages/cached-adapter-store/vitest.config.ts b/packages/cached-adapter-store/vitest.config.ts new file mode 100644 index 0000000..ec86030 --- /dev/null +++ b/packages/cached-adapter-store/vitest.config.ts @@ -0,0 +1,12 @@ +import {defineProject} from 'vitest/config'; + +export default defineProject({ + test: { + name: 'cached-adapter-store', + coverage: { + provider: 'v8', + include: ['src/**/*.ts'], + thresholds: {lines: 100, branches: 100, functions: 100, statements: 100}, + }, + }, +}); From 947bb28ea83a02733bb1c28502e24797f9fafd76 Mon Sep 17 00:00:00 2001 From: Gerard Date: Wed, 13 May 2026 16:33:13 +0200 Subject: [PATCH 2/5] docs(conventions): no direct axios imports in dependent packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Direct `import type {AxiosResponse} from 'axios'` breaks rolldown's `d.cts` emission on dual-bundle packages. Caught during fs-cached-adapter-store scaffold — the workaround was to route through `Parameters[0]` (re-exported by fs-http). Codifying so the next package author doesn't hit the same wall. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CLAUDE.md b/CLAUDE.md index 40105a7..146df2a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th - **Loose coupling:** Prefer structural typing (duck types) over direct package imports where possible. `fs-theme`'s `ThemeStorageContract` is the exemplar. - **Test environment:** Browser-dependent tests use `// @vitest-environment happy-dom` file-level comments. - **Identical build config:** All packages share the same `tsdown.config.ts` structure. +- **No direct axios imports in dependent packages.** Route `AxiosResponse` / `AxiosRequestConfig` / sibling types through `fs-http`'s re-exports (e.g. `Parameters[0]` for response types). Direct `import type {AxiosResponse} from 'axios'` breaks rolldown's `d.cts` emission on dual-bundle packages — caught during `fs-cached-adapter-store` scaffold 2026-05-13. ### Internal Dependency Coordination From e1309938888f2408e98fb5bd5ac93f37dd47143d Mon Sep 17 00:00:00 2001 From: Gerard Oosterhof Date: Wed, 13 May 2026 22:17:26 +0200 Subject: [PATCH 3/5] =?UTF-8?q?feat(cached-adapter-store)!:=20narrow=20pub?= =?UTF-8?q?lic=20surface=20=E2=80=94=20remove=20retrieveAll/retrieveById,?= =?UTF-8?q?=20add=20prime()=20(Locks=20#11=20reversed=20+=20#12/#13)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per Commander 2026-05-13 Architecture Lock revisions: - Lock #11 REVERSED: retrieveById no longer passthrough — removed from public surface. - NEW Lock #12: retrieveAll removed from public surface. Response middleware is the sole steady-state trigger of inner.retrieveAll. - NEW Lock #13: prime() is the only consumer-facing fetch entry point — idempotent, in-flight-deduped against middleware-driven retrieveAlls, no-op once a successful inner retrieve has completed in this session and localHash !== null. Implementation: - The `retrieveAll` closure is replaced by a private `triggerInnerRetrieveAll` coordinator that owns the in-flight ref, the skip-when-equal check, the persist-after-success snapshot, and a new `hasCompletedAtLeastOnce` flag. - The response middleware's per-cacheKey handler now does TWO things on every observed hash: (a) bump currentServerHash, (b) fire-and-forget `triggerInnerRetrieveAll()`. The trigger inherits in-flight dedup + skip-when-equal, so an equal observed hash is short-circuited internally; an async rejection on the inner promise is contained inside the inflight closure's try/finally and a top-level `.catch(() => {})` ensures no unhandled rejection escapes back to abort the caller's request. - `prime()` short-circuits when `hasCompletedAtLeastOnce && localHash.value !== null`; otherwise delegates to `triggerInnerRetrieveAll()` and awaits its resolution, surfacing failures to the caller. - The returned module is exactly `{getAll, getById, getOrFailById, generateNew, prime}` — no `retrieveAll`, no `retrieveById`. Type narrowing: - New exported type `CachedStoreModuleForAdapter` in src/types.ts captures the narrower surface. The factory's return type changes from `StoreModuleForAdapter` to `CachedStoreModuleForAdapter`. The new type is intentionally NOT assignable to `StoreModuleForAdapter` (no `retrieveAll`/`retrieveById` properties) — verified by a `@ts-expect-error` assertion in the type test. - Re-exported from src/index.ts as a named type export. See orders/fs-packages/fs-cached-adapter-store-public-surface-narrowing-engineer-deployment.md for the full specification. --- .../src/cached-adapter-store.ts | 222 ++++++++++++------ packages/cached-adapter-store/src/index.ts | 2 +- packages/cached-adapter-store/src/types.ts | 41 ++++ 3 files changed, 190 insertions(+), 75 deletions(-) diff --git a/packages/cached-adapter-store/src/cached-adapter-store.ts b/packages/cached-adapter-store/src/cached-adapter-store.ts index 1b5d177..b78b5cb 100644 --- a/packages/cached-adapter-store/src/cached-adapter-store.ts +++ b/packages/cached-adapter-store/src/cached-adapter-store.ts @@ -1,17 +1,11 @@ -import type { - Adapted, - AdapterStoreConfig, - Item, - NewAdapted, - StoreModuleForAdapter, -} from '@script-development/fs-adapter-store'; +import type {Adapted, AdapterStoreConfig, Item, NewAdapted} from '@script-development/fs-adapter-store'; import type {HttpService, ResponseMiddlewareFunc} from '@script-development/fs-http'; import type {Ref} from 'vue'; import {createAdapterStoreModule} from '@script-development/fs-adapter-store'; import {ref} from 'vue'; -import type {CachedAdapterStoreOptions} from './types'; +import type {CachedAdapterStoreOptions, CachedStoreModuleForAdapter} from './types'; /** * Convenience alias for the response shape passed to a response middleware. @@ -28,7 +22,7 @@ type Response = Parameters[0]; * carries a freshness signal. Value shape: `v1.${urlencoded JSON}` where the * JSON is a flat `{cacheKey: hashString}` map. Anything not starting with * `v1.` is treated as no-signal (strict version-prefix policy — see - * Architecture Lock #5 in the Engineer deployment order, 2026-05-13). + * Architecture Lock #5 in the scaffold Engineer deployment order, 2026-05-13). */ const HEADER_NAME = 'x-fs-cache-hashes'; const VERSION_PREFIX = 'v1.'; @@ -48,16 +42,17 @@ const LOG_PREFIX = '[fs-cached-adapter-store]'; * for header parsing. Without this, two wrapper-factory calls sharing one * `httpService` would parse each response twice. * - * The registry maps `HttpService` -> a map keyed by `cacheKey` -> setter - * for the corresponding `currentServerHash` ref. When a new wrapper is - * constructed: + * The registry maps `HttpService` -> a map keyed by `cacheKey` -> the + * per-cacheKey "observe & trigger" handler that owns + * `currentServerHash` update AND the (deduped, skip-if-equal) call into + * `inner.retrieveAll()`. When a new wrapper is constructed: * - If its `httpService` is already registered, append the new cacheKey - * setter to the existing map. + * handler to the existing map. * - Otherwise, install a single response middleware on the `httpService` - * that iterates the map and routes hash updates to the right setter. + * that iterates the map and dispatches to the right handler. */ -type CacheKeySetter = (hash: string) => void; -const httpServiceRegistry = new WeakMap>(); +type CacheKeyHandler = (hash: string) => void; +const httpServiceRegistry = new WeakMap>(); /** * Synchronous, exception-safe parse of the `x-fs-cache-hashes` header. @@ -65,11 +60,11 @@ const httpServiceRegistry = new WeakMap * failure mode (missing header, missing prefix, decode error, JSON parse * error, structurally wrong shape). The function never throws. * - * Per Architecture Lock #10 (Engineer deployment order 2026-05-13) and the - * Surveyor middleware-invariants verdict (2026-05-13 §Q7/Q8/Q9), fs-http - * does NOT isolate middleware throws — a throw inside our middleware aborts - * the caller's entire request. The wrapper owns that isolation discipline, - * so this function is the throw boundary. + * Per Architecture Lock #10 (scaffold Engineer deployment order 2026-05-13) + * and the Surveyor middleware-invariants verdict (2026-05-13 §Q7/Q8/Q9), + * fs-http does NOT isolate middleware throws — a throw inside our middleware + * aborts the caller's entire request. The wrapper owns that isolation + * discipline, so this function is the throw boundary. * * Exported for direct unit testing. The parser's per-branch behavior is * difficult to discriminate from the outside (the wrapper's outer try/catch @@ -109,22 +104,39 @@ export const parseCacheHashHeader = (response: Response): Record * Wraps {@link createAdapterStoreModule} with a hash-bumping cache-check that * suppresses redundant `retrieveAll` GETs at the source. * - * On every response carrying `x-fs-cache-hashes`, the wrapper updates its - * in-memory `currentServerHash`. At `retrieveAll()` time, it compares - * `localHash` (hydrated from `storageService` at construction) against - * `currentServerHash`; if both are non-null and equal, the inner - * `retrieveAll()` is skipped entirely. After every successful inner - * `retrieveAll()`, the current server hash is snapshotted into both the - * in-memory `localHash` and `storageService` — never before, so a failed - * round-trip cannot leave a persisted hash that doesn't match `state`. + * **Public surface is intentionally narrower than `StoreModuleForAdapter`.** + * Consumers see `getAll`, `getById`, `getOrFailById`, `generateNew`, and a + * single bootstrap entry point `prime()`. There is NO `retrieveAll` and NO + * `retrieveById` on the returned module — retrieval is owned by the wrapper: + * + * - **Middleware-driven invalidation (steady state).** On every response + * carrying `x-fs-cache-hashes`, the wrapper updates its in-memory + * `currentServerHash` for each registered cacheKey AND, if + * `localHash !== currentServerHash`, triggers an internal + * `inner.retrieveAll()` (fire-and-forget; in-flight-deduped; skip-if-equal). + * - **`prime()` (cold-start).** A single idempotent entry point for the + * case where no response has yet stamped a hash on this tab. Two rapid + * `prime()` calls dedupe to one inner fetch via the shared in-flight ref; + * once a successful inner retrieve has completed and `localHash !== null`, + * subsequent `prime()` calls return immediately without invoking inner. + * + * After every successful inner `retrieveAll()`, the current server hash is + * snapshotted into both the in-memory `localHash` and `storageService` — never + * before, so a failed round-trip cannot leave a persisted hash that doesn't + * match `state`. * * All wrapper concerns (in-flight deduplication, idempotent middleware * registration across stores sharing one `httpService`, exception-safe * header parsing) are baked into the factory. Adapter-store is unmodified. * - * @see Architecture Locks 1-11 in + * @see Architecture Locks 1-10 in * `orders/fs-packages/fs-cached-adapter-store-scaffold-engineer-deployment.md` * for the v1 scope-shaping decisions Commander made on 2026-05-13. + * @see Architecture Lock revisions (Commander 2026-05-13) in + * `orders/fs-packages/fs-cached-adapter-store-public-surface-narrowing-engineer-deployment.md`: + * Lock #11 REVERSED (`retrieveById` removed from public surface); + * new Lock #12 (`retrieveAll` removed from public surface); + * new Lock #13 (`prime()` is the only consumer-facing fetch entry point). */ export const createCachedAdapterStoreModule = < T extends Item, @@ -133,7 +145,7 @@ export const createCachedAdapterStoreModule = < >( config: AdapterStoreConfig, options: CachedAdapterStoreOptions, -): StoreModuleForAdapter => { +): CachedStoreModuleForAdapter => { const {cacheKey} = options; const {httpService, storageService} = config; const hashStorageKey = `${cacheKey}.cache-hash`; @@ -144,14 +156,97 @@ export const createCachedAdapterStoreModule = < const localHash: Ref = ref(initialPersistedHash); const currentServerHash: Ref = ref(null); - const setCurrentHash: CacheKeySetter = (hash: string) => { + let inflight: Promise | null = null; + let hasCompletedAtLeastOnce = false; + + /** + * Shared retrieve coordinator. Both `prime()` (cold-start consumer + * trigger) and the response middleware (steady-state observer trigger) + * call into this. Owns three responsibilities: + * + * 1. In-flight dedup — any second caller awaits the same promise. + * 2. Skip-if-equal — if `localHash` and `currentServerHash` are both + * populated and equal, the inner fetch is skipped. + * 3. Persist-after-success — only on a successful `inner.retrieveAll()` + * do we snapshot `currentServerHash` into `localHash` + + * `storageService`. + * + * Errors propagate to the caller (used by `prime()` to surface failures + * up to consumer code). The middleware path is fire-and-forget; it does + * NOT await the returned promise. + */ + const triggerInnerRetrieveAll = async (): Promise => { + if (inflight) return inflight; + // Skip only when both are populated and equal. The `localHash !== null` + // clause is load-bearing: without it, a fresh wrapper with no + // persisted hash AND no server signal would have + // `localHash === currentServerHash === null` → skip → empty state + // forever. The `=== currentServerHash.value` comparison then handles + // the populated-equality case AND implicitly rejects + // `currentServerHash === null` (since localHash is non-null). + if (localHash.value !== null && localHash.value === currentServerHash.value) { + return; + } + inflight = (async () => { + try { + await inner.retrieveAll(); + // Persist after success only. The local hash must always + // match the data currently in state — persisting on response + // middleware receipt would race a cold-start re-mount that + // skipped the fetch and rendered empty state. + const serverHashSnapshot = currentServerHash.value; + if (serverHashSnapshot !== null) { + localHash.value = serverHashSnapshot; + storageService.put(hashStorageKey, serverHashSnapshot); + } + hasCompletedAtLeastOnce = true; + } finally { + inflight = null; + } + })(); + return inflight; + }; + + const handleObservedHash: CacheKeyHandler = (hash: string) => { currentServerHash.value = hash; + // Middleware-driven invalidation. Once the new server hash differs from + // localHash (the most common case being a fresh wrapper with + // localHash === null), trigger the inner fetch. Fire-and-forget — the + // middleware body stays synchronous from fs-http's perspective. The + // outer try/catch around the middleware body (below) covers any + // synchronous throw `triggerInnerRetrieveAll` could emit before + // returning its promise; the promise itself resolves into the void + // return type and any async rejection is contained inside the + // inflight closure's try/finally — it does NOT escape back through + // the middleware to abort the caller's request. + // + // `triggerInnerRetrieveAll` inherits the in-flight dedup and skip-when- + // equal logic, so a header that arrives mid-retrieve doesn't double-fire + // and a header that matches localHash doesn't fire at all. Note: the + // skip-when-equal check happens *after* this line bumps currentServerHash, + // so an equal observed hash is short-circuited by the trigger itself — + // we do not duplicate that check here. + // + // v1 simplification: once-per-burst is the contract. If currentServerHash + // continues changing after this trigger fires, later mismatches are + // picked up on the *next* response that carries a still-mismatched hash. + // The middleware path does NOT pre-check `localHash !== hash` to short- + // circuit before calling — the trigger owns that decision. + void triggerInnerRetrieveAll().catch(() => { + // Swallowed by design. The trigger's own try/finally already + // clears the in-flight ref and prevents persist-on-failure; a + // top-level handler here exists so unhandled-rejection logs + // don't fire on transient inner failures observed via the + // middleware path. Consumers who care about middleware-driven + // fetch failures observe via the inner adapter-store's own + // failure surface, not via the wrapper. + }); }; // Idempotent middleware registration. The registry is keyed by // HttpService instance, so multiple wrapper-factory calls sharing one // httpService produce ONE response middleware that fans out to all - // registered cacheKey setters — not N middlewares parsing the same + // registered cacheKey handlers — not N middlewares parsing the same // header N times. // // We treat httpService as Pick<...>-typed in config, but the registry @@ -159,20 +254,20 @@ export const createCachedAdapterStoreModule = < // requires registerResponseMiddleware on the same instance, and the // shape Pick captures is a subset of the live object. const httpServiceAsRegistryKey = httpService as HttpService; - const existingSetters = httpServiceRegistry.get(httpServiceAsRegistryKey); - if (existingSetters) { - existingSetters.set(cacheKey, setCurrentHash); + const existingHandlers = httpServiceRegistry.get(httpServiceAsRegistryKey); + if (existingHandlers) { + existingHandlers.set(cacheKey, handleObservedHash); } else { - const setters = new Map(); - setters.set(cacheKey, setCurrentHash); - httpServiceRegistry.set(httpServiceAsRegistryKey, setters); + const handlers = new Map(); + handlers.set(cacheKey, handleObservedHash); + httpServiceRegistry.set(httpServiceAsRegistryKey, handlers); httpServiceAsRegistryKey.registerResponseMiddleware((response: Response) => { try { const map = parseCacheHashHeader(response); if (map === null) return; for (const [key, hash] of Object.entries(map)) { - const setter = setters.get(key); - if (setter) setter(hash); + const handler = handlers.get(key); + if (handler) handler(hash); } } catch (error) { // Defense-in-depth. parseCacheHashHeader is exception-safe by @@ -187,37 +282,17 @@ export const createCachedAdapterStoreModule = < }); } - let inflight: Promise | null = null; - - const retrieveAll = async (): Promise => { - if (inflight) return inflight; - // Skip only when both are populated and equal. The `localHash !== null` - // clause is load-bearing: without it, a fresh wrapper with no - // persisted hash AND no server signal would have - // `localHash === currentServerHash === null` → skip → empty state - // forever. The `=== currentServerHash.value` comparison then handles - // the populated-equality case AND implicitly rejects - // `currentServerHash === null` (since localHash is non-null). - if (localHash.value !== null && localHash.value === currentServerHash.value) { - return; - } - inflight = (async () => { - try { - await inner.retrieveAll(); - // Persist after success only. The local hash must always - // match the data currently in state — persisting on response - // middleware receipt would race a cold-start re-mount that - // skipped the fetch and rendered empty state. - const serverHashSnapshot = currentServerHash.value; - if (serverHashSnapshot !== null) { - localHash.value = serverHashSnapshot; - storageService.put(hashStorageKey, serverHashSnapshot); - } - } finally { - inflight = null; - } - })(); - return inflight; + /** + * Idempotent bootstrap. Exists purely to cover the cold-start path: + * no response carrying `x-fs-cache-hashes` has yet been observed on this + * tab, so the middleware-driven trigger cannot fire. After the first + * successful inner retrieve, this becomes a no-op once `localHash !== null` + * — at that point the response middleware is the authoritative trigger + * for any subsequent re-fetches. + */ + const prime = async (): Promise => { + if (hasCompletedAtLeastOnce && localHash.value !== null) return; + return triggerInnerRetrieveAll(); }; return { @@ -225,7 +300,6 @@ export const createCachedAdapterStoreModule = < getById: inner.getById, getOrFailById: inner.getOrFailById, generateNew: inner.generateNew, - retrieveById: inner.retrieveById, - retrieveAll, + prime, }; }; diff --git a/packages/cached-adapter-store/src/index.ts b/packages/cached-adapter-store/src/index.ts index 4cb78cf..e239fdf 100644 --- a/packages/cached-adapter-store/src/index.ts +++ b/packages/cached-adapter-store/src/index.ts @@ -1,2 +1,2 @@ export {createCachedAdapterStoreModule} from './cached-adapter-store'; -export type {CachedAdapterStoreOptions} from './types'; +export type {CachedAdapterStoreOptions, CachedStoreModuleForAdapter} from './types'; diff --git a/packages/cached-adapter-store/src/types.ts b/packages/cached-adapter-store/src/types.ts index 9ecb724..3f84ffa 100644 --- a/packages/cached-adapter-store/src/types.ts +++ b/packages/cached-adapter-store/src/types.ts @@ -1,3 +1,6 @@ +import type {Adapted, Item, NewAdapted} from '@script-development/fs-adapter-store'; +import type {ComputedRef} from 'vue'; + /** * Options for {@link createCachedAdapterStoreModule}. * @@ -16,3 +19,41 @@ export type CachedAdapterStoreOptions = { */ cacheKey: string; }; + +/** + * Public API of a cached-adapter-store wrapper. Strictly narrower than + * `StoreModuleForAdapter`: `retrieveAll` and `retrieveById` are deliberately + * absent. Retrieval is owned by the wrapper — middleware-driven for steady + * state, `prime()` for cold-start. + * + * NOT assignable to `StoreModuleForAdapter`. This is intentional: + * a "cached store" that lets consumers re-introduce ad-hoc retrieval is + * not a cached store. If you need the unwrapped contract, use + * `createAdapterStoreModule` directly. + * + * @see Architecture Locks #12 and #13 in + * `orders/fs-packages/fs-cached-adapter-store-public-surface-narrowing-engineer-deployment.md` + * (Commander 2026-05-13 reversal of scaffold Lock #11 + new Locks #12/#13). + */ +export type CachedStoreModuleForAdapter< + T extends Item, + E extends Adapted = Adapted, + N extends NewAdapted = NewAdapted, +> = { + getAll: ComputedRef; + getById: (id: number) => ComputedRef; + getOrFailById: (id: number) => Promise; + generateNew: () => N; + /** + * Idempotent bootstrap. Call once at the consumer's preferred initialization + * point (app boot, route enter, root component setup) to guarantee data is + * loaded even if no server response has yet stamped `x-fs-cache-hashes` for + * this cacheKey. Subsequent calls dedupe against the shared in-flight ref + * and short-circuit once a successful `inner.retrieveAll` has completed. + * + * No-op when `localHash !== null` AND a successful inner retrieve has already + * happened in this session — at that point the response middleware is the + * authoritative trigger for any subsequent re-fetches. + */ + prime: () => Promise; +}; From 2e9971ded24cb81e578413ab2916e39d5d585a92 Mon Sep 17 00:00:00 2001 From: Gerard Oosterhof Date: Wed, 13 May 2026 22:17:43 +0200 Subject: [PATCH 4/5] test(cached-adapter-store): rewrite test surface around prime() and middleware-driven trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public-API surface tests (new): - Returned module has exactly {getAll, getById, getOrFailById, generateNew, prime} — five enumerable keys. - `expect(store).not.toHaveProperty('retrieveAll')` — verifies the narrowing. - `expect(store).not.toHaveProperty('retrieveById')` — verifies the narrowing. prime() behavior (new group): - Cold start (localHash null, no header) → exactly one inner fetch. - localHash set but no header → exactly one inner fetch. - Already in sync → no inner fetch. - Mismatch on cold-start with header already seen → fires inner once, persists new hash. - Idempotency: two rapid prime() calls → exactly one inner fetch (in-flight dedup). - Post-success no-op: after successful prime, second prime returns immediately. - Post-success no-op requires localHash !== null — if persist did not happen (no header), second prime CAN fire again (pins the localHash !== null clause of the guard). Middleware-driven trigger (new group): - Response with hash differing from localHash (cold + warm) → middleware fires inner exactly once and updates persisted hash on success. - Response with hash equal to localHash → middleware does NOT fire inner. - Response with header missing/malformed (5a/5b/5c) → middleware does NOT fire inner. prime + middleware race coordination (new group): - Mid-flight response arriving during a prime() call → ONE inner fetch via shared in-flight ref (v1 once-per-burst contract; later mismatches handled by the NEXT header). Persist-after-success timing (rewritten): - ONLY after inner success does storageService.put fire — verified with a pending Promise that holds the inner fetch open so we can assert the negative before resolving. - Inner rejection (prime path) → no persist; rejection surfaces to caller. - Inner rejection (middleware path) → no persist; rejection swallowed (fire-and-forget; no unhandled-rejection escape). Idempotent middleware registration (rewritten): - Existing assertions preserved; entry-point swapped from retrieveAll() to prime(). Parser branches (rewritten): - All malformed-header tests now drive through prime() instead of retrieveAll. Setup-rig comment updated to describe the new entry point. Exception-safe response middleware (Architecture Lock #10, rewritten): - Existing 5a/5b/5c contract-pin tests preserved (no longer chain to a removed retrieveAll on the rig, just verify no-throw on delivery). - 5-success: rewritten to await store.prime() so the assertion rendezvous with the middleware-triggered inner fetch. - NEW: inner.retrieveAll rejection on the middleware path does NOT propagate back through the middleware — verified via `process.on('unhandledRejection', ...)` capturing during microtask drain. types.spec.ts (rewritten): - Asserts the factory's return type is `CachedStoreModuleForAdapter` (was `StoreModuleForAdapter<...>`). - Adds a `@ts-expect-error` assertion that the wrapper's return is NOT assignable to `StoreModuleForAdapter<...>`. If a future refactor re-adds retrieveAll/retrieveById, the directive becomes unused and tsc errors out. - Type-test body guarded by `if (false as boolean)` so the type-level assignment is checked but the runtime call into the real factory (against an empty config) never executes. Test count: 69 → 80 (+11, well above the order's ≥69 baseline). Coverage: 100% statements/branches/functions/lines on src/cached-adapter-store.ts and src/types.ts. Mutation: 94.81% on cached-adapter-store.ts (≥90% threshold; 4 surviving mutants are documented equivalent — parser try/catch fall-throughs and the post-success hasCompletedAtLeastOnce/early-return optimization, observationally indistinguishable from the unmutated form because skip-when-equal in triggerInnerRetrieveAll catches the same case). --- .../tests/cached-adapter-store.spec.ts | 512 +++++++++++++----- .../cached-adapter-store/tests/types.spec.ts | 60 +- 2 files changed, 422 insertions(+), 150 deletions(-) diff --git a/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts b/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts index cba0daa..1e08dd1 100644 --- a/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts +++ b/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts @@ -154,7 +154,7 @@ const makeStorageService = (initial: Record = {}): TestStorageS * fetch otherwise". Tests that need a mutation-discriminating no-signal assertion * persist 'X' to storage AND seed the malformed response with a payload that * would parse to {lanes: 'X'} ONLY if the parser is broken. After delivery, we - * call retrieveAll and assert whether httpService.getRequest fired. + * call prime() and assert whether httpService.getRequest fired. * * If the parser correctly rejects → currentServerHash null → fetch (1 call). * If the parser incorrectly accepts → currentServerHash 'X' === localHash 'X' @@ -178,8 +178,8 @@ describe('createCachedAdapterStoreModule', () => { vi.restoreAllMocks(); }); - describe('public API surface', () => { - it('returns all StoreModuleForAdapter methods including wrapped retrieveAll', () => { + describe('public API surface (narrowed — no retrieveAll / no retrieveById)', () => { + it('returns exactly {getAll, getById, getOrFailById, generateNew, prime}', () => { const httpService = makeFakeHttpService(); const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; @@ -187,35 +187,51 @@ describe('createCachedAdapterStoreModule', () => { makeConfig(httpService, storageService, loadingService), {cacheKey: 'lanes'}, ); - expect(typeof store.retrieveAll).toBe('function'); - expect(typeof store.retrieveById).toBe('function'); - expect(typeof store.getOrFailById).toBe('function'); - expect(typeof store.generateNew).toBe('function'); expect(store.getAll).toBeDefined(); expect(typeof store.getById).toBe('function'); + expect(typeof store.getOrFailById).toBe('function'); + expect(typeof store.generateNew).toBe('function'); + expect(typeof store.prime).toBe('function'); }); - it('passes through retrieveById unchanged', async () => { + it('does NOT expose retrieveAll on the returned module', () => { const httpService = makeFakeHttpService(); const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; - vi.mocked(httpService.getRequest).mockResolvedValue({ - data: {id: 5, name: 'Lane 5'}, - } as AxiosResponse); const store = createCachedAdapterStoreModule( makeConfig(httpService, storageService, loadingService), {cacheKey: 'lanes'}, ); + expect(store).not.toHaveProperty('retrieveAll'); + }); - await store.retrieveById(5); + it('does NOT expose retrieveById on the returned module', () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + expect(store).not.toHaveProperty('retrieveById'); + }); - expect(httpService.getRequest).toHaveBeenCalledWith('lanes/5'); - expect(store.getById(5).value?.tag()).toBe('adapted-5'); + it('returned object has exactly five enumerable keys', () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + expect(Object.keys(store).sort()).toEqual( + ['generateNew', 'getAll', 'getById', 'getOrFailById', 'prime'].sort(), + ); }); }); - describe('skip-or-fetch decision', () => { - it('fetches on cold start (no localHash, no currentServerHash)', async () => { + describe('prime() behavior', () => { + it('cold start: localHash null, no header seen → fires inner.retrieveAll exactly once', async () => { const httpService = makeFakeHttpService(); const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; @@ -225,13 +241,13 @@ describe('createCachedAdapterStoreModule', () => { {cacheKey: 'lanes'}, ); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); expect(httpService.getRequest).toHaveBeenCalledWith('lanes'); }); - it('fetches when localHash is set but currentServerHash is still null (no signal received)', async () => { + it('localHash set but no header seen → fires inner.retrieveAll exactly once', async () => { const httpService = makeFakeHttpService(); const storageService = makeStorageService({'lanes.cache-hash': 'abc'}); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; @@ -241,34 +257,30 @@ describe('createCachedAdapterStoreModule', () => { {cacheKey: 'lanes'}, ); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); - it('fetches when localHash is null but currentServerHash is set (cold start with signal)', async () => { - // Discriminates the `localHash !== null` clause. If that clause were - // flipped to `true`, then localHash (null) === currentServerHash - // ('abc') would be false → still fetch. But if the comparison were - // mutated to use `!==` instead of `===`, this case would skip. So - // we keep this test as a baseline; the equality-flip is killed - // elsewhere via the all-null short-circuit assertion. + it('already in sync (localHash === header hash, both non-null) → prime() does NOT fire inner', async () => { const httpService = makeFakeHttpService(); - const storageService = makeStorageService(); + const storageService = makeStorageService({'lanes.cache-hash': 'matching-hash'}); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; - vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); const store = createCachedAdapterStoreModule( makeConfig(httpService, storageService, loadingService), {cacheKey: 'lanes'}, ); - httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'server-only'})})); - await store.retrieveAll(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'matching-hash'})})); - expect(httpService.getRequest).toHaveBeenCalledTimes(1); + // Middleware-triggered fetch should be skipped (equal hash). + // The subsequent prime() should also be skipped — same reasoning. + await store.prime(); + + expect(httpService.getRequest).not.toHaveBeenCalled(); }); - it('fetches when localHash and currentServerHash differ', async () => { + it('hash mismatch on cold-start with header already seen → prime() fires inner once and persists the new hash on success', async () => { const httpService = makeFakeHttpService(); const storageService = makeStorageService({'lanes.cache-hash': 'old-hash'}); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; @@ -280,45 +292,67 @@ describe('createCachedAdapterStoreModule', () => { httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'new-hash'})})); - await store.retrieveAll(); + // The middleware-triggered fetch fires asynchronously; prime() dedupes against it. + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'new-hash'); }); - it('skips when localHash and currentServerHash are equal', async () => { + it('idempotency: two rapid prime() calls → exactly one inner fetch (in-flight dedup)', async () => { const httpService = makeFakeHttpService(); - const storageService = makeStorageService({'lanes.cache-hash': 'matching-hash'}); + const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + let resolveGet!: (value: AxiosResponse) => void; + const pending = new Promise>((resolve) => { + resolveGet = resolve; + }); + vi.mocked(httpService.getRequest).mockReturnValue(pending); const store = createCachedAdapterStoreModule( makeConfig(httpService, storageService, loadingService), {cacheKey: 'lanes'}, ); - httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'matching-hash'})})); + const first = store.prime(); + const second = store.prime(); - await store.retrieveAll(); + resolveGet({data: []} as AxiosResponse); + await Promise.all([first, second]); - expect(httpService.getRequest).not.toHaveBeenCalled(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); - it('skips return undefined (not the inflight promise) on a synchronous hit', async () => { + it('post-success no-op: after one successful prime(), a second prime() returns immediately without invoking inner', async () => { const httpService = makeFakeHttpService(); - const storageService = makeStorageService({'lanes.cache-hash': 'h'}); + const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); const store = createCachedAdapterStoreModule( makeConfig(httpService, storageService, loadingService), {cacheKey: 'lanes'}, ); - httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'h'})})); + // First prime: cold-start, header arrives before fetch resolves. + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'persisted'})})); + await store.prime(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); - await expect(store.retrieveAll()).resolves.toBeUndefined(); - expect(httpService.getRequest).not.toHaveBeenCalled(); + // Second prime: hasCompletedAtLeastOnce && localHash !== null → no-op. + // We do NOT stamp a new header here; the post-success short-circuit + // is what's being pinned. Even without a new header, prime() must + // not fire a second fetch. + await store.prime(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); - }); - describe('persist-after-success timing', () => { - it('persists localHash to storageService only after inner.retrieveAll succeeds', async () => { + it('post-success no-op requires localHash !== null — if persist did not happen (no header seen), a second prime() can fire again', async () => { + // Pins the `localHash.value !== null` guard in prime(). After a + // successful inner fetch where no header was ever observed, + // `localHash` remains null (persist-after-success skipped because + // currentServerHash was null). A subsequent prime() must NOT + // short-circuit; it must call into the trigger, which itself + // proceeds to fetch again because the skip-if-equal guard's + // `localHash !== null` clause is false. const httpService = makeFakeHttpService(); const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; @@ -328,97 +362,249 @@ describe('createCachedAdapterStoreModule', () => { {cacheKey: 'lanes'}, ); - httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'server-hash'})})); - expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', expect.anything()); - - await store.retrieveAll(); + await store.prime(); + await store.prime(); - expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'server-hash'); + expect(httpService.getRequest).toHaveBeenCalledTimes(2); }); + }); - it('does NOT persist localHash when inner.retrieveAll rejects', async () => { + describe('middleware-driven trigger', () => { + it('response with hash differing from localHash (cold) → middleware fires inner.retrieveAll exactly once', async () => { const httpService = makeFakeHttpService(); const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; - vi.mocked(httpService.getRequest).mockRejectedValue(new Error('network died')); - const store = createCachedAdapterStoreModule( + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + createCachedAdapterStoreModule( makeConfig(httpService, storageService, loadingService), {cacheKey: 'lanes'}, ); - httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'server-hash'})})); + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'fresh-hash'})})); - await expect(store.retrieveAll()).rejects.toThrow('network died'); + // Wait for the fire-and-forget trigger to settle. + await new Promise((resolve) => setTimeout(resolve, 0)); - expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', expect.anything()); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'fresh-hash'); }); - it('does NOT persist when retrieveAll succeeds but no server hash has been received yet', async () => { + it('response with hash differing from localHash (warm) → middleware fires inner once and updates persisted hash', async () => { const httpService = makeFakeHttpService(); - const storageService = makeStorageService(); + const storageService = makeStorageService({'lanes.cache-hash': 'old-hash'}); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); - const store = createCachedAdapterStoreModule( + createCachedAdapterStoreModule( makeConfig(httpService, storageService, loadingService), {cacheKey: 'lanes'}, ); - await store.retrieveAll(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'new-hash'})})); - // Discriminating assertion: if `if (serverHashSnapshot !== null)` - // were mutated to `if (true)`, storageService.put would be called - // with ('lanes.cache-hash', null). We assert it was NEVER called - // with that key at all. - expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', expect.anything()); - expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', null); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'new-hash'); }); - it('captures the current server hash at success time, not at receipt time', async () => { - // If a later response carries a different hash AFTER retrieveAll - // returns but the snapshot was taken correctly, the persisted hash - // matches the data that was actually retrieved. + it('response with hash equal to localHash → middleware does NOT fire inner.retrieveAll', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({'lanes.cache-hash': 'same-hash'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'same-hash'})})); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + + it('response with header missing entirely → middleware does NOT fire inner', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({'lanes.cache-hash': 'X'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'content-type': 'application/json'})); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + + it('response with header malformed (5a: wrong version prefix) → middleware does NOT fire inner', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({'lanes.cache-hash': 'X'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': `v2.${encodeURIComponent('{"lanes":"X"}')}`})); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + + it('response with header malformed (5b: truncated JSON) → middleware does NOT fire inner', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({'lanes.cache-hash': 'X'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":"X"')}`})); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + + it('response with header valid but missing our cacheKey (5c) → middleware does NOT fire inner', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService({'lanes.cache-hash': 'X'}); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({teams: 'X', users: 'X'})})); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + }); + + describe('prime + middleware race coordination', () => { + it('prime() is in flight and a mid-flight response with a different hash arrives → exactly ONE inner fetch (in-flight dedup is shared)', async () => { const httpService = makeFakeHttpService(); const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; - vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + let resolveGet!: (value: AxiosResponse) => void; + const pending = new Promise>((resolve) => { + resolveGet = resolve; + }); + vi.mocked(httpService.getRequest).mockReturnValue(pending); const store = createCachedAdapterStoreModule( makeConfig(httpService, storageService, loadingService), {cacheKey: 'lanes'}, ); - httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'A'})})); - await store.retrieveAll(); + // prime() kicks off the inner fetch (inflight set). + const primePromise = store.prime(); - // Persisted hash matches the in-memory currentServerHash at success time. - expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'A'); + // Mid-flight, a response arrives carrying a different hash. The + // middleware should observe the in-flight ref and skip firing a + // second fetch. + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'mid-flight-hash'})})); + + resolveGet({data: []} as AxiosResponse); + await primePromise; + // Drain any fire-and-forget tasks queued by the middleware. + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Once-per-burst contract: only ONE inner fetch fires for the + // overlapping prime() + mid-flight response. v1 simplification — + // a later mismatched response is the responsibility of the NEXT + // header to be observed. + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'mid-flight-hash'); }); }); - describe('in-flight deduplication', () => { - it('invokes inner.retrieveAll exactly once when called twice in rapid succession', async () => { + describe('persist-after-success timing', () => { + it('persists localHash to storageService only after inner.retrieveAll succeeds', async () => { const httpService = makeFakeHttpService(); const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + // Header arrives — middleware bumps currentServerHash AND triggers + // the inner fetch. At this synchronous point in test execution, + // the inner fetch promise has been created (the trigger called + // `inner.retrieveAll()`) but the inflight closure has not yet + // observed the resolution — but vi.mocked already returns a + // resolved promise, so persist may complete on a microtask. To + // assert the "ONLY after success" invariant cleanly, we hold the + // fetch in a pending state below. let resolveGet!: (value: AxiosResponse) => void; const pending = new Promise>((resolve) => { resolveGet = resolve; }); vi.mocked(httpService.getRequest).mockReturnValue(pending); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'server-hash'})})); + // Allow the fire-and-forget trigger to schedule the inner call. + await new Promise((resolve) => setTimeout(resolve, 0)); + // At this point, inner.retrieveAll is still pending — assert NO persist yet. + expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', expect.anything()); + + // Now resolve the inner fetch. Through prime() we also synchronize. + resolveGet({data: []} as AxiosResponse); + await store.prime(); + + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'server-hash'); + }); + + it('does NOT persist localHash when inner.retrieveAll rejects (prime path)', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockRejectedValue(new Error('network died')); const store = createCachedAdapterStoreModule( makeConfig(httpService, storageService, loadingService), {cacheKey: 'lanes'}, ); - const first = store.retrieveAll(); - const second = store.retrieveAll(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'server-hash'})})); - resolveGet({data: []} as AxiosResponse); - await Promise.all([first, second]); + // The middleware-fired trigger and prime() both share inflight. + // prime() awaits and surfaces the rejection. + await expect(store.prime()).rejects.toThrow('network died'); - expect(httpService.getRequest).toHaveBeenCalledTimes(1); + expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', expect.anything()); + }); + + it('does NOT persist localHash when inner.retrieveAll rejects (middleware path) — failing inner does not leave a persisted hash', async () => { + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockRejectedValue(new Error('boom')); + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + // Middleware-triggered fetch (fire-and-forget). The middleware path + // swallows the rejection internally via `.catch(() => {})`. + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'server-hash'})})); + + // Drain microtasks to let the inflight closure observe the rejection + // and the swallow handler run. + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', expect.anything()); }); - it('allows a fresh fetch after the previous inflight call settles', async () => { + it('does NOT persist when prime succeeds but no server hash has been received yet', async () => { const httpService = makeFakeHttpService(); const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; @@ -428,28 +614,34 @@ describe('createCachedAdapterStoreModule', () => { {cacheKey: 'lanes'}, ); - await store.retrieveAll(); - await store.retrieveAll(); + await store.prime(); - expect(httpService.getRequest).toHaveBeenCalledTimes(2); + // Discriminating assertion: if `if (serverHashSnapshot !== null)` + // were mutated to `if (true)`, storageService.put would be called + // with ('lanes.cache-hash', null). We assert it was NEVER called + // with that key at all. + expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', expect.anything()); + expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', null); }); - it('clears inflight on rejection so subsequent retries can fire', async () => { + it('captures the current server hash at success time, not at receipt time', async () => { + // If a later response carries a different hash AFTER prime returns + // but the snapshot was taken correctly, the persisted hash + // matches the data that was actually retrieved. const httpService = makeFakeHttpService(); const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; - vi.mocked(httpService.getRequest) - .mockRejectedValueOnce(new Error('first failed')) - .mockResolvedValueOnce({data: []} as AxiosResponse); + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); const store = createCachedAdapterStoreModule( makeConfig(httpService, storageService, loadingService), {cacheKey: 'lanes'}, ); - await expect(store.retrieveAll()).rejects.toThrow('first failed'); - await store.retrieveAll(); + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'A'})})); + await store.prime(); - expect(httpService.getRequest).toHaveBeenCalledTimes(2); + // Persisted hash matches the in-memory currentServerHash at success time. + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'A'); }); }); @@ -492,13 +684,13 @@ describe('createCachedAdapterStoreModule', () => { makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'lanes-hash', teams: 'teams-hash'})}), ); - await laneStore.retrieveAll(); - await teamStore.retrieveAll(); + await laneStore.prime(); + await teamStore.prime(); expect(httpService.getRequest).not.toHaveBeenCalled(); }); it('ignores unregistered cacheKeys without affecting any setter', async () => { - // Pins the `if (setter)` lookup in the iteration. If that check + // Pins the `if (handler)` lookup in the iteration. If that check // were flipped to `if (true)`, calling `undefined(hash)` would // throw, but the wrapper's try/catch would swallow it. The // discriminating assertion: a registered store's currentServerHash @@ -512,13 +704,13 @@ describe('createCachedAdapterStoreModule', () => { {cacheKey: 'lanes'}, ); // 'unknown' is NOT a registered cacheKey on this httpService. - // The middleware must skip it (setters.get returns undefined) and + // The middleware must skip it (handlers.get returns undefined) and // STILL process 'lanes'. httpService.deliver( makeResponse({'x-fs-cache-hashes': encodeHashHeader({unknown: 'whatever', lanes: 'matching'})}), ); - await store.retrieveAll(); + await store.prime(); // Registered key was applied → skip. expect(httpService.getRequest).not.toHaveBeenCalled(); @@ -544,16 +736,16 @@ describe('createCachedAdapterStoreModule', () => { }); }); - describe('parser branches (mutation-discriminating)', () => { + describe('parser branches (mutation-discriminating via prime())', () => { // Each test uses setupMalformDiscriminator: // - storage has localHash = 'X' - // - retrieveAll's `getRequest` is mocked - // - if parser correctly REJECTS the input → currentServerHash null - // → fetch (1 call to getRequest) - // - if parser INCORRECTLY accepts and sets currentServerHash = 'X' - // → skip (0 calls) - // The assertion `toHaveBeenCalledTimes(1)` therefore pins the rejection - // behavior of each parser branch. + // - inner getRequest is mocked + // - middleware sees a malformed header → parser returns null → + // no setter fires → currentServerHash stays null + // - prime() then fires (localHash 'X', currentServerHash null → + // not equal → fetch). 1 call to getRequest. + // - If parser INCORRECTLY accepts → currentServerHash 'X' === localHash 'X' + // → skip (0 calls). it('rejects header value when not a string (e.g., array form)', async () => { const {httpService, store} = setupMalformDiscriminator(); @@ -566,7 +758,7 @@ describe('createCachedAdapterStoreModule', () => { } as unknown as AxiosResponse; httpService.deliver(response); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -574,7 +766,7 @@ describe('createCachedAdapterStoreModule', () => { const {httpService, store} = setupMalformDiscriminator(); httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeURIComponent(JSON.stringify({lanes: 'X'}))})); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -584,7 +776,7 @@ describe('createCachedAdapterStoreModule', () => { makeResponse({'x-fs-cache-hashes': `v2.${encodeURIComponent(JSON.stringify({lanes: 'X'}))}`}), ); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -592,7 +784,7 @@ describe('createCachedAdapterStoreModule', () => { const {httpService, store} = setupMalformDiscriminator(); httpService.deliver(makeResponse({'x-fs-cache-hashes': 'v1.%E0%A4%A'})); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -600,7 +792,7 @@ describe('createCachedAdapterStoreModule', () => { const {httpService, store} = setupMalformDiscriminator(); httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":"X"')}`})); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -608,7 +800,7 @@ describe('createCachedAdapterStoreModule', () => { const {httpService, store} = setupMalformDiscriminator(); httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('null')}`})); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -616,7 +808,7 @@ describe('createCachedAdapterStoreModule', () => { const {httpService, store} = setupMalformDiscriminator(); httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('["lanes","X"]')}`})); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -624,7 +816,7 @@ describe('createCachedAdapterStoreModule', () => { const {httpService, store} = setupMalformDiscriminator(); httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('"X"')}`})); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -632,7 +824,7 @@ describe('createCachedAdapterStoreModule', () => { const {httpService, store} = setupMalformDiscriminator(); httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":42}')}`})); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -640,7 +832,7 @@ describe('createCachedAdapterStoreModule', () => { const {httpService, store} = setupMalformDiscriminator(); httpService.deliver(makeResponse({})); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -654,18 +846,19 @@ describe('createCachedAdapterStoreModule', () => { } as unknown as AxiosResponse; httpService.deliver(responseWithoutHeaders); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); it('rejects valid v1. payload missing the wrapper-registered cacheKey', async () => { // Pins the `if (map === null) return` early-return and the - // setter-key lookup: the map has well-formed keys but NOT 'lanes', - // so the setter for 'lanes' never fires. + // handler-key lookup: the map has well-formed keys but NOT 'lanes', + // so the handler for 'lanes' never fires → currentServerHash stays + // null → prime() fires. const {httpService, store} = setupMalformDiscriminator(); httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({teams: 'X', users: 'X'})})); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).toHaveBeenCalledTimes(1); }); @@ -673,48 +866,41 @@ describe('createCachedAdapterStoreModule', () => { // Positive control matching the negative tests above. With // localHash 'X' persisted and a valid v1. payload {lanes: 'X'}, // the parser correctly accepts → currentServerHash = 'X' → - // localHash === currentServerHash → skip → 0 calls. This - // discriminates "parser rejects everything" mutations from - // "parser accepts everything" mutations. + // localHash === currentServerHash → skip-when-equal short-circuits + // BOTH the middleware-triggered fetch AND the prime() call → 0 calls. const {httpService, store} = setupMalformDiscriminator(); httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'X'})})); - await store.retrieveAll(); + await store.prime(); expect(httpService.getRequest).not.toHaveBeenCalled(); }); }); describe('exception-safe response middleware (Architecture Lock #10)', () => { - it('5a: malformed v1 prefix → no throw; consumer caller-resolves cleanly', async () => { - const {httpService, store} = setupMalformDiscriminator(); + it('5a: malformed v1 prefix → no throw', () => { + const {httpService} = setupMalformDiscriminator(); expect(() => httpService.deliver( makeResponse({'x-fs-cache-hashes': `v2.${encodeURIComponent(JSON.stringify({lanes: 'X'}))}`}), ), ).not.toThrow(); - - await expect(store.retrieveAll()).resolves.toBeUndefined(); }); - it('5b: malformed JSON → no throw', async () => { - const {httpService, store} = setupMalformDiscriminator(); + it('5b: malformed JSON → no throw', () => { + const {httpService} = setupMalformDiscriminator(); expect(() => httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":"X"')}`})), ).not.toThrow(); - - await expect(store.retrieveAll()).resolves.toBeUndefined(); }); - it('5c: valid JSON but missing the cacheKey → no throw, no state change', async () => { - const {httpService, store} = setupMalformDiscriminator(); + it('5c: valid JSON but missing the cacheKey → no throw, no state change', () => { + const {httpService} = setupMalformDiscriminator(); expect(() => httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({teams: 'X', users: 'X'})})), ).not.toThrow(); - - await expect(store.retrieveAll()).resolves.toBeUndefined(); }); - it('5-success: valid v1. header for our cacheKey → currentServerHash updated; localHash persisted after retrieveAll succeeds', async () => { + it('5-success: valid v1. header for our cacheKey → currentServerHash updated; localHash persisted after middleware-triggered retrieveAll succeeds', async () => { const httpService = makeFakeHttpService(); const storageService = makeStorageService(); const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; @@ -725,15 +911,15 @@ describe('createCachedAdapterStoreModule', () => { ); httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'fresh-hash'})})); - expect(storageService.put).not.toHaveBeenCalledWith('lanes.cache-hash', 'fresh-hash'); - await store.retrieveAll(); + // Middleware triggers inner fetch; prime() rendezvous with it. + await store.prime(); expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'fresh-hash'); }); it('parser-null path returns early without entering the iteration (no debug log on no-signal)', () => { - // Discriminating line 156's `if (map === null) return`. If the + // Discriminating the `if (map === null) return` guard. If the // guard were flipped, the iteration body would execute against a // null map and Object.entries(null) would throw, caught by the // outer try/catch and surfaced as a debug log. We assert the @@ -776,5 +962,49 @@ describe('createCachedAdapterStoreModule', () => { expect.any(Error), ); }); + + it('inner.retrieveAll rejection on the middleware path does NOT propagate back through the middleware to abort the caller', async () => { + // The middleware path is fire-and-forget; a rejection inside + // `triggerInnerRetrieveAll` (i.e., inner.retrieveAll rejects) + // must NOT escape back through the middleware to the caller's + // request. We simulate by: + // 1. mocking inner.getRequest to reject + // 2. delivering a header that triggers the inner fetch + // 3. asserting the deliver call itself returned cleanly + // 4. draining microtasks and asserting no unhandled rejection + // surfaced (the `.catch(() => {})` swallow in the trigger + // ensures this) + const httpService = makeFakeHttpService(); + const storageService = makeStorageService(); + const loadingService: TestLoadingService = {ensureLoadingFinished: vi.fn().mockResolvedValue(undefined)}; + vi.mocked(httpService.getRequest).mockRejectedValue(new Error('inner exploded')); + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + // Track unhandled rejections during the test. + let unhandled: unknown = null; + const onUnhandled = (reason: unknown): void => { + unhandled = reason; + }; + process.on('unhandledRejection', onUnhandled); + + try { + expect(() => + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'fresh'})})), + ).not.toThrow(); + + // Drain microtasks several times to ensure the rejection has + // been processed by the swallow handler. + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(unhandled).toBeNull(); + } finally { + process.off('unhandledRejection', onUnhandled); + } + }); }); }); diff --git a/packages/cached-adapter-store/tests/types.spec.ts b/packages/cached-adapter-store/tests/types.spec.ts index 9939523..ea87f70 100644 --- a/packages/cached-adapter-store/tests/types.spec.ts +++ b/packages/cached-adapter-store/tests/types.spec.ts @@ -1,24 +1,29 @@ import type { Adapted, + Adapter, AdapterStoreConfig, Item, NewAdapted, StoreModuleForAdapter, } from '@script-development/fs-adapter-store'; +import type {HttpService} from '@script-development/fs-http'; +import type {LoadingService} from '@script-development/fs-loading'; +import type {StorageService} from '@script-development/fs-storage'; import {describe, expectTypeOf, it} from 'vitest'; -import type {CachedAdapterStoreOptions} from '../src/types'; +import type {CachedAdapterStoreOptions, CachedStoreModuleForAdapter} from '../src/types'; import {createCachedAdapterStoreModule} from '../src/cached-adapter-store'; /** - * Type-only assertions. These prove the wrapper's drop-in-compatibility - * invariant: a value produced by `createCachedAdapterStoreModule` - * is assignable to `StoreModuleForAdapter`. If this ever breaks - * (e.g., the wrapper accidentally widens or narrows the return type), - * vitest's `expectTypeOf` will surface the regression at typecheck time - * via the spec file's tsc pass. + * Type-only assertions. These prove the wrapper's intentionally NARROWER + * surface invariant: a value produced by `createCachedAdapterStoreModule` + * is assignable to `CachedStoreModuleForAdapter` AND is NOT assignable + * to `StoreModuleForAdapter`. If the wrapper ever accidentally widens + * its return shape to include `retrieveAll` / `retrieveById` again, the + * `@ts-expect-error` line below will go from "expected error" to "no error" and + * fail typecheck. */ interface DemoItem extends Item { id: number; @@ -29,7 +34,7 @@ type DemoAdapted = Adapted; type DemoNewAdapted = NewAdapted; describe('createCachedAdapterStoreModule type surface', () => { - it('returns StoreModuleForAdapter', () => { + it('returns CachedStoreModuleForAdapter', () => { expectTypeOf(createCachedAdapterStoreModule) .parameter(0) .toEqualTypeOf>(); @@ -37,11 +42,48 @@ describe('createCachedAdapterStoreModule type surface', () => { .parameter(1) .toEqualTypeOf(); expectTypeOf(createCachedAdapterStoreModule).returns.toEqualTypeOf< - StoreModuleForAdapter + CachedStoreModuleForAdapter >(); }); it('CachedAdapterStoreOptions has only cacheKey (no staleAfterMs, etc.)', () => { expectTypeOf().toEqualTypeOf<{cacheKey: string}>(); }); + + it('return type is NOT assignable to StoreModuleForAdapter', () => { + // The narrower CachedStoreModuleForAdapter intentionally lacks + // `retrieveAll` and `retrieveById`. Assigning to the wider type must + // fail typecheck. The `@ts-expect-error` directly below is the + // assertion — if a future refactor accidentally re-adds those keys to + // the public return, this directive becomes unused and tsc errors out + // ("Unused '@ts-expect-error' directive"), which fails CI. + // + // The body is guarded by `if (false)` so that the type-level + // assignment is type-checked but the runtime call into + // createCachedAdapterStoreModule (which would invoke the real + // adapter-store factory against an empty config) never executes. + if (false as boolean) { + const config = {} as AdapterStoreConfig; + const options = {cacheKey: 'x'} as CachedAdapterStoreOptions; + // @ts-expect-error — narrower than StoreModuleForAdapter (no retrieveAll / retrieveById) + const _wider: StoreModuleForAdapter = createCachedAdapterStoreModule< + DemoItem, + DemoAdapted, + DemoNewAdapted + >(config, options); + void _wider; + } + // Reference the surrounding library types so this spec file's import + // graph remains representative of a real consumer (and the type test + // doesn't get tree-shaken into nothing by an over-zealous linter + // tomorrow). These are pure type references — no runtime cost. + type _UnusedAdapter = Adapter; + type _UnusedHttp = HttpService; + type _UnusedLoading = LoadingService; + type _UnusedStorage = StorageService; + void (null as unknown as _UnusedAdapter); + void (null as unknown as _UnusedHttp); + void (null as unknown as _UnusedLoading); + void (null as unknown as _UnusedStorage); + }); }); From c02b56e7255c7d76ade759362efb0e76f22b40eb Mon Sep 17 00:00:00 2001 From: Gerard Oosterhof Date: Wed, 13 May 2026 22:17:57 +0200 Subject: [PATCH 5/5] docs(cached-adapter-store): rewrite README + territory CLAUDE.md row for the narrowed public surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit README — three sections rewritten per the deployment order: 1. Usage section — removes the "same shape as createAdapterStoreModule" / "drop-in replacement" claim. Replaces with a paragraph explaining the narrower surface (getAll, getById, getOrFailById, generateNew, prime), why retrieveAll and retrieveById are intentionally absent, and the rule of thumb (`call prime() once at init; trust the middleware for everything else`). Adds a sentence noting that the new `CachedStoreModuleForAdapter` is NOT structurally assignable to `StoreModuleForAdapter` — enforced at the type level. 2. Protocol section — bullet 3 rewritten: was "At retrieveAll() time, compares the local hash …", now "On every response carrying the header, the middleware updates the in-memory currentServerHash for each matching cacheKey, AND triggers an internal inner.retrieveAll() if localHash !== currentServerHash. prime() covers the cold-start path where no header has yet been observed." Persist-after-success language preserved verbatim. 3. End-of-Protocol retrieveById sentence — was "The wrapper does NOT wrap retrieveById in v1 — that method is passed through unchanged", now "The wrapper does NOT expose retrieveById. The hash-bumping protocol is all-or-nothing — single-item retrieval would break the invariant that localHash describes the data currently in state. If you need per-id retrieval semantics, the cached wrapper is the wrong tool — use createAdapterStoreModule directly." Wrapper invariants — updated `In-flight deduplication` bullet to describe prime() / middleware-trigger sharing one underlying promise. Updated `Throw isolation` bullet to mention the new fire-and-forget middleware-trigger contract (top-level .catch swallows the async rejection so unhandled-rejection logs don't fire on transient inner failures). Territory root CLAUDE.md (packages table, 11th row): - Old: `Higher-order factory wrapping fs-adapter-store with hash-bumping cache-check that suppresses redundant GETs` - New: `Hash-bumping cache wrapper around fs-adapter-store; middleware-driven invalidation with prime() bootstrap; no retrieveAll/retrieveById on the public surface` The new description is verbatim per the deployment order. Versioning Discipline cascade section unchanged (the package's caret-cascade peers are unchanged). Out of scope (deferred to follow-on dispatches per the order's §"Out of scope"): - No fs-http changes. - No Kendo adoption work. - No Emmie adoption work. - No release-cut decision. - No new top-level CI gates. --- CLAUDE.md | 2 +- packages/cached-adapter-store/README.md | 28 ++++++++++++++++++------- 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 146df2a..57fcfca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,7 +39,7 @@ Consumer territories must apply per-call timeouts at instantiation OR rely on th | fs-theme | Yes | Reactive dark/light mode with storage persistence | | fs-loading | Yes | Loading state service with HTTP middleware | | fs-adapter-store | Yes | Reactive adapter-store pattern with CRUD resource adapters | -| fs-cached-adapter-store | Yes | Higher-order factory wrapping fs-adapter-store with hash-bumping cache-check that suppresses redundant GETs | +| fs-cached-adapter-store | Yes | Hash-bumping cache wrapper around fs-adapter-store; middleware-driven invalidation with prime() bootstrap; no retrieveAll/retrieveById on the public surface | | fs-toast | Yes | Component-agnostic toast queue (FIFO) | | fs-dialog | Yes | Component-agnostic dialog stack (LIFO) with error middleware | | fs-translation | Yes | Type-safe reactive i18n with dot-notation keys | diff --git a/packages/cached-adapter-store/README.md b/packages/cached-adapter-store/README.md index 0246187..cd2036c 100644 --- a/packages/cached-adapter-store/README.md +++ b/packages/cached-adapter-store/README.md @@ -28,9 +28,23 @@ const lanesStore = createCachedAdapterStoreModule( }, {cacheKey: `projects/${projectId}/lanes`}, ); + +// Public surface: +lanesStore.getAll; // ComputedRef +lanesStore.getById(id); // ComputedRef +lanesStore.getOrFailById(id); // Promise +lanesStore.generateNew(); // NewLane +await lanesStore.prime(); // bootstrap (idempotent) ``` -The returned module has the **same shape** as `createAdapterStoreModule`'s `StoreModuleForAdapter` — a drop-in replacement at every call site. +The returned module is **intentionally narrower** than `createAdapterStoreModule`'s `StoreModuleForAdapter`. It exposes `getAll`, `getById`, `getOrFailById`, `generateNew`, and a single bootstrap entry point `prime()`. The two retrieval methods that `createAdapterStoreModule` returns — `retrieveAll` and `retrieveById` — are **deliberately absent** from the public surface: + +- **`retrieveAll` is gone** because every ad-hoc consumer-driven `retrieveAll()` call is a potential 429 — the response middleware that observes the cache-hash header is the sole steady-state trigger of the inner fetch. Consumers no longer get to decide "when do we fetch"; the wrapper owns it. +- **`retrieveById` is gone** because the hash-bumping protocol invalidates a collection wholesale. A store that lets you top up with single-item fetches breaks the invariant that `localHash` describes the contents of `state`. If you need per-id retrieval semantics, the cached wrapper is the wrong tool — use `createAdapterStoreModule` directly. + +**Rule of thumb:** call `prime()` once at the consumer's preferred initialization point (app boot, route enter, root component setup) to guarantee the data is loaded even before the first response stamps a cache-hash header on this tab. Trust the middleware for everything else. + +The returned type is `CachedStoreModuleForAdapter`; it is **not** structurally assignable to `StoreModuleForAdapter`. This is enforced at the type level — attempting that assignment is a compile-time error. ## Options @@ -51,13 +65,13 @@ x-fs-cache-hashes: v1. where the JSON is a flat `{cacheKey: hashString}` map. The wrapper: 1. Parses the header on every response that carries it. -2. Updates an in-memory `currentServerHash` for each `cacheKey` matching a registered wrapper instance. -3. At `retrieveAll()` time, compares the **local hash** (hydrated from `storageService` at construction) against `currentServerHash`. If both are non-null and equal, the inner `retrieveAll()` is skipped entirely. +2. On every response carrying the header, the middleware updates the in-memory `currentServerHash` for each matching cacheKey, AND triggers an internal `inner.retrieveAll()` if `localHash !== currentServerHash` (fire-and-forget; in-flight-deduped; skip-if-equal). +3. `prime()` covers the cold-start path where no header has yet been observed on this tab. It is idempotent: two rapid calls dedupe to a single inner fetch, and once a successful retrieve has completed with `localHash !== null`, subsequent `prime()` calls return immediately without invoking inner. 4. After every successful inner `retrieveAll()`, the current server hash is snapshotted into both the in-memory local hash and `storageService` — never before. -The strict `v1.` version prefix is non-negotiable. A header value not starting with `v1.` is treated as no-signal (fallthrough to fetch). This is intentional: every response stamped with this header is contractually opting into the v1 wire format. +The strict `v1.` version prefix is non-negotiable. A header value not starting with `v1.` is treated as no-signal (no trigger, no state change). This is intentional: every response stamped with this header is contractually opting into the v1 wire format. -The wrapper does NOT wrap `retrieveById` in v1 — that method is passed through unchanged. The 429 incident that motivated this package is driven by `retrieveAll`; per-id caching is future work. +The wrapper does NOT expose `retrieveById`. The hash-bumping protocol is all-or-nothing — single-item retrieval would break the invariant that `localHash` describes the data currently in `state`. If you need per-id retrieval semantics, the cached wrapper is the wrong tool — use `createAdapterStoreModule` directly. ## Operational notes @@ -77,8 +91,8 @@ Per war-room ADR-0011 (Action Class Architecture, cross-project), the backend mu The wrapper is designed against `fs-http`'s response-middleware contract as documented in the 2026-05-13 Surveyor middleware-invariants report: -- **Throw isolation.** fs-http does not isolate middleware throws — a synchronous throw inside a middleware aborts response delivery to the caller. The wrapper's response middleware body is wrapped in `try/catch` so a malformed header (un-decodable URI, malformed JSON) cannot poison the caller's request. -- **In-flight deduplication.** Two `retrieveAll()` calls in rapid succession invoke the inner `retrieveAll` exactly once and resolve from the same underlying promise. +- **Throw isolation.** fs-http does not isolate middleware throws — a synchronous throw inside a middleware aborts response delivery to the caller. The wrapper's response middleware body is wrapped in `try/catch` so a malformed header (un-decodable URI, malformed JSON) cannot poison the caller's request. The middleware-triggered `inner.retrieveAll()` is fire-and-forget; an async rejection is contained inside the in-flight closure's try/finally and a top-level `.catch(() => {})` ensures no unhandled rejection escapes. +- **In-flight deduplication.** A `prime()` call and a middleware-triggered fetch in flight at the same time share one underlying promise. Two rapid `prime()` calls likewise resolve to one inner fetch. - **Idempotent middleware registration.** Multiple wrapper instances sharing one `httpService` register exactly one response middleware between them. Header parsing happens once per response, regardless of how many wrappers are listening. ## Compatibility