@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.
- package/CLAUDE.md +89 -102
- package/README.md +140 -20
- package/package.json +4 -4
- package/src/advisory-lock.ts +57 -0
- package/src/boot.ts +12 -7
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +2 -2
- package/src/changefeed.ts +39 -1
- package/src/channel-authz.ts +43 -12
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +34 -4
- package/src/channel-records.ts +33 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +143 -44
- package/src/client-channels.ts +10 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +34 -4
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +1 -0
- package/src/cursor.ts +11 -0
- package/src/errors.ts +70 -19
- package/src/fanout.ts +13 -0
- package/src/index.ts +1 -1
- package/src/live-contract.ts +6 -2
- package/src/live-definition.ts +46 -11
- package/src/live-fanout.ts +70 -11
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +38 -0
- package/src/live-query.ts +119 -91
- package/src/live-refusal.ts +21 -0
- package/src/live-replicator.ts +6 -6
- package/src/live-resume.ts +74 -0
- package/src/live-tenant.ts +25 -0
- package/src/nats-client.ts +11 -2
- package/src/nats-open.ts +9 -0
- package/src/nats-transport.ts +23 -1
- package/src/offline-queue.ts +48 -5
- package/src/page-errors.ts +17 -0
- package/src/page-outbox.ts +121 -50
- package/src/pg-advisory-lock.ts +45 -1
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +119 -21
- package/src/pg-entity-row.ts +27 -2
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +44 -45
- package/src/pg-socket.ts +6 -4
- package/src/pg-tls.ts +18 -0
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/presence.ts +85 -8
- package/src/query-window.ts +28 -7
- package/src/record-persister.ts +7 -2
- package/src/replication-errors.ts +8 -4
- package/src/replicator-envelope.ts +110 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +319 -186
- package/src/server.ts +11 -7
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/subscriber-gate.ts +11 -1
- package/src/sync-auth.ts +47 -2
- package/src/sync-frames.ts +73 -20
- package/src/sync-node-bounds.ts +15 -0
- package/src/sync-node-contract.ts +20 -2
- package/src/sync-node.ts +92 -17
- package/src/sync-origin.ts +26 -11
- package/src/sync-protocol.ts +13 -3
- package/src/sync-upgrade.ts +76 -8
- package/src/use-mutation.ts +33 -5
- 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
|
-
-
|
|
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
|
|
41
|
-
|
|
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
|
|
58
|
-
bound
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
89
|
-
`
|
|
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
|
|
110
|
-
- **A
|
|
111
|
-
|
|
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
|
|
121
|
-
|
|
122
|
-
`
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
`
|
|
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
|
|
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
|
|
153
|
-
(`sync-origin.ts
|
|
154
|
-
`
|
|
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
|
|
181
|
-
|
|
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`
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
- **
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
- **
|
|
202
|
-
|
|
203
|
-
- **
|
|
204
|
-
|
|
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
|
|
209
|
-
|
|
210
|
-
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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
|
-
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
##
|
|
286
|
+
## The page boot
|
|
291
287
|
|
|
292
|
-
|
|
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,
|
|
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`.
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
`
|
|
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`,
|
|
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
|
-
-
|
|
447
|
-
SHA-256(canonicalJson(input))>`, 64 bits
|
|
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*
|
|
459
|
-
(`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures
|
|
460
|
-
|
|
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.
|
|
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
|
|
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
|
-
| `
|
|
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` |
|