@ultimat3/realtime 1.2.0 → 2.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 +591 -0
  2. package/README.md +320 -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 +174 -19
  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 +96 -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 +151 -0
  40. package/src/rebase.ts +68 -8
  41. package/src/replicator.ts +84 -11
  42. package/src/socket.ts +170 -14
  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 +284 -243
  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,145 @@ 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
+ | retained patch bytes per node | 64 MiB | `new RingChangeBuffer({ maxBytes, maxBytesPerQuery })` | eviction, then a re-snapshot on resume |
172
+ | array lengths and `input` nesting in a frame | `FRAME_LIMITS` | none — a hard ceiling | `X_PROTOCOL_VERSION` |
173
+
174
+ **Every one of those is taken as a reservation, not checked.** A subscribe holds nothing until three
175
+ awaits later, so `SubscriptionBook.reserve(socket, sid)` and `ChannelHub`'s bridge reservation decide
176
+ the sid claim and all four subscription caps **synchronously, before the first `await`**, against a
177
+ count that already includes the subscribes still in flight. One WebSocket write carrying N subscribe
178
+ frames used to pass every cap N times — the ordinary case, no attacker required. The slot is given
179
+ back in a `finally`, and releasing twice is a no-op.
180
+
181
+ The accept budget bounds the accept **rate**; `maxConnections` bounds the **count**, and they are
182
+ two different attacks — 500 accepts/s held open with one keepalive each is 1.8M sockets an hour.
183
+ The frame budget is per socket and checked at the top of the frame router, before anything a frame
184
+ can reach: a subscribe frame is a database read, a presence write and a fleet-wide publish, and one
185
+ authenticated socket is the cheapest foothold there is.
186
+
187
+ `FRAME_LIMITS` is the wire's own hard ceiling — array lengths (`cursor.ids`, `patches`, `rows`,
188
+ `members`) plus the depth and node count of a client-supplied `input`. It is not an option:
189
+ `input` reaches `canonicalJson`, which recurses, so an unbounded one is a stack overflow in the
190
+ process rather than a slow query.
191
+
192
+ ## One row per `(entity, id)`
193
+
194
+ Two components subscribing to two live queries that both return post #7 hold **one** row, not two
195
+ copies of it. That is the client's whole store: a `LiveClient` owns one `IdentityMap`, every live
196
+ window is an ordered list of ids over it, and the tier-3 local store's tables are membership over
197
+ the same map. A write through any of them is the same row for all of them.
198
+
199
+ ```ts
200
+ const feed = useLive(liveFeed, () => ({ orgId })); // holds p1, p2, p7
201
+ const pinned = useLive(livePinned, () => ({ orgId })); // holds p7
202
+
203
+ await like({ postId: 'p7' }); // one optimistic write...
204
+ feed()[2] === pinned()[0]; // ...and both views are looking at it
205
+ ```
206
+
207
+ Nothing is declared to get this. There is no normalization schema, no cache key, no selector — an
208
+ app writes `useLive` and `useMutation` exactly as before.
209
+
210
+ | Rule | Why |
211
+ |---|---|
212
+ | Identity is `(entity, id)`, never `id` alone | two entities may spell one id the same way; `posts/7` and `users/7` are two rows |
213
+ | 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 |
214
+ | 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 |
215
+ | A value is **replaced, never mutated** — every write is a new object | a mutated row is a render that never happens |
216
+ | 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 |
217
+ | 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 |
218
+ | 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 |
219
+
220
+ `entity` on a `snapshot` frame is **additive**: an older node omits it and the client falls back to
221
+ the private scope, a newer node sends it and an older client ignores it. Both skews are safe in
222
+ both directions, which is why it carries no `PROTOCOL_VERSION` bump.
223
+
91
224
  ## Reconnect is the hard part
92
225
 
93
226
  A deploy drops N sockets at once and every one asks "what changed since X?". If that answer needs
@@ -111,16 +244,156 @@ sends a `reconnect` frame carrying that delay — clients redistribute instead o
111
244
  `AcceptBudget` is the receiving node's token bucket, and a refusal always carries a retry delay,
