Skip to content

feat(cached-adapter-store): scaffold fs-cached-adapter-store package (Phase 2 of cached-store protocol campaign)#85

Open
Goosterhof wants to merge 5 commits into
mainfrom
fs-cached-adapter-store-scaffold
Open

feat(cached-adapter-store): scaffold fs-cached-adapter-store package (Phase 2 of cached-store protocol campaign)#85
Goosterhof wants to merge 5 commits into
mainfrom
fs-cached-adapter-store-scaffold

Conversation

@Goosterhof
Copy link
Copy Markdown
Contributor

@Goosterhof Goosterhof commented May 13, 2026

Summary

Scaffolds a new fs-package @script-development/fs-cached-adapter-store — a higher-order factory wrapping fs-adapter-store with a hash-bumping cache-check that suppresses redundant retrieveAll GETs at source. This is the frontend half of the cross-territory hash-bumping protocol campaign (campaigns/cross-territory/2026-05-13-request-suppression-vs-cheapening-caching-tactic.md).

The package is a sibling to fs-adapter-store; it does not modify it. Adapter-store consumers who do not opt in are unaffected.

Architecture locks (Commander 2026-05-13)

  1. Higher-order factory composition (Design A) — internally calls createAdapterStoreModule(config).
  2. Factory named createCachedAdapterStoreModule (fs-packages create* convention).
  3. Header name x-fs-cache-hashes (clean break from Emmie's incumbent).
  4. Header value shape v1.<urlencoded JSON> flat map.
  5. Strict v1. version prefix; non-v1 values treated as no-signal.
  6. No staleAfterMs knob — hash-or-fetch only.
  7. No onMissingServerHash knob — hard-coded 'fetch' behavior.
  8. No hashExtractor knob — hard-coded header reader.
  9. No hashStorageKey knob — hard-coded ${cacheKey}.cache-hash.
  10. Wrapper middleware body wrapped in internal try/catch (fs-http does NOT isolate middleware throws — Surveyor 2026-05-13 §Q7/Q8/Q9).
  11. REVERSED 2026-05-13 — was: "No retrieveById wrapping in v1 — passthrough only." Now: retrieveById is REMOVED from the public surface. Consumers of the cached wrapper cannot call it.
  12. NEW 2026-05-13retrieveAll is REMOVED from the public surface. Consumer code cannot call it. The response middleware is the sole steady-state trigger of inner.retrieveAll.
  13. NEW 2026-05-13prime(): Promise<void> is the only consumer-facing fetch entry point. It is idempotent, in-flight-deduped against middleware-driven retrieveAlls, and a no-op if localHash !== null AND inner.retrieveAll has already completed at least once in this session.

Architecture Lock revisions (Commander 2026-05-13)

This PR's history reflects a course correction made on the same day the scaffold landed. The initial scaffold (commit eb749d4) honored Lock #11 in its original form (retrieveById passthrough). Commander reversed that decision and added two new locks (#12 and #13) — captured in orders/fs-packages/fs-cached-adapter-store-public-surface-narrowing-engineer-deployment.md. The amending commits (e91ae9f, 71ce62a, 7aae266) implement the reversal: the public surface is intentionally narrower than StoreModuleForAdapter, and the wrapper now owns "when do we fetch" entirely.

Rationale — eliminating retrieveAll from the public surface is the highest-leverage 429 mitigation. Real consumer code paths call retrieveAll() at moments where the wrapper's hash-skip cannot kick in (currentServerHash is still null, prior-session localHash hasn't been re-confirmed, mount/route guard fires before any bootstrap response returns). Each of those produces an inner fetch; N stores × M components mounted in burst hit the rate-limit ceiling. The fix is to remove the ability to fire ad-hoc fetches at all. retrieveById is a category error on a cached store — the hash-bumping protocol invalidates a collection wholesale; a "top up with single-item fetch" call breaks the invariant that localHash describes the contents of state.

Public API

import {createCachedAdapterStoreModule} from '@script-development/fs-cached-adapter-store';

const lanesStore = createCachedAdapterStoreModule<LaneBase, Lane, NewLane>(
    {domainName, adapter, httpService, storageService, loadingService, broadcast},
    {cacheKey: `projects/${projectId}/lanes`},
);

