@ultimat3/realtime 24.0.0 → 25.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 (88) hide show
  1. package/CLAUDE.md +61 -63
  2. package/README.md +131 -46
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +10 -5
  5. package/src/boot.ts +14 -9
  6. package/src/changefeed-env.ts +8 -8
  7. package/src/changefeed.ts +42 -18
  8. package/src/channel-authz.ts +7 -7
  9. package/src/channel-describe.ts +1 -1
  10. package/src/channel-logs.ts +16 -25
  11. package/src/channel-presence.ts +3 -3
  12. package/src/channel-records.ts +3 -3
  13. package/src/channel-ref.ts +2 -2
  14. package/src/channel-registry.ts +2 -2
  15. package/src/channel.ts +8 -7
  16. package/src/client-channels.ts +2 -0
  17. package/src/client-frames.ts +9 -2
  18. package/src/client.ts +29 -2
  19. package/src/error-titles.ts +91 -0
  20. package/src/errors.ts +47 -90
  21. package/src/first-paint-hold.ts +49 -0
  22. package/src/idb-types.ts +2 -2
  23. package/src/index.ts +24 -6
  24. package/src/json.ts +16 -3
  25. package/src/live-contract.ts +14 -0
  26. package/src/live-definition.ts +12 -2
  27. package/src/live-deliver.ts +55 -0
  28. package/src/live-fanout.ts +81 -24
  29. package/src/live-query-options.ts +6 -0
  30. package/src/live-query.ts +66 -144
  31. package/src/live-reauth.ts +127 -0
  32. package/src/live-replicator.ts +28 -8
  33. package/src/live-resume.ts +44 -8
  34. package/src/live-rows.ts +32 -3
  35. package/src/live-spend.ts +50 -0
  36. package/src/local-store-idb.ts +5 -0
  37. package/src/matcher-bridge.ts +11 -5
  38. package/src/nats-client.ts +26 -4
  39. package/src/nats-lib-client.ts +36 -7
  40. package/src/nats-subscriptions.ts +63 -0
  41. package/src/nats-transport.ts +127 -15
  42. package/src/offline-queue.ts +5 -0
  43. package/src/page-errors.ts +8 -9
  44. package/src/page-outbox.ts +5 -5
  45. package/src/page-runtime-wait.ts +72 -0
  46. package/src/page-runtime.ts +32 -0
  47. package/src/page-socket.ts +7 -16
  48. package/src/page-store.ts +83 -16
  49. package/src/pg-advisory-lock.ts +9 -4
  50. package/src/pg-connection.ts +7 -1
  51. package/src/pg-replication.ts +6 -6
  52. package/src/pg-socket.ts +20 -3
  53. package/src/pg-values.ts +29 -14
  54. package/src/policy-gate.ts +7 -7
  55. package/src/presence.ts +2 -2
  56. package/src/principal-sockets.ts +75 -0
  57. package/src/query-window.ts +1 -1
  58. package/src/record-publisher.ts +178 -0
  59. package/src/record-store.ts +25 -4
  60. package/src/record-synced.ts +43 -1
  61. package/src/replication-errors.ts +3 -3
  62. package/src/replicator-envelope.ts +61 -11
  63. package/src/replicator-row.ts +1 -1
  64. package/src/replicator.ts +12 -23
  65. package/src/server.ts +36 -18
  66. package/src/socket-defaults.ts +20 -0
  67. package/src/socket.ts +14 -19
  68. package/src/subscription-book.ts +129 -71
  69. package/src/subscription-count.ts +151 -0
  70. package/src/sync-actor-change.ts +56 -0
  71. package/src/sync-bus-handlers.ts +131 -0
  72. package/src/sync-frames.ts +1 -1
  73. package/src/sync-node-bounds.ts +1 -1
  74. package/src/sync-node-contract.ts +16 -2
  75. package/src/sync-node.ts +115 -109
  76. package/src/sync-origin.ts +1 -1
  77. package/src/sync-protocol.ts +73 -13
  78. package/src/sync-upgrade.ts +39 -0
  79. package/src/type-pins.ts +9 -1
  80. package/src/use-channel.ts +19 -14
  81. package/src/use-connection.ts +3 -3
  82. package/src/use-mutation.ts +9 -16
  83. package/src/use-query.ts +11 -11
  84. package/src/use-record.ts +89 -10
  85. package/src/wire-channel.ts +5 -5
  86. package/src/wire-read.ts +2 -2
  87. package/src/idb-fake.ts +0 -133
  88. package/src/policy-fake.ts +0 -14
package/README.md CHANGED
@@ -61,8 +61,8 @@ half reaches the other; `barrel-split.test.ts` fails if one name is exported fro
61
61
  Migrating from 7.x: an import of a **server** name changes its specifier and nothing else.
62
62
 
63
63
  ```
64
- - import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime';
65
- + import { ChannelHub, createSyncNode, LiveQueryRegistry } from '@ultimat3/realtime/server';
64
+ - import { ChannelHub, syncNode, LiveQueryRegistry } from '@ultimat3/realtime';
65
+ + import { ChannelHub, syncNode, LiveQueryRegistry } from '@ultimat3/realtime/server';
66
66
  ```
67
67
 
68
68
  Client names — the hooks, `RecordStore`, `OfflineQueue`, `encode`/`decode`, every `X_*` error
@@ -73,20 +73,22 @@ class — stay on `.`.
73
73
  | Concern | Entry | Export |
74
74
  |---|---|---|
75
75
  | tier 1 | `./server` | `ChannelHub`, `PresenceRegistry`, `SyncSocket`, `SocketRegistry` |
76
- | tier 2 | `./server` | `LiveQueryRegistry`, `InMemoryChangeFeed`, `PgLogicalReplicationFeed`, `selectChangeFeed`, `createReplicator`, `PgAdvisoryLock`, `matcherFor` |
77
- | replication | `./server` | `parsePgUrl`, `bunPgStream`, `PgOutputDecoder`, `entityRow`, `changeLsn`, `commitPositionOf` |
78
- | the in-process change source | `./server` | `startLiveReplicator` — a repository's own writes as `ChangeEvent`s, for the embedded database `x dev` runs on (PGlite has no walsender). Moved from `@ultimat3/testing`, which no longer re-exports it |
76
+ | tier 2 | `./server` | `LiveQueryRegistry`, `memoryChangeFeed()`, `postgresChangeFeed()`, `selectChangeFeed`, `changeFeedReplicator`, `postgresAdvisoryLock()`, `matcherFor` |
77
+ | replication | `./server` | `parsePgUrl`, `bunPgStream`, `entityRow`, `changeLsn`, `commitPositionOf` |
78
+ | the in-process change source | `./server` | `startLiveReplicator` — a repository's own writes as `ChangeEvent`s, for the embedded database `x dev` runs on (PGlite has no walsender). `ChangeEvent.table` is the entity's TABLE on it as on the WAL decoder and `recordPublisher`, so an `entity(name, { table })` reaches channels and live windows (25.0.0). Moved from `@ultimat3/testing`, which no longer re-exports it |
79
79
  | fanout | `./server` | `Transport`, `InProcessTransport`, `NatsTransport`, `selectTransport`, `subjectMatches` |
80
80
  | the bus, behind `NatsTransport` | `./server` | the port — `NatsClient`, `NatsMessage`, `NatsSubscription`, `NatsConnect`, `NatsTarget`, `parseNatsUrl` — plus `openNatsClient` (the `nats` adapter), `NatsKvSet`, `ensureKvBucket`, `kvGet`/`kvLast`/`kvWrite`, `assertBucket`, `encodeToken`/`decodeToken`, and `FakeNatsBroker`/`fakeNatsConnect` for tests |
81
81
  | reconnect | both | `LiveCursor`, `resumeFrom`, `shouldResnapshot`, `defaultReconnectBudget`, `Scheduler`, `timeoutScheduler` on `.`; `RingChangeBuffer`, `drainPlan`, `AcceptBudget`, `reconnectFrame` on `./server` — the node's half of the reconnect is the node's |
82
82
  | the page's record store | `.` | `RecordStore` — one record per `type:key`, synced truth plus the optimistic overlay — `recordKey`, `LocalTx`, `RowWindows`, `applyPatches`/`orderAfterPatches` |
83
- | the outbox | `.` | `OfflineQueue`, `MemoryQueueStore` — replayed over HTTP from plan 101 slice 12. The conflict vocabulary is `ConflictPolicy` from `@ultimat3/core`; realtime declares none |
83
+ | the outbox | `.` | `OfflineQueue`, `memoryQueueStore()` — replayed over HTTP from plan 101 slice 12. The conflict vocabulary is `ConflictPolicy` from `@ultimat3/core`; realtime declares none |
84
84
  | wire | `.` | `PROTOCOL_VERSION` (3), `encode`, `decode`, `Frame` |
85
- | the node | `./server` | `createSyncNode` / `listenSyncNode` (`sync` role) |
85
+ | the node | `./server` | `syncNode` / `listenSyncNode` (`sync` role) |
86
86
  | a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
87
- | hooks | `.` | `useQuery`, `useRecord`, `useMutation`, `useMutationQueue`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
87
+ | hooks | `.` | `useQuery`, `useRecord`, `useRecords` (by key, or a whole type: `RecordSelection`), `useMutation`, `useMutationQueue`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
88
+ | channel records, no replicator | `./server` | `recordPublisher` — an app's committed rows as channel `records`, sequenced per producer ([below](#channel-records-with-no-replicator)) |
88
89
  | channels | `.` | `channel`, `channelRef`, `ChannelHandle`, `topic`, `readPresence`, the channel frame types |
89
- | offline | `.` | `pageOutbox`, `recordPersister`, `persistedTypes`, `openLocalStore`, `pageLocalStore`, `MemoryLocalStore` |
90
+ | first paint | `.` | `holdFirstPaint`, `FIRST_PAINT_HOLD_MS` (1 s) — what the island bootstrap awaits before `mount`: the boot's restore and the open outbox, capped (#506) |
91
+ | offline | `.` | `pageOutbox`, `recordPersister`, `persistedTypes`, `openLocalStore`, `pageLocalStore`, `memoryLocalStore()` |
90
92
  | the socket's worker | `./sync-worker` | the SharedWorker entry — no exports |
91
93
 
92
94
  ## `channelRef` — a channel an island can hold
@@ -119,13 +121,72 @@ is `X_CHANNEL_DECLARATION_INVALID` at declaration. `catchUp` is read on every ac
119
121
  whose name `registerQueries()` stamps at boot is picked up. The reference app's
120
122
  [`app/posts/channel-ref.ts`](../../examples/dummy/apps/web/app/posts/channel-ref.ts) is the idiom.
121
123
 