112
245
  because refusing without one just moves the herd next door.
113
246
 
247
+ The client dials itself back. A closed socket arms one timer — the node's delay when a `reconnect`
248
+ frame assigned one, otherwise `backoffDelay()` — and that timer calls `connect()`, which re-subscribes
249
+ every registration **and re-announces every topic**. Topic membership is state on the node's socket
250
+ and `hello` carries none of it, so without that half a channel goes silent from the first reconnect
251
+ onwards while its handler is still installed — and its presence membership is swept, because
252
+ subscribing to a topic *is* joining the room. `reconnectAt` is what a component renders while it
253
+ waits; `close()` cancels it, and `connect()` starts over. The timer comes from an injected
254
+ `Scheduler`, so a test fires it by hand instead of sleeping.
255
+
256
+ ### Liveness: `heartbeatMs`
257
+
258
+ A half-open socket — the TCP connection is dead and no `close` ever fires — is invisible to the
259
+ browser. The client is the only thing that can end one.
260
+
261
+ ```ts
262
+ new LiveClient({ signal, connect, buildId, heartbeatMs: 15_000 }); // 0 disables the pass
263
+ ```
264
+
265
+ | Property | Behaviour |
266
+ |---|---|
267
+ | 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 |
268
+ | 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 |
269
+ | Why the topics | on the node, repeating the subscribe frame **is** the presence heartbeat; presence has no frame of its own in either direction |
270
+ | 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 |
271
+ | 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 |
272
+ | 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 |
273
+
274
+ `realtime.heartbeatMs` in `app.config.ts` is **read by nothing** `As of 2026-08`; this option is the
275
+ only knob that changes behaviour.
276
+
277
+ ### A `send` that returned is not an acknowledgement
278
+
279
+ `WebSocket.send` on a CLOSING socket discards the frame and returns normally, so a drained mutation
280
+ is `inflight` — never `acked` — until the server settles it with an `ack`/`fail` frame, or a lost
281
+ connection returns it to `pending`. Only `pending` entries are sendable, so nothing is put on the
282
+ wire twice by a reconnect that raced an ack.
283
+
284
+ | Rule | Consequence |
285
+ |---|---|
286
+ | `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 |
287
+ | A pass stops at the first refusal | continuing past a failure is how a sync engine reorders a user's intent |
288
+ | 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 |
289
+ | 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 |
290
+ | 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 |
291
+ | 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 |
292
+
114
293
  ### Limits, stated plainly
115
294
 
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.
295
+ - **The change window is per node, and a `qid` window can only be.** A client that reconnects to a
296
+ *different* `sync` node has no ring there and takes the snapshot path. It is not a placement bug:
297
+ a patch is query-scoped, and the replicator is entity-scoped it holds no compiled shape, no
298
+ matcher and no window, so it cannot produce one. What the snapshot path costs is one **shared**
299
+ read per (query, node), not one per client. A cross-node delta needs an *entity*-keyed window each
300
+ node fills from the change stream it already subscribes to, which is a `ResumeSource` shape change.
301
+ - **Fanout is at-most-once, and a gap is detected rather than assumed away.** The replicator stamps
302
+ every published change with `producer` + `seq`; a `sync` node that sees a skipped sequence
303
+ invalidates every window it holds and desyncs every subscriber, so the next change to each query
304
+ re-reads and re-snapshots. Both fields are optional on the bus, so a publisher that does not
305
+ sequence simply detects nothing. Durable replay (JetStream) is a separate decision — retention,
306
+ storage and replay window — and is deliberately not this mechanism.
307
+ - **`desynced` has a reader.** A subscriber recorded as diverged — a dropped patch, a gate that
308
+ failed, a window that lost its tail — is served a fresh snapshot out of the shared window on the
309
+ next delivery, and only then is the mark cleared. A snapshot the socket refuses leaves it
310
+ diverged, which is the state it is actually in.
311
+ - **The client's cursor advances on every patch, not only on a snapshot.** Left behind, `cursor.at`
312
+ froze at the last snapshot and `shouldResnapshot`'s lag check answered "re-snapshot" for every
313
+ client connected longer than `maxLagMs` — the delta resume the retained window exists for, dead
314
+ exactly during the deploy storm it was built for.
315
+ - **An accepted mutation is committed, not merely acknowledged.** The `ack` drops the journal row
316
+ and the rebase-log entry — there is nothing to roll back *to* any more, and a later reconcile
317
+ would otherwise replay a write the server already applied over rows that have moved on. The row
318
+ itself stays exactly as the optimistic twin left it: an accepted write does not flicker.
319
+ - **A refused mutation is rolled back, not retried.** An `ack` carrying an error undoes that
320
+ mutation's optimistic write *and* every write made after it — newest first — then replays the
321
+ others without it, which is sound only because `local` is pure. The refused intent is dropped from
322
+ the rebase log rather than retried: a denial is a decision about that intent, and replaying it
323
+ would put the write the server refused back on the screen. Idempotent for a key the log does not
324
+ hold, because a denial can arrive twice and tier 2 records nothing to undo.
120
325
  - **A delta resume leaves the digest unverified** (`DIGEST_UNVERIFIED`). Only a snapshot re-establishes
121
326
  it. `verifyDigest()` is how a client detects drift and asks for a fresh one.
122
327
  - **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.
328
+ is recorded on the socket (`desynced`) and the next delivery re-snapshots rather than diverging.
329
+ - **A dropped CHANNEL frame is not safe, and is not repaired.** A topic has no cursor, no mark and
330
+ no re-snapshot, so tier 1 is **at most once**. Every refusal is counted — the series
331
+ `channel_frames_dropped_total` (no labels: a topic is client-chosen, so a per-topic label is
332
+ unbounded series one socket can mint), the log line `channel.frames_dropped` at `warn` carrying
333
+ `{ topic, dropped, total }`, and `node.sockets.droppedChannelFrames` for a test or a benchmark
334
+ that cannot scrape. Node-wide and cumulative, because a socket past `maxDroppedFrames` is closed
335
+ and removed — a per-socket count leaves exactly when loss is worst. Distinct from
336
+ `SyncSocket.droppedFrames`, which counts every kind of frame one connection lost and dies with it.
337
+ Repair would need a per-topic sequence on the wire: a channel's `lsn` is the publishing hub's own
338
+ per-node counter, so a client cannot tell a gap from a message that arrived via another node.
339
+ **Anything that must arrive belongs on a live query.**
340
+ - **Bun's native WS pub/sub is not used.** `subscribeTopic` does not call `ws.subscribe` and the
341
+ websocket config declares no `publishToSelf`; every channel message is one filtered `send` per
342
+ socket through `SocketRegistry.deliver`, reading a per-topic index rather than walking the socket
343
+ table. A native publish cannot be refused per socket, cannot report the frame it dropped and
344
+ cannot mark a subscriber desynced — which is to say it cannot do any of the three things above.
345
+ `WsLike.subscribe`/`unsubscribe` stay **declared and unused**: the interface is structural and a
346
+ tracked app implements it, so deleting the members breaks that app's typecheck.
347
+ - **Inbound frames are ordered per `mutate`-socket and per subscription, never per socket.** A
348
+ global per-socket lane puts every frame behind the slowest one, and the slowest one is a
349
+ subscribe's snapshot read — the round trip every reconnecting client pays in a restart storm.
350
+ `mutate` is one lane per socket; `subscribe` is one lane per sid, or per topic name; `hello` and
351
+ the server-authored kinds are unlaned. A lane exists only while work is queued on it, because a
352
+ lane keyed by a client-chosen sid that outlived its work is an unbounded map one socket can grow.
353
+ - **`qid` is `<name>:<first 16 hex of SHA-256(canonicalJson(input))>`** — 64 bits `As of 2026-08`,
354
+ where it was a 32-bit FNV-1a. It is a *sharing* key: a hit is answered with the existing entry and
355
+ the seated window, both holding the first subscriber's input and rows, and input is client-chosen,
356
+ so a collision is one client served out of another's window. A rolling deploy across that change
357
+ costs one bounded snapshot per subscription — a cursor minted under the old format names a ring
358
+ entry the new node never held, so the resume falls back correctly rather than silently.
359
+ - **A topic guard that *fails* keeps the topic.** On the re-auth pass, only a denial
360
+ (`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures`
361
+ and logs `channel.guard_failed`. `catch { unsubscribe }` reported a store that timed out as a
362
+ revoked grant — every topic on every re-authenticated socket, silently, with the client never told
363
+ to resubscribe. The initial `subscribe` is deliberately not split that way: there is no
364
+ subscription to keep, so a guard that raises refuses that subscribe and the client hears about it.
365
+ - **A `sync` node shuts down in two phases.** The `accept` phase calls `stopAccepting()`: `/readyz`
366
+ answers 503 and a late upgrade is shed with `retry-after-ms`, while **every socket the node holds
367
+ keeps its patch stream**. The `close` phase is `drain()` then `stop()`. Registered with no phase it
368
+ all landed in `close`, and until that ran the node went on upgrading new websockets onto a process
369
+ that was going away. Both hooks are unregistered by the listener's `stop()`.
370
+ - **A full presence frame is capped** at `maxMembers` (256) and carries `total`, so a 5,000-person
371
+ room renders "and 4,744 others" instead of shipping 5,000 members to every joiner. The set itself
372
+ is never capped — the sweep differences it — and one node per topic runs that sweep, elected
373
+ through the shared store, rather than every node re-reading every room it has ever seen.
374
+ - **Deliveries are serialized per query id, not per node.** A change is fanned out inside that
375
+ query's own FIFO lane, so two changes off the bus cannot interleave: the window one of them
376
+ writes is the window every subscriber's gate reads, and patch frames leave in lsn order. Every
377
+ lane is entered before any is awaited, so one slow policy pass never sets the node's pace, and
378
+ across query ids there is no ordering and none is wanted — a qid pins both the query and its
379
+ input. A lane that fails costs one query id: its own subscribers are desynced and re-snapshotted
380
+ on the next flush, every other query id still sees the change, and the failure still reaches the
381
+ caller.
382
+ - **A cold subscribe reads once per query id.** Subscribers arriving during a read join it and each
383
+ runs its own policy pass over the result. A read that resolves behind a change already fanned out
384
+ is discarded rather than written back: the window only ever moves forwards.
385
+ - **A denial drops a row; a gate that could not decide does not.** A policy answer (`X_FORBIDDEN`,
386
+ `X_UNAUTHENTICATED`) is a decision and costs the row, counted as `rowsDenied`. Anything else a
387
+ gate throws — a rule whose lookup timed out, a predicate with a typo — is counted as
388
+ `gateFailures` and reported through `onGateFailed`, never as a denial: it raises out of
389
+ `subscribe`, desyncs exactly the one subscriber it happened to during a delivery, and leaves a
390
+ subscription standing at `reauthorize`. Reading a timeout as "you may no longer see this" is an
391
+ outage published as a permission change.
392
+ - **A patch is authorized against the whole row or it is not authorized.** An update patch carries
393
+ the changed columns only, so a rule reading a column the change did not touch would read
394
+ `undefined` and answer as if the row had said so. A patch whose row the shared window does not
395
+ hold is withheld — the window *is* the result set — and a subscriber holding that row gets the
396
+ one `delete` that says so. It counts as neither a denial nor a gate failure: nothing decided.
124
397
  - **`PgLogicalReplicationFeed` decodes `pgoutput` off a real slot** — its own Postgres v3 client
125
398
  (SCRAM-SHA-256, in-band TLS, CopyBoth), no driver dependency. It preflights `wal_level`, the
126
399
  publication and the slot, creates the slot when there is none, and confirms the slot as it goes so
@@ -149,14 +422,19 @@ because refusing without one just moves the herd next door.
149
422
  `presenceTtlMs` comes back with it because the bucket's whole-stream age limit was derived from
150
423
  it — a `PresenceRegistry` given a different number would report members leaving that never left.
151
424
  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.
425
+ - **`NatsTransport` runs on the official `nats` client** `nats@2.29.3`, pinned exact, admitted at
426
+ this transport seam and nowhere else
427
+ ([`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md)). The package reaches it
428
+ through one port, `NatsClient`, and exactly one file imports the library to implement it, so a
429
+ test injects a client rather than a socket. Fanout is core NATS. `shared` is a JetStream KV bucket the transport creates on
430
+ first connect, one key per presence member, expired by the **server's** per-message TTL so a node
431
+ that dies needs nobody to notice that bucket and its direct reads stay the framework's, because
432
+ the library's own KV abstraction expresses neither a per-message TTL nor a batch direct get.
433
+ Reconnect and re-subscription are the library's: a lost connection is re-established underneath
434
+ the caller, which is what makes `sync` stateless, and the jitter that spreads a restart herd is
435
+ handed to it as its reconnect delay rather than re-implemented above it. The bucket needs
436
+ nats-server ≥ 2.11 (batch direct get, per-message TTL); an older one is `X_TRANSPORT_PROTOCOL` on
437
+ the first dial, never a retry loop, because no amount of reconnecting makes a server newer.
160
438
  - **The lsn is `<commit position><row position in the transaction>`, 24 hex characters.** Neither
