diff --git a/CLAUDE.md b/CLAUDE.md index 3132c18..57fcfca 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 | 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 | +| fs-router | Yes | Type-safe router service factory with CRUD navigation, middleware pipeline, and custom components for Vue Router | ## Conventions @@ -52,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 @@ -61,7 +63,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 +73,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..cd2036c --- /dev/null +++ b/packages/cached-adapter-store/README.md @@ -0,0 +1,104 @@ +# @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`}, +); + +// Public surface: +lanesStore.getAll; // ComputedRef +lanesStore.getById(id); // ComputedRef +lanesStore.getOrFailById(id); // Promise +lanesStore.generateNew(); // NewLane +await lanesStore.prime(); // bootstrap (idempotent) +``` + +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 + +```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. 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 (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 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 + +### 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. 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 + +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..b78b5cb --- /dev/null +++ b/packages/cached-adapter-store/src/cached-adapter-store.ts @@ -0,0 +1,305 @@ +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, CachedStoreModuleForAdapter} 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 scaffold 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` -> 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 + * handler to the existing map. + * - Otherwise, install a single response middleware on the `httpService` + * that iterates the map and dispatches to the right handler. + */ +type CacheKeyHandler = (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 (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 + * 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. + * + * **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-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, + E extends Adapted = Adapted, + N extends NewAdapted = NewAdapted, +>( + config: AdapterStoreConfig, + options: CachedAdapterStoreOptions, +): CachedStoreModuleForAdapter => { + 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); + + 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 handlers — 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 existingHandlers = httpServiceRegistry.get(httpServiceAsRegistryKey); + if (existingHandlers) { + existingHandlers.set(cacheKey, handleObservedHash); + } else { + 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 handler = handlers.get(key); + if (handler) handler(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); + } + }); + } + + /** + * 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 { + getAll: inner.getAll, + getById: inner.getById, + getOrFailById: inner.getOrFailById, + generateNew: inner.generateNew, + prime, + }; +}; diff --git a/packages/cached-adapter-store/src/index.ts b/packages/cached-adapter-store/src/index.ts new file mode 100644 index 0000000..e239fdf --- /dev/null +++ b/packages/cached-adapter-store/src/index.ts @@ -0,0 +1,2 @@ +export {createCachedAdapterStoreModule} from './cached-adapter-store'; +export type {CachedAdapterStoreOptions, CachedStoreModuleForAdapter} 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..3f84ffa --- /dev/null +++ b/packages/cached-adapter-store/src/types.ts @@ -0,0 +1,59 @@ +import type {Adapted, Item, NewAdapted} from '@script-development/fs-adapter-store'; +import type {ComputedRef} from 'vue'; + +/** + * 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; +}; + +/** + * 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; +}; 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..1e08dd1 --- /dev/null +++ b/packages/cached-adapter-store/tests/cached-adapter-store.spec.ts @@ -0,0 +1,1010 @@ +// @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 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' + * → 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 (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)}; + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + 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('does NOT expose retrieveAll 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('retrieveAll'); + }); + + 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'); + }); + + 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('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)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + await store.prime(); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + expect(httpService.getRequest).toHaveBeenCalledWith('lanes'); + }); + + 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)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + await store.prime(); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('already in sync (localHash === header hash, both non-null) → prime() does NOT fire inner', 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'})})); + + // 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('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)}; + 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'})})); + + // 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('idempotency: two rapid prime() calls → exactly one inner fetch (in-flight dedup)', 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.prime(); + const second = store.prime(); + + resolveGet({data: []} as AxiosResponse); + await Promise.all([first, second]); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('post-success no-op: after one successful prime(), a second prime() returns immediately without invoking inner', 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'}, + ); + + // 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); + + // 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); + }); + + 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)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + await store.prime(); + await store.prime(); + + expect(httpService.getRequest).toHaveBeenCalledTimes(2); + }); + }); + + 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).mockResolvedValue({data: []} as AxiosResponse); + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'fresh-hash'})})); + + // Wait for the fire-and-forget trigger to settle. + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'fresh-hash'); + }); + + it('response with hash differing from localHash (warm) → middleware fires inner once and updates persisted hash', 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); + createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'new-hash'})})); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'new-hash'); + }); + + 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)}; + 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'}, + ); + + // prime() kicks off the inner fetch (inflight set). + const primePromise = store.prime(); + + // 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('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'}, + ); + + httpService.deliver(makeResponse({'x-fs-cache-hashes': encodeHashHeader({lanes: 'server-hash'})})); + + // The middleware-fired trigger and prime() both share inflight. + // prime() awaits and surfaces the rejection. + await expect(store.prime()).rejects.toThrow('network died'); + + 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('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)}; + vi.mocked(httpService.getRequest).mockResolvedValue({data: []} as AxiosResponse); + const store = createCachedAdapterStoreModule( + makeConfig(httpService, storageService, loadingService), + {cacheKey: 'lanes'}, + ); + + await store.prime(); + + // 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 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).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.prime(); + + // Persisted hash matches the in-memory currentServerHash at success time. + expect(storageService.put).toHaveBeenCalledWith('lanes.cache-hash', 'A'); + }); + }); + + 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.prime(); + await teamStore.prime(); + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + + it('ignores unregistered cacheKeys without affecting any setter', async () => { + // 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 + // 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 (handlers.get returns undefined) and + // STILL process 'lanes'. + httpService.deliver( + makeResponse({'x-fs-cache-hashes': encodeHashHeader({unknown: 'whatever', lanes: 'matching'})}), + ); + + await store.prime(); + + // 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 via prime())', () => { + // Each test uses setupMalformDiscriminator: + // - storage has localHash = 'X' + // - 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(); + 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.prime(); + 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.prime(); + 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.prime(); + 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.prime(); + 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.prime(); + 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.prime(); + 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.prime(); + 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.prime(); + 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.prime(); + expect(httpService.getRequest).toHaveBeenCalledTimes(1); + }); + + it('rejects absent x-fs-cache-hashes header entirely', async () => { + const {httpService, store} = setupMalformDiscriminator(); + httpService.deliver(makeResponse({})); + + await store.prime(); + 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.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 + // 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.prime(); + 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-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.prime(); + expect(httpService.getRequest).not.toHaveBeenCalled(); + }); + }); + + describe('exception-safe response middleware (Architecture Lock #10)', () => { + 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(); + }); + + it('5b: malformed JSON → no throw', () => { + const {httpService} = setupMalformDiscriminator(); + expect(() => + httpService.deliver(makeResponse({'x-fs-cache-hashes': `v1.${encodeURIComponent('{"lanes":"X"')}`})), + ).not.toThrow(); + }); + + 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(); + }); + + 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)}; + 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'})})); + + // 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 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 + // 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), + ); + }); + + 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/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..ea87f70 --- /dev/null +++ b/packages/cached-adapter-store/tests/types.spec.ts @@ -0,0 +1,89 @@ +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, CachedStoreModuleForAdapter} from '../src/types'; + +import {createCachedAdapterStoreModule} from '../src/cached-adapter-store'; + +/** + * 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; + name: string; +} + +type DemoAdapted = Adapted; +type DemoNewAdapted = NewAdapted; + +describe('createCachedAdapterStoreModule type surface', () => { + it('returns CachedStoreModuleForAdapter', () => { + expectTypeOf(createCachedAdapterStoreModule) + .parameter(0) + .toEqualTypeOf>(); + expectTypeOf(createCachedAdapterStoreModule) + .parameter(1) + .toEqualTypeOf(); + expectTypeOf(createCachedAdapterStoreModule).returns.toEqualTypeOf< + 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); + }); +}); 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}, + }, + }, +});