// Consumer surface:
lanesStore.getAll;            // ComputedRef<Lane[]>
lanesStore.getById(id);       // ComputedRef<Lane | undefined>
lanesStore.getOrFailById(id); // Promise<Lane>
lanesStore.generateNew();     // NewLane
await lanesStore.prime();     // bootstrap (idempotent)

// Not on the public surface — consumer code MUST NOT call:
// lanesStore.retrieveAll  // gone
// lanesStore.retrieveById // gone

Returns CachedStoreModuleForAdapter<T, E, N> — a strictly narrower type than StoreModuleForAdapter<T, E, N>. The new type is intentionally NOT assignable to StoreModuleForAdapter<T, E, N>; attempting the assignment is a compile-time error (pinned by @ts-expect-error in tests/types.spec.ts).

Internal behavior

  • At construction: registers a response middleware on config.httpService (idempotent across multiple wrappers sharing one service via a module-level WeakMap<HttpService, ...> registry). Hydrates localHash from storageService.get(\${cacheKey}.cache-hash`)`.
  • On every response carrying x-fs-cache-hashes: the wrapper's per-cacheKey handler does TWO things — (a) updates currentServerHash for the matching cacheKey, (b) fire-and-forget calls into triggerInnerRetrieveAll(). The trigger inherits in-flight dedup and skip-when-equal (an equal observed hash is short-circuited internally; a header that arrives mid-retrieve doesn't double-fire).
  • prime() (cold-start): idempotent bootstrap. Short-circuits when hasCompletedAtLeastOnce && localHash.value !== null; otherwise delegates to triggerInnerRetrieveAll() and awaits its resolution. Surfaces failures to the caller (consumers may handle them).
  • In-flight dedup: 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.
  • Persist-after-success: the local hash is written to storageService ONLY after inner.retrieveAll() succeeds — never on response receipt. Failing inner does not leave a persisted hash, regardless of which entry point (prime or middleware) initiated the fetch.
  • Throw isolation: the response middleware body is wrapped in try/catch so a malformed header cannot poison the caller's request. The middleware-triggered inner.retrieveAll() is fire-and-forget; async rejection is contained inside the in-flight closure's try/finally and a top-level .catch(() => {}) ensures no unhandled rejection escapes back to the caller.

