@ultimat3/realtime 10.0.0 → 11.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 +66 -6
- package/README.md +22 -3
- package/package.json +3 -3
- package/src/client-contract.ts +23 -0
- package/src/client.ts +1 -0
- package/src/drain-evictions.ts +31 -0
- package/src/errors.ts +39 -96
- package/src/hooks.ts +38 -5
- package/src/index.ts +4 -0
- package/src/realtime-error.ts +28 -0
- package/src/replication-errors.ts +87 -0
- package/src/server-render-client.ts +96 -0
- package/src/sync-node.ts +23 -7
- package/src/sync-upgrade.ts +15 -6
- package/src/type-pins.ts +12 -1
package/CLAUDE.md
CHANGED
|
@@ -27,6 +27,14 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
27
27
|
type-only export leaves no runtime entry). `errors.ts` is deliberately whole on `.`: every code
|
|
28
28
|
reaches the wire through `toWireError`, so a client must be able to name any of them, and the
|
|
29
29
|
module is already in the client graph via `sync-protocol`.
|
|
30
|
+
- **`errors.ts` is the code TABLE plus the client-reachable refusals; two neighbours hold the rest,
|
|
31
|
+
and every name is still exported from `./errors`** (2026-08-23, at the 500-line ceiling).
|
|
32
|
+
`realtime-error.ts` holds the base class alone and `replication-errors.ts` the four Postgres ones
|
|
33
|
+
— the only codes no browser can reach. The base needs its own module rather than a re-export:
|
|
34
|
+
`extends` runs at module evaluation and imports hoist above it, so a `replication-errors` that
|
|
35
|
+
imported the base back out of `errors.ts` would read it in its temporal dead zone. Neither
|
|
36
|
+
neighbour runs anything at import, which is what keeps `sideEffects` naming `errors.ts` alone
|
|
37
|
+
true — `registerErrorCodes()` stays there, unconditional, and `bun run side-effects` is the check.
|
|
30
38
|
- **`sideEffects` is the ARRAY `["./src/errors.ts"]`, never `false` and never absent.** Absent was
|
|
31
39
|
what made the failure above unrecoverable — with no field a bundler must assume every module has
|
|
32
40
|
effects, so nothing was tree-shaken and `nats` came along with `useLive`. Measured, not guessed:
|
|
@@ -205,6 +213,17 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
205
213
|
other node renders for a full TTL. During a **rolling restart** that is every room showing each
|
|
206
214
|
user twice for up to 30s, beside the same client's reconnection under a new socket id. One
|
|
207
215
|
`evict(socket, code, reason)`, and every path that ends a socket without a callback takes it.
|
|
216
|
+
- **A `drain()` WAITS for the presence leaves it started; a `close` callback cannot** (2026-08-23).
|
|
217
|
+
`teardown` returns those promises as well as `detach`ing them: Bun's `close` callback is
|
|
218
|
+
synchronous, so there the detach is the whole of it — but a drain has no callback behind it and
|
|
219
|
+
is the one path that can wait. It did not: `release()`, `hub.close()` and the process's exit all
|
|
220
|
+
ran under N·M in-flight KV writes, so every other node rendered every drained member for a full
|
|
221
|
+
TTL — the same rolling-restart double vision `evict` exists to prevent, reached the long way
|
|
222
|
+
round. `evictInChunks` (`drain-evictions.ts`) evicts `DRAIN_EVICT_CHUNK` sockets, waits out what
|
|
223
|
+
they started, then takes the next: one synchronous loop over 50,000 sockets opens a quarter of a
|
|
224
|
+
million writes on one connection at the exact moment the fleet is already restarting.
|
|
225
|
+
`allSettled`, never `all` — a leave that fails is a member left to its TTL, which is what the
|
|
226
|
+
write meant when nobody waited for it at all, and it must not hold up the sockets behind it.
|
|
208
227
|
- **The idle sweep exists, is armed by `start()`, and its budget is an APPLICATION one.**
|
|
209
228
|
`SocketRegistry.sweepIdle` had no caller for as long as it existed, so `touch()`, `idleFor` and
|
|
210
229
|
the 120s default decided nothing and `idleTimeoutMs` was unreachable from `createSyncNode`. The
|
|
@@ -325,6 +344,38 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
325
344
|
- One registered `LiveClient` per app (`setLiveClient`), and every hook reads it through that seam —
|
|
326
345
|
no hook takes a client argument, and an unregistered one is `X_LIVE_CLIENT_MISSING`, never a
|
|
327
346
|
lazily-constructed default.
|
|
347
|
+
- **A DOM is the whole of the question, and it decides what "no client" MEANS** (2026-08-23, issue
|
|
348
|
+
#271). Deliberately the same rule, the same probe and the same words as `@ultimat3/ui`'s
|
|
349
|
+
`solid()`: with a DOM, a hook that finds no registration is a real bug — the app entry forgot
|
|
350
|
+
`setLiveClient` and every live query on the page is dead — so it stays `X_LIVE_CLIENT_MISSING`.
|
|
351
|
+
Without one there is no socket a client could have been registered *for*; that is a **server
|
|
352
|
+
render**, and it gets `serverRenderLiveClient()`. Before it, a page whose whole body read a live
|
|
353
|
+
query could not server-render at all: `useConnection()` threw and the route answered 500, and the
|
|
354
|
+
existing `hasLiveClient()` guard could not help — it only serves a component that already has a
|
|
355
|
+
static fallback written. `hasLiveClient()` still answers **false** on the server, on purpose,
|
|
356
|
+
because that is exactly what such a component is asking.
|
|
357
|
+
- **The server client serves the first render and opens no socket, so it holds nothing per
|
|
358
|
+
request.** One instance per process, and that is only safe because `useLive` on it registers
|
|
359
|
+
nothing: a client that kept a registration per call would grow by one entry per request forever
|
|
360
|
+
and pin a row window with each. `state()` is **`loading`**, never `offline` and never `live` — the
|
|
361
|
+
rows arrive over a socket this render does not have, so the page's own loading fallback is what
|
|
362
|
+
the document carries. `offline` would be read as a settled answer (`state() !== 'loading'` is the
|
|
363
|
+
gate a page writes), so an empty result set would render "you have no posts" for a feed that has
|
|
364
|
+
some. `connected` is `true` for the mirror-image reason: `useConnection().offline` is a banner
|
|
365
|
+
about this visitor's connectivity, and the request being served is the proof it is up. Everything
|
|
366
|
+
that can only mean "talk to the socket" — `mutate`, `drain` — refuses with
|
|
367
|
+
`X_LIVE_SERVER_RENDER`, because a dropped mutation looks exactly like one that happened.
|
|
368
|
+
- **The hook seam takes `LiveClientLike`, not the `LiveClient` class, and that is a measurement.**
|
|
369
|
+
A value import of the class from `hooks.ts` put the whole connection lifecycle — heartbeat, topic
|
|
370
|
+
book, mutation sender, wire protocol, backoff — into every island that calls `useLive`: a
|
|
371
|
+
`useLive`-only browser chunk went **8,368 B → 26,571 B**. Against the structural shape it is
|
|
372
|
+
9,356 B, and the ~1 kB is the server client and its refusal. `type-pins.ts`
|
|
373
|
+
(`_LiveClientSatisfiesTheHookSeam`) is what keeps the two in step.
|
|
374
|
+
- **A server render that renders is not a live page.** A page component never runs in a browser —
|
|
375
|
+
only an `island()` module does — so `useLive` in a page body server-renders its loading branch and
|
|
376
|
+
nothing replaces it unless that route ships an island that registers a client. The server client
|
|
377
|
+
removes the 500; it does not make a page live, and it must never be described as if it did.
|
|
378
|
+
`examples/dummy`'s `/feed` is exactly that state and its own header says so.
|
|
328
379
|
- Anything a component reads is a **getter or an accessor**, never a value snapshotted at hook time:
|
|
329
380
|
a plain field cannot re-render. `MutatorLike.local` is declared with method syntax so an
|
|
330
381
|
`@ultimat3/action` `Mutator` assigns with no cast — a function-typed property would not.
|
|
@@ -547,12 +598,20 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
547
598
|
what releases the change subscription carrying them. `drain()` + `stop()` are the `close` phase.
|
|
548
599
|
Registered with no phase, both landed in `close` and the node upgraded new websockets until the
|
|
549
600
|
very end. `listenSyncNode` unregisters both on `stop()`.
|
|
550
|
-
- **Readiness
|
|
551
|
-
that passed the
|
|
552
|
-
lands, and the `accept` phase is over by the time it reaches
|
|
553
|
-
a node the load balancer has already stopped routing to, so
|
|
554
|
-
socket count are therefore **functions** on `UpgradeDeps`,
|
|
555
|
-
|
|
601
|
+
- **Readiness AND the connection cap are asked twice, because `authenticate` is app code with an
|
|
602
|
+
await in it.** A request that passed the checks at the top of `handleUpgrade` can be parked in a
|
|
603
|
+
token service when SIGTERM lands, and the `accept` phase is over by the time it reaches
|
|
604
|
+
`server.upgrade` — one more socket on a node the load balancer has already stopped routing to, so
|
|
605
|
+
nothing takes it over. `ready` and the socket count are therefore **functions** on `UpgradeDeps`,
|
|
606
|
+
not values read once.
|
|
607
|
+
**`socketCount()` was the half that was read once and never re-asked** (2026-08-23), which is the
|
|
608
|
+
same staleness with a worse blast radius: a restart storm dials every client of a dead node at
|
|
609
|
+
this one at once and each parks in the token service having passed the cap while the node still
|
|
610
|
+
held nothing, so `maxConnections: 2` with ten parked upgrades took **ten** sockets — reproduced,
|
|
611
|
+
`upgraded 10, shed 0`. Sound because there is no await between the recheck and `server.upgrade`,
|
|
612
|
+
and the count moves INSIDE it: Bun runs `websocket.open` synchronously there, which is where
|
|
613
|
+
`sockets.add` runs. The recheck sheds with the same 503 + `retry-after-ms` and takes no second
|
|
614
|
+
`tryAccept()`: that budget was spent.
|
|
556
615
|
- **A client `send` that returned is not an acknowledgement.** A browser `WebSocket.send` on a
|
|
557
616
|
CLOSING socket discards the frame and returns normally, so a drained mutation is `inflight` until
|
|
558
617
|
the server settles it or `requeueInflight` returns it. Only `pending` is sendable, `drain()` is one
|
|
@@ -714,6 +773,7 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
|
|
|
714
773
|
| `sync-frames.ts` | what a RECEIVED frame does to server state — the node's inbound surface, and the mirror of `client-frames.ts` |
|
|
715
774
|
| `sync-upgrade.ts` | the node's HTTP surface: `/healthz`, `/readyz`, load shedding, and the authenticated upgrade — `WsData` and `UpgradeTarget` are declared with the decision that builds them |
|
|
716
775
|
| `sync-listen.ts` | binding a node to `Bun.serve` and to the shutdown hook — the only `Bun.serve` in the package |
|
|
776
|
+
| `drain-evictions.ts` | evicting every socket a drain holds, in bounded chunks, and waiting out the presence leaves each eviction started |
|
|
717
777
|
| `query-window.ts` | the shared pre-policy window per query id: built once, read once for N subscribers, and replaced when it is known to be wrong |
|
|
718
778
|
| `client-frames.ts` | what a RECEIVED frame does to client state, and `ClientFrameTarget` — the only inbound surface the client exposes. The mirror of `sync-frames.ts` |
|
|
719
779
|
| `client-harness-fixture.ts` | the injected socket + scheduler + harness both client suites drive. Excluded from the tarball |
|
package/README.md
CHANGED
|
@@ -81,13 +81,23 @@ Client names — `useLive`, `liveHookFor`, `LiveClient`, `OfflineQueue`, `Rebase
|
|
|
81
81
|
| wire | `.` | `PROTOCOL_VERSION`, `encode`, `decode`, `Frame` |
|
|
82
82
|
| halves | both | `LiveClient` on `.`; `createSyncNode` / `listenSyncNode` (`sync` role) on `./server` |
|
|
83
83
|
| a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
|
|
84
|
-
| hooks | `.` | `setLiveClient`, `useLive`, `useConnection`, `useMutation`, `useMutationQueue` |
|
|
84
|
+
| hooks | `.` | `setLiveClient`, `useLive`, `useConnection`, `useMutation`, `useMutationQueue`, `hasLiveClient` |
|
|
85
|
+
| the server render's client | `.` | `serverRenderLiveClient` — what a hook falls back to with no DOM; `LiveClientLike` is the shape both it and `LiveClient` satisfy |
|
|
85
86
|
| the typed projection | `.` | `liveHookFor` — one query bound to one named hook |
|
|
86
87
|
|
|
87
88
|
## The four hooks
|
|
88
89
|
|
|
89
90
|
Register the client once, in the app entry. Every hook reads it from there — no hook takes a client
|
|
90
|
-
argument, and one that runs before the registration is `X_LIVE_CLIENT_MISSING`,
|
|
91
|
+
argument, and one that runs **in a browser** before the registration is `X_LIVE_CLIENT_MISSING`,
|
|
92
|
+
never a default.
|
|
93
|
+
|
|
94
|
+
**A server render is not a missing registration.** With no DOM there is no socket a client could
|
|
95
|
+
have been registered for, so every hook falls back to `serverRenderLiveClient()`: `useLive` answers
|
|
96
|
+
`state() === 'loading'` with no rows, `useConnection()` reports online, both queue counts are `0`,
|
|
97
|
+
and `mutate` / `drain` refuse with `X_LIVE_SERVER_RENDER`. The page renders its own loading branch
|
|
98
|
+
and a hydrating island takes over. `hasLiveClient()` still answers `false` there, which is what a
|
|
99
|
+
component with a static fallback is asking. `useLive` in a page BODY is not made live by this — a
|
|
100
|
+
page component never runs in a browser; put the live half in an `island()`.
|
|
91
101
|
|
|
92
102
|
```ts
|
|
93
103
|
setLiveClient(new LiveClient({ signal: createSignal, connect, buildId, store, queue }));
|
|
@@ -203,6 +213,9 @@ back in a `finally`, and releasing twice is a no-op.
|
|
|
203
213
|
|
|
204
214
|
The accept budget bounds the accept **rate**; `maxConnections` bounds the **count**, and they are
|
|
205
215
|
two different attacks — 500 accepts/s held open with one keepalive each is 1.8M sockets an hour.
|
|
216
|
+
Both the count and `/readyz` are re-asked **after** `authenticate` resolves and immediately before
|
|
217
|
+
`server.upgrade`: awaiting app code is awaiting a token service, and a restart storm parks every
|
|
218
|
+
client of a dead node in there at once, each having passed a cap the node has since filled.
|
|
206
219
|
The frame budget is per socket and checked at the top of the frame router, before anything a frame
|
|
207
220
|
can reach: a subscribe frame is a database read, a presence write and a fleet-wide publish, and one
|
|
208
221
|
authenticated socket is the cheapest foothold there is.
|
|
@@ -412,6 +425,11 @@ wire twice by a reconnect that raced an ack.
|
|
|
412
425
|
keeps its patch stream**. The `close` phase is `drain()` then `stop()`. Registered with no phase it
|
|
413
426
|
all landed in `close`, and until that ran the node went on upgrading new websockets onto a process
|
|
414
427
|
that was going away. Both hooks are unregistered by the listener's `stop()`.
|
|
428
|
+
- **`drain()` resolves once the presence leaves have LANDED**, not once they have been started —
|
|
429
|
+
in bounded chunks of sockets, so a node holding tens of thousands does not open a write per topic
|
|
430
|
+
per socket in one go. Started and not waited for, the process could exit with them still on the
|
|
431
|
+
wire, and every other node would render every drained member for a full TTL: the rolling-restart
|
|
432
|
+
double vision the leave exists to prevent.
|
|
415
433
|
- **A full presence frame is capped** at `maxMembers` (256) and carries `total`, so a 5,000-person
|
|
416
434
|
room renders "and 4,744 others" instead of shipping 5,000 members to every joiner. The set itself
|
|
417
435
|
is never capped — the sweep differences it — and one node per topic runs that sweep, elected
|
|
@@ -523,7 +541,8 @@ wire twice by a reconnect that raced an ack.
|
|
|
523
541
|
`X_PROTOCOL_VERSION` · `X_CURSOR_STALE` ·
|
|
524
542
|
`X_REBASE_CONFLICT` · `X_TRANSPORT_UNAVAILABLE` · `X_TRANSPORT_PROTOCOL` ·
|
|
525
543
|
`X_REPLICATION_FAILED` · `X_REPLICATION_PROTOCOL` · `X_REPLICATOR_SLOT_HELD` ·
|
|
526
|
-
`X_LIVE_CLIENT_MISSING` · `
|
|
544
|
+
`X_LIVE_CLIENT_MISSING` · `X_LIVE_SERVER_RENDER` · `X_LIVE_QUERY_UNKNOWN` ·
|
|
545
|
+
`X_LIVE_REPLICA_IDENTITY` ·
|
|
527
546
|
`X_SOCKET_UNAUTHENTICATED` · `X_SOCKET_AUTH_UNAVAILABLE` · `X_NOT_IMPLEMENTED`
|
|
528
547
|
|
|
529
548
|
Topics deny by default: a topic with no matching guard is forbidden. An authz hole is not a config
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.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",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"test": "bun test"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@ultimat3/core": "
|
|
40
|
-
"@ultimat3/query": "
|
|
39
|
+
"@ultimat3/core": "11.0.0",
|
|
40
|
+
"@ultimat3/query": "11.0.0",
|
|
41
41
|
"nats": "2.29.3"
|
|
42
42
|
}
|
|
43
43
|
}
|
package/src/client-contract.ts
CHANGED
|
@@ -55,6 +55,29 @@ export interface MutatorRef<T extends TableMap = TableMap> {
|
|
|
55
55
|
readonly conflict?: ConflictStrategy;
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
/**
|
|
59
|
+
* What the HOOKS need a client to be — every member `hooks.ts` reads, and not one more.
|
|
60
|
+
*
|
|
61
|
+
* A structural interface rather than the `LiveClient` class, and the reason is measured: a value
|
|
62
|
+
* import of that class from the hook seam put the whole connection lifecycle (heartbeat, topic
|
|
63
|
+
* book, mutation sender, wire protocol, backoff) into every island that calls `useLive`, taking a
|
|
64
|
+
* `useLive`-only browser chunk from 8,368 B to 26,571 B. The server render's client
|
|
65
|
+
* (`server-render-client.ts`) satisfies this and imports no lifecycle at all, so the browser pays
|
|
66
|
+
* nothing for a shape only the server uses. `type-pins.ts` pins that `LiveClient` still satisfies
|
|
67
|
+
* it, so a member added there and not here is a build error rather than a hook that cannot see it.
|
|
68
|
+
*/
|
|
69
|
+
export interface LiveClientLike<T extends TableMap = TableMap> {
|
|
70
|
+
readonly signal: SignalFactory;
|
|
71
|
+
readonly queue: OfflineQueue | undefined;
|
|
72
|
+
readonly connected: boolean;
|
|
73
|
+
readonly reconnectAt: () => number | null;
|
|
74
|
+
readonly appUpdateAvailable: () => string | null;
|
|
75
|
+
useLive<R extends Row>(query: LiveQueryRef, input: JsonValue): LiveHandle<R>;
|
|
76
|
+
mutate(mutator: MutatorRef<T>, input: JsonValue, key?: string): Promise<void>;
|
|
77
|
+
drain(): Promise<void>;
|
|
78
|
+
onQueueChange(listener: () => void): () => void;
|
|
79
|
+
}
|
|
80
|
+
|
|
58
81
|
export interface LiveClientOptions<T extends TableMap = TableMap> {
|
|
59
82
|
readonly signal: SignalFactory;
|
|
60
83
|
/** Called for every connect attempt; returning a fresh socket keeps reconnect logic here. */
|
package/src/client.ts
CHANGED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Evicting every socket a drain holds, in bounded chunks, and waiting out what each eviction put
|
|
2
|
+
// on the bus. Split out of `sync-node.ts` at the 500-line ceiling; the loop closes over nothing the
|
|
3
|
+
// node holds, which is the same seam `detach.ts` took.
|
|
4
|
+
|
|
5
|
+
/** Sockets evicted before the leaves they started are awaited. See `evictInChunks`. */
|
|
6
|
+
export const DRAIN_EVICT_CHUNK = 128;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Every socket released, then the writes those releases started, then the next chunk.
|
|
10
|
+
*
|
|
11
|
+
* Chunked rather than one pass, because an eviction's presence leave is a write per TOPIC per
|
|
12
|
+
* SOCKET: a node holding 50,000 sockets in a handful of rooms each would open a quarter of a
|
|
13
|
+
* million KV writes on one connection in a single synchronous loop, which is a self-inflicted
|
|
14
|
+
* thundering herd on the bus at the exact moment the fleet is already restarting.
|
|
15
|
+
*
|
|
16
|
+
* `allSettled`, never `all`: a leave that fails is a member left to its TTL — the same degradation
|
|
17
|
+
* the write has when nobody waits for it at all — and it must not stop the sockets behind it from
|
|
18
|
+
* being released. The failure itself is already reported, by the `detach` the eviction path
|
|
19
|
+
* attached before handing the promise back here.
|
|
20
|
+
*/
|
|
21
|
+
export async function evictInChunks<Socket>(
|
|
22
|
+
sockets: readonly Socket[],
|
|
23
|
+
evict: (socket: Socket) => readonly Promise<unknown>[],
|
|
24
|
+
chunkSize: number = DRAIN_EVICT_CHUNK,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
for (let start = 0; start < sockets.length; start += chunkSize) {
|
|
27
|
+
const pending: Promise<unknown>[] = [];
|
|
28
|
+
for (const socket of sockets.slice(start, start + chunkSize)) pending.push(...evict(socket));
|
|
29
|
+
await Promise.allSettled(pending);
|
|
30
|
+
}
|
|
31
|
+
}
|
package/src/errors.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Realtime's X_* codes. Every throw in this package goes through one of these classes so
|
|
2
2
|
// the same string renders in the terminal, the browser overlay, and `--json`.
|
|
3
3
|
|
|
4
|
-
import { registerErrorCodes
|
|
4
|
+
import { registerErrorCodes } from '@ultimat3/core';
|
|
5
|
+
import { RealtimeError } from './realtime-error';
|
|
5
6
|
|
|
6
7
|
/** Codes this package declares and owns. */
|
|
7
8
|
export const REALTIME_OWNED_ERROR_CODES = [
|
|
@@ -18,6 +19,7 @@ export const REALTIME_OWNED_ERROR_CODES = [
|
|
|
18
19
|
'X_REPLICATION_FAILED',
|
|
19
20
|
'X_REPLICATOR_SLOT_HELD',
|
|
20
21
|
'X_LIVE_CLIENT_MISSING',
|
|
22
|
+
'X_LIVE_SERVER_RENDER',
|
|
21
23
|
'X_LIVE_ROW_UNIDENTIFIED',
|
|
22
24
|
'X_LIVE_QUERY_UNKNOWN',
|
|
23
25
|
'X_LIVE_REPLICA_IDENTITY',
|
|
@@ -111,7 +113,8 @@ export const REALTIME_ERROR_TITLES: Readonly<Record<RealtimeOwnedErrorCode, stri
|
|
|
111
113
|
X_REPLICATION_PROTOCOL: 'the WAL stream cannot be decoded',
|
|
112
114
|
X_REPLICATION_FAILED: 'the replication connection was refused',
|
|
113
115
|
X_REPLICATOR_SLOT_HELD: 'another replicator already owns this database',
|
|
114
|
-
X_LIVE_CLIENT_MISSING: 'a realtime hook ran with no LiveClient registered',
|
|
116
|
+
X_LIVE_CLIENT_MISSING: 'a realtime hook ran in a browser with no LiveClient registered',
|
|
117
|
+
X_LIVE_SERVER_RENDER: 'a browser-only live operation ran during a server render',
|
|
115
118
|
X_LIVE_ROW_UNIDENTIFIED: 'a live query returned a row with no id',
|
|
116
119
|
X_LIVE_QUERY_UNKNOWN: 'no live query is registered under the name a subscribe frame asked for',
|
|
117
120
|
X_LIVE_REPLICA_IDENTITY: 'a replicated table sends a key-only row on delete',
|
|
@@ -128,23 +131,17 @@ registerErrorCodes(
|
|
|
128
131
|
),
|
|
129
132
|
);
|
|
130
133
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
code: opts.code,
|
|
143
|
-
cause: opts.cause,
|
|
144
|
-
fix: opts.fix,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
}
|
|
134
|
+
// Re-exported, never re-declared: `RealtimeError` lives in `realtime-error.ts` and the four
|
|
135
|
+
// replication errors in `replication-errors.ts`, so this file stays the CODE TABLE plus the
|
|
136
|
+
// client-reachable refusals. Every name is still importable from `./errors`, which is what the
|
|
137
|
+
// seventeen `pg-*` modules and the barrel already do.
|
|
138
|
+
export { RealtimeError } from './realtime-error';
|
|
139
|
+
export {
|
|
140
|
+
ReplicaIdentityError,
|
|
141
|
+
ReplicationFailedError,
|
|
142
|
+
ReplicationProtocolError,
|
|
143
|
+
ReplicatorSlotHeldError,
|
|
144
|
+
} from './replication-errors';
|
|
148
145
|
|
|
149
146
|
/** Subscribe (or an actor change) denied by the topic's policy. Never leaks the topic's data. */
|
|
150
147
|
export class TopicForbiddenError extends RealtimeError {
|
|
@@ -284,93 +281,39 @@ export class TransportProtocolError extends RealtimeError {
|
|
|
284
281
|
}
|
|
285
282
|
|
|
286
283
|
/**
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*/
|
|
291
|
-
export class ReplicationProtocolError extends RealtimeError {
|
|
292
|
-
constructor(args: { stage: string; detail: string; fix?: string }) {
|
|
293
|
-
super({
|
|
294
|
-
code: 'X_REPLICATION_PROTOCOL',
|
|
295
|
-
cause: `postgres replication ${args.stage}: ${args.detail}`,
|
|
296
|
-
fix:
|
|
297
|
-
args.fix ??
|
|
298
|
-
'x doctor db — the server must be postgres >= 14 with a pgoutput publication and wal_level=logical',
|
|
299
|
-
});
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/**
|
|
304
|
-
* The replication connection itself failed — refused credentials, a slot another process holds,
|
|
305
|
-
* an `ErrorResponse` from the server. The server's own message is passed through verbatim
|
|
306
|
-
* because it names the object that has to change.
|
|
307
|
-
*/
|
|
308
|
-
export class ReplicationFailedError extends RealtimeError {
|
|
309
|
-
constructor(args: { stage: string; detail: string; fix: string }) {
|
|
310
|
-
super({
|
|
311
|
-
code: 'X_REPLICATION_FAILED',
|
|
312
|
-
cause: `postgres replication ${args.stage} failed: ${args.detail}`,
|
|
313
|
-
fix: args.fix,
|
|
314
|
-
});
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
/**
|
|
319
|
-
* A second replicator found the advisory lock held. Distinct from `X_REPLICATION_FAILED` because
|
|
320
|
-
* nothing is wrong with this process: the database already has its one replicator, and a second
|
|
321
|
-
* one that started anyway would publish every change twice. Terminal for a container whose whole
|
|
322
|
-
* job is that role — the scheduler is the thing that has to change, not the connection.
|
|
323
|
-
*/
|
|
324
|
-
export class ReplicatorSlotHeldError extends RealtimeError {
|
|
325
|
-
constructor(args: { key: string; holder?: string | undefined }) {
|
|
326
|
-
super({
|
|
327
|
-
code: 'X_REPLICATOR_SLOT_HELD',
|
|
328
|
-
cause:
|
|
329
|
-
`advisory lock ${args.key} is held${args.holder === undefined ? '' : ` by ${args.holder}`}` +
|
|
330
|
-
' — one database has exactly one replicator',
|
|
331
|
-
fix: 'scale the replicator to 1 per database: kubectl scale deploy/replicator --replicas=1',
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* A table in the entity list replicates with a replica identity other than FULL, so its `delete`
|
|
338
|
-
* (and any key-changing `update`) carries the KEY COLUMNS ONLY. `toRow` accepts that tuple —
|
|
339
|
-
* it only requires a text `id` — so the live matcher decides "did this row leave the result set"
|
|
340
|
-
* from a one-column row, and a row policy written against `!row.private` reads `undefined`.
|
|
284
|
+
* A hook was called IN A BROWSER before the app entry registered its client. Never a transient
|
|
285
|
+
* fault: the registration is a single call in the entry, so the fix is the call itself rather than
|
|
286
|
+
* a retry.
|
|
341
287
|
*
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
* which counts the changes this actually affects. Refusing it at `x verify` time is the follow-up.
|
|
346
|
-
*
|
|
347
|
-
* The tables are named because the fix is per table, and they are the entity list's own names —
|
|
348
|
-
* every one has already passed `assertIdentifier`, so the `fix:` is SQL that can be pasted.
|
|
288
|
+
* A server render is deliberately not this error, and never was a missing registration: there is
|
|
289
|
+
* no socket to register a client for. It gets `serverRenderLiveClient()` instead — the same rule
|
|
290
|
+
* `@ultimat3/ui`'s `solid()` follows for a missing Solid runtime, one package over.
|
|
349
291
|
*/
|
|
350
|
-
export class
|
|
351
|
-
constructor(args: {
|
|
292
|
+
export class LiveClientMissingError extends RealtimeError {
|
|
293
|
+
constructor(args: { hook: string }) {
|
|
352
294
|
super({
|
|
353
|
-
code: '
|
|
354
|
-
cause:
|
|
355
|
-
|
|
356
|
-
'delete carries the key columns only and a live query decides visibility from a partial row',
|
|
357
|
-
fix:
|
|
358
|
-
`${args.tables.map((table) => `ALTER TABLE ${table} REPLICA IDENTITY FULL;`).join(' ')}` +
|
|
359
|
-
' -- rows already written to the WAL keep the identity they were written with',
|
|
295
|
+
code: 'X_LIVE_CLIENT_MISSING',
|
|
296
|
+
cause: `${args.hook}() ran in a browser before any LiveClient was registered`,
|
|
297
|
+
fix: 'setLiveClient(new LiveClient({ signal: createSignal, connect, buildId })) in the app entry, above the first render',
|
|
360
298
|
});
|
|
361
299
|
}
|
|
362
300
|
}
|
|
363
301
|
|
|
364
302
|
/**
|
|
365
|
-
*
|
|
366
|
-
*
|
|
303
|
+
* Something that can only mean "talk to the socket" ran on the server client — a mutation, a
|
|
304
|
+
* publish, a topic subscription, a dial. There is no socket during a server render and there never
|
|
305
|
+
* will be one: the document is built and sent, and the browser opens the connection.
|
|
306
|
+
*
|
|
307
|
+
* A refusal rather than a silent no-op, because both alternatives are worse. Queueing it would
|
|
308
|
+
* hold one process-wide queue on behalf of whichever request happened to render, and dropping it
|
|
309
|
+
* would make a write that never happened look like one that did.
|
|
367
310
|
*/
|
|
368
|
-
export class
|
|
369
|
-
constructor(args: {
|
|
311
|
+
export class ServerRenderLiveError extends RealtimeError {
|
|
312
|
+
constructor(args: { operation: string }) {
|
|
370
313
|
super({
|
|
371
|
-
code: '
|
|
372
|
-
cause: `${args.
|
|
373
|
-
fix: '
|
|
314
|
+
code: 'X_LIVE_SERVER_RENDER',
|
|
315
|
+
cause: `${args.operation} ran during a server render, where this app has no live socket`,
|
|
316
|
+
fix: 'call it from an island mount() instead of from the page — or guard it with hasLiveClient(), which answers false on the server',
|
|
374
317
|
});
|
|
375
318
|
}
|
|
376
319
|
}
|
package/src/hooks.ts
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
// accessor is a closure over the `SignalFactory` the registered `LiveClient` was built with, and
|
|
4
4
|
// every hook resolves that client through one ambient seam rather than a context per surface.
|
|
5
5
|
|
|
6
|
-
import type {
|
|
6
|
+
import type { LiveClientLike, LiveHandle, LiveQueryRef, MutatorRef } from './client';
|
|
7
7
|
import { LiveClientMissingError } from './errors';
|
|
8
8
|
import type { JsonValue, Row } from './json';
|
|
9
9
|
import type { LocalTx } from './local-store';
|
|
10
10
|
import type { ConflictStrategy } from './rebase';
|
|
11
|
+
import { serverRenderLiveClient } from './server-render-client';
|
|
11
12
|
|
|
12
13
|
/** What `setLiveClient` holds. The version signal is the queue's only reactive handle — see below. */
|
|
13
14
|
interface Registered {
|
|
14
|
-
readonly client:
|
|
15
|
+
readonly client: LiveClientLike;
|
|
15
16
|
/** Read to subscribe, bumped to invalidate: `OfflineQueue` stores plain arrays, not signals. */
|
|
16
17
|
readonly version: () => number;
|
|
17
18
|
readonly bump: () => void;
|
|
@@ -22,7 +23,7 @@ interface Registered {
|
|
|
22
23
|
let registered: Registered | null = null;
|
|
23
24
|
|
|
24
25
|
/** Register once, in the app entry, before the first render. One app, one socket, one client. */
|
|
25
|
-
export function setLiveClient(client:
|
|
26
|
+
export function setLiveClient(client: LiveClientLike): void {
|
|
26
27
|
// The previous registration's listener goes with it. The client outlives `setLiveClient` — a hot
|
|
27
28
|
// reload, a test's next case, an app that re-registers after signing in — so a discarded
|
|
28
29
|
// unsubscribe is a listener nothing can reach, bumping a signal nothing renders, once per
|
|
@@ -50,9 +51,41 @@ export function hasLiveClient(): boolean {
|
|
|
50
51
|
return registered !== null;
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/**
|
|
55
|
+
* A DOM is the whole of the question — the same probe and the same rule `@ultimat3/ui`'s `solid()`
|
|
56
|
+
* follows, and deliberately the same words. With a DOM, a hook reaching for a client nobody
|
|
57
|
+
* registered is a real bug: the app entry forgot `setLiveClient`, and every live query on the page
|
|
58
|
+
* is dead. Without one there is no socket a client could have been registered FOR — that is a
|
|
59
|
+
* server render, and `serverRenderLiveClient()` is an honest account of it rather than a
|
|
60
|
+
* degradation of a working path. Never widen this to "no client, never throw": that is the silent
|
|
61
|
+
* feed-that-never-loads the split exists to prevent.
|
|
62
|
+
*/
|
|
63
|
+
function hasDom(): boolean {
|
|
64
|
+
return typeof document !== 'undefined' && typeof window !== 'undefined';
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The server render's registration, built once. Deliberately NOT written to `registered`:
|
|
69
|
+
* `hasLiveClient()` must keep answering `false` on the server, because that is the guard a
|
|
70
|
+
* component with a static fallback already uses to decide it is being server-rendered
|
|
71
|
+
* (`examples/dummy`'s `update-banner.tsx`).
|
|
72
|
+
*/
|
|
73
|
+
let serverSide: Registered | null = null;
|
|
74
|
+
|
|
75
|
+
function serverRegistration(): Registered {
|
|
76
|
+
if (serverSide !== null) return serverSide;
|
|
77
|
+
const client = serverRenderLiveClient();
|
|
78
|
+
// The version signal never moves, and nothing on the server can move it: one pass, no queue,
|
|
79
|
+
// no ack — so `bump` and `release` are the no-ops that fact makes them.
|
|
80
|
+
const [version] = client.signal<number>(0);
|
|
81
|
+
serverSide = { client, version, bump: () => undefined, release: () => undefined };
|
|
82
|
+
return serverSide;
|
|
83
|
+
}
|
|
84
|
+
|
|
53
85
|
function live(hook: string): Registered {
|
|
54
|
-
if (registered
|
|
55
|
-
|
|
86
|
+
if (registered !== null) return registered;
|
|
87
|
+
if (hasDom()) throw new LiveClientMissingError({ hook });
|
|
88
|
+
return serverRegistration();
|
|
56
89
|
}
|
|
57
90
|
|
|
58
91
|
// ---- useLive ------------------------------------------------------------------------------------
|
package/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ export { applyPatches, orderAfterPatches } from './apply-patches';
|
|
|
9
9
|
export {
|
|
10
10
|
type ClientSocket,
|
|
11
11
|
LiveClient,
|
|
12
|
+
type LiveClientLike,
|
|
12
13
|
type LiveClientOptions,
|
|
13
14
|
type LiveHandle,
|
|
14
15
|
type LiveQueryRef,
|
|
@@ -54,6 +55,7 @@ export {
|
|
|
54
55
|
ReplicationFailedError,
|
|
55
56
|
ReplicationProtocolError,
|
|
56
57
|
ReplicatorSlotHeldError,
|
|
58
|
+
ServerRenderLiveError,
|
|
57
59
|
SubscriptionLimitError,
|
|
58
60
|
TopicForbiddenError,
|
|
59
61
|
TransportProtocolError,
|
|
@@ -139,6 +141,8 @@ export {
|
|
|
139
141
|
type ServerAck,
|
|
140
142
|
strategyName,
|
|
141
143
|
} from './rebase';
|
|
144
|
+
// ---- what a LiveClient IS on the server: it serves the first render and opens no socket --------
|
|
145
|
+
export { serverRenderLiveClient } from './server-render-client';
|
|
142
146
|
// ---- the wire -------------------------------------------------------------------------------------
|
|
143
147
|
export {
|
|
144
148
|
type AckFrame,
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The class every realtime error extends, and nothing else.
|
|
2
|
+
//
|
|
3
|
+
// Apart from `errors.ts` so a concern-specific error module can extend it WITHOUT importing the
|
|
4
|
+
// code table — `errors.ts` re-exports both, so `RealtimeError` and every subclass stay importable
|
|
5
|
+
// from where they always were. Its own file rather than a re-export inside `errors.ts`, because
|
|
6
|
+
// that would be a cycle: `extends` runs at module evaluation, imports hoist above it, and the base
|
|
7
|
+
// would be in its temporal dead zone by the time the subclass module was evaluated.
|
|
8
|
+
|
|
9
|
+
import { UltimateError } from '@ultimat3/core';
|
|
10
|
+
import type { RealtimeErrorCode } from './errors';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Base for every realtime error. No `docs:` — `UltimateError` fills it from
|
|
14
|
+
* `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
|
|
15
|
+
* code, never one per code, because `wiki/` is the framework's only public documentation surface
|
|
16
|
+
* and a code lives there in a TABLE ROW, which has no anchor. The
|
|
17
|
+
* `https://ultimate.dev/errors/<code>` links this class built until 9.x answered 404, host
|
|
18
|
+
* included, on every error it has ever thrown — including the ones `toWireError` puts on the wire.
|
|
19
|
+
*/
|
|
20
|
+
export class RealtimeError extends UltimateError {
|
|
21
|
+
constructor(opts: { code: RealtimeErrorCode; cause: string; fix: string }) {
|
|
22
|
+
super({
|
|
23
|
+
code: opts.code,
|
|
24
|
+
cause: opts.cause,
|
|
25
|
+
fix: opts.fix,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// The four refusals the Postgres replication half raises: the wire, the connection, the slot, and
|
|
2
|
+
// the replica identity it warns about.
|
|
3
|
+
//
|
|
4
|
+
// Split out of `errors.ts` on the one seam this package already draws — these are the only codes
|
|
5
|
+
// no browser can reach, thrown by `pg-*.ts` and the replicator and by nothing on the client half.
|
|
6
|
+
// The codes themselves stay in `errors.ts`, whose `registerErrorCodes()` is what
|
|
7
|
+
// `package.json`'s `sideEffects` names; this module runs nothing at import.
|
|
8
|
+
|
|
9
|
+
import { RealtimeError } from './realtime-error';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The bytes on the replication socket are not the bytes the protocol allows: a truncated message,
|
|
13
|
+
* an unknown pgoutput tag, an auth method we do not speak. Always a version or configuration
|
|
14
|
+
* mismatch rather than a transient fault, so retrying the same connection cannot help.
|
|
15
|
+
*/
|
|
16
|
+
export class ReplicationProtocolError extends RealtimeError {
|
|
17
|
+
constructor(args: { stage: string; detail: string; fix?: string }) {
|
|
18
|
+
super({
|
|
19
|
+
code: 'X_REPLICATION_PROTOCOL',
|
|
20
|
+
cause: `postgres replication ${args.stage}: ${args.detail}`,
|
|
21
|
+
fix:
|
|
22
|
+
args.fix ??
|
|
23
|
+
'x doctor db — the server must be postgres >= 14 with a pgoutput publication and wal_level=logical',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The replication connection itself failed — refused credentials, a slot another process holds,
|
|
30
|
+
* an `ErrorResponse` from the server. The server's own message is passed through verbatim
|
|
31
|
+
* because it names the object that has to change.
|
|
32
|
+
*/
|
|
33
|
+
export class ReplicationFailedError extends RealtimeError {
|
|
34
|
+
constructor(args: { stage: string; detail: string; fix: string }) {
|
|
35
|
+
super({
|
|
36
|
+
code: 'X_REPLICATION_FAILED',
|
|
37
|
+
cause: `postgres replication ${args.stage} failed: ${args.detail}`,
|
|
38
|
+
fix: args.fix,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A second replicator found the advisory lock held. Distinct from `X_REPLICATION_FAILED` because
|
|
45
|
+
* nothing is wrong with this process: the database already has its one replicator, and a second
|
|
46
|
+
* one that started anyway would publish every change twice. Terminal for a container whose whole
|
|
47
|
+
* job is that role — the scheduler is the thing that has to change, not the connection.
|
|
48
|
+
*/
|
|
49
|
+
export class ReplicatorSlotHeldError extends RealtimeError {
|
|
50
|
+
constructor(args: { key: string; holder?: string | undefined }) {
|
|
51
|
+
super({
|
|
52
|
+
code: 'X_REPLICATOR_SLOT_HELD',
|
|
53
|
+
cause:
|
|
54
|
+
`advisory lock ${args.key} is held${args.holder === undefined ? '' : ` by ${args.holder}`}` +
|
|
55
|
+
' — one database has exactly one replicator',
|
|
56
|
+
fix: 'scale the replicator to 1 per database: kubectl scale deploy/replicator --replicas=1',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A table in the entity list replicates with a replica identity other than FULL, so its `delete`
|
|
63
|
+
* (and any key-changing `update`) carries the KEY COLUMNS ONLY. `toRow` accepts that tuple —
|
|
64
|
+
* it only requires a text `id` — so the live matcher decides "did this row leave the result set"
|
|
65
|
+
* from a one-column row, and a row policy written against `!row.private` reads `undefined`.
|
|
66
|
+
*
|
|
67
|
+
* **Raised at preflight and LOGGED, never thrown.** Every app running today on the default
|
|
68
|
+
* identity would stop booting, and the replicator refusing to start is a worse outcome than the
|
|
69
|
+
* partial rows it is warning about. The runtime half is `ReplicationStreamStats.partialBefore`,
|
|
70
|
+
* which counts the changes this actually affects. Refusing it at `x verify` time is the follow-up.
|
|
71
|
+
*
|
|
72
|
+
* The tables are named because the fix is per table, and they are the entity list's own names —
|
|
73
|
+
* every one has already passed `assertIdentifier`, so the `fix:` is SQL that can be pasted.
|
|
74
|
+
*/
|
|
75
|
+
export class ReplicaIdentityError extends RealtimeError {
|
|
76
|
+
constructor(args: { tables: readonly string[] }) {
|
|
77
|
+
super({
|
|
78
|
+
code: 'X_LIVE_REPLICA_IDENTITY',
|
|
79
|
+
cause:
|
|
80
|
+
`${args.tables.join(', ')} replicate with a replica identity other than FULL, so a ` +
|
|
81
|
+
'delete carries the key columns only and a live query decides visibility from a partial row',
|
|
82
|
+
fix:
|
|
83
|
+
`${args.tables.map((table) => `ALTER TABLE ${table} REPLICA IDENTITY FULL;`).join(' ')}` +
|
|
84
|
+
' -- rows already written to the WAL keep the identity they were written with',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// What a live client IS on the server: one that serves the first render and opens no socket.
|
|
2
|
+
//
|
|
3
|
+
// The rule it exists for is `@ultimat3/ui`'s, one package over — no runtime and no DOM is a SERVER
|
|
4
|
+
// RENDER, and a server render gets an honest account of itself rather than a throw. A page whose
|
|
5
|
+
// whole body reads a live query could not server-render at all before this: `useConnection()` threw
|
|
6
|
+
// `X_LIVE_CLIENT_MISSING` and the route answered 500 (issue #271).
|
|
7
|
+
//
|
|
8
|
+
// It implements `LiveClientLike` and imports NO connection lifecycle — no `LiveClient`, no
|
|
9
|
+
// heartbeat, no wire protocol. Measured: reaching the class from here costs every island that
|
|
10
|
+
// calls `useLive` 18 kB it can never run.
|
|
11
|
+
|
|
12
|
+
import type { LiveClientLike, LiveHandle, LiveQueryRef, SignalFactory } from './client-contract';
|
|
13
|
+
import { ServerRenderLiveError } from './errors';
|
|
14
|
+
import type { JsonValue, Row } from './json';
|
|
15
|
+
import type { LiveState } from './live-rows';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A signal that never changes, because nothing on the server can change it: one render, one pass,
|
|
19
|
+
* no reactive runtime. The setter is kept rather than dropped so a caller that writes through it
|
|
20
|
+
* reads its own write back — a signal that swallowed writes would be a different lie.
|
|
21
|
+
*/
|
|
22
|
+
const inertSignal: SignalFactory = <T>(initial: T): [() => T, (next: T) => void] => {
|
|
23
|
+
let held = initial;
|
|
24
|
+
return [
|
|
25
|
+
(): T => held,
|
|
26
|
+
(next: T): void => {
|
|
27
|
+
held = next;
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Frozen, so a handle a page holds cannot be turned into a result set by writing to it. */
|
|
33
|
+
const NO_ROWS: readonly Row[] = Object.freeze([]);
|
|
34
|
+
|
|
35
|
+
/** Nothing was subscribed, so nothing is released — and a teardown never fails a render. */
|
|
36
|
+
const releaseNothing = (): void => undefined;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The handle a server render gets for a live query: `loading`, never `offline` and never `live`.
|
|
40
|
+
*
|
|
41
|
+
* That is the one honest state — the rows arrive over a socket this render does not have, so the
|
|
42
|
+
* page's own loading fallback is what the document carries until hydration replaces it. `offline`
|
|
43
|
+
* would be read as a SETTLED answer (`state() !== 'loading'` is the gate `examples/dummy`'s feed
|
|
44
|
+
* uses), so an empty result set would render "you have no posts" for a feed that has some.
|
|
45
|
+
*/
|
|
46
|
+
function serverRenderHandle<R extends Row>(): LiveHandle<R> {
|
|
47
|
+
return {
|
|
48
|
+
rows: () => NO_ROWS as readonly R[],
|
|
49
|
+
state: (): LiveState => 'loading',
|
|
50
|
+
cursor: () => null,
|
|
51
|
+
unsubscribe: releaseNothing,
|
|
52
|
+
[Symbol.dispose]: releaseNothing,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Every member that can only mean "talk to the socket" refuses; every member a render READS
|
|
58
|
+
* answers what a server render actually is.
|
|
59
|
+
*
|
|
60
|
+
* `connected: true` is not a lie about the socket — `useConnection().offline` is a banner about
|
|
61
|
+
* THIS visitor's connectivity, and the request being served is the proof it is up. Answering
|
|
62
|
+
* `false` would server-render "you are offline" into every document, for a reader who is not, and
|
|
63
|
+
* then remove it on hydrate.
|
|
64
|
+
*
|
|
65
|
+
* It registers nothing, which is what makes ONE instance per process safe under concurrent
|
|
66
|
+
* renders: a client that kept a registration per `useLive` would grow by one entry per request,
|
|
67
|
+
* forever, and hold a row window with each.
|
|
68
|
+
*/
|
|
69
|
+
function build(): LiveClientLike {
|
|
70
|
+
return {
|
|
71
|
+
signal: inertSignal,
|
|
72
|
+
queue: undefined,
|
|
73
|
+
connected: true,
|
|
74
|
+
reconnectAt: () => null,
|
|
75
|
+
appUpdateAvailable: () => null,
|
|
76
|
+
useLive: <R extends Row>(_query: LiveQueryRef, _input: JsonValue): LiveHandle<R> =>
|
|
77
|
+
serverRenderHandle<R>(),
|
|
78
|
+
mutate: (): Promise<void> => {
|
|
79
|
+
throw new ServerRenderLiveError({ operation: 'mutate()' });
|
|
80
|
+
},
|
|
81
|
+
drain: (): Promise<void> => {
|
|
82
|
+
throw new ServerRenderLiveError({ operation: 'drain()' });
|
|
83
|
+
},
|
|
84
|
+
// A listener is accepted and never called: nothing on the server can change a queue that does
|
|
85
|
+
// not exist. Refusing here would break `setLiveClient`, which registers one unconditionally.
|
|
86
|
+
onQueueChange: () => releaseNothing,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let held: LiveClientLike | null = null;
|
|
91
|
+
|
|
92
|
+
/** ONE per process, built on first use. It holds nothing per request — see `build` above. */
|
|
93
|
+
export function serverRenderLiveClient(): LiveClientLike {
|
|
94
|
+
held ??= build();
|
|
95
|
+
return held;
|
|
96
|
+
}
|
package/src/sync-node.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { type Clock, logger, markReady, reportError, systemClock, uuid } from '@ultimat3/core';
|
|
8
8
|
import type { ChannelHub, Topic } from './channel';
|
|
9
9
|
import { detach } from './detach';
|
|
10
|
+
import { evictInChunks } from './drain-evictions';
|
|
10
11
|
import { isClientFault } from './errors';
|
|
11
12
|
import type { Transport, TransportSubscription } from './fanout';
|
|
12
13
|
import type { LiveQueryRegistry } from './live-query';
|
|
@@ -187,8 +188,12 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
187
188
|
* Everything one socket held, released once. Bun's `close` callback runs it, and so does a
|
|
188
189
|
* revoked grant — a socket this node closes itself gets no callback in a unit test, and in
|
|
189
190
|
* production the second run is the no-op every step here already is.
|
|
191
|
+
*
|
|
192
|
+
* Returns the presence leaves it started, which is the only step here that is not over when this
|
|
193
|
+
* function returns: `close` is a SYNCHRONOUS Bun callback and cannot await one, so the promise is
|
|
194
|
+
* both detached (that path has nobody to wait for it) and handed back (the drain does).
|
|
190
195
|
*/
|
|
191
|
-
const teardown = (socket: SyncSocket):
|
|
196
|
+
const teardown = (socket: SyncSocket): readonly Promise<unknown>[] => {
|
|
192
197
|
options.registry.unsubscribeSocket(socket.id);
|
|
193
198
|
const topics = [...socket.topics] as Topic[];
|
|
194
199
|
for (const name of topics) options.hub.unsubscribe(socket, name);
|
|
@@ -197,9 +202,17 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
197
202
|
// A closed socket is a leave, said now rather than left to TTL: everyone else would otherwise
|
|
198
203
|
// keep rendering a member who is provably gone for the rest of its window. The write is on the
|
|
199
204
|
// bus and the close callback is synchronous, so it cannot be awaited here.
|
|
205
|
+
const leaves: Promise<unknown>[] = [];
|
|
200
206
|
if (presence) {
|
|
201
|
-
for (const name of topics)
|
|
207
|
+
for (const name of topics) {
|
|
208
|
+
const leave = presence.leave(name, socket.id);
|
|
209
|
+
// Detached as well as returned: `detach` attaches the reporting catch, so a caller that
|
|
210
|
+
// awaits this later is awaiting a promise whose rejection is already handled.
|
|
211
|
+
detach(leave, 'presence.leave', name);
|
|
212
|
+
leaves.push(leave);
|
|
213
|
+
}
|
|
202
214
|
}
|
|
215
|
+
return leaves;
|
|
203
216
|
};
|
|
204
217
|
|
|
205
218
|
/**
|
|
@@ -208,9 +221,9 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
208
221
|
* because dropping the socket from the table is three of `teardown`'s five steps and the two it
|
|
209
222
|
* misses are the ones another node can see.
|
|
210
223
|
*/
|
|
211
|
-
const evict = (socket: SyncSocket, code: number, reason: string):
|
|
224
|
+
const evict = (socket: SyncSocket, code: number, reason: string): readonly Promise<unknown>[] => {
|
|
212
225
|
socket.close(code, reason);
|
|
213
|
-
teardown(socket);
|
|
226
|
+
return teardown(socket);
|
|
214
227
|
};
|
|
215
228
|
|
|
216
229
|
/**
|
|
@@ -460,9 +473,12 @@ export function createSyncNode(options: SyncNodeOptions): SyncNode {
|
|
|
460
473
|
// five steps, and the two they skip are the ones the rest of the fleet can see. A drained
|
|
461
474
|
// socket that never left its presence set is a member every other node renders for a full
|
|
462
475
|
// TTL — during a rolling restart, beside the same client's reconnection under a new id —
|
|
463
|
-
// and its live subscriptions stay in the registry, so `entry.subscribers` never empties
|
|
464
|
-
//
|
|
465
|
-
|
|
476
|
+
// and its live subscriptions stay in the registry, so `entry.subscribers` never empties.
|
|
477
|
+
//
|
|
478
|
+
// AWAITED, in chunks: a leave is a write to the shared set, so a drain that merely started
|
|
479
|
+
// them released, closed the hub and let the process exit with N·M writes still on the wire —
|
|
480
|
+
// which is that same full-TTL double vision, reached the long way round.
|
|
481
|
+
await evictInChunks([...sockets.all()], (socket) => evict(socket, CLOSE.goingAway, 'drain'));
|
|
466
482
|
// Released once the sockets are gone rather than at the top: a client is entitled to its
|
|
467
483
|
// patches for the whole grace window, and it is entitled to them *before* the hub the
|
|
468
484
|
// fanout writes through is closed.
|
package/src/sync-upgrade.ts
CHANGED
|
@@ -101,12 +101,21 @@ export async function handleUpgrade(
|
|
|
101
101
|
);
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
//
|
|
109
|
-
|
|
104
|
+
// BOTH facts asked again, because `authenticate` is app code and awaiting it is awaiting a token
|
|
105
|
+
// service: everything this request read above is history by the time it gets here.
|
|
106
|
+
//
|
|
107
|
+
// `ready`, because SIGTERM can land while the request is parked and the `accept` phase is over
|
|
108
|
+
// by now — upgrading then is the one socket that phase exists to refuse, on a node the load
|
|
109
|
+
// balancer has already been told is out.
|
|
110
|
+
//
|
|
111
|
+
// The socket COUNT, for the same reason and it was the half that was missing: a restart storm
|
|
112
|
+
// dials every client of a dead node at this one at once, each parked in the token service having
|
|
113
|
+
// passed the cap while the node still held nothing — so a node capped at 2 accepted as many
|
|
114
|
+
// sockets as there were parked requests, and `maxConnections` bounded nothing that a herd could
|
|
115
|
+
// reach. Sound because there is no await between this line and `server.upgrade`, and the count
|
|
116
|
+
// moves INSIDE it: Bun runs `websocket.open` synchronously there, which is where `sockets.add`
|
|
117
|
+
// runs. No second `tryAccept()`: that budget was spent above.
|
|
118
|
+
if (!deps.ready() || deps.socketCount() >= deps.maxConnections) return shed(deps);
|
|
110
119
|
const data: WsData = {
|
|
111
120
|
socketId: deps.newSocketId(),
|
|
112
121
|
clientBuildId: url.searchParams.get('build') ?? deps.buildId,
|
package/src/type-pins.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// stops catching the typo that makes a subscription match nothing.
|
|
8
8
|
|
|
9
9
|
import type { Query } from '@ultimat3/query';
|
|
10
|
-
import type { LiveHandle, Unsubscribe } from './client';
|
|
10
|
+
import type { LiveClient, LiveClientLike, LiveHandle, Unsubscribe } from './client';
|
|
11
11
|
import type { LiveRows } from './hooks';
|
|
12
12
|
import type { LiveQueryHook, LiveQuerySource } from './query-hook';
|
|
13
13
|
|
|
@@ -70,3 +70,14 @@ export type _LiveRowsIsDisposable = Assert<[LiveRows] extends [Disposable] ? tru
|
|
|
70
70
|
|
|
71
71
|
/** `channel.subscribe()`'s return must stay both callable and `Disposable`. */
|
|
72
72
|
export type _UnsubscribeIsDisposable = Assert<[Unsubscribe] extends [Disposable] ? true : false>;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The hook seam takes `LiveClientLike`, not `LiveClient` — a structural shape, so the server
|
|
76
|
+
* render's client can satisfy it without dragging the connection lifecycle into every island that
|
|
77
|
+
* calls `useLive` (measured: 8,368 B → 26,571 B). This is what keeps the two in step: a member
|
|
78
|
+
* `hooks.ts` needs and `LiveClient` stops providing fails HERE, at the build, rather than at the
|
|
79
|
+
* one app that registered a real client.
|
|
80
|
+
*/
|
|
81
|
+
export type _LiveClientSatisfiesTheHookSeam = Assert<
|
|
82
|
+
[LiveClient] extends [LiveClientLike] ? true : false
|
|
83
|
+
>;
|