Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 23 additions & 15 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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<ResponseMiddlewareFunc>[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

Expand All @@ -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:

Expand All @@ -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
Expand Down
26 changes: 26 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

104 changes: 104 additions & 0 deletions packages/cached-adapter-store/README.md
Original file line number Diff line number Diff line change
@@ -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<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:

- **`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<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

```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.<urlencoded JSON>
```

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
60 changes: 60 additions & 0 deletions packages/cached-adapter-store/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading