@ultimat3/realtime 1.1.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 +184 -15
  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/CLAUDE.md ADDED
@@ -0,0 +1,591 @@
1
+ # @ultimat3/realtime — agent notes
2
+
3
+ Tier 3 package. Channels, live queries, local-first sync. One protocol for all three.
4
+
5
+ ## Boundary
6
+
7
+ | May import | Must not |
8
+ |---|---|
9
+ | `@ultimat3/core`, `@ultimat3/query` | anything tier 4+ (`render`, `pwa`, `mcp`, `ui`, `cli`) |
10
+ | `@ultimat3/policy` **only via** `@ultimat3/query`'s `guard` | a second authz path of any kind |
11
+ | — | `solid-js` (the client takes an injected signal factory) |
12
+ | `nats` (the one external dependency, pinned exact) — from `nats-lib-client.ts`, and no other file | `nats` from anywhere else: a second importer is the failure this row exists to prevent. Every other file is written against the port in `nats-client.ts` |
13
+
14
+ ## Rules
15
+
16
+ - Policy is evaluated **once per subscriber**, never once per query. `live-query.test.ts` proves it
17
+ for a hand-written definition and `live-definition.test.ts` proves it for a real declared
18
+ `query({ live: true })` — the second one matters, because a rule that only holds for test fakes
19
+ is a rule no declaration can reach.
20
+ - **A name nothing registered is `X_LIVE_QUERY_UNKNOWN`, never `X_PROTOCOL_VERSION`.** The frame
21
+ parsed and the version matched — one string in it names nothing — so "x build && redeploy the
22
+ client" is the one instruction that cannot work: a rebuilt client spells the typo the same way,
23
+ and the registry that would have shown the mismatch never gets opened. The fix line is
24
+ `x queries list --json`. The name the client sent is echoed back; the registry is never
25
+ enumerated over the wire, because an unauthenticated socket walking `a`…`zz` is not entitled to
26
+ a list of every read this app declares. It is a client fault, so it never pages anyone. `fix` is
27
+ the command and nothing else — what to do with what it prints is in `cause`, because a fix line
28
+ is pasted into a shell and prose appended to one is a command that does not run.
29
+ - **One build per `(query, input)`, and the window reads through it.** `target.live()` produces the
30
+ descriptor *and* runs the read (`LiveQuery.execute`) — a second subject-less `sourceFor` for the
31
+ rows was two descriptions of one read that agreed only by luck, at twice the parse and twice the
32
+ `sql()` per query id. Both halves must come from one build or the matcher patches a window it
33
+ never saw: `live-definition.test.ts` proves it with a declaration whose rows carry the number of
34
+ the build that produced them, and under the old code the subscriber was served build 2's rows.
35
+ `execute()` runs on every call rather than memoising — a client joining an existing subscription
36
+ sees the rows as they are now.
37
+ - What `liveQueryDefinition` caches per query id is the compiled source, the shape, the matcher and
38
+ the shared row window. What it must never cache is a decision. It builds that shared half with
39
+ `enforce: false` **on purpose**: a source compiled under the first subscriber's authority and
40
+ then keyed by query id is that subscriber's entitlements becoming everyone's. `authorize` is
41
+ still the subscribe-time decision and still runs per socket.
42
+ - Every policy call in `live-query.ts` takes a `Subscriber`. That is the enforcement: there is no
43
+ path through the gate that reads a query id and no actor.
44
+ - **The row policy always sees the *whole* row from the shared window, never a partial patch — and
45
+ a window that does not hold the row is not a partial one.** An update patch carries the changed
46
+ columns plus the id, so merging it onto nothing and calling that a row hands `visible` a
47
+ `undefined` for every column the change did not touch: fail-closed for `row.ownerId === actor.id`,
48
+ and a leak for every `!row.private`. So a patch whose row the window does not hold is **withheld**
49
+ — dropped, or the one `delete` frame that tells a subscriber holding it that it is gone. It is
50
+ neither `rowsDenied` nor `gateFailures`: nothing decided anything, the window simply *is* the
51
+ result set. The one path that could meet an empty window is a delta resume onto an entry nothing
52
+ has read yet, and `subscribe` fills it first (`entry.lsn === ''`) rather than withholding
53
+ everything — conditional on purpose, because re-reading per resuming subscriber is exactly the
54
+ cost a delta resume exists to skip in a restart storm.
55
+ - **A denial is a decision; everything else is a failure, and the two never share an answer.** A
56
+ bare `catch { return false }` in the row gate read a dead pool as "you may not see this row" —
57
+ the rows left the screen, `live.rows_denied` counted the drop, and the outage shipped as a
58
+ permission change. `visibleWithPolicy` matches `QueryDeniedError` (the only thing `guard` throws
59
+ for a decision) and rethrows the rest; `subscriber-gate.ts` and `reauthorize` ask
60
+ `isPolicyDenial(error)` instead, because `authorize` and `visible` are caller-supplied functions
61
+ and the answer has to come off the error's code. What a failure costs is decided per surface: a
62
+ snapshot **raises** out of `subscribe` (a short result set is indistinguishable from a correct
63
+ one), a delivery desyncs that **one** subscriber and lets the fanout finish, and a `reauthorize`
64
+ keeps the subscription — destroying it would report a timeout as a revoked grant, and a client
65
+ does not resubscribe to a denial. Every failure is counted as `gateFailures` and reported through
66
+ `onGateFailed`, never through `onRowDenied`: an alert fires on one of them.
67
+ - **One serial lane per query id, and it is the only thing that orders a fanout.** Nothing upstream
68
+ does: `sync` fires `void registry.deliver(change)` straight off the bus subscription, so two
69
+ changes arriving back to back both start, both write `entry.rows`/`entry.lsn`, and both await
70
+ their way through a per-subscriber gate in between. Unordered, a subscriber is handed lsn 2 and
71
+ then asked to fold lsn 1 on top of it, its cursor rewound to 1 — a reconnect then replays what it
72
+ already applied, over newer state, and the row stays at the older value. `WindowLock` (`run`)
73
+ gives each entry a FIFO lane. `deliver` *enters* every lane before awaiting any of them, and no
74
+ fanout ever takes a second lane, so holding all of them at once cannot be a cycle — and two
75
+ deliveries queue onto each query id in call order, which is what makes "per query id, not per
76
+ node" true. Awaiting one entry before entering the next was two bugs in one line: one slow policy
77
+ pass set the whole node's pace, and a lane that threw ended the loop, so every entry behind it
78
+ missed the change with **nobody desynced** — the silent divergence `markDesynced` exists to
79
+ prevent. A lane that fails now desyncs its own subscribers and the first failure still reaches
80
+ the caller, but it costs one query id. The lane chains on a settled shadow of each task: one
81
+ fanout that threw must not reject every fanout behind it.
82
+ - **The definition's read is once per entry, not once per subscriber.** A cold subscriber arriving
83
+ while another's read is in flight joins that read — N cold subscribers on one query id being N
84
+ reads is the shared window not existing. It is a share, not a cache: the in-flight promise is
85
+ cleared as it settles, so a later subscriber reads current rows rather than a window that has
86
+ been drifting since boot. The result lands **in the lane and never backwards**: a snapshot that
87
+ resolved after a newer change was already fanned out is discarded and its caller is served from
88
+ the newer window, because rewinding hands that subscriber rows the fanout has moved past and a
89
+ cursor behind the change that would have corrected them.
90
+ - The retained change window stores **pre-policy** patches; resume re-filters them per subscriber.
91
+ - A resume is the one gate pass that runs **outside** the lane, and it reads the live window on
92
+ purpose: the window can only have moved forwards, and a row whose grant was revoked in the
93
+ meantime is one that pass must refuse rather than replay from its state at the cursor's lsn.
94
+ - Truth is the server. A client is never the merge authority.
95
+ - Presence lives in `transport.shared`, never in a node's heap — it must survive a node loss.
96
+ - The `sync` node is `PresenceRegistry`'s only caller. Subscribing to a topic **is** joining its
97
+ presence set, repeating the frame is the heartbeat, and dropping the subscription or closing the
98
+ socket is the leave — presence has no frame of its own in either direction, because a second way
99
+ to say "I am here" is a client that can be subscribed and invisible at the same time.
100
+ - Expiry is silent by design, so the node sweeps on an interval: without it a member whose node
101
+ died is never announced as gone, and the survivors render a cursor that stopped moving. It has to
102
+ be an interval — a sweep only reports members a previous sweep saw, which is how a member that
103
+ joined on another node becomes leavable here at all.
104
+ - **The wire is the library's, the integration is ours, and `nats-client.ts` is the line between
105
+ them.** `nats` is imported by exactly one file — `nats-lib-client.ts` — and everything else in the
106
+ package, the transport and the JetStream bucket and the KV presence set and every test, is written
107
+ against that port. It is what lets the fake be an in-memory bus with *server* semantics instead of
108
+ 431 lines of forged wire bytes, and what made deleting 1,019 LOC of framing, parser, PING/PONG and
109
+ session a swap rather than a rewrite ([`docs/idea/18-build-vs-wrap.md`](../../docs/idea/18-build-vs-wrap.md)).
110
+ The consequence that has to be held: **reconnect and re-subscription are the library's job now.** A
111
+ subscription outlives a dropped connection because the client re-establishes it underneath the
112
+ caller, so `NatsTransport` must never re-grow subscription bookkeeping of its own — a map of wanted
113
+ subjects, a dial promise, a loss counter, a rebind loop. Two things re-subscribing is a doubled
114
+ delivery on every reconnect and a subscription the caller's `unsubscribe` no longer reaches — the
115
+ hand-rolled client's lifecycle bugs were deleted with it rather than fixed for exactly that reason.
116
+ What stays above the port is what the library has no opinion on: our thundering-herd jitter, handed
117
+ over as its `reconnectDelayHandler` because spreading a restart herd is the framework's decision,
118
+ and the KV semantics presence needs (`nats-jetstream.ts`, `nats-kv.ts`) — a per-message TTL and a
119
+ batch direct get, which the library's own KV abstraction cannot express.
120
+ - **Nothing leaves `NatsTransport` uncoded, and `#translating` is where that is enforced — added
121
+ 2026-08.** `client.publish` and `client.subscribe` are the port's two SYNCHRONOUS calls, and the
122
+ library refuses locally on both: a bad subject, a payload over the server's `max_payload`, a
123
+ connection torn down between the `#ensure` and the call, a permissions violation on the subject.
124
+ A raw `NatsError` escaped `publish()` into `ChannelHub`'s bridge, `SocketRegistry` and the
125
+ replicator — no code, no `fix:`, nothing an operator can act on — while `InProcessTransport`
126
+ answered `X_TRANSPORT_UNAVAILABLE` for its own one refusal. `transport-parity.test.ts` asserts
127
+ both transports in one test, and a third case proves the wrap still DELIVERS: a translation that
128
+ swallowed a working publish would satisfy the two refusal cases and fan out nothing. An
129
+ `UltimateError` passes through untouched — the port raises its own for a closed client, and
130
+ re-wrapping buries the code a caller branches on. `nats-lib-client.ts`'s header claim that every
131
+ failure leaves *there* as an `UltimateError` is still false for those two calls; the translation
132
+ is deliberately in ONE place, and it is the transport, because `connect` is a public injection
133
+ seam and an app-supplied client throws whatever it likes.
134
+ - **Reusing a client whatever its state is a DECISION, not the other half of that bug.** `#ensure`
135
+ hands back a client that is mid-reconnect on purpose: the library is re-establishing that same
136
+ connection and its subscriptions, and a second dial alongside it doubles every delivery. What a
137
+ caller gets from a client whose reconnect budget is spent is a publish that resolves into
138
+ nothing — reported through `onError` by `#watch`, and visible to `/readyz` through `connected`,
139
+ which is where a dead bus is meant to be caught.
140
+ - One place reads `NATS_URL`, and it is `selectTransport` — a boot that resolved the bus itself
141
+ could reach a different one than the container it is standing in for. The KV bucket and the
142
+ presence TTL come back with the transport for the same reason: they are one decision.
143
+ - `sync` is stateless: no sticky sessions, nothing on a socket survives a restart.
144
+ - **`drain()` and `stop()` both release what `start()` acquired, and releasing twice is a no-op.**
145
+ A `drain()` is terminal on its own — it closes the hub and evicts every socket — and nothing
146
+ obliges a `stop()` to follow it, so leaving the change subscription and the presence sweep to
147
+ `stop()` alone is a drained node still pulling every change off the bus into a fanout with no
148
+ sockets, and still sweeping a room it left, through a hub it already closed. One `release()`,
149
+ called by both. `drain()` calls it after the sockets are gone and before `hub.close()`: a client
150
+ is entitled to its patches for the whole grace window, and to get them through a hub that is
151
+ still open.
152
+ - Exactly one `replicator` per DB, enforced by a session-level advisory lock.
153
+ - **The replication pump has one way out, and it closes what it held.** Both exits — a decode error
154
+ and `nextCopyData()` returning `undefined`, which is the walsender ending the copy — run `#die`:
155
+ record `stats().failure`, stop the confirm timer, close the connection and null it. Each one left
156
+ behind is a dead replicator claiming to be a live one. A `null` failure answers `/readyz` ready
157
+ for a loop reading no WAL; a live `#running` makes the next `start()` a silent no-op; a retained
158
+ timer keeps telling the walsender a dead stream is keeping up; a retained `#connection` is a
159
+ socket the next `start()` overwrites rather than closes, holding the slot `active`. A `start()`
160
+ that goes live clears the previous failure, and `stop()` awaits the pump even when the connection
161
+ is already gone — `#die` nulls it *before* closing it, so returning early reports a released slot
162
+ to the supervisor that is about to start the next process.
163
+ - **`#pump` *is* the terminal cleanup, so a restart awaits it before it dials.** `#drain` awaits
164
+ `#die` and `#die` awaits `connection.close()`, but `#die` clears `#running` and nulls
165
+ `#connection` before that close settles: a `start()` checking `#running` alone dialled into a
166
+ slot the dead walsender still owned and replaced `#pump` with its own, so the next `stop()`
167
+ awaited only the new pump. `start()` takes the previous pump and awaits it first; its failure
168
+ path calls `stop().catch(() => undefined)` because the boot diagnosis is the one an operator acts
169
+ on and a teardown that also failed must not replace it.
170
+ - **`stop()` releases everything before it reports anything.** A `#confirm` or an `endCopy` that
171
+ threw skipped the close and the pump await, leaking the socket and telling a supervisor the
172
+ teardown was over before it had begun. Every step runs whatever the step before it did, and the
173
+ first failure is rethrown only once the connection is closed and the pump has ended.
174
+ - A change lsn is `<16 hex commit position><8 hex row position in that transaction>`. Never order by
175
+ either half alone: the commit lsn repeats within a transaction, and per-record WAL positions are
176
+ not monotonic across transactions. Never make it depend on wall time, the entity list or a process
177
+ counter — a replay must produce byte-identical lsns or at-least-once turns into duplicate delivery.
178
+ - Slot, publication and entity names are interpolated into a replication command, so they are
179
+ checked against `[a-z_][a-z0-9_]*` first. That regex is a security boundary, not a style rule.
180
+ - Same rule on the bus, for the half that is still ours: a bucket name is interpolated into a
181
+ JetStream stream name and its API subjects, so it is checked first (`assertBucket` in
182
+ `nats-jetstream.ts`, `X_TRANSPORT_PROTOCOL`). Subject validation went with the hand-rolled client —
183
+ the library refuses a malformed subject itself, and a second spelling of that rule here is a second
184
+ place it can drift. A presence key or member id is user data, so it is base64url-encoded
185
+ (`encodeToken`) rather than validated — no name is refused for its spelling.
186
+ - **One row value per `(entity, id)` per client, and `identity-map.ts` is the only place one lives.**
187
+ A live window is an ordered list of ids over that map and a local-store table is membership over
188
+ it — neither holds a row of its own, because two components holding two copies of post #7 is the
189
+ bug the map exists to make unrepresentable. A `LiveClient` takes the map off its store when tier 3
190
+ is configured (`options.store.identity`) and builds one otherwise: a second map here would be that
191
+ same duplication, one level up.
192
+ - **The scope is `(entity, id)`, never `id` alone, and the entity comes from the server.** The
193
+ compiled shape's root entity (`live.shape.entity`) is the one name the live path, `ChangeEvent`,
194
+ a mutator's `tx.<table>` and `rebase`'s `ack.entity` all already agree on; a browser cannot derive
195
+ it, because the shape is compiled out of `sql`. It rides on the `snapshot` frame, and a
196
+ subscription that is told no entity keeps its rows under `?query:<name>` — private, colliding with
197
+ nothing. Wrong sharing merges two entities into one row; no sharing only costs a stale view.
198
+ - **`snapshot.entity` is additive, and that is why `PROTOCOL_VERSION` did NOT move.** The bump rule
199
+ exists for a shape change that makes an old frame unreadable. This one is readable both ways — an
200
+ old node omits the field and the client falls back to the private scope, an old client drops it in
201
+ `decode` — so bumping would refuse every in-flight client during a rolling deploy in exchange for
202
+ nothing. An *incompatible* frame change still bumps, and every kind still needs a fixture.
203
+ - **A value is replaced, never mutated, and a write merges columns rather than replacing the row.**
204
+ A mutated row is a render that never happens — the projections hand rows to a signal, which
205
+ compares by reference. And two queries may project different columns of one row, so a snapshot
206
+ from the narrower one must not blank what the wider one is rendering. Only a `delete` removes.
207
+ - **A row lives exactly as long as something holds it.** Every projection retains its ids and
208
+ releases them when it lets go (`RowWindows` on a re-snapshot, a patch, a close; a table on delete
209
+ and rollback). The last release drops the value — without it an infinite scroll retains every row
210
+ it ever saw. It is what lets a rollback of an optimistic insert leave a row a live window still
211
+ holds: the table's membership goes, the row does not.
212
+ - `local(tx, input)` is pure: no I/O, no `Date.now()`, no `Math.random()`. Rebase replays it.
213
+ - One registered `LiveClient` per app (`setLiveClient`), and every hook reads it through that seam —
214
+ no hook takes a client argument, and an unregistered one is `X_LIVE_CLIENT_MISSING`, never a
215
+ lazily-constructed default.
216
+ - Anything a component reads is a **getter or an accessor**, never a value snapshotted at hook time:
217
+ a plain field cannot re-render. `MutatorLike.local` is declared with method syntax so an
218
+ `@ultimat3/action` `Mutator` assigns with no cast — a function-typed property would not.
219
+ - `useLive`'s thunk input is read once, at subscribe time. There is no reactive runtime here to
220
+ re-run it, and pretending otherwise would be a silently stale subscription.
221
+ - Every subscription handle client code gets back — `LiveHandle` (`useLive`'s return, and
222
+ `LiveRows` one layer up through the hook), `Unsubscribe` (`client.subscribe(topic, …)`'s return)
223
+ — is `Disposable`. `[Symbol.dispose]` is the exact same function reference as `unsubscribe`,
224
+ never a second implementation that could drift from it, so `using sub = client.useLive(...)` and
225
+ `sub.unsubscribe()` are one teardown path either way. Pinned in `type-pins.ts`
226
+ (`_LiveHandleIsDisposable`, `_LiveRowsIsDisposable`, `_UnsubscribeIsDisposable`) so a refactor
227
+ that drops the member fails the build, not a call site months later.
228
+ - `liveHookFor(query)` is the typed projection the wiki promises as `useLiveFeed({ orgId })`. It
229
+ **binds** `useLive` — it never re-implements a subscribe path, because two of those is two places
230
+ a subscription can be opened wrong. It names `Query`'s shape structurally (`LiveQuerySource`)
231
+ rather than importing `@ultimat3/query` as a value: a hook is browser code, and a value import
232
+ would pull the server's read path into the bundle.
233
+ - The query's name is read **per call**, never captured at bind time. `export const useLiveFeed =
234
+ liveHookFor(liveFeed)` runs at import; `registerQueries()` stamps the name later, at boot.
235
+ - Type claims about the hook go in `type-pins.ts`, never in a `.test.ts` — `tsconfig.json` excludes
236
+ test files, so `tsc -b` never reads one and an assertion written there can never fail.
237
+ - The client owns its own reconnect: a closed socket arms **one** timer through the injected
238
+ `Scheduler`, and that timer calls `connect()`. `reconnectAt` is the render half and never the
239
+ mechanism — publishing it without arming anything is exactly the bug that shipped. Rules that
240
+ hold the arming together: `onClose` nulls `#socket` (a retained dead socket makes `#send` a
241
+ silent no-op), it only schedules when nothing is armed (a `reconnect` frame arms the node's
242
+ spread slot *before* closing, and a local backoff would overwrite it), and `close()` cancels —
243
+ a client whose owner is gone must stop dialling, while `connect()` starts it over. A close speaks
244
+ only for **its own** socket: `onClose` returns before touching any state when `#socket` is no
245
+ longer the socket that closed, because a replaced socket closing late must not mark the live
246
+ connection offline or arm a backoff behind it — and `close()` therefore reports its subscriptions
247
+ offline itself. A dial that throws inside the timer arms the next attempt and is **reported
248
+ through `onError`** (default `console.error`; never `logger`, whose writer is `process.stderr`
249
+ and this is browser code): a socket constructor may refuse, one refusal ending the chain is the
250
+ same outage as never arming, and nothing awaits a timer — a throw out of one is an uncaught
251
+ exception that can kill the process that was going to retry. Only the timer owns the chain and
252
+ only the timer reports — a `connect()` the app called itself throws to the app and arms nothing.
253
+ - **A `sid` is CLIENT data, so a subscription is keyed by `(socket, sid)` — never by `sid` alone.**
254
+ `LiveQueryRegistry.unsubscribe(socketId, sid)` and `.subscription(socketId, sid)` both take the
255
+ owner. Keyed by the sid alone, socket B reusing socket A's sid overwrote A's slot: A's
256
+ subscription stayed in its query entry's `subscribers` map with nothing able to reach it, so
257
+ `unsubscribeSocket(A)` freed nothing, `subscribers.size` never hit zero, and the entry's matcher
258
+ and shared window were pinned for the process's life while every change fanned out to a dead
259
+ socket. A `{op:'drop', sid}` frame from B ended A's stream with no error on either side.
260
+ `sync-node` passes `socket.id` on the drop path for that reason. Reusing a sid the SAME socket
261
+ already holds is `X_SUBSCRIPTION_ID_TAKEN` — refused rather than replaced, because replacing is
262
+ the strand. `subscription-book.ts` owns that identity and is the only place it is spelled: the
263
+ query entry's own `subscribers` map takes the same composite key, so one `unsubscribe` reaches
264
+ both by one identity.
265
+ - **`connect()` closes the socket it is replacing, and a frame speaks only for its own socket.**
266
+ A remount calling `connect()` on a live client left the previous socket open: its `onMessage`
267
+ kept folding patches into the live registrations, and the node held two sockets for one client —
268
+ double presence membership, double fanout — until the tab closed. `#socket` is nulled before the
269
+ close so the corpse's `onClose` takes its early return, and `onMessage` carries the same identity
270
+ guard `onClose` already had.
271
+ - **A socket's actor comes from `createSyncNode({ authenticate })` and from nowhere else.** The node
272
+ imports no authenticator — the app supplies one, exactly as it supplies `onMutate` — and it runs
273
+ on the upgrade *before* `server.upgrade`, so a refused credential never costs a websocket.
274
+ `null` is a **decision** (401, `X_SOCKET_UNAUTHENTICATED`, a client fault that pages nobody); a
275
+ throw is a **failure** (503, `X_SOCKET_AUTH_UNAVAILABLE`, reported) — the same rule the row gate
276
+ follows, one layer out. Absent, every socket is anonymous and `start()` warns: that node is
277
+ single-tenant, and `hub.guard('org.*.feed', ({ actor }) => actor?.orgId === …)` denies everyone.
278
+ The actor is written in exactly one place, the `GrantBook`; `WsData` deliberately carries none,
279
+ because two spellings of one identity disagree the moment a re-auth renews one of them.
280
+ - **A grant expires; a socket does not.** `authenticate` answers a `SyncGrant`, not an `Actor`: a
281
+ 15-minute token on a socket that stays up for hours was authorized once and served forever, and
282
+ an active client never idles out either — every inbound frame `touch()`es it. The node re-decides
283
+ an expired grant on an interval and then calls both halves that already existed and had no
284
+ caller: `hub.onActorChange` (topics) and `registry.reauthorize` (subscriptions). `refresh` is the
285
+ app's closure, so the framework retains no credential of its own — re-reading the upgrade
286
+ `Request` would mean holding one per socket. No `refresh` = close with `1008` and let the client
287
+ re-dial. A `refresh` that **raises** keeps the socket and retries: a token service timing out is
288
+ not a revocation.
289
+ - **`desynced` is a mark with a reader.** It is written when a patch is dropped by backpressure,
290
+ when a gate fails, when a window loses its tail and when a re-auth survives; the *next* delivery
291
+ serves that subscriber a fresh snapshot out of the shared window (no DB read) and only then
292
+ clears it. A snapshot the socket refuses leaves the mark, which is the state it is in. Four
293
+ writers and no reader was a subscription that stayed permanently and silently stale on a healthy
294
+ socket, with the server knowing and the client not.
295
+ - **`result.refill` is checked BEFORE the mark, because a repair out of a guessed window clears it.**
296
+ The word "fresh" above is load-bearing: when the matcher lost the window's tail, `entry.rows` is a
297
+ guess, and the same fanout that refuses to send a *patch* derived from it was resnapshotting every
298
+ already-desynced subscriber out of it — and clearing the one mark that would have made the next
299
+ change re-read. That subscriber is then recorded as repaired against rows nothing trusts and gets
300
+ a patch, not the snapshot it is still owed, from the refilled window. A lost tail degrades every
301
+ subscriber the same way, whatever each was holding, and they are all repaired on the next change
302
+ after `refillWindowInLane` has replaced the window. `live-fanout.test.ts` pins both halves.
303
+ - **A change the window already holds is refused on the way in.** The replicator guarded duplicates
304
+ and out-of-order on the *publish* side; `entry.lsn = change.lsn` was unconditional on the
305
+ *consume* side, so a redelivery rewound every subscriber's cursor. `change.lsn <= entry.lsn` is
306
+ dropped and counted as `staleChanges`.
307
+ - **A gap in the change stream is detected, not assumed away.** Fanout is core NATS — at most once
308
+ — and an lsn cannot reveal a gap, because a WAL position is a byte offset and every legitimate
309
+ next change is already an arbitrary jump. The replicator stamps `producer` + `seq`; a skipped
310
+ sequence marks every window `stale` and every subscriber desynced, and the next change to each
311
+ query re-reads. Both fields are optional on the bus: a publisher that does not sequence detects
312
+ nothing rather than crying gap, and a *new* producer restarts the count rather than reading as
313
+ one. A stale window is replaced in the lane (`refillWindowInLane`) — `fillWindow` takes the
314
+ entry's own lane and a lane is not reentrant.
315
+ - **A hub that closed opens nothing, and `#open` is the only thing that can enforce it.** `close()`
316
+ walks `#bridges` and then clears it, which reaches every bridge that is open and none that is
317
+ still opening: a reservation an in-flight `subscribe` has taken is `sub === null`, so
318
+ `unsubscribeWhenOpen` does nothing to it and `clear()` drops the entry. The transport then hands a
319
+ live subscription to a `Bridge` nothing can name — `#release` looks the topic up, misses and
320
+ returns — and its handler keeps calling `deliver` for the life of the process. The same orphan the
321
+ `Bridge` comment describes, one state earlier. So `close()` sets `#closed` **before** the walk and
322
+ `#open` closes its own subscription when it lands after one, dropping the entry with it so a
323
+ second post-close subscribe opens and closes its own rather than double-unsubscribing this handle.
324
+ - Deny by default on topics. No guard = `X_TOPIC_FORBIDDEN`.
325
+ - **A guard that FAILS is not a guard that denied — the hub's copy of the rule the row gate already
326
+ follows.** On `onActorChange` (the re-auth pass) only a denial unsubscribes; anything else keeps
327
+ the topic, increments `guardFailures` and logs `channel.guard_failed`. A guard is app code and may
328
+ reach a database, so `catch { unsubscribe }` reported a store that timed out as a revoked grant —
329
+ every topic on every re-authenticated socket on the node, silently, with the client never told to
330
+ resubscribe. The initial `subscribe` is deliberately NOT split: there is no subscription to keep,
331
+ so a raising guard refuses that subscribe and the client hears about it.
332
+ - **The `rebase` frame goes out BEFORE its `ack`, and an `ack` refers to what failed.** The ack is
333
+ the receipt and the receipt retires the client's journal row and rebase-log entry, so a rebase
334
+ landing after it has no entry to read `conflict` off — every merge silently becomes `server-wins`
335
+ — and no sequence to decide which later optimistic writes to replay. Two frames on one socket:
336
+ the order is the only coordination there is. `ackRefOf` answers the mutation key for a `mutate`
337
+ and the sid for a `subscribe`; the socket id is only for a frame that could not be decoded, since
338
+ `queue.fail(ref)` looks up by idempotency key and a socket id names a key no queue holds.
339
+ - **Inbound frames run in a lane, and the lane is NEVER the socket.** `sync-node.message` dispatches
340
+ every frame as `void (async () => routeFrame(…))()`, so nothing upstream orders them. A global
341
+ per-socket lane would put every frame behind the slowest one, and the slowest one is a subscribe's
342
+ snapshot read — a DB round trip every reconnecting client pays once per live query, which is the
343
+ restart storm this framework is measured on. `mutate` is one lane per socket, `subscribe` is
344
+ `sub:<sid>` or `topic:<name>`, everything else is unlaned (`frame-lanes.ts`). A lane exists only
345
+ while work is queued on it: keyed by a client-chosen sid, a lane that outlived its work is an
346
+ unbounded map one socket grows at will.
347
+ - **A cap is a RESERVATION taken before the first await, never a check.** A lane makes concurrent
348
+ frames sequential and N sequential subscribes still pass a check-then-act cap N times — and the
349
+ per-tenant cap spans sockets, where no lane can see it at all. `SubscriptionBook.reserve(socket,
350
+ sid)` decides the sid claim, `maxPerSocket` and `maxPerTenant` in one synchronous step;
351
+ `ChannelHub.subscribe` does the same for `maxTopicsPerSocket`, `maxTopicsPerNode` and the node's
352
+ bridge slot, before the guard is awaited. The tenant is captured, not re-derived — a re-auth may
353
+ `retenant` the socket while the read is in flight, and the release has to give the slot back to
354
+ the tenant that took it. Released in a `finally`, and releasing twice is a no-op.
355
+ - **Bun's native pub/sub is deleted, not wired.** Nothing here publishes to a native topic and
356
+ nothing will: a native publish cannot be refused per socket, cannot report the frame it dropped
357
+ and cannot mark a subscriber desynced. `SocketRegistry.deliver` is the one fanout path.
358
+ `WsLike.subscribe`/`unsubscribe` stay declared and unused — a tracked app implements the
359
+ interface structurally, so removing the members is that app's typecheck failure — and the
360
+ declaration says so, because a member that looks live is one someone will call.
361
+ - **A dropped channel frame is counted in three places and repaired in none.** The series
362
+ `channel_frames_dropped_total` (no attributes — a topic is client-chosen, so a per-topic label is
363
+ unbounded series one socket can mint), the log `channel.frames_dropped` with `{ topic, dropped,
364
+ total }`, and `SocketRegistry.droppedChannelFrames` for a test or a bench that cannot scrape.
365
+ Node-wide because a socket past `maxDroppedFrames` is closed and removed — a per-socket count
366
+ leaves exactly when loss is worst — and distinct from `SyncSocket.droppedFrames`, which counts
367
+ every frame kind and dies with its socket. Repair needs a per-topic sequence on the wire: a
368
+ channel's lsn is the publishing hub's own per-node counter, so a client cannot tell a gap from a
369
+ message that came via another node. Declared in `socket.ts`, not core's `runtime-metrics.ts`:
370
+ that file is the series every process emits, this one exists only where channels do.
371
+ - **`qidOf` is `stableDigest` (SHA-256, 16 hex) and never `fnv1a`.** The qid is a *sharing* key —
372
+ a hit hands back the existing entry and the seated window, carrying the first subscriber's input
373
+ and rows — and input is client-chosen, so 32 bits is a collision found offline in seconds and one
374
+ client served out of another's window. `fnv1a` stays the cursor's result-set digest, where a
375
+ collision costs a missed re-sort.
376
+ - **`canonicalJson` is injective over the values it accepts, and `JSON.stringify` is not.**
377
+ `JSON.stringify` answers `"null"` for `NaN` and `±Infinity` and `"0"` for `-0`, so four distinct
378
+ inputs hashed to one qid — and a qid *hit* hands the joiner the first subscriber's compiled
379
+ source, matcher and seated window. Bare `NaN` / `Infinity` / `-Infinity` / `-0` tokens are emitted
380
+ instead; they are not valid JSON, which is correct, because this output is hashed and never
381
+ parsed. Exposure is narrower than it looks and the tests say so rather than overclaiming: `NaN`
382
+ and `±Infinity` have no JSON spelling and so cannot arrive on a `subscribe` frame — they reach
383
+ `qidOf` only from a caller building `input` in JS. **`-0` is wire-reachable**: `JSON.parse('{"a":-0}')`
384
+ answers `-0`.
385
+ - **Refusing new sockets and draining the ones you have are two shutdown phases.** `stopAccepting()`
386
+ is the `accept` phase: `ready = false`, `/readyz` 503, a late upgrade shed with `retry-after-ms`,
387
+ and every socket untouched — a draining node still owes its clients their patches, and `stop()` is
388
+ what releases the change subscription carrying them. `drain()` + `stop()` are the `close` phase.
389
+ Registered with no phase, both landed in `close` and the node upgraded new websockets until the
390
+ very end. `listenSyncNode` unregisters both on `stop()`.
391
+ - **Readiness is asked twice, because `authenticate` is app code with an await in it.** A request
392
+ that passed the check at the top of `handleUpgrade` can be parked in a token service when SIGTERM
393
+ lands, and the `accept` phase is over by the time it reaches `server.upgrade` — one more socket on
394
+ a node the load balancer has already stopped routing to, so nothing takes it over. `ready` and the
395
+ socket count are therefore **functions** on `UpgradeDeps`, not values read once. The recheck sheds
396
+ with the same 503 + `retry-after-ms` and takes no second `tryAccept()`: that budget was spent.
397
+ - **A client `send` that returned is not an acknowledgement.** A browser `WebSocket.send` on a
398
+ CLOSING socket discards the frame and returns normally, so a drained mutation is `inflight` until
399
+ the server settles it or `requeueInflight` returns it. Only `pending` is sendable, `drain()` is one
400
+ chained pass at a time (two overlapping passes put one key on the wire twice, and a later pass can
401
+ overtake the one ahead of it), and backpressure over `MAX_BUFFERED_BYTES` declines rather than
402
+ fails — the mutation stays pending and the pass stops instead of reordering the ones behind it.
403
+ - **The lane orders passes; it does not order a socket death, so the queue carries an epoch.**
404
+ `requeueInflight` is not a pass and cannot reach into one parked at `await send(...)`: it hands
405
+ back what was on the dead socket, the parked pass resumes and marks everything *behind* that
406
+ mutation `inflight` for a connection that is gone. `#sendable` excludes `inflight`, so the next
407
+ drain skips them, no ack ever arrives and the writes are lost — invariant 3 inverted. `#epoch` is
408
+ bumped before the requeue scan and read at the top of every `#pass` iteration; a pass whose epoch
409
+ went stale returns and leaves the rest `pending` for the connection that arms the next one.
410
+ - **`#persist` hands the store a SNAPSHOT, never the live entries.** `QueueStore.save` is a durable
411
+ write (OPFS, IndexedDB) and may await before it reads. Given the array itself, a store that
412
+ resolves after the next pass has moved on persists a status that was never true when it was
413
+ called — and `inflight` is the one a reload cannot recover from.
414
+ - **A reconnect replays registrations AND topics, and every socket handler carries the identity
415
+ guard.** A reconnect is one `hello` plus one frame per thing this client holds: a `subscribe` per
416
+ registration, carrying that registration's cursor, and a `subscribe` per topic. Topic membership
417
+ is state on the node's socket, so a channel is silent from the first reconnect while its handler
418
+ is still installed — and its presence membership is swept — unless every one is re-announced.
419
+ `onOpen` needed the `#socket !== socket` guard `onMessage` and `onClose` already had: a replaced
420
+ socket opening late marked the connection up and replayed every subscription onto the current one.
421
+ - **`hello` carries NO cursors, and `HelloFrame.resume` is deleted (2026-08).** It was filled by
422
+ every client on open and read by nobody — the node replied `resume: []` and decided resume per
423
+ subscription from the `subscribe` frame — so every reconnect shipped each cursor twice, up to 512
424
+ ids each, in the restart storm this package is measured on. Wiring it was the wrong half of the
425
+ choice: a cursor's `qid` is `` `${name}:${stableDigest(input)}` ``, so a node reading a resume list
426
+ recovers the query **name** — it is the plaintext prefix — but never the `input`, which is the half
427
+ every decision needs. Without it `definition.authorize({ actor, input })` cannot run and no entry
428
+ can be built; the qid names a window but not a decision, and the retained window holds pre-policy
429
+ patches, so answering from it at `hello` time means answering before the per-subscriber
430
+ authorization pass, for a subscription that does not exist yet. It could only ever restate,
431
+ unauthorized, what `subscribe` decides with the input in hand — and it could not even save the
432
+ bytes, because the cursor still has to ride its `subscribe`. Two places deciding one thing is what axiom 1 refuses. **`PROTOCOL_VERSION` did NOT
433
+ move**, same rule as `snapshot.entity`: `decode` is a whitelist, so a new node drops an old
434
+ client's `resume` and an old node reads a new client's omission as the empty list it always got.
435
+ The one deploy of skew costs nothing in either direction.
436
+ - **The client beats, because only the client can end a half-open socket.** `heartbeatMs` (default
437
+ `DEFAULT_HEARTBEAT_MS`, 15s; `0` disables) sends a `hello` — byte-identical to the opening one,
438
+ since the frame has no resume list to leave out — plus one subscribe frame per topic, which is the
439
+ node's presence heartbeat. It is **not** how a deploy is noticed: `socket.skewed` compares the
440
+ build id recorded at the upgrade against this node's, both fixed for the socket's life, so every
441
+ `hello` on one socket answers the same forever and `update-available` reaches a client on the
442
+ socket it opens against the *new* node. Two silent windows and the client closes with `4000` and
443
+ arms the reconnect. It is one
444
+ self-re-arming tick on the injected `Scheduler`, not an interval: a client is either beating on a
445
+ live socket or backing off toward a new one, never both. The 15s is restated from
446
+ `realtime.heartbeatMs` rather than read — that is server config and this is browser code — and
447
+ `realtime.heartbeatMs` is read by nothing today.
448
+ - **Every question a hot path asks is indexed, never scanned.** `SubscriptionBook` keeps
449
+ `#bySocket` and a per-tenant count beside `#bySid`, and `SocketRegistry` keeps `#byTopic` beside
450
+ the socket table. Both replaced a walk of the whole node that ran once per socket or once per
451
+ frame: `ofSocket` filtered a copy of every subscription (100,000 entries measured at **17.7s** of
452
+ blocking work per teardown or re-auth sweep — a deploy or a batch of grants expiring together is
453
+ the whole trigger), and the per-tenant cap walked the same map on **every subscribe frame**
454
+ (7.96 ms each at that size). A new index goes where the deaths are seen: topic membership is the
455
+ registry's because `remove` is the one path a close, a drain and the idle sweep all take, and
456
+ `joinTopic`/`leaveTopic` are the only way to change it — two call sites for one membership is how
457
+ an index goes wrong. When an actor changes, `reauthorize` calls `book.retenant(socket)`: an index
458
+ nobody updates is a count that drifts for the rest of the process.
459
+ - **A ceiling per resource, and the wire's are not options.** `README.md` has the table. The rule
460
+ behind it: the accept budget bounds the accept *rate*, so the *count* needs its own
461
+ (`maxConnections`, shed as the same 503 + `retry-after-ms`); a socket that is open needs a frame
462
+ budget (`socket.frameBudget`, checked at the top of `routeFrame` **before `touch()`** — a frame
463
+ this node refuses must not renew the idle window); and anything a client sizes is bounded in
464
+ `decode` by `FRAME_LIMITS`, which a caller may narrow but never widen. `list()` takes a required
465
+ `max` so a new array field on a new frame cannot ship without someone choosing its size.
466
+ `input` is walked ITERATIVELY: the thing being refused is a stack overflow in `canonicalJson`, so
467
+ a recursive check would be the same crash one frame earlier.
468
+ - **Every ceiling on a socket `sync` builds is reachable from `createSyncNode`.** The node
469
+ constructs every `SyncSocket` it holds, so a `SyncSocketOptions` the node does not forward is a
470
+ number an operator can only change by abandoning `createSyncNode` — which is what
471
+ `maxBufferedBytes` and `maxDroppedFrames` were until 2026-08. Forwarded the same way
472
+ `maxFramesPerSecond`/`frameBurst` already are (`...(x === undefined ? {} : { x })`, so an unset
473
+ option keeps `SyncSocket`'s own default rather than overwriting it with `undefined`).
474
+ - **One socket's buffer has one number on the server and a separate one in the browser.**
475
+ `DEFAULT_MAX_BUFFERED_BYTES` (`socket.ts`) is both `SyncSocket`'s send-side ceiling and the
476
+ `backpressureLimit` `sync-node.ts` hands Bun — two spellings of one buffer on one side, and the
477
+ runtime's limit set lower means our check never fires and a frame is dropped with nothing marked
478
+ desynced. `client-mutations.ts`'s `MAX_BUFFERED_BYTES` is deliberately *not* imported from it:
479
+ that is browser code and `socket.ts` is the node's registry, its metrics and its close codes.
480
+ `sync-limits.test.ts` pins the server pair through behaviour, not by comparing two constants that
481
+ are now one declaration — an equality between them is a test that cannot fail.
482
+ - **A `SubscriptionLimitError` names the knob, never the default.** `knob` defaults to
483
+ `maxPerSocket`/`maxPerTenant`, which are `LiveQueryRegistry`'s — so the channel hub's per-socket
484
+ *topic* cap, thrown without one, told an operator to move a number in a different constructor
485
+ that would not have helped. Every throw site passes `knob` explicitly (`maxTopicsPerSocket`,
486
+ `maxTopicsPerNode`, `maxEntries`, `maxPerSocket`, `maxPerTenant`); `channel.test.ts` asserts the
487
+ two hub ones against the option names, because a fix line naming the wrong setting is worse than
488
+ no fix line — it is an instruction that runs and changes nothing.
489
+ - **Retained memory is bounded by BYTES.** `RingChangeBuffer` keeps the patch-count cap as a
490
+ *replay* bound (what a delta resume costs to fold) and adds the byte budgets as the memory one —
491
+ `packages/cache/src/lru.ts:1-2` states why: 4,096 queries x 1,024 patches is 4.19M retained rows
492
+ and no number of bytes at all. `forget(qid)` is called by `LiveQueryRegistry.unsubscribe` when the
493
+ last subscriber of a query id goes; it had no caller, so the ring outlived the entry.
494
+ - **An error never renders a value that carries a credential.** `parsePgUrl` names `DATABASE_URL`
495
+ rather than echoing the URL it refused — an error reaches a log, `--json`, an agent transcript
496
+ and a ticket, and the password is in the string. Same rule as `packages/mail/src/driver-smtp.ts`.
497
+ - **A full presence frame is capped and says so; the set behind it is never capped.** `roster()` is
498
+ what a frame carries (`maxMembers`, 256, plus `total`); `list()` stays whole because the sweep
499
+ differences it, and a short list would report every member past the cap as having left. `total`
500
+ is set on `sync` only — a `join`/`leave`/`update` frame is a delta, and a count beside one reads
501
+ as truncation.
502
+ - **One node per topic sweeps.** Every node sweeping every room it has seen is one full-set read
503
+ multiplied by the fleet, and the same `leave` frame published N times. The election needs no
504
+ compare-and-set the shared store does not have: the lease key is a *keyed set*, so each node's
505
+ claim is its own member and the leader is the lowest id every claimant can see. Eventually
506
+ consistent on purpose — the worst case is a duplicate `leave` for someone already gone.
507
+ - **Money is THREE physical columns on the wire too, and a live row must equal a repository row.**
508
+ `entityRow` folds `<p>_minor`/`<p>_currency`/`<p>_scale` into one property. It matched two, so a
509
+ scaled amount arrived at every subscriber unscaled *and* carrying a stray physical `priceScale`
510
+ beside `price` — one row, two shapes, no error anywhere. NULL and absent both mean **no `scale`
511
+ key**, never `0` (that is whole units, a 100x reinterpretation of an ordinary price), which is
512
+ exactly what `@ultimat3/entity`'s `moneyOf` does. That equality is the pin:
513
+ `pg-entity-row-parity.test.ts` reads one physical row through both surfaces — this package's fold
514
+ and a real `postgresRepo` — and asserts one object, each side absolutely as well as against the
515
+ other, because equality alone is satisfied by both failing open together. It is the one test here
516
+ that imports `@ultimat3/entity` (tier 2, a legal downward edge, test-only: `*.test.ts` never
517
+ ships), and it has to, or the thing being compared against is a copy of the reader instead of the
518
+ reader. The `0…15` scale bound stays `@ultimat3/schema`'s — enforced by the column CHECK and by
519
+ `parseScale`, never restated here.
520
+ - Never a bare `Error`. Never `any`. Never `Date.now()` — take a `Clock` (`clock.now()` is a `Date`;
521
+ use `monotonic()` for durations).
522
+ - **A test fixture standing in for a FOREIGN error extends `Error` on purpose, and that is not the
523
+ bare-`Error` rule being broken.** `PoolTimeout`, `Denied`, `MutationFailed` and `ThirdPartySdkError`
524
+ (nine sites across `realtime`, `db` and `ai`) simulate a driver, a policy library or an app's
525
+ `onMutate` — values this package did not construct and must handle anyway. `isPolicyDenial`,
526
+ `stringField` and `renderThrowable` all exist *because* such values arrive; rebuilt as
527
+ `UltimateError`s the fixture would prove the framework handles its own errors, which is the
528
+ "equality satisfied by both sides failing open together" failure the row-parity test names. The
529
+ rule governs what this package **throws**, never what a test hands it.
530
+
531
+ ## Map
532
+
533
+ | File | Owns |
534
+ |---|---|
535
+ | `sync-protocol.ts` | the wire: 10 frame kinds, `encode`/`decode`, `PROTOCOL_VERSION` |
536
+ | `channel.ts` / `presence.ts` / `socket.ts` | tier 1 |
537
+ | `live-query.ts` / `live-definition.ts` / `changefeed.ts` / `changefeed-env.ts` / `replicator.ts` / `pg-advisory-lock.ts` / `fanout.ts` / `transport-env.ts` / `matcher-bridge.ts` | tier 2 |
538
+ | `pg-bytes.ts` / `pg-wire.ts` / `pg-auth.ts` / `pg-connection.ts` / `pg-socket.ts` | the Postgres v3 client: bytes, frames, SASL, session, socket |
539
+ | `pgoutput.ts` / `pg-entity-row.ts` / `pg-replication.ts` | WAL decode → `ChangeEvent`, and the lsn that orders it |
540
+ | `nats-client.ts` | the bus port: publish/subscribe/request/requestMany/close/version/connected, and `parseNatsUrl` — the library takes `host:port` plus credentials and never reads a URL's userinfo |
541
+ | `nats-lib-client.ts` | the `nats` adapter — **the only file in the repo that imports `nats`** |
542
+ | `nats-jetstream.ts` / `nats-kv.ts` / `nats-transport.ts` | the JetStream KV bucket, presence over it, and the production `Transport` — all three written against the port |
543
+ | `nats-fake.ts` | an in-memory bus implementing the port — server semantics, not wire bytes; the only way to prove multi-node fanout under a sealed network |
544
+ | `cursor.ts` / `change-buffer.ts` / `thundering-herd.ts` | reconnect — the highest-risk area |
545
+ | `identity-map.ts` | the client's single source of truth: one row value per `(scope, id)`, its holds and its batched change notification |
546
+ | `live-rows.ts` | one subscription's window over that map — its scope, its order, its retain/release, and `Registration` itself |
547
+ | `local-store.ts` / `offline-queue.ts` / `rebase.ts` | tier 3 |
548
+ | `client.ts` / `sync-node.ts` | the two halves — connection lifecycle, subscriptions, mutations |
549
+ | `sync-auth.ts` | what a socket's identity IS (`SyncGrant`), the book that holds one per socket, and the pass that re-decides an expired one |
550
+ | `sync-frames.ts` | what a RECEIVED frame does to server state — the node's inbound surface, and the mirror of `client-frames.ts` |
551
+ | `sync-upgrade.ts` | the node's HTTP surface: `/healthz`, `/readyz`, load shedding, and the authenticated upgrade — `WsData` and `UpgradeTarget` are declared with the decision that builds them |
552
+ | `sync-listen.ts` | binding a node to `Bun.serve` and to the shutdown hook — the only `Bun.serve` in the package |
553
+ | `query-window.ts` | the shared pre-policy window per query id: built once, read once for N subscribers, and replaced when it is known to be wrong |
554
+ | `client-frames.ts` | what a RECEIVED frame does to client state, and `ClientFrameTarget` — the only inbound surface the client exposes. The mirror of `sync-frames.ts` |
555
+ | `client-harness-fixture.ts` | the injected socket + scheduler + harness both client suites drive. Excluded from the tarball |
556
+ | `hooks-fixture.ts` | the same, for the two hook suites (`hooks.test.ts`, `hooks-identity.test.ts`). Excluded from the tarball |
557
+ | `subscription-book.ts` | who holds which subscription, keyed by `(socket, sid)`, and the per-socket/per-tenant caps answered from it |
558
+ | `apply-patches.ts` | folding a patch list onto a row list (`applyPatches`) or onto ids alone (`orderAfterPatches`, what a window uses) — the client's one stateless piece, and one fold, not two |
559
+ | `hooks.ts` | the ambient client seam + the four component hooks — the only file an app imports |
560
+ | `query-hook.ts` | the typed projection: one declared query bound to one named hook |
561
+ | `type-pins.ts` | compile-time assertions `tsc` checks — the hook's input type, its row type, the `Query` seam |
562
+ | `window-lock.ts` | one FIFO lane per query id — the only thing that orders a fanout |
563
+ | `frame-lanes.ts` | the order one socket's INBOUND frames are applied in, and the lane key each kind belongs to. `WindowLock` again, keyed differently — and it bounds no cap |
564
+ | `live-fanout.ts` | what one change does inside one entry's lane: match, fold, one policy pass per subscriber, and the re-snapshot that repairs a desynced one |
565
+ | `client-mutations.ts` | the outbound mutation path — the optimistic twin, the rebase entry, the queue entry, and the sender the drain hands each frame to |
566
+ | `client-heartbeat.ts` | when to beat and when to give up. A policy, which is why it is not in `client.ts`'s connection lifecycle |
567
+ | `client-topics.ts` | the client's channel book, and the one membership frame its two callers (`subscribe`, the reconnect replay) must never spell differently |
568
+ | `client-contract.ts` | the client's injected shapes — `ClientSocket`, `LiveClientOptions`, `LiveHandle` — declared apart from the class that consumes them |
569
+ | `policy-gate.ts` | the only authz seam |
570
+ | `subscriber-gate.ts` | the per-subscriber pass of a definition's row policy, and its two counters — `rowsDenied` and `gateFailures`. Evaluates no policy of its own |
571
+ | `live-contract.ts` | what a live query IS: `qidOf`, `LiveQueryDefinition`, `SnapshotResult`, `LiveSubscription`. Four modules need the shape and none of them needs the registry that runs it |
572
+ | `json.ts` | the wire's value types, `canonicalJson`, and the two hashes — `stableDigest` (sharing keys) and `fnv1a` (drift). Tested in `json.test.ts`, beside the declarations: a `qidOf` test proves the qid, not the primitive under it |
573
+ | `live-definition.ts` | the only bridge from a declared `query({ live: true })` to a registrable definition — and `policy-gate.ts`'s only caller |
574
+ | `matcher-bridge.ts` | the only `@ultimat3/query` matcher seam |
575
+
576
+ ## Commands
577
+
578
+ ```
579
+ bun test # from packages/realtime
580
+ bun run typecheck
581
+ ```
582
+
583
+ Changing a frame shape means adding a fixture to `sync-protocol.test.ts` — the round-trip test
584
+ fails if a kind has no fixture — and bumping `PROTOCOL_VERSION` **when the change makes an old
585
+ frame unreadable in either direction**. An *additive optional* field (`snapshot.entity`, 2026-08)
586
+ is not that: `decode` builds a whitelist, so an old client drops it and a new client reads its
587
+ absence as a defined answer. Neither is *removing a field nothing read* (`hello.resume`, 2026-08):
588
+ the same whitelist drops an old client's copy, and a new client's omission decodes to what the
589
+ field always held. Bumping for either refuses every in-flight client on a rolling deploy and buys
590
+ nothing — the version guards incompatibility, not novelty. Removing a field something *does* read
591
+ is the opposite case and bumps.