@happyvertical/smrt-web 0.51.28 → 0.51.30
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 +2 -2
- package/README.md +48 -3
- package/agents/offline-outbox.md +75 -4
- package/agents/version-persistence.md +19 -0
- package/dist/chunks/{src-0IQGhPIM.js → src-B2uQWayW.js} +476 -81
- package/dist/chunks/src-B2uQWayW.js.map +1 -0
- package/dist/index.d.ts +291 -11
- package/dist/index.js +2 -2
- package/dist/webmcp.js +1 -1
- package/package.json +3 -3
- package/dist/chunks/src-0IQGhPIM.js.map +0 -1
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-
|
|
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` |
|
package/agents/offline-outbox.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
67
|
-
`smrt-web-outbox-leader:<namespace>`
|
|
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
|