@ultimat3/realtime 1.2.0 → 3.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 (61) hide show
  1. package/CLAUDE.md +641 -0
  2. package/README.md +336 -19
  3. package/package.json +6 -3
  4. package/src/apply-patches.ts +60 -0
  5. package/src/change-buffer.ts +77 -11
  6. package/src/channel.ts +202 -20
  7. package/src/client-contract.ts +81 -0
  8. package/src/client-frames.ts +175 -0
  9. package/src/client-heartbeat.ts +77 -0
  10. package/src/client-mutations.ts +114 -0
  11. package/src/client-topics.ts +54 -0
  12. package/src/client.ts +307 -273
  13. package/src/cursor.ts +7 -1
  14. package/src/errors.ts +193 -4
  15. package/src/frame-lanes.ts +58 -0
  16. package/src/hooks.ts +19 -5
  17. package/src/identity-map.ts +141 -0
  18. package/src/index.ts +99 -28
  19. package/src/json.ts +38 -1
  20. package/src/live-contract.ts +67 -0
  21. package/src/live-definition.ts +16 -11
  22. package/src/live-fanout.ts +150 -0
  23. package/src/live-query.ts +215 -268
  24. package/src/live-rows.ts +143 -0
  25. package/src/local-store.ts +86 -43
  26. package/src/nats-client.ts +132 -0
  27. package/src/nats-fake.ts +389 -344
  28. package/src/nats-jetstream.ts +21 -20
  29. package/src/nats-kv.ts +7 -7
  30. package/src/nats-lib-client.ts +210 -0
  31. package/src/nats-transport.ts +109 -138
  32. package/src/offline-queue.ts +146 -30
  33. package/src/pg-entity-row.ts +99 -31
  34. package/src/pg-replication.ts +84 -27
  35. package/src/pg-socket.ts +4 -1
  36. package/src/policy-gate.ts +13 -5
  37. package/src/presence.ts +76 -6
  38. package/src/query-hook.ts +56 -0
  39. package/src/query-window.ts +187 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +225 -34
  43. package/src/subscriber-gate.ts +209 -0
  44. package/src/subscription-book.ts +237 -0
  45. package/src/sync-auth.ts +124 -0
  46. package/src/sync-frames.ts +185 -0
  47. package/src/sync-listen.ts +73 -0
  48. package/src/sync-node.ts +324 -248
  49. package/src/sync-protocol.ts +115 -24
  50. package/src/sync-upgrade.ts +124 -0
  51. package/src/thundering-herd.ts +21 -0
  52. package/src/transport-env.ts +3 -3
  53. package/src/type-pins.ts +72 -0
  54. package/src/window-lock.ts +21 -0
  55. package/src/nats-commands.ts +0 -97
  56. package/src/nats-connection-fixture.ts +0 -105
  57. package/src/nats-connection.ts +0 -464
  58. package/src/nats-protocol.ts +0 -222
  59. package/src/nats-socket.ts +0 -236
  60. package/src/pg-connection-fixture.ts +0 -215
  61. package/src/pg-replication-fixture.ts +0 -261
package/README.md CHANGED
@@ -6,7 +6,7 @@ Three tiers, one ladder, one protocol. Climbing a rung is a config change, never
6
6
 
7
7
  | Tier | What it gives you | What it costs you |
8
8
  |---|---|---|
9
- | **1 — channels** | `publish`/`subscribe` on typed topics, presence, cursors, typing indicators | ~0. Pub/sub over Bun's native WS. No DB, no replication slot |
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
11
  | **3 — local-first** | writes that survive being offline | a durable local store, a rebase log, client-side migrations, a conflict story per mutator |
12
12
 
@@ -52,12 +52,15 @@ frame handler is unchanged between rungs.
52
52
  | tier 2 | `LiveQueryRegistry`, `InMemoryChangeFeed`, `PgLogicalReplicationFeed`, `selectChangeFeed`, `createReplicator`, `PgAdvisoryLock`, `matcherFor` |
53
53
  | replication | `parsePgUrl`, `bunPgStream`, `PgOutputDecoder`, `entityRow`, `changeLsn`, `commitPositionOf` |
54
54
  | fanout | `Transport`, `InProcessTransport`, `NatsTransport`, `selectTransport`, `subjectMatches` |
55
- | the bus client | `NatsConnection`, `NatsProtocolParser`, `NatsKvSet`, `ensureKvBucket`, `parseNatsUrl`, `bunNatsStream`, `FakeNatsServer` |
56
- | reconnect | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `RingChangeBuffer`, `backoffDelay`, `drainPlan`, `AcceptBudget` |
55
+ | the bus, behind `NatsTransport` | 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 |
56
+ | reconnect | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `RingChangeBuffer`, `backoffDelay`, `Scheduler`, `timeoutScheduler`, `drainPlan`, `AcceptBudget` |
57
+ | the client store | `IdentityMap` — one row value per `(entity, id)` — plus `RowWindows`, `rowKey`, `privateScope`, `applyPatches`/`orderAfterPatches` |
57
58
  | tier 3 | `MemoryLocalStore`, `createOpfsLocalStore`, `OfflineQueue`, `RebaseLog`, `reconcile`, `custom` |
