@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 +254 -0
- package/dist/index.d.ts +551 -35
- package/dist/index.js +1202 -11
- package/dist/index.js.map +1 -1
- package/package.json +4 -3
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
|