@ultimat3/realtime 23.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.
- package/CLAUDE.md +118 -126
- package/README.md +229 -54
- package/package.json +9 -6
- package/src/advisory-lock.ts +62 -0
- package/src/boot.ts +25 -15
- package/src/change-buffer.ts +96 -41
- package/src/changefeed-env.ts +9 -9
- package/src/changefeed.ts +81 -19
- package/src/channel-authz.ts +48 -17
- package/src/channel-describe.ts +1 -1
- package/src/channel-latch.ts +52 -0
- package/src/channel-logs.ts +36 -15
- package/src/channel-presence.ts +3 -3
- package/src/channel-records.ts +35 -4
- package/src/channel-ref.ts +2 -2
- package/src/channel-registry.ts +2 -2
- package/src/channel-sids.ts +37 -0
- package/src/channel.ts +148 -48
- package/src/client-channels.ts +12 -1
- package/src/client-contract.ts +4 -1
- package/src/client-frames.ts +16 -3
- package/src/client-heartbeat.ts +39 -9
- package/src/client.ts +30 -2
- package/src/cursor.ts +11 -0
- package/src/error-titles.ts +91 -0
- package/src/errors.ts +113 -105
- package/src/fanout.ts +13 -0
- package/src/first-paint-hold.ts +49 -0
- package/src/idb-types.ts +2 -2
- package/src/index.ts +25 -7
- package/src/json.ts +16 -3
- package/src/live-contract.ts +14 -0
- package/src/live-definition.ts +34 -9
- package/src/live-deliver.ts +55 -0
- package/src/live-fanout.ts +122 -29
- package/src/live-floor.ts +35 -0
- package/src/live-query-options.ts +44 -0
- package/src/live-query.ts +96 -175
- package/src/live-reauth.ts +127 -0
- package/src/live-replicator.ts +28 -8
- package/src/live-resume.ts +44 -8
- package/src/live-rows.ts +32 -3
- package/src/live-spend.ts +50 -0
- package/src/local-store-idb.ts +5 -0
- package/src/matcher-bridge.ts +11 -5
- package/src/nats-client.ts +37 -6
- package/src/nats-lib-client.ts +36 -7
- package/src/nats-subscriptions.ts +63 -0
- package/src/nats-transport.ts +149 -15
- package/src/offline-queue.ts +53 -5
- package/src/page-errors.ts +25 -9
- package/src/page-outbox.ts +125 -54
- package/src/page-runtime-wait.ts +72 -0
- package/src/page-runtime.ts +32 -0
- package/src/page-socket.ts +7 -16
- package/src/page-store.ts +83 -16
- package/src/pg-advisory-lock.ts +54 -5
- package/src/pg-auth.ts +9 -4
- package/src/pg-bytes.ts +4 -1
- package/src/pg-connection.ts +126 -22
- package/src/pg-entity-row.ts +16 -0
- package/src/pg-identity-tables.ts +25 -0
- package/src/pg-preflight.ts +50 -4
- package/src/pg-replication-row.ts +82 -0
- package/src/pg-replication.ts +50 -51
- package/src/pg-socket.ts +26 -7
- package/src/pg-tls.ts +18 -0
- package/src/pg-values.ts +29 -14
- package/src/pg-wire.ts +5 -3
- package/src/pgoutput.ts +19 -9
- package/src/policy-gate.ts +7 -7
- package/src/presence.ts +87 -10
- package/src/principal-sockets.ts +75 -0
- package/src/query-window.ts +17 -5
- package/src/record-persister.ts +7 -2
- package/src/record-publisher.ts +178 -0
- package/src/record-store.ts +25 -4
- package/src/record-synced.ts +43 -1
- package/src/replication-errors.ts +11 -7
- package/src/replicator-envelope.ts +160 -0
- package/src/replicator-row.ts +75 -0
- package/src/replicator.ts +313 -191
- package/src/server.ts +45 -23
- package/src/socket-defaults.ts +20 -0
- package/src/socket-engine.ts +14 -2
- package/src/socket-idle.ts +3 -1
- package/src/socket-routes.ts +8 -3
- package/src/socket.ts +14 -19
- package/src/subscriber-gate.ts +11 -1
- package/src/subscription-book.ts +129 -71
- package/src/subscription-count.ts +151 -0
- package/src/sync-actor-change.ts +56 -0
- package/src/sync-auth.ts +47 -2
- package/src/sync-bus-handlers.ts +131 -0
- package/src/sync-frames.ts +74 -21
- package/src/sync-node-bounds.ts +16 -1
- package/src/sync-node-contract.ts +36 -4
- package/src/sync-node.ts +163 -82
- package/src/sync-origin.ts +27 -12
- package/src/sync-protocol.ts +86 -16
- package/src/sync-upgrade.ts +115 -8
- package/src/type-pins.ts +9 -1
- package/src/use-channel.ts +19 -14
- package/src/use-connection.ts +3 -3
- package/src/use-mutation.ts +41 -20
- package/src/use-query.ts +11 -11
- package/src/use-record.ts +89 -10
- package/src/wire-channel.ts +18 -9
- package/src/wire-read.ts +2 -2
- package/src/idb-fake.ts +0 -133
- package/src/policy-fake.ts +0 -14
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 }) {
|
|
@@ -58,8 +61,8 @@ half reaches the other; `barrel-split.test.ts` fails if one name is exported fro
|
|
|
58
61
|
Migrating from 7.x: an import of a **server** name changes its specifier and nothing else.
|
|
59
62
|
|
|
60
63
|
```
|
|
61
|
-
- import { ChannelHub,
|
|
62
|
-
+ import { ChannelHub,
|
|
64
|
+
- import { ChannelHub, syncNode, LiveQueryRegistry } from '@ultimat3/realtime';
|
|
65
|
+
+ import { ChannelHub, syncNode, LiveQueryRegistry } from '@ultimat3/realtime/server';
|
|
63
66
|
```
|
|
64
67
|
|
|
65
68
|
Client names — the hooks, `RecordStore`, `OfflineQueue`, `encode`/`decode`, every `X_*` error
|
|
@@ -70,20 +73,22 @@ class — stay on `.`.
|
|
|
70
73
|
| Concern | Entry | Export |
|
|
71
74
|
|---|---|---|
|
|
72
75
|
| tier 1 | `./server` | `ChannelHub`, `PresenceRegistry`, `SyncSocket`, `SocketRegistry` |
|
|
73
|
-
| tier 2 | `./server` | `LiveQueryRegistry`, `
|
|
74
|
-
| replication | `./server` | `parsePgUrl`, `bunPgStream`, `
|
|
75
|
-
| 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 |
|
|
76
79
|
| fanout | `./server` | `Transport`, `InProcessTransport`, `NatsTransport`, `selectTransport`, `subjectMatches` |
|
|
77
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 |
|
|
78
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 |
|
|
79
82
|
| the page's record store | `.` | `RecordStore` — one record per `type:key`, synced truth plus the optimistic overlay — `recordKey`, `LocalTx`, `RowWindows`, `applyPatches`/`orderAfterPatches` |
|
|
80
|
-
| the outbox | `.` | `OfflineQueue`, `
|
|
83
|
+
| the outbox | `.` | `OfflineQueue`, `memoryQueueStore()` — replayed over HTTP from plan 101 slice 12. The conflict vocabulary is `ConflictPolicy` from `@ultimat3/core`; realtime declares none |
|
|
81
84
|
| wire | `.` | `PROTOCOL_VERSION` (3), `encode`, `decode`, `Frame` |
|
|
82
|
-
| the node | `./server` | `
|
|
85
|
+
| the node | `./server` | `syncNode` / `listenSyncNode` (`sync` role) |
|
|
83
86
|
| a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
|
|
84
|
-
| 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)) |
|
|
85
89
|
| channels | `.` | `channel`, `channelRef`, `ChannelHandle`, `topic`, `readPresence`, the channel frame types |
|
|
86
|
-
|
|
|
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()` |
|
|
87
92
|
| the socket's worker | `./sync-worker` | the SharedWorker entry — no exports |
|
|
88
93
|
|
|
89
94
|
## `channelRef` — a channel an island can hold
|
|
@@ -116,13 +121,72 @@ is `X_CHANNEL_DECLARATION_INVALID` at declaration. `catchUp` is read on every ac
|
|
|
116
121
|
whose name `registerQueries()` stamps at boot is picked up. The reference app's
|
|
117
122
|
[`app/posts/channel-ref.ts`](../../examples/dummy/apps/web/app/posts/channel-ref.ts) is the idiom.
|
|
118
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
|
+
|
|
119
180
|
## The hooks
|
|
120
181
|
|
|
121
182
|
One record store and one socket per **page**, on `globalThis`: every island is its own bundle, so a
|
|
122
|
-
module-level singleton would be one per island.
|
|
123
|
-
|
|
124
|
-
an island
|
|
125
|
-
|
|
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.
|
|
126
190
|
|
|
127
191
|
```ts
|
|
128
192
|
import {
|
|
@@ -135,6 +199,7 @@ import {
|
|
|
135
199
|
usePresence,
|
|
136
200
|
useQuery,
|
|
137
201
|
useRecord,
|
|
202
|
+
useRecords,
|
|
138
203
|
} from '@ultimat3/realtime';
|
|
139
204
|
|
|
140
205
|
declare const orgId: string;
|
|
@@ -142,10 +207,16 @@ declare const postId: string;
|
|
|
142
207
|
declare const LIKE_POST: MutatorLike; // name + local twin + conflict — never the mutator VALUE
|
|
143
208
|
declare const orgFeed: ChannelRef<'orgId'>; // a `channel('org-feed', { params: ['orgId'], … })`
|
|
144
209
|
declare function onEvent(event: Readonly<Record<string, unknown>>): void;
|
|
210
|
+
type Run = { readonly id: string; readonly state: string; readonly startedAt: string };
|
|
145
211
|
|
|
146
212
|
const feed = useQuery({ name: 'liveFeed', live: true }, { orgId }); // AsyncState<readonly Row[]>
|
|
147
213
|
const posts = useQuery({ name: 'listPosts', entity: 'posts' }, {}); // one HTTP read, rows as records
|
|
148
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
|
+
});
|
|
149
220
|
const like = useMutation(LIKE_POST); // await like(input); like.pending
|
|
150
221
|
const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
|
|
151
222
|
const writes = useMutationQueue(); // .pending .failed
|
|
@@ -158,8 +229,9 @@ const room = usePresence(orgFeed, { orgId }); // the channe
|
|
|
158
229
|
same engine runs in-page. Writes that find no network go to the page's outbox — overlay kept — and
|
|
159
230
|
replay over HTTP, under their original idempotency keys, when the socket comes back. The outbox is
|
|
160
231
|
the page boot's (`@ultimat3/realtime/boot`): an island only reads it off the page. On a page the
|
|
161
|
-
CLI renders no boot for (no scope tag, so nothing on it persists)
|
|
162
|
-
other — rejected, overlay taken back — never held
|
|
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.
|
|
163
235
|
|
|
164
236
|
`<AsyncRegion state={feed()} …/>` takes the answer as-is: `AsyncState` is `@ultimat3/core`'s, the
|
|
165
237
|
same type `@ultimat3/ui` renders.
|
|
@@ -168,6 +240,7 @@ same type `@ultimat3/ui` renders.
|
|
|
168
240
|
|---|---|
|
|
169
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) |
|
|
170
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) |
|
|
171
244
|
| Every write is HTTP through core's `clientTransport` | one write path: the action's authz, idempotency and contract; the socket carries none |
|
|
172
245
|
| The answer's records are adopted before the overlay goes | a convergent twin never flickers back to the pre-write value |
|
|
173
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 |
|
|
@@ -182,7 +255,7 @@ asks.
|
|
|
182
255
|
|
|
183
256
|
## Who a socket is
|
|
184
257
|
|
|
185
|
-
`
|
|
258
|
+
`syncNode({ authenticate })` is the one place a websocket gets an identity. It runs on the
|
|
186
259
|
upgrade **before** `server.upgrade`, so a refused credential never costs a socket, and the actor it
|
|
187
260
|
resolves is what every policy downstream decides against — the topic guard, `authorize`, `visible`,
|
|
188
261
|
the per-tenant subscription cap.
|
|
@@ -196,7 +269,7 @@ declare function sessionFrom(
|
|
|
196
269
|
): Promise<{ actor: Actor; expiresAt: number; token: string } | null>;
|
|
197
270
|
declare function renew(token: string): Promise<SyncGrant | null>;
|
|
198
271
|
|
|
199
|
-
// The one option this section is about; `
|
|
272
|
+
// The one option this section is about; `syncNode({ …, authenticate })` takes it.
|
|
200
273
|
const options: Pick<SyncNodeOptions, 'authenticate'> = {
|
|
201
274
|
// From @ultimat3/auth, or anywhere else: `sync` imports no authenticator, exactly as it owns no
|
|
202
275
|
// business logic. `refresh` is yours too, so the framework retains no credential of its own.
|
|
@@ -227,7 +300,7 @@ the socket and retries next pass — a token service timing out is not a revocat
|
|
|
227
300
|
`hub.guard('org.*.feed', ({ actor }) => actor?.orgId === …)` denies everyone and the only guard that
|
|
228
301
|
lets anything through is one that reads no actor at all.
|
|
229
302
|
|
|
230
|
-
Authz goes through `@ultimat3/query`'s `
|
|
303
|
+
Authz goes through `@ultimat3/query`'s `guardQuery`, which is the only contact with `@ultimat3/policy`.
|
|
231
304
|
One authz system, never two: `policy` is evaluated **once per subscriber**, never once per query.
|
|
232
305
|
Two actors on one live query get two different result sets, and a row that leaves an actor's policy
|
|
233
306
|
is delivered to them as a `delete` — never as silence.
|
|
@@ -266,18 +339,21 @@ wire.
|
|
|
266
339
|
|
|
267
340
|
| Ceiling | Default | Option | Refused with |
|
|
268
341
|
|---|---|---|---|
|
|
269
|
-
| concurrent sockets on this node | 250,000 | `
|
|
270
|
-
| inbound bytes per frame | 256 KiB | `
|
|
271
|
-
| inbound frames per socket | 64/s, burst 256 | `
|
|
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` |
|
|
272
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` |
|
|
273
348
|
| live subscriptions per tenant | unset | `new LiveQueryRegistry({ maxPerTenant, tenantOf })` — **both**, or it arms nothing | `X_SUBSCRIPTION_LIMIT` |
|
|
274
349
|
| distinct `(query, input)` pairs per node | 10,000 | `new LiveQueryRegistry({ maxEntries })` | `X_SUBSCRIPTION_LIMIT` |
|
|
275
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 |
|
|
276
351
|
| channel topics per socket | 64 | `new ChannelHub({ maxTopicsPerSocket })` | `X_SUBSCRIPTION_LIMIT` |
|
|
277
352
|
| distinct channel topics per node | 10,000 | `new ChannelHub({ maxTopicsPerNode })` | `X_SUBSCRIPTION_LIMIT` |
|
|
278
|
-
| outbound bytes buffered on one socket | 1 MiB | `
|
|
279
|
-
| dropped frames before that socket is closed | 32 | `
|
|
280
|
-
| time one socket may route no frame | 120s | `
|
|
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 |
|
|
281
357
|
| retained patch bytes per node | 64 MiB | `new RingChangeBuffer({ maxBytes, maxBytesPerQuery })` | eviction, then a re-snapshot on resume |
|
|
282
358
|
| array lengths and `input` nesting in a frame | `FRAME_LIMITS` | none — a hard ceiling | `X_PROTOCOL_VERSION` |
|
|
283
359
|
|
|
@@ -314,6 +390,7 @@ over it, and an HTTP answer's records, a socket patch and an optimistic write al
|
|
|
314
390
|
| The live path names the type server-side (`recordTypeForTable`) | a changefeed speaks tables; the store speaks entities |
|
|
315
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 |
|
|
316
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 |
|
|
317
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 |
|
|
318
395
|
| A structurally bad row is `X_RECORD_REJECTED`, dropped and reported | never partially merged — a keyless row would overwrite another record |
|
|
319
396
|
| The last holder leaving evicts the record | an infinite scroll must not retain every row it ever saw |
|
|
@@ -350,15 +427,32 @@ credential drained the bucket and every signed-in reconnect behind it was shed.
|
|
|
350
427
|
behind an ingress every dial has the ingress's address, and a forwarded header is the caller's own
|
|
351
428
|
claim.
|
|
352
429
|
|
|
353
|
-
**A socket from
|
|
430
|
+
**A socket from another origin is refused** — `403 X_SOCKET_ORIGIN_REFUSED`, before `authenticate`
|
|
354
431
|
and before the budget. A websocket carries the session cookie and no CORS applies to it, so a page on
|
|
355
432
|
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,
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
`
|
|
433
|
+
rule is `@ultimat3/core`'s `proveSameOrigin`, the one `@ultimat3/http`'s CSRF check asks, and what
|
|
434
|
+
is admitted is decided in `sync-origin.ts`:
|
|
435
|
+
|
|
436
|
+
| Admitted | When |
|
|
437
|
+
|---|---|
|
|
438
|
+
| no `Origin` header | always — RFC 6455 has every browser send one, so its absence is not a browser |
|
|
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" |
|
|
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 |
|
|
441
|
+
|
|
442
|
+
The CLI passes `APP_URL`'s origin, which the Compose rung (`:3000` / `:3001`) requires on `sync`
|
|
443
|
+
and the Helm chart sets from `ingress.host`. Under `x dev` it also adds the web role's own origin
|
|
444
|
+
and its `localhost`, `127.0.0.1` and `[::1]` spellings to a declared list, so `x dev --port 4000`
|
|
445
|
+
beside an `.env` naming `:3000` still connects, and passes `admitReachedOrigin: true`, which keeps
|
|
446
|
+
the origin the node was reached on beside the list (a Codespace, a tunnel: `x dev` binds loopback,
|
|
447
|
+
so a `Host` reaches it only through a forward its own user set up); a container adds nothing, and a wrong `APP_URL`
|
|
448
|
+
there refuses. The refusal's cause names the origin that asked and every origin the node admits;
|
|
449
|
+
its fix is the `export APP_URL=…` that admits the asker.
|
|
450
|
+
|
|
451
|
+
**The node's `/healthz` and `/readyz` tell the detail to a listed peer only**
|
|
452
|
+
(`syncNode({ healthDetailPeers })`, default the box itself). A request carrying `Forwarded`,
|
|
453
|
+
`X-Forwarded-For`, `X-Forwarded-Host`, `X-Forwarded-Proto`, `X-Real-IP` or `Via` is told the
|
|
454
|
+
verdict only. A proxy that adds none of them is indistinguishable from a direct peer: a
|
|
455
|
+
header-less proxy must not route the health paths.
|
|
362
456
|
|
|
363
457
|
The client dials itself back. A closed socket arms one timer — the node's delay when a `reconnect`
|
|
364
458
|
frame assigned one, otherwise `@ultimat3/core`'s `backoffDelay()` on the client's `BackoffPolicy` — and that timer calls `connect()`, which re-subscribes
|
|
@@ -384,7 +478,7 @@ below (`heartbeatMs` on the internal `LiveClient`; `0` disables the pass).
|
|
|
384
478
|
|
|
385
479
|
| Property | Behaviour |
|
|
386
480
|
|---|---|
|
|
387
|
-
| Default | `DEFAULT_HEARTBEAT_MS`,
|
|
481
|
+
| 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
482
|
| 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
483
|
| 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
484
|
| 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 +507,10 @@ wire twice by a reconnect that raced an ack.
|
|
|
413
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 |
|
|
414
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` |
|
|
415
509
|
|
|
510
|
+
`localOutbox({ warn })` — where a disk that refused the queue is said
|
|
511
|
+
(`X_LOCAL_STORE_UNAVAILABLE`, default `console.warn`); the outbox then queues in memory for that
|
|
512
|
+
principal.
|
|
513
|
+
|
|
416
514
|
### Limits, stated plainly
|
|
417
515
|
|
|
418
516
|
- **The change window is per node, and a `qid` window can only be.** A client that reconnects to a
|
|
@@ -421,16 +519,29 @@ wire twice by a reconnect that raced an ack.
|
|
|
421
519
|
matcher and no window, so it cannot produce one. What the snapshot path costs is one **shared**
|
|
422
520
|
read per (query, node), not one per client. A cross-node delta needs an *entity*-keyed window each
|
|
423
521
|
node fills from the change stream it already subscribes to, which is a `ResumeSource` shape change.
|
|
522
|
+
- **A ring is complete from its floor, and never from the start of time** (24.0.0). A
|
|
523
|
+
`RingChangeBuffer` ring is born with one: the lsn of its first patch, or the lsn its window was
|
|
524
|
+
read at (`floorAt(qid, lsn)`, which only ever raises it and drops what a re-read superseded). A
|
|
525
|
+
cursor below the floor re-snapshots. A ring used to be born with none, so a cursor minted on
|
|
526
|
+
another node resumed as a delta over changes this node never retained.
|
|
424
527
|
- **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
|
|
528
|
+
every published change with `producer` + `seq`; a `sync` node that sees a skipped sequence —
|
|
529
|
+
or, since 24.0.0, a new `producer` after one it already read: the run before it died, and the
|
|
530
|
+
tail of a dead stream has no later sequence to be missed against —
|
|
426
531
|
invalidates every window it holds and desyncs every subscriber, so the next change to each query
|
|
427
|
-
re-reads and re-snapshots
|
|
428
|
-
|
|
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,
|
|
429
538
|
storage and replay window — and is deliberately not this mechanism.
|
|
430
539
|
- **A bulk write re-reads only the windows over its entity.** `updateWhere`/`deleteWhere` name a
|
|
431
540
|
filter, not rows, so the in-process replicator calls `registry.invalidate(entity)`: windows whose
|
|
432
541
|
shape reads that entity go stale and their subscribers desynced. The next change re-reads the
|
|
433
|
-
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.
|
|
434
545
|
- **`desynced` has a reader.** A subscriber recorded as diverged — a dropped patch, a gate that
|
|
435
546
|
failed, a window that lost its tail — is served a fresh snapshot out of the shared window on the
|
|
436
547
|
next delivery, and only then is the mark cleared. A snapshot the socket refuses leaves it
|
|
@@ -449,17 +560,17 @@ wire twice by a reconnect that raced an ack.
|
|
|
449
560
|
readers that throw on an absent field, unlike the `list()` that made `hello.resume`'s removal free.
|
|
450
561
|
- **Backpressure drops patch frames.** That is safe *only* because a re-snapshot is cheap: the drop
|
|
451
562
|
is recorded on the socket (`desynced`) and the next delivery re-snapshots rather than diverging.
|
|
452
|
-
- **A dropped
|
|
453
|
-
|
|
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
|
|
454
566
|
`channel_frames_dropped_total` (no labels: a topic is client-chosen, so a per-topic label is
|
|
455
567
|
unbounded series one socket can mint), the log line `channel.frames_dropped` at `warn` carrying
|
|
456
568
|
`{ topic, dropped, total }`, and `node.sockets.droppedChannelFrames` for a test or a benchmark
|
|
457
569
|
that cannot scrape. Node-wide and cumulative, because a socket past `maxDroppedFrames` is closed
|
|
458
570
|
and removed — a per-socket count leaves exactly when loss is worst. Distinct from
|
|
459
571
|
`SyncSocket.droppedFrames`, which counts every kind of frame one connection lost and dies with it.
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
**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.**
|
|
463
574
|
- **Bun's native WS pub/sub is not used.** `subscribeTopic` does not call `ws.subscribe` and the
|
|
464
575
|
websocket config declares no `publishToSelf`; every channel message is one filtered `send` per
|
|
465
576
|
socket through `SocketRegistry.deliver`, reading a per-topic index rather than walking the socket
|
|
@@ -485,9 +596,11 @@ wire twice by a reconnect that raced an ack.
|
|
|
485
596
|
the new node never held, so the resume falls back correctly rather than silently; the `qidOf`
|
|
486
597
|
removal itself costs nothing, because every qid a node computes comes from a decoded frame and
|
|
487
598
|
`JSON.parse` produces none of the values the two spellings disagreed about.
|
|
488
|
-
- **A topic guard that *fails*
|
|
489
|
-
(`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures
|
|
490
|
-
|
|
599
|
+
- **A topic guard that *fails* suspends the topic.** On the re-auth pass, only a denial
|
|
600
|
+
(`X_TOPIC_FORBIDDEN`, or a policy denial) unsubscribes; anything else increments `hub.guardFailures`,
|
|
601
|
+
logs `channel.guard_failed` and SUSPENDS the seat — kept, and sent nothing, until a pass
|
|
602
|
+
succeeds. Two answers are denials before any rule runs: a `null` actor on a channel with a `row`
|
|
603
|
+
loader, and a loader the tenant guard refused. `catch { unsubscribe }` reported a store that timed out as a
|
|
491
604
|
revoked grant — every topic on every re-authenticated socket, silently, with the client never told
|
|
492
605
|
to resubscribe. The initial `subscribe` is deliberately not split that way: there is no
|
|
493
606
|
subscription to keep, so a guard that raises refuses that subscribe and the client hears about it.
|
|
@@ -516,7 +629,8 @@ wire twice by a reconnect that raced an ack.
|
|
|
516
629
|
- **A full presence frame is capped** at `maxMembers` (256) and carries `total`, so a 5,000-person
|
|
517
630
|
room renders "and 4,744 others" instead of shipping 5,000 members to every joiner. The set itself
|
|
518
631
|
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
|
|
632
|
+
through the shared store, rather than every node re-reading every room it has ever seen; the
|
|
633
|
+
leader's lease carries its roster, so a takeover announces a dead leader's members.
|
|
520
634
|
- **Deliveries are serialized per query id, not per node.** A change is fanned out inside that
|
|
521
635
|
query's own FIFO lane, so two changes off the bus cannot interleave: the window one of them
|
|
522
636
|
writes is the window every subscriber's gate reads, and patch frames leave in lsn order. Every
|
|
@@ -548,7 +662,7 @@ wire twice by a reconnect that raced an ack.
|
|
|
548
662
|
every subscriber learned the id and the instant of every *other* tenant's row as it was deleted,
|
|
549
663
|
on a query whose `visible` rule had never let them see one. A subscriber that holds the row is
|
|
550
664
|
told it is gone; one that does not gets nothing, counted as `rowsDenied`.
|
|
551
|
-
- **`
|
|
665
|
+
- **`postgresChangeFeed()` decodes `pgoutput` off a real slot** — its own Postgres v3 client
|
|
552
666
|
(SCRAM-SHA-256, in-band TLS, CopyBoth), no driver dependency. It preflights `wal_level`, the
|
|
553
667
|
publication, every entity's replica identity and the slot — in that order, because the identity
|
|
554
668
|
check is worthless once the slot exists — creates the slot when there is none, and confirms the
|
|
@@ -556,14 +670,36 @@ wire twice by a reconnect that raced an ack.
|
|
|
556
670
|
checked** (`pg-publication.ts`): missing, it is created `FOR TABLE` every entity table; present,
|
|
557
671
|
it gains the entity tables it lacks (`ALTER PUBLICATION … ADD TABLE`) and loses none. `FOR TABLE`
|
|
558
672
|
because a table's owner may publish it without a superuser; a role that may not is refused with
|
|
559
|
-
`X_REPLICATION_FAILED` and the statement to run as one that may. `
|
|
560
|
-
defaults for `x dev` and every test.
|
|
673
|
+
`X_REPLICATION_FAILED` and the statement to run as one that may. `memoryChangeFeed()` + `InProcessTransport` remain the
|
|
674
|
+
defaults for `x dev` and every test. `START_REPLICATION` asks `messages 'true'`, which needs
|
|
675
|
+
**Postgres ≥ 14**.
|
|
676
|
+
- **A replication stream that ends is restarted** (24.0.0). `ChangeFeed.start` takes
|
|
677
|
+
`onEnd(reason)`, called when the pump dies on its own — the walsender ended the copy, a decode
|
|
678
|
+
failed, the handler rejected — and never for a `stop()`. `changeFeedReplicator` answers it: `running`
|
|
679
|
+
goes `false`, the feed is stopped, the advisory lock is released so a standby may take the slot,
|
|
680
|
+
and the same process redials on `retryDelayMs(attempt)` (through `ReplicatorOptions.schedule`)
|
|
681
|
+
until it holds the stream again or `stop()` is called. The restart resumes from the last lsn this
|
|
682
|
+
process published, so one rejected `transport.publish` costs a redial and loses nothing.
|
|
683
|
+
`stats()` carries `restarts` and `failure`; **`replicator.running` is what a readiness check
|
|
684
|
+
reads**. Until 24.0.0 the death was recorded in `stats().failure`, which nothing read.
|
|
685
|
+
- **A row crosses the bus as the row it was** (`replicator-row.ts`, 24.0.0). The bus carries text;
|
|
686
|
+
`parseEnvelope` revives each value through the column its entity declared (`entityForTable` +
|
|
687
|
+
`$parse`) — a `timestamp()` is a `Date` again, `bytes()` (sent as base64) a `Uint8Array` — and
|
|
688
|
+
decides by that declaration, never by what a value looks like. A table with no entity on the
|
|
689
|
+
node, and a value its column refuses, cross as they arrived. Until 24.0.0 a `Date` reached the
|
|
690
|
+
matcher as an ISO string and every window ordered by a timestamp mis-sorted on any deployment
|
|
691
|
+
with a bus.
|
|
692
|
+
- **`ChangeEvent.omitted` names what an update could not carry** (24.0.0). Postgres logs no bytes
|
|
693
|
+
for an out-of-line (TOAST) value an `UPDATE` did not touch. Under `REPLICA IDENTITY FULL` the
|
|
694
|
+
value is copied from the old tuple and `after` is whole; under any other identity `after` lacks
|
|
695
|
+
the property and `omitted` lists it (entity property names), so a consumer re-reads the row
|
|
696
|
+
rather than adopting a partial one.
|
|
561
697
|
- **`selectChangeFeed(env, { entities })` decides which feed a boot installs** — same law
|
|
562
698
|
`selectMailDriver` follows: an unset variable means the embedded default. It returns `{ feed,
|
|
563
699
|
mode, detail, slot, lock }`: `mode` is `'embedded' | 'external'`, `detail` is the env key that
|
|
564
700
|
selected it and never a credential, and `lock` is the `AdvisoryLock` for that feed — built here
|
|
565
701
|
rather than by the caller, because constructing one needs the URL and the URL carries a password.
|
|
566
|
-
Neither `DATABASE_URL` nor `REPLICATION_URL` set → `
|
|
702
|
+
Neither `DATABASE_URL` nor `REPLICATION_URL` set → `memoryChangeFeed()`,
|
|
567
703
|
`mode: 'embedded'`. `REPLICATION_URL` wins when both are set, but naming a different host, port or
|
|
568
704
|
database than `DATABASE_URL` is refused at boot with `X_CONFIG_INVALID` — a feed streaming the
|
|
569
705
|
wrong database's WAL would be silently wrong forever. `REPLICATION_SLOT` (default `x_replicator`)
|
|
@@ -580,6 +716,14 @@ wire twice by a reconnect that raced an ack.
|
|
|
580
716
|
`X_REPLICATION_TLS` naming the check. Until 22.1.0 `prefer` verified — every private-CA server
|
|
581
717
|
(CNPG) failed as a refused write — and the raw socket kept feeding ciphertext to the reader
|
|
582
718
|
after the upgrade, so a trusted CA still hung the stream.
|
|
719
|
+
- **A password the network could use is sent only when the mode allows it** (24.0.0): a
|
|
720
|
+
cleartext or md5 request is answered under `require`, `verify-ca`, `verify-full` (the session is
|
|
721
|
+
guaranteed encrypted) or `disable` (the operator said it is not), and refused with
|
|
722
|
+
`X_REPLICATION_FAILED` under `prefer` — the default — and `allow`, where a stripped upgrade falls
|
|
723
|
+
back to cleartext without a word. SCRAM is answered under every mode. `require` encrypts to
|
|
724
|
+
whoever answered and verifies no certificate: it protects the password from a passive listener
|
|
725
|
+
only. An attacker on the path needs `verify-ca` or `verify-full` to be stopped; `require`
|
|
726
|
+
answers a cleartext or md5 request for libpq parity, not because it is safe against one.
|
|
583
727
|
- **`REPLICATION` is a cluster-wide grant.** A `replication=database` session may also run
|
|
584
728
|
`BASE_BACKUP` and `START_REPLICATION PHYSICAL` with no database check, so on a shared Postgres
|
|
585
729
|
cluster the role could copy every database, `pg_authid` included, drop other slots and exhaust
|
|
@@ -588,10 +732,25 @@ wire twice by a reconnect that raced an ack.
|
|
|
588
732
|
`REPLICATION` to an app role on a shared cluster. Every fix line that hands the grant over says
|
|
589
733
|
so (`REPLICATION_GRANT_WARNING`, `pg-wire.ts`) and links
|
|
590
734
|
[`docs/ops/01-kubernetes.md`](../../docs/ops/01-kubernetes.md#replication-is-a-cluster-wide-grant).
|
|
591
|
-
-
|
|
735
|
+
- **A lock that is lost ends the stream** (24.0.0). The lock is a session, and a session can die
|
|
736
|
+
while the replication stream stays up — Postgres then grants the lock to the next process that
|
|
737
|
+
asks, and a holder that kept streaming would be one of two replicators. `AdvisoryLock.onLost(
|
|
738
|
+
listener)` (required) reports it: `postgresAdvisoryLock()` watches its idle session through
|
|
739
|
+
`PgConnection.watchIdle` (EOF, a read error or a server `FATAL`; no polling), and
|
|
740
|
+
`changeFeedReplicator` answers exactly as it does a dead stream — `running` false, feed stopped,
|
|
741
|
+
back into the takeover loop to compete for the lock again.
|
|
742
|
+
- **A recovery asks nothing, and a stop waits a bounded time** (24.0.0). A dead stream or a lost
|
|
743
|
+
lock calls `ChangeFeed.abandon()` and then `AdvisoryLock.abandon()` (both required, both
|
|
744
|
+
synchronous): the sockets are closed outright with no goodbye written, the feed first so it is
|
|
745
|
+
silent before the lock is given up. `stop()` asks politely and gives each goodbye
|
|
746
|
+
`STOP_DEADLINE_MS` (5 s) before abandoning it. The restart delay resets only once a change has
|
|
747
|
+
been published, so a change the bus refuses every time backs off to `maxMs` instead of
|
|
748
|
+
redialling at the base; `stats().failure` names its entity and lsn, and `running` stays `false`
|
|
749
|
+
until it goes out.
|
|
750
|
+
- **`postgresAdvisoryLock()` is the production `AdvisoryLock`** — `SELECT
|
|
592
751
|
pg_try_advisory_lock(hashtext('x:replicator:<slot>'))` on its own session. Session-scoped, so a
|
|
593
752
|
crashed replicator releases it automatically: no lease renewal, no fencing token, no split brain.
|
|
594
|
-
`
|
|
753
|
+
`memoryAdvisoryLock()` remains the single-process default for `x dev` and tests.
|
|
595
754
|
- **`selectTransport(env, realtime)` decides which transport a boot fans out on** — and since
|
|
596
755
|
22.0.0 the CONFIG decides it, not the environment: `realtime` is `app.config.ts`'s
|
|
597
756
|
`{ transport, urlEnv }`. It returns `{ transport, mode, detail, bucket, presenceTtlMs, connect }`:
|
|
@@ -614,7 +773,16 @@ wire twice by a reconnect that raced an ack.
|
|
|
614
773
|
the library's own KV abstraction expresses neither a per-message TTL nor a batch direct get.
|
|
615
774
|
Reconnect and re-subscription are the library's: a lost connection is re-established underneath
|
|
616
775
|
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
|
|
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.
|
|
783
|
+
`transport.onReconnect(listener)` — a required member of `Transport` since 24.0.0 — announces
|
|
784
|
+
each recovery and returns the unsubscribe: fanout is at-most-once, so whatever was published
|
|
785
|
+
during the drop never arrived. `InProcessTransport` has no connection to lose and never calls it. The bucket needs
|
|
618
786
|
nats-server ≥ 2.11 (batch direct get, per-message TTL); an older one is `X_TRANSPORT_PROTOCOL` on
|
|
619
787
|
the first dial, never a retry loop, because no amount of reconnecting makes a server newer.
|
|
620
788
|
- **The lsn is `<commit position><row position in the transaction>`, 24 hex characters.** Neither
|
|
@@ -622,7 +790,14 @@ wire twice by a reconnect that raced an ack.
|
|
|
622
790
|
*transactions* in commit order, so per-record WAL positions are not monotonic across them. The
|
|
623
791
|
pair sorts in delivery order and is byte-identical on replay, which is what turns at-least-once
|
|
624
792
|
redelivery into a drop instead of a duplicate.
|
|
625
|
-
- **A
|
|
793
|
+
- **A channel with params needs `REPLICA IDENTITY FULL` on its `records` tables** (24.0.0). It
|
|
794
|
+
holds no window: a DELETE is routed to the topic the OLD row's params name, and under the default
|
|
795
|
+
identity that image is the key alone — no topic, no `remove`, and members keep the deleted
|
|
796
|
+
record. Preflight asks the catalog about exactly those tables (`paramsChannelTables()`, or
|
|
797
|
+
`PostgresChangeFeedOptions.fullIdentityTables`) and logs `replication.channel_identity_partial`
|
|
798
|
+
with the fix `x db gen "replica identity full"`: `x db gen` grants FULL to those tables beside
|
|
799
|
+
the live queries' `subscribes:` ones, so an app that declares a params channel owes one migration.
|
|
800
|
+
- **For a live query, a keyed table does not need `REPLICA IDENTITY FULL`; a table with NO identity is warned**
|
|
626
801
|
(`As of 2026-09-23`). Under DEFAULT an update carries no old tuple and a delete only the key, and
|
|
627
802
|
neither decides a live query: the shared window holds the whole row, so a row leaving the result
|
|
628
803
|
set is decided from the window and a delete by `holds(id)` — proved on real WAL by
|
|
@@ -685,7 +860,7 @@ a job boundary the class is gone and the `code` is what survives — match on th
|
|
|
685
860
|
| `FrameRateLimitError` (extends `RealtimeError`) | `X_FRAME_RATE_LIMIT` | `src/errors.ts` |
|
|
686
861
|
| `LiveQueryUnknownError` (extends `RealtimeError`) | `X_LIVE_QUERY_UNKNOWN` | `src/errors.ts` |
|
|
687
862
|
| `LiveRowUnidentifiedError` (extends `RealtimeError`) | `X_LIVE_ROW_UNIDENTIFIED` | `src/errors.ts` |
|
|
688
|
-
| `
|
|
863
|
+
| `OfflineQueueAbandonedError` (extends `RealtimeError`) | `X_OFFLINE_QUEUE_ABANDONED` | `src/page-errors.ts` |
|
|
689
864
|
| `ProtocolVersionError` (extends `RealtimeError`) | `X_PROTOCOL_VERSION` | `src/page-errors.ts` |
|
|
690
865
|
| `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
866
|
| `RealtimeUninstalledError` (extends `RealtimeError`) | `X_REALTIME_UNINSTALLED` | `src/page-errors.ts` |
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "
|
|
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/
|
|
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.
|
|
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": "
|
|
42
|
-
"@ultimat3/entity": "
|
|
43
|
-
"@ultimat3/
|
|
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
|
}
|