@ultimat3/realtime 10.0.0 → 11.1.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 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 is asked twice, because `authenticate` is app code with an await in it.** A request
551
- that passed the check at the top of `handleUpgrade` can be parked in a token service when SIGTERM
552
- lands, and the `accept` phase is over by the time it reaches `server.upgrade` — one more socket on
553
- a node the load balancer has already stopped routing to, so nothing takes it over. `ready` and the
554
- socket count are therefore **functions** on `UpgradeDeps`, not values read once. The recheck sheds
555
- with the same 503 + `retry-after-ms` and takes no second `tryAccept()`: that budget was spent.
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`, never a default.
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` · `X_LIVE_QUERY_UNKNOWN` · `X_LIVE_REPLICA_IDENTITY` ·
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": "10.0.0",
3
+ "version": "11.1.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": "10.0.0",
40
- "@ultimat3/query": "10.0.0",
39
+ "@ultimat3/core": "11.1.0",
40
+ "@ultimat3/query": "11.1.0",
41
41
  "nats": "2.29.3"
42
42
  }
43
43
  }
@@ -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
@@ -33,6 +33,7 @@ import { backoffDelay, defaultBackoff, timeoutScheduler } from './thundering-her
33
33
  */
34
34
  export type {
35
35
  ClientSocket,
36
+ LiveClientLike,
36
37
  LiveClientOptions,
37
38
  LiveHandle,
38
39
  LiveQueryRef,
@@ -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, UltimateError } from '@ultimat3/core';
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
- * Base for every realtime error. No `docs:` `UltimateError` fills it from
133
- * `describeErrorCode(code).docs`, which is `@ultimat3/core`'s `ERROR_DOCS_URL`: one page for every
134
- * code, never one per code, because `wiki/` is the framework's only public documentation surface
135
- * and a code lives there in a TABLE ROW, which has no anchor. The
136
- * `https://ultimate.dev/errors/<code>` links this class built until 9.x answered 404, host
137
- * included, on every error it has ever thrown — including the ones `toWireError` puts on the wire.
138
- */
139
- export class RealtimeError extends UltimateError {
140
- constructor(opts: { code: RealtimeErrorCode; cause: string; fix: string }) {
141
- super({
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
- * The bytes on the replication socket are not the bytes the protocol allows: a truncated message,
288
- * an unknown pgoutput tag, an auth method we do not speak. Always a version or configuration
289
- * mismatch rather than a transient fault, so retrying the same connection cannot help.
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
- * **Raised at preflight and LOGGED, never thrown.** Every app running today on the default
343
- * identity would stop booting, and the replicator refusing to start is a worse outcome than the
344
- * partial rows it is warning about. The runtime half is `ReplicationStreamStats.partialBefore`,
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 ReplicaIdentityError extends RealtimeError {
351
- constructor(args: { tables: readonly string[] }) {
292
+ export class LiveClientMissingError extends RealtimeError {
293
+ constructor(args: { hook: string }) {
352
294
  super({
353
- code: 'X_LIVE_REPLICA_IDENTITY',
354
- cause:
355
- `${args.tables.join(', ')} replicate with a replica identity other than FULL, so a ` +
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
- * A hook was called before the app entry registered its client. Never a transient fault: the
366
- * registration is a single call in the entry, so the fix is the call itself rather than a retry.
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 LiveClientMissingError extends RealtimeError {
369
- constructor(args: { hook: string }) {
311
+ export class ServerRenderLiveError extends RealtimeError {
312
+ constructor(args: { operation: string }) {
370
313
  super({
371
- code: 'X_LIVE_CLIENT_MISSING',
372
- cause: `${args.hook}() ran before any LiveClient was registered`,
373
- fix: 'setLiveClient(new LiveClient({ signal: createSignal, connect, buildId })) in the app entry, above the first render',
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 { LiveClient, LiveHandle, LiveQueryRef, MutatorRef } from './client';
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: LiveClient;
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: LiveClient): void {
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 === null) throw new LiveClientMissingError({ hook });
55
- return registered;
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): void => {
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) detach(presence.leave(name, socket.id), 'presence.leave', name);
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): void => {
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 and
464
- // the matcher, the shared window and the retained ring are pinned for the process's life.
465
- for (const socket of [...sockets.all()]) evict(socket, CLOSE.goingAway, 'drain');
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.
@@ -101,12 +101,21 @@ export async function handleUpgrade(
101
101
  );
102
102
  }
103
103
  }
104
- // Asked again, because `authenticate` is app code and awaiting it is awaiting a token service: a
105
- // request that passed the check above can be parked there when SIGTERM lands, and the `accept`
106
- // phase is over by the time it gets here. Upgrading then is the one socket that phase exists to
107
- // refuse the load balancer has already been told this node is out, so nothing takes it over. No
108
- // second `tryAccept()`: that budget was spent above.
109
- if (!deps.ready()) return shed(deps);
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
+ >;