@ultimat3/realtime 23.0.0 → 24.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CLAUDE.md +75 -81
  2. package/README.md +107 -17
  3. package/package.json +4 -4
  4. package/src/advisory-lock.ts +57 -0
  5. package/src/boot.ts +12 -7
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +2 -2
  8. package/src/changefeed.ts +39 -1
  9. package/src/channel-authz.ts +43 -12
  10. package/src/channel-latch.ts +52 -0
  11. package/src/channel-logs.ts +34 -4
  12. package/src/channel-records.ts +33 -2
  13. package/src/channel-sids.ts +37 -0
  14. package/src/channel.ts +143 -44
  15. package/src/client-channels.ts +10 -1
  16. package/src/client-contract.ts +4 -1
  17. package/src/client-frames.ts +7 -1
  18. package/src/client-heartbeat.ts +39 -9
  19. package/src/client.ts +1 -0
  20. package/src/cursor.ts +11 -0
  21. package/src/errors.ts +70 -19
  22. package/src/fanout.ts +13 -0
  23. package/src/index.ts +1 -1
  24. package/src/live-definition.ts +22 -7
  25. package/src/live-fanout.ts +43 -7
  26. package/src/live-floor.ts +35 -0
  27. package/src/live-query-options.ts +38 -0
  28. package/src/live-query.ts +36 -37
  29. package/src/nats-client.ts +11 -2
  30. package/src/nats-transport.ts +22 -0
  31. package/src/offline-queue.ts +48 -5
  32. package/src/page-errors.ts +17 -0
  33. package/src/page-outbox.ts +121 -50
  34. package/src/pg-advisory-lock.ts +45 -1
  35. package/src/pg-auth.ts +9 -4
  36. package/src/pg-bytes.ts +4 -1
  37. package/src/pg-connection.ts +119 -21
  38. package/src/pg-entity-row.ts +16 -0
  39. package/src/pg-identity-tables.ts +25 -0
  40. package/src/pg-preflight.ts +50 -4
  41. package/src/pg-replication-row.ts +82 -0
  42. package/src/pg-replication.ts +44 -45
  43. package/src/pg-socket.ts +6 -4
  44. package/src/pg-tls.ts +18 -0
  45. package/src/pg-wire.ts +5 -3
  46. package/src/pgoutput.ts +19 -9
  47. package/src/presence.ts +85 -8
  48. package/src/query-window.ts +16 -4
  49. package/src/record-persister.ts +7 -2
  50. package/src/replication-errors.ts +8 -4
  51. package/src/replicator-envelope.ts +110 -0
  52. package/src/replicator-row.ts +75 -0
  53. package/src/replicator.ts +319 -186
  54. package/src/server.ts +10 -6
  55. package/src/socket-engine.ts +14 -2
  56. package/src/socket-idle.ts +3 -1
  57. package/src/socket-routes.ts +8 -3
  58. package/src/subscriber-gate.ts +11 -1
  59. package/src/sync-auth.ts +47 -2
  60. package/src/sync-frames.ts +73 -20
  61. package/src/sync-node-bounds.ts +15 -0
  62. package/src/sync-node-contract.ts +20 -2
  63. package/src/sync-node.ts +92 -17
  64. package/src/sync-origin.ts +26 -11
  65. package/src/sync-protocol.ts +13 -3
  66. package/src/sync-upgrade.ts +76 -8
  67. package/src/use-mutation.ts +33 -5
  68. package/src/wire-channel.ts +13 -4
package/CLAUDE.md CHANGED
@@ -29,18 +29,13 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
29
29
  and never absent. `bun run side-effects` is the check.
30
30
  - **Every `@ultimat3/realtime/<subpath>` written in shipped source must be a key of `exports`**,
31
31
  comments included — `fix-specifier.test.ts`.
32
- - **`@ultimat3/realtime/server` needs its own `paths` entry in `tsconfig.base.json`** (the wildcard
33
- maps it to a directory that does not exist; `scripts/**` reports `TS2307` without it).
34
- - A browser file imports core from `@ultimat3/core/page` (`packages/core/src/page-bundle.test.ts`,
35
- `packages/query/src/client-bundle.test.ts`).
32
+ - A browser file imports core from `@ultimat3/core/page` (`packages/core/src/page-bundle.test.ts`).
36
33
 
37
34
  ## Numbers and ceilings
38
35
 
39
36
  - **Every numeric option is refused when it is not FINITE** — `@ultimat3/core`'s `finiteOption()`.
40
- `bun run finite-bounds` reads zero for this package, but it cannot see an option with no `??`
41
- default or where a screen runs, so audit by reading the option interfaces
42
- (`SubscriptionBook`'s `maxPerTenant` and `openNatsClient`'s `maxReconnectAttempts` were the two it
43
- missed). `SyncGrant.expiresAt` is app DATA, not an option, and is deliberately unscreened.
37
+ `bun run finite-bounds` reads zero here but cannot see an option with no `??` default, so audit
38
+ by reading the option interfaces. `SyncGrant.expiresAt` is app DATA, deliberately unscreened.
44
39
  - **A ceiling is refused where the object is BUILT**, never in a per-connection callback:
45
40
  `socketCeilings()` (`sync-node-bounds.ts`) runs once in `createSyncNode`; `SyncSocket` keeps its own
