@happyvertical/smrt-web 0.40.21 → 0.40.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -28,348 +28,17 @@ mutations without hand-wiring cache keys or fetch/state.
28
28
  (`T[]`, `{ data }` envelopes, `{ error }` bodies → thrown
29
29
  `SmrtWebRequestError`).
30
30
 
31
- ## The capability extension seam (#1755)
31
+ ## Modules
32
32
 
33
- `createSmrtCollection` takes an optional `capabilities: SmrtWebCapability[]`
34
- plug-ins that hook the collection lifecycle so the three follow-on client slices
35
- (offline outbox #1762, persistence #1764, live SSE invalidation #1763-client)
36
- each live in their OWN module instead of contending on `index.ts`. Capabilities
37
- run in **array order** at six fixed points:
33
+ Per-capability semantics live in sibling module docs read the one for the
34
+ module you are editing. This file keeps what holds in every module.
38
35
 
39
- 1. `contributeCacheKey(ctx)` ONCE, before construction. Returned segments
40
- extend the base `smrt:(scope:)name` scheme so a capability can partition the
41
- cache. Segments are spliced into the queryKey **just before the collection
42
- name, so the name stays the LAST segment** `invalidateRelated()`'s predicate
43
- (and thus relationship-derived invalidation #1761 and a capability's own
44
- `ctx.invalidate()`) identifies a collection by `key[key.length - 1]`, so a
45
- name-last key is required or a collection using this hook would silently stop
46
- invalidating. `cacheId` is an opaque engine id the predicate never reads, so it
47
- appends.
48
- 2. `warmStart(ctx)` — ONCE, before the first read. Returns rows that seed the
49
- cache (the persistence rehydrate-from-disk path). **`initialData` wins**: it
50
- is the fresher same-request SSR truth, so `warmStart` is consulted only when
51
- `initialData` is undefined; the FIRST capability that returns rows contributes.
52
- A **sync** return seeds inline; an **async** return is captured and
53
- `SmrtWebCollection.preload()` AWAITS it before the engine's own load, so an
54
- async rehydrate reliably suppresses the first `list()` on the preload path (a
55
- subscribe-driven read racing an unresolved warmStart may still fetch once —
56
- bounded, self-heals via the atomic seed updater).
57
- 3. `wrapMutation(envelope, ctx)` — per mutation, BEFORE the fetcher. Return
58
- `{ handled: true, result }` to take over the write (the fetcher is skipped and
59
- `result` reconciles the optimistic row — the offline path); return
60
- `{ handled: false }`/`undefined` to fall through. The FIRST `{ handled: true }`
61
- wins and later capabilities' `wrapMutation` are skipped (`runWrapMutation`).
62
- When a write is handled offline, the engine's post-mutation refetch AND
63
- `invalidateRelated()` are **suppressed** (the handler returns `{ refetch:
64
- false }`) — the offline write never hit the server, so a refetch of the server
65
- list would DROP the optimistic row #1762's outbox must keep until it replays.
66
- A mixed batch is conservative: any handled mutation suppresses the whole
67
- handler's refetch (common case is one mutation per transaction). An unhandled
68
- write refetches/invalidates exactly as before the seam.
69
- 4. `onSettled(envelope, outcome, ctx)` — per mutation, after it settles, on BOTH
70
- a successful persist (`{ ok: true, result }`) AND a fetcher throw
71
- (`{ ok: false, error }`). Never swallows the throw — a rejected fetcher still
72
- rolls the optimistic state back.
73
- 5. `onAttach(ctx)` — ONCE, right after the engine collection is constructed. The
74
- ONLY place a capability wires an external (non-mutation) trigger such as an
75
- SSE subscription; `ctx.invalidate()` enters the same relationship-derived
76
- invalidation the factory runs post-mutation.
77
- 6. `teardown(ctx)` — inside `cleanup()`, AFTER the engine's own cleanup; awaited
78
- if async.
79
-
80
- **Hook error isolation.** Every hook invocation is wrapped so one misbehaving
81
- capability cannot break the collection: a throwing `contributeCacheKey`,
82
- `warmStart` (sync throw or async reject), `onSettled`, `onAttach`, or `teardown`
83
- is logged via `console.warn` and skipped — a successful mutation still commits
84
- (no rollback), construction still completes, `cleanup()` still resolves, later
85
- capabilities' hooks still run, and an async warmStart rejection never escapes as
86
- an unhandled rejection.
87
-
88
- The context (`SmrtWebCapabilityContext`) and mutation envelope
89
- (`SmrtWebMutationEnvelope`) are typed **entirely in SMRT-owned terms**
90
- (`SmrtWebCollectionDefinition`, `SmrtCrudFetchers`, `SmrtWebRow`, plain TS) — no
91
- `@tanstack/*` type, so the seam stays inside the engine boundary. A capability
92
- needing deeper engine access reaches it through the existing
93
- `getEngineCollection()` unknown-bridge from its own module, never by widening
94
- these types.
95
-
96
- **No-op guarantee.** With `capabilities` undefined or `[]` every code path is
97
- byte-for-byte the collection of today: zero contributeCacheKey/warmStart/
98
- onAttach/teardown calls, `wrapMutation` always falls through to the real
99
- fetcher, `onSettled` runs nothing, and relationship-derived invalidation fires
100
- exactly as before. This PR ships the plug-in point and the shared durable-store
101
- foundation but **zero concrete capabilities** by design.
102
-
103
- ### Shared durable-store foundation (#1755)
104
-
105
- `durable-store.ts` is the ONE SMRT-layer namespacing + wipe registry both the
106
- future outbox (#1762) and persistence (#1764) slices build on — pure
107
- bookkeeping, ZERO `@tanstack/*` imports.
108
-
109
- - `durableStoreNamespace(key)` — deterministic
110
- `smrt-web:${apiBase}:${tenantId ?? '-'}:${identityId ?? '-'}:${manifestHash}`,
111
- so a logout, tenant switch, or schema change each land on a different
112
- namespace (`manifestHash` source is #1764's call; this layer is source-agnostic).
113
- - `registerDurableResource(namespace, resource)` → unregister; `wipeDurableStore(namespace)`
114
- clears every registered `DurableResource` under a namespace (best-effort — a
115
- rejected `clear()` doesn't abort the sweep) then drops it; a safe no-op on an
116
- unknown/empty namespace.
117
-
118
- Rationale: the persistence slice's storage (SQLite-WASM/OPFS) and the outbox's
119
- IndexedDB queue are **separate storage engines**, so the shared foundation lives
120
- one level up — each slice keys its own storage primitive under a shared
121
- namespace, and `wipe()` clears both through the registry without the two modules
122
- importing each other. The outbox (#1762) is the first live consumer:
123
- `durableStoreNamespace(config.namespace)` is its IndexedDB dbName + leader-lock
124
- root, and it registers its queue as an `outbox` `DurableResource` so `wipe()`
125
- empties it.
126
-
127
- ## Offline outbox (#1762)
128
-
129
- The first concrete capability over the seam — durable offline writes for
130
- opted-in collections. `offlineOutbox(config)` (from the root entry) returns a
131
- `SmrtWebCapability`; add it to a collection's `capabilities` array and mutations
132
- are captured in a durable IndexedDB queue that survives reloads/crashes, then
133
- replayed FIFO against the sync-apply batch contract (#1759) when connectivity
134
- returns, with exponential-backoff retries. A collection **without** it is
135
- byte-for-byte unaffected (the seam's no-op guarantee) — that IS the "opt-in per
136
- model" acceptance criterion.
137
-
138
- **Config** (`OfflineOutboxConfig`): `object` (the SAME generated definition
139
- passed to `createSmrtCollection` — its `name` is the sync-apply `object` route
140
- segment); `namespace` (a `DurableStoreKey` — folds api/tenant/identity/manifest;
141
- `manifestHash` is opaque caller-supplied config here, canonical source is
142
- #1764's call); `syncApplyBasePath` (default `/api/v1`; set `/api` for the
143
- generated SvelteKit route); `fetchFn`; `backoff` (`initialDelayMs`=1000,
144
- `multiplier`=2, `maxDelayMs`=60000); `onSyncStateChange` / `onConflict`
145
- (push callbacks, NOT a store — smrt-svelte wraps them later).
146
-
147
- **Hand-rolled, NOT `@tanstack/offline-transactions`.** That layer's public API
148
- is engine-typed (`Collection<…>`, `mutationFns`), so importing it would emit a
149
- `@tanstack/` specifier into `dist/*.d.ts` and FAIL
150
- `check-smrt-web-engine-boundary.mjs` (an unconditional dist-wide scan). It also
151
- pins `@tanstack/db` exactly and competes with smrt-web's own mutation lifecycle.
152
- So the outbox is a raw IndexedDB FIFO queue + native Web Locks, with ZERO new
153
- runtime dependency (only `fake-indexeddb` as a test devDep). The public surface
154
- is engine-free by construction (the boundary check is the proof).
155
-
156
- **Replay is sync-apply-ONLY** (`offline/engine.ts` → `POST
157
- {basePath}/sync/apply`), never `ctx.fetchers.create`. This is load-bearing:
158
- the normal REST create strips the client id (#1540) and mints a NEW server id,
159
- which would orphan the optimistic row the outbox is keeping; sync-apply's
160
- strict-insert path preserves the client UUID, so replay reconciles the exact
161
- optimistic row. `wrapMutation` enqueues then returns `{ handled: true, result:
162
- envelope.data }`, and the factory suppresses the post-mutation refetch +
163
- `invalidateRelated()` (its `{ refetch: false }` path) so the optimistic row is
164
- not dropped by a refetch of a server list that has never seen the offline write.
165
-
166
- **Idempotency / no duplicates.** Rows carry client-generated UUIDs and the
167
- endpoint is idempotent (`_insertOnly` create + no-op re-apply), so a batch that
168
- was sent but whose response was lost is blindly re-sent with no duplicate rows —
169
- the ambiguous-failure AC. Result → durable-transition mapping follows the
170
- sync-apply-contract's "Web outbox (#1762)" consumer notes: `applied` → remove /
171
- `synced`; `conflict` → remove + fire `onConflict` / `synced` (a conflict is a
172
- RESOLVED outcome, not a failure); retryable `write_failed` → keep + backoff /
173
- `pending`; `auth_required`/`forbidden` → PAUSE the loop until re-auth, keep
174
- queued; other terminal rejections → remove / `failed`; network/non-200/lost
175
- response → whole batch stays `pending`.
176
-
177
- **Shared, namespace-keyed engine** (`offline/engine.ts`,
178
- `getOrCreateOutboxEngine`): N collections under the same `namespace` share ONE
179
- ref-counted engine = ONE IndexedDB db + ONE leader lock + ONE FIFO queue. This
180
- is REQUIRED for correctness, not an optimization — independent per-collection
181
- locks would let two tabs each win a different collection's lock and both replay.
182
- The last collection to detach (via `teardown`) disposes the engine; the durable
183
- ROWS survive for the next load.
184
-
185
- **Web Locks leader election** (`offline/leader.ts`): with multiple tabs, exactly
186
- one replays the queue. A tab requests an EXCLUSIVE `navigator.locks` lock keyed
187
- `smrt-web-outbox-leader:<namespace>` and holds it while leader; the browser
188
- auto-releases on tab crash/close (no heartbeat) so the next tab takes over
189
- instantly. **Single-tab fallback (documented gap):** no `navigator.locks` →
190
- warn once + acquire leadership unconditionally; the outbox still replays but the
191
- multi-tab exactly-one-replayer guarantee does not hold across fallback tabs
192
- (NOT a BroadcastChannel shim in v1).
193
-
194
- **Observable state + bridge.** State events route by collection `object` (NOT by
195
- itemId), so rows REHYDRATED from IndexedDB after a reload still reach the
196
- reloaded collection's `onSyncStateChange` even though this session never
197
- enqueued them. `getOutboxHandle(durableStoreNamespace(key))` (a bridge like
198
- `getEngineCollection`) exposes `snapshot()` + `retry(itemId)` for trusted
199
- callers.
200
-
201
- **Reload-visibility gap (this slice's scope).** The outbox does NOT rehydrate
202
- the READ cache after a reload — a reloaded tab's `collection.toArray()` will not
203
- show captured-offline rows until a fetch runs; that read-side rehydrate is
204
- #1764's `warmStart`. So durability is proven via `OutboxHandle.snapshot()` / the
205
- raw IndexedDB store, not `collection.toArray()`. The WRITE side (capture →
206
- durable → exactly-once replay) is complete here.
207
-
208
- **Durable-store integration.** The engine registers its queue as an `outbox`
209
- `DurableResource` under `durableStoreNamespace(config.namespace)`, so
210
- `wipeDurableStore(namespace)` (a logout / tenant-switch) empties the queue; the
211
- namespace is also the IndexedDB dbName and the leader-lock root.
212
-
213
- ### Live invalidation (#1763-client)
214
-
215
- `sse-client.ts` is the client half of live cache invalidation — ONE app-wide
216
- subscriber that turns the #1763 server's coarse change signals into collection
217
- refetches, so a dashboard reflects another session's writes without a manual
218
- refresh. It is a capability built on the seam above (`onAttach` registers the
219
- external trigger, `teardown` unregisters); it never touches the engine —
220
- `ctx.invalidate()` is the refetch primitive.
221
-
222
- - `createSmrtWebEventSubscriber(config)` — the ONE app-wide instance (mirror
223
- `createSmrtWebClient`: construct once, pass to every collection; NOT
224
- auto-derived per collection — one EventSource / one poll loop feeds all).
225
- Config: `{ eventsUrl, changesUrl, fetchFn?, eventSourceFactory?,
226
- pollIntervalMs?=5000, withCredentials?=true, manifestHash?, updateState? }`.
227
- `manifestHash` + `updateState` enable live contract detection from the
228
- server's connection-open `manifest` SSE frame (#1859). Public surface:
229
- `{ transport: 'sse'|'polling'|'idle', registerTable(table, invalidate) →
230
- unregister, invalidateAll(), close() }`.
231
- - `liveInvalidation({ subscriber, tableName })` — the thin per-collection
232
- capability. `tableName` is **EXPLICIT** config: a `SmrtWebCollectionDefinition`
233
- has no physical-table field and STI children share one base table, so the
234
- subscriber (which keys signals by physical table) must be told the table, not
235
- guess it.
236
-
237
- **Wire contract it consumes** (both channels are generated by the #1763 server
238
- half in `packages/core/src/generators/`):
239
-
240
- - Push — the `_events` SSE route (`events-route.ts`): **NAMED** events
241
- `event: change` / `event: resync`, `data` is `{table, operation, rowId,
242
- tenantId}`, and the cursor `seq` is carried **only** in the SSE `id:` field
243
- (the browser mirrors it to `MessageEvent.lastEventId`). `event: manifest`
244
- carries `{ manifestHash }` at connection open so a reconnect can latch the
245
- contract update signal. `resync` uses its `id:` as the fresh horizon for any
246
- later polling downgrade. Heartbeats are `: heartbeat` comment lines
247
- EventSource ignores natively.
248
- - Pull — the `_changes` route (`changes-route.ts`): `GET {changesUrl}?since=
249
- &tables=` → `{changes, cursor, resyncRequired?, resyncCursor?}`, the full
250
- fallback.
251
-
252
- **The NAMED-event gotcha.** The frames are named, so the subscriber wires
253
- `es.addEventListener('change', …)` / `('resync', …)` / `('manifest', …)` — **`onmessage` never
254
- fires** for a named event and would silently receive nothing. A `change` frame's
255
- `data` is JSON-parsed **defensively**: malformed input is logged + dropped, never
256
- thrown back into the EventSource message loop (a throw there breaks later
257
- delivery).
258
-
259
- **No tenant logic (server enforces).** The wire carries only a signal, never a
260
- row payload; the subscriber just maps `table → invalidate`, and the refetch
261
- re-reads through the authorized collection routes. So authorization and
262
- tenant-scoping stay **entirely on the read path** — the `_events` stream is
263
- auth-guarded and tenant-scoped at connection open, `_changes` per request. This
264
- module does no tenant filtering and needs no identity.
265
-
266
- **SSE vs polling — feature-detect once, downgrade-on-fatal.** Construction
267
- feature-detects EventSource: present → `connectSse()`; absent → `startPolling()`.
268
- `transport` reflects it. A transient SSE `onerror` (readyState still OPEN) needs
269
- no code — the browser auto-reconnects, resending Last-Event-ID. A **fatal** error
270
- (readyState CLOSED — server 401 / route disabled) downgrades to polling **for the
271
- subscriber's life (no flap-back)**.
272
-
273
- **Polling fallback + resync cursor.** `poll()` skips work until at least one
274
- table is registered, then fetches `{changesUrl}?since=${lastSeq ?? 0}&tables=`
275
- for the current registered physical tables; each returned change invalidates
276
- its table and the cursor advances to the page cursor. `resyncRequired: true`
277
- (HTTP 200, with `cursor` still echoing the rejected value) invalidates
278
- everything and resumes from `resyncCursor`, the server's current horizon after
279
- the client performs a full refetch. A fetch **rejection** is a separate path:
280
- caught + logged, the interval keeps ticking (self-heals).
281
-
282
- **Idempotent → no client-side seq dedup.** Re-invalidating on a replayed change
283
- (a reconnect replays the tail) is safe — invalidation only schedules a background
284
- refetch, and relationship-derived invalidation is itself idempotent. So a
285
- replayed signal STILL fires, which is exactly what makes a reconnect miss no
286
- invalidation. `lastSeq` is a resume cursor for the poll fallback, not a dedup
287
- filter.
288
-
289
- ## Version awareness & persistence (#1764)
290
-
291
- The read-side twin of the outbox plus a first-class "an update is available"
292
- signal. Two locked architecture decisions (maintainer): the manifest-hash source
293
- is a **build-time inject** (core's web-module generator emits a `manifestHash`
294
- constant — NOT a runtime endpoint), and the ETag salt is **folded into this PR**
295
- (the same hash threads into `computeTableVersionEtag`, closing #1765's documented
296
- shape-change staleness gap). See `packages/core/AGENTS.md` for the core halves.
297
-
298
- ### Persistence capability — `persistCollection(config)` (opt-in)
299
-
300
- The read-cache rehydrate the outbox left to #1764. Add it to a collection's
301
- `capabilities`; a collection **without** it is byte-for-byte non-persistent — the
302
- seam's no-op guarantee, and the "opt-in per model" AC. **Sensitive models simply
303
- omit the capability and never touch disk.**
304
-
305
- - `warmStart(ctx)` reads the persisted snapshot for
306
- `(durableStoreNamespace(namespace), collection)` and returns its rows to seed
307
- the cache — the stale render paints instantly; the engine then revalidates in
308
- the background via its normal SWR (the preload path AWAITS the async warmStart,
309
- so the first `list()` is suppressed). **Manifest-hash change drops caches
310
- automatically:** the namespace INCLUDES `manifestHash`, so a contract-changing
311
- deploy lands on a DIFFERENT IndexedDB database → the old snapshot is never found
312
- → an empty warmStart → a fresh fetch with NO stale-schema hydration. No explicit
313
- invalidation.
314
- - Write-back: `onAttach` subscribes to the collection's changes (via the seam's
315
- engine-free `ctx.subscribe`/`ctx.snapshot` — added for this slice, plain-DTO
316
- payloads only, no engine type) and persists the current rows DEBOUNCED (250ms
317
- trailing default; the scheduler is timer-based even at 0ms so a pending write is
318
- cancelable). `teardown` sets `detached` and CLEARS the pending timer FIRST so
319
- the "rows removed" change that the engine's own `cleanup()` fires just before
320
- teardown can't persist an empty snapshot over the good one, then drains any
321
- in-flight write. The per-change write-back already persisted the latest rows
322
- during the collection's life, so the durable snapshot survives for the next
323
- load.
324
- - Storage: `persistence/snapshot-store.ts` — raw IndexedDB, ONE blob per
325
- `(namespace, collection)`, mirroring the outbox's hand-rolled queue (ZERO
326
- `@tanstack/*`; the boundary check is the proof). N collections under one
327
- namespace share ONE ref-counted store; the last detach closes the db.
328
- - `registerDurableResource(namespace, { kind: 'persisted-collection', clear })` at
329
- first attach, unregistered on last detach BEFORE the db closes (so a later
330
- `wipeDurableStore` is a no-op, not a double-clear — the #1762 discipline).
331
- - **Namespace segregation:** the namespace folds api/tenant/identity/manifest, so
332
- switching users on one device lands on a different database — one user can never
333
- read another's persisted rows.
334
- - **Logout wipe clears the outbox too:** the outbox (#1762) registers its queue
335
- under the SAME namespace, so ONE `wipeDurableStore(namespace)` clears BOTH the
336
- persisted collections AND the outbox queue.
337
- - **IndexedDB unavailable** (probe `open()` throws — private mode, sandboxed
338
- iframe): `console.warn` ONCE + behave as non-persistent (warmStart returns
339
- nothing, write-back is a no-op). Never throws — the outbox's `probeIndexedDb`
340
- posture.
341
-
342
- ### `updateAvailable` primitive — `createUpdateState(config)` (framework-free)
343
-
344
- A tiny pub/sub (`update-state.ts`) with TWO INDEPENDENT signals; `updateAvailable
345
- = bundle || contract`:
346
-
347
- - **bundle** — the client BUNDLE changed on the server. SvelteKit detects it
348
- natively (`updated` store); the consumer/smrt-svelte binding pushes it via
349
- `notifyBundleUpdated()`. This module owns no polling.
350
- - **contract** — the API CONTRACT (manifest hash) changed, which only SMRT knows.
351
- Under BUILD-TIME INJECT: on init the primitive compares the RUNNING build's
352
- `manifestHash` (passed in by the consumer, imported from
353
- `@happyvertical/smrt-virt-web`) against a persisted "last-seen manifestHash" in
354
- durable storage; if they differ → fire `contract` + store the new value. First
355
- run (no baseline) records without firing. The live `_events` manifest frame
356
- can also push the same sticky signal via `notifyContractUpdated()` (#1859).
357
-
358
- The last-seen hash lives in `update-state/meta-store.ts` (a tiny IDB key/value
359
- store) under the durable namespace, registered as a durable resource so
360
- `wipeDurableStore` clears it too (the "wipe clears the last-seen-hash record"
361
- AC). Degrades gracefully if IndexedDB is absent (bundle-only) or no running hash
362
- is supplied.
363
-
364
- > **Trade-off:** live contract detection is reconnect-based. The server hash is
365
- > advertised when the `_events` stream opens, so a deploy surfaces when the tab
366
- > reconnects (instant when the deploy drops old SSE connections, otherwise on
367
- > the next natural reconnect). Reconnect-independent fan-out remains a later
368
- > enhancement.
369
-
370
- The reactive Svelte binding (`useUpdateAvailable`) ships in
371
- `@happyvertical/smrt-svelte/web` — it wires SvelteKit's `updated` store into the
372
- bundle signal and surfaces both reactively for a toast/reload UX.
36
+ | Module | Scope | Module doc |
37
+ |---|---|---|
38
+ | `index.ts` hooks + `durable-store.ts` | the six capability hook points, hook error isolation, the no-op guarantee, and the shared durable-store namespacing/wipe registry | [agents/capability-seam.md](agents/capability-seam.md) |
39
+ | `offline/` | durable offline writes — config, sync-apply-only replay, idempotency, the shared namespace-keyed engine, and Web Locks leader election | [agents/offline-outbox.md](agents/offline-outbox.md) |
40
+ | `sse-client.ts` | the client half of live cache invalidation the app-wide subscriber, the wire contract it consumes, and SSE-vs-polling behaviour | [agents/live-invalidation.md](agents/live-invalidation.md) |
41
+ | `persistence/` + `update-state.ts` | the read-cache rehydrate capability and the framework-free `updateAvailable` primitive (bundle + contract signals) | [agents/version-persistence.md](agents/version-persistence.md) |
373
42
 
374
43
  ## The engine-absorption boundary (ratified conditions, #1761)
375
44
 
@@ -0,0 +1,101 @@
1
+ # smrt-web/capability seam
2
+
3
+ Module semantics for `index.ts` hooks + `durable-store.ts`. Package orientation, the cross-module
4
+ invariants, and the traps that apply before editing anything live in
5
+ [../AGENTS.md](../AGENTS.md) — read that first.
6
+
7
+ ## The capability extension seam (#1755)
8
+
9
+ `createSmrtCollection` takes an optional `capabilities: SmrtWebCapability[]` —
10
+ plug-ins that hook the collection lifecycle so the three follow-on client slices
11
+ (offline outbox #1762, persistence #1764, live SSE invalidation #1763-client)
12
+ each live in their OWN module instead of contending on `index.ts`. Capabilities
13
+ run in **array order** at six fixed points:
14
+
15
+ 1. `contributeCacheKey(ctx)` — ONCE, before construction. Returned segments
16
+ extend the base `smrt:(scope:)name` scheme so a capability can partition the
17
+ cache. Segments are spliced into the queryKey **just before the collection
18
+ name, so the name stays the LAST segment** — `invalidateRelated()`'s predicate
19
+ (and thus relationship-derived invalidation #1761 and a capability's own
20
+ `ctx.invalidate()`) identifies a collection by `key[key.length - 1]`, so a
21
+ name-last key is required or a collection using this hook would silently stop
22
+ invalidating. `cacheId` is an opaque engine id the predicate never reads, so it
23
+ appends.
24
+ 2. `warmStart(ctx)` — ONCE, before the first read. Returns rows that seed the
25
+ cache (the persistence rehydrate-from-disk path). **`initialData` wins**: it
26
+ is the fresher same-request SSR truth, so `warmStart` is consulted only when
27
+ `initialData` is undefined; the FIRST capability that returns rows contributes.
28
+ A **sync** return seeds inline; an **async** return is captured and
29
+ `SmrtWebCollection.preload()` AWAITS it before the engine's own load, so an
30
+ async rehydrate reliably suppresses the first `list()` on the preload path (a
31
+ subscribe-driven read racing an unresolved warmStart may still fetch once —
32
+ bounded, self-heals via the atomic seed updater).
33
+ 3. `wrapMutation(envelope, ctx)` — per mutation, BEFORE the fetcher. Return
34
+ `{ handled: true, result }` to take over the write (the fetcher is skipped and
35
+ `result` reconciles the optimistic row — the offline path); return
36
+ `{ handled: false }`/`undefined` to fall through. The FIRST `{ handled: true }`
37
+ wins and later capabilities' `wrapMutation` are skipped (`runWrapMutation`).
38
+ When a write is handled offline, the engine's post-mutation refetch AND
39
+ `invalidateRelated()` are **suppressed** (the handler returns `{ refetch:
40
+ false }`) — the offline write never hit the server, so a refetch of the server
41
+ list would DROP the optimistic row #1762's outbox must keep until it replays.
42
+ A mixed batch is conservative: any handled mutation suppresses the whole
43
+ handler's refetch (common case is one mutation per transaction). An unhandled
44
+ write refetches/invalidates exactly as before the seam.
45
+ 4. `onSettled(envelope, outcome, ctx)` — per mutation, after it settles, on BOTH
46
+ a successful persist (`{ ok: true, result }`) AND a fetcher throw
47
+ (`{ ok: false, error }`). Never swallows the throw — a rejected fetcher still
48
+ rolls the optimistic state back.
49
+ 5. `onAttach(ctx)` — ONCE, right after the engine collection is constructed. The
50
+ ONLY place a capability wires an external (non-mutation) trigger such as an
51
+ SSE subscription; `ctx.invalidate()` enters the same relationship-derived
52
+ invalidation the factory runs post-mutation.
53
+ 6. `teardown(ctx)` — inside `cleanup()`, AFTER the engine's own cleanup; awaited
54
+ if async.
55
+
56
+ **Hook error isolation.** Every hook invocation is wrapped so one misbehaving
57
+ capability cannot break the collection: a throwing `contributeCacheKey`,
58
+ `warmStart` (sync throw or async reject), `onSettled`, `onAttach`, or `teardown`
59
+ is logged via `console.warn` and skipped — a successful mutation still commits
60
+ (no rollback), construction still completes, `cleanup()` still resolves, later
61
+ capabilities' hooks still run, and an async warmStart rejection never escapes as
62
+ an unhandled rejection.
63
+
64
+ The context (`SmrtWebCapabilityContext`) and mutation envelope
65
+ (`SmrtWebMutationEnvelope`) are typed **entirely in SMRT-owned terms**
66
+ (`SmrtWebCollectionDefinition`, `SmrtCrudFetchers`, `SmrtWebRow`, plain TS) — no
67
+ `@tanstack/*` type, so the seam stays inside the engine boundary. A capability
68
+ needing deeper engine access reaches it through the existing
69
+ `getEngineCollection()` unknown-bridge from its own module, never by widening
70
+ these types.
71
+
72
+ **No-op guarantee.** With `capabilities` undefined or `[]` every code path is
73
+ byte-for-byte the collection of today: zero contributeCacheKey/warmStart/
74
+ onAttach/teardown calls, `wrapMutation` always falls through to the real
75
+ fetcher, `onSettled` runs nothing, and relationship-derived invalidation fires
76
+ exactly as before. This PR ships the plug-in point and the shared durable-store
77
+ foundation but **zero concrete capabilities** by design.
78
+
79
+ ### Shared durable-store foundation (#1755)
80
+
81
+ `durable-store.ts` is the ONE SMRT-layer namespacing + wipe registry both the
82
+ future outbox (#1762) and persistence (#1764) slices build on — pure
83
+ bookkeeping, ZERO `@tanstack/*` imports.
84
+
85
+ - `durableStoreNamespace(key)` — deterministic
86
+ `smrt-web:${apiBase}:${tenantId ?? '-'}:${identityId ?? '-'}:${manifestHash}`,
87
+ so a logout, tenant switch, or schema change each land on a different
88
+ namespace (`manifestHash` source is #1764's call; this layer is source-agnostic).
89
+ - `registerDurableResource(namespace, resource)` → unregister; `wipeDurableStore(namespace)`
90
+ clears every registered `DurableResource` under a namespace (best-effort — a
91
+ rejected `clear()` doesn't abort the sweep) then drops it; a safe no-op on an
92
+ unknown/empty namespace.
93
+
94
+ Rationale: the persistence slice's storage (SQLite-WASM/OPFS) and the outbox's
95
+ IndexedDB queue are **separate storage engines**, so the shared foundation lives
96
+ one level up — each slice keys its own storage primitive under a shared
97
+ namespace, and `wipe()` clears both through the registry without the two modules
98
+ importing each other. The outbox (#1762) is the first live consumer:
99
+ `durableStoreNamespace(config.namespace)` is its IndexedDB dbName + leader-lock
100
+ root, and it registers its queue as an `outbox` `DurableResource` so `wipe()`
101
+ empties it.
@@ -0,0 +1,81 @@
1
+ # smrt-web/live invalidation
2
+
3
+ Module semantics for `sse-client.ts`. Package orientation, the cross-module
4
+ invariants, and the traps that apply before editing anything live in
5
+ [../AGENTS.md](../AGENTS.md) — read that first.
6
+
7
+ ### Live invalidation (#1763-client)
8
+
9
+ `sse-client.ts` is the client half of live cache invalidation — ONE app-wide
10
+ subscriber that turns the #1763 server's coarse change signals into collection
11
+ refetches, so a dashboard reflects another session's writes without a manual
12
+ refresh. It is a capability built on the seam ([capability-seam.md](capability-seam.md) — `onAttach` registers the
13
+ external trigger, `teardown` unregisters); it never touches the engine —
14
+ `ctx.invalidate()` is the refetch primitive.
15
+
16
+ - `createSmrtWebEventSubscriber(config)` — the ONE app-wide instance (mirror
17
+ `createSmrtWebClient`: construct once, pass to every collection; NOT
18
+ auto-derived per collection — one EventSource / one poll loop feeds all).
19
+ Config: `{ eventsUrl, changesUrl, fetchFn?, eventSourceFactory?,
20
+ pollIntervalMs?=5000, withCredentials?=true, manifestHash?, updateState? }`.
21
+ `manifestHash` + `updateState` enable live contract detection from the
22
+ server's connection-open `manifest` SSE frame (#1859). Public surface:
23
+ `{ transport: 'sse'|'polling'|'idle', registerTable(table, invalidate) →
24
+ unregister, invalidateAll(), close() }`.
25
+ - `liveInvalidation({ subscriber, tableName })` — the thin per-collection
26
+ capability. `tableName` is **EXPLICIT** config: a `SmrtWebCollectionDefinition`
27
+ has no physical-table field and STI children share one base table, so the
28
+ subscriber (which keys signals by physical table) must be told the table, not
29
+ guess it.
30
+
31
+ **Wire contract it consumes** (both channels are generated by the #1763 server
32
+ half in `packages/core/src/generators/`):
33
+
34
+ - Push — the `_events` SSE route (`events-route.ts`): **NAMED** events
35
+ `event: change` / `event: resync`, `data` is `{table, operation, rowId,
36
+ tenantId}`, and the cursor `seq` is carried **only** in the SSE `id:` field
37
+ (the browser mirrors it to `MessageEvent.lastEventId`). `event: manifest`
38
+ carries `{ manifestHash }` at connection open so a reconnect can latch the
39
+ contract update signal. `resync` uses its `id:` as the fresh horizon for any
40
+ later polling downgrade. Heartbeats are `: heartbeat` comment lines
41
+ EventSource ignores natively.
42
+ - Pull — the `_changes` route (`changes-route.ts`): `GET {changesUrl}?since=
43
+ &tables=` → `{changes, cursor, resyncRequired?, resyncCursor?}`, the full
44
+ fallback.
45
+
46
+ **The NAMED-event gotcha.** The frames are named, so the subscriber wires
47
+ `es.addEventListener('change', …)` / `('resync', …)` / `('manifest', …)` — **`onmessage` never
48
+ fires** for a named event and would silently receive nothing. A `change` frame's
49
+ `data` is JSON-parsed **defensively**: malformed input is logged + dropped, never
50
+ thrown back into the EventSource message loop (a throw there breaks later
51
+ delivery).
52
+
53
+ **No tenant logic (server enforces).** The wire carries only a signal, never a
54
+ row payload; the subscriber just maps `table → invalidate`, and the refetch
55
+ re-reads through the authorized collection routes. So authorization and
56
+ tenant-scoping stay **entirely on the read path** — the `_events` stream is
57
+ auth-guarded and tenant-scoped at connection open, `_changes` per request. This
58
+ module does no tenant filtering and needs no identity.
59
+
60
+ **SSE vs polling — feature-detect once, downgrade-on-fatal.** Construction
61
+ feature-detects EventSource: present → `connectSse()`; absent → `startPolling()`.
62
+ `transport` reflects it. A transient SSE `onerror` (readyState still OPEN) needs
63
+ no code — the browser auto-reconnects, resending Last-Event-ID. A **fatal** error
64
+ (readyState CLOSED — server 401 / route disabled) downgrades to polling **for the
65
+ subscriber's life (no flap-back)**.
66
+
67
+ **Polling fallback + resync cursor.** `poll()` skips work until at least one
68
+ table is registered, then fetches `{changesUrl}?since=${lastSeq ?? 0}&tables=`
69
+ for the current registered physical tables; each returned change invalidates
70
+ its table and the cursor advances to the page cursor. `resyncRequired: true`
71
+ (HTTP 200, with `cursor` still echoing the rejected value) invalidates
72
+ everything and resumes from `resyncCursor`, the server's current horizon after
73
+ the client performs a full refetch. A fetch **rejection** is a separate path:
74
+ caught + logged, the interval keeps ticking (self-heals).
75
+
76
+ **Idempotent → no client-side seq dedup.** Re-invalidating on a replayed change
77
+ (a reconnect replays the tail) is safe — invalidation only schedules a background
78
+ refetch, and relationship-derived invalidation is itself idempotent. So a
79
+ replayed signal STILL fires, which is exactly what makes a reconnect miss no
80
+ invalidation. `lastSeq` is a resume cursor for the poll fallback, not a dedup
81
+ filter.
@@ -0,0 +1,91 @@
1
+ # smrt-web/offline outbox
2
+
3
+ Module semantics for `offline/`. Package orientation, the cross-module
4
+ invariants, and the traps that apply before editing anything live in
5
+ [../AGENTS.md](../AGENTS.md) — read that first.
6
+
7
+ ## Offline outbox (#1762)
8
+
9
+ The first concrete capability over the seam — durable offline writes for
10
+ opted-in collections. `offlineOutbox(config)` (from the root entry) returns a
11
+ `SmrtWebCapability`; add it to a collection's `capabilities` array and mutations
12
+ are captured in a durable IndexedDB queue that survives reloads/crashes, then
13
+ replayed FIFO against the sync-apply batch contract (#1759) when connectivity
14
+ returns, with exponential-backoff retries. A collection **without** it is
15
+ byte-for-byte unaffected (the seam's no-op guarantee) — that IS the "opt-in per
16
+ model" acceptance criterion.
17
+
18
+ **Config** (`OfflineOutboxConfig`): `object` (the SAME generated definition
19
+ passed to `createSmrtCollection` — its `name` is the sync-apply `object` route
20
+ segment); `namespace` (a `DurableStoreKey` — folds api/tenant/identity/manifest;
21
+ `manifestHash` is opaque caller-supplied config here, canonical source is
22
+ #1764's call); `syncApplyBasePath` (default `/api/v1`; set `/api` for the
23
+ generated SvelteKit route); `fetchFn`; `backoff` (`initialDelayMs`=1000,
24
+ `multiplier`=2, `maxDelayMs`=60000); `onSyncStateChange` / `onConflict`
25
+ (push callbacks, NOT a store — smrt-svelte wraps them later).
26
+
27
+ **Hand-rolled, NOT `@tanstack/offline-transactions`.** That layer's public API
28
+ is engine-typed (`Collection<…>`, `mutationFns`), so importing it would emit a
29
+ `@tanstack/` specifier into `dist/*.d.ts` and FAIL
30
+ `check-smrt-web-engine-boundary.mjs` (an unconditional dist-wide scan). It also
31
+ pins `@tanstack/db` exactly and competes with smrt-web's own mutation lifecycle.
32
+ So the outbox is a raw IndexedDB FIFO queue + native Web Locks, with ZERO new
33
+ runtime dependency (only `fake-indexeddb` as a test devDep). The public surface
34
+ is engine-free by construction (the boundary check is the proof).
35
+
36
+ **Replay is sync-apply-ONLY** (`offline/engine.ts` → `POST
37
+ {basePath}/sync/apply`), never `ctx.fetchers.create`. This is load-bearing:
38
+ the normal REST create strips the client id (#1540) and mints a NEW server id,
39
+ which would orphan the optimistic row the outbox is keeping; sync-apply's
40
+ strict-insert path preserves the client UUID, so replay reconciles the exact
41
+ optimistic row. `wrapMutation` enqueues then returns `{ handled: true, result:
42
+ envelope.data }`, and the factory suppresses the post-mutation refetch +
43
+ `invalidateRelated()` (its `{ refetch: false }` path) so the optimistic row is
44
+ not dropped by a refetch of a server list that has never seen the offline write.
45
+
46
+ **Idempotency / no duplicates.** Rows carry client-generated UUIDs and the
47
+ endpoint is idempotent (`_insertOnly` create + no-op re-apply), so a batch that
48
+ was sent but whose response was lost is blindly re-sent with no duplicate rows —
49
+ the ambiguous-failure AC. Result → durable-transition mapping follows the
50
+ sync-apply-contract's "Web outbox (#1762)" consumer notes: `applied` → remove /
51
+ `synced`; `conflict` → remove + fire `onConflict` / `synced` (a conflict is a
52
+ RESOLVED outcome, not a failure); retryable `write_failed` → keep + backoff /
53
+ `pending`; `auth_required`/`forbidden` → PAUSE the loop until re-auth, keep
54
+ queued; other terminal rejections → remove / `failed`; network/non-200/lost
55
+ response → whole batch stays `pending`.
56
+
57
+ **Shared, namespace-keyed engine** (`offline/engine.ts`,
58
+ `getOrCreateOutboxEngine`): N collections under the same `namespace` share ONE
59
+ ref-counted engine = ONE IndexedDB db + ONE leader lock + ONE FIFO queue. This
60
+ is REQUIRED for correctness, not an optimization — independent per-collection
61
+ locks would let two tabs each win a different collection's lock and both replay.
62
+ The last collection to detach (via `teardown`) disposes the engine; the durable
63
+ ROWS survive for the next load.
64
+
65
+ **Web Locks leader election** (`offline/leader.ts`): with multiple tabs, exactly
66
+ one replays the queue. A tab requests an EXCLUSIVE `navigator.locks` lock keyed
67
+ `smrt-web-outbox-leader:<namespace>` and holds it while leader; the browser
68
+ auto-releases on tab crash/close (no heartbeat) so the next tab takes over
69
+ instantly. **Single-tab fallback (documented gap):** no `navigator.locks` →
70
+ warn once + acquire leadership unconditionally; the outbox still replays but the
71
+ multi-tab exactly-one-replayer guarantee does not hold across fallback tabs
72
+ (NOT a BroadcastChannel shim in v1).
73
+
74
+ **Observable state + bridge.** State events route by collection `object` (NOT by
75
+ itemId), so rows REHYDRATED from IndexedDB after a reload still reach the
76
+ reloaded collection's `onSyncStateChange` even though this session never
77
+ enqueued them. `getOutboxHandle(durableStoreNamespace(key))` (a bridge like
78
+ `getEngineCollection`) exposes `snapshot()` + `retry(itemId)` for trusted
79
+ callers.
80
+
81
+ **Reload-visibility gap (this slice's scope).** The outbox does NOT rehydrate
82
+ the READ cache after a reload — a reloaded tab's `collection.toArray()` will not
83
+ show captured-offline rows until a fetch runs; that read-side rehydrate is
84
+ #1764's `warmStart`. So durability is proven via `OutboxHandle.snapshot()` / the
85
+ raw IndexedDB store, not `collection.toArray()`. The WRITE side (capture →
86
+ durable → exactly-once replay) is complete here.
87
+
88
+ **Durable-store integration.** The engine registers its queue as an `outbox`
89
+ `DurableResource` under `durableStoreNamespace(config.namespace)`, so
90
+ `wipeDurableStore(namespace)` (a logout / tenant-switch) empties the queue; the
91
+ namespace is also the IndexedDB dbName and the leader-lock root.
@@ -0,0 +1,92 @@
1
+ # smrt-web/version awareness & persistence
2
+
3
+ Module semantics for `persistence/` + `update-state.ts`. Package orientation, the cross-module
4
+ invariants, and the traps that apply before editing anything live in
5
+ [../AGENTS.md](../AGENTS.md) — read that first.
6
+
7
+ ## Version awareness & persistence (#1764)
8
+
9
+ The read-side twin of the outbox plus a first-class "an update is available"
10
+ signal. Two locked architecture decisions (maintainer): the manifest-hash source
11
+ is a **build-time inject** (core's web-module generator emits a `manifestHash`
12
+ constant — NOT a runtime endpoint), and the ETag salt is **folded into this PR**
13
+ (the same hash threads into `computeTableVersionEtag`, closing #1765's documented
14
+ shape-change staleness gap). See `packages/core/agents/generators.md` (the
15
+ `manifestHash` emission sites and ETag v2) and `packages/core/agents/change-feed.md`
16
+ for the core halves.
17
+
18
+ ### Persistence capability — `persistCollection(config)` (opt-in)
19
+
20
+ The read-cache rehydrate the outbox left to #1764. Add it to a collection's
21
+ `capabilities`; a collection **without** it is byte-for-byte non-persistent — the
22
+ seam's no-op guarantee, and the "opt-in per model" AC. **Sensitive models simply
23
+ omit the capability and never touch disk.**
24
+
25
+ - `warmStart(ctx)` reads the persisted snapshot for
26
+ `(durableStoreNamespace(namespace), collection)` and returns its rows to seed
27
+ the cache — the stale render paints instantly; the engine then revalidates in
28
+ the background via its normal SWR (the preload path AWAITS the async warmStart,
29
+ so the first `list()` is suppressed). **Manifest-hash change drops caches
30
+ automatically:** the namespace INCLUDES `manifestHash`, so a contract-changing
31
+ deploy lands on a DIFFERENT IndexedDB database → the old snapshot is never found
32
+ → an empty warmStart → a fresh fetch with NO stale-schema hydration. No explicit
33
+ invalidation.
34
+ - Write-back: `onAttach` subscribes to the collection's changes (via the seam's
35
+ engine-free `ctx.subscribe`/`ctx.snapshot` — added for this slice, plain-DTO
36
+ payloads only, no engine type) and persists the current rows DEBOUNCED (250ms
37
+ trailing default; the scheduler is timer-based even at 0ms so a pending write is
38
+ cancelable). `teardown` sets `detached` and CLEARS the pending timer FIRST so
39
+ the "rows removed" change that the engine's own `cleanup()` fires just before
40
+ teardown can't persist an empty snapshot over the good one, then drains any
41
+ in-flight write. The per-change write-back already persisted the latest rows
42
+ during the collection's life, so the durable snapshot survives for the next
43
+ load.
44
+ - Storage: `persistence/snapshot-store.ts` — raw IndexedDB, ONE blob per
45
+ `(namespace, collection)`, mirroring the outbox's hand-rolled queue (ZERO
46
+ `@tanstack/*`; the boundary check is the proof). N collections under one
47
+ namespace share ONE ref-counted store; the last detach closes the db.
48
+ - `registerDurableResource(namespace, { kind: 'persisted-collection', clear })` at
49
+ first attach, unregistered on last detach BEFORE the db closes (so a later
50
+ `wipeDurableStore` is a no-op, not a double-clear — the #1762 discipline).
51
+ - **Namespace segregation:** the namespace folds api/tenant/identity/manifest, so
52
+ switching users on one device lands on a different database — one user can never
53
+ read another's persisted rows.
54
+ - **Logout wipe clears the outbox too:** the outbox (#1762) registers its queue
55
+ under the SAME namespace, so ONE `wipeDurableStore(namespace)` clears BOTH the
56
+ persisted collections AND the outbox queue.
57
+ - **IndexedDB unavailable** (probe `open()` throws — private mode, sandboxed
58
+ iframe): `console.warn` ONCE + behave as non-persistent (warmStart returns
59
+ nothing, write-back is a no-op). Never throws — the outbox's `probeIndexedDb`
60
+ posture.
61
+
62
+ ### `updateAvailable` primitive — `createUpdateState(config)` (framework-free)
63
+
64
+ A tiny pub/sub (`update-state.ts`) with TWO INDEPENDENT signals; `updateAvailable
65
+ = bundle || contract`:
66
+
67
+ - **bundle** — the client BUNDLE changed on the server. SvelteKit detects it
68
+ natively (`updated` store); the consumer/smrt-svelte binding pushes it via
69
+ `notifyBundleUpdated()`. This module owns no polling.
70
+ - **contract** — the API CONTRACT (manifest hash) changed, which only SMRT knows.
71
+ Under BUILD-TIME INJECT: on init the primitive compares the RUNNING build's
72
+ `manifestHash` (passed in by the consumer, imported from
73
+ `@happyvertical/smrt-virt-web`) against a persisted "last-seen manifestHash" in
74
+ durable storage; if they differ → fire `contract` + store the new value. First
75
+ run (no baseline) records without firing. The live `_events` manifest frame
76
+ can also push the same sticky signal via `notifyContractUpdated()` (#1859).
77
+
78
+ The last-seen hash lives in `update-state/meta-store.ts` (a tiny IDB key/value
79
+ store) under the durable namespace, registered as a durable resource so
80
+ `wipeDurableStore` clears it too (the "wipe clears the last-seen-hash record"
81
+ AC). Degrades gracefully if IndexedDB is absent (bundle-only) or no running hash
82
+ is supplied.
83
+
84
+ > **Trade-off:** live contract detection is reconnect-based. The server hash is
85
+ > advertised when the `_events` stream opens, so a deploy surfaces when the tab
86
+ > reconnects (instant when the deploy drops old SSE connections, otherwise on
87
+ > the next natural reconnect). Reconnect-independent fan-out remains a later
88
+ > enhancement.
89
+
90
+ The reactive Svelte binding (`useUpdateAvailable`) ships in
91
+ `@happyvertical/smrt-svelte/web` — it wires SvelteKit's `updated` store into the
92
+ bundle signal and surfaces both reactively for a toast/reload UX.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@happyvertical/smrt-web",
3
- "version": "0.40.21",
3
+ "version": "0.40.23",
4
4
  "description": "SMRT browser client data runtime: typed collection factory wrapping the client-data engine over generated REST clients",
5
5
  "author": "HappyVertical",
6
6
  "type": "module",
@@ -9,7 +9,8 @@
9
9
  "files": [
10
10
  "CLAUDE.md",
11
11
  "dist",
12
- "AGENTS.md"
12
+ "AGENTS.md",
13
+ "agents"
13
14
  ],
14
15
  "exports": {
15
16
  ".": {