@ultimat3/realtime 2.0.0 → 4.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.
package/CLAUDE.md CHANGED
@@ -79,6 +79,21 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
79
79
  prevent. A lane that fails now desyncs its own subscribers and the first failure still reaches
80
80
  the caller, but it costs one query id. The lane chains on a settled shadow of each task: one
81
81
  fanout that threw must not reject every fanout behind it.
82
+ - **Two reads of one entry are ordered by a READ GENERATION, never by an lsn.** `QueryEntry.lsn`
83
+ is optional — a definition with no lsn provider answers `''` from every snapshot — so the
84
+ never-backwards rule expressed purely in lsn terms read `'' >= ''` as "newer" and let the older
85
+ of two concurrent reads land on top of the newer one's window. The interleaving: a cold
86
+ subscriber issues P1; the change stream skips a sequence and `registry.invalidate()` marks the
87
+ entry; a second cold subscriber forces P2, which **clears `stale` on the way in**; P2 lands with
88
+ the post-gap rows; P1 lands last and overwrites them. `stale` is false, so `fanoutChange`'s
89
+ repair never fires, the next change patches the pre-gap window and re-snapshots every desynced
90
+ subscriber out of it — permanently stale on a healthy socket, which is the exact outcome `stale`
91
+ exists to prevent. `entry.generation` is bumped in `startRead` and `entry.applied` records the
92
+ newest read whose rows are on the window: an *identity* check, the same one `startRead` makes on
93
+ `entry.reading` one function down and `packages/cache/src/single-flight.ts:70` makes for the same
94
+ reason. The lsn guard stays beside it for the other question — a read that resolved behind a
95
+ *change* the fanout already folded — because those are two orderings and neither answers the
96
+ other.
82
97
  - **The definition's read is once per entry, not once per subscriber.** A cold subscriber arriving
83
98
  while another's read is in flight joins that read — N cold subscribers on one query id being N
84
99
  reads is the shared window not existing. It is a share, not a cache: the in-flight promise is
@@ -141,6 +156,34 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
141
156
  could reach a different one than the container it is standing in for. The KV bucket and the
142
157
  presence TTL come back with the transport for the same reason: they are one decision.
143
158
  - `sync` is stateless: no sticky sessions, nothing on a socket survives a restart.
159
+ - **A socket the node evicts ITSELF is released through `teardown`, never through
160
+ `sockets.remove`.** Bun's `close` callback runs `teardown`; a drain and the idle sweep have no
161
+ callback behind them — Bun's fires a tick later and `sockets.get` misses by then — so whatever
162
+ they do instead *is* the whole release. `drain()` inlined three of `teardown`'s five steps
163
+ (`close`, `sockets.remove`, `grants.delete`) and skipped the two the rest of the fleet can see:
164
+ `registry.unsubscribeSocket` and `presence.leave` per topic. What that left is a `QueryEntry`
165
+ whose `subscribers` map never empties — matcher, shared window and `WindowLock` pinned, and
166
+ `source.forget(qid)` never called — and, worse because it is cross-node, a presence member every
167
+ other node renders for a full TTL. During a **rolling restart** that is every room showing each
168
+ user twice for up to 30s, beside the same client's reconnection under a new socket id. One
169
+ `evict(socket, code, reason)`, and every path that ends a socket without a callback takes it.
170
+ - **The idle sweep exists, is armed by `start()`, and its budget is an APPLICATION one.**
171
+ `SocketRegistry.sweepIdle` had no caller for as long as it existed, so `touch()`, `idleFor` and
172
+ the 120s default decided nothing and `idleTimeoutMs` was unreachable from `createSyncNode`. The
173
+ only live guard was `websocket.idleTimeout: 120` handed to Bun — which Bun's own ping/pong
174
+ renews, so a client whose frame loop is wedged answers pings and keeps its `GrantBook` entry,
175
+ its `SubscriptionBook` entries and its `#byTopic` membership forever. It is now
176
+ `SocketRegistry.idle()`, a **query**: this table is three of the five things a socket holds, so
177
+ the object that can evict one is the node and not the registry. `start()` arms one `.unref()`ed
178
+ pass every `idleSweepPeriodMs(idleTimeoutMs)` — a quarter of the budget, floored at a second,
179
+ derived rather than configured because a second knob is a second number that can disagree with
180
+ the one it is a fraction of — and `release()` clears it beside the presence sweep. **It measures
181
+ on `Clock.monotonic()`**, the clock `AcceptBudget` already uses: the sweep compares a DURATION,
182
+ and a duration read off `now().getTime()` is decided by whatever NTP last wrote — a step forward
183
+ evicts every socket that is talking, a step backward makes `idleFor` negative and spares every
184
+ socket that is dead, and the sweep had only just gained its first caller when both became
185
+ reachable. The field is named `lastSeenMonotonicMs` so nobody hands it to `new Date()`;
186
+ `openedAt` is the wall-clock one and stays that way, because a human reads it.
144
187
  - **`drain()` and `stop()` both release what `start()` acquired, and releasing twice is a no-op.**
145
188
  A `drain()` is terminal on its own — it closes the hub and evicts every socket — and nothing
146
189
  obliges a `stop()` to follow it, so leaving the change subscription and the presence sweep to
@@ -171,6 +214,22 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
171
214
  threw skipped the close and the pump await, leaking the socket and telling a supervisor the
172
215
  teardown was over before it had begun. Every step runs whatever the step before it did, and the
173
216
  first failure is rethrown only once the connection is closed and the pump has ended.
217
+ - **`REPLICA IDENTITY FULL` is asked about at preflight, warned about, and counted — never thrown**
218
+ (added 2026-08-19). `pg-replication.ts`'s `#deliver` hands a `delete` its `message.before`,
219
+ and under any identity but FULL that tuple is the KEY COLUMNS ALONE — which `toRow` accepts,
220
+ because it only requires a text `id`. So `bridgeChange` decided "did this row leave the result
221
+ set" from a one-column row, `visible` read `undefined` for every column the identity did not
222
+ carry, and nothing anywhere recorded that it had happened: no emit, no check, and
223
+ `X_LIVE_REPLICA_IDENTITY` existed in neither the source nor the manifest. The check is a fourth
224
+ `connection.query` in `preflight`, and it **must** stay ahead of
225
+ `pg_create_logical_replication_slot` — changing the identity after a slot exists does not reach
226
+ what that slot decodes. It WARNS (`logger.warn(code, { cause, fix, tables })`, the message being
227
+ the code alone) because a throw would stop every app on the default identity from booting, which
228
+ is a worse outcome than the partial rows. `ReplicationStreamStats.partialBefore` is the runtime
229
+ half, read off `PgRelation.replicaIdentity` and never off the tuple: a DEFAULT-identity table
230
+ whose non-key columns happen to be NULL sends the bytes a FULL one does, so counting absent keys
231
+ would undercount exactly the rows a policy is most likely to misjudge. A hard refusal in the
232
+ `x verify` step is the follow-up and lives in `@ultimat3/cli`.
174
233
  - A change lsn is `<16 hex commit position><8 hex row position in that transaction>`. Never order by
175
234
  either half alone: the commit lsn repeats within a transaction, and per-record WAL positions are
176
235
  not monotonic across transactions. Never make it depend on wall time, the entity list or a process
@@ -268,6 +327,33 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
268
327
  double presence membership, double fanout — until the tab closed. `#socket` is nulled before the
269
328
  close so the corpse's `onClose` takes its early return, and `onMessage` carries the same identity
270
329
  guard `onClose` already had.
330
+ - **The grant is recorded BEFORE `server.upgrade`, and released on the path that never opens.**
331
+ Bun runs `websocket.open` SYNCHRONOUSLY inside `server.upgrade` and does not return until it has
332
+ (bun 1.3.14), and `open` is where `sync-node` reads the `GrantBook` for the socket's actor.
333
+ Recorded on the line after the upgrade — which it was — every authenticated socket on a real node
334
+ carried `actor: null`: `ChannelHub.#authorize` denied every topic with `X_TOPIC_FORBIDDEN`,
335
+ `authorize`/`visible` decided about nobody, `maxPerTenant` never applied and `hello.actorId` was
336
+ null. It did not self-repair, because `GrantBook.expired()` skips a grant with no `expiresAt` —
337
+ the shape `authenticate: async () => ({ actor })` produces. `onUngranted` is what makes the
338
+ correct order safe (only a `close` callback deletes a grant, and an upgrade that never took gets
339
+ no callback); it is REQUIRED on `UpgradeDeps`, so a second host of `handleUpgrade` cannot forget
340
+ it. The harness is half the rule: `sync-node-auth.test.ts`'s `upgradeTarget()` returned `true`
341
+ without ever calling `open`, so the bug was invisible to every test here — it now opens the socket
342
+ inside `upgrade()`, the way Bun does.
343
+ - **Every `socket.send` on the node reads its answer, and what a `false` costs is decided per
344
+ frame.** A subscribe reply is REPAIRABLE and was the silent one: `registry.subscribe` has already
345
+ seated the subscription and cleared its desync mark, so a dropped snapshot left the server
346
+ believing a client holding no rows was in sync, and every later change reached it as a patch
347
+ folded onto nothing — on a healthy socket, forever. It is marked desynced, exactly as
348
+ `live-fanout` marks a lost patch. A `rebase`/`ack` has nothing to mark (the node keeps no
349
+ per-mutation state) and a client only returns an `inflight` mutation to its queue when the
350
+ connection dies, so an undeliverable settlement **closes the socket**: the reconnect requeues and
351
+ replays it under the same idempotency key, and acking a rebase that never left is the
352
+ rebase-before-ack order defeated one frame later. A presence roster has no repair at all — the
353
+ membership is already on the shared set and the client's next heartbeat re-rosters — so it is
354
+ logged (`sync.presence_roster_dropped`). The drain's `reconnect` frame is the socket's slot in the
355
+ spread and nothing re-sends it, so `drain()` returns `DrainedSocket[]` with `notified` per socket
356
+ and logs `sync.drain_frames_dropped`.
271
357
  - **A socket's actor comes from `createSyncNode({ authenticate })` and from nowhere else.** The node
272
358
  imports no authenticator — the app supplies one, exactly as it supplies `onMutate` — and it runs
273
359
  on the upgrade *before* `server.upgrade`, so a refused credential never costs a websocket.
@@ -321,6 +407,13 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
321
407
  `Bridge` comment describes, one state earlier. So `close()` sets `#closed` **before** the walk and
322
408
  `#open` closes its own subscription when it lands after one, dropping the entry with it so a
323
409
  second post-close subscribe opens and closes its own rather than double-unsubscribing this handle.
410
+ It then **raises** `X_TRANSPORT_UNAVAILABLE` rather than returning: returning let `subscribe` fall
411
+ through to `joinTopic`, so the socket became a member of a topic nothing on this node is bridged
412
+ to — silent for the life of the connection, no error on either side, and no reason for the client
413
+ to redial. Reachable between `hub.close()` inside `node.drain()` and the last in-flight subscribe.
414
+ `#release` takes the bridge the caller reserved for the same reason: after `close()` cleared the
415
+ table, that topic name may hold a bridge a LATER subscribe opened, and releasing by name alone
416
+ decrements somebody else's refcount.
324
417
  - Deny by default on topics. No guard = `X_TOPIC_FORBIDDEN`.
325
418
  - **A guard that FAILS is not a guard that denied — the hub's copy of the rule the row gate already
326
419
  follows.** On `onActorChange` (the re-auth pass) only a denial unsubscribes; anything else keeps
@@ -368,20 +461,33 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
368
461
  channel's lsn is the publishing hub's own per-node counter, so a client cannot tell a gap from a
369
462
  message that came via another node. Declared in `socket.ts`, not core's `runtime-metrics.ts`:
370
463
  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`.
464
+ - **A qid is `@ultimat3/query`'s `queryHash(name, input)`, and this package derives none of its
465
+ own `As of 2026-08`.** `qidOf` was the same two lines over a local copy of the canonical form
466
+ (`stableDigest(canonicalJson(input))`), and `canonicalJson`/`stableDigest` were this package's
467
+ third copy of what `@ultimat3/action` and `@ultimat3/query` also each held. They had already
468
+ diverged: `{ a: undefined, b: 1 }` gave `feed:eb8ed3ccb5023093` from `queryHash` and
469
+ `feed:c0bf82ad036cb0a5` from `qidOf`, because query's walk drops an `undefined`-valued key and
470
+ this one rendered `"a":null`. The two are COMPARED in one flow `@ultimat3/query`'s `planResume`
471
+ decides refetch-vs-resume by comparing a cursor's `queryHash` against the query's, while
472
+ `liveQueryDefinition` keys the shared window by the qid so keeping both correct was never the
473
+ option; the first time either moved, every resume decision and every window lookup were keyed
474
+ differently. `realtime -> query` is the one declared sideways edge and this package already
475
+ imports it. **`fnv1a` stays here**, and only it: its job is the cursor's result-set digest, where
476
+ a collision costs a missed re-sort and never one client served out of another's window.
477
+ `live-contract.test.ts` is the pin — it reads `registry.subscriberCount(queryHash(name, input))`
478
+ through a real subscribe, so a local derivation fails it. Cost of the move: none observable on the
479
+ server. Every qid a node computes comes from a DECODED frame, and `JSON.parse` produces no
480
+ `undefined`, no `Date`, no `Map` and no `Set` — the four values the two forms disagree about — so
481
+ no live subscription re-keyed and nothing re-snapshotted.
482
+ - **The canonical form is injective over the values it accepts, and `JSON.stringify` is not** —
483
+ the reason that survives the move, now `@ultimat3/core`'s to enforce. `JSON.stringify` answers
484
+ `"null"` for `NaN` and `±Infinity` and `"0"` for `-0`, so four distinct inputs hashed to one qid
485
+ — and a qid *hit* hands the joiner the first subscriber's compiled source, matcher and seated
486
+ window. Bare `NaN` / `Infinity` / `-Infinity` / `-0` tokens are emitted instead; they are not
487
+ valid JSON, which is correct, because that output is hashed and never parsed. Exposure is
488
+ narrower than it looks and the tests say so rather than overclaiming: `NaN` and `±Infinity` have
489
+ no JSON spelling and so cannot arrive on a `subscribe` frame — they reach the hash only from a
490
+ caller building `input` in JS. **`-0` is wire-reachable**: `JSON.parse('{"a":-0}')` answers `-0`.
385
491
  - **Refusing new sockets and draining the ones you have are two shutdown phases.** `stopAccepting()`
386
492
  is the `accept` phase: `ready = false`, `/readyz` 503, a late upgrade shed with `retry-after-ms`,
387
493
  and every socket untouched — a draining node still owes its clients their patches, and `stop()` is
@@ -422,7 +528,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
422
528
  every client on open and read by nobody — the node replied `resume: []` and decided resume per
423
529
  subscription from the `subscribe` frame — so every reconnect shipped each cursor twice, up to 512
424
530
  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
531
+ choice: a cursor's `qid` is `` `${name}:${fingerprint(input)}` ``, so a node reading a resume list
426
532
  recovers the query **name** — it is the plaintext prefix — but never the `input`, which is the half
427
533
  every decision needs. Without it `definition.authorize({ actor, input })` cannot run and no entry
428
534
  can be built; the qid names a window but not a decision, and the retained window holds pre-policy
@@ -442,9 +548,11 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
442
548
  socket it opens against the *new* node. Two silent windows and the client closes with `4000` and
443
549
  arms the reconnect. It is one
444
550
  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.
551
+ live socket or backing off toward a new one, never both. The 15s is the client's OWN number:
552
+ `realtime.heartbeatMs` was a `RealtimeConfig` key read by nothing and it is **deleted**
553
+ (2026-08-19). The server half of the beat stays derived — `PresenceRegistry.heartbeatMs` is
554
+ `max(1000, floor(ttlMs / 3))`, the same rule `idleSweepPeriodMs` follows, because a second knob
555
+ is a second number that can disagree with the one it is a fraction of.
448
556
  - **Every question a hot path asks is indexed, never scanned.** `SubscriptionBook` keeps
449
557
  `#bySocket` and a per-tenant count beside `#bySid`, and `SocketRegistry` keeps `#byTopic` beside
450
558
  the socket table. Both replaced a walk of the whole node that ran once per socket or once per
@@ -537,6 +645,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
537
645
  | `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
646
  | `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
647
  | `pgoutput.ts` / `pg-entity-row.ts` / `pg-replication.ts` | WAL decode → `ChangeEvent`, and the lsn that orders it |
648
+ | `pg-preflight.ts` | the four questions asked before `START_REPLICATION` — `wal_level`, the publication, every entity's replica identity, the slot — plus `assertIdentifier`, the charset all four interpolate through |
540
649
  | `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
650
  | `nats-lib-client.ts` | the `nats` adapter — **the only file in the repo that imports `nats`** |
542
651
  | `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 |
@@ -568,8 +677,8 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
568
677
  | `client-contract.ts` | the client's injected shapes — `ClientSocket`, `LiveClientOptions`, `LiveHandle` — declared apart from the class that consumes them |
569
678
  | `policy-gate.ts` | the only authz seam |
570
679
  | `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 |
680
+ | `live-contract.ts` | what a live query IS: `LiveQueryDefinition`, `SnapshotResult`, `LiveSubscription`. Four modules need the shape and none of them needs the registry that runs it. The **id** is not here and not anywhere in this package — it is `@ultimat3/query`'s `queryHash` |
681
+ | `json.ts` | the wire's value types and `fnv1a` (drift), the one hash still owned here. The canonical form and the sharing-key hash are `@ultimat3/core`'s (`canonicalJson`, `fingerprint`) `json.test.ts` pins that `fnv1a` is never mistakable for one |
573
682
  | `live-definition.ts` | the only bridge from a declared `query({ live: true })` to a registrable definition — and `policy-gate.ts`'s only caller |
574
683
  | `matcher-bridge.ts` | the only `@ultimat3/query` matcher seam |
575
684
 
package/README.md CHANGED
@@ -168,6 +168,7 @@ wire.
168
168
  | distinct channel topics per node | 10,000 | `new ChannelHub({ maxTopicsPerNode })` | `X_SUBSCRIPTION_LIMIT` |
169
169
  | outbound bytes buffered on one socket | 1 MiB | `createSyncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
170
170
  | dropped frames before that socket is closed | 32 | `createSyncNode({ maxDroppedFrames })` | close `1013` (`overloaded`), reason `backpressure` |
171
+ | time one socket may route no frame | 120s | `createSyncNode({ idleTimeoutMs })` | close `4001` (`idle`), reason `idle timeout` |
171
172
  | retained patch bytes per node | 64 MiB | `new RingChangeBuffer({ maxBytes, maxBytesPerQuery })` | eviction, then a re-snapshot on resume |
172
173
  | array lengths and `input` nesting in a frame | `FRAME_LIMITS` | none — a hard ceiling | `X_PROTOCOL_VERSION` |
173
174
 
@@ -264,15 +265,17 @@ new LiveClient({ signal, connect, buildId, heartbeatMs: 15_000 }); // 0 disables
264
265
 
265
266
  | Property | Behaviour |
266
267
  |---|---|
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
+ | Default | `DEFAULT_HEARTBEAT_MS`, 15s. The client's own number and the only one: `realtime.heartbeatMs` in `app.config.ts` was deleted 2026-08-19 because nothing read it |
268
269
  | One beat | a `hello` — which carries no cursors at all; `HelloFrame` has no resume list, so a beat and an opening frame are byte-identical — plus one subscribe frame per topic held |
269
270
  | Why the topics | on the node, repeating the subscribe frame **is** the presence heartbeat; presence has no frame of its own in either direction |
270
271
  | Not a deploy check | `update-available` answers a skew between the build id recorded at the upgrade and the node's own, and neither can change on an open socket — so every `hello` on one socket answers the same forever. A client hears about a deploy on the socket it opens against the **new** node |
271
272
  | Silence | nothing received for **two** intervals ⇒ close `4000` (a private-use code, so it is distinguishable in a log) and arm the reconnect. Judged from the last frame of any kind, since the point is that bytes still cross |
272
273
  | Not an interval | one armed tick, re-armed by itself, on the same injected `Scheduler` the reconnect uses — a client is either beating on a live socket or backing off toward a new one, never both |