46
41
  screen because it is exported. `AcceptBudget`, `SyncSocket` and `SocketRegistry` throw
@@ -50,14 +45,14 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
50
45
  at the top of `routeFrame` **before `touch()`**), and `FRAME_LIMITS` in `decode` everything a
51
46
  client sizes (narrowable, never widenable). `list()` takes a required `max`. `input` is walked
52
47
  ITERATIVELY.
53
- - **Every `SyncSocket` ceiling is reachable from `createSyncNode`**, forwarded as
54
- `...(x === undefined ? {} : { x })`.
55
48
  - **A `SubscriptionLimitError` names the knob**: every throw site passes `knob` explicitly;
56
49
  `channel.test.ts` asserts the hub's two against the option names.
57
- - **Retained memory is bounded by BYTES**: `RingChangeBuffer` keeps the patch count as a replay
58
- bound and byte budgets as the memory bound. `forget(qid)` runs when the last subscriber goes and
59
- from `#dropIfUnheld` after a failed cold subscribe (only if the entry is still in the table, unheld
60
- and has no read in flight).
50
+ - **Retained memory is bounded by BYTES** (`RingChangeBuffer`: patch count bounds replay, bytes
51
+ bound memory). `forget(qid)` runs when the last subscriber goes and from `#dropIfUnheld`.
52
+ - **The ring floor is one rule (`change-buffer.ts`, `Ring`)**: only a read that LANDED floors it
53
+ (`fillWindow().landed`), a forced one exclusively; a position-less read is marked with the
54
+ node's origin and answers that cursor alone below its first patch; a stale entry serves cold
55
+ starts only.
61
56
  - **Every question a hot path asks is indexed**: `SubscriptionBook` keeps `#bySocket` and a
62
57
  per-tenant count; `SocketRegistry` keeps `#byTopic`, changed only by `joinTopic`/`leaveTopic`.
63
58
  `reauthorize` calls `book.retenant(socket)`.
@@ -85,8 +80,8 @@ 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, unless its org moved — then it is
89
- refused under its sid (`refuseSubscription`), as is a failed re-seat. Failures count as
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
90
85
  `gateFailures` via `onGateFailed`, never `onRowDenied`.
91
86
  - **One serial lane per query id (`WindowLock`) is the only thing that orders a fanout.** `deliver`
92
87
  enters every lane before awaiting any; no fanout takes a second lane; a lane that fails desyncs its
@@ -107,7 +102,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
107
102
  never clears a mark (`live-fanout.test.ts`).
108
103
  - **A change the window already holds is refused** (`change.lsn <= entry.lsn`, counted as
109
104
  `staleChanges`). **A gap is detected**: the replicator stamps `producer` + `seq`; a skipped
110
- sequence marks every window stale and every subscriber desynced; `refillWindowInLane` replaces it.
105
+ sequence — or a NEW producer after a known one — stales every window; `refillWindowInLane` replaces it.
111
106
  - **A bulk write stales only the windows reading its entity** (`invalidate(entity)`, from
112
107
  `onBulk`). **A stale window's re-read re-snapshots EVERY subscriber**, before the lsn guard and
113
108
  on a non-matching change too (`live-replicator-bulk.test.ts`, `live-fanout.test.ts`).
@@ -124,24 +119,21 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
124
119
  ## The node, the bus and presence
125
120
 
126
121
  - `sync` is stateless: no sticky sessions, nothing on a socket survives a restart.
127
- - **`selectTransport(env, realtime)` is the one place `realtime.transport` + env become a bus**; the
128
- KV bucket and presence TTL come back with it. The config decides, the env supplies (22.0.0):
129
- `'nats'` dials the variable `urlEnv` names and refuses (`X_CONFIG_INVALID`) when unset; `'memory'`
130
- 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.
131
125
  - **`nats` is imported only by `nats-lib-client.ts`**; reconnect and re-subscription are the
132
- library's job — `NatsTransport` must never grow subscription bookkeeping. What stays above the
133
- port: the herd jitter (`reconnectDelayHandler`) and the KV semantics presence needs
134
- (`nats-jetstream.ts`, `nats-kv.ts`). `nats-fake.ts` is a bus with server semantics.
135
- - **Nothing leaves `NatsTransport` uncoded** — `#translating` wraps the port's synchronous
136
- `publish`/`subscribe` refusals as `X_TRANSPORT_UNAVAILABLE`; an `UltimateError` passes through.
137
- `transport-parity.test.ts` asserts both transports, and that the wrap still delivers. `#ensure`
138
- 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.
139
131
  - **A bucket name is checked before interpolation** (`assertBucket`, `X_TRANSPORT_PROTOCOL`); a
140
132
  presence key or member id is base64url-encoded (`encodeToken`), never validated.
141
133
  - Presence lives in `transport.shared`. The sync node is `PresenceRegistry`'s only caller:
142
134
  subscribing to a topic IS joining, repeating the frame is the heartbeat, dropping or closing is the
143
135
  leave. Expiry is silent, so the node sweeps on an interval, and **one node per topic sweeps** (lowest
144
- id in the lease key's keyed set). `roster()` caps a frame at `maxMembers` (256) with `total`;
136
+ id in the lease's keyed set; the lease carries the roster followers record). `roster()` caps a frame at `maxMembers` (256) with `total`;
145
137
  `list()` is never capped. `total` is set on `sync` frames only.
146
138
  - **A socket the node evicts ITSELF goes through `evict(socket, code, reason)`** (the full
147
139
  `teardown`), never `sockets.remove`. **`drain()` waits for the presence leaves it started** —
@@ -156,9 +148,12 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
156
148
  - **A hub that closed opens nothing**: `close()` sets `#closed` before the walk; `#open` closes a
157
149
  late subscription and RAISES `X_TRANSPORT_UNAVAILABLE`. `#release` takes the reserved bridge, never
158
150
  a name.
159
- - **An upgrade from a foreign page is `403 X_SOCKET_ORIGIN_REFUSED`** before `authenticate`
160
- (`sync-origin.ts`, core's `proveSameOrigin`); `AcceptBudget` is reserved before `authenticate`,
161
- `refund()`ed on every exit that takes no socket.
151
+ - **An upgrade from another origin is `403 X_SOCKET_ORIGIN_REFUSED`** before `authenticate`
152
+ (`sync-origin.ts`): `allowedOrigins`, when declared, is the WHOLE list; undeclared, the
153
+ `Host`-derived origin (and its `https` spelling when reached over plain http). The refusal names
154
+ the asker and the list. `x dev` keeps the reached-on origin (`admitReachedOrigin`) and adds
155
+ its web role's beside a declared list; a container adds nothing. `AcceptBudget` is reserved before
156
+ `authenticate`, `refund()`ed on every exit that takes no socket.
162
157
  - **A socket's actor comes from `createSyncNode({ authenticate })` only**, run before
163
158
  `server.upgrade`. `null` = 401 `X_SOCKET_UNAUTHENTICATED`; a throw = 503
164
159
  `X_SOCKET_AUTH_UNAVAILABLE`. Absent = anonymous, and `start()` warns. The actor lives only in the
@@ -182,42 +177,42 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
182
177
  - **A channel is a DECLARATION**: `channel(name, { params, policy, catchUp, records?, events? })`.
183
178
  Deny by default, and `policy` is REQUIRED (`X_CHANNEL_DECLARATION_INVALID`; a public channel says
184
179
  `policy: allow('public')`). Presence rides `events: true` channels.
185
- - **A guard that FAILS keeps the topic on re-auth** (`guardFailures`, `channel.guard_failed`); the
186
- initial subscribe still refuses.
180
+ - **A guard that FAILS on re-auth SUSPENDS the seat** (`guardFailures`): kept, silent until a pass.
181
+ A `null` actor on a `row` channel and a tenancy refusal are DENIALS.
182
+ - **A seat is decided for the actor ON the socket when the guard resolves** (`#settle`); a denied
183
+ re-ask on a suspended seat is refused and latched. The latch is capped at `maxTopicsPerSocket`.
184
+ - **A missed change reopens every records topic** (`ChannelHub.invalidate()`, beside both
185
+ `registry.invalidate()` sites).
187
186
  - **A channel patch id carries the node** (`nodeId`, default a per-hub `uuid()`).
188
- - **An error never renders a credential**: `parsePgUrl` names `DATABASE_URL`, never the URL.
187
+ - **An error never renders a credential**: `parsePgUrl`/`parseNatsUrl` name the variable.
189
188
 
190
189
  ## Replication
191
190
 
192
191
  - Exactly one `replicator` per DB, by a session-level advisory lock. **`start()` and
193
- `PgAdvisoryLock.tryAcquire` are MEMOISED** (guard and promise published synchronously, cleared on
194
- settle); `stop()`/`release()` await the in-flight one. A start that FAILS hands the lock back, and
195
- `running` is set only once the feed pumps.
196
- - **The pump has one way out, `#die`**, which records the failure, stops the confirm timer, closes and
197
- nulls the connection. `start()` awaits the previous pump before it dials. **`stop()` releases
198
- everything before it reports anything.**
199
- - **The replicator ensures its publication** (`pg-publication.ts`, from preflight): `CREATE … FOR
200
- TABLE` every entity table, or `ALTER … ADD TABLE` the missing; never a DROP, never `FOR ALL
201
- TABLES`. Only a server ErrorResponse becomes the refusal. No not-live declaration exists.
202
- - **A fix handing over `REPLICATION` carries `REPLICATION_GRANT_WARNING`** (cluster-wide grant).
203
- - **TLS is libpq's `sslmode`** (`pg-tls.ts`): only `verify-*` verify; the runtime never rejects
204
- (`rejectUnauthorized: false`), `judgeHandshake` decides; the raw socket is DEAF after
205
- `upgradeTLS` (Bun feeds it ciphertext).
206
- - **Only a table with NO replica identity is warned** (`NOTHING`, or keyless `DEFAULT`), before the
207
- slot. A keyed DEFAULT table is correct: the window decides (`pg-identity-window.live.test.ts`).
208
- - **Every session pins `datestyle=ISO`, `intervalstyle=postgres`, `extra_float_digits=3`** in the
209
- startup packet (`pg-connection.ts`), as Postgres' own walreceiver does.
192
+ `PgAdvisoryLock.tryAcquire` are MEMOISED**; `stop()`/`release()` await the in-flight one. A start
193
+ that FAILS hands the lock back; `running` is set once the feed pumps.
194
+ - **The pump has one way out, `#die`**: failure recorded, timer stopped, connection closed, then
195
+ `onEnd(reason)`. `start()` awaits the previous pump. **`stop()` releases everything first.**
196
+ - **A stream that ENDS is restarted**: feed then lock ABANDONED (synchronous, never asked);
197
+ `stop()` bounds both goodbyes by `STOP_DEADLINE_MS`; redial on `retryDelayMs` (injected
198
+ `schedule`) from `lastLsn` until `stop()`. The delay resets only when a change is PUBLISHED. A
199
+ rejected `publish` is one such end, and so is a LOST lock (`AdvisoryLock.onLost`), mid-dial
200
+ included. `running` = pumping AND no refused change owed; `onChange` is fenced per run and
201
+ REFUSES, never drops.
202
+ - **A row crosses the bus through its entity's columns** (`replicator-row.ts`): `parseEnvelope`
203
+ revives by `entityForTable` + `$parse`, bytes as base64 — by declared kind, never by shape.
204
+ - **Cleartext/md5 auth is answered only under `sslmode` `require`, `verify-*` or `disable`**.
205
+ - **The replicator ensures its publication** (`pg-publication.ts`): `CREATE … FOR TABLE` every
206
+ entity table, or `ALTER … ADD TABLE` the missing; never a DROP, never `FOR ALL TABLES`.
207
+ - **TLS is libpq's `sslmode`** (`pg-tls.ts`): only `verify-*` verify; the runtime never rejects,
208
+ `judgeHandshake` decides; the raw socket is DEAF after `upgradeTLS`.
209
+ - **Every session pins `datestyle=ISO`, `intervalstyle=postgres`, `extra_float_digits=3`**.
210
210
  - A change lsn is `<16 hex commit position><8 hex row position>`; never order by one half, a wall
211
211
  time or a counter.
212
212
  - Slot, publication and entity names match `[a-z_][a-z0-9_]*` before interpolation — a security
213
- boundary (`pg-identifier.ts`, the one `assertIdentifier`). A SQLSTATE is data: `pg-wire.ts`'s
214
- `FIXES` is read with `Object.hasOwn`.
215
- - **A live row equals a repository row**: `pg-entity-row.ts` decodes through `@ultimat3/entity`'s
216
- own `decodeRow` / `entityForTable`, so money (three physical columns), scale and every other kind
217
- fold exactly as `postgresRepo` folds them.
218
- - A write's name rides the WAL: `pg-replication.ts` reads it off the transaction's opening
219
- `pg_logical_emit_message` (prefix `WRITE_ORIGIN_WAL_PREFIX`; `START_REPLICATION` asks
220
- `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`.
221
216
 
222
217
  ## The page
223
218
 
@@ -256,11 +251,15 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
256
251
  `useQuery`'s input is read once, at call time.
257
252
  - Every subscription handle (`LiveHandle`, the hook accessors, `Unsubscribe`) is `Disposable`, and
258
253
  `[Symbol.dispose]` is the same function as the release. Type claims go in `type-pins.ts`.
259
- - **The outbox**: only `pending` is sendable; `drain()` is one chained pass at a time; backpressure
260
- over `MAX_BUFFERED_BYTES` declines rather than fails. `#epoch` is bumped before the requeue scan and
261
- read every pass iteration. `#persist` hands the store SNAPSHOTS by KEY (`QueueChange`); one tab
262
- drains at a time (`navigator.locks`, `page-outbox.ts`'s `exclusive`) and resets a stored `inflight`
263
- to `pending` under the lock.
254
+ - **The outbox**: only `pending` is sendable; `drain()` is one chained pass at a time. `#epoch` is
255
+ bumped before the requeue scan and read every pass iteration. `#persist` hands the store
256
+ SNAPSHOTS by KEY; one tab drains at a time (`navigator.locks`) and resets a stored `inflight`
257
+ under the lock. A `rescope` abandons the queue, clears the single-flight slot and bumps the
258
+ outbox's epoch synchronously; `enqueue` decides WHOSE queue before its first await and refuses
259
+ otherwise (`X_OFFLINE_QUEUE_ABANDONED`), and `useMutation` fences its own await (the POST) on the
260
+ scope epoch it was called under. A wipe or open the disk refuses is warned
261
+ `X_LOCAL_STORE_UNAVAILABLE` and never rejects `ready`. At-least-once: an entry leaves the disk
262
+ when its ack is written.
264
263
 
265
264
  ## The client connection
266
265
 
@@ -273,20 +272,16 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
273
272
  - **A refusal `ack` is revived as a branded `UltimateError`** (`refusalError`, `client-frames.ts`).
274
273
  - **A reconnect replays registrations AND topics** — one `hello`, one `subscribe` per registration
275
274
  (with its cursor) and per topic.
276
- - **The client beats** (`heartbeatMs`, default `DEFAULT_HEARTBEAT_MS` 15 s, `0` disables): a `hello`
277
- plus one subscribe per topic (the presence heartbeat). Two silent windows close with `4000`. The
278
- node's `socket.skewed` compares the build the `hello` claims against its own. The server beat is
279
- derived: `PresenceRegistry.heartbeatMs = max(1000, floor(ttlMs / 3))`.
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.
280
279
  - **There is ONE `backoffDelay`, `@ultimat3/core`'s, counted from 1** — `policyDelay()` maps a
281
- `BackoffPolicy` onto it; `bun run flight-copies` refuses a second curve. Long form:
282
- `docs/history/realtime.md`, "Moved 2026-10-01".
280
+ `BackoffPolicy` onto it; `bun run flight-copies` refuses a second curve.
283
281
 
284
282
  ## Rules for code here
285
283
 
286
284
  - Never a bare `Error`. Never `any`. Never `Date.now()` — take a `Clock` (`monotonic()` for durations).
287
- - **A test fixture standing in for a FOREIGN error extends `Error` on purpose** (`PoolTimeout`,
288
- `Denied`, `ThirdPartySdkError`): it simulates a value this package did not construct. The rule
289
- governs what this package **throws**, never what a test hands it.
290
285
 
291
286
  ## The page boot
292
287
 
@@ -294,7 +289,8 @@ Browser bytes per hook, as last measured: `docs/history/realtime.md`. Re-measure
294
289
 
295
290
  - **The disk boot is ONE page script (`boot.ts`, `./boot`)**, served at `/_x/page-boot/<hash>.js`
296
291
  on a document with the scope tag and a realtime island; it wipes every other principal's stored
297
- scope, then restores. `booted` reads its promise off `globalThis[Symbol.for('ultimate.page-boot')]`.
292
+ scope — the scope kept is read at the wipe, after the disk opens, never at boot start — then
293
+ restores. `booted` reads its promise off `globalThis[Symbol.for('ultimate.page-boot')]`.
298
294
  - **An island never carries the outbox**: `boot.ts` builds it; `useMutation` and the page socket read
299
295
  it through `outbox-slot.ts` after `page.booted`. No boot ⇒ a refused write is rejected, not queued.
300
296
 
@@ -303,13 +299,12 @@ Browser bytes per hook, as last measured: `docs/history/realtime.md`. Re-measure
303
299
  `index.ts` / `server.ts` are the two barrels. Server side: `sync-node.ts` (+ `sync-auth`,
304
300
  `sync-frames`, `sync-upgrade`, `sync-listen`, `drain-evictions`), `channel.ts`, `presence.ts`,
305
301
  `socket.ts`, `live-query.ts` (+ `live-definition`, `live-refusal`, `live-tenant`, `live-resume`, `query-window`, `live-fanout`, `window-lock`,
306
- `subscriber-gate`, `policy-gate` — the only authz seam — and `matcher-bridge`), `replicator.ts` /
302
+ `subscriber-gate`, `policy-gate` — the only authz seam — and `matcher-bridge`), `replicator.ts` (+ `replicator-envelope`) /
307
303
  `live-replicator.ts`, the Postgres client (`pg-*.ts`, `pgoutput.ts`), the bus (`nats-*.ts`,
308
304
  `transport-env.ts`, `fanout.ts`). Client side: `client*.ts`, `socket-engine.ts` / `socket-host.ts` /
309
305
  `sync-worker.ts`, `record-*.ts`, `page-*.ts`, `boot.ts`, `offline-queue.ts`, `use-*.ts`,
310
306
  `reactivity.ts`. Shared: `sync-protocol.ts` (the wire), `cursor.ts`, `change-buffer.ts`,
311
- `thundering-herd.ts`, `live-contract.ts`, `json.ts` (no hash), `type-pins.ts`. Each file's header
312
- states its one job.
307
+ `thundering-herd.ts`, `live-contract.ts`, `json.ts` (no hash), `type-pins.ts`.
313
308
 
314
309
  ## Commands
315
310
 
@@ -318,12 +313,11 @@ bun test packages/realtime/src # from the REPO ROOT, never from packa
318
313
  bun run typecheck
319
314
  ```
320
315
 
321
- `bunfig.toml`'s preload installs `@ultimat3/testing`'s matchers and Bun reads it from the cwd.
316
+ `bunfig.toml`'s preload installs `@ultimat3/testing`'s matchers; Bun reads it from the cwd.
322
317
 
323
318
  Changing a frame shape means a fixture in `sync-protocol.test.ts` (the round-trip test fails
324
319
  without one) and bumping `PROTOCOL_VERSION` **when an old frame becomes unreadable in either
325
320
  direction**. `decode` is a whitelist: an additive optional field, or removing a field read through
326
- `list()`, is free; removing a field read through `str()`/`num()` (which throw) is a bump. Read the
327
- 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.
328
322
 
329
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 }) {
@@ -278,6 +281,7 @@ wire.
278
281
  | outbound bytes buffered on one socket | 1 MiB | `createSyncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
279
282
  | dropped frames before that socket is closed | 32 | `createSyncNode({ maxDroppedFrames })` | close `1013` (`overloaded`), reason `backpressure` |
280
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 |
281
285
  | retained patch bytes per node | 64 MiB | `new RingChangeBuffer({ maxBytes, maxBytesPerQuery })` | eviction, then a re-snapshot on resume |
282
286
  | array lengths and `input` nesting in a frame | `FRAME_LIMITS` | none — a hard ceiling | `X_PROTOCOL_VERSION` |
283
287
 
@@ -350,15 +354,32 @@ credential drained the bucket and every signed-in reconnect behind it was shed.
350
354
  behind an ingress every dial has the ingress's address, and a forwarded header is the caller's own
351
355
  claim.
352
356
 
353
- **A socket from a foreign page is refused** — `403 X_SOCKET_ORIGIN_REFUSED`, before `authenticate`
357
+ **A socket from another origin is refused** — `403 X_SOCKET_ORIGIN_REFUSED`, before `authenticate`
354
358
  and before the budget. A websocket carries the session cookie and no CORS applies to it, so a page on
355
359
  a sibling host (same-site, which `SameSite=Lax` does not stop) could open one as its visitor. The
356
- rule is `@ultimat3/core`'s `proveSameOrigin`, the one `@ultimat3/http`'s CSRF check asks, with two
357
- admissions of the node's own (`sync-origin.ts`): no `Origin` header (RFC 6455 has every browser send
358
- one, so its absence is not a browser), and the node's own host name at any port or scheme (cookies
359
- are not port-isolated, and the Compose rung serves the page on `:3000` and the node on `:3001`). A
360
- page on another host is admitted by `createSyncNode({ allowedOrigins })` — the CLI passes
361
- `APP_URL`'s origin.
360
+ rule is `@ultimat3/core`'s `proveSameOrigin`, the one `@ultimat3/http`'s CSRF check asks, and what
361
+ is admitted is decided in `sync-origin.ts`:
362
+
363
+ | Admitted | When |
364
+ |---|---|
365
+ | no `Origin` header | always — RFC 6455 has every browser send one, so its absence is not a browser |
366
+ | `createSyncNode({ allowedOrigins })`, compared exactly (scheme, host, port) | when any is declared. The declaration is the WHOLE list (24.0.0): the origin the node was reached on is no longer admitted beside it, because that origin is read off the `Host` header and a sibling subdomain pointed at the node would be "its own" |
367
+ | the origin the node was reached on | only when nothing is declared. A node reached over plain `http` also admits the `https` spelling of that host and port: TLS ended at a proxy, the scheme is not knowable there, and a browser sends no `sec-fetch-site` on a websocket handshake |
368
+
369
+ The CLI passes `APP_URL`'s origin, which the Compose rung (`:3000` / `:3001`) requires on `sync`
370
+ and the Helm chart sets from `ingress.host`. Under `x dev` it also adds the web role's own origin
371
+ and its `localhost`, `127.0.0.1` and `[::1]` spellings to a declared list, so `x dev --port 4000`
372
+ beside an `.env` naming `:3000` still connects, and passes `admitReachedOrigin: true`, which keeps
373
+ the origin the node was reached on beside the list (a Codespace, a tunnel: `x dev` binds loopback,
374
+ so a `Host` reaches it only through a forward its own user set up); a container adds nothing, and a wrong `APP_URL`
375
+ there refuses. The refusal's cause names the origin that asked and every origin the node admits;
376
+ its fix is the `export APP_URL=…` that admits the asker.
377
+
378
+ **The node's `/healthz` and `/readyz` tell the detail to a listed peer only**
379
+ (`createSyncNode({ healthDetailPeers })`, default the box itself). A request carrying `Forwarded`,
380
+ `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Real-IP` or `Via` is told the
381
+ verdict only. A proxy that adds none of them is indistinguishable from a direct peer: a
382
+ header-less proxy must not route the health paths.
362
383
 
363
384
  The client dials itself back. A closed socket arms one timer — the node's delay when a `reconnect`
364
385
  frame assigned one, otherwise `@ultimat3/core`'s `backoffDelay()` on the client's `BackoffPolicy` — and that timer calls `connect()`, which re-subscribes
@@ -384,7 +405,7 @@ below (`heartbeatMs` on the internal `LiveClient`; `0` disables the pass).
384
405
 
385
406
  | Property | Behaviour |
386
407
  |---|---|
387
- | Default | `DEFAULT_HEARTBEAT_MS`, 15s. The client's own number and the only one: `realtime.heartbeatMs` in `app.config.ts` was deleted 2026-08-19 because nothing read it |
408
+ | Default | `DEFAULT_HEARTBEAT_MS`, 10s — a third of the default presence ttl, until the node names its own on its `hello` reply (`hello.heartbeatMs` = `min(ttl / 3, idle / 4)`, floored at 1 s): the client follows that for the life of the socket, and the SharedWorker engine reaps a tab on the longer of the two. The client's own number: `realtime.heartbeatMs` in `app.config.ts` was deleted 2026-08-19 because nothing read it |
388
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 |
389
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 |
390
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 |
@@ -413,6 +434,10 @@ wire twice by a reconnect that raced an ack.
413
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 |
414
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` |
415
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
+
416
441
  ### Limits, stated plainly
417
442
 
418
443
  - **The change window is per node, and a `qid` window can only be.** A client that reconnects to a
@@ -421,8 +446,15 @@ wire twice by a reconnect that raced an ack.
421
446
  matcher and no window, so it cannot produce one. What the snapshot path costs is one **shared**
422
447
  read per (query, node), not one per client. A cross-node delta needs an *entity*-keyed window each
423
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.
424
454
  - **Fanout is at-most-once, and a gap is detected rather than assumed away.** The replicator stamps
425
- 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 —
426
458
  invalidates every window it holds and desyncs every subscriber, so the next change to each query
427
459
  re-reads and re-snapshots. Both fields are optional on the bus, so a publisher that does not
428
460
  sequence simply detects nothing. Durable replay (JetStream) is a separate decision — retention,
@@ -485,9 +517,11 @@ wire twice by a reconnect that raced an ack.
485
517
  the new node never held, so the resume falls back correctly rather than silently; the `qidOf`
486
518
  removal itself costs nothing, because every qid a node computes comes from a decoded frame and
487
519
  `JSON.parse` produces none of the values the two spellings disagreed about.
488
- - **A topic guard that *fails* keeps the topic.** On the re-auth pass, only a denial
489
- (`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures`
490
- and logs `channel.guard_failed`. `catch { unsubscribe }` reported a store that timed out as a
520
+ - **A topic guard that *fails* suspends the topic.** On the re-auth pass, only a denial
521
+ (`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures`,
522
+ logs `channel.guard_failed` and SUSPENDS the seat — kept, and sent nothing, until a pass
523
+ succeeds. Two answers are denials before any rule runs: a `null` actor on a channel with a `row`
524
+ loader, and a loader the tenant guard refused. `catch { unsubscribe }` reported a store that timed out as a
491
525
  revoked grant — every topic on every re-authenticated socket, silently, with the client never told
492
526
  to resubscribe. The initial `subscribe` is deliberately not split that way: there is no
493
527
  subscription to keep, so a guard that raises refuses that subscribe and the client hears about it.
@@ -516,7 +550,8 @@ wire twice by a reconnect that raced an ack.
516
550
  - **A full presence frame is capped** at `maxMembers` (256) and carries `total`, so a 5,000-person
517
551
  room renders "and 4,744 others" instead of shipping 5,000 members to every joiner. The set itself
518
552
  is never capped — the sweep differences it — and one node per topic runs that sweep, elected
519
- 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.
520
555
  - **Deliveries are serialized per query id, not per node.** A change is fanned out inside that
521
556
  query's own FIFO lane, so two changes off the bus cannot interleave: the window one of them
522
557
  writes is the window every subscriber's gate reads, and patch frames leave in lsn order. Every
@@ -557,7 +592,29 @@ wire twice by a reconnect that raced an ack.
557
592
  it gains the entity tables it lacks (`ALTER PUBLICATION … ADD TABLE`) and loses none. `FOR TABLE`
558
593
  because a table's owner may publish it without a superuser; a role that may not is refused with
559
594
  `X_REPLICATION_FAILED` and the statement to run as one that may. `InMemoryChangeFeed` + `InProcessTransport` remain the
560
- 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.
561
618
  - **`selectChangeFeed(env, { entities })` decides which feed a boot installs** — same law
562
619
  `selectMailDriver` follows: an unset variable means the embedded default. It returns `{ feed,
563
620
  mode, detail, slot, lock }`: `mode` is `'embedded' | 'external'`, `detail` is the env key that
@@ -580,6 +637,14 @@ wire twice by a reconnect that raced an ack.
580
637
  `X_REPLICATION_TLS` naming the check. Until 22.1.0 `prefer` verified — every private-CA server
581
638
  (CNPG) failed as a refused write — and the raw socket kept feeding ciphertext to the reader
582
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.
583
648
  - **`REPLICATION` is a cluster-wide grant.** A `replication=database` session may also run
584
649
  `BASE_BACKUP` and `START_REPLICATION PHYSICAL` with no database check, so on a shared Postgres
585
650
  cluster the role could copy every database, `pg_authid` included, drop other slots and exhaust
@@ -588,6 +653,21 @@ wire twice by a reconnect that raced an ack.
588
653
  `REPLICATION` to an app role on a shared cluster. Every fix line that hands the grant over says
589
654
  so (`REPLICATION_GRANT_WARNING`, `pg-wire.ts`) and links
590
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.
591
671
  - **`PgAdvisoryLock` is the production `AdvisoryLock`** — `SELECT
592
672
  pg_try_advisory_lock(hashtext('x:replicator:<slot>'))` on its own session. Session-scoped, so a
593
673
  crashed replicator releases it automatically: no lease renewal, no fencing token, no split brain.
@@ -614,7 +694,10 @@ wire twice by a reconnect that raced an ack.
614
694
  the library's own KV abstraction expresses neither a per-message TTL nor a batch direct get.
615
695
  Reconnect and re-subscription are the library's: a lost connection is re-established underneath
616
696
  the caller, which is what makes `sync` stateless, and the jitter that spreads a restart herd is
617
- handed to it as its reconnect delay rather than re-implemented above it. The bucket needs
697
+ handed to it as its reconnect delay rather than re-implemented above it.
698
+ `transport.onReconnect(listener)` — a required member of `Transport` since 24.0.0 — announces
699
+ each recovery and returns the unsubscribe: fanout is at-most-once, so whatever was published
700
+ during the drop never arrived. `InProcessTransport` has no connection to lose and never calls it. The bucket needs
618
701
  nats-server ≥ 2.11 (batch direct get, per-message TTL); an older one is `X_TRANSPORT_PROTOCOL` on
619
702
  the first dial, never a retry loop, because no amount of reconnecting makes a server newer.
620
703
  - **The lsn is `<commit position><row position in the transaction>`, 24 hex characters.** Neither
@@ -622,7 +705,14 @@ wire twice by a reconnect that raced an ack.
622
705
  *transactions* in commit order, so per-record WAL positions are not monotonic across them. The
623
706
  pair sorts in delivery order and is byte-identical on replay, which is what turns at-least-once
624
707
  redelivery into a drop instead of a duplicate.
625
- - **A keyed table does not need `REPLICA IDENTITY FULL`; a table with NO identity is warned**
708
+ - **A channel with params needs `REPLICA IDENTITY FULL` on its `records` tables** (24.0.0). It
709
+ holds no window: a DELETE is routed to the topic the OLD row's params name, and under the default
710
+ identity that image is the key alone — no topic, no `remove`, and members keep the deleted
711
+ record. Preflight asks the catalog about exactly those tables (`paramsChannelTables()`, or
712
+ `PgLogicalReplicationOptions.fullIdentityTables`) and logs `replication.channel_identity_partial`
713
+ with the fix `x db gen "replica identity full"`: `x db gen` grants FULL to those tables beside
714
+ the live queries' `subscribes:` ones, so an app that declares a params channel owes one migration.
715
+ - **For a live query, a keyed table does not need `REPLICA IDENTITY FULL`; a table with NO identity is warned**
626
716
  (`As of 2026-09-23`). Under DEFAULT an update carries no old tuple and a delete only the key, and
627
717
  neither decides a live query: the shared window holds the whole row, so a row leaving the result
628
718
  set is decided from the window and a delete by `holds(id)` — proved on real WAL by
@@ -685,7 +775,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
685
775
  | `FrameRateLimitError` (extends `RealtimeError`) | `X_FRAME_RATE_LIMIT` | `src/errors.ts` |
686
776
  | `LiveQueryUnknownError` (extends `RealtimeError`) | `X_LIVE_QUERY_UNKNOWN` | `src/errors.ts` |
687
777
  | `LiveRowUnidentifiedError` (extends `RealtimeError`) | `X_LIVE_ROW_UNIDENTIFIED` | `src/errors.ts` |
688
- | `NotImplementedError` (extends `RealtimeError`) | `X_NOT_IMPLEMENTED` | `src/errors.ts` |
778
+ | `OfflineQueueAbandonedError` (extends `RealtimeError`) | `X_OFFLINE_QUEUE_ABANDONED` | `src/page-errors.ts` |
689
779
  | `ProtocolVersionError` (extends `RealtimeError`) | `X_PROTOCOL_VERSION` | `src/page-errors.ts` |
690
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` |
691
781
  | `RealtimeUninstalledError` (extends `RealtimeError`) | `X_REALTIME_UNINSTALLED` | `src/page-errors.ts` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "23.0.0",
3
+ "version": "24.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",
@@ -38,9 +38,9 @@
38
38
  "test": "bun test"
39
39
  },
