@ultimat3/realtime 20.2.1 → 21.0.0

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.
Files changed (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. package/src/server-render-client.ts +0 -96
package/README.md CHANGED
@@ -8,7 +8,7 @@ Three tiers, one ladder, one protocol. Climbing a rung is a config change, never
8
8
  |---|---|---|
9
9
  | **1 — channels** | `publish`/`subscribe` on typed topics, presence, cursors, typing indicators | ~0. One filtered `send` per subscribed socket — no DB, no replication slot. **At most once**: a frame backpressure drops is counted, never replayed |
10
10
  | **2 — live queries** | the list updates when someone else edits; your own click feels instant | one change feed + a matcher per query id + a bounded change window |
11
- | **3 — local-first** | writes that survive being offline | a durable local store, a rebase log, client-side migrations, a conflict story per mutator |
11
+ | **3 — local-first** | writes that survive being offline | a durable outbox, client-side migrations, a conflict story per mutator (plan 101 slice 12) |
12
12
 
13
13
  Tier 2 covers ~90% of "make it realtime". Tier 3 buys exactly one extra property — offline writes — and charges a client database for it. Do not buy it by accident.
14
14
 
@@ -25,35 +25,34 @@ export const liveFeed = query({
25
25
 
26
26
  // mutator (action + optimistic local twin)
27
27
  export const likePost = mutator({
28
- // Convergent, not incremental: `local` replays on every rebase, so applying it N times has to
28
+ // Convergent, not incremental: `local` replays on every server update, so applying it N times has to
29
29
  // equal applying it once — `likedByMe` is what makes the second application a no-op.
30
30
  local(tx, { postId }) {
31
31
  tx.posts.update(postId, (p) =>
32
32
  p.likedByMe ? {} : { likedByMe: true, likeCount: p.likeCount + 1 });
33
33
  },
34
34
  async server(ctx, { postId }) { return ctx.posts.like(postId); },
35
- conflict: 'server-wins', // | 'last-write-wins' | custom(merge)
35
+ conflict: 'server-wins', // | 'last-write-wins' | { kind: 'custom', merge(local, server) }
36
36
  });
37
37
  ```
38
38
 
39
- `persist: true` on the query moves that route from tier 2 to tier 3. Same mutator, same authz, same
40
- frames — `local` starts writing to a durable store and the mutation queue starts surviving reloads.
41
- **One protocol serves all three tiers**: a channel message, a live-query patch and an offline
42
- mutation drain are frames in the same discriminated union (`src/sync-protocol.ts`), so the client's
43
- frame handler is unchanged between rungs.
39
+ A write is always HTTP: `useMutation(likePost)` runs `local` into the page store's optimistic
40
+ overlay, then POSTs the mutator's action with an idempotency key. The socket is **read-only** —
41
+ subscriptions go up, snapshots, patches and presence come down (protocol 3, 21.0.0).
44
42
 
45
- `local` must be pure — no I/O, no `Date.now()`, no `Math.random()` — because rebase replays it.
43
+ `local` must be pure and convergent — no I/O, no `Date.now()`, no `Math.random()`, and applying it
44
+ over its own result changes nothing — because the overlay REPLAYS it over every server update.
46
45
 
47
46
  ## Two entries, and which one an island may import
48
47
 
49
- `As of 2026-08`, `@ultimat3/realtime` is the **client** half — the hooks, the identity map, the offline queue, the
50
- wire and the reconnect vocabulary. `@ultimat3/realtime/server` is the bus, the Postgres replication
48
+ `@ultimat3/realtime` is the **client** half — the hooks, the page's record store, the offline outbox,
49
+ the wire and the reconnect vocabulary. `@ultimat3/realtime/server` is the bus, the Postgres replication
51
50
  path and the sync node. A name lives in exactly one of them; the `Entry` column below says which.
52
51
 
53
52
  The split is not cosmetic. `nats` `require()`s `stream/web`, so one barrel carrying `openNatsClient`
54
- beside `useLive` made the browser island this package promises **unbuildable** —
53
+ beside the client hooks made the browser island this package promises **unbuildable** —
55
54
  `Browser build cannot require() Node.js builtin: "stream/web"`. `packages/cli/src/realtime-browser-barrel.test.ts`
56
- bundles an entry importing only `useLive` for `target: 'browser'` and fails the build if either
55
+ bundles a client-only entry for `target: 'browser'` and fails the build if either
57
56
  half reaches the other; `barrel-split.test.ts` fails if one name is exported from both.
58
57
 
59
58
  Migrating from 7.x: an import of a **server** name changes its specifier and nothing else.
@@ -63,8 +62,8 @@ Migrating from 7.x: an import of a **server** name changes its specifier and not
63
62
  + import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime/server';
64
63
  ```
65
64
 
66
- Client names — `useLive`, `liveHookFor`, `LiveClient`, `OfflineQueue`, `RebaseLog`, `IdentityMap`,
67
- `encode`/`decode`, every `X_*` error class — are unchanged.
65
+ Client names — the hooks, `RecordStore`, `OfflineQueue`, `encode`/`decode`, every `X_*` error
66
+ class — stay on `.`.
68
67
 
69
68
  ## Public API
70
69
 
@@ -76,68 +75,79 @@ Client names — `useLive`, `liveHookFor`, `LiveClient`, `OfflineQueue`, `Rebase
76
75
  | fanout | `./server` | `Transport`, `InProcessTransport`, `NatsTransport`, `selectTransport`, `subjectMatches` |
77
76
  | the bus, behind `NatsTransport` | `./server` | the port — `NatsClient`, `NatsMessage`, `NatsSubscription`, `NatsConnect`, `NatsTarget`, `parseNatsUrl` — plus `openNatsClient` (the `nats` adapter), `NatsKvSet`, `ensureKvBucket`, `kvGet`/`kvLast`/`kvWrite`, `assertBucket`, `encodeToken`/`decodeToken`, and `FakeNatsBroker`/`fakeNatsConnect` for tests |
78
77
  | reconnect | both | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `backoffDelay`, `Scheduler`, `timeoutScheduler` on `.`; `RingChangeBuffer`, `drainPlan`, `AcceptBudget`, `reconnectFrame` on `./server` — the node's half of the reconnect is the node's |
79
- | the client store | `.` | `IdentityMap` — one row value per `(entity, id)` — plus `RowWindows`, `rowKey`, `privateScope`, `applyPatches`/`orderAfterPatches` |
80
- | tier 3 | `.` | `MemoryLocalStore`, `createOpfsLocalStore`, `OfflineQueue`, `RebaseLog`, `reconcile`, `custom` |
81
- | wire | `.` | `PROTOCOL_VERSION`, `encode`, `decode`, `Frame` |
82
- | halves | both | `LiveClient` on `.`; `createSyncNode` / `listenSyncNode` (`sync` role) on `./server` |
78
+ | the page's record store | `.` | `RecordStore` — one record per `type:key`, synced truth plus the optimistic overlay — `recordKey`, `LocalTx`, `RowWindows`, `applyPatches`/`orderAfterPatches` |
79
+ | the outbox | `.` | `OfflineQueue`, `MemoryQueueStore` — replayed over HTTP from plan 101 slice 12. The conflict vocabulary is `ConflictPolicy` from `@ultimat3/core`; realtime declares none |
80
+ | wire | `.` | `PROTOCOL_VERSION` (3), `encode`, `decode`, `Frame` |
81
+ | the node | `./server` | `createSyncNode` / `listenSyncNode` (`sync` role) |
83
82
  | a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
84
- | hooks | `.` | `setLiveClient`, `useLive`, `useConnection`, `useMutation`, `useMutationQueue`, `hasLiveClient` |
85
- | the server render's client | `.` | `serverRenderLiveClient` — what a hook falls back to with no DOM; `LiveClientLike` is the shape both it and `LiveClient` satisfy |
86
- | the typed projection | `.` | `liveHookFor` — one query bound to one named hook |
83
+ | hooks | `.` | `useQuery`, `useRecord`, `useMutation`, `useMutationQueue`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
84
+ | channels | `.` | `channel`, `topic`, `readPresence`, the channel frame types |
85
+ | offline | `.` | `pageOutbox`, `recordPersister`, `persistedTypes`, `openLocalStore`, `pageLocalStore`, `MemoryLocalStore` |
86
+ | the socket's worker | `./sync-worker` | the SharedWorker entry — no exports |
87
87
 
88
- ## The four hooks
88
+ ## The hooks
89
89
 
90
- Register the client once, in the app entry. Every hook reads it from there — no hook takes a client
91
- argument, and one that runs **in a browser** before the registration is `X_LIVE_CLIENT_MISSING`,
92
- never a default.
93
-
94
- **A server render is not a missing registration.** With no DOM there is no socket a client could
95
- have been registered for, so every hook falls back to `serverRenderLiveClient()`: `useLive` answers
96
- `state() === 'loading'` with no rows, `useConnection()` reports online, both queue counts are `0`,
97
- and `mutate` / `drain` refuse with `X_LIVE_SERVER_RENDER`. The page renders its own loading branch
98
- and a hydrating island takes over. `hasLiveClient()` still answers `false` there, which is what a
99
- component with a static fallback is asking. `useLive` in a page BODY is not made live by this — a
100
- page component never runs in a browser; put the live half in an `island()`.
90
+ One record store and one socket per **page**, on `globalThis`: every island is its own bundle, so a
91
+ module-level singleton would be one per island. The island bootstrap `x build` prepends installs
92
+ realtime for its bundle — `installRealtime({ signal: createSignal, sync: { url, buildId } })` — and
93
+ an island never constructs a client or a socket. The socket opens on the first live hook; an island
94
+ that only reads records or writes ships none of it.
101
95
 
102
96
  ```ts
103
- setLiveClient(new LiveClient({ signal: createSignal, connect, buildId, store, queue }));
104
-
105
- const feed = useLive(liveFeed, () => ({ orgId: actor.orgId })); // feed(), feed.state(), feed.unsubscribe()
106
- const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
107
- const like = useMutation(likePost); // await like(input); like.pending
108
- const queue = useMutationQueue(); // .pending .failed .drain()
97
+ import {
98
+ type ChannelRef,
99
+ type MutatorLike,
100
+ useChannel,
101
+ useConnection,
102
+ useMutation,
103
+ useMutationQueue,
104
+ usePresence,
105
+ useQuery,
106
+ useRecord,
107
+ } from '@ultimat3/realtime';
108
+
109
+ declare const orgId: string;
110
+ declare const postId: string;
111
+ declare const LIKE_POST: MutatorLike; // name + local twin + conflict — never the mutator VALUE
112
+ declare const orgFeed: ChannelRef<'orgId'>; // a `channel('org-feed', { params: ['orgId'], … })`
113
+ declare function onEvent(event: Readonly<Record<string, unknown>>): void;
114
+
115
+ const feed = useQuery({ name: 'liveFeed', live: true }, { orgId }); // AsyncState<readonly Row[]>
116
+ const posts = useQuery({ name: 'listPosts', entity: 'posts' }, {}); // one HTTP read, rows as records
117
+ const post = useRecord('posts', postId); // AsyncState<Row | undefined>
118
+ const like = useMutation(LIKE_POST); // await like(input); like.pending
119
+ const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
120
+ const writes = useMutationQueue(); // .pending .failed
121
+ const feedChannel = useChannel(orgFeed, { orgId }, { onEvent }); // records → the store; events → onEvent
122
+ const room = usePresence(orgFeed, { orgId }); // the channel's roster
109
123
  ```
110
124
 
111
- | Rule | Why |
112
- |---|---|
113
- | **No `solid-js` import.** Reactivity is the `SignalFactory` the client was built with | one reactive runtime per app, and a tier-3 package that installs and tests with none |
114
- | Every member is a **getter**, every result set an **accessor** | a value snapshotted at hook time never re-renders |
115
- | A thunk `input` is read **once**, at subscribe time | nothing here re-runs it; changing input is a new subscription |
116
- | The caller owns `unsubscribe` | this layer does not know what a mount is |
117
- | Every subscription handle (`useLive`'s return, `client.subscribe(topic, …)`'s return) is `Disposable` | `using feed = useLive(liveFeed, () => input)` unsubscribes on scope exit — the same call as `unsubscribe()`, never a second teardown path |
118
- | `pending` / `failed` are read off the queue, through an invalidation signal refreshed on each `mutate` and `drain` | the count is never a second copy of the queue, and `OfflineQueue` holds arrays, not signals |
119
-
120
- Tier 2 has no queue, so `pending` is `0` there — stated, not guessed.
121
-
122
- ### The typed one: `liveHookFor`
125
+ **One socket per origin and principal**: the page's socket lives in a `SharedWorker`
126
+ (`@ultimat3/realtime/sync-worker`, bundled by `x build`) shared by every tab; with no worker the
127
+ same engine runs in-page. Writes that find no network go to the page's outbox — overlay kept — and
128
+ replay over HTTP, under their original idempotency keys, when the socket comes back. The outbox is
129
+ the page boot's (`@ultimat3/realtime/boot`): an island only reads it off the page. On a page the
130
+ CLI renders no boot for (no scope tag, so nothing on it persists) such a write is refused like any
131
+ other — rejected, overlay taken back — never held in memory a reload would silently lose.
123
132
 
124
- `useLive(query, input)` takes any object carrying a `name`, so it cannot type either side.
125
- `liveHookFor` binds one declared `query({ live: true })` to one named hook and carries both types
126
- through — the query's `input` in, its row type out. It is not a second subscribe path: it *is*
127
- `useLive`, with the name and the types already bound.
133
+ `<AsyncRegion state={feed()} …/>` takes the answer as-is: `AsyncState` is `@ultimat3/core`'s, the
134
+ same type `@ultimat3/ui` renders.
128
135
 
129
- ```ts
130
- export const useLiveFeed = liveHookFor(liveFeed); // app/feed/hooks.ts — one line, no codegen
131
-
132
- const feed = useLiveFeed({ orgId: actor.orgId }); // feed()[0].title typechecks
133
- useLiveFeed({ orgIdd: actor.orgId }); // does not compile
134
- ```
135
-
136
- The query's name is read **per call**, never at bind time: `registerQueries()` stamps it at boot,
137
- after a module-level binding has already run. Binding a query with no `live: true` is
138
- `X_QUERY_NOT_SUBSCRIBABLE`, thrown where the binding is written — a read that never patches has no
139
- subscription to hold, and the non-live read from a component is `query.client({ baseUrl })`.
140
- `type-pins.ts` fails the build if the hook ever widens either type.
136
+ | Rule | Why |
137
+ |---|---|
138
+ | A query ref is `{ name, live?, entity? }`, never the query VALUE | importing a `query()` drags its read path into the island (698,801 B measured) |
139
+ | Lists hold keys; rows are the store's | a record updated by any answer or frame re-renders every list showing it, with no refetch |
140
+ | Every write is HTTP through core's `clientTransport` | one write path: the action's authz, idempotency and contract; the socket carries none |
141
+ | The answer's records are adopted before the overlay goes | a convergent twin never flickers back to the pre-write value |
142
+ | **No `solid-js` import.** Each bundle installs its own `SignalFactory` | every island carries its own solid-js; a signal from another bundle is invisible to its effects |
143
+ | Every member is a **getter**, every result an **accessor** | a value snapshotted at hook time never re-renders |
144
+ | `input` is read **once** | nothing here re-runs it; a changed input is a new `useQuery` |
145
+ | The caller owns `release()` / `using` | this layer does not know what a mount is |
146
+
147
+ **A server render** (no install, no DOM) answers `pending`, reports online and creates no page
148
+ state; a `useMutation` call refuses with `X_LIVE_SERVER_RENDER`. With a DOM and no install, every
149
+ hook is `X_REALTIME_UNINSTALLED`. `hasPageSocket()` is the guard a component with a static fallback
150
+ asks.
141
151
 
142
152
  ## Who a socket is
143
153
 
@@ -147,16 +157,25 @@ resolves is what every policy downstream decides against — the topic guard, `a
147
157
  the per-tenant subscription cap.
148
158
 
149
159
  ```ts
150
- createSyncNode({
160
+ import type { Actor } from '@ultimat3/core';
161
+ import type { SyncGrant, SyncNodeOptions } from '@ultimat3/realtime/server';
162
+
163
+ declare function sessionFrom(
164
+ request: Request,
165
+ ): Promise<{ actor: Actor; expiresAt: number; token: string } | null>;
166
+ declare function renew(token: string): Promise<SyncGrant | null>;
167
+
168
+ // The one option this section is about; `createSyncNode({ …, authenticate })` takes it.
169
+ const options: Pick<SyncNodeOptions, 'authenticate'> = {
151
170
  // From @ultimat3/auth, or anywhere else: `sync` imports no authenticator, exactly as it owns no
152
- // mutation logic. `refresh` is yours too, so the framework retains no credential of its own.
171
+ // business logic. `refresh` is yours too, so the framework retains no credential of its own.
153
172
  authenticate: async (request) => {
154
173
  const session = await sessionFrom(request);
155
174
  return session === null
156
175
  ? null
157
176
  : { actor: session.actor, expiresAt: session.expiresAt, refresh: () => renew(session.token) };
158
177
  },
159
- });
178
+ };
160
179
  ```
161
180
 
162
181
  | The answer | What the node does |
@@ -226,37 +245,22 @@ authenticated socket is the cheapest foothold there is.
226
245
  `input` reaches `canonicalJson`, which recurses, so an unbounded one is a stack overflow in the
227
246
  process rather than a slow query.
228
247
 
229
- ## One row per `(entity, id)`
230
-
231
- Two components subscribing to two live queries that both return post #7 hold **one** row, not two
232
- copies of it. That is the client's whole store: a `LiveClient` owns one `IdentityMap`, every live
233
- window is an ordered list of ids over it, and the tier-3 local store's tables are membership over
234
- the same map. A write through any of them is the same row for all of them.
235
-
236
- ```ts
237
- const feed = useLive(liveFeed, () => ({ orgId })); // holds p1, p2, p7
238
- const pinned = useLive(livePinned, () => ({ orgId })); // holds p7
239
-
240
- await like({ postId: 'p7' }); // one optimistic write...
241
- feed()[2] === pinned()[0]; // ...and both views are looking at it
242
- ```
248
+ ## One record per `type:key`, per page
243
249
 
244
- Nothing is declared to get this. There is no normalization schema, no cache key, no selector — an
245
- app writes `useLive` and `useMutation` exactly as before.
250
+ Two islands showing post #7 hold **one** record, not two copies. The page's `RecordStore` is the
251
+ whole client store: every live window, every `useQuery` list and every `useRecord` is a projection
252
+ over it, and an HTTP answer's records, a socket patch and an optimistic write all land in it.
246
253
 
247
254
  | Rule | Why |
248
255
  |---|---|
249
- | Identity is `(entity, id)`, never `id` alone | two entities may spell one id the same way; `posts/7` and `users/7` are two rows |
250
- | The entity comes **from the server**, on the `snapshot` frame | the shape is compiled server-side out of `sql`; a browser cannot derive it, and a scope an app declares by hand is a second place for it to be wrong |
251
- | A subscription the server named no entity for keeps its rows in a scope private to itself | no sharing is a stale view; wrong sharing is two entities merged into one row |
252
- | A value is **replaced, never mutated** — every write is a new object | a mutated row is a render that never happens |
253
- | A write **merges** columns; it never drops one | two queries may project different columns of one row, and the narrower one must not blank what the wider one renders |
254
- | A row is dropped when the last window and the last table let go of it | an infinite scroll must not retain every row it ever saw |
255
- | A rebase rolls back through the same map | the optimistic write, the server's truth and the replay are one row's history, not a second copy's |
256
-
257
- `entity` on a `snapshot` frame is **additive**: an older node omits it and the client falls back to
258
- the private scope, a newer node sends it and an older client ignores it. Both skews are safe in
259
- both directions, which is why it carries no `PROTOCOL_VERSION` bump.
256
+ | Identity is `type:key` — the entity's NAME and the key the SERVER computed | two entities may spell one key the same way, and the browser has no entity schema to derive a key from |
257
+ | The live path names the type server-side (`recordTypeForTable`) | a changefeed speaks tables; the store speaks entities |
258
+ | Two layers: synced truth and the optimistic overlay | a refused write drops its overlay and shows exactly what the server said — never a stale before-image |
259
+ | The overlay is REPLAYED over every server update | two pending writes on one row land in order on top of someone else's change |
260
+ | A value is **replaced, never mutated**; a write **merges** columns | a mutated row is a render that never happens; a narrower projection must not blank a wider one |
261
+ | A structurally bad row is `X_RECORD_REJECTED`, dropped and reported | never partially merged — a keyless row would overwrite another record |
262
+ | The last holder leaving evicts the record | an infinite scroll must not retain every row it ever saw |
263
+ | A principal change clears the store | nothing of the previous principal survives in memory |
260
264
 
261
265
  ## Reconnect is the hard part
262
266
 
@@ -292,14 +296,18 @@ subscribing to a topic *is* joining the room. `reconnectAt` is what a component
292
296
  waits; `close()` cancels it, and `connect()` starts over. The timer comes from an injected
293
297
  `Scheduler`, so a test fires it by hand instead of sleeping.
294
298
 
299
+ A browser's curve is `browserBackoff` — the same core curve, `equal` jitter, capped at
300
+ `BROWSER_RECONNECT_MAX_MS` (4s) where the server-side `defaultBackoff` caps at 30s: a node that
301
+ comes back is reached within seconds, and so is the `update-available` it carries. The socket
302
+ engine (one per origin, in the `SharedWorker`) keeps none of it across pages: a page arriving while
303
+ the node is down dials at once on a fresh curve, and the last page leaving forgets the target, so
304
+ the next build's page never dials with the old build id.
305
+
295
306
  ### Liveness: `heartbeatMs`
296
307
 
297
308
  A half-open socket — the TCP connection is dead and no `close` ever fires — is invisible to the
298
- browser. The client is the only thing that can end one.
299
-
300
- ```ts
301
- new LiveClient({ signal, connect, buildId, heartbeatMs: 15_000 }); // 0 disables the pass
302
- ```
309
+ browser. The client is the only thing that can end one, and the page socket beats at the default
310
+ below (`heartbeatMs` on the internal `LiveClient`; `0` disables the pass).
303
311
 
304
312
  | Property | Behaviour |
305
313
  |---|---|
@@ -353,16 +361,9 @@ wire twice by a reconnect that raced an ack.
353
361
  froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for every
354
362
  client connected longer than `maxLagMs` — the delta resume the retained window exists for, dead
355
363
  exactly during the deploy storm it was built for.
356
- - **An accepted mutation is committed, not merely acknowledged.** The `ack` drops the journal row
357
- and the rebase-log entry — there is nothing to roll back *to* any more, and a later reconcile
358
- would otherwise replay a write the server already applied over rows that have moved on. The row
359
- itself stays exactly as the optimistic twin left it: an accepted write does not flicker.
360
- - **A refused mutation is rolled back, not retried.** An `ack` carrying an error undoes that
361
- mutation's optimistic write *and* every write made after it — newest first — then replays the
362
- others without it, which is sound only because `local` is pure. The refused intent is dropped from
363
- the rebase log rather than retried: a denial is a decision about that intent, and replaying it
364
- would put the write the server refused back on the screen. Idempotent for a key the log does not
365
- hold, because a denial can arrive twice and tier 2 records nothing to undo.
364
+ - **The socket carries no writes (protocol 3, 21.0.0).** A write is HTTP; an `ack` is only ever a
365
+ refusal, naming the subscription it refused (that window renders `failed`) or the socket for a
366
+ frame the node could not read — including a `mutate` from a client one major behind.
366
367
  - **Nothing on the client detects drift, and nothing ever did.** `verifyDigest()` claimed to and had
367
368
  no caller (deleted 2026-08-23); the `digest` it read went with it (2026-08-24), along with the
368
369
  `count` beside it. What detects drift is the server's `desynced` mark and the re-snapshot it
@@ -388,10 +389,10 @@ wire twice by a reconnect that raced an ack.
388
389
  cannot mark a subscriber desynced — which is to say it cannot do any of the three things above.
389
390
  `WsLike.subscribe`/`unsubscribe` stay **declared and unused**: the interface is structural and a
390
391
  tracked app implements it, so deleting the members breaks that app's typecheck.
391
- - **Inbound frames are ordered per `mutate`-socket and per subscription, never per socket.** A
392
+ - **Inbound frames are ordered per subscription, never per socket.** A
392
393
  global per-socket lane puts every frame behind the slowest one, and the slowest one is a
393
394
  subscribe's snapshot read — the round trip every reconnecting client pays in a restart storm.
394
- `mutate` is one lane per socket; `subscribe` is one lane per sid, or per topic name; `hello` and
395
+ `subscribe` is one lane per sid, or per topic name; `hello` and
395
396
  the server-authored kinds are unlaned. A lane exists only while work is queued on it, because a
396
397
  lane keyed by a client-chosen sid that outlived its work is an unbounded map one socket can grow.
397
398
  - **`qid` is `@ultimat3/query`'s `queryHash(name, input)`** — `<name>:<first 16 hex of
@@ -528,16 +529,9 @@ wire twice by a reconnect that raced an ack.
528
529
  running half: one per change delivered off a relation that is not FULL, so the decisions it
529
530
  actually cost are countable rather than silent. A hard refusal at `x verify` time is the
530
531
  follow-up.
531
- - Tier 3's OPFS SQLite store is browser-only, is **not built**, and throws `X_NOT_IMPLEMENTED` on
532
- call. `createOpfsLocalStore` is exported from `.` and stays there when it ships — there is no
533
- third entry to wait for, and the refusal used to name one (`@ultimat3/realtime/browser`, a
534
- subpath `exports` never declared). `MemoryLocalStore`, beside it on `.`, implements the full
535
- journal/rollback/replay semantics today and is what the refusal's `fix:` names. It holds
536
- membership and the journal; the row values are the client's one `IdentityMap`, which is what a
537
- browser store has to inherit rather than re-implement.
538
- - The identity map is **per client**, in memory, and it is not a query cache: it answers "what is
539
- row X now", never "have I run this query before". Nothing evicts by time or size — a row lives
540
- exactly as long as a window or a table holds it.
532
+ - The record store is **per page**, in memory, and it is not a query cache: it answers "what is
533
+ record X now", never "have I run this query before". Nothing evicts by time or size — a record
534
+ lives as long as something holds it. Persisting it (IndexedDB) is plan 101 slice 12.
541
535
 
542
536
  ## Errors
543
537
 
@@ -545,7 +539,8 @@ wire twice by a reconnect that raced an ack.
545
539
  `X_PROTOCOL_VERSION` · `X_CURSOR_STALE` ·
546
540
  `X_REBASE_CONFLICT` · `X_TRANSPORT_UNAVAILABLE` · `X_TRANSPORT_PROTOCOL` ·
547
541
  `X_REPLICATION_FAILED` · `X_REPLICATION_PROTOCOL` · `X_REPLICATOR_SLOT_HELD` ·
548
- `X_LIVE_CLIENT_MISSING` · `X_LIVE_SERVER_RENDER` · `X_LIVE_QUERY_UNKNOWN` ·
542
+ `X_REALTIME_UNINSTALLED` · `X_SYNC_UNCONFIGURED` · `X_RECORD_REJECTED` ·
543
+ `X_LIVE_SERVER_RENDER` · `X_LIVE_QUERY_UNKNOWN` ·
549
544
  `X_LIVE_REPLICA_IDENTITY` ·
550
545
  `X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED` ·
551
546
  `X_TIMEOUT`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "20.2.1",
3
+ "version": "21.0.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,7 +18,9 @@
18
18
  },
19
19
  "exports": {
20
20
  ".": "./src/index.ts",
21
- "./server": "./src/server.ts"
21
+ "./boot": "./src/boot.ts",
22
+ "./server": "./src/server.ts",
23
+ "./sync-worker": "./src/sync-worker.ts"
22
24
  },
23
25
  "files": [
24
26
  "src",
@@ -36,8 +38,9 @@
36
38
  "test": "bun test"
37
39
  },
38
40
  "dependencies": {
39
- "@ultimat3/core": "20.2.1",
40
- "@ultimat3/query": "20.2.1",
41
+ "@ultimat3/core": "21.0.0",
42
+ "@ultimat3/entity": "21.0.0",
43
+ "@ultimat3/query": "21.0.0",
41
44
  "nats": "2.29.3"
42
45
  }
43
46
  }
@@ -3,7 +3,7 @@
3
3
  // app can reuse to apply the same patches to its own store.
4
4
  //
5
5
  // Order and values are folded separately: a live window keeps its ORDER here and its VALUES in the
6
- // identity map, so `applyPatches` is the array form of the same fold rather than a second one.
6
+ // record store, so `applyPatches` is the array form of the same fold rather than a second one.
7
7
 
8
8
  import type { JsonObject, Row, RowPatch } from './json';
9
9
 
package/src/boot.ts ADDED
@@ -0,0 +1,72 @@
1
+ // `@ultimat3/realtime/boot` — the page's boot, ONE classic script per document (plan 101), built
2
+ // and served by the CLI the way the sync worker is. It restores the principal's persisted records
3
+ // and opens the outbox, so a reload replays what the previous load queued even on a page whose
4
+ // islands only read. Here and not in `page-store.ts`, so no island bundle carries it.
5
+
6
+ import type { ClientScope } from '@ultimat3/core/page';
7
+ import { pageClient } from '@ultimat3/core/page';
8
+ import { pageLocalStore, scopeKey } from './local-store-idb';
9
+ import { pageOutbox } from './page-outbox';
10
+ import { BOOT_KEY, BOOT_RELEASE_KEY, type BootHost, pageRealtime } from './page-store';
11
+ import { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
12
+ import type { RecordStore } from './record-store';
13
+
14
+ /**
15
+ * The types the document marks persisted, read back off disk, then the page's outbox opened. None
16
+ * marked is no record disk.
17
+ */
18
+ async function restoreFromDisk(store: RecordStore, principal: Principal): Promise<void> {
19
+ const types = persistedTypes();
20
+ const keep = scopeKey(principal);
21
+ let persister: RecordPersister | undefined;
22
+ try {
23
+ // A sign-out by full navigation (a form post and a redirect) never calls `rescope()`, so the
24
+ // previous principal's rows and queued writes would outlive it on disk. The boot is the one
25
+ // moment every page load passes: everything not THIS principal's goes, before anything is
26
+ // restored. An unscoped page (rendered for nobody) wipes nothing — it knows no principal.
27
+ // Trade-off: two principals in two tabs of one browser — the newer boot wipes the other's
28
+ // disk; that tab keeps its records in memory and re-persists them on its next write.
29
+ if (keep !== undefined) await (await pageLocalStore()).wipeOthers(keep);
30
+ if (types.size > 0) {
31
+ persister = recordPersister({ store, local: await pageLocalStore(), types });
32
+ await persister.restore();
33
+ }
34
+ } catch (error) {
35
+ // A disk the browser refused (quota, a private window) costs the offline copy, never the page.
36
+ console.error(error);
37
+ }
38
+ // The outbox opens with the page, not with the first write or live hook: a reload that holds
39
+ // neither must still replay what the previous load queued (on open, `online`, the SW's drain).
40
+ // After the restore, so a replay settles overlays over the records it restored.
41
+ // A queued write flushes the persisted rows first, so both are on disk before a reload can come.
42
+ const kept = persister;
43
+ pageOutbox({ beforeEnqueue: kept === undefined ? undefined : () => kept.flush() });
44
+ }
45
+
46
+ type Principal = ClientScope['principal'];
47
+
48
+ /**
49
+ * Once per page, whoever calls first; the promise is what `pageRealtime().booted` answers. The
50
+ * scope is the page's own — a parameter only so a test can boot an unscoped page (an OBJECT, so an
51
+ * explicit `undefined` principal is not mistaken for "use the default").
52
+ */
53
+ export function bootPage(
54
+ scope: { readonly principal: Principal } = pageClient().scope,
55
+ ): Promise<void> {
56
+ const host = globalThis as BootHost;
57
+ const started = host[BOOT_KEY];
58
+ const waiting = host[BOOT_RELEASE_KEY];
59
+ // Already booting — unless what sits there is an island's placeholder, which this boot claims.
60
+ if (started !== undefined && waiting === undefined) return started;
61
+ Reflect.deleteProperty(host, BOOT_RELEASE_KEY);
62
+ const booted = restoreFromDisk(pageRealtime().store, scope.principal);
63
+ if (started !== undefined && waiting !== undefined) {
64
+ void booted.then(waiting);
65
+ return started;
66
+ }
67
+ Object.defineProperty(host, BOOT_KEY, { value: booted, configurable: true });
68
+ return booted;
69
+ }
70
+
71
+ // As a page script it runs itself; imported by a test (no DOM) it waits to be called.
72
+ if (typeof document !== 'undefined') void bootPage();
@@ -0,0 +1,42 @@
1
+ // The ONE `new WebSocket` in the framework. The page opens one socket, through this adapter; an
2
+ // island never dials, and neither does an app. Four handlers and a send — the reconnect, the
3
+ // backoff and the heartbeat all stay in `LiveClient`, which is why this is the whole adapter.
4
+
5
+ import type { ClientSocket } from './client-contract';
6
+ import type { SyncTarget } from './page-store';
7
+
8
+ export function browserSocket(url: string): ClientSocket {
9
+ const socket = new WebSocket(url);
10
+ return {
11
+ send: (data: string): void => {
12
+ socket.send(data);
13
+ },
14
+ close: (code?: number, reason?: string): void => {
15
+ socket.close(code, reason);
16
+ },
17
+ onOpen: (handler: () => void): void => {
18
+ socket.onopen = (): void => {
19
+ handler();
20
+ };
21
+ },
22
+ onMessage: (handler: (data: string) => void): void => {
23
+ socket.onmessage = (event: MessageEvent): void => {
24
+ handler(String(event.data));
25
+ };
26
+ },
27
+ onClose: (handler: (code: number) => void): void => {
28
+ socket.onclose = (event: CloseEvent): void => {
29
+ handler(event.code);
30
+ };
31
+ },
32
+ get bufferedAmount(): number {
33
+ return socket.bufferedAmount;
34
+ },
35
+ };
36
+ }
37
+
38
+ /** `?build=` rides the dial, so a stale tab is told to reload on the socket it opens. */
39
+ export function dialUrl(target: SyncTarget): string {
40
+ const joiner = target.url.includes('?') ? '&' : '?';
41
+ return `${target.url}${joiner}build=${encodeURIComponent(target.buildId)}`;
42
+ }
package/src/changefeed.ts CHANGED
@@ -26,6 +26,13 @@ export interface ChangeEvent<R extends Row = Row> {
26
26
  readonly orgId: string | null;
27
27
  /** Commit time, epoch ms. */
28
28
  readonly at: number;
29
+ /**
30
+ * The write that made this change: `writeDigest` of the idempotency key its request carried
31
+ * (`@ultimat3/core`). Absent for a change no keyed request made. Read off the WAL message the
32
+ * Postgres driver writes first in the transaction, or off the request scope in-process; a
33
+ * channel stamps it on the `records` frame so the writing page recognises its own echo.
34
+ */
35
+ readonly write?: string;
29
36
  }
30
37
 
31
38
  export interface ChangeFeedStartOptions {
@@ -0,0 +1,33 @@
1
+ // A declared channel's policy, asked on subscribe: the params are the input and the row is what
2
+ // the channel's own loader answered. Through `@ultimat3/query`'s `guard`, the package's one authz
3
+ // seam — the same call `policy-gate.ts` makes for a live query.
4
+
5
+ import type { Actor, Ctx } from '@ultimat3/core';
6
+ import { guard, QueryDeniedError } from '@ultimat3/query';
7
+ import type { Channel } from './channel-decl';
8
+ import { TopicForbiddenError } from './errors';
9
+
10
+ /**
11
+ * A denial is `X_TOPIC_FORBIDDEN`. A loader or a rule that RAISED is not a denial and leaves as it
12
+ * came, so the caller can tell an outage from a decision — `onActorChange` keeps the topic on one.
13
+ */
14
+ export async function authorizeChannel(
15
+ channel: Channel,
16
+ ctx: Ctx,
17
+ actor: Actor | null,
18
+ topic: string,
19
+ params: Readonly<Record<string, string>>,
20
+ ): Promise<void> {
21
+ if (channel.policy === undefined) return;
22
+ const row = channel.row === undefined ? null : await channel.row({ params, ctx });
23
+ try {
24
+ guard(channel.policy, { actor, input: params, row, ctx, query: channel.name }, 'live');
25
+ } catch (error) {
26
+ if (!(error instanceof QueryDeniedError)) throw error;
27
+ throw new TopicForbiddenError({
28
+ topic,
29
+ actorId: actor === null ? null : actor.id,
30
+ reason: `channel "${channel.name}" policy denied the subscribe`,
31
+ });
32
+ }
33
+ }
@@ -0,0 +1,34 @@
1
+ // One topic's transport bridge into this node — the shape `ChannelHub` refcounts, and its safe
2
+ // close.
3
+
4
+ import type { TransportSubscription } from './fanout';
5
+
6
+ /**
7
+ * One topic's fanout into this node. `sub` is the transport subscription as a PROMISE, published
8
+ * into the table before it is awaited: looked up before the await and written after it, two sockets
9
+ * reaching one topic at once opened two transport subscriptions — the second replacing the first in
10
+ * the table, and the first then unreachable by `#release`, by a socket dying, by `close()` or by
11
+ * anything else, delivering every message on that topic a second time for the life of the process.
12
+ *
13
+ * `null` means the slot is taken and nothing is open yet: the node cap is decided before the guard
14
+ * runs, so the reservation has to exist before there is anything to reserve it with.
15
+ */
16
+ export interface Bridge {
17
+ sub: Promise<TransportSubscription> | null;
18
+ refs: number;
19
+ }
20
+
21
+ /**
22
+ * A bridge released while its subscription is still opening still has to be closed — the transport
23
+ * hands the handle back after the caller has gone, and dropping the promise would leave a live
24
+ * subscription this node can no longer name. An open that failed has nothing to unsubscribe and its
25
+ * rejection was already answered to the subscriber that caused it.
26
+ */
27
+ export function unsubscribeWhenOpen(bridge: Bridge): void {
28
+ void bridge.sub?.then(
29
+ (sub) => {
30
+ sub.unsubscribe();
31
+ },
32
+ () => undefined,
33
+ );
34
+ }