@happyvertical/smrt-web 0.51.28 → 0.51.31

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
@@ -36,10 +36,10 @@ module you are editing. This file keeps what holds in every module.
36
36
  | Module | Scope | Module doc |
37
37
  |---|---|---|
38
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) |
39
+ | `offline/` | durable offline writes — config, sync-apply or consumer-declared transport replay (#3021), idempotency, the shared namespace-keyed engine, and Web Locks leader election | [agents/offline-outbox.md](agents/offline-outbox.md) |
40
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
41
  | `webmcp.ts` | framework-agnostic WebMCP registrar; validates an optionally bounded/namespaced, effect-filtered prospective set atomically, keeps legacy list-backed tools on collection state, and executes canonical tool-only definitions directly through REST fetchers. `registerWebMcpBespokeTool` is the single-tool sibling a UI layer's hand-written component tool routes through (`useWebMcpTool` in smrt-svelte, #2586): same fail-closed effect classification and `effects` policy, no `namespace`/`maxTools` | — |
42
- | `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) |
42
+ | `persistence/` + `update-state.ts` | the read-cache rehydrate capability, `persistDataSurface` (#3021), and the framework-free `updateAvailable` primitive (bundle + contract signals) | [agents/version-persistence.md](agents/version-persistence.md) |
43
43
  | `webmcp-tool-names.ts` | the document-global tool-name lock every registration path reserves through (#2613). Dependency-free; ships as `@happyvertical/smrt-web/webmcp-tool-names` | see below |
44
44
  | `intents.ts` + `capability-classification.ts` | the framework-agnostic declarative view-intent contract, its declaration registry, and the one capability classification rule both it and `webmcp.ts` apply (#2587, #2588). Ships as the dependency-free `@happyvertical/smrt-web/intents` entry — importing it pulls no client-data engine | — |
45
45
  | `data-query.ts` | dependency-free browser mirror and defensive response normalizer for the canonical bounded data-query envelope (#2444) | — |
package/README.md CHANGED
@@ -98,6 +98,46 @@ Client UUIDs and idempotent strict inserts reconcile the optimistic row instead
98
98
  of creating a second server identity. Web Locks elect one replay leader across
99
99
  tabs when supported.
100
100
 
101
+ ### Offline writes through your own server operation
102
+
103
+ When a model's generated write verbs are closed because a permission-gated
104
+ service owns its write rules, replay through that operation instead of
105
+ `sync/apply`. Pass `transport` to `offlineOutbox()` for a collection, or use
106
+ `offlineCommandQueue()` for writes with no collection:
107
+
108
+ ```ts
109
+ import {
110
+ dataSurfaceActionCommandTransport,
111
+ offlineCommandQueue,
112
+ } from '@happyvertical/smrt-web';
113
+
114
+ const punches = offlineCommandQueue({
115
+ name: 'clock_in',
116
+ namespace, // the same DurableStoreKey as the app's other outboxes
117
+ transport: dataSurfaceActionCommandTransport({
118
+ transport: actionTransport, // the surface's online action transport
119
+ request: (command) => ({
120
+ identity: descriptor.identity,
121
+ actionId: 'clock-in',
122
+ expectedRevision: currentRevision(), // read at first replay, then pinned
123
+ selection: { scope: 'current-page' },
124
+ payload: command.payload,
125
+ }),
126
+ }),
127
+ });
128
+
129
+ const itemId = await punches.enqueue({ payload: { note } });
130
+ if (!itemId) {
131
+ // Not captured durably (no IndexedDB): perform the action online instead.
132
+ }
133
+ ```
134
+
135
+ Each replay carries the write's durable `idempotencyKey`, which the server
136
+ operation must dedupe on. Each route replays FIFO under its own cross-tab
137
+ leader, with the same backoff and `pending → uploading → synced | failed` state
138
+ machine. A reloaded write waits until a queue with its name attaches; it is
139
+ never re-routed to `sync/apply`.
140
+
101
141
  ### Persisted read cache
102
142
 
103
143
  `persistCollection()` warm-starts from an IndexedDB snapshot, then revalidates
@@ -105,6 +145,11 @@ in the background. Its namespace includes API, tenant, identity, and manifest
105
145
  hash, so user switches and contract changes cannot hydrate another scope's rows.
106
146
  Sensitive collections should omit this capability.
107
147
 
148
+ `persistDataSurface({ namespace, identity })` does the same for a mounted data
149
+ surface whose rows come from a principal-scoped query rather than a generated
150
+ `list` route: `load()` before the first fetch, `save(rows)` after each refresh.
151
+ It shares the namespace, so the same wipe clears it.
152
+
108
153
  ### Live invalidation
109
154
 
110
155
  Create one app-wide `createSmrtWebEventSubscriber()` and register
@@ -132,10 +177,10 @@ core.
132
177
  | --- | --- |
133
178
  | Collections | `createSmrtCollection`, `createSmrtWebClient`, `newLocalId` |
134
179
  | Remote queries | `createSmrtWebQuery`, `SmrtWebQueryTransport` |
135
- | Data-surface actions | `executeSmrtWebDataSurfaceAction`, `SmrtWebDataSurfaceActionTransport` |
180
+ | Data-surface actions | `executeSmrtWebDataSurfaceAction`, `SmrtWebDataSurfaceActionTransport`, `dataSurfaceActionCommandTransport` |
136
181
  | HTTP | `createDefinitionFetchers`, `unwrapListResult`, `unwrapItemResult` |
137
- | Offline | `offlineOutbox`, `getOutboxHandle` |
138
- | Persistence | `persistCollection`, `wipeDurableStore` |
182
+ | Offline | `offlineOutbox`, `offlineCommandQueue`, `getOutboxHandle` |
183
+ | Persistence | `persistCollection`, `persistDataSurface`, `wipeDurableStore` |
139
184
  | Live updates | `createSmrtWebEventSubscriber`, `liveInvalidation` |
140
185
  | Version awareness | `createUpdateState` |
141
186
  | WebMCP | `registerWebMcpTools`, `registerWebMcpBespokeTool`, `registerViewIntent`, `reserveWebMcpToolNames` |
@@ -34,7 +34,9 @@ runtime dependency (only `fake-indexeddb` as a test devDep). The public surface
34
34
  is engine-free by construction (the boundary check is the proof).
35
35
 
36
36
  **Replay is sync-apply-ONLY** (`offline/engine.ts` → `POST
37
- {basePath}/sync/apply`), never `ctx.fetchers.create`. This is load-bearing:
37
+ {basePath}/sync/apply`), never `ctx.fetchers.create` — unless the consumer
38
+ declared its own replay transport (see "Consumer-declared replay transports"
39
+ below). This is load-bearing:
38
40
  the normal REST create strips the client id (#1540) and mints a NEW server id,
39
41
  which would orphan the optimistic row the outbox is keeping; sync-apply's
40
42
  strict-insert path preserves the client UUID, so replay reconciles the exact
@@ -56,15 +58,18 @@ response → whole batch stays `pending`.
56
58
 
57
59
  **Shared, namespace-keyed engine** (`offline/engine.ts`,
58
60
  `getOrCreateOutboxEngine`): N collections under the same `namespace` share ONE
59
- ref-counted engine = ONE IndexedDB db + ONE leader lock + ONE FIFO queue. This
61
+ ref-counted engine = ONE IndexedDB db + ONE queue, with one leader lock per
62
+ replay route (see #3021 below; all sync-apply collections are one route). This
60
63
  is REQUIRED for correctness, not an optimization — independent per-collection
61
64
  locks would let two tabs each win a different collection's lock and both replay.
62
65
  The last collection to detach (via `teardown`) disposes the engine; the durable
63
66
  ROWS survive for the next load.
64
67
 
65
68
  **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
69
+ one replays each route. A tab requests an EXCLUSIVE `navigator.locks` lock keyed
70
+ `smrt-web-outbox-leader:<namespace>` (sync-apply; unchanged name) or
71
+ `smrt-web-outbox-leader:<namespace>:transport:<name>` for each route it has a
72
+ binding for, and holds it while leader; the browser
68
73
  auto-releases on tab crash/close (no heartbeat) so the next tab takes over
69
74
  instantly. **Single-tab fallback (documented gap):** no `navigator.locks` →
70
75
  warn once + acquire leadership unconditionally; the outbox still replays but the
@@ -89,3 +94,69 @@ durable → exactly-once replay) is complete here.
89
94
  `DurableResource` under `durableStoreNamespace(config.namespace)`, so
90
95
  `wipeDurableStore(namespace)` (a logout / tenant-switch) empties the queue; the
91
96
  namespace is also the IndexedDB dbName and the leader-lock root.
97
+
98
+ ## Consumer-declared replay transports (#3021)
99
+
100
+ Sync-apply calls `collection.create()` directly, so it cannot carry a write
101
+ whose rules live in a hand-written, permission-gated service (or a model whose
102
+ generated verbs are closed). Two entry points replay through the consumer's own
103
+ operation instead, on the SAME engine:
104
+
105
+ - `offlineOutbox({ transport })` — a collection's writes queue with
106
+ `row.transport = object.name`; `syncApplyBasePath` is ignored.
107
+ - `offlineCommandQueue({ name, namespace, transport })` — no collection;
108
+ `enqueue()` returns the idempotency key, or `undefined` when the write was
109
+ not captured durably (the caller performs it online). Its `name` shares the
110
+ per-object event routing, so it must not equal a sibling collection name.
111
+
112
+ `dataSurfaceActionCommandTransport` adapts a data-surface action transport:
113
+ the request is built at the FIRST replay attempt (current `expectedRevision`)
114
+ and pinned durably to the row via `command.pin()` before sending; every later
115
+ attempt, including after a reload, resends the pinned request, because the
116
+ server fingerprints the request per idempotency key and a changed resend gets
117
+ `idempotency_conflict` instead of the recorded result. With `preview: true`
118
+ the preview's `confirmationToken` is pinned too before apply; a retry applies
119
+ directly with it (the server replays a completed idempotency record before
120
+ checking the token or revision — re-previewing an applied write would get
121
+ `stale_revision`) and previews again only when apply says the token is
122
+ unusable (`invalid_or_expired_confirmation`, `confirmation_*`,
123
+ `stale_preview`), which means nothing was recorded. `apply`
124
+ carries `idempotencyKey`, `preview: true` runs preview first for its
125
+ `confirmationToken`, and `classifySmrtWebDataSurfaceActionResult` maps reasons
126
+ (transient → `write_failed`, auth → `auth_required`, stale → `conflict`,
127
+ anything else incl. `denied` → terminal).
128
+
129
+ Invariants:
130
+
131
+ - **Durable route, never re-routed.** The row stores the transport NAME
132
+ (functions are not durable). A transport row never falls back to
133
+ `sync/apply`: that is the path its consumer closed.
134
+ - **Per-route leadership, FIFO, and backoff.** Leadership is one Web Lock per
135
+ route, requested by a tab only while it has a binding serving that route
136
+ (sync-apply keeps the legacy lock name, so it stays exclusive with older
137
+ builds). A tab that cannot serve a route never leads it, so it can never
138
+ strand another tab's rows for that route — the failure a single
139
+ namespace-wide lock had. Rows of a route no attached tab serves (e.g. after
140
+ a reload, before its queue re-attaches) simply wait; they gate nothing else.
141
+ FIFO and backoff gating hold within a route, not across routes; sync-apply
142
+ rows still batch (≤1000), a transport row replays alone. Detaching a route's
143
+ last binding (or disposing the engine) stops new sends at once but holds the
144
+ lock until the in-flight send and its durable transition settle, so another
145
+ tab never replays a row this one is still settling.
146
+ - **Wipe registration.** The queue registers synchronously at engine
147
+ construction (not after the async open), so a wipe issued right after attach
148
+ still clears rows already on disk; replay is suspended while a wipe is
149
+ pending. `dispose()` waits for a wipe already issued before closing the
150
+ queue (a disposed-while-opening engine leaves closing to `dispose()`).
151
+ `wipeDurableStore` drops a namespace's registrations, so the clear
152
+ callback forgets ours and the next enqueue re-registers; a later wipe still
153
+ clears rows written after the first.
154
+ - **Sync-apply endpoint comes from bindings, not the engine creator.** A
155
+ command queue may create the shared engine first; the first sync-apply
156
+ binding supplies `basePath`/`fetchFn`.
157
+ - **Same state machine.** `OutboxCommandResult` uses the sync-apply result
158
+ vocabulary and goes through `applyResult`; a thrown transport is the
159
+ ambiguous path (backoff, resend with the SAME `idempotencyKey`, `attempt`
160
+ incremented). The server operation must dedupe on that key.
161
+ - Rows written before #3021 lack `transport` and keep their sync-apply
162
+ meaning, so no IndexedDB schema bump.
@@ -59,6 +59,25 @@ omit the capability and never touch disk.**
59
59
  nothing, write-back is a no-op). Never throws — the outbox's `probeIndexedDb`