40
40
  "dependencies": {
41
- "@ultimat3/core": "23.0.0",
42
- "@ultimat3/entity": "23.0.0",
43
- "@ultimat3/query": "23.0.0",
41
+ "@ultimat3/core": "24.0.0",
42
+ "@ultimat3/entity": "24.0.0",
43
+ "@ultimat3/query": "24.0.0",
44
44
  "nats": "2.29.3"
45
45
  }
46
46
  }
@@ -0,0 +1,57 @@
1
+ // Single responsibility: the mutual-exclusion contract behind "exactly one replicator per database",
2
+ // and its single-process implementation. The Postgres one is `pg-advisory-lock.ts`.
3
+
4
+ /** Session-scoped mutual exclusion. Postgres-backed in production, in-memory for `x dev`. */
5
+ export interface AdvisoryLock {
6
+ readonly key: string;
7
+ /** `false` means another process holds it — never block, never steal. */
8
+ tryAcquire(): Promise<boolean>;
9
+ release(): Promise<void>;
10
+ /**
11
+ * Let go WITHOUT asking: drop the session, synchronously, and wait for nothing. Closing the
12
+ * session releases the lock, so this is the release for a session that may be dead — an unlock
13
+ * statement on one is a wait nothing ends. Never calls `onLost`. A no-op when nothing is held.
14
+ */
15
+ abandon(): void;
16
+ /**
17
+ * Told when a lock this object HELD is gone without `release()` — its session died. Never for a
18
+ * release, never for a `tryAcquire` that answered `false`. Returns the unsubscribe. The holder
19
+ * must stop doing what the lock allowed: the database will give it to the next process that asks.
20
+ */
21
+ onLost(listener: (reason: string) => void): () => void;
22
+ }
23
+
24
+ /** Single-process default: correct for `x dev` and tests, useless across containers (by design). */
25
+ export class InMemoryAdvisoryLock implements AdvisoryLock {
26
+ static readonly #held = new Set<string>();
27
+ readonly key: string;
28
+ #mine = false;
29
+
30
+ constructor(key: string) {
31
+ this.key = key;
32
+ }
33
+
34
+ async tryAcquire(): Promise<boolean> {
35
+ if (InMemoryAdvisoryLock.#held.has(this.key)) return false;
36
+ InMemoryAdvisoryLock.#held.add(this.key);
37
+ this.#mine = true;
38
+ return true;
39
+ }
40
+
41
+ async release(): Promise<void> {
42
+ if (!this.#mine) return;
43
+ InMemoryAdvisoryLock.#held.delete(this.key);
44
+ this.#mine = false;
45
+ }
46
+
47
+ abandon(): void {
48
+ if (!this.#mine) return;
49
+ InMemoryAdvisoryLock.#held.delete(this.key);
50
+ this.#mine = false;
51
+ }
52
+
53
+ /** A set in this process's own heap has no session to lose: the listener is never called. */
54
+ onLost(): () => void {
55
+ return () => undefined;
56
+ }
57
+ }