161
439
  half works alone: every row of one transaction shares a commit lsn, and logical decoding emits
162
440
  *transactions* in commit order, so per-record WAL positions are not monotonic across them. The
@@ -165,16 +443,39 @@ because refusing without one just moves the herd next door.
165
443
  - **A live query needs `REPLICA IDENTITY FULL`.** Deciding whether a row *left* a result set needs
166
444
  the old values; with the default identity a delete replicates only the key columns.
167
445
  - 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.
446
+ implements the full journal/rollback/replay semantics today. It holds membership and the journal;
447
+ the row values are the client's one `IdentityMap`, which is what a browser store has to inherit
448
+ rather than re-implement.
449
+ - The identity map is **per client**, in memory, and it is not a query cache: it answers "what is
450
+ row X now", never "have I run this query before". Nothing evicts by time or size — a row lives
451
+ exactly as long as a window or a table holds it.
169
452
 
170
453
  ## Errors
171
454
 
172
- `X_TOPIC_FORBIDDEN` · `X_SUBSCRIPTION_LIMIT` · `X_PROTOCOL_VERSION` · `X_CURSOR_STALE` ·
455
+ `X_TOPIC_FORBIDDEN` · `X_SUBSCRIPTION_LIMIT` · `X_SUBSCRIPTION_ID_TAKEN` ·
456
+ `X_PROTOCOL_VERSION` · `X_CURSOR_STALE` ·
173
457
  `X_REBASE_CONFLICT` · `X_TRANSPORT_UNAVAILABLE` · `X_TRANSPORT_PROTOCOL` ·