Verification (all gates green locally)

  • Standard 8 fs-packages CI gates — audit, format, lint, build, typecheck, lint:pkg, coverage, mutation. All pass.
  • 100% coverage on the wrapper's source (Statements 100% 76/76, Branches 100% 29/29, Functions 100% 8/8, Lines 100% 68/68).
  • 94.81% mutation score on cached-adapter-store.ts (≥90% threshold; 4 surviving mutants are equivalent — two parser try/catch fall-throughs where dropping return null cascades into the next guard's null, and two on the hasCompletedAtLeastOnce / prime() early-return optimization, which is observationally indistinguishable from skip-when-equal in steady state).
  • Type test: tests/types.spec.ts asserts createCachedAdapterStoreModule<A,B,C>(...) returns CachedStoreModuleForAdapter<A,B,C> via expectTypeOf, AND asserts (via @ts-expect-error) that the result is NOT assignable to StoreModuleForAdapter<A,B,C>.
  • Public API surface tests: expect(store).not.toHaveProperty('retrieveAll'), same for retrieveById; Object.keys(store) is exactly the five expected names.
  • prime() behavior tests: cold-start fires once; in-sync no-ops; mismatch persists; idempotency dedupes; post-success short-circuits.
  • Middleware-driven trigger tests: mismatch fires once; equal no-ops; malformed/missing header no-ops.
  • prime + middleware race coordination: mid-flight response shares the in-flight ref (v1 once-per-burst contract documented).
  • Persist-after-success tests: verified for both prime() and middleware paths, success and rejection branches.
  • Exception-safe middleware: existing 5a/5b/5c contract-pin tests preserved; NEW assertion that an inner.retrieveAll rejection on the middleware path does NOT escape as an unhandled rejection.

Total: 80 passing tests across 3 spec files (cached-adapter-store.spec.ts, parser.spec.ts, types.spec.ts) — +11 over the scaffold baseline of 69.

Caret-cascade tax registration

CLAUDE.md updated:

  • 11th row in the Packages table for fs-cached-adapter-store now reads: Hash-bumping cache wrapper around fs-adapter-store; middleware-driven invalidation with prime() bootstrap; no retrieveAll/retrieveById on the public surface.
  • Versioning Discipline section's cascade peers list unchanged from scaffold.

Documentation

README rewritten in three sections per the order:

  1. Usage — removes the "drop-in replacement" / "same shape as createAdapterStoreModule" claim; explains the narrower surface (5 keys) and why retrieveAll/retrieveById are intentionally absent; gives the rule of thumb: call prime() once at init; trust the middleware for everything else.
  2. Protocol — bullet 3 now describes middleware-driven invalidation; bullet 4 describes prime()'s role.
  3. End-of-Protocol retrieveById sentence — replaced with "the wrapper does NOT expose retrieveById…use createAdapterStoreModule directly."

README also ships three operational notes (unchanged from scaffold): tenancy is the consumer's responsibility (via storageService prefix); cancellation is fs-http's responsibility (war-room enforcement queue #62); backend bump semantics live in Actions per war-room ADR-0011.

Out of scope (per order)

  • NO fs-http hardening (R2/R3/R5 from Surveyor 2026-05-13 verdict).
  • NO Emmie adoption work (separate campaign per Path C lock).
  • NO Kendo frontend changes (next Engineer order, gated on this PR merging).
  • NO new top-level CI gates.
  • NO publish on merge — release-cut is a separate decision after the Kendo frontend pilot lands.

Notable implementation detail

AxiosResponse is imported via Parameters<ResponseMiddlewareFunc>[0] (re-exported from fs-http) rather than direct import from axios. Axios's CJS d.ts ships its types under a nested namespace which causes rolldown's d.cts emission to fail with a MISSING_EXPORT error on direct named imports. fs-http's ResponseMiddlewareFunc re-export sidesteps the issue cleanly.

Test plan

  • All 8 fs-packages CI gates pass locally
  • 100% coverage on src/cached-adapter-store.ts + src/types.ts
  • 94.81% mutation score (≥90% threshold) — 4 equivalent-mutant survivors documented
  • Contract-pin tests for 5a/5b/5c/success path
  • Type-shape test verifies narrowing — return type is CachedStoreModuleForAdapter<T, E, N> and is NOT assignable to StoreModuleForAdapter<T, E, N> (@ts-expect-error pinned)
  • In-flight dedup + idempotent middleware tests
  • Public-API surface tests assert retrieveAll and retrieveById are absent from the returned module
  • prime() behavior tests cover cold-start, in-sync, mismatch, idempotency, post-success
  • Middleware-driven trigger tests cover fire-once on mismatch, no-fire on equal, no-fire on malformed/missing header
  • Persist-after-success verified for both prime() and middleware paths
  • Middleware-path async rejection does NOT escape as unhandled rejection
  • CI run on PR confirms green
  • Next: Kendo frontend pilot consumes the package (separate dispatch)

…(Phase 2 of cached-store protocol campaign)

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) <noreply@anthropic.com>
@cloudflare-workers-and-pages
Copy link
Copy Markdown

cloudflare-workers-and-pages Bot commented May 13, 2026

Deploying fs-packages with  Cloudflare Pages  Cloudflare Pages

Latest commit: 7aae266
Status: ✅  Deploy successful!
Preview URL: https://906e8421.fs-packages.pages.dev
Branch Preview URL: https://fs-cached-adapter-store-scaf.fs-packages.pages.dev

View logs

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<ResponseMiddlewareFunc>[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) <noreply@anthropic.com>
@Goosterhof
Copy link
Copy Markdown
Contributor Author

Niet mergen. Ben er niet helemaal mee eens met deze opzet. Nog even verder over nadenken

…ll/retrieveById, add prime() (Locks #11 reversed + #12/#13)

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<T, E, N>` in src/types.ts captures the narrower surface. The factory's return type changes from `StoreModuleForAdapter<T, E, N>` to `CachedStoreModuleForAdapter<T, E, N>`. The new type is intentionally NOT assignable to `StoreModuleForAdapter<T, E, N>` (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.
…iddleware-driven trigger

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<DemoItem, DemoAdapted, DemoNewAdapted>` (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).
…for the narrowed public surface

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<T, E, N>` is NOT structurally assignable to `StoreModuleForAdapter<T, E, N>` — 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant