@ultimat3/realtime 22.15.0 → 24.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 (74) hide show
  1. package/CLAUDE.md +89 -102
  2. package/README.md +140 -20
  3. package/package.json +4 -4
  4. package/src/advisory-lock.ts +57 -0
  5. package/src/boot.ts +12 -7
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +2 -2
  8. package/src/changefeed.ts +39 -1
  9. package/src/channel-authz.ts +43 -12
  10. package/src/channel-latch.ts +52 -0
  11. package/src/channel-logs.ts +34 -4
  12. package/src/channel-records.ts +33 -2
  13. package/src/channel-sids.ts +37 -0
  14. package/src/channel.ts +143 -44
  15. package/src/client-channels.ts +10 -1
  16. package/src/client-contract.ts +4 -1
  17. package/src/client-frames.ts +34 -4
  18. package/src/client-heartbeat.ts +39 -9
  19. package/src/client.ts +1 -0
  20. package/src/cursor.ts +11 -0
  21. package/src/errors.ts +70 -19
  22. package/src/fanout.ts +13 -0
  23. package/src/index.ts +1 -1
  24. package/src/live-contract.ts +6 -2
  25. package/src/live-definition.ts +46 -11
  26. package/src/live-fanout.ts +70 -11
  27. package/src/live-floor.ts +35 -0
  28. package/src/live-query-options.ts +38 -0
  29. package/src/live-query.ts +119 -91
  30. package/src/live-refusal.ts +21 -0
  31. package/src/live-replicator.ts +6 -6
  32. package/src/live-resume.ts +74 -0
  33. package/src/live-tenant.ts +25 -0
  34. package/src/nats-client.ts +11 -2
  35. package/src/nats-open.ts +9 -0
  36. package/src/nats-transport.ts +23 -1
  37. package/src/offline-queue.ts +48 -5
  38. package/src/page-errors.ts +17 -0
  39. package/src/page-outbox.ts +121 -50
  40. package/src/pg-advisory-lock.ts +45 -1
  41. package/src/pg-auth.ts +9 -4
  42. package/src/pg-bytes.ts +4 -1
  43. package/src/pg-connection.ts +119 -21
  44. package/src/pg-entity-row.ts +27 -2
  45. package/src/pg-identity-tables.ts +25 -0
  46. package/src/pg-preflight.ts +50 -4
  47. package/src/pg-replication-row.ts +82 -0
  48. package/src/pg-replication.ts +44 -45
  49. package/src/pg-socket.ts +6 -4
  50. package/src/pg-tls.ts +18 -0
  51. package/src/pg-wire.ts +5 -3
  52. package/src/pgoutput.ts +19 -9
  53. package/src/presence.ts +85 -8
  54. package/src/query-window.ts +28 -7
  55. package/src/record-persister.ts +7 -2
  56. package/src/replication-errors.ts +8 -4
  57. package/src/replicator-envelope.ts +110 -0
  58. package/src/replicator-row.ts +75 -0
  59. package/src/replicator.ts +319 -186
  60. package/src/server.ts +11 -7
  61. package/src/socket-engine.ts +14 -2
  62. package/src/socket-idle.ts +3 -1
  63. package/src/socket-routes.ts +8 -3
  64. package/src/subscriber-gate.ts +11 -1
  65. package/src/sync-auth.ts +47 -2
  66. package/src/sync-frames.ts +73 -20
  67. package/src/sync-node-bounds.ts +15 -0
  68. package/src/sync-node-contract.ts +20 -2
  69. package/src/sync-node.ts +92 -17
  70. package/src/sync-origin.ts +26 -11
  71. package/src/sync-protocol.ts +13 -3
  72. package/src/sync-upgrade.ts +76 -8
  73. package/src/use-mutation.ts +33 -5
  74. package/src/wire-channel.ts +13 -4
package/CLAUDE.md CHANGED
@@ -29,18 +29,13 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
29
29
  and never absent. `bun run side-effects` is the check.
30
30
  - **Every `@ultimat3/realtime/<subpath>` written in shipped source must be a key of `exports`**,
31
31
  comments included — `fix-specifier.test.ts`.
32
- - **`@ultimat3/realtime/server` needs its own `paths` entry in `tsconfig.base.json`** (the wildcard
33
- maps it to a directory that does not exist; `scripts/**` reports `TS2307` without it).
34
- - A browser file imports core from `@ultimat3/core/page` (`packages/core/src/page-bundle.test.ts`,
35
- `packages/query/src/client-bundle.test.ts`).
32
+ - A browser file imports core from `@ultimat3/core/page` (`packages/core/src/page-bundle.test.ts`).
36
33
 
37
34
  ## Numbers and ceilings
38
35
 
39
36
  - **Every numeric option is refused when it is not FINITE** — `@ultimat3/core`'s `finiteOption()`.
40
- `bun run finite-bounds` reads zero for this package, but it cannot see an option with no `??`
41
- default or where a screen runs, so audit by reading the option interfaces
42
- (`SubscriptionBook`'s `maxPerTenant` and `openNatsClient`'s `maxReconnectAttempts` were the two it
43
- missed). `SyncGrant.expiresAt` is app DATA, not an option, and is deliberately unscreened.
37
+ `bun run finite-bounds` reads zero here but cannot see an option with no `??` default, so audit
38
+ by reading the option interfaces. `SyncGrant.expiresAt` is app DATA, deliberately unscreened.
44
39
  - **A ceiling is refused where the object is BUILT**, never in a per-connection callback:
45
40
  `socketCeilings()` (`sync-node-bounds.ts`) runs once in `createSyncNode`; `SyncSocket` keeps its own
46
41
  screen because it is exported. `AcceptBudget`, `SyncSocket` and `SocketRegistry` throw
@@ -50,14 +45,14 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
50
45
  at the top of `routeFrame` **before `touch()`**), and `FRAME_LIMITS` in `decode` everything a
51
46
  client sizes (narrowable, never widenable). `list()` takes a required `max`. `input` is walked
52
47
  ITERATIVELY.
53
- - **Every `SyncSocket` ceiling is reachable from `createSyncNode`**, forwarded as
54
- `...(x === undefined ? {} : { x })`.
55
48
  - **A `SubscriptionLimitError` names the knob**: every throw site passes `knob` explicitly;
56
49
  `channel.test.ts` asserts the hub's two against the option names.
57
- - **Retained memory is bounded by BYTES**: `RingChangeBuffer` keeps the patch count as a replay
58
- bound and byte budgets as the memory bound. `forget(qid)` runs when the last subscriber goes and
59
- from `#dropIfUnheld` after a failed cold subscribe (only if the entry is still in the table, unheld
60
- and has no read in flight).
50
+ - **Retained memory is bounded by BYTES** (`RingChangeBuffer`: patch count bounds replay, bytes
51
+ bound memory). `forget(qid)` runs when the last subscriber goes and from `#dropIfUnheld`.
52
+ - **The ring floor is one rule (`change-buffer.ts`, `Ring`)**: only a read that LANDED floors it
53
+ (`fillWindow().landed`), a forced one exclusively; a position-less read is marked with the
54
+ node's origin and answers that cursor alone below its first patch; a stale entry serves cold
55
+ starts only.
61
56
  - **Every question a hot path asks is indexed**: `SubscriptionBook` keeps `#bySocket` and a
62
57
  per-tenant count; `SocketRegistry` keeps `#byTopic`, changed only by `joinTopic`/`leaveTopic`.
63
58
  `reauthorize` calls `book.retenant(socket)`.
@@ -85,8 +80,9 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
85
80
  - **A denial is a decision; everything else is a failure.** `visibleWithPolicy` matches
86
81
  `QueryDeniedError` and rethrows the rest; `subscriber-gate.ts` and `reauthorize` ask
87
82
  `isPolicyDenial(error)`. A failed snapshot raises out of `subscribe`; a failed delivery desyncs that
88
- one subscriber; a failed `reauthorize` keeps the subscription. Failures count as `gateFailures` via
89
- `onGateFailed`, never `onRowDenied`.
83
+ one subscriber; a failed `reauthorize` keeps the subscription, a DENIED one is refused under its sid
84
+ (`refuseSubscription`) — as is one whose org moved, and a failed re-seat. Failures count as
85
+ `gateFailures` via `onGateFailed`, never `onRowDenied`.
90
86
  - **One serial lane per query id (`WindowLock`) is the only thing that orders a fanout.** `deliver`
91
87
  enters every lane before awaiting any; no fanout takes a second lane; a lane that fails desyncs its
92
88
  own subscribers; lanes chain on a settled shadow of each task.
@@ -106,9 +102,15 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
106
102
  never clears a mark (`live-fanout.test.ts`).
107
103
  - **A change the window already holds is refused** (`change.lsn <= entry.lsn`, counted as
108
104
  `staleChanges`). **A gap is detected**: the replicator stamps `producer` + `seq`; a skipped
