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