@happyvertical/smrt-web 0.38.2 → 0.38.4

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,6 +28,260 @@ 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)
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:
38
+
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 }`. Public surface:
227
+ `{ transport: 'sse'|'polling'|'idle', registerTable(table, invalidate) →
228
+ unregister, invalidateAll(), close() }`.
229
+ - `liveInvalidation({ subscriber, tableName })` — the thin per-collection
230
+ capability. `tableName` is **EXPLICIT** config: a `SmrtWebCollectionDefinition`
231
+ has no physical-table field and STI children share one base table, so the
232
+ subscriber (which keys signals by physical table) must be told the table, not
233
+ guess it.
234
+
235
+ **Wire contract it consumes** (both channels are generated by the #1763 server
236
+ half in `packages/core/src/generators/`):
237
+
238
+ - Push — the `_events` SSE route (`events-route.ts`): **NAMED** events
239
+ `event: change` / `event: resync`, `data` is `{table, operation, rowId,
240
+ tenantId}`, and the cursor `seq` is carried **only** in the SSE `id:` field
241
+ (the browser mirrors it to `MessageEvent.lastEventId`). `resync` uses that
242
+ `id:` as the fresh horizon for any later polling downgrade. Heartbeats are
243
+ `: heartbeat` comment lines EventSource ignores natively.
244
+ - Pull — the `_changes` route (`changes-route.ts`): `GET {changesUrl}?since=
245
+ &tables=` → `{changes, cursor, resyncRequired?, resyncCursor?}`, the full
246
+ fallback.
247
+
248
+ **The NAMED-event gotcha.** The frames are named, so the subscriber wires
249
+ `es.addEventListener('change', …)` / `('resync', …)` — **`onmessage` never
250
+ fires** for a named event and would silently receive nothing. A `change` frame's
251
+ `data` is JSON-parsed **defensively**: malformed input is logged + dropped, never
252
+ thrown back into the EventSource message loop (a throw there breaks later
253
+ delivery).
254
+
255
+ **No tenant logic (server enforces).** The wire carries only a signal, never a
256
+ row payload; the subscriber just maps `table → invalidate`, and the refetch
257
+ re-reads through the authorized collection routes. So authorization and
258
+ tenant-scoping stay **entirely on the read path** — the `_events` stream is
259
+ auth-guarded and tenant-scoped at connection open, `_changes` per request. This
260
+ module does no tenant filtering and needs no identity.
261
+
262
+ **SSE vs polling — feature-detect once, downgrade-on-fatal.** Construction
263
+ feature-detects EventSource: present → `connectSse()`; absent → `startPolling()`.
264
+ `transport` reflects it. A transient SSE `onerror` (readyState still OPEN) needs
265
+ no code — the browser auto-reconnects, resending Last-Event-ID. A **fatal** error
266
+ (readyState CLOSED — server 401 / route disabled) downgrades to polling **for the
267
+ subscriber's life (no flap-back)**.
268
+
269
+ **Polling fallback + resync cursor.** `poll()` skips work until at least one
270
+ table is registered, then fetches `{changesUrl}?since=${lastSeq ?? 0}&tables=`
271
+ for the current registered physical tables; each returned change invalidates
272
+ its table and the cursor advances to the page cursor. `resyncRequired: true`
273
+ (HTTP 200, with `cursor` still echoing the rejected value) invalidates
274
+ everything and resumes from `resyncCursor`, the server's current horizon after
275
+ the client performs a full refetch. A fetch **rejection** is a separate path:
276
+ caught + logged, the interval keeps ticking (self-heals).
277
+
278
+ **Idempotent → no client-side seq dedup.** Re-invalidating on a replayed change
279
+ (a reconnect replays the tail) is safe — invalidation only schedules a background
280
+ refetch, and relationship-derived invalidation is itself idempotent. So a
281
+ replayed signal STILL fires, which is exactly what makes a reconnect miss no
282
+ invalidation. `lastSeq` is a resume cursor for the poll fallback, not a dedup
283
+ filter.
284
+
31
285
  ## The engine-absorption boundary (ratified conditions, #1761)
32
286
 
33
287
  1. **No engine types in the public API.** `@tanstack/*` types must never appear