124
+ ### What `useChannel()` reports
125
+
126
+ `useChannel(ref, params)()` is a `ChannelState`. Every channel that carries `records` or `events`
127
+ leaves `joining` on the node's first answer; nothing waits for app traffic.
128
+
129
+ | State | Entered when | Left when |
130
+ |---|---|---|
131
+ | `joining` | the subscribe is sent (on hold, and again on every reconnect) | the node's answer: a `records` channel's `replay-gap` (a fresh seat) or the `records` frame at a current cursor; an `events` channel's roster — sent on every seat, an empty one from a node with no presence (24.0.0) |
132
+ | `catching-up` | a `replay-gap`, or a frame in a new epoch: the `catchUp` query is re-read, frames held behind it | the read lands → `live` (the held frames applied in order) |
133
+ | `live` | as above | the socket drops → `offline`; a gap → `catching-up` |
134
+ | `offline` | the page socket closed | it reopens → `joining` |
135
+ | `failed` | the node refused the subscribe (`error()` is the coded refusal), or a catch-up read failed (retried on the client's curve, and on the next open) | a successful retry → `live` |
136
+
137
+ A channel declared with **neither** `records` nor `events` carries nothing, so nothing answers its
138
+ join and it reads `joining` for as long as it is held.
139
+
140
+ ## Channel records with no replicator
141
+
142
+ A channel's `records` come off the change bus every sync node reads. The WAL replicator fills it
143
+ — and needs a replication slot and the `REPLICATION` grant, cluster-wide on a shared Postgres.
144
+ Without one, `recordPublisher` (`./server`) is how an app puts its own **committed** rows on that
145
+ bus; every node turns them into `records` frames exactly as it does a replicated change (#682).
146
+
147
+ ```ts
148
+ import type { ChannelEntity, Row } from '@ultimat3/realtime';
149
+ import { recordPublisher, type Transport } from '@ultimat3/realtime/server';
150
+
151
+ declare const transport: Transport; // the process's bus — the one `selectTransport` chose
152
+ declare const runs: ChannelEntity; // an `entity()` a `channel(…, { records: [runs] })` lists
153
+ declare const finished: readonly Row[];
154
+
155
+ // Once per process, at boot, naming every entity it will publish.
156
+ const publisher = recordPublisher({ transport, entities: [runs] });
157
+
158
+ // After the transaction commits.
159
+ export async function settled(): Promise<void> {
160
+ await publisher.publish(runs, finished);
161
+ await publisher.publish(runs, finished, { op: 'delete' });
162
+ }
163
+
164
+ export const shutdown = (): void => publisher.close();
165
+ ```
166
+
167
+ | Fact | Rule |
168
+ |---|---|
169
+ | one per process | one publisher is one `producer`; its `seq` counts from 1, one per row, in call order. A node reads a hole in it as a gap, and the members of every records topic re-run their catch-up read. A NEW publisher is not a gap — publishers run side by side, one per process |
170
+ | committed rows only | call it after COMMIT. A row published from inside a transaction is on every page before a rollback; nothing here can see the transaction |
171
+ | what rides | the entity's record properties and nothing else: a sealed column and a property the entity does not declare are dropped. A row without its key is `X_RECORD_KEY_MISSING`, before any row of the call is sent |
172
+ | `op` | `upsert` (default) adopts the rows; `delete` removes them by key. A row whose channel params changed: publish its old image as a `delete`, then the new one |
173
+ | `write` | the keyed request the call runs inside (`writeDigest` of its idempotency key), so the writing page settles its own echo; or the digest you pass; or `null` |
174
+ | a send the bus refused | the call rejects (`X_TRANSPORT_UNAVAILABLE` from NATS); that row's seq stays spent, so the nodes see the hole and repair it |
175
+ | channels, never live queries | a publisher has no commit position, and a live window refuses a change below its own lsn as stale. Its changes reach the declared channels only; a live query needs the replicator |
176
+ | under `x dev` | an entity a publisher claims is no longer carried to channels by the in-process bridge — the app's publish is its one delivery, as in production. Live queries still hear the bridge |
177
+ | one producer kind per table | a sync node keeps the producer KIND (replicator or publisher) that first carried a table and drops the other's changes for it, logged once per table as `X_REALTIME_PRODUCER_CONFLICT` — a replicator and a publisher on one table was every row twice on every channel. The replicator carries every entity table, so the fix is always to stop publishing that one; the node decides again on restart |
178
+ | refusals | `X_INVARIANT`: an entity the publisher did not claim, a `write` that is not a digest, a closed publisher |
179
+
122
180
  ## The hooks
123
181
 
124
182
  One record store and one socket per **page**, on `globalThis`: every island is its own bundle, so a
125
- module-level singleton would be one per island. The island bootstrap `x build` prepends installs
126
- realtime for its bundle — `installRealtime({ signal: createSignal, sync: { url, buildId } })` — and
127
- an island never constructs a client or a socket. The socket opens on the first live hook; an island
128
- that only reads records or writes ships none of it.
183
+ module-level singleton would be one per island. Both belong to the **page runtime** — the store,
184
+ the socket stack, the query client and core's transport — installed ONCE per page and never inside
185
+ an island: the page boot carries it, and where no boot is rendered the island loads
186
+ `/islands/page-runtime.<id>.js`, the one chunk `x build` builds from `@ultimat3/realtime/page-runtime`.
187
+ The island bootstrap `x build` prepends awaits that runtime, then installs realtime for its bundle —
188
+ `installRealtime({ signal: createSignal })` — so a hook is a thin reader and an island never
189
+ constructs a client or a socket. The socket opens on the first live hook.
129
190
 
130
191
  ```ts
131
192
  import {
@@ -138,6 +199,7 @@ import {
138
199
  usePresence,
139
200
  useQuery,
140
201
  useRecord,
202
+ useRecords,
141
203
  } from '@ultimat3/realtime';
142
204
 
143
205
  declare const orgId: string;
@@ -145,10 +207,16 @@ declare const postId: string;
145
207
  declare const LIKE_POST: MutatorLike; // name + local twin + conflict — never the mutator VALUE
146
208
  declare const orgFeed: ChannelRef<'orgId'>; // a `channel('org-feed', { params: ['orgId'], … })`
147
209
  declare function onEvent(event: Readonly<Record<string, unknown>>): void;
210
+ type Run = { readonly id: string; readonly state: string; readonly startedAt: string };
148
211
 
149
212
  const feed = useQuery({ name: 'liveFeed', live: true }, { orgId }); // AsyncState<readonly Row[]>
150
213
  const posts = useQuery({ name: 'listPosts', entity: 'posts' }, {}); // one HTTP read, rows as records
151
214
  const post = useRecord('posts', postId); // AsyncState<Row | undefined>
215
+ const pair = useRecords('posts', [postId]); // those keys, in key order
216
+ const runs = useRecords<Run>('runs', { // EVERY `runs` record on the page
217
+ where: (run) => run.state !== 'done',
218
+ order: (a, b) => (a.startedAt < b.startedAt ? 1 : -1),
219
+ });
152
220
  const like = useMutation(LIKE_POST); // await like(input); like.pending
153
221
  const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
154
222
  const writes = useMutationQueue(); // .pending .failed
@@ -161,8 +229,9 @@ const room = usePresence(orgFeed, { orgId }); // the channe
161
229
  same engine runs in-page. Writes that find no network go to the page's outbox — overlay kept — and
162
230
  replay over HTTP, under their original idempotency keys, when the socket comes back. The outbox is
163
231
  the page boot's (`@ultimat3/realtime/boot`): an island only reads it off the page. On a page the
164
- CLI renders no boot for (no scope tag, so nothing on it persists) such a write is refused like any
165
- other — rejected, overlay taken back — never held in memory a reload would silently lose.
232
+ CLI renders no boot for (no scope tag, so nothing on it persists) the island's runtime chunk opens
233
+ no outbox, and such a write is refused like any other — rejected, overlay taken back — never held
234
+ in memory a reload would silently lose.
166
235
 
167
236
  `<AsyncRegion state={feed()} …/>` takes the answer as-is: `AsyncState` is `@ultimat3/core`'s, the
168
237
  same type `@ultimat3/ui` renders.
@@ -171,6 +240,7 @@ same type `@ultimat3/ui` renders.
171
240
  |---|---|
172
241
  | A query ref is `{ name, live?, entity? }`, never the query VALUE | importing a `query()` drags its read path into the island (698,801 B measured) |
173
242
  | Lists hold keys; rows are the store's | a record updated by any answer or frame re-renders every list showing it, with no refetch |
243
+ | A list that must GROW with the store is `useRecords(type, { where?, order? })` | a `useQuery` list holds its own answer's keys and `useRecords(type, keys)` the keys it was given, so a record a channel's `records` frame adopts reaches neither. The whole-type form is the channel-records counterpart of a live window: `ready` from its first read, every record of the type the store holds (optimistic ones included), default order by key, and none evicted while it is held — a row it alone kept goes when the last reader of the type lets go (#680) |
174
244
  | Every write is HTTP through core's `clientTransport` | one write path: the action's authz, idempotency and contract; the socket carries none |
175
245
  | The answer's records are adopted before the overlay goes | a convergent twin never flickers back to the pre-write value |
176
246
  | **No `solid-js` import.** Each bundle installs its own `SignalFactory` | every island carries its own solid-js; a signal from another bundle is invisible to its effects |
@@ -185,7 +255,7 @@ asks.
185
255
 
186
256
  ## Who a socket is
187
257
 
188
- `createSyncNode({ authenticate })` is the one place a websocket gets an identity. It runs on the
258
+ `syncNode({ authenticate })` is the one place a websocket gets an identity. It runs on the
189
259
  upgrade **before** `server.upgrade`, so a refused credential never costs a socket, and the actor it
190
260
  resolves is what every policy downstream decides against — the topic guard, `authorize`, `visible`,
191
261
  the per-tenant subscription cap.
@@ -199,7 +269,7 @@ declare function sessionFrom(
199
269
  ): Promise<{ actor: Actor; expiresAt: number; token: string } | null>;
200
270
  declare function renew(token: string): Promise<SyncGrant | null>;
201
271
 
202
- // The one option this section is about; `createSyncNode({ …, authenticate })` takes it.
272
+ // The one option this section is about; `syncNode({ …, authenticate })` takes it.
203
273
  const options: Pick<SyncNodeOptions, 'authenticate'> = {
204
274
  // From @ultimat3/auth, or anywhere else: `sync` imports no authenticator, exactly as it owns no
205
275
  // business logic. `refresh` is yours too, so the framework retains no credential of its own.
@@ -230,7 +300,7 @@ the socket and retries next pass — a token service timing out is not a revocat
230
300
  `hub.guard('org.*.feed', ({ actor }) => actor?.orgId === …)` denies everyone and the only guard that
231
301
  lets anything through is one that reads no actor at all.
232
302
 
233
- Authz goes through `@ultimat3/query`'s `guard`, which is the only contact with `@ultimat3/policy`.
303
+ Authz goes through `@ultimat3/query`'s `guardQuery`, which is the only contact with `@ultimat3/policy`.
234
304
  One authz system, never two: `policy` is evaluated **once per subscriber**, never once per query.
235
305
  Two actors on one live query get two different result sets, and a row that leaves an actor's policy
236
306
  is delivered to them as a `delete` — never as silence.
@@ -269,19 +339,21 @@ wire.
269
339
 
270
340
  | Ceiling | Default | Option | Refused with |
271
341
  |---|---|---|---|
272
- | concurrent sockets on this node | 250,000 | `createSyncNode({ maxConnections })` | `503` + `retry-after-ms`, the same shed as the accept budget |
273
- | inbound bytes per frame | 256 KiB | `createSyncNode({ maxFrameBytes })` | the socket, by `Bun.serve`'s `maxPayloadLength` |
274
- | inbound frames per socket | 64/s, burst 256 | `createSyncNode({ maxFramesPerSecond, frameBurst })` | `X_FRAME_RATE_LIMIT` |
342
+ | concurrent sockets on this node | 250,000 | `syncNode({ maxConnections })` | `503` + `retry-after-ms`, the same shed as the accept budget |
343
+ | inbound bytes per frame | 256 KiB | `syncNode({ maxFrameBytes })` | the socket, by `Bun.serve`'s `maxPayloadLength` |
344
+ | inbound frames per socket | 64/s, burst 256 | `syncNode({ maxFramesPerSecond, frameBurst })` | `X_FRAME_RATE_LIMIT` |
275
345
  | live subscriptions per socket | 128 | `new LiveQueryRegistry({ maxPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
346
+ | live subscriptions per actor (anonymous: per resolved client address), across its sockets | 1,000 (`DEFAULT_MAX_PER_ACTOR`) | `new LiveQueryRegistry({ maxPerActor })`; the boot passes `realtime.maxSubscriptionsPerActor` | `X_SUBSCRIPTION_LIMIT` |
347
+ | sockets per actor; an anonymous network (IPv4 exact, IPv6 /64) gets 8 × (`ANONYMOUS_SOCKET_MULTIPLIER`) — one NAT is many people | 16 (`DEFAULT_MAX_SOCKETS_PER_ACTOR`); 128 per anonymous network | `syncNode({ maxSocketsPerActor })`; the boot passes `realtime.maxSocketsPerActor` | `429 X_SOCKET_LIMIT` |
276
348
  | live subscriptions per tenant | unset | `new LiveQueryRegistry({ maxPerTenant, tenantOf })` — **both**, or it arms nothing | `X_SUBSCRIPTION_LIMIT` |
277
349
  | distinct `(query, input)` pairs per node | 10,000 | `new LiveQueryRegistry({ maxEntries })` | `X_SUBSCRIPTION_LIMIT` |
278
350
  | how long one entry's SHARED snapshot read may hold its slot | 30s | `new LiveQueryRegistry({ readDeadlineMs })` | `X_TIMEOUT`, to that read's caller AND every subscriber joined to it |
279
351
  | channel topics per socket | 64 | `new ChannelHub({ maxTopicsPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
280
352
  | distinct channel topics per node | 10,000 | `new ChannelHub({ maxTopicsPerNode })` | `X_SUBSCRIPTION_LIMIT` |
281
- | outbound bytes buffered on one socket | 1 MiB | `createSyncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
282
- | dropped frames before that socket is closed | 32 | `createSyncNode({ maxDroppedFrames })` | close `1013` (`overloaded`), reason `backpressure` |
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 |
353
+ | outbound bytes buffered on one socket | 1 MiB | `syncNode({ maxBufferedBytes })` | the frame is dropped and `send` answers `false` |
354
+ | dropped frames before that socket is closed | 32 | `syncNode({ maxDroppedFrames })` | close `1013` (`overloaded`), reason `backpressure` |
355
+ | time one socket may route no frame | 120s | `syncNode({ idleTimeoutMs })` | close `4001` (`idle`), reason `idle timeout` |
356
+ | time one grant's `refresh()` may hold the re-auth pass | 10s (10 000 ms) | `syncNode({ grantRefreshDeadlineMs })` | reported and skipped: the grant stays expired and is asked again next pass |
285
357
  | retained patch bytes per node | 64 MiB | `new RingChangeBuffer({ maxBytes, maxBytesPerQuery })` | eviction, then a re-snapshot on resume |
286
358
  | array lengths and `input` nesting in a frame | `FRAME_LIMITS` | none — a hard ceiling | `X_PROTOCOL_VERSION` |
287
359
 
@@ -318,6 +390,7 @@ over it, and an HTTP answer's records, a socket patch and an optimistic write al
318
390
  | The live path names the type server-side (`recordTypeForTable`) | a changefeed speaks tables; the store speaks entities |
319
391
  | Two layers: synced truth and the optimistic overlay | a refused write drops its overlay and shows exactly what the server said — never a stale before-image |
320
392
  | The overlay is REPLAYED over every server update | two pending writes on one row land in order on top of someone else's change |
393
+ | A server frame that echoes this page's write names it — `records.write`, `patch.writes` (a delta resume names each it replays), `writeDigest(idempotencyKey)` — and settles it in the frame's own batch | merged under its still-pending twin, the echo painted the write twice (truth plus overlay) until the HTTP answer landed. `ChangeEvent.write` is `string \| null`, required, so a producer cannot forget it |
321
394
  | A value is **replaced, never mutated**; a write **merges** columns | a mutated row is a render that never happens; a narrower projection must not blank a wider one |
322
395
  | A structurally bad row is `X_RECORD_REJECTED`, dropped and reported | never partially merged — a keyless row would overwrite another record |
323
396
  | The last holder leaving evicts the record | an infinite scroll must not retain every row it ever saw |
@@ -363,7 +436,7 @@ is admitted is decided in `sync-origin.ts`:
363
436
  | Admitted | When |
364
437
  |---|---|
365
438
  | 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" |
439
+ | `syncNode({ 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
440
  | 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
441
 
369
442
  The CLI passes `APP_URL`'s origin, which the Compose rung (`:3000` / `:3001`) requires on `sync`
@@ -376,7 +449,7 @@ there refuses. The refusal's cause names the origin that asked and every origin
376
449
  its fix is the `export APP_URL=…` that admits the asker.
377
450
 
378
451
  **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`,
452
+ (`syncNode({ healthDetailPeers })`, default the box itself). A request carrying `Forwarded`,
380
453
  `X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Real-IP` or `Via` is told the
381
454
  verdict only. A proxy that adds none of them is indistinguishable from a direct peer: a
382
455
  header-less proxy must not route the health paths.
@@ -434,7 +507,7 @@ wire twice by a reconnect that raced an ack.
434
507
  | 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 |
435
508
  | 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` |
436
509
 
437
- `createOutbox({ warn })` — where a disk that refused the queue is said
510
+ `localOutbox({ warn })` — where a disk that refused the queue is said
438
511
  (`X_LOCAL_STORE_UNAVAILABLE`, default `console.warn`); the outbox then queues in memory for that
439
512
  principal.
440
513
 
@@ -456,13 +529,19 @@ principal.
456
529
  or, since 24.0.0, a new `producer` after one it already read: the run before it died, and the
457
530
  tail of a dead stream has no later sequence to be missed against —
458
531
  invalidates every window it holds and desyncs every subscriber, so the next change to each query
459
- re-reads and re-snapshots. Both fields are optional on the bus, so a publisher that does not
460
- sequence simply detects nothing. Durable replay (JetStream) is a separate decision — retention,
532
+ re-reads and re-snapshots — and tells every member of every open topic that carries records
533
+ `replay-gap` at a new epoch, so each re-runs its channel's catch-up read (`ChannelHub.invalidate`,
534
+ 24.0.0, #681). A bus that reconnected is the same repair, with no later message needed. Both
535
+ fields are optional on the bus, so a publisher that does not sequence simply detects nothing.
536
+ A `recordPublisher`'s changes are sequenced too (`source: 'publisher'`): a hole in one is a
537
+ gap; a new publisher is not, because publishers run side by side. Durable replay (JetStream) is a separate decision — retention,
461
538
  storage and replay window — and is deliberately not this mechanism.
462
539
  - **A bulk write re-reads only the windows over its entity.** `updateWhere`/`deleteWhere` name a
463
540
  filter, not rows, so the in-process replicator calls `registry.invalidate(entity)`: windows whose
464
541
  shape reads that entity go stale and their subscribers desynced. The next change re-reads the
465
- window and re-snapshots every subscriber, whether or not that change matches the query.
542
+ window and re-snapshots every subscriber, whether or not that change matches the query. The
543
+ channels are told too — `ChannelHub.invalidate(entity)` — so every topic carrying that table
544
+ sends its members `replay-gap` and they re-run the channel's catch-up read.
466
545
  - **`desynced` has a reader.** A subscriber recorded as diverged — a dropped patch, a gate that
467
546
  failed, a window that lost its tail — is served a fresh snapshot out of the shared window on the
468
547
  next delivery, and only then is the mark cleared. A snapshot the socket refuses leaves it
@@ -481,17 +560,17 @@ principal.
481
560
  readers that throw on an absent field, unlike the `list()` that made `hello.resume`'s removal free.
482
561
  - **Backpressure drops patch frames.** That is safe *only* because a re-snapshot is cheap: the drop
483
562
  is recorded on the socket (`desynced`) and the next delivery re-snapshots rather than diverging.
484
- - **A dropped CHANNEL frame is not safe, and is not repaired.** A topic has no cursor, no mark and
485
- no re-snapshot, so tier 1 is **at most once**. Every refusal is counted — the series
563
+ - **A dropped `events` frame is not repaired** (a dropped `records` frame is: `replay-gap` once the
564
+ socket drains). An event has no cursor, no mark and no re-snapshot, so tier 1's `events` are
565
+ **at most once**. Every refusal is counted — the series
486
566
  `channel_frames_dropped_total` (no labels: a topic is client-chosen, so a per-topic label is
487
567
  unbounded series one socket can mint), the log line `channel.frames_dropped` at `warn` carrying
488
568
  `{ topic, dropped, total }`, and `node.sockets.droppedChannelFrames` for a test or a benchmark
489
569
  that cannot scrape. Node-wide and cumulative, because a socket past `maxDroppedFrames` is closed
490
570
  and removed — a per-socket count leaves exactly when loss is worst. Distinct from
491
571
  `SyncSocket.droppedFrames`, which counts every kind of frame one connection lost and dies with it.
492
- Repair would need a per-topic sequence on the wire: a channel's `lsn` is the publishing hub's own
493
- per-node counter, so a client cannot tell a gap from a message that arrived via another node.
494
- **Anything that must arrive belongs on a live query.**
572
+ `records` frames carry a per-node `seq`/`epoch`, which is what lets them be repaired; `events`
573
+ carry none. **Anything that must arrive belongs on a live query or a `records` channel.**
495
574
  - **Bun's native WS pub/sub is not used.** `subscribeTopic` does not call `ws.subscribe` and the
496
575
  websocket config declares no `publishToSelf`; every channel message is one filtered `send` per
497
576
  socket through `SocketRegistry.deliver`, reading a per-topic index rather than walking the socket
@@ -583,7 +662,7 @@ principal.
583
662
  every subscriber learned the id and the instant of every *other* tenant's row as it was deleted,
584
663
  on a query whose `visible` rule had never let them see one. A subscriber that holds the row is
585
664
  told it is gone; one that does not gets nothing, counted as `rowsDenied`.
586
- - **`PgLogicalReplicationFeed` decodes `pgoutput` off a real slot** — its own Postgres v3 client
665
+ - **`postgresChangeFeed()` decodes `pgoutput` off a real slot** — its own Postgres v3 client
587
666
  (SCRAM-SHA-256, in-band TLS, CopyBoth), no driver dependency. It preflights `wal_level`, the
588
667
  publication, every entity's replica identity and the slot — in that order, because the identity
589
668
  check is worthless once the slot exists — creates the slot when there is none, and confirms the
@@ -591,12 +670,12 @@ principal.
591
670
  checked** (`pg-publication.ts`): missing, it is created `FOR TABLE` every entity table; present,
592
671
  it gains the entity tables it lacks (`ALTER PUBLICATION … ADD TABLE`) and loses none. `FOR TABLE`
593
672
  because a table's owner may publish it without a superuser; a role that may not is refused with
594
- `X_REPLICATION_FAILED` and the statement to run as one that may. `InMemoryChangeFeed` + `InProcessTransport` remain the
673
+ `X_REPLICATION_FAILED` and the statement to run as one that may. `memoryChangeFeed()` + `InProcessTransport` remain the
595
674
  defaults for `x dev` and every test. `START_REPLICATION` asks `messages 'true'`, which needs
596
675
  **Postgres ≥ 14**.
597
676
  - **A replication stream that ends is restarted** (24.0.0). `ChangeFeed.start` takes
598
677
  `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`
678
+ failed, the handler rejected — and never for a `stop()`. `changeFeedReplicator` answers it: `running`
600
679
  goes `false`, the feed is stopped, the advisory lock is released so a standby may take the slot,
601
680
  and the same process redials on `retryDelayMs(attempt)` (through `ReplicatorOptions.schedule`)
602
681
  until it holds the stream again or `stop()` is called. The restart resumes from the last lsn this
@@ -620,7 +699,7 @@ principal.
620
699
  mode, detail, slot, lock }`: `mode` is `'embedded' | 'external'`, `detail` is the env key that
621
700
  selected it and never a credential, and `lock` is the `AdvisoryLock` for that feed — built here
622
701
  rather than by the caller, because constructing one needs the URL and the URL carries a password.
623
- Neither `DATABASE_URL` nor `REPLICATION_URL` set → `InMemoryChangeFeed`,
702
+ Neither `DATABASE_URL` nor `REPLICATION_URL` set → `memoryChangeFeed()`,
624
703
  `mode: 'embedded'`. `REPLICATION_URL` wins when both are set, but naming a different host, port or
625
704
  database than `DATABASE_URL` is refused at boot with `X_CONFIG_INVALID` — a feed streaming the
626
705
  wrong database's WAL would be silently wrong forever. `REPLICATION_SLOT` (default `x_replicator`)
@@ -656,9 +735,9 @@ principal.
656
735
  - **A lock that is lost ends the stream** (24.0.0). The lock is a session, and a session can die
657
736
  while the replication stream stays up — Postgres then grants the lock to the next process that
658
737
  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
738
+ listener)` (required) reports it: `postgresAdvisoryLock()` watches its idle session through
660
739
  `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,
740
+ `changeFeedReplicator` answers exactly as it does a dead stream — `running` false, feed stopped,
662
741
  back into the takeover loop to compete for the lock again.
663
742
  - **A recovery asks nothing, and a stop waits a bounded time** (24.0.0). A dead stream or a lost
664
743
  lock calls `ChangeFeed.abandon()` and then `AdvisoryLock.abandon()` (both required, both
@@ -668,10 +747,10 @@ principal.
668
747
  been published, so a change the bus refuses every time backs off to `maxMs` instead of
669
748
  redialling at the base; `stats().failure` names its entity and lsn, and `running` stays `false`
670
749
  until it goes out.
671
- - **`PgAdvisoryLock` is the production `AdvisoryLock`** — `SELECT
750
+ - **`postgresAdvisoryLock()` is the production `AdvisoryLock`** — `SELECT
672
751
  pg_try_advisory_lock(hashtext('x:replicator:<slot>'))` on its own session. Session-scoped, so a
673
752
  crashed replicator releases it automatically: no lease renewal, no fencing token, no split brain.
674
- `InMemoryAdvisoryLock` remains the single-process default for `x dev` and tests.
753
+ `memoryAdvisoryLock()` remains the single-process default for `x dev` and tests.
675
754
  - **`selectTransport(env, realtime)` decides which transport a boot fans out on** — and since
676
755
  22.0.0 the CONFIG decides it, not the environment: `realtime` is `app.config.ts`'s
677
756
  `{ transport, urlEnv }`. It returns `{ transport, mode, detail, bucket, presenceTtlMs, connect }`:
@@ -694,7 +773,13 @@ principal.
694
773
  the library's own KV abstraction expresses neither a per-message TTL nor a batch direct get.
695
774
  Reconnect and re-subscription are the library's: a lost connection is re-established underneath
696
775
  the caller, which is what makes `sync` stateless, and the jitter that spreads a restart herd is
697
- handed to it as its reconnect delay rather than re-implemented above it.
776
+ handed to it as its reconnect delay rather than re-implemented above it. The one loss the
777
+ library does not recover is its own end: once its reconnect budget is spent it closes the
778
+ client for good and says so through the port's `onClosed`. `NatsTransport` then re-dials in the
779
+ background on the same backoff, one attempt at a time, until a client is up or `close()` runs;
780
+ callers meanwhile get `X_TRANSPORT_UNAVAILABLE` at once. Every live subscription is bound again
781
+ on the new client and the recovery is announced through `onReconnect`. Before 2026-10 the dead
782
+ client was handed out until the process was restarted.
698
783
  `transport.onReconnect(listener)` — a required member of `Transport` since 24.0.0 — announces
699
784
  each recovery and returns the unsubscribe: fanout is at-most-once, so whatever was published
700
785
  during the drop never arrived. `InProcessTransport` has no connection to lose and never calls it. The bucket needs
@@ -709,7 +794,7 @@ principal.
709
794
  holds no window: a DELETE is routed to the topic the OLD row's params name, and under the default
710
795
  identity that image is the key alone — no topic, no `remove`, and members keep the deleted
711
796
  record. Preflight asks the catalog about exactly those tables (`paramsChannelTables()`, or
712
- `PgLogicalReplicationOptions.fullIdentityTables`) and logs `replication.channel_identity_partial`
797
+ `PostgresChangeFeedOptions.fullIdentityTables`) and logs `replication.channel_identity_partial`
713
798
  with the fix `x db gen "replica identity full"`: `x db gen` grants FULL to those tables beside
714
799
  the live queries' `subscribes:` ones, so an app that declares a params channel owes one migration.
715
800
  - **For a live query, a keyed table does not need `REPLICA IDENTITY FULL`; a table with NO identity is warned**
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "24.0.0",
3
+ "version": "25.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",
7
7
  "sideEffects": [
8
- "./src/errors.ts"
8
+ "./src/error-titles.ts"
9
9
  ],
10
10
  "repository": {
11
11
  "type": "git",
@@ -19,6 +19,8 @@
19
19
  "exports": {
20
20
  ".": "./src/index.ts",
21
21
  "./boot": "./src/boot.ts",
22
+ "./page-runtime": "./src/page-runtime.ts",
23
+ "./page-runtime-wait": "./src/page-runtime-wait.ts",
22
24
  "./server": "./src/server.ts",
23
25
  "./sync-worker": "./src/sync-worker.ts"
24
26
  },
@@ -31,16 +33,17 @@
31
33
  "LICENSE"
32
34
  ],
33
35
  "engines": {
34
- "bun": ">=1.4.0"
36
+ "bun": ">=1.4.2"
35
37
  },
36
38
  "scripts": {
37
39
  "typecheck": "tsc --noEmit -p tsconfig.json",
38
40
  "test": "bun test"
39
41
  },
40
42
  "dependencies": {
41
- "@ultimat3/core": "24.0.0",
42
- "@ultimat3/entity": "24.0.0",
43
- "@ultimat3/query": "24.0.0",
43
+ "@ultimat3/core": "25.0.0",
44
+ "@ultimat3/entity": "25.0.0",
45
+ "@ultimat3/policy": "25.0.0",
46
+ "@ultimat3/query": "25.0.0",
44
47
  "nats": "2.29.3"
45
48
  }
46
49
  }
@@ -22,7 +22,7 @@ export interface AdvisoryLock {
22
22
  }
23
23
 
24
24
  /** Single-process default: correct for `x dev` and tests, useless across containers (by design). */
25
- export class InMemoryAdvisoryLock implements AdvisoryLock {
25
+ export class MemoryAdvisoryLock implements AdvisoryLock {
26
26
  static readonly #held = new Set<string>();
27
27
  readonly key: string;
28
28
  #mine = false;
@@ -32,21 +32,21 @@ export class InMemoryAdvisoryLock implements AdvisoryLock {
32
32
  }
33
33
 
34
34
  async tryAcquire(): Promise<boolean> {
35
- if (InMemoryAdvisoryLock.#held.has(this.key)) return false;
36
- InMemoryAdvisoryLock.#held.add(this.key);
35
+ if (MemoryAdvisoryLock.#held.has(this.key)) return false;
36
+ MemoryAdvisoryLock.#held.add(this.key);
37
37
  this.#mine = true;
38
38
  return true;
39
39
  }
40
40
 
41
41
  async release(): Promise<void> {
42
42
  if (!this.#mine) return;
43
- InMemoryAdvisoryLock.#held.delete(this.key);
43
+ MemoryAdvisoryLock.#held.delete(this.key);
44
44
  this.#mine = false;
45
45
  }
46
46
 
47
47
  abandon(): void {
48
48
  if (!this.#mine) return;
49
- InMemoryAdvisoryLock.#held.delete(this.key);
49
+ MemoryAdvisoryLock.#held.delete(this.key);
50
50
  this.#mine = false;
51
51
  }
52
52
 
@@ -55,3 +55,8 @@ export class InMemoryAdvisoryLock implements AdvisoryLock {
55
55
  return () => undefined;
56
56
  }
57
57
  }
58
+
59
+ /** The one way to build the in-process lock — the twin of `postgresAdvisoryLock()`; the class is a type in the barrel only (`X_FACTORY_NAME_SPELLING`). */
60
+ export function memoryAdvisoryLock(key: string): MemoryAdvisoryLock {
61
+ return new MemoryAdvisoryLock(key);
62
+ }
package/src/boot.ts CHANGED
@@ -1,13 +1,14 @@
1
- // `@ultimat3/realtime/boot` — the page's boot, ONE classic script per document (plan 101), built
2
- // and served by the CLI the way the sync worker is. It restores the principal's persisted records
3
- // and opens the outbox, so a reload replays what the previous load queued even on a page whose
4
- // islands only read. Here and not in `page-store.ts`, so no island bundle carries it.
1
+ // `@ultimat3/realtime/boot` — the page's boot, ONE classic script per document (plan 101), served
2
+ // by the CLI like the sync worker. It installs the page runtime (store, socket, host, channel book;
3
+ // islands await and read it, never carry it — #505), restores the principal's persisted records
4
+ // and opens the outbox, so a reload replays what the last load queued even if islands only read.
5
5
 
6
6
  import type { ClientScope } from '@ultimat3/core/page';
7
7
  import { pageClient } from '@ultimat3/core/page';
8
8
  import { pageLocalStore, scopeKey } from './local-store-idb';
9
9
  import { pageOutbox } from './page-outbox';
10
- import { BOOT_KEY, BOOT_RELEASE_KEY, type BootHost, pageRealtime } from './page-store';
10
+ import { installPageRuntime } from './page-runtime';
11
+ import { BOOT_KEY, BOOT_RELEASE_KEY, type BootHost } from './page-store';
11
12
  import { persistedTypes, type RecordPersister, recordPersister } from './record-persister';
12
13
  import type { RecordStore } from './record-store';
13
14
 
@@ -50,9 +51,12 @@ async function restoreFromDisk(store: RecordStore, principal: () => Principal):
50
51
  type Principal = ClientScope['principal'];
51
52
 
52
53
  /**
53
- * Once per page, whoever calls first; the promise is what `pageRealtime().booted` answers. The
54
- * scope is the page's own — a parameter only so a test can boot an unscoped page (an OBJECT, so an
55
- * explicit `undefined` principal is not mistaken for "use the default").
54
+ * Once per page, whoever calls first; the promise is what `pageRealtime().booted` answers — the
55
+ * one moment an island may treat the page's records as restored (#506 holds first paint on it).
56
+ * The runtime is installed synchronously, before the first await, so an island waiting on this
57
+ * script (`awaitPageRuntime`) is released by the script's `load` with the store already there.
58
+ * The scope is the page's own — a parameter only so a test can boot an unscoped page (an OBJECT,
59
+ * so an explicit `undefined` principal is not mistaken for "use the default").
56
60
  */
57
61
  export function bootPage(scope?: { readonly principal: Principal }): Promise<void> {
58
62
  const host = globalThis as BootHost;
@@ -61,10 +65,11 @@ export function bootPage(scope?: { readonly principal: Principal }): Promise<voi
61
65
  // Already booting — unless what sits there is an island's placeholder, which this boot claims.
62
66
  if (started !== undefined && waiting === undefined) return started;
63
67
  Reflect.deleteProperty(host, BOOT_RELEASE_KEY);
68
+ const page = installPageRuntime();
64
69
  // Asked each time, never captured: the page's principal is whatever `rescope()` last said.
65
70
  const principal = (): Principal =>
66
71
  scope === undefined ? pageClient().scope.principal : scope.principal;
67
- const booted = restoreFromDisk(pageRealtime().store, principal);
72
+ const booted = restoreFromDisk(page.store, principal);
68
73
  if (started !== undefined && waiting !== undefined) {
69
74
  void booted.then(waiting);
70
75
  return started;
@@ -1,15 +1,15 @@
1
1
  // Single responsibility: environment → change feed. The one place that decides which `ChangeFeed`
2
2
  // a boot installs, so `x dev`, a replicator container and any custom host resolve it identically.
3
- // `PgLogicalReplicationFeed` is useless until something constructs it from a connection string;
3
+ // `PostgresChangeFeed` is useless until something constructs it from a connection string;
4
4
  // this is that something, keyed on env rather than a config field so one image deploys everywhere.
5
5
 
6
6
  import type { Clock } from '@ultimat3/core';
7
7
  import { ConfigInvalidError } from '@ultimat3/core';
8
8
  import type { AdvisoryLock } from './advisory-lock';
9
- import { InMemoryAdvisoryLock } from './advisory-lock';
9
+ import { memoryAdvisoryLock } from './advisory-lock';
10
10
  import type { ChangeFeed } from './changefeed';
11
- import { InMemoryChangeFeed, PgLogicalReplicationFeed } from './changefeed';
12
- import { PgAdvisoryLock } from './pg-advisory-lock';
11
+ import { memoryChangeFeed, postgresChangeFeed } from './changefeed';
12
+ import { postgresAdvisoryLock } from './pg-advisory-lock';
13
13
  import type { PgTarget } from './pg-socket';
14
14
  import { parsePgUrl } from './pg-socket';
15
15
  import type { PgStream } from './pg-wire';
@@ -105,12 +105,12 @@ export function selectChangeFeed(
105
105
 
106
106
  if (url === undefined) {
107
107
  return {
108
- feed: new InMemoryChangeFeed(options.retain === undefined ? {} : { retain: options.retain }),
108
+ feed: memoryChangeFeed(options.retain === undefined ? {} : { retain: options.retain }),
109
109
  mode: 'embedded',
110
110
  detail: 'in-process change feed — set DATABASE_URL to decode a real WAL',
111
111
  slot: null,
112
112
  // Single-process mutual exclusion, which is all one process needs and all it can enforce.
113
- lock: new InMemoryAdvisoryLock(replicatorLockKey(DEFAULT_REPLICATION_SLOT)),
113
+ lock: memoryAdvisoryLock(replicatorLockKey(DEFAULT_REPLICATION_SLOT)),
114
114
  };
115
115
  }
116
116
 
@@ -122,7 +122,7 @@ export function selectChangeFeed(
122
122
  return {
123
123
  // Slot and publication are validated inside the feed, against the same identifier rule the
124
124
  // replication command needs — a second copy of that regex here is a second thing to keep true.
125
- feed: new PgLogicalReplicationFeed({
125
+ feed: postgresChangeFeed({
126
126
  url,
127
127
  slot,
128
128
  publication: nonEmpty(env['REPLICATION_PUBLICATION']) ?? DEFAULT_REPLICATION_PUBLICATION,
@@ -136,7 +136,7 @@ export function selectChangeFeed(
136
136
  slot,
137
137
  // Taken on the same database the feed reads, so the lock and the slot cannot end up in
138
138
  // different places — which is the only way "exactly one replicator" could quietly become two.
139
- lock: new PgAdvisoryLock({
139
+ lock: postgresAdvisoryLock({
140
140
  url,
141
141
  key: replicatorLockKey(slot),
142
142
  stream: options.stream,