273
274
 
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.
275
+ `realtime.heartbeatMs` in `app.config.ts` is **gone** `As of 2026-08-19` it was read by nothing,
276
+ and an app that still sets it fails `x verify`'s typecheck step with TS2353 (`'heartbeatMs' does not
277
+ exist in type 'Input<RealtimeConfig>'`). Delete the line; this option is the only knob that changes
278
+ behaviour, and the node's presence beat is derived from its TTL rather than configured.
276
279
 
277
280
  ### A `send` that returned is not an acknowledgement
278
281
 
@@ -350,18 +353,36 @@ wire twice by a reconnect that raced an ack.
350
353
  `mutate` is one lane per socket; `subscribe` is one lane per sid, or per topic name; `hello` and
351
354
  the server-authored kinds are unlaned. A lane exists only while work is queued on it, because a
352
355
  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.
356
+ - **`qid` is `@ultimat3/query`'s `queryHash(name, input)`**`<name>:<first 16 hex of
357
+ SHA-256(canonicalJson(input))>`, 64 bits `As of 2026-08` where it was a 32-bit FNV-1a. It is a
358
+ *sharing* key: a hit is answered with the existing entry and the seated window, both holding the
359
+ first subscriber's input and rows, and input is client-chosen, so a collision is one client served
360
+ out of another's window. This package derives none of its own `qidOf` was a second spelling of
361
+ `queryHash` while `@ultimat3/query`'s `planResume` compares a cursor's `queryHash` against the
362
+ query's, so the two had to be one function or every resume decision and every window lookup would
363
+ be keyed differently the first time either moved. A rolling deploy across the *hash* change costs
364
+ one bounded snapshot per subscription — a cursor minted under the old format names a ring entry
365
+ the new node never held, so the resume falls back correctly rather than silently; the `qidOf`
366
+ removal itself costs nothing, because every qid a node computes comes from a decoded frame and
367
+ `JSON.parse` produces none of the values the two spellings disagreed about.
359
368
  - **A topic guard that *fails* keeps the topic.** On the re-auth pass, only a denial
360
369
  (`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures`
361
370
  and logs `channel.guard_failed`. `catch { unsubscribe }` reported a store that timed out as a
362
371
  revoked grant — every topic on every re-authenticated socket, silently, with the client never told
363
372
  to resubscribe. The initial `subscribe` is deliberately not split that way: there is no
364
373
  subscription to keep, so a guard that raises refuses that subscribe and the client hears about it.
374
+ - **An idle socket is swept, and the sweep is an APPLICATION budget, not Bun's.** Bun's own
375
+ `idleTimeout` is renewed by its ping/pong, so a client whose frame loop is wedged answers pings
376
+ and keeps its grant, its live subscriptions and its topic membership indefinitely. `start()`
377
+ arms one `.unref()`ed pass every `idleTimeoutMs / 4` (floored at a second, derived rather than
378
+ configured) and evicts anything past the budget the same way a close does — through the node's
379
+ `teardown`, never `SocketRegistry.remove`. `SocketRegistry.idle()` is a *query* for that reason:
380
+ the socket table is three of the five things a socket holds, and the other two are its live
381
+ subscriptions and its presence membership on the shared set. `sweepIdle` — which closed and
382
+ removed here, and had no caller at all — is gone. The budget is measured on `Clock.monotonic()`,
383
+ so `SyncSocket.lastSeenMonotonicMs` is a duration's start and not an instant: an NTP step forward
384
+ would otherwise evict every socket that is talking, and a step backward would spare every socket
385
+ that is dead. `openedAt` stays on the wall clock — it is a value a human reads.
365
386
  - **A `sync` node shuts down in two phases.** The `accept` phase calls `stopAccepting()`: `/readyz`
366
387
  answers 503 and a late upgrade is shed with `retry-after-ms`, while **every socket the node holds
367
388
  keeps its patch stream**. The `close` phase is `drain()` then `stop()`. Registered with no phase it
@@ -381,7 +402,10 @@ wire twice by a reconnect that raced an ack.
381
402
  caller.
382
403
  - **A cold subscribe reads once per query id.** Subscribers arriving during a read join it and each
383
404
  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.
405
+ is discarded rather than written back: the window only ever moves forwards. Two reads are ordered
406
+ by a monotonic **read generation** and never by lsn — a definition with no lsn provider answers
407
+ `''` for every read, and `'' >= ''` let the older of two concurrent reads land on top of the
408
+ newer one's gap repair, with `stale` already cleared and therefore nothing left to re-read.
385
409
  - **A denial drops a row; a gate that could not decide does not.** A policy answer (`X_FORBIDDEN`,
386
410
  `X_UNAUTHENTICATED`) is a decision and costs the row, counted as `rowsDenied`. Anything else a
387
411
  gate throws — a rule whose lookup timed out, a predicate with a typo — is counted as
@@ -394,10 +418,16 @@ wire twice by a reconnect that raced an ack.
394
418
  `undefined` and answer as if the row had said so. A patch whose row the shared window does not
395
419
  hold is withheld — the window *is* the result set — and a subscriber holding that row gets the
396
420
  one `delete` that says so. It counts as neither a denial nor a gate failure: nothing decided.
421
+ - **A `delete` is withheld too, and `holds` is the whole decision** (`As of 2026-08`). It carries no
422
+ row, so there is nothing to put in front of the rule — and it was forwarded unconditionally, so
423
+ every subscriber learned the id and the instant of every *other* tenant's row as it was deleted,
424
+ on a query whose `visible` rule had never let them see one. A subscriber that holds the row is
425
+ told it is gone; one that does not gets nothing, counted as `rowsDenied`.
397
426
  - **`PgLogicalReplicationFeed` decodes `pgoutput` off a real slot** — its own Postgres v3 client
398
427
  (SCRAM-SHA-256, in-band TLS, CopyBoth), no driver dependency. It preflights `wal_level`, the
399
- publication and the slot, creates the slot when there is none, and confirms the slot as it goes so
400
- the WAL does not grow without bound. `InMemoryChangeFeed` + `InProcessTransport` remain the
428
+ publication, every entity's replica identity and the slot in that order, because the identity
429
+ check is worthless once the slot exists creates the slot when there is none, and confirms the
430
+ slot as it goes so the WAL does not grow without bound. `InMemoryChangeFeed` + `InProcessTransport` remain the
401
431
  defaults for `x dev` and every test.
402
432
  - **`selectChangeFeed(env, { entities })` decides which feed a boot installs** — same law
403
433
  `selectMailDriver` follows: an unset variable means the embedded default. It returns `{ feed,
@@ -440,8 +470,18 @@ wire twice by a reconnect that raced an ack.
440
470
  *transactions* in commit order, so per-record WAL positions are not monotonic across them. The
441
471
  pair sorts in delivery order and is byte-identical on replay, which is what turns at-least-once
442
472
  redelivery into a drop instead of a duplicate.
443
- - **A live query needs `REPLICA IDENTITY FULL`.** Deciding whether a row *left* a result set needs
444
- the old values; with the default identity a delete replicates only the key columns.
473
+ - **A live query needs `REPLICA IDENTITY FULL`, and the replicator now says so** (`As of
474
+ 2026-08-19`). Deciding whether a row *left* a result set needs the old values; with the default
475
+ identity a delete replicates only the key columns, and `toRow` accepts that tuple because it only
476
+ requires a text `id`. `preflight` asks `pg_class.relreplident` for every entity in the list — the
477
+ fourth question it asks, and **before** `pg_create_logical_replication_slot`, since changing the
478
+ identity after a slot exists does not reach the rows that slot will decode. It is a **coded
479
+ warning**, `X_LIVE_REPLICA_IDENTITY`, whose `fix:` is the `ALTER TABLE <t> REPLICA IDENTITY FULL;`
480
+ per named table — not a throw, because every app on the default identity would otherwise stop
481
+ booting, which is worse than the partial rows. `ReplicationStreamStats.partialBefore` is the
482
+ running half: one per change delivered off a relation that is not FULL, so the decisions it
483
+ actually cost are countable rather than silent. A hard refusal at `x verify` time is the
484
+ follow-up.
445
485
  - Tier 3's OPFS SQLite store is browser-only and throws until the browser entry ships; `MemoryLocalStore`
446
486
  implements the full journal/rollback/replay semantics today. It holds membership and the journal;
447
487
  the row values are the client's one `IdentityMap`, which is what a browser store has to inherit
@@ -456,8 +496,8 @@ wire twice by a reconnect that raced an ack.
456
496
  `X_PROTOCOL_VERSION` · `X_CURSOR_STALE` ·
457
497
  `X_REBASE_CONFLICT` · `X_TRANSPORT_UNAVAILABLE` · `X_TRANSPORT_PROTOCOL` ·
458
498
  `X_REPLICATION_FAILED` · `X_REPLICATION_PROTOCOL` · `X_REPLICATOR_SLOT_HELD` ·
459
- `X_LIVE_CLIENT_MISSING` · `X_LIVE_QUERY_UNKNOWN` · `X_SOCKET_UNAUTHENTICATED` ·
460
- `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED`
499
+ `X_LIVE_CLIENT_MISSING` · `X_LIVE_QUERY_UNKNOWN` · `X_LIVE_REPLICA_IDENTITY` ·
500
+ `X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED`
461
501
 
462
502
  Topics deny by default: a topic with no matching guard is forbidden. An authz hole is not a config
463
503
  option someone forgot to set.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "2.0.0",
3
+ "version": "4.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",
@@ -32,8 +32,8 @@
32
32
  "test": "bun test"
33
33
  },
34
34
  "dependencies": {
35
- "@ultimat3/core": "2.0.0",
36
- "@ultimat3/query": "2.0.0",
35
+ "@ultimat3/core": "4.0.0",
36
+ "@ultimat3/query": "4.0.0",
37
37
  "nats": "2.29.3"
38
38
  }
39
39
  }
package/src/changefeed.ts CHANGED
@@ -126,14 +126,26 @@ export class InMemoryChangeFeed implements ChangeFeed {
126
126
  async #deliver(event: ChangeEvent): Promise<void> {
127
127
  const handler = this.#handler;
128
128
  if (!handler) return;
129
- this.#tail = this.#tail.then(async () => {
129
+ const result = this.#tail.then(async () => {
130
130
  await handler(event);
131
131
  this.#lastLsn = event.lsn;
132
132
  });
133
- await this.#tail;
133
+ // The lane chains on a SETTLED shadow, never on `result` — `window-lock.ts` solves the same
134
+ // problem the same way. Chained on the live tail, one rejected link poisoned every link behind
135
+ // it: later changes rejected with the FIRST error, the handler was never called again and
136
+ // `lastLsn()` froze. Reachable on any single-node deployment, because `createReplicator`'s
137
+ // `onChange` awaits `transport.publish(...)` and a closed `InProcessTransport` refuses — one
138
+ // transient publish failure ended change delivery for the life of the process. The rejection
139
+ // still reaches the caller that pushed THAT event, and only it; `stop()` awaits the shadow, so
140
+ // a teardown reports the teardown rather than re-raising a failure already handed over.
141
+ this.#tail = result.then(ignore, ignore);
142
+ await result;
134
143
  }