174
458
  `X_REPLICATION_FAILED` · `X_REPLICATION_PROTOCOL` · `X_REPLICATOR_SLOT_HELD` ·
175
- `X_LIVE_CLIENT_MISSING` · `X_NOT_IMPLEMENTED`
459
+ `X_LIVE_CLIENT_MISSING` · `X_LIVE_QUERY_UNKNOWN` · `X_SOCKET_UNAUTHENTICATED` ·
460
+ `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED`
176
461
 
177
462
  Topics deny by default: a topic with no matching guard is forbidden. An authz hole is not a config
178
463
  option someone forgot to set.
179
464
 
465
+ An upgrade `authenticate` refuses is `X_SOCKET_UNAUTHENTICATED` (401) and one it *could not decide*
466
+ is `X_SOCKET_AUTH_UNAVAILABLE` (503). Two codes, because the two have opposite instructions: the
467
+ first is the client's credential and pages nobody, the second is this node's dependency and pages
468
+ someone. Both are rendered as the error contract in the response body — there is no frame to carry
469
+ one, because the client never got a socket.
470
+
471
+ A `sid` belongs to the socket that chose it. A subscription is keyed by `(socket, sid)`, a drop
472
+ frame is scoped to the socket that sent it, and reusing a sid the same socket already holds is
473
+ `X_SUBSCRIPTION_ID_TAKEN` — one client can neither take over nor end another's live stream.
474
+
475
+ A `subscribe` frame naming a query this node never registered is `X_LIVE_QUERY_UNKNOWN`, not
476
+ `X_PROTOCOL_VERSION`: the frame parsed and the version matched, so "rebuild and redeploy the
477
+ client" is the one instruction that cannot help — a rebuilt client spells the name the same way.
478
+ The fix is `x queries list --json`, and the name the client sent is echoed back while the registry
479
+ never is.
480
+
180
481
  `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": "2.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": "2.0.0",
36
+ "@ultimat3/query": "2.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
+ }
@@ -1,50 +1,109 @@
1
- // The bounded per-query change window that makes reconnect a delta instead of a refetch.
2
- // Lives on the `replicator` (one per DB), so a reconnecting client costs zero DB work while its
3
- // gap is inside the window. Outside it, `resumeFrom` takes one snapshot — never WAL traversal.
1
+ // The bounded per-query change window that makes reconnect a delta instead of a refetch. Inside
2
+ // the window a reconnecting client costs zero DB work; outside it, `resumeFrom` takes one bounded
3
+ // snapshot — never WAL traversal.
4
+ //
5
+ // **It is per `sync` node, and a `qid` window can only be.** The header used to say it lives on the
6
+ // replicator; it does not, and it could not — a patch is query-scoped, so producing one needs that
7
+ // query's compiled shape, its matcher and its current window, none of which the replicator has (it
8
+ // is entity-scoped by construction). The consequence is real and is not fixed here: a client that
9
+ // reconnects onto a node that never served its `qid` finds no ring, `shouldResnapshot` answers
10
+ // `out-of-window`, and it takes the snapshot path. What that costs is one *shared* read per
11
+ // (query, node) — `fillWindow` joins every subscriber arriving during a read into it — and not one
12
+ // read per client. Making the delta path work across nodes means an **entity**-keyed window every
13
+ // node fills from the change stream it already subscribes to, which is a `ResumeSource` shape
14
+ // change, not a placement change.
4
15
 
5
16
  import type { ResumeSource } from './cursor';
6
17
  import type { RowPatch } from './json';
7
18
 
8
19
  export interface ChangeBufferOptions {
9
- /** Retained patches per query hash. */
20
+ /** Retained patches per query hash — a REPLAY bound: what a delta resume may cost to fold. */
10
21
  readonly capacity?: number;
11
22
  /** Retained query hashes; the least-recently-written is dropped first. */
12
23
  readonly maxQueries?: number;
24
+ /** Retained bytes per query hash. The memory bound, and the one that actually holds. */
25
+ readonly maxBytesPerQuery?: number;
26
+ /** Retained bytes across every query on this node. */
27
+ readonly maxBytes?: number;
13
28
  }
14
29
 
30
+ /**
31
+ * The node's retained-patch memory ceiling. `packages/cache/src/lru.ts:1-2` states the rule this
32
+ * exists to obey: bounded by BYTES, never by entry count — 4,096 queries x 1,024 patches is 4.19M
33
+ * retained `RowPatch` objects, each holding a whole row, and nothing in that product is memory.
34
+ */
35
+ export const DEFAULT_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
36
+ export const DEFAULT_MAX_BUFFER_BYTES_PER_QUERY = 1024 * 1024;
37
+
15
38
  interface Ring {
16
39
  patches: RowPatch[];
40
+ bytes: number;
17
41
  /** Highest lsn already dropped. A cursor at or after this is still resumable. */
18
42
  evictedThrough: string | null;
19
43
  }
20
44
 
45
+ const encoder = new TextEncoder();
46
+
47
+ /** What one retained patch costs. Its serialised size: the row is the whole of it. */
48
+ function patchBytes(patch: RowPatch): number {
49
+ return encoder.encode(JSON.stringify(patch)).length;
50
+ }
51
+
21
52
  export class RingChangeBuffer implements ResumeSource {
22
53
  readonly #rings = new Map<string, Ring>();
23
54
  readonly #capacity: number;
24
55
  readonly #maxQueries: number;
56
+ readonly #maxBytesPerQuery: number;
57
+ readonly #maxBytes: number;
58
+ #bytes = 0;
25
59
 
26
60
  constructor(options: ChangeBufferOptions = {}) {
27
61
  this.#capacity = options.capacity ?? 1024;
28
62
  this.#maxQueries = options.maxQueries ?? 4096;
63
+ this.#maxBytesPerQuery = options.maxBytesPerQuery ?? DEFAULT_MAX_BUFFER_BYTES_PER_QUERY;
64
+ this.#maxBytes = options.maxBytes ?? DEFAULT_MAX_BUFFER_BYTES;
65
+ }
66
+
67
+ /** Retained bytes across every query on this node. The number the ceiling is about. */
68
+ get bytes(): number {
69
+ return this.#bytes;
29
70
  }
30
71
 
31
72
  append(qid: string, patch: RowPatch): void {
32
73
  const existing = this.#rings.get(qid);
33
- const ring: Ring = existing ?? { patches: [], evictedThrough: null };
74
+ const ring: Ring = existing ?? { patches: [], bytes: 0, evictedThrough: null };
34
75
  ring.patches.push(patch);
35
- while (ring.patches.length > this.#capacity) {
36
- const dropped = ring.patches.shift();
37
- if (dropped) ring.evictedThrough = dropped.lsn;
76
+ const cost = patchBytes(patch);
77
+ ring.bytes += cost;
78
+ this.#bytes += cost;
79
+ // Two ceilings, because they bound two different things: the count bounds what a resume has
80
+ // to fold, the bytes bound what this process holds. Whichever bites first, bites.
81
+ while (ring.patches.length > this.#capacity || ring.bytes > this.#maxBytesPerQuery) {
82
+ if (!this.#shift(ring)) break;
38
83
  }
39
84
  // Re-insert to move this qid to the tail of the LRU order.
40
85
  if (existing) this.#rings.delete(qid);
41
86
  this.#rings.set(qid, ring);
42
- if (this.#rings.size > this.#maxQueries) {
87
+ while (this.#rings.size > this.#maxQueries || this.#bytes > this.#maxBytes) {
43
88
  const oldest = this.#rings.keys().next();
44
- if (!oldest.done) this.#rings.delete(oldest.value);
89
+ // The only ring left is the one just written: evicting it would make a node under memory
90
+ // pressure retain nothing at all, and every reconnect a snapshot.
91
+ if (oldest.done || this.#rings.size === 1) break;
92
+ this.forget(oldest.value);
45
93
  }
46
94
  }
47
95
 
96
+ /** Drop the oldest patch of a ring, keeping both byte counters honest. Answers what it did. */
97
+ #shift(ring: Ring): boolean {
98
+ const dropped = ring.patches.shift();
99
+ if (!dropped) return false;
100
+ const cost = patchBytes(dropped);
101
+ ring.bytes -= cost;
102
+ this.#bytes -= cost;
103
+ ring.evictedThrough = dropped.lsn;
104
+ return true;
105
+ }
106
+
48
107
  since(qid: string, lsn: string): RowPatch[] | null {
49
108
  const ring = this.#rings.get(qid);
50
109
  if (!ring) return null;
@@ -58,8 +117,15 @@ export class RingChangeBuffer implements ResumeSource {
58
117
  return last ? last.lsn : null;
59
118
  }
60
119
 
61
- /** Called when the last subscriber of a query goes away, so an idle query stops costing memory. */
120
+ /**
121
+ * Called when the last subscriber of a query goes away, so an idle query stops costing memory.
122
+ * It had no caller until `LiveQueryRegistry.unsubscribe` gained one: the entry was dropped and
123
+ * the ring behind it kept every patch it held until the LRU happened to reach it.
124
+ */
62
125
  forget(qid: string): void {
126
+ const ring = this.#rings.get(qid);
127
+ if (!ring) return;
128
+ this.#bytes -= ring.bytes;
63
129
  this.#rings.delete(qid);
64
130
  }
65
131