58
59
  | wire | `PROTOCOL_VERSION`, `encode`, `decode`, `Frame` |
59
60
  | halves | `LiveClient` (client), `createSyncNode` / `listenSyncNode` (`sync` role) |
61
+ | a socket's identity | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
60
62
  | hooks | `setLiveClient`, `useLive`, `useConnection`, `useMutation`, `useMutationQueue` |
63
+ | the typed projection | `liveHookFor` — one query bound to one named hook |
61
64
 
62
65
  ## The four hooks
63
66
 
@@ -79,15 +82,146 @@ const queue = useMutationQueue(); // .pending .fai
79
82
  | Every member is a **getter**, every result set an **accessor** | a value snapshotted at hook time never re-renders |
80
83
  | A thunk `input` is read **once**, at subscribe time | nothing here re-runs it; changing input is a new subscription |
81
84
  | The caller owns `unsubscribe` | this layer does not know what a mount is |
85
+ | 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 |
82
86
  | `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 |
83
87
 
84
88
  Tier 2 has no queue, so `pending` is `0` there — stated, not guessed.
85
89
 
90
+ ### The typed one: `liveHookFor`
91
+
92
+ `useLive(query, input)` takes any object carrying a `name`, so it cannot type either side.
93
+ `liveHookFor` binds one declared `query({ live: true })` to one named hook and carries both types
94
+ through — the query's `input` in, its row type out. It is not a second subscribe path: it *is*
95
+ `useLive`, with the name and the types already bound.
96
+
97
+ ```ts
98
+ export const useLiveFeed = liveHookFor(liveFeed); // app/feed/hooks.ts — one line, no codegen
99
+
100
+ const feed = useLiveFeed({ orgId: actor.orgId }); // feed()[0].title typechecks
101
+ useLiveFeed({ orgIdd: actor.orgId }); // does not compile
102
+ ```
103
+
104
+ The query's name is read **per call**, never at bind time: `registerQueries()` stamps it at boot,
105
+ after a module-level binding has already run. Binding a query with no `live: true` is
106
+ `X_QUERY_NOT_SUBSCRIBABLE`, thrown where the binding is written — a read that never patches has no
107
+ subscription to hold, and the non-live read from a component is `query.client({ baseUrl })`.
108
+ `type-pins.ts` fails the build if the hook ever widens either type.
109
+
110
+ ## Who a socket is
111
+
112
+ `createSyncNode({ authenticate })` is the one place a websocket gets an identity. It runs on the
113
+ upgrade **before** `server.upgrade`, so a refused credential never costs a socket, and the actor it
114
+ resolves is what every policy downstream decides against — the topic guard, `authorize`, `visible`,
115
+ the per-tenant subscription cap.
116
+
117
+ ```ts
118
+ createSyncNode({
119
+ // From @ultimat3/auth, or anywhere else: `sync` imports no authenticator, exactly as it owns no
120
+ // mutation logic. `refresh` is yours too, so the framework retains no credential of its own.
121
+ authenticate: async (request) => {
122
+ const session = await sessionFrom(request);
123
+ return session === null
124
+ ? null
125
+ : { actor: session.actor, expiresAt: session.expiresAt, refresh: () => renew(session.token) };
126
+ },
127
+ });
128
+ ```
129
+
130
+ | The answer | What the node does |
131
+ |---|---|
132
+ | a `SyncGrant` | upgrades, and the socket carries `grant.actor` |
133
+ | `null` | **401** `X_SOCKET_UNAUTHENTICATED` — a decision, and the client's own condition |
134
+ | a throw | **503** `X_SOCKET_AUTH_UNAVAILABLE` — a failure, reported, and the client is told to retry |
135
+ | the option is absent | upgrades **anonymous**, and `start()` warns: every policy on that node is being asked about `null` |
136
+
137
+ `expiresAt` is the half a long-lived socket needs: the node re-decides an expired grant on an
138
+ interval (`reauthenticateIntervalMs`, 30s), calling `refresh` and then `hub.onActorChange` +
139
+ `registry.reauthorize` — so a revoked role drops the topics and subscriptions it no longer covers,
140
+ and survivors are re-snapshotted under the new authority. No `refresh`, and an expired grant closes
141
+ the socket with `1008`; the client re-dials with a fresh credential. A `refresh` that *raises* keeps
142
+ the socket and retries next pass — a token service timing out is not a revocation.
143
+
144
+ **Without `authenticate` this node is single-tenant.** Every actor is `null`, so
145
+ `hub.guard('org.*.feed', ({ actor }) => actor?.orgId === …)` denies everyone and the only guard that
146
+ lets anything through is one that reads no actor at all.
147
+
86
148
  Authz goes through `@ultimat3/query`'s `guard`, which is the only contact with `@ultimat3/policy`.
87
149
  One authz system, never two: `policy` is evaluated **once per subscriber**, never once per query.
88
150
  Two actors on one live query get two different result sets, and a row that leaves an actor's policy
89
151
  is delivered to them as a `delete` — never as silence.
90
152
 
153
+ ## What one socket may cost
154
+
155
+ Every ceiling on this node, and the option that moves it. Each one is a default, not a policy: an
156
+ app narrows or widens it where the object is constructed, and none of them can be raised from the
157
+ wire.
158
+
159
+ | Ceiling | Default | Option | Refused with |
160
+ |---|---|---|---|
161
+ | concurrent sockets on this node | 250,000 | `createSyncNode({ maxConnections })` | `503` + `retry-after-ms`, the same shed as the accept budget |
162
+ | inbound bytes per frame | 256 KiB | `createSyncNode({ maxFrameBytes })` | the socket, by `Bun.serve`'s `maxPayloadLength` |
163
+ | inbound frames per socket | 64/s, burst 256 | `createSyncNode({ maxFramesPerSecond, frameBurst })` | `X_FRAME_RATE_LIMIT` |
164
+ | live subscriptions per socket | 128 | `new LiveQueryRegistry({ maxPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
165
+ | live subscriptions per tenant | unset | `new LiveQueryRegistry({ maxPerTenant, tenantOf })` — **both**, or it arms nothing | `X_SUBSCRIPTION_LIMIT` |
166
+ | distinct `(query, input)` pairs per node | 10,000 | `new LiveQueryRegistry({ maxEntries })` | `X_SUBSCRIPTION_LIMIT` |
167
+ | channel topics per socket | 64 | `new ChannelHub({ maxTopicsPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
168
+ | distinct channel topics per node | 10,000 | `new ChannelHub({ maxTopicsPerNode })` | `X_SUBSCRIPTION_LIMIT` |
169
+ | outbound bytes buffered on one socket | 1 MiB | `createSyncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
170
+ | dropped frames before that socket is closed | 32 | `createSyncNode({ maxDroppedFrames })` | close `1013` (`overloaded`), reason `backpressure` |
171
+ | time one socket may route no frame | 120s | `createSyncNode({ idleTimeoutMs })` | close `4001` (`idle`), reason `idle timeout` |
172
+ | retained patch bytes per node | 64 MiB | `new RingChangeBuffer({ maxBytes, maxBytesPerQuery })` | eviction, then a re-snapshot on resume |
173
+ | array lengths and `input` nesting in a frame | `FRAME_LIMITS` | none — a hard ceiling | `X_PROTOCOL_VERSION` |
174
+
175
+ **Every one of those is taken as a reservation, not checked.** A subscribe holds nothing until three
176
+ awaits later, so `SubscriptionBook.reserve(socket, sid)` and `ChannelHub`'s bridge reservation decide
177
+ the sid claim and all four subscription caps **synchronously, before the first `await`**, against a
178
+ count that already includes the subscribes still in flight. One WebSocket write carrying N subscribe
179
+ frames used to pass every cap N times — the ordinary case, no attacker required. The slot is given
180
+ back in a `finally`, and releasing twice is a no-op.
181
+
182
+ The accept budget bounds the accept **rate**; `maxConnections` bounds the **count**, and they are
183
+ two different attacks — 500 accepts/s held open with one keepalive each is 1.8M sockets an hour.
184
+ The frame budget is per socket and checked at the top of the frame router, before anything a frame
185
+ can reach: a subscribe frame is a database read, a presence write and a fleet-wide publish, and one
186
+ authenticated socket is the cheapest foothold there is.
187
+
188
+ `FRAME_LIMITS` is the wire's own hard ceiling — array lengths (`cursor.ids`, `patches`, `rows`,
189
+ `members`) plus the depth and node count of a client-supplied `input`. It is not an option:
190
+ `input` reaches `canonicalJson`, which recurses, so an unbounded one is a stack overflow in the
191
+ process rather than a slow query.
192
+
193
+ ## One row per `(entity, id)`
194
+
195
+ Two components subscribing to two live queries that both return post #7 hold **one** row, not two
196
+ copies of it. That is the client's whole store: a `LiveClient` owns one `IdentityMap`, every live
197
+ window is an ordered list of ids over it, and the tier-3 local store's tables are membership over
198
+ the same map. A write through any of them is the same row for all of them.
199
+
200
+ ```ts
201
+ const feed = useLive(liveFeed, () => ({ orgId })); // holds p1, p2, p7
202
+ const pinned = useLive(livePinned, () => ({ orgId })); // holds p7
203
+
204
+ await like({ postId: 'p7' }); // one optimistic write...
205
+ feed()[2] === pinned()[0]; // ...and both views are looking at it
206
+ ```
207
+
208
+ Nothing is declared to get this. There is no normalization schema, no cache key, no selector — an
209
+ app writes `useLive` and `useMutation` exactly as before.
210
+
211
+ | Rule | Why |
212
+ |---|---|
213
+ | Identity is `(entity, id)`, never `id` alone | two entities may spell one id the same way; `posts/7` and `users/7` are two rows |
214
+ | 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 |
215
+ | 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 |
216
+ | A value is **replaced, never mutated** — every write is a new object | a mutated row is a render that never happens |
217
+ | 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 |
218
+ | 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 |
219
+ | 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 |
220
+
221
+ `entity` on a `snapshot` frame is **additive**: an older node omits it and the client falls back to
222
+ the private scope, a newer node sends it and an older client ignores it. Both skews are safe in
223
+ both directions, which is why it carries no `PROTOCOL_VERSION` bump.
224
+
91
225
  ## Reconnect is the hard part
92
226
 
93
227
  A deploy drops N sockets at once and every one asks "what changed since X?". If that answer needs
@@ -111,16 +245,171 @@ sends a `reconnect` frame carrying that delay — clients redistribute instead o
111
245
  `AcceptBudget` is the receiving node's token bucket, and a refusal always carries a retry delay,
112
246
  because refusing without one just moves the herd next door.
113
247
 
248
+ The client dials itself back. A closed socket arms one timer — the node's delay when a `reconnect`
249
+ frame assigned one, otherwise `backoffDelay()` — and that timer calls `connect()`, which re-subscribes
250
+ every registration **and re-announces every topic**. Topic membership is state on the node's socket
251
+ and `hello` carries none of it, so without that half a channel goes silent from the first reconnect
252
+ onwards while its handler is still installed — and its presence membership is swept, because
253
+ subscribing to a topic *is* joining the room. `reconnectAt` is what a component renders while it
254
+ waits; `close()` cancels it, and `connect()` starts over. The timer comes from an injected
255
+ `Scheduler`, so a test fires it by hand instead of sleeping.
256
+
257
+ ### Liveness: `heartbeatMs`
258
+
259
+ A half-open socket — the TCP connection is dead and no `close` ever fires — is invisible to the
260
+ browser. The client is the only thing that can end one.
261
+
262
+ ```ts
263
+ new LiveClient({ signal, connect, buildId, heartbeatMs: 15_000 }); // 0 disables the pass
264
+ ```
265
+
266
+ | Property | Behaviour |
267
+ |---|---|
268
+ | Default | `DEFAULT_HEARTBEAT_MS`, 15s. The same number as the server's `realtime.heartbeatMs`, restated rather than read: that is server config and this is browser code |
269
+ | One beat | a `hello` — which carries no cursors at all; `HelloFrame` has no resume list, so a beat and an opening frame are byte-identical — plus one subscribe frame per topic held |
270
+ | Why the topics | on the node, repeating the subscribe frame **is** the presence heartbeat; presence has no frame of its own in either direction |
271
+ | Not a deploy check | `update-available` answers a skew between the build id recorded at the upgrade and the node's own, and neither can change on an open socket — so every `hello` on one socket answers the same forever. A client hears about a deploy on the socket it opens against the **new** node |
272
+ | Silence | nothing received for **two** intervals ⇒ close `4000` (a private-use code, so it is distinguishable in a log) and arm the reconnect. Judged from the last frame of any kind, since the point is that bytes still cross |
273
+ | Not an interval | one armed tick, re-armed by itself, on the same injected `Scheduler` the reconnect uses — a client is either beating on a live socket or backing off toward a new one, never both |
274
+
275
+ `realtime.heartbeatMs` in `app.config.ts` is **read by nothing** `As of 2026-08`; this option is the
276
+ only knob that changes behaviour.
277
+
278
+ ### A `send` that returned is not an acknowledgement
279
+
280
+ `WebSocket.send` on a CLOSING socket discards the frame and returns normally, so a drained mutation
281
+ is `inflight` — never `acked` — until the server settles it with an `ack`/`fail` frame, or a lost
282
+ connection returns it to `pending`. Only `pending` entries are sendable, so nothing is put on the
283
+ wire twice by a reconnect that raced an ack.
284
+
285
+ | Rule | Consequence |
286
+ |---|---|
287
+ | `drain()` is **one pass at a time**, chained rather than joined | two overlapping passes read the same entry as sendable and put one key on the wire twice; a later pass could also overtake the one in front of it, which is the ordering guarantee gone. A caller that enqueued mid-pass gets a pass *behind* it, not that pass's promise |
288
+ | A pass stops at the first refusal | continuing past a failure is how a sync engine reorders a user's intent |
289
+ | Backpressure **declines**, it does not fail | over `MAX_BUFFERED_BYTES` (1 MiB, the node's `backpressureLimit` at the other end of the same socket) the sender throws `X_TRANSPORT_UNAVAILABLE`, the mutation stays pending and the next drain resumes there. `ClientSocket.bufferedAmount` is optional; a socket that does not report it is treated as never backed up |
290
+ | Delivery is therefore at least once | every mutation carries an idempotency key — the `key` argument, or `<mutator>:<uuid>` — and the resend carries the same one |
291
+ | A lost connection **cancels the pass it interrupted** | the lane orders passes against each other, but a socket death is not a pass and cannot reach one parked inside `send`. `requeueInflight()` bumps a connection epoch; a pass whose epoch went stale returns and leaves the rest `pending`. Without it the parked pass resumed and marked everything behind it `inflight` for a dead socket — never re-sent (`inflight` is not sendable) and never acked |
292
+ | The store is handed a **snapshot**, never the live entries | `QueueStore.save` is a durable write and may await before it reads; given the array itself it persists a status that was never true when it was called |
293
+
114
294
  ### Limits, stated plainly
115
295
 
116
- - **The change window is per node.** A client that reconnects to a *different* `sync` node has no
117
- window there and takes a snapshot. Making the window shared (replicator-side, request/reply) is
118
- gated on the milestone-6 reconnect benchmark50k sockets, forced restart, measure
119
- time-to-consistent because that number decides the topology.
296
+ - **The change window is per node, and a `qid` window can only be.** A client that reconnects to a
297
+ *different* `sync` node has no ring there and takes the snapshot path. It is not a placement bug:
298
+ a patch is query-scoped, and the replicator is entity-scoped it holds no compiled shape, no
299
+ matcher and no window, so it cannot produce one. What the snapshot path costs is one **shared**
300
+ read per (query, node), not one per client. A cross-node delta needs an *entity*-keyed window each
301
+ node fills from the change stream it already subscribes to, which is a `ResumeSource` shape change.
302
+ - **Fanout is at-most-once, and a gap is detected rather than assumed away.** The replicator stamps
303
+ every published change with `producer` + `seq`; a `sync` node that sees a skipped sequence
304
+ invalidates every window it holds and desyncs every subscriber, so the next change to each query
305
+ re-reads and re-snapshots. Both fields are optional on the bus, so a publisher that does not
306
+ sequence simply detects nothing. Durable replay (JetStream) is a separate decision — retention,
307
+ storage and replay window — and is deliberately not this mechanism.
308
+ - **`desynced` has a reader.** A subscriber recorded as diverged — a dropped patch, a gate that
309
+ failed, a window that lost its tail — is served a fresh snapshot out of the shared window on the
310
+ next delivery, and only then is the mark cleared. A snapshot the socket refuses leaves it
311
+ diverged, which is the state it is actually in.
312
+ - **The client's cursor advances on every patch, not only on a snapshot.** Left behind, `cursor.at`
313
+ froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for every
314
+ client connected longer than `maxLagMs` — the delta resume the retained window exists for, dead
315
+ exactly during the deploy storm it was built for.
316
+ - **An accepted mutation is committed, not merely acknowledged.** The `ack` drops the journal row
317
+ and the rebase-log entry — there is nothing to roll back *to* any more, and a later reconcile
318
+ would otherwise replay a write the server already applied over rows that have moved on. The row
319
+ itself stays exactly as the optimistic twin left it: an accepted write does not flicker.
320
+ - **A refused mutation is rolled back, not retried.** An `ack` carrying an error undoes that
321
+ mutation's optimistic write *and* every write made after it — newest first — then replays the
322
+ others without it, which is sound only because `local` is pure. The refused intent is dropped from
323
+ the rebase log rather than retried: a denial is a decision about that intent, and replaying it
324
+ would put the write the server refused back on the screen. Idempotent for a key the log does not
325
+ hold, because a denial can arrive twice and tier 2 records nothing to undo.
120
326
  - **A delta resume leaves the digest unverified** (`DIGEST_UNVERIFIED`). Only a snapshot re-establishes
121
327
  it. `verifyDigest()` is how a client detects drift and asks for a fresh one.
122
328
  - **Backpressure drops patch frames.** That is safe *only* because a re-snapshot is cheap: the drop
123
- is recorded on the socket (`desynced`) and the next flush re-snapshots rather than diverging.
329
+ is recorded on the socket (`desynced`) and the next delivery re-snapshots rather than diverging.
330
+ - **A dropped CHANNEL frame is not safe, and is not repaired.** A topic has no cursor, no mark and
331
+ no re-snapshot, so tier 1 is **at most once**. Every refusal is counted — the series
332
+ `channel_frames_dropped_total` (no labels: a topic is client-chosen, so a per-topic label is
333
+ unbounded series one socket can mint), the log line `channel.frames_dropped` at `warn` carrying
334
+ `{ topic, dropped, total }`, and `node.sockets.droppedChannelFrames` for a test or a benchmark
335
+ that cannot scrape. Node-wide and cumulative, because a socket past `maxDroppedFrames` is closed
336
+ and removed — a per-socket count leaves exactly when loss is worst. Distinct from
337
+ `SyncSocket.droppedFrames`, which counts every kind of frame one connection lost and dies with it.
338
+ Repair would need a per-topic sequence on the wire: a channel's `lsn` is the publishing hub's own
339
+ per-node counter, so a client cannot tell a gap from a message that arrived via another node.
340
+ **Anything that must arrive belongs on a live query.**
341
+ - **Bun's native WS pub/sub is not used.** `subscribeTopic` does not call `ws.subscribe` and the
342
+ websocket config declares no `publishToSelf`; every channel message is one filtered `send` per
343
+ socket through `SocketRegistry.deliver`, reading a per-topic index rather than walking the socket
344
+ table. A native publish cannot be refused per socket, cannot report the frame it dropped and
345
+ cannot mark a subscriber desynced — which is to say it cannot do any of the three things above.
346
+ `WsLike.subscribe`/`unsubscribe` stay **declared and unused**: the interface is structural and a
347
+ tracked app implements it, so deleting the members breaks that app's typecheck.
348
+ - **Inbound frames are ordered per `mutate`-socket and per subscription, never per socket.** A
349
+ global per-socket lane puts every frame behind the slowest one, and the slowest one is a
350
+ subscribe's snapshot read — the round trip every reconnecting client pays in a restart storm.
351
+ `mutate` is one lane per socket; `subscribe` is one lane per sid, or per topic name; `hello` and
352
+ the server-authored kinds are unlaned. A lane exists only while work is queued on it, because a
353
+ lane keyed by a client-chosen sid that outlived its work is an unbounded map one socket can grow.
354
+ - **`qid` is `<name>:<first 16 hex of SHA-256(canonicalJson(input))>`** — 64 bits `As of 2026-08`,
355
+ where it was a 32-bit FNV-1a. It is a *sharing* key: a hit is answered with the existing entry and
356
+ the seated window, both holding the first subscriber's input and rows, and input is client-chosen,
357
+ so a collision is one client served out of another's window. A rolling deploy across that change
358
+ costs one bounded snapshot per subscription — a cursor minted under the old format names a ring
359
+ entry the new node never held, so the resume falls back correctly rather than silently.
360
+ - **A topic guard that *fails* keeps the topic.** On the re-auth pass, only a denial
361
+ (`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures`
362
+ and logs `channel.guard_failed`. `catch { unsubscribe }` reported a store that timed out as a
363
+ revoked grant — every topic on every re-authenticated socket, silently, with the client never told
364
+ to resubscribe. The initial `subscribe` is deliberately not split that way: there is no
365
+ subscription to keep, so a guard that raises refuses that subscribe and the client hears about it.
366
+ - **An idle socket is swept, and the sweep is an APPLICATION budget, not Bun's.** Bun's own
367
+ `idleTimeout` is renewed by its ping/pong, so a client whose frame loop is wedged answers pings
368
+ and keeps its grant, its live subscriptions and its topic membership indefinitely. `start()`
369
+ arms one `.unref()`ed pass every `idleTimeoutMs / 4` (floored at a second, derived rather than
370
+ configured) and evicts anything past the budget the same way a close does — through the node's
371
+ `teardown`, never `SocketRegistry.remove`. `SocketRegistry.idle()` is a *query* for that reason:
372
+ the socket table is three of the five things a socket holds, and the other two are its live
373
+ subscriptions and its presence membership on the shared set. `sweepIdle` — which closed and
374
+ removed here, and had no caller at all — is gone. The budget is measured on `Clock.monotonic()`,
375
+ so `SyncSocket.lastSeenMonotonicMs` is a duration's start and not an instant: an NTP step forward
376
+ would otherwise evict every socket that is talking, and a step backward would spare every socket
377
+ that is dead. `openedAt` stays on the wall clock — it is a value a human reads.
378
+ - **A `sync` node shuts down in two phases.** The `accept` phase calls `stopAccepting()`: `/readyz`
379
+ answers 503 and a late upgrade is shed with `retry-after-ms`, while **every socket the node holds
380
+ keeps its patch stream**. The `close` phase is `drain()` then `stop()`. Registered with no phase it
381
+ all landed in `close`, and until that ran the node went on upgrading new websockets onto a process
382
+ that was going away. Both hooks are unregistered by the listener's `stop()`.
383
+ - **A full presence frame is capped** at `maxMembers` (256) and carries `total`, so a 5,000-person
384
+ room renders "and 4,744 others" instead of shipping 5,000 members to every joiner. The set itself
385
+ is never capped — the sweep differences it — and one node per topic runs that sweep, elected
386
+ through the shared store, rather than every node re-reading every room it has ever seen.
387
+ - **Deliveries are serialized per query id, not per node.** A change is fanned out inside that
388
+ query's own FIFO lane, so two changes off the bus cannot interleave: the window one of them
389
+ writes is the window every subscriber's gate reads, and patch frames leave in lsn order. Every
390
+ lane is entered before any is awaited, so one slow policy pass never sets the node's pace, and
391
+ across query ids there is no ordering and none is wanted — a qid pins both the query and its
392
+ input. A lane that fails costs one query id: its own subscribers are desynced and re-snapshotted
393
+ on the next flush, every other query id still sees the change, and the failure still reaches the
394
+ caller.
395
+ - **A cold subscribe reads once per query id.** Subscribers arriving during a read join it and each
396
+ runs its own policy pass over the result. A read that resolves behind a change already fanned out
397
+ is discarded rather than written back: the window only ever moves forwards. Two reads are ordered
398
+ by a monotonic **read generation** and never by lsn — a definition with no lsn provider answers
399
+ `''` for every read, and `'' >= ''` let the older of two concurrent reads land on top of the
400
+ newer one's gap repair, with `stale` already cleared and therefore nothing left to re-read.
401
+ - **A denial drops a row; a gate that could not decide does not.** A policy answer (`X_FORBIDDEN`,
402
+ `X_UNAUTHENTICATED`) is a decision and costs the row, counted as `rowsDenied`. Anything else a
403
+ gate throws — a rule whose lookup timed out, a predicate with a typo — is counted as
404
+ `gateFailures` and reported through `onGateFailed`, never as a denial: it raises out of
405
+ `subscribe`, desyncs exactly the one subscriber it happened to during a delivery, and leaves a
406
+ subscription standing at `reauthorize`. Reading a timeout as "you may no longer see this" is an
407
+ outage published as a permission change.
408
+ - **A patch is authorized against the whole row or it is not authorized.** An update patch carries
409
+ the changed columns only, so a rule reading a column the change did not touch would read
410
+ `undefined` and answer as if the row had said so. A patch whose row the shared window does not
411
+ hold is withheld — the window *is* the result set — and a subscriber holding that row gets the
412
+ one `delete` that says so. It counts as neither a denial nor a gate failure: nothing decided.
124
413
  - **`PgLogicalReplicationFeed` decodes `pgoutput` off a real slot** — its own Postgres v3 client
125
414
  (SCRAM-SHA-256, in-band TLS, CopyBoth), no driver dependency. It preflights `wal_level`, the
126
415
  publication and the slot, creates the slot when there is none, and confirms the slot as it goes so
@@ -149,14 +438,19 @@ because refusing without one just moves the herd next door.
149
438
  `presenceTtlMs` comes back with it because the bucket's whole-stream age limit was derived from
150
439
  it — a `PresenceRegistry` given a different number would report members leaving that never left.
151
440
  Selection is pure; `connect()` is the dial, so an unreachable bus fails at boot.
152
- - **`NatsTransport` speaks NATS itself** its own protocol codec and session over `Bun.connect`,
153
- no client dependency. Fanout is core NATS; `shared` is a JetStream KV bucket the transport
154
- creates on first connect, one key per presence member, expired by the **server's** per-message
155
- TTL so a node that dies needs nobody to notice. Subscriptions are held as *intent*, so a lost
156
- connection re-dials and re-subscribes underneath the caller which is what makes `sync`
157
- stateless. That bucket needs nats-server 2.11 (batch direct get, per-message TTL); an older
158
- one is `X_TRANSPORT_PROTOCOL` on the first dial, never a retry loop, because no amount of
159
- reconnecting makes a server newer.
441
+ - **`NatsTransport` runs on the official `nats` client** `nats@2.29.3`, pinned exact, admitted at
442
+ this transport seam and nowhere else
443
+ ([`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md)). The package reaches it
444
+ through one port, `NatsClient`, and exactly one file imports the library to implement it, so a
445
+ test injects a client rather than a socket. Fanout is core NATS. `shared` is a JetStream KV bucket the transport creates on
446
+ first connect, one key per presence member, expired by the **server's** per-message TTL so a node
447
+ that dies needs nobody to notice that bucket and its direct reads stay the framework's, because
448
+ the library's own KV abstraction expresses neither a per-message TTL nor a batch direct get.
449
+ Reconnect and re-subscription are the library's: a lost connection is re-established underneath
450
+ the caller, which is what makes `sync` stateless, and the jitter that spreads a restart herd is
451
+ handed to it as its reconnect delay rather than re-implemented above it. The bucket needs
452
+ nats-server ≥ 2.11 (batch direct get, per-message TTL); an older one is `X_TRANSPORT_PROTOCOL` on
453
+ the first dial, never a retry loop, because no amount of reconnecting makes a server newer.
160
454
  - **The lsn is `<commit position><row position in the transaction>`, 24 hex characters.** Neither
161
455
  half works alone: every row of one transaction shares a commit lsn, and logical decoding emits
162
456
  *transactions* in commit order, so per-record WAL positions are not monotonic across them. The
@@ -165,16 +459,39 @@ because refusing without one just moves the herd next door.
165
459
  - **A live query needs `REPLICA IDENTITY FULL`.** Deciding whether a row *left* a result set needs
166
460
  the old values; with the default identity a delete replicates only the key columns.
167
461
  - Tier 3's OPFS SQLite store is browser-only and throws until the browser entry ships; `MemoryLocalStore`
168
- implements the full journal/rollback/replay semantics today.
462
+ implements the full journal/rollback/replay semantics today. It holds membership and the journal;
463
+ the row values are the client's one `IdentityMap`, which is what a browser store has to inherit
464
+ rather than re-implement.
465
+ - The identity map is **per client**, in memory, and it is not a query cache: it answers "what is
466
+ row X now", never "have I run this query before". Nothing evicts by time or size — a row lives
467
+ exactly as long as a window or a table holds it.
169
468
 
170
469
  ## Errors
171
470
 
172
- `X_TOPIC_FORBIDDEN` · `X_SUBSCRIPTION_LIMIT` · `X_PROTOCOL_VERSION` · `X_CURSOR_STALE` ·
471
+ `X_TOPIC_FORBIDDEN` · `X_SUBSCRIPTION_LIMIT` · `X_SUBSCRIPTION_ID_TAKEN` ·
472
+ `X_PROTOCOL_VERSION` · `X_CURSOR_STALE` ·
173
473
  `X_REBASE_CONFLICT` · `X_TRANSPORT_UNAVAILABLE` · `X_TRANSPORT_PROTOCOL` ·
174
474
  `X_REPLICATION_FAILED` · `X_REPLICATION_PROTOCOL` · `X_REPLICATOR_SLOT_HELD` ·
175
- `X_LIVE_CLIENT_MISSING` · `X_NOT_IMPLEMENTED`
475
+ `X_LIVE_CLIENT_MISSING` · `X_LIVE_QUERY_UNKNOWN` · `X_SOCKET_UNAUTHENTICATED` ·
476
+ `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED`
176
477
 
177
478
  Topics deny by default: a topic with no matching guard is forbidden. An authz hole is not a config
178
479
  option someone forgot to set.
179
480
 
481
+ An upgrade `authenticate` refuses is `X_SOCKET_UNAUTHENTICATED` (401) and one it *could not decide*
482
+ is `X_SOCKET_AUTH_UNAVAILABLE` (503). Two codes, because the two have opposite instructions: the
483
+ first is the client's credential and pages nobody, the second is this node's dependency and pages
484
+ someone. Both are rendered as the error contract in the response body — there is no frame to carry
485
+ one, because the client never got a socket.
486
+
487
+ A `sid` belongs to the socket that chose it. A subscription is keyed by `(socket, sid)`, a drop
488
+ frame is scoped to the socket that sent it, and reusing a sid the same socket already holds is
489
+ `X_SUBSCRIPTION_ID_TAKEN` — one client can neither take over nor end another's live stream.
490
+
491
+ A `subscribe` frame naming a query this node never registered is `X_LIVE_QUERY_UNKNOWN`, not
492
+ `X_PROTOCOL_VERSION`: the frame parsed and the version matched, so "rebuild and redeploy the
493
+ client" is the one instruction that cannot help — a rebuilt client spells the name the same way.
494
+ The fix is `x queries list --json`, and the name the client sent is echoed back while the registry
495
+ never is.
496
+
180
497
  `As of 2026-07`: tiers 1–2 target v1, tier 3 targets v2.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "1.2.0",
3
+ "version": "3.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",
@@ -19,6 +19,8 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/*.test.ts",
22
+ "!src/**/*-fixture.ts",
23
+ "CLAUDE.md",
22
24
  "README.md",
23
25
  "LICENSE"
24
26
  ],
@@ -30,7 +32,8 @@
30
32
  "test": "bun test"
31
33
  },
32
34
  "dependencies": {
33
- "@ultimat3/core": "1.2.0",
34
- "@ultimat3/query": "1.2.0"
35
+ "@ultimat3/core": "3.0.0",
36
+ "@ultimat3/query": "3.0.0",
37
+ "nats": "2.29.3"
35
38
  }
36
39
  }
@@ -0,0 +1,60 @@
1
+ // One job: fold a patch list onto a result set. Pure and outside `LiveClient` because it is the only
2
+ // piece of the client with no socket, no signal and no state — which is what makes it the piece an
3
+ // app can reuse to apply the same patches to its own store.
4
+ //
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.
7
+
8
+ import type { JsonObject, Row, RowPatch } from './json';
9
+
10
+ /**
11
+ * Membership and order after a patch list, ids only. `index` places a row the set does not hold
12
+ * yet; a row it already holds keeps its position, because a patch reorders nothing it did not say
13
+ * to reorder.
14
+ */
15
+ export function orderAfterPatches(
16
+ ids: readonly string[],
17
+ patches: readonly RowPatch[],
18
+ ): readonly string[] {
19
+ let next = ids;
20
+ for (const patch of patches) {
21
+ if (patch.op === 'delete') {
22
+ next = next.filter((id) => id !== patch.id);
23
+ continue;
24
+ }
25
+ if (patch.row === null) continue;
26
+ if (next.includes(patch.id)) continue;
27
+ if (patch.index !== undefined) {
28
+ const copy = [...next];
29
+ copy.splice(patch.index, 0, patch.id);
30
+ next = copy;
31
+ continue;
32
+ }
33
+ next = [...next, patch.id];
34
+ }
35
+ return next;
36
+ }
37
+
38
+ /** Minimal in-place patch application: the shape a Solid store update maps onto directly. */
39
+ export function applyPatches(rows: readonly Row[], patches: readonly RowPatch[]): readonly Row[] {
40
+ const values = new Map<string, Row>(rows.map((row) => [row.id, row]));
41
+ for (const patch of patches) {
42
+ if (patch.op === 'delete' || patch.row === null) continue;
43
+ values.set(patch.id, mergeRow(values.get(patch.id), patch.id, patch.row));
44
+ }
45
+ const out: Row[] = [];
46
+ for (const id of orderAfterPatches(
47
+ rows.map((row) => row.id),
48
+ patches,
49
+ )) {
50
+ const row = values.get(id);
51
+ if (row !== undefined) out.push(row);
52
+ }
53
+ return out;
54
+ }
55
+
56
+ /** The one merge rule: changed columns over the current value, and `id` is never overwritten. */
57
+ function mergeRow(current: Row | undefined, id: string, columns: JsonObject): Row {
58
+ const next: JsonObject = { ...(current ?? {}), ...columns };
59
+ return { ...next, id };
60
+ }