60
60
  posture.
61
61
 
62
+ ### Data-surface persistence — `persistDataSurface(config)` (#3021)
63
+
64
+ For a mounted surface (e.g. `mountListDataSurface`) whose rows come from a
65
+ principal-scoped data-surface query rather than a generated `list` route. The
66
+ page owns its rows, so this is a handle, not a capability: `load()`, debounced
67
+ `save(rows)` (last call wins; writes serialized), `flush()`, and `dispose()`
68
+ (flushes, then releases). It reuses the namespace's shared snapshot engine —
69
+ same database, durable-resource registration, wipe, and no-IndexedDB degrade.
70
+ Rows are keyed `data-surface:` + JSON of `[kind, surfaceId, subject.type,
71
+ subject.id]`, which cannot collide with a collection name. Principal scoping
72
+ comes from the namespace's `identityId`; nothing here widens a server scope.
73
+ The shared store registers for wipes synchronously when created (before the
74
+ async open; its clear awaits the open). A wipe bumps the shared engine's
75
+ `wipeEpoch` synchronously and drops its registration; a
76
+ handle created before it is inert afterwards (`load` → nothing, `save` and any
77
+ pending save dropped — no await sits between the epoch check and the save
78
+ transaction), and the next acquire re-registers so a second wipe still
79
+ clears.
80
+
62
81
  ### `updateAvailable` primitive — `createUpdateState(config)` (framework-free)
63
82
 
64
83
  A tiny pub/sub (`update-state.ts`) with TWO INDEPENDENT signals; `updateAvailable