@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 +9 -340
- package/agents/capability-seam.md +101 -0
- package/agents/live-invalidation.md +81 -0
- package/agents/offline-outbox.md +91 -0
- package/agents/version-persistence.md +92 -0
- package/package.json +3 -2
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
|
-
##
|
|
31
|
+
## Modules
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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.
|
|
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
|
".": {
|