@script-development/fs-cached-adapter-store
v0.1.1
Published
Higher-order factory wrapping @script-development/fs-adapter-store with hash-bumping cache-check that suppresses redundant retrieveAll GETs at source
Readme
@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
npm install @script-development/fs-cached-adapter-storePeer dependencies: @script-development/fs-adapter-store, @script-development/fs-http, @script-development/fs-storage, vue.
Usage
import {createCachedAdapterStoreModule} from '@script-development/fs-cached-adapter-store';
const lanesStore = createCachedAdapterStoreModule<LaneBase, Lane, NewLane>(
{
domainName: `projects/${projectId}/lanes`,
adapter: makeLaneAdapterForProject(projectId),
httpService,
storageService,
loadingService,
broadcast: makeLaneBroadcastForProject(projectId),
},
{cacheKey: `projects/${projectId}/lanes`},
);
// Public surface:
lanesStore.getAll; // ComputedRef<Lane[]>
lanesStore.getById(id); // ComputedRef<Lane | undefined>
lanesStore.getOrFailById(id); // Promise<Lane>
lanesStore.generateNew(); // NewLane
await lanesStore.prime(); // bootstrap (idempotent)The returned module is intentionally narrower than createAdapterStoreModule's StoreModuleForAdapter<T, E, N>. 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:
retrieveAllis gone because every ad-hoc consumer-drivenretrieveAll()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.retrieveByIdis gone because the hash-bumping protocol invalidates a collection wholesale. A store that lets you top up with single-item fetches breaks the invariant thatlocalHashdescribes the contents ofstate. If you need per-id retrieval semantics, the cached wrapper is the wrong tool — usecreateAdapterStoreModuledirectly.
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<T, E, N>; it is not structurally assignable to StoreModuleForAdapter<T, E, N>. This is enforced at the type level — attempting that assignment is a compile-time error.
Options
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.<urlencoded JSON>where the JSON is a flat {cacheKey: hashString} map. The wrapper:
- Parses the header on every response that carries it.
- On every response carrying the header, the middleware updates the in-memory
currentServerHashfor each matching cacheKey, AND triggers an internalinner.retrieveAll()iflocalHash !== currentServerHash(fire-and-forget; in-flight-deduped; skip-if-equal). 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 withlocalHash !== null, subsequentprime()calls return immediately without invoking inner.- After every successful inner
retrieveAll(), the current server hash is snapshotted into both the in-memory local hash andstorageService— 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/catchso a malformed header (un-decodable URI, malformed JSON) cannot poison the caller's request. The middleware-triggeredinner.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 rapidprime()calls likewise resolve to one inner fetch. - Idempotent middleware registration. Multiple wrapper instances sharing one
httpServiceregister 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
