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