135
144
  }
136
145
 
146
+ /** Settles the shadow lane whichever way the delivery went. Nothing observes the value. */
147
+ const ignore = (): void => undefined;
148
+
137
149
  export interface PgLogicalReplicationOptions {
138
150
  /** Connection string for a role with REPLICATION: `postgres://user:pass@host:5432/db`. */
139
151
  readonly url: string;
package/src/channel.ts CHANGED
@@ -6,7 +6,12 @@
6
6
 
7
7
  import { type Actor, logger, renderThrowable } from '@ultimat3/core';
8
8
  import { formatLsn } from './changefeed';
9
- import { isPolicyDenial, SubscriptionLimitError, TopicForbiddenError } from './errors';
9
+ import {
10
+ isPolicyDenial,
11
+ SubscriptionLimitError,
12
+ TopicForbiddenError,
13
+ TransportUnavailableError,
14
+ } from './errors';
10
15
  import { subjectMatches, type Transport, type TransportSubscription } from './fanout';
11
16
  import type { JsonObject } from './json';
12
17
  import type { SocketRegistry, SyncSocket } from './socket';
@@ -152,8 +157,11 @@ export class ChannelHub {
152
157
  await this.#authorize(socket.actor, name);
153
158
  await this.#open(name, bridge);
154
159
  } catch (error) {
155
- // The slot this subscribe took, given back on the one path that will never fill it.
156
- this.#release(name);
160
+ // The slot this subscribe took, given back on the one path that will never fill it — and
161
+ // given back to the bridge this subscribe actually reserved. `close()` clears the table, so
162
+ // a later subscribe may have put a DIFFERENT bridge under this name in the meantime, and
163
+ // decrementing that one's refs releases a topic somebody else is holding.
164
+ this.#release(name, bridge);
157
165
  throw error;
158
166
  } finally {
159
167
  const held = this.#claimed.get(socket) ?? 1;
@@ -164,7 +172,7 @@ export class ChannelHub {
164
172
  // membership this socket's close will give back, so the reference taken above has to go now or
165
173
  // it is a bridge nothing will ever release.
166
174
  if (socket.topics.has(name)) {
167
- this.#release(name);
175
+ this.#release(name, bridge);
168
176
  return;
169
177
  }
170
178
  // Through the registry, never `socket.subscribeTopic` directly: membership and the index the
@@ -297,12 +305,31 @@ export class ChannelHub {
297
305
  if (this.#closed) {
298
306
  unsubscribeWhenOpen(bridge);
299
307
  if (this.#bridges.get(name) === bridge) this.#bridges.delete(name);
308
+ // RAISED, not returned. Returning let `subscribe` fall through to `joinTopic`, so the socket
309
+ // became a member of a topic nothing on this node is bridged to: silent for the life of the
310
+ // connection, with no error on either side and nothing telling the client to redial. The
311
+ // same refusal the transport itself answers when it is gone, because from the client's side
312
+ // that is what happened — this node's bus for that topic is closed.
313
+ throw new TransportUnavailableError({
314
+ transport: 'channel',
315
+ reason: `the hub closed while "${name}" was opening`,
316
+ // The reader is a browser websocket client, which cannot run a CLI — so pure command
317
+ // advice would be worse than prose here. The shape that satisfies axiom 4 anyway is
318
+ // `http/src/error-map.ts`'s: a command that SHIPS (`x errors explain`, unlike the planned
319
+ // `x logs tail`) for whoever is holding a terminal, then the instruction as a comment.
320
+ fix: 'x errors explain X_TRANSPORT_UNAVAILABLE --json # then reconnect and resubscribe: this node is draining',
321
+ });
300
322
  }
301
323
  }
302
324
 
303
- #release(name: Topic): void {
325
+ /**
326
+ * `expected` is the bridge the caller reserved. Without it a release looks the topic up by name,
327
+ * and after a `close()` cleared the table that name may hold a bridge a LATER subscribe opened.
328
+ */
329
+ #release(name: Topic, expected?: Bridge): void {
304
330
  const bridge = this.#bridges.get(name);
305
331
  if (!bridge) return;
332
+ if (expected !== undefined && bridge !== expected) return;
306
333
  bridge.refs -= 1;
307
334
  if (bridge.refs > 0) return;
308
335
  unsubscribeWhenOpen(bridge);
@@ -72,8 +72,9 @@ export interface LiveClientOptions<T extends TableMap = TableMap> {
72
72
  readonly scheduler?: Scheduler;
73
73
  /**
74
74
  * How often a live socket re-announces itself, in ms. `0` disables it. Defaults to
75
- * `DEFAULT_HEARTBEAT_MS` the same 15s as the server's `realtime.heartbeatMs`, which browser
76
- * code cannot read, so it is an option here rather than a value shipped down the wire.
75
+ * `DEFAULT_HEARTBEAT_MS`, 15s. The one knob for the beat: `realtime.heartbeatMs` in
76
+ * `app.config.ts` was deleted 2026-08-19 because nothing read it, so this is not a restatement
77
+ * of a server value — browser code could never have reached one.
77
78
  */
78
79
  readonly heartbeatMs?: number;
79
80
  /** Where a dial failure inside the reconnect timer is reported. Defaults to `reportToConsole`. */
@@ -5,9 +5,10 @@
5
5
  import type { Scheduler } from './thundering-herd';
6
6
 
7
7
  /**
8
- * The same 15s as `realtime.heartbeatMs` in `@ultimat3/core`'s config, restated rather than read:
9
- * that value is server configuration and this is browser code, so the client cannot reach it. The
10
- * two are kept equal on purpose a node's presence TTL is sized against this interval.
8
+ * The client's own number, and `As of 2026-08-19` the only one. It used to be described as a
9
+ * restatement of `realtime.heartbeatMs` in `@ultimat3/core`'s config that key was read by
10
+ * nothing and is deleted, so there is no second value to keep this equal to. The server side of
11
+ * the beat is DERIVED, never configured: `PresenceRegistry.heartbeatMs` is `ttlMs / 3`.
11
12
  */
12
13
  export const DEFAULT_HEARTBEAT_MS = 15_000;
13
14
 
package/src/cursor.ts CHANGED
@@ -3,9 +3,9 @@
3
3
  // and the budget exists to make the expensive answer (a snapshot) the *chosen* one, not the
4
4
  // accidental one. See README "Reconnect is the hard part".
5
5
 
6
- import { type Clock, systemClock } from '@ultimat3/core';
6
+ import { type Clock, canonicalJson, systemClock } from '@ultimat3/core';
7
7
  import { CursorStaleError } from './errors';
8
- import { canonicalJson, fnv1a, type Row, type RowPatch } from './json';
8
+ import { fnv1a, type Row, type RowPatch } from './json';
9
9
 
10
10
  /** Ids are bounded so a cursor stays small enough to ship on every `subscribe` frame. */
11
11
  export const CURSOR_ID_LIMIT = 512;
package/src/errors.ts CHANGED
@@ -20,6 +20,7 @@ export const REALTIME_OWNED_ERROR_CODES = [
20
20
  'X_LIVE_CLIENT_MISSING',
21
21
  'X_LIVE_ROW_UNIDENTIFIED',
22
22
  'X_LIVE_QUERY_UNKNOWN',
23
+ 'X_LIVE_REPLICA_IDENTITY',
23
24
  'X_QUERY_NOT_SUBSCRIBABLE',
24
25
  'X_SOCKET_UNAUTHENTICATED',
25
26
  'X_SOCKET_AUTH_UNAVAILABLE',
@@ -113,6 +114,7 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
113
114
  X_LIVE_CLIENT_MISSING: 'a realtime hook ran with no LiveClient registered',
114
115
  X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
115
116
  X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
117
+ X_LIVE_REPLICA_IDENTITY: 'a replicated table sends a key-only row on delete',
116
118
  X_QUERY_NOT_SUBSCRIBABLE: 'a hook was bound to a query that is not declared live',
117
119
  X_SOCKET_UNAUTHENTICATED: 'the sync upgrade carried no credential this app accepts',
118
120
  X_SOCKET_AUTH_UNAVAILABLE: 'the sync node could not decide who a connecting socket is',
@@ -327,6 +329,34 @@ export class ReplicatorSlotHeldError extends RealtimeError {
327
329
  }
328
330
  }
329
331
 
332
+ /**
333
+ * A table in the entity list replicates with a replica identity other than FULL, so its `delete`
334
+ * (and any key-changing `update`) carries the KEY COLUMNS ONLY. `toRow` accepts that tuple —
335
+ * it only requires a text `id` — so the live matcher decides "did this row leave the result set"
336
+ * from a one-column row, and a row policy written against `!row.private` reads `undefined`.
337
+ *
338
+ * **Raised at preflight and LOGGED, never thrown.** Every app running today on the default
339
+ * identity would stop booting, and the replicator refusing to start is a worse outcome than the
340
+ * partial rows it is warning about. The runtime half is `ReplicationStreamStats.partialBefore`,
341
+ * which counts the changes this actually affects. Refusing it at `x verify` time is the follow-up.
342
+ *
343
+ * The tables are named because the fix is per table, and they are the entity list's own names —
344
+ * every one has already passed `assertIdentifier`, so the `fix:` is SQL that can be pasted.
345
+ */
346
+ export class ReplicaIdentityError extends RealtimeError {
347
+ constructor(args: { tables: readonly string[] }) {
348
+ super({
349
+ code: 'X_LIVE_REPLICA_IDENTITY',
350
+ cause:
351
+ `${args.tables.join(', ')} replicate with a replica identity other than FULL, so a ` +
352
+ 'delete carries the key columns only and a live query decides visibility from a partial row',
353
+ fix:
354
+ `${args.tables.map((table) => `ALTER TABLE ${table} REPLICA IDENTITY FULL;`).join(' ')}` +
355
+ ' -- rows already written to the WAL keep the identity they were written with',
356
+ });
357
+ }
358
+ }
359
+
330
360
  /**
331
361
  * A hook was called before the app entry registered its client. Never a transient fault: the
332
362
  * registration is a single call in the entry, so the fix is the call itself rather than a retry.