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
Open
Conversation
…(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>
Deploying fs-packages with
|
| 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 |
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>
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Scaffolds a new fs-package
@script-development/fs-cached-adapter-store— a higher-order factory wrappingfs-adapter-storewith a hash-bumping cache-check that suppresses redundantretrieveAllGETs 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)
createAdapterStoreModule(config).createCachedAdapterStoreModule(fs-packagescreate*convention).x-fs-cache-hashes(clean break from Emmie's incumbent).v1.<urlencoded JSON>flat map.v1.version prefix; non-v1 values treated as no-signal.staleAfterMsknob — hash-or-fetch only.onMissingServerHashknob — hard-coded'fetch'behavior.hashExtractorknob — hard-coded header reader.hashStorageKeyknob — hard-coded${cacheKey}.cache-hash.retrieveByIdwrapping in v1 — passthrough only." Now:retrieveByIdis REMOVED from the public surface. Consumers of the cached wrapper cannot call it.retrieveAllis REMOVED from the public surface. Consumer code cannot call it. The response middleware is the sole steady-state trigger ofinner.retrieveAll.prime(): Promise<void>is the only consumer-facing fetch entry point. It is idempotent, in-flight-deduped against middleware-driven retrieveAlls, and a no-op iflocalHash !== nullANDinner.retrieveAllhas 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 (retrieveByIdpassthrough). Commander reversed that decision and added two new locks (#12 and #13) — captured inorders/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 thanStoreModuleForAdapter, and the wrapper now owns "when do we fetch" entirely.Rationale — eliminating
retrieveAllfrom the public surface is the highest-leverage 429 mitigation. Real consumer code paths callretrieveAll()at moments where the wrapper's hash-skip cannot kick in (currentServerHashis stillnull, prior-sessionlocalHashhasn'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.retrieveByIdis 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 thatlocalHashdescribes the contents ofstate.Public API
Returns
CachedStoreModuleForAdapter<T, E, N>— a strictly narrower type thanStoreModuleForAdapter<T, E, N>. The new type is intentionally NOT assignable toStoreModuleForAdapter<T, E, N>; attempting the assignment is a compile-time error (pinned by@ts-expect-errorintests/types.spec.ts).Internal behavior
config.httpService(idempotent across multiple wrappers sharing one service via a module-levelWeakMap<HttpService, ...>registry). HydrateslocalHashfromstorageService.get(\${cacheKey}.cache-hash`)`.x-fs-cache-hashes: the wrapper's per-cacheKey handler does TWO things — (a) updatescurrentServerHashfor the matching cacheKey, (b) fire-and-forget calls intotriggerInnerRetrieveAll(). 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 whenhasCompletedAtLeastOnce && localHash.value !== null; otherwise delegates totriggerInnerRetrieveAll()and awaits its resolution. Surfaces failures to the caller (consumers may handle them).prime()call and a middleware-triggered fetch in flight at the same time share one underlying promise. Two rapidprime()calls likewise resolve to one inner fetch.storageServiceONLY afterinner.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.try/catchso a malformed header cannot poison the caller's request. The middleware-triggeredinner.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)
cached-adapter-store.ts(≥90% threshold; 4 surviving mutants are equivalent — two parser try/catch fall-throughs where droppingreturn nullcascades into the next guard's null, and two on thehasCompletedAtLeastOnce/prime()early-return optimization, which is observationally indistinguishable from skip-when-equal in steady state).tests/types.spec.tsassertscreateCachedAdapterStoreModule<A,B,C>(...)returnsCachedStoreModuleForAdapter<A,B,C>viaexpectTypeOf, AND asserts (via@ts-expect-error) that the result is NOT assignable toStoreModuleForAdapter<A,B,C>.expect(store).not.toHaveProperty('retrieveAll'), same forretrieveById;Object.keys(store)is exactly the five expected names.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.mdupdated:fs-cached-adapter-storenow reads: Hash-bumping cache wrapper around fs-adapter-store; middleware-driven invalidation with prime() bootstrap; no retrieveAll/retrieveById on the public surface.Documentation
README rewritten in three sections per the order:
retrieveAll/retrieveByIdare intentionally absent; gives the rule of thumb: call prime() once at init; trust the middleware for everything else.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)
Notable implementation detail
AxiosResponseis imported viaParameters<ResponseMiddlewareFunc>[0](re-exported from fs-http) rather than direct import fromaxios. Axios's CJS d.ts ships its types under a nested namespace which causes rolldown's d.cts emission to fail with aMISSING_EXPORTerror on direct named imports. fs-http'sResponseMiddlewareFuncre-export sidesteps the issue cleanly.Test plan
CachedStoreModuleForAdapter<T, E, N>and is NOT assignable toStoreModuleForAdapter<T, E, N>(@ts-expect-errorpinned)retrieveAllandretrieveByIdare absent from the returned module