109
- sequence marks every window stale and every subscriber desynced; `refillWindowInLane` replaces it.
110
- - **A qid is `@ultimat3/query`'s `queryHash(name, input)`**; this package owns no hash
111
- (`live-contract.test.ts`). The canonical form is core's and injective (`-0` is wire-reachable).
105
+ sequence — or a NEW producer after a known one — stales every window; `refillWindowInLane` replaces it.
106
+ - **A bulk write stales only the windows reading its entity** (`invalidate(entity)`, from
107
+ `onBulk`). **A stale window's re-read re-snapshots EVERY subscriber**, before the lsn guard and
108
+ on a non-matching change too (`live-replicator-bulk.test.ts`, `live-fanout.test.ts`).
109
+ - **A WINDOW is per TENANT**: its id is `windowId(queryHash(name, input), tenant)` (`live-tenant.ts`;
110
+ tenant = the subscriber's `actor.orgId`). This package owns no hash. The read runs `readFor`
111
+ (`live-definition.ts`): the node's ctx, a service actor carrying ONLY that org — never the
112
+ subscriber. A cursor naming another window is a cold start; `reauthorize` re-seats a
113
+ subscription whose actor changed org (`live-tenancy.test.ts`; `docs/history/realtime.md`).
112
114
  - **A `sid` is CLIENT data: a subscription is keyed by `(socket, sid)`**
113
115
  (`subscription-book.ts` is the only spelling). Reusing a sid the same socket holds is
114
116
  `X_SUBSCRIPTION_ID_TAKEN`.
@@ -117,24 +119,21 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
117
119
  ## The node, the bus and presence
118
120
 
119
121
  - `sync` is stateless: no sticky sessions, nothing on a socket survives a restart.
120
- - **`selectTransport(env, realtime)` is the one place `realtime.transport` + env become a bus**; the
121
- KV bucket and presence TTL come back with it. The config decides, the env supplies (22.0.0):
122
- `'nats'` dials the variable `urlEnv` names and refuses (`X_CONFIG_INVALID`) when unset; `'memory'`
123
- refuses a set `NATS_URL` (or the named variable).
122
+ - **`selectTransport(env, realtime)` is the one place `realtime.transport` + env become a bus** (KV
123
+ bucket and presence TTL come back with it). The config decides, the env supplies: `'nats'` refuses
124
+ (`X_CONFIG_INVALID`) when the `urlEnv` variable is unset; `'memory'` refuses a set one.
124
125
  - **`nats` is imported only by `nats-lib-client.ts`**; reconnect and re-subscription are the
125
- library's job — `NatsTransport` must never grow subscription bookkeeping. What stays above the
126
- port: the herd jitter (`reconnectDelayHandler`) and the KV semantics presence needs
127
- (`nats-jetstream.ts`, `nats-kv.ts`). `nats-fake.ts` is a bus with server semantics.
128
- - **Nothing leaves `NatsTransport` uncoded** — `#translating` wraps the port's synchronous
129
- `publish`/`subscribe` refusals as `X_TRANSPORT_UNAVAILABLE`; an `UltimateError` passes through.
130
- `transport-parity.test.ts` asserts both transports, and that the wrap still delivers. `#ensure`
131
- reuses a mid-reconnect client on purpose.
126
+ library's — `NatsTransport` never grows subscription bookkeeping. Above the port: the herd jitter
127
+ and presence's KV semantics (`nats-jetstream.ts`, `nats-kv.ts`). `nats-fake.ts` has server semantics.
128
+ - **Nothing leaves `NatsTransport` uncoded** — `#translating` wraps `publish`/`subscribe` refusals
129
+ as `X_TRANSPORT_UNAVAILABLE`. `#ensure` reuses a mid-reconnect client on purpose;
130
+ `Transport.onReconnect(listener)` (required) announces each recovery.
132
131
  - **A bucket name is checked before interpolation** (`assertBucket`, `X_TRANSPORT_PROTOCOL`); a
133
132
  presence key or member id is base64url-encoded (`encodeToken`), never validated.
134
133
  - Presence lives in `transport.shared`. The sync node is `PresenceRegistry`'s only caller:
135
134
  subscribing to a topic IS joining, repeating the frame is the heartbeat, dropping or closing is the
136
135
  leave. Expiry is silent, so the node sweeps on an interval, and **one node per topic sweeps** (lowest
137
- id in the lease key's keyed set). `roster()` caps a frame at `maxMembers` (256) with `total`;
136
+ id in the lease's keyed set; the lease carries the roster followers record). `roster()` caps a frame at `maxMembers` (256) with `total`;
138
137
  `list()` is never capped. `total` is set on `sync` frames only.
139
138
  - **A socket the node evicts ITSELF goes through `evict(socket, code, reason)`** (the full
140
139
  `teardown`), never `sockets.remove`. **`drain()` waits for the presence leaves it started** —
@@ -149,9 +148,12 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
149
148
  - **A hub that closed opens nothing**: `close()` sets `#closed` before the walk; `#open` closes a
150
149
  late subscription and RAISES `X_TRANSPORT_UNAVAILABLE`. `#release` takes the reserved bridge, never
151
150
  a name.
152
- - **An upgrade from a foreign page is `403 X_SOCKET_ORIGIN_REFUSED`** before `authenticate`
153
- (`sync-origin.ts`, core's `proveSameOrigin`); `AcceptBudget` is reserved before `authenticate`,
154
- `refund()`ed on every exit that takes no socket.
151
+ - **An upgrade from another origin is `403 X_SOCKET_ORIGIN_REFUSED`** before `authenticate`
152
+ (`sync-origin.ts`): `allowedOrigins`, when declared, is the WHOLE list; undeclared, the
153
+ `Host`-derived origin (and its `https` spelling when reached over plain http). The refusal names
154
+ the asker and the list. `x dev` keeps the reached-on origin (`admitReachedOrigin`) and adds
155
+ its web role's beside a declared list; a container adds nothing. `AcceptBudget` is reserved before
156
+ `authenticate`, `refund()`ed on every exit that takes no socket.
155
157
  - **A socket's actor comes from `createSyncNode({ authenticate })` only**, run before
156
158
  `server.upgrade`. `null` = 401 `X_SOCKET_UNAUTHENTICATED`; a throw = 503
157
159
  `X_SOCKET_AUTH_UNAVAILABLE`. Absent = anonymous, and `start()` warns. The actor lives only in the
@@ -168,8 +170,6 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
168
170
  `DrainedSocket[]` with `notified` and logs `sync.drain_frames_dropped`.
169
171
  - **Inbound frames run in a lane, never the socket**: `subscribe` is `sub:<sid>` or `topic:<name>`,
170
172
  everything else unlaned (`frame-lanes.ts`); a lane exists only while work is queued.
171
- - **Bun's native pub/sub is deleted**; `SocketRegistry.deliver` is the one fanout path.
172
- `WsLike.subscribe`/`unsubscribe` stay declared for structural implementers.
173
173
  - **A dropped `records` frame is counted AND repaired**: per-node `seq`/`epoch` per channel; a refused
174
174
  frame marks the socket gapped, and the websocket `drain` handler sends `replay-gap`
175
175
  (`channel_replay_gaps_total` beside `channel_frames_dropped_total`). The client re-runs the
@@ -177,42 +177,42 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
177
177
  - **A channel is a DECLARATION**: `channel(name, { params, policy, catchUp, records?, events? })`.
178
178
  Deny by default, and `policy` is REQUIRED (`X_CHANNEL_DECLARATION_INVALID`; a public channel says
179
179
  `policy: allow('public')`). Presence rides `events: true` channels.
180
- - **A guard that FAILS keeps the topic on re-auth** (`guardFailures`, `channel.guard_failed`); the
181
- initial subscribe still refuses.
180
+ - **A guard that FAILS on re-auth SUSPENDS the seat** (`guardFailures`): kept, silent until a pass.
181
+ A `null` actor on a `row` channel and a tenancy refusal are DENIALS.
182
+ - **A seat is decided for the actor ON the socket when the guard resolves** (`#settle`); a denied
183
+ re-ask on a suspended seat is refused and latched. The latch is capped at `maxTopicsPerSocket`.
184
+ - **A missed change reopens every records topic** (`ChannelHub.invalidate()`, beside both
185
+ `registry.invalidate()` sites).
182
186
  - **A channel patch id carries the node** (`nodeId`, default a per-hub `uuid()`).
183
- - **An error never renders a credential**: `parsePgUrl` names `DATABASE_URL`, never the URL.
187
+ - **An error never renders a credential**: `parsePgUrl`/`parseNatsUrl` name the variable.
184
188
 
185
189
  ## Replication
186
190
 
187
191
  - Exactly one `replicator` per DB, by a session-level advisory lock. **`start()` and
188
- `PgAdvisoryLock.tryAcquire` are MEMOISED** (guard and promise published synchronously, cleared on
189
- settle); `stop()`/`release()` await the in-flight one. A start that FAILS hands the lock back, and
190
- `running` is set only once the feed pumps.
191
- - **The pump has one way out, `#die`**, which records the failure, stops the confirm timer, closes and
192
- nulls the connection. `start()` awaits the previous pump before it dials. **`stop()` releases
193
- everything before it reports anything.**
194
- - **The replicator ensures its publication** (`pg-publication.ts`, from preflight): `CREATE … FOR
195
- TABLE` every entity table, or `ALTER … ADD TABLE` the missing; never a DROP, never `FOR ALL
196
- TABLES`. Only a server ErrorResponse becomes the refusal. No not-live declaration exists.
197
- - **A fix handing over `REPLICATION` carries `REPLICATION_GRANT_WARNING`** (cluster-wide grant).
198
- - **TLS is libpq's `sslmode`** (`pg-tls.ts`): only `verify-*` verify; the runtime never rejects
199
- (`rejectUnauthorized: false`), `judgeHandshake` decides; the raw socket is DEAF after
200
- `upgradeTLS` (Bun feeds it ciphertext).
201
- - **Only a table with NO replica identity is warned** (`NOTHING`, or keyless `DEFAULT`), before the
202
- slot. A keyed DEFAULT table is correct: the window decides (`pg-identity-window.live.test.ts`).
203
- - **Every session pins `datestyle=ISO`, `intervalstyle=postgres`, `extra_float_digits=3`** in the
204
- startup packet (`pg-connection.ts`), as Postgres' own walreceiver does.
192
+ `PgAdvisoryLock.tryAcquire` are MEMOISED**; `stop()`/`release()` await the in-flight one. A start
193
+ that FAILS hands the lock back; `running` is set once the feed pumps.
194
+ - **The pump has one way out, `#die`**: failure recorded, timer stopped, connection closed, then
195
+ `onEnd(reason)`. `start()` awaits the previous pump. **`stop()` releases everything first.**
196
+ - **A stream that ENDS is restarted**: feed then lock ABANDONED (synchronous, never asked);
197
+ `stop()` bounds both goodbyes by `STOP_DEADLINE_MS`; redial on `retryDelayMs` (injected
198
+ `schedule`) from `lastLsn` until `stop()`. The delay resets only when a change is PUBLISHED. A
199
+ rejected `publish` is one such end, and so is a LOST lock (`AdvisoryLock.onLost`), mid-dial
200
+ included. `running` = pumping AND no refused change owed; `onChange` is fenced per run and
201
+ REFUSES, never drops.
202
+ - **A row crosses the bus through its entity's columns** (`replicator-row.ts`): `parseEnvelope`
203
+ revives by `entityForTable` + `$parse`, bytes as base64 — by declared kind, never by shape.
204
+ - **Cleartext/md5 auth is answered only under `sslmode` `require`, `verify-*` or `disable`**.
205
+ - **The replicator ensures its publication** (`pg-publication.ts`): `CREATE … FOR TABLE` every
206
+ entity table, or `ALTER … ADD TABLE` the missing; never a DROP, never `FOR ALL TABLES`.
207
+ - **TLS is libpq's `sslmode`** (`pg-tls.ts`): only `verify-*` verify; the runtime never rejects,
208
+ `judgeHandshake` decides; the raw socket is DEAF after `upgradeTLS`.
209
+ - **Every session pins `datestyle=ISO`, `intervalstyle=postgres`, `extra_float_digits=3`**.
205
210
  - A change lsn is `<16 hex commit position><8 hex row position>`; never order by one half, a wall
206
211
  time or a counter.
207
212
  - Slot, publication and entity names match `[a-z_][a-z0-9_]*` before interpolation — a security
208
- boundary (`pg-identifier.ts`, the one `assertIdentifier`). A SQLSTATE is data: `pg-wire.ts`'s
209
- `FIXES` is read with `Object.hasOwn`.
210
- - **A live row equals a repository row**: `pg-entity-row.ts` decodes through `@ultimat3/entity`'s
211
- own `decodeRow` / `entityForTable`, so money (three physical columns), scale and every other kind
212
- fold exactly as `postgresRepo` folds them.
213
- - A write's name rides the WAL: `pg-replication.ts` reads it off the transaction's opening
214
- `pg_logical_emit_message` (prefix `WRITE_ORIGIN_WAL_PREFIX`; `START_REPLICATION` asks
215
- `messages 'true'`).
213
+ boundary (`pg-identifier.ts`). A SQLSTATE is data: `FIXES` is read with `Object.hasOwn`.
214
+ - **A live row equals a repository row**: `pg-entity-row.ts` decodes by `@ultimat3/entity`'s
215
+ `decodeRow` / `entityForTable`.
216
216
 
217
217
  ## The page
218
218
 
@@ -251,11 +251,15 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
251
251
  `useQuery`'s input is read once, at call time.
252
252
  - Every subscription handle (`LiveHandle`, the hook accessors, `Unsubscribe`) is `Disposable`, and
253
253
  `[Symbol.dispose]` is the same function as the release. Type claims go in `type-pins.ts`.
254
- - **The outbox**: only `pending` is sendable; `drain()` is one chained pass at a time; backpressure
255
- over `MAX_BUFFERED_BYTES` declines rather than fails. `#epoch` is bumped before the requeue scan and
256
- read every pass iteration. `#persist` hands the store SNAPSHOTS by KEY (`QueueChange`); one tab
257
- drains at a time (`navigator.locks`, `page-outbox.ts`'s `exclusive`) and resets a stored `inflight`
258
- to `pending` under the lock.
254
+ - **The outbox**: only `pending` is sendable; `drain()` is one chained pass at a time. `#epoch` is
255
+ bumped before the requeue scan and read every pass iteration. `#persist` hands the store
256
+ SNAPSHOTS by KEY; one tab drains at a time (`navigator.locks`) and resets a stored `inflight`
257
+ under the lock. A `rescope` abandons the queue, clears the single-flight slot and bumps the
258
+ outbox's epoch synchronously; `enqueue` decides WHOSE queue before its first await and refuses
259
+ otherwise (`X_OFFLINE_QUEUE_ABANDONED`), and `useMutation` fences its own await (the POST) on the
260
+ scope epoch it was called under. A wipe or open the disk refuses is warned
261
+ `X_LOCAL_STORE_UNAVAILABLE` and never rejects `ready`. At-least-once: an entry leaves the disk
262
+ when its ack is written.
259
263
 
260
264
  ## The client connection
261
265
 
@@ -265,43 +269,28 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
265
269
  throws in the timer is reported through `onError` (default `console.error`) and arms the next.
266
270
  - **`connect()` closes the socket it replaces**; `onOpen`, `onMessage` and `onClose` all carry the
267
271
  identity guard.
272
+ - **A refusal `ack` is revived as a branded `UltimateError`** (`refusalError`, `client-frames.ts`).
268
273
  - **A reconnect replays registrations AND topics** — one `hello`, one `subscribe` per registration
269
274
  (with its cursor) and per topic.
270
- - **`hello` carries NO cursors** (`HelloFrame.resume` is deleted): a cursor's qid names a window but
271
- not the input a decision needs.
272
- - **The client beats** (`heartbeatMs`, default `DEFAULT_HEARTBEAT_MS` 15 s, `0` disables): a `hello`
273
- plus one subscribe per topic (the presence heartbeat). Two silent windows close with `4000`. The
274
- node's `socket.skewed` compares the build the `hello` claims against its own. The server beat is
275
- derived: `PresenceRegistry.heartbeatMs = max(1000, floor(ttlMs / 3))`.
276
- - **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1.** `policyDelay(policy, attempt,
277
- rng)` (internal, not on the barrel) maps a `BackoffPolicy` onto it; a 0-based counter adds 1 at the
278
- call site (`client.ts`, `socket-engine.ts`, `replicator.ts`'s 0-based `retryDelayMs`).
279
- `client-reconnect.test.ts`, `socket-engine-reconnect.test.ts`, `nats-transport.test.ts` pin the
280
- first wait at the base. `drainPlan()` and `AcceptBudget` keep their own arithmetic.
281
- `bun run flight-copies` refuses a second curve.
275
+ - **The client beats** (`DEFAULT_HEARTBEAT_MS` 10 s = default ttl / 3, pinned by
276
+ `client-heartbeat.test.ts`; `0` disables): a `hello` plus one subscribe per topic. Two silent
277
+ windows close with `4000`. **The node names the beat: `hello.heartbeatMs`** — `Heartbeat.follow`
278
+ per socket; the engine reaps ports on the longer of its own and the node's.
279
+ - **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1** — `policyDelay()` maps a
280
+ `BackoffPolicy` onto it; `bun run flight-copies` refuses a second curve.
282
281
 
283
282
  ## Rules for code here
284
283
 
285
284
  - Never a bare `Error`. Never `any`. Never `Date.now()` — take a `Clock` (`monotonic()` for durations).
286
- - **A test fixture standing in for a FOREIGN error extends `Error` on purpose** (`PoolTimeout`,
287
- `Denied`, `ThirdPartySdkError`): it simulates a value this package did not construct. The rule
288
- governs what this package **throws**, never what a test hands it.
289
285
 
290
- ## Browser bytes, per hook (`As of 2026-09-22`)
286
+ ## The page boot
291
287
 
292
- `bun build --target=browser --minify`, one entry importing one hook from the barrel. Re-measure
293
- before quoting.
294
-
295
- | Hook | Current |
296
- |---|---|
297
- | `useRecord` | 13,226 |
298
- | `useMutation` | 21,725 (after the outbox left islands) |
299
- | `useQuery` | 47,696 |
300
- | `useChannel` | 45,585 |
288
+ Browser bytes per hook, as last measured: `docs/history/realtime.md`. Re-measure before quoting.
301
289
 
302
290
  - **The disk boot is ONE page script (`boot.ts`, `./boot`)**, served at `/_x/page-boot/<hash>.js`
303
291
  on a document with the scope tag and a realtime island; it wipes every other principal's stored
304
- scope, then restores. `booted` reads its promise off `globalThis[Symbol.for('ultimate.page-boot')]`.
292
+ scope — the scope kept is read at the wipe, after the disk opens, never at boot start — then
293
+ restores. `booted` reads its promise off `globalThis[Symbol.for('ultimate.page-boot')]`.
305
294
  - **An island never carries the outbox**: `boot.ts` builds it; `useMutation` and the page socket read
306
295
  it through `outbox-slot.ts` after `page.booted`. No boot ⇒ a refused write is rejected, not queued.
307
296
 
@@ -309,14 +298,13 @@ before quoting.
309
298
 
310
299
  `index.ts` / `server.ts` are the two barrels. Server side: `sync-node.ts` (+ `sync-auth`,
311
300
  `sync-frames`, `sync-upgrade`, `sync-listen`, `drain-evictions`), `channel.ts`, `presence.ts`,
312
- `socket.ts`, `live-query.ts` (+ `live-definition`, `query-window`, `live-fanout`, `window-lock`,
313
- `subscriber-gate`, `policy-gate` — the only authz seam — and `matcher-bridge`), `replicator.ts` /
301
+ `socket.ts`, `live-query.ts` (+ `live-definition`, `live-refusal`, `live-tenant`, `live-resume`, `query-window`, `live-fanout`, `window-lock`,
302
+ `subscriber-gate`, `policy-gate` — the only authz seam — and `matcher-bridge`), `replicator.ts` (+ `replicator-envelope`) /
314
303
  `live-replicator.ts`, the Postgres client (`pg-*.ts`, `pgoutput.ts`), the bus (`nats-*.ts`,
315
304
  `transport-env.ts`, `fanout.ts`). Client side: `client*.ts`, `socket-engine.ts` / `socket-host.ts` /
316
305
  `sync-worker.ts`, `record-*.ts`, `page-*.ts`, `boot.ts`, `offline-queue.ts`, `use-*.ts`,
317
306
  `reactivity.ts`. Shared: `sync-protocol.ts` (the wire), `cursor.ts`, `change-buffer.ts`,
318
- `thundering-herd.ts`, `live-contract.ts`, `json.ts` (no hash), `type-pins.ts`. Each file's header
319
- states its one job.
307
+ `thundering-herd.ts`, `live-contract.ts`, `json.ts` (no hash), `type-pins.ts`.
320
308
 
321
309
  ## Commands
322
310
 
@@ -325,12 +313,11 @@ bun test packages/realtime/src # from the REPO ROOT, never from packa
325
313
  bun run typecheck
326
314
  ```
327
315
 
328
- `bunfig.toml`'s preload installs `@ultimat3/testing`'s matchers and Bun reads it from the cwd.
316
+ `bunfig.toml`'s preload installs `@ultimat3/testing`'s matchers; Bun reads it from the cwd.
329
317
 
330
318
  Changing a frame shape means a fixture in `sync-protocol.test.ts` (the round-trip test fails
331
319
  without one) and bumping `PROTOCOL_VERSION` **when an old frame becomes unreadable in either
332
320
  direction**. `decode` is a whitelist: an additive optional field, or removing a field read through
333
- `list()`, is free; removing a field read through `str()`/`num()` (which throw) is a bump. Read the
334
- field's line in `decode` before claiming a removal is free.
321
+ `list()`, is free; removing a field read through `str()`/`num()` (which throw) is a bump.
335
322
 
336
323
  Why each rule above is shaped the way it is: [`docs/history/realtime.md`](../../docs/history/realtime.md).
package/README.md CHANGED
@@ -25,6 +25,9 @@ export const liveFeed = query({
25
25
 
26
26
  // mutator (action + optimistic local twin)
27
27
  export const likePost = mutator({
28
+ // REQUIRED (`X_MUTATOR_NOT_IDEMPOTENT`): a replay under the same Idempotency-Key answers the
29
+ // first result instead of running `server` again.
30
+ idempotent: true,
28
31
  // Convergent, not incremental: `local` replays on every server update, so applying it N times has to
29
32
  // equal applying it once — `likedByMe` is what makes the second application a no-op.
30
33
  local(tx, { postId }) {
@@ -232,6 +235,32 @@ One authz system, never two: `policy` is evaluated **once per subscriber**, neve
232
235
  Two actors on one live query get two different result sets, and a row that leaves an actor's policy
233
236
  is delivered to them as a `delete` — never as silence.
234
237
 
238
+ **The shared read is a TENANT's, never the node's.** One `(query, input)` is one window per org:
239
+ its id is `windowId(queryHash(name, input), tenant)`, the tenant being the subscriber's
240
+ `actor.orgId`, and its source is read in a context that carries that org and nothing else of any
241
+ subscriber's. So a repo that leaves the tenant to the acting actor — what `x g query --live`
242
+ generates — reads on a sync node exactly as it reads over HTTP, and no org predicate is written
243
+ by hand.
244
+
245
+ | On a sync node | Answer |
246
+ |---|---|
247
+ | two subscribers of one org, one `(query, input)` | one window, one read, one policy pass each |
248
+ | two subscribers in two orgs, one `(query, input)` | two windows, two reads, two retained patch rings — a row of one is never in the other's, policy or not |
249
+ | the source names no org (`db.posts.orderBy(…)`) | scoped to the window's tenant by `@ultimat3/entity`'s guard |
250
+ | the source names ANOTHER org | `X_TENANCY_ACTOR_MISMATCH`, as over HTTP — it was answered, until 2026-10 |
251
+ | a subscriber with no org reads a tenant-scoped table | `X_TENANCY_ACTOR_ORG_REQUIRED`, as over HTTP |
252
+ | a reconnect presents a cursor minted in another org's window | a cold start: one snapshot, never a replay of that window's patches |
253
+ | the actor changes org mid-connection | `reauthorize` re-seats the subscription on its new tenant's window and sends that snapshot |
254
+
255
+ The read's actor is `serviceActor({ id: 'live-window', orgId })` — the shape a job run gets. Never
256
+ a subscriber's: a window has many, and one subscriber's identity on a shared read is that
257
+ subscriber's entitlements becoming the window's.
258
+
259
+ **A refusal reaches the page as itself.** A subscription the node refuses is an `ack` carrying
260
+ `{ code, cause, fix }`; the client revives it as a branded `UltimateError` (`meta.origin:
261
+ 'remote'`), so `useQuery`'s `failed` state and an `<ErrorState>` render that code and that fix.
262
+ Handed over as the bare wire object it failed `isUltimateError` and rendered as `X_INTERNAL`.
263
+
235
264
  ## What one socket may cost
236
265
 
237
266
  Every ceiling on this node, and the option that moves it. Each one is a default, not a policy: an
@@ -252,6 +281,7 @@ wire.
252
281
  | outbound bytes buffered on one socket | 1 MiB | `createSyncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
253
282
  | dropped frames before that socket is closed | 32 | `createSyncNode({ maxDroppedFrames })` | close `1013` (`overloaded`), reason `backpressure` |
254
283
  | time one socket may route no frame | 120s | `createSyncNode({ idleTimeoutMs })` | close `4001` (`idle`), reason `idle timeout` |
284
+ | time one grant's `refresh()` may hold the re-auth pass | 10s (10 000 ms) | `createSyncNode({ grantRefreshDeadlineMs })` | reported and skipped: the grant stays expired and is asked again next pass |
255
285
  | retained patch bytes per node | 64 MiB | `new RingChangeBuffer({ maxBytes, maxBytesPerQuery })` | eviction, then a re-snapshot on resume |
256
286
  | array lengths and `input` nesting in a frame | `FRAME_LIMITS` | none — a hard ceiling | `X_PROTOCOL_VERSION` |
257
287
 
@@ -324,15 +354,32 @@ credential drained the bucket and every signed-in reconnect behind it was shed.
324
354
  behind an ingress every dial has the ingress's address, and a forwarded header is the caller's own
325
355
  claim.
326
356
 
327
- **A socket from a foreign page is refused** — `403 X_SOCKET_ORIGIN_REFUSED`, before `authenticate`
357
+ **A socket from another origin is refused** — `403 X_SOCKET_ORIGIN_REFUSED`, before `authenticate`
328
358
  and before the budget. A websocket carries the session cookie and no CORS applies to it, so a page on
329
359
  a sibling host (same-site, which `SameSite=Lax` does not stop) could open one as its visitor. The
330
- rule is `@ultimat3/core`'s `proveSameOrigin`, the one `@ultimat3/http`'s CSRF check asks, with two
331
- admissions of the node's own (`sync-origin.ts`): no `Origin` header (RFC 6455 has every browser send
332
- one, so its absence is not a browser), and the node's own host name at any port or scheme (cookies
333
- are not port-isolated, and the Compose rung serves the page on `:3000` and the node on `:3001`). A
334
- page on another host is admitted by `createSyncNode({ allowedOrigins })` — the CLI passes
335
- `APP_URL`'s origin.
360
+ rule is `@ultimat3/core`'s `proveSameOrigin`, the one `@ultimat3/http`'s CSRF check asks, and what
361
+ is admitted is decided in `sync-origin.ts`:
362
+
363
+ | Admitted | When |
364
+ |---|---|
365
+ | no `Origin` header | always — RFC 6455 has every browser send one, so its absence is not a browser |
366
+ | `createSyncNode({ allowedOrigins })`, compared exactly (scheme, host, port) | when any is declared. The declaration is the WHOLE list (24.0.0): the origin the node was reached on is no longer admitted beside it, because that origin is read off the `Host` header and a sibling subdomain pointed at the node would be "its own" |
367
+ | the origin the node was reached on | only when nothing is declared. A node reached over plain `http` also admits the `https` spelling of that host and port: TLS ended at a proxy, the scheme is not knowable there, and a browser sends no `sec-fetch-site` on a websocket handshake |
368
+
369
+ The CLI passes `APP_URL`'s origin, which the Compose rung (`:3000` / `:3001`) requires on `sync`
370
+ and the Helm chart sets from `ingress.host`. Under `x dev` it also adds the web role's own origin
371
+ and its `localhost`, `127.0.0.1` and `[::1]` spellings to a declared list, so `x dev --port 4000`
372
+ beside an `.env` naming `:3000` still connects, and passes `admitReachedOrigin: true`, which keeps
373
+ the origin the node was reached on beside the list (a Codespace, a tunnel: `x dev` binds loopback,
374
+ so a `Host` reaches it only through a forward its own user set up); a container adds nothing, and a wrong `APP_URL`
375
+ there refuses. The refusal's cause names the origin that asked and every origin the node admits;
376
+ its fix is the `export APP_URL=…` that admits the asker.
377
+
378
+ **The node's `/healthz` and `/readyz` tell the detail to a listed peer only**
379
+ (`createSyncNode({ healthDetailPeers })`, default the box itself). A request carrying `Forwarded`,
380
+ `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Real-IP` or `Via` is told the
381
+ verdict only. A proxy that adds none of them is indistinguishable from a direct peer: a
382
+ header-less proxy must not route the health paths.
336
383
 
337
384
  The client dials itself back. A closed socket arms one timer — the node's delay when a `reconnect`
338
385
  frame assigned one, otherwise `@ultimat3/core`'s `backoffDelay()` on the client's `BackoffPolicy` — and that timer calls `connect()`, which re-subscribes
@@ -358,7 +405,7 @@ below (`heartbeatMs` on the internal `LiveClient`; `0` disables the pass).
358
405
 
359
406
  | Property | Behaviour |
360
407
  |---|---|
361
- | 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 |
408
+ | Default | `DEFAULT_HEARTBEAT_MS`, 10s — a third of the default presence ttl, until the node names its own on its `hello` reply (`hello.heartbeatMs` = `min(ttl / 3, idle / 4)`, floored at 1 s): the client follows that for the life of the socket, and the SharedWorker engine reaps a tab on the longer of the two. The client's own number: `realtime.heartbeatMs` in `app.config.ts` was deleted 2026-08-19 because nothing read it |
362
409
  | 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 |
363
410
  | Why the topics | on the node, repeating the subscribe frame **is** the presence heartbeat; presence has no frame of its own in either direction |
364
411
  | Not a deploy check | `update-available` answers a skew between the build the client claims — the `hello`'s `buildId`, or `?build=` on the dial; every hello is read and the latest one is the record — and the node's own. A client says the same build on every beat and the node's never moves while the socket is open, 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 |
@@ -387,6 +434,10 @@ wire twice by a reconnect that raced an ack.
387
434
  | The store is handed **snapshots, by key**, never the live entries or the whole queue | `QueueStore.write` is a durable write and may await before it reads; given an entry itself it persists a status that was never true when it was called. By key, because two tabs of one user share the store and a whole-queue save let the last tab erase the other's write |
388
435
  | One tab drains at a time, and drains what every tab queued | the outbox's replay runs under the Web Lock `ultimate-outbox:<principal>` and re-reads the queue first, so a write another tab queued is sent, in order, and a stored `inflight` from a closed page goes back to `pending` |
389
436
 
437
+ `createOutbox({ warn })` — where a disk that refused the queue is said
438
+ (`X_LOCAL_STORE_UNAVAILABLE`, default `console.warn`); the outbox then queues in memory for that
439
+ principal.
440
+
390
441
  ### Limits, stated plainly
391
442
 
392
443
  - **The change window is per node, and a `qid` window can only be.** A client that reconnects to a
@@ -395,12 +446,23 @@ wire twice by a reconnect that raced an ack.
395
446
  matcher and no window, so it cannot produce one. What the snapshot path costs is one **shared**
396
447
  read per (query, node), not one per client. A cross-node delta needs an *entity*-keyed window each
397
448
  node fills from the change stream it already subscribes to, which is a `ResumeSource` shape change.
449
+ - **A ring is complete from its floor, and never from the start of time** (24.0.0). A
450
+ `RingChangeBuffer` ring is born with one: the lsn of its first patch, or the lsn its window was
451
+ read at (`floorAt(qid, lsn)`, which only ever raises it and drops what a re-read superseded). A
452
+ cursor below the floor re-snapshots. A ring used to be born with none, so a cursor minted on
453
+ another node resumed as a delta over changes this node never retained.
398
454
  - **Fanout is at-most-once, and a gap is detected rather than assumed away.** The replicator stamps
399
- every published change with `producer` + `seq`; a `sync` node that sees a skipped sequence
455
+ every published change with `producer` + `seq`; a `sync` node that sees a skipped sequence —
456
+ or, since 24.0.0, a new `producer` after one it already read: the run before it died, and the
457
+ tail of a dead stream has no later sequence to be missed against —
400
458
  invalidates every window it holds and desyncs every subscriber, so the next change to each query
401
459
  re-reads and re-snapshots. Both fields are optional on the bus, so a publisher that does not
402
460
  sequence simply detects nothing. Durable replay (JetStream) is a separate decision — retention,
403
461
  storage and replay window — and is deliberately not this mechanism.
462
+ - **A bulk write re-reads only the windows over its entity.** `updateWhere`/`deleteWhere` name a
463
+ filter, not rows, so the in-process replicator calls `registry.invalidate(entity)`: windows whose
464
+ shape reads that entity go stale and their subscribers desynced. The next change re-reads the
465
+ window and re-snapshots every subscriber, whether or not that change matches the query.
404
466
  - **`desynced` has a reader.** A subscriber recorded as diverged — a dropped patch, a gate that
405
467
  failed, a window that lost its tail — is served a fresh snapshot out of the shared window on the
406
468
  next delivery, and only then is the mark cleared. A snapshot the socket refuses leaves it
@@ -443,9 +505,9 @@ wire twice by a reconnect that raced an ack.
443
505
  `subscribe` is one lane per sid, or per topic name; `hello` and
444
506
  the server-authored kinds are unlaned. A lane exists only while work is queued on it, because a
445
507
  lane keyed by a client-chosen sid that outlived its work is an unbounded map one socket can grow.
446
- - **`qid` is `@ultimat3/query`'s `queryHash(name, input)`** — `<name>:<first 16 hex of
447
- SHA-256(canonicalJson(input))>`, 64 bits `As of 2026-08` where it was a 32-bit FNV-1a. It is a
448
- *sharing* key: a hit is answered with the existing entry and the seated window, both holding the
508
+ - **A window's `qid` is `@ultimat3/query`'s `queryHash(name, input)`, qualified by the tenant it
509
+ is read for** (`windowId`) — `<name>:<first 16 hex of SHA-256(canonicalJson(input))>`, 64 bits
510
+ `As of 2026-08` where it was a 32-bit FNV-1a. It is a *sharing* key: a hit is answered with the existing entry and the seated window, both holding the
449
511
  first subscriber's input and rows, and input is client-chosen, so a collision is one client served
450
512
  out of another's window. This package derives none of its own — `qidOf` was a second spelling of
451
513
  `queryHash` while `@ultimat3/query`'s `planResume` compares a cursor's `queryHash` against the
@@ -455,9 +517,11 @@ wire twice by a reconnect that raced an ack.
455
517
  the new node never held, so the resume falls back correctly rather than silently; the `qidOf`
456
518
  removal itself costs nothing, because every qid a node computes comes from a decoded frame and
457
519
  `JSON.parse` produces none of the values the two spellings disagreed about.
458
- - **A topic guard that *fails* keeps the topic.** On the re-auth pass, only a denial
459
- (`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures`
460
- and logs `channel.guard_failed`. `catch { unsubscribe }` reported a store that timed out as a
520
+ - **A topic guard that *fails* suspends the topic.** On the re-auth pass, only a denial
521
+ (`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures`,
522
+ logs `channel.guard_failed` and SUSPENDS the seat — kept, and sent nothing, until a pass
523
+ succeeds. Two answers are denials before any rule runs: a `null` actor on a channel with a `row`
524
+ loader, and a loader the tenant guard refused. `catch { unsubscribe }` reported a store that timed out as a
461
525
  revoked grant — every topic on every re-authenticated socket, silently, with the client never told
462
526
  to resubscribe. The initial `subscribe` is deliberately not split that way: there is no
463
527
  subscription to keep, so a guard that raises refuses that subscribe and the client hears about it.
@@ -486,7 +550,8 @@ wire twice by a reconnect that raced an ack.
486
550
  - **A full presence frame is capped** at `maxMembers` (256) and carries `total`, so a 5,000-person
487
551
  room renders "and 4,744 others" instead of shipping 5,000 members to every joiner. The set itself
488
552
  is never capped — the sweep differences it — and one node per topic runs that sweep, elected
489
- through the shared store, rather than every node re-reading every room it has ever seen.
553
+ through the shared store, rather than every node re-reading every room it has ever seen; the
554
+ leader's lease carries its roster, so a takeover announces a dead leader's members.
490
555
  - **Deliveries are serialized per query id, not per node.** A change is fanned out inside that
491
556
  query's own FIFO lane, so two changes off the bus cannot interleave: the window one of them
492
557
  writes is the window every subscriber's gate reads, and patch frames leave in lsn order. Every
@@ -527,7 +592,29 @@ wire twice by a reconnect that raced an ack.
527
592
  it gains the entity tables it lacks (`ALTER PUBLICATION … ADD TABLE`) and loses none. `FOR TABLE`
528
593
  because a table's owner may publish it without a superuser; a role that may not is refused with
529
594
  `X_REPLICATION_FAILED` and the statement to run as one that may. `InMemoryChangeFeed` + `InProcessTransport` remain the
530
- defaults for `x dev` and every test.
595
+ defaults for `x dev` and every test. `START_REPLICATION` asks `messages 'true'`, which needs
596
+ **Postgres ≥ 14**.
597
+ - **A replication stream that ends is restarted** (24.0.0). `ChangeFeed.start` takes
598
+ `onEnd(reason)`, called when the pump dies on its own — the walsender ended the copy, a decode
599
+ failed, the handler rejected — and never for a `stop()`. `createReplicator` answers it: `running`
600
+ goes `false`, the feed is stopped, the advisory lock is released so a standby may take the slot,
601
+ and the same process redials on `retryDelayMs(attempt)` (through `ReplicatorOptions.schedule`)
602
+ until it holds the stream again or `stop()` is called. The restart resumes from the last lsn this
603
+ process published, so one rejected `transport.publish` costs a redial and loses nothing.
604
+ `stats()` carries `restarts` and `failure`; **`replicator.running` is what a readiness check
605
+ reads**. Until 24.0.0 the death was recorded in `stats().failure`, which nothing read.
606
+ - **A row crosses the bus as the row it was** (`replicator-row.ts`, 24.0.0). The bus carries text;
607
+ `parseEnvelope` revives each value through the column its entity declared (`entityForTable` +
608
+ `$parse`) — a `timestamp()` is a `Date` again, `bytes()` (sent as base64) a `Uint8Array` — and
609
+ decides by that declaration, never by what a value looks like. A table with no entity on the
610
+ node, and a value its column refuses, cross as they arrived. Until 24.0.0 a `Date` reached the
611
+ matcher as an ISO string and every window ordered by a timestamp mis-sorted on any deployment
612
+ with a bus.
613
+ - **`ChangeEvent.omitted` names what an update could not carry** (24.0.0). Postgres logs no bytes
614
+ for an out-of-line (TOAST) value an `UPDATE` did not touch. Under `REPLICA IDENTITY FULL` the
615
+ value is copied from the old tuple and `after` is whole; under any other identity `after` lacks
616
+ the property and `omitted` lists it (entity property names), so a consumer re-reads the row
617
+ rather than adopting a partial one.
531
618
  - **`selectChangeFeed(env, { entities })` decides which feed a boot installs** — same law
532
619
  `selectMailDriver` follows: an unset variable means the embedded default. It returns `{ feed,
533
620
  mode, detail, slot, lock }`: `mode` is `'embedded' | 'external'`, `detail` is the env key that
@@ -550,6 +637,14 @@ wire twice by a reconnect that raced an ack.
550
637
  `X_REPLICATION_TLS` naming the check. Until 22.1.0 `prefer` verified — every private-CA server
551
638
  (CNPG) failed as a refused write — and the raw socket kept feeding ciphertext to the reader
552
639
  after the upgrade, so a trusted CA still hung the stream.
640
+ - **A password the network could use is sent only when the mode allows it** (24.0.0): a
641
+ cleartext or md5 request is answered under `require`, `verify-ca`, `verify-full` (the session is
642
+ guaranteed encrypted) or `disable` (the operator said it is not), and refused with
643
+ `X_REPLICATION_FAILED` under `prefer` — the default — and `allow`, where a stripped upgrade falls
644
+ back to cleartext without a word. SCRAM is answered under every mode. `require` encrypts to
645
+ whoever answered and verifies no certificate: it protects the password from a passive listener
646
+ only. An attacker on the path needs `verify-ca` or `verify-full` to be stopped; `require`
647
+ answers a cleartext or md5 request for libpq parity, not because it is safe against one.
553
648
  - **`REPLICATION` is a cluster-wide grant.** A `replication=database` session may also run
554
649
  `BASE_BACKUP` and `START_REPLICATION PHYSICAL` with no database check, so on a shared Postgres
555
650
  cluster the role could copy every database, `pg_authid` included, drop other slots and exhaust
@@ -558,6 +653,21 @@ wire twice by a reconnect that raced an ack.
558
653
  `REPLICATION` to an app role on a shared cluster. Every fix line that hands the grant over says
559
654
  so (`REPLICATION_GRANT_WARNING`, `pg-wire.ts`) and links
560
655
  [`docs/ops/01-kubernetes.md`](../../docs/ops/01-kubernetes.md#replication-is-a-cluster-wide-grant).
656
+ - **A lock that is lost ends the stream** (24.0.0). The lock is a session, and a session can die
657
+ while the replication stream stays up — Postgres then grants the lock to the next process that
658
+ asks, and a holder that kept streaming would be one of two replicators. `AdvisoryLock.onLost(
659
+ listener)` (required) reports it: `PgAdvisoryLock` watches its idle session through
660
+ `PgConnection.watchIdle` (EOF, a read error or a server `FATAL`; no polling), and
661
+ `createReplicator` answers exactly as it does a dead stream — `running` false, feed stopped,
662
+ back into the takeover loop to compete for the lock again.
663
+ - **A recovery asks nothing, and a stop waits a bounded time** (24.0.0). A dead stream or a lost
664
+ lock calls `ChangeFeed.abandon()` and then `AdvisoryLock.abandon()` (both required, both
665
+ synchronous): the sockets are closed outright with no goodbye written, the feed first so it is
666
+ silent before the lock is given up. `stop()` asks politely and gives each goodbye
667
+ `STOP_DEADLINE_MS` (5 s) before abandoning it. The restart delay resets only once a change has
668
+ been published, so a change the bus refuses every time backs off to `maxMs` instead of
669
+ redialling at the base; `stats().failure` names its entity and lsn, and `running` stays `false`
670
+ until it goes out.
561
671
  - **`PgAdvisoryLock` is the production `AdvisoryLock`** — `SELECT
562
672
  pg_try_advisory_lock(hashtext('x:replicator:<slot>'))` on its own session. Session-scoped, so a
563
673
  crashed replicator releases it automatically: no lease renewal, no fencing token, no split brain.
@@ -584,7 +694,10 @@ wire twice by a reconnect that raced an ack.
584
694
  the library's own KV abstraction expresses neither a per-message TTL nor a batch direct get.
585
695
  Reconnect and re-subscription are the library's: a lost connection is re-established underneath
586
696
  the caller, which is what makes `sync` stateless, and the jitter that spreads a restart herd is
587
- handed to it as its reconnect delay rather than re-implemented above it. The bucket needs
697
+ handed to it as its reconnect delay rather than re-implemented above it.
698
+ `transport.onReconnect(listener)` — a required member of `Transport` since 24.0.0 — announces
699
+ each recovery and returns the unsubscribe: fanout is at-most-once, so whatever was published
700
+ during the drop never arrived. `InProcessTransport` has no connection to lose and never calls it. The bucket needs
588
701
  nats-server ≥ 2.11 (batch direct get, per-message TTL); an older one is `X_TRANSPORT_PROTOCOL` on
589
702
  the first dial, never a retry loop, because no amount of reconnecting makes a server newer.
590
703
  - **The lsn is `<commit position><row position in the transaction>`, 24 hex characters.** Neither
@@ -592,7 +705,14 @@ wire twice by a reconnect that raced an ack.
592
705
  *transactions* in commit order, so per-record WAL positions are not monotonic across them. The
593
706
  pair sorts in delivery order and is byte-identical on replay, which is what turns at-least-once
594
707
  redelivery into a drop instead of a duplicate.
595
- - **A keyed table does not need `REPLICA IDENTITY FULL`; a table with NO identity is warned**
708
+ - **A channel with params needs `REPLICA IDENTITY FULL` on its `records` tables** (24.0.0). It
709
+ holds no window: a DELETE is routed to the topic the OLD row's params name, and under the default
710
+ identity that image is the key alone — no topic, no `remove`, and members keep the deleted
711
+ record. Preflight asks the catalog about exactly those tables (`paramsChannelTables()`, or
712
+ `PgLogicalReplicationOptions.fullIdentityTables`) and logs `replication.channel_identity_partial`
713
+ with the fix `x db gen "replica identity full"`: `x db gen` grants FULL to those tables beside
714
+ the live queries' `subscribes:` ones, so an app that declares a params channel owes one migration.
715
+ - **For a live query, a keyed table does not need `REPLICA IDENTITY FULL`; a table with NO identity is warned**
596
716
  (`As of 2026-09-23`). Under DEFAULT an update carries no old tuple and a delete only the key, and
597
717
  neither decides a live query: the shared window holds the whole row, so a row leaving the result
598
718
  set is decided from the window and a delete by `holds(id)` — proved on real WAL by
@@ -655,7 +775,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
655
775
  | `FrameRateLimitError` (extends `RealtimeError`) | `X_FRAME_RATE_LIMIT` | `src/errors.ts` |
656
776
  | `LiveQueryUnknownError` (extends `RealtimeError`) | `X_LIVE_QUERY_UNKNOWN` | `src/errors.ts` |
657
777
  | `LiveRowUnidentifiedError` (extends `RealtimeError`) | `X_LIVE_ROW_UNIDENTIFIED` | `src/errors.ts` |
658
- | `NotImplementedError` (extends `RealtimeError`) | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
778
+ | `OfflineQueueAbandonedError` (extends `RealtimeError`) | `X_OFFLINE_QUEUE_ABANDONED` | `src/page-errors.ts` |
659
779
  | `ProtocolVersionError` (extends `RealtimeError`) | `X_PROTOCOL_VERSION` | `src/page-errors.ts` |
660
780
  | `RealtimeError` | any `RealtimeErrorCode` — `REALTIME_ERROR_CODES`; the base of every other class here, thrown directly for a code none of them covers | `src/realtime-error.ts` |
661
781
  | `RealtimeUninstalledError` (extends `RealtimeError`) | `X_REALTIME_UNINSTALLED` | `src/page-errors.ts` |