@ultimat3/realtime 23.0.0 → 25.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/CLAUDE.md +118 -126
  2. package/README.md +229 -54
  3. package/package.json +9 -6
  4. package/src/advisory-lock.ts +62 -0
  5. package/src/boot.ts +25 -15
  6. package/src/change-buffer.ts +96 -41
  7. package/src/changefeed-env.ts +9 -9
  8. package/src/changefeed.ts +81 -19
  9. package/src/channel-authz.ts +48 -17
  10. package/src/channel-describe.ts +1 -1
  11. package/src/channel-latch.ts +52 -0
  12. package/src/channel-logs.ts +36 -15
  13. package/src/channel-presence.ts +3 -3
  14. package/src/channel-records.ts +35 -4
  15. package/src/channel-ref.ts +2 -2
  16. package/src/channel-registry.ts +2 -2
  17. package/src/channel-sids.ts +37 -0
  18. package/src/channel.ts +148 -48
  19. package/src/client-channels.ts +12 -1
  20. package/src/client-contract.ts +4 -1
  21. package/src/client-frames.ts +16 -3
  22. package/src/client-heartbeat.ts +39 -9
  23. package/src/client.ts +30 -2
  24. package/src/cursor.ts +11 -0
  25. package/src/error-titles.ts +91 -0
  26. package/src/errors.ts +113 -105
  27. package/src/fanout.ts +13 -0
  28. package/src/first-paint-hold.ts +49 -0
  29. package/src/idb-types.ts +2 -2
  30. package/src/index.ts +25 -7
  31. package/src/json.ts +16 -3
  32. package/src/live-contract.ts +14 -0
  33. package/src/live-definition.ts +34 -9
  34. package/src/live-deliver.ts +55 -0
  35. package/src/live-fanout.ts +122 -29
  36. package/src/live-floor.ts +35 -0
  37. package/src/live-query-options.ts +44 -0
  38. package/src/live-query.ts +96 -175
  39. package/src/live-reauth.ts +127 -0
  40. package/src/live-replicator.ts +28 -8
  41. package/src/live-resume.ts +44 -8
  42. package/src/live-rows.ts +32 -3
  43. package/src/live-spend.ts +50 -0
  44. package/src/local-store-idb.ts +5 -0
  45. package/src/matcher-bridge.ts +11 -5
  46. package/src/nats-client.ts +37 -6
  47. package/src/nats-lib-client.ts +36 -7
  48. package/src/nats-subscriptions.ts +63 -0
  49. package/src/nats-transport.ts +149 -15
  50. package/src/offline-queue.ts +53 -5
  51. package/src/page-errors.ts +25 -9
  52. package/src/page-outbox.ts +125 -54
  53. package/src/page-runtime-wait.ts +72 -0
  54. package/src/page-runtime.ts +32 -0
  55. package/src/page-socket.ts +7 -16
  56. package/src/page-store.ts +83 -16
  57. package/src/pg-advisory-lock.ts +54 -5
  58. package/src/pg-auth.ts +9 -4
  59. package/src/pg-bytes.ts +4 -1
  60. package/src/pg-connection.ts +126 -22
  61. package/src/pg-entity-row.ts +16 -0
  62. package/src/pg-identity-tables.ts +25 -0
  63. package/src/pg-preflight.ts +50 -4
  64. package/src/pg-replication-row.ts +82 -0
  65. package/src/pg-replication.ts +50 -51
  66. package/src/pg-socket.ts +26 -7
  67. package/src/pg-tls.ts +18 -0
  68. package/src/pg-values.ts +29 -14
  69. package/src/pg-wire.ts +5 -3
  70. package/src/pgoutput.ts +19 -9
  71. package/src/policy-gate.ts +7 -7
  72. package/src/presence.ts +87 -10
  73. package/src/principal-sockets.ts +75 -0
  74. package/src/query-window.ts +17 -5
  75. package/src/record-persister.ts +7 -2
  76. package/src/record-publisher.ts +178 -0
  77. package/src/record-store.ts +25 -4
  78. package/src/record-synced.ts +43 -1
  79. package/src/replication-errors.ts +11 -7
  80. package/src/replicator-envelope.ts +160 -0
  81. package/src/replicator-row.ts +75 -0
  82. package/src/replicator.ts +313 -191
  83. package/src/server.ts +45 -23
  84. package/src/socket-defaults.ts +20 -0
  85. package/src/socket-engine.ts +14 -2
  86. package/src/socket-idle.ts +3 -1
  87. package/src/socket-routes.ts +8 -3
  88. package/src/socket.ts +14 -19
  89. package/src/subscriber-gate.ts +11 -1
  90. package/src/subscription-book.ts +129 -71
  91. package/src/subscription-count.ts +151 -0
  92. package/src/sync-actor-change.ts +56 -0
  93. package/src/sync-auth.ts +47 -2
  94. package/src/sync-bus-handlers.ts +131 -0
  95. package/src/sync-frames.ts +74 -21
  96. package/src/sync-node-bounds.ts +16 -1
  97. package/src/sync-node-contract.ts +36 -4
  98. package/src/sync-node.ts +163 -82
  99. package/src/sync-origin.ts +27 -12
  100. package/src/sync-protocol.ts +86 -16
  101. package/src/sync-upgrade.ts +115 -8
  102. package/src/type-pins.ts +9 -1
  103. package/src/use-channel.ts +19 -14
  104. package/src/use-connection.ts +3 -3
  105. package/src/use-mutation.ts +41 -20
  106. package/src/use-query.ts +11 -11
  107. package/src/use-record.ts +89 -10
  108. package/src/wire-channel.ts +18 -9
  109. package/src/wire-read.ts +2 -2
  110. package/src/idb-fake.ts +0 -133
  111. package/src/policy-fake.ts +0 -14
@@ -2,7 +2,12 @@
2
2
  // patches, presence and refusals come down. A client write is HTTP (`useMutation`), never a frame —
3
3
  // so there is one write path, with the action's authz, idempotency store and contract behind it.
4
4
 
5
- import { renderThrowable, stringField } from '@ultimat3/core/page';
5
+ import {
6
+ isWriteDigest,
7
+ renderThrowable,
8
+ stringField,
9
+ WRITE_DIGEST_LENGTH,
10
+ } from '@ultimat3/core/page';
6
11
  import type {
7
12
  ChannelEventsFrame,
8
13
  ChannelRecordsFrame,
@@ -11,7 +16,7 @@ import type {
11
16
  } from './channel-wire';
12
17
  import type { LiveCursor } from './cursor';
13
18
  import {
14
- isJsonObject,
19
+ isParsedJsonObject,
15
20
  isRow,
16
21
  type JsonObject,
17
22
  type JsonValue,
@@ -37,6 +42,8 @@ export interface WireError {
37
42
  readonly cause: string;
38
43
  readonly fix: string;
39
44
  readonly docs?: string;
45
+ /** When a retry can succeed — a rate-limited subscribe's refill. Whole seconds, positive. */
46
+ readonly retryAfterSeconds?: number;
40
47
  }
41
48
 
42
49
  export interface PresenceMember {
@@ -80,6 +87,12 @@ export interface HelloFrame {
80
87
  /** Server-assigned on the reply, `null` on the client's opening frame. */
81
88
  readonly sessionId: string | null;
82
89
  readonly actorId: string | null;
90
+ /**
91
+ * On the NODE's reply only: how often this client must beat, in ms — a third of the node's real
92
+ * presence ttl, and never slower than the node's idle budget allows. Additive and optional, so
93
+ * no version bump: a node that says nothing leaves the client on its own default.
94
+ */
95
+ readonly heartbeatMs?: number;
83
96
  }
84
97
 
85
98
  export interface SubscribeFrame {
@@ -110,6 +123,12 @@ export interface SnapshotFrame {
110
123
  * derives a key.
111
124
  */
112
125
  readonly keys?: readonly string[];
126
+ /**
127
+ * The writes this server truth already holds, named as `PatchFrame.writes` names them — only
128
+ * writes behind a row in `rows`, so never one this subscriber may not see. Optional and additive:
129
+ * no `PROTOCOL_VERSION` bump, for the reason `entity` needs none.
130
+ */
131
+ readonly writes?: readonly string[];
113
132
  }
114
133
 
115
134
  export interface PatchFrame {
@@ -118,6 +137,15 @@ export interface PatchFrame {
118
137
  readonly sid: string;
119
138
  readonly patches: readonly RowPatch[];
120
139
  readonly lsn: string;
140
+ /**
141
+ * The writes that made these patches: `writeDigest` of each keyed request's idempotency key
142
+ * (`@ultimat3/core`), never the key, as `ChannelRecordsFrame.write` names one. A live patch names
143
+ * at most one; a delta resume names every write behind a patch it replays. Absent when no keyed
144
+ * request made any of them. The page that holds one pending settles it in the frame's own batch.
145
+ * Additive and optional: a decoder one deploy behind drops it, and a frame without it is read as
146
+ * it always was — so no `PROTOCOL_VERSION` bump.
147
+ */
148
+ readonly writes?: readonly string[];
121
149
  }
122
150
 
123
151
  export interface AckFrame {
@@ -186,21 +214,25 @@ export function decode(raw: string | Uint8Array): Frame {
186
214
  } catch {
187
215
  throw fail('frame is not JSON');
188
216
  }
189
- if (!isJsonObject(parsed)) throw fail('frame is not an object');
217
+ if (!isParsedJsonObject(parsed)) throw fail('frame is not an object');
190
218
  const version = parsed['v'];
191
219
  if (version !== PROTOCOL_VERSION) {
192
220
  throw new ProtocolVersionError({ got: version, expected: PROTOCOL_VERSION });
193
221
  }
194
222
  const kind = parsed['type'];
195
223
  switch (kind) {
196
- case 'hello':
197
- return {
224
+ case 'hello': {
225
+ const hello = {
198
226
  type: 'hello',
199
227
  v: PROTOCOL_VERSION,
200
228
  buildId: str(parsed, 'buildId'),
201
229
  sessionId: nullableStr(parsed, 'sessionId'),
202
230
  actorId: nullableStr(parsed, 'actorId'),
203
- };
231
+ } as const;
232
+ return parsed['heartbeatMs'] === undefined
233
+ ? hello
234
+ : { ...hello, heartbeatMs: num(parsed, 'heartbeatMs') };
235
+ }
204
236
  case 'subscribe':
205
237
  return {
206
238
  type: 'subscribe',
@@ -218,7 +250,9 @@ export function decode(raw: string | Uint8Array): Frame {
218
250
  cursor: cursor(parsed['cursor']),
219
251
  } as const;
220
252
  const entity = nullableStr(parsed, 'entity');
221
- const scoped = entity === null ? base : { ...base, entity };
253
+ const writes = writesOf(parsed, 'snapshot.writes');
254
+ const named = writes.length === 0 ? base : { ...base, writes };
255
+ const scoped = entity === null ? named : { ...named, entity };
222
256
  if (parsed['keys'] === undefined) return scoped;
223
257
  const keys = list(parsed, 'keys', FRAME_LIMITS.rows).map((key) => {
224
258
  if (typeof key !== 'string') throw fail('snapshot.keys must hold strings');
@@ -227,14 +261,17 @@ export function decode(raw: string | Uint8Array): Frame {
227
261
  if (keys.length !== base.rows.length) throw fail('snapshot.keys must pair with rows');
228
262
  return { ...scoped, keys };
229
263
  }
230
- case 'patch':
231
- return {
264
+ case 'patch': {
265
+ const base = {
232
266
  type: 'patch',
233
267
  v: PROTOCOL_VERSION,
234
268
  sid: str(parsed, 'sid'),
235
269
  patches: list(parsed, 'patches', FRAME_LIMITS.patches).map(patch),
236
270
  lsn: str(parsed, 'lsn'),
237
- };
271
+ } as const;
272
+ const writes = writesOf(parsed, 'patch.writes');
273
+ return writes.length === 0 ? base : { ...base, writes };
274
+ }
238
275
  case 'ack':
239
276
  return {
240
277
  type: 'ack',
@@ -275,7 +312,37 @@ export function toWireError(error: unknown): WireError {
275
312
  const cause = stringField(error, 'cause') ?? renderThrowable(error);
276
313
  const fix = stringField(error, 'fix') ?? 'x doctor realtime';
277
314
  const docs = stringField(error, 'docs');
278
- return docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
315
+ const base = docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
316
+ const retryAfterSeconds = retryHintOf(error);
317
+ return retryAfterSeconds === undefined ? base : { ...base, retryAfterSeconds };
318
+ }
319
+
320
+ /**
321
+ * `meta.retryAfterSeconds`, the field `@ultimat3/http`'s `X_RATE_LIMITED` carries — read as totally
322
+ * as `stringField` reads the rest, because the throwable is app code's and its getters may throw.
323
+ */
324
+ function retryHintOf(error: unknown): number | undefined {
325
+ try {
326
+ const meta: unknown = (error as { readonly meta?: unknown } | null)?.meta;
327
+ const held: unknown = isParsedJsonObject(meta) ? meta['retryAfterSeconds'] : undefined;
328
+ return typeof held === 'number' && Number.isFinite(held) && held > 0 ? held : undefined;
329
+ } catch {
330
+ return undefined;
331
+ }
332
+ }
333
+
334
+ /**
335
+ * `writes` on a `patch` or `snapshot`: digests only, capped at the patch ceiling (one write per
336
+ * patch at most; `snapshotFrame` caps what it sends to the same number). Absent, `null` and `[]`
337
+ * all name nothing, and read as the frame an older node sends.
338
+ */
339
+ function writesOf(parsed: JsonObject, label: string): readonly string[] {
340
+ return list(parsed, 'writes', FRAME_LIMITS.patches, label).map((write) => {
341
+ if (!isWriteDigest(write)) {
342
+ throw fail(`${label} must hold ${WRITE_DIGEST_LENGTH}-character lowercase hex digests`);
343
+ }
344
+ return write;
345
+ });
279
346
  }
280
347
 
281
348
  function row(value: unknown): Row {
@@ -284,7 +351,7 @@ function row(value: unknown): Row {
284
351
  }
285
352
 
286
353
  function cursor(value: unknown): LiveCursor {
287
- if (!isJsonObject(value)) throw fail('cursor must be an object');
354
+ if (!isParsedJsonObject(value)) throw fail('cursor must be an object');
288
355
  return {
289
356
  qid: str(value, 'qid'),
290
357
  lsn: str(value, 'lsn'),
@@ -297,7 +364,7 @@ function cursor(value: unknown): LiveCursor {
297
364
  }
298
365
 
299
366
  function patch(value: unknown): RowPatch {
300
- if (!isJsonObject(value)) throw fail('patch must be an object');
367
+ if (!isParsedJsonObject(value)) throw fail('patch must be an object');
301
368
  const base = {
302
369
  op: pick(value, 'op', ['insert', 'update', 'delete'] as const),
303
370
  id: str(value, 'id'),
@@ -310,7 +377,7 @@ function patch(value: unknown): RowPatch {
310
377
  }
311
378
 
312
379
  function target(value: unknown): SubscribeTarget {
313
- if (!isJsonObject(value)) throw fail('subscribe.target must be an object');
380
+ if (!isParsedJsonObject(value)) throw fail('subscribe.target must be an object');
314
381
  const kind = pick(value, 'kind', ['query', 'channel'] as const);
315
382
  if (kind === 'channel') return channelTarget(value);
316
383
  return {
@@ -324,7 +391,10 @@ function target(value: unknown): SubscribeTarget {
324
391
 
325
392
  function wireError(value: unknown): WireError | null {
326
393
  if (value === null || value === undefined) return null;
327
- if (!isJsonObject(value)) throw fail('ack.error must be an object or null');
394
+ if (!isParsedJsonObject(value)) throw fail('ack.error must be an object or null');
328
395
  const base = { code: str(value, 'code'), cause: str(value, 'cause'), fix: str(value, 'fix') };
329
- return value['docs'] === undefined ? base : { ...base, docs: str(value, 'docs') };
396
+ const documented = value['docs'] === undefined ? base : { ...base, docs: str(value, 'docs') };
397
+ return value['retryAfterSeconds'] === undefined
398
+ ? documented
399
+ : { ...documented, retryAfterSeconds: num(value, 'retryAfterSeconds') };
330
400
  }
@@ -2,12 +2,22 @@
2
2
  // from `sync-node.ts` because deciding whether a request becomes a websocket is a different job
3
3
  // from what the socket then does — the same line `sync-frames.ts` and `sync-listen.ts` already draw.
4
4
 
5
- import { healthzPayload, readyzPayload, reportError } from '@ultimat3/core';
5
+ import {
6
+ type HealthPayload,
7
+ healthBody,
8
+ healthPeerListed,
9
+ healthzPayload,
10
+ readyzPayload,
11
+ reportError,
12
+ } from '@ultimat3/core';
6
13
  import {
7
14
  SocketAuthUnavailableError,
15
+ SocketLimitError,
8
16
  SocketOriginRefusedError,
9
17
  SocketUnauthenticatedError,
10
18
  } from './errors';
19
+ import type { PrincipalSockets } from './principal-sockets';
20
+ import { principalFor } from './subscription-book';
11
21
  import type { SyncAuthenticator, SyncGrant } from './sync-auth';
12
22
  import { upgradeOrigin } from './sync-origin';
13
23
  import { toWireError } from './sync-protocol';
@@ -26,11 +36,21 @@ export interface WsData {
26
36
  * forever for having sent no query.
27
37
  */
28
38
  readonly clientBuildId: string;
39
+ /**
40
+ * The caller's address, resolved here once (`clientAddressOf`); `null` when nothing can say.
41
+ * Optional only for a socket a host builds without this upgrade (a test harness).
42
+ */
43
+ readonly clientAddress?: string | null;
29
44
  }
30
45
 
31
46
  /** Structural view of `Bun.serve`'s server object; keeps this module free of a Bun import. */
32
47
  export interface UpgradeTarget {
33
48
  upgrade(request: Request, options: { data: WsData }): boolean;
49
+ /**
50
+ * The peer's SOCKET address, as `Bun.serve`'s server answers it — what decides who is told the
51
+ * health detail. `null` when the host cannot say, which is told the verdict only.
52
+ */
53
+ requestIP(request: Request): { readonly address: string } | null;
34
54
  }
35
55
 
36
56
  /**
@@ -49,10 +69,26 @@ export interface UpgradeDeps {
49
69
  newSocketId(): string;
50
70
  readonly authenticate?: SyncAuthenticator | undefined;
51
71
  /**
52
- * Exact origins a page may dial from besides the node's own host name — `APP_URL`'s, when the
53
- * page is served on another host than the node (`sync-origin.ts`).
72
+ * Exact origins a page may dial from — `APP_URL`'s. Declared, they are the WHOLE list: the
73
+ * origin the node sees is `Host`-derived and is admitted only when nothing is declared
74
+ * (`sync-origin.ts`).
54
75
  */
55
76
  readonly allowedOrigins?: readonly string[] | undefined;
77
+ /**
78
+ * Who the caller is, for keying an anonymous reader's rate limit: the deployment's own rule —
79
+ * `@ultimat3/http`'s `clientAddress()` under its `TRUSTED_PROXY_HOPS`, injected by the boot so
80
+ * this package reads no proxy header itself. Absent, the socket's address is the caller.
81
+ */
82
+ readonly clientAddressOf?:
83
+ | ((request: Request, socketAddress: string | null) => string | null)
84
+ | undefined;
85
+ /** The `Host`-derived origin admitted beside a declared list too — `x dev` only. */
86
+ readonly admitReachedOrigin?: boolean | undefined;
87
+ /**
88
+ * Peers told the whole health report — address classes or exact IP literals, the app's
89
+ * `http.healthDetailPeers`. Everyone else gets `{ state, ready, role }`.
90
+ */
91
+ readonly healthDetailPeers: readonly string[];
56
92
  /**
57
93
  * Recorded BEFORE `server.upgrade`, because Bun runs `websocket.open` synchronously inside it
58
94
  * (measured on bun 1.4.0) and `open` is where the node reads this grant to build the socket's
@@ -69,6 +105,11 @@ export interface UpgradeDeps {
69
105
  * same "reserve, then release" shape `channel.ts` uses for a topic slot.
70
106
  */
71
107
  onUngranted(socketId: string): void;
108
+ /**
109
+ * One principal's sockets on this node, keyed as the live-query principal is. Admitted right
110
+ * before `server.upgrade`; `onUngranted` and the socket's close give the slot back.
111
+ */
112
+ readonly principals?: PrincipalSockets | undefined;
72
113
  }
73
114
 
74
115
  /**
@@ -83,16 +124,21 @@ export async function handleUpgrade(
83
124
  const url = new URL(request.url);
84
125
  // Health is the process's, readiness is this node's: a draining node stays healthy while it hands
85
126
  // its sockets to the rest of the fleet.
86
- if (url.pathname === '/healthz') return json(healthzPayload());
127
+ if (url.pathname === '/healthz') return health(deps, request, server, healthzPayload());
87
128
  if (url.pathname === '/readyz') {
88
129
  const payload = readyzPayload({ deep: url.searchParams.get('deep') === '1' });
89
- return deps.ready() ? json(payload) : json({ status: 503, body: payload.body });
130
+ return health(deps, request, server, deps.ready() ? payload : { ...payload, status: 503 });
90
131
  }
91
132
  if (url.pathname !== deps.path) return new Response('not found', { status: 404 });
92
133
  // First, and before anything is spent: a foreign page is refused whatever the node's load.
93
- const origin = upgradeOrigin(request, url, deps.allowedOrigins ?? []);
94
- if (!origin.ok)
95
- return wireErrorResponse(403, new SocketOriginRefusedError({ reason: origin.reason }));
134
+ const origin = upgradeOrigin(request, url, deps.allowedOrigins ?? [], deps.admitReachedOrigin);
135
+ if (!origin.ok) {
136
+ const asked = { asked: request.headers.get('origin'), admitted: admittedList(deps) };
137
+ return wireErrorResponse(
138
+ 403,
139
+ new SocketOriginRefusedError({ reason: origin.reason, ...asked }),
140
+ );
141
+ }
96
142
  // The count and readiness. Decided before `authenticate` so a full node costs no token service
97
143
  // call.
98
144
  if (deps.socketCount() >= deps.maxConnections || !deps.ready()) return shed(deps);
@@ -148,7 +194,18 @@ export async function handleUpgrade(
148
194
  socketId: deps.newSocketId(),
149
195
  // The node's own id is "not skewed until the hello says so", never "current forever".
150
196
  clientBuildId: url.searchParams.get('build') ?? deps.buildId,
197
+ clientAddress: callerAddress(deps, request, server),
151
198
  };
199
+ // Beside the count recheck and for its reason: no await between this and `server.upgrade`, so a
200
+ // herd of one principal parked in `authenticate` cannot all pass one count.
201
+ const principal = principalFor(grant?.actor ?? null, data.clientAddress ?? null);
202
+ if (deps.principals !== undefined && !deps.principals.admit(data.socketId, principal)) {
203
+ refund();
204
+ return wireErrorResponse(
205
+ 429,
206
+ new SocketLimitError({ principal, limit: deps.principals.limitFor(principal) }),
207
+ );
208
+ }
152
209
  // Before the upgrade, never after: `server.upgrade` runs `websocket.open` synchronously and does
153
210
  // not return until it has, so a grant recorded on the next line is one the socket was already
154
211
  // built without.
@@ -177,6 +234,49 @@ export async function handleUpgrade(
177
234
  return undefined;
178
235
  }
179
236
 
237
+ /**
238
+ * What a proxy writes. Any one of them means the socket's address is the proxy's, not the
239
+ * caller's — and a proxy that writes none of them is indistinguishable from a direct peer, so a
240
+ * header-less proxy must not route the health paths at all.
241
+ */
242
+ const FORWARDED_HEADERS = [
243
+ 'forwarded',
244
+ 'x-forwarded-for',
245
+ 'x-forwarded-host',
246
+ 'x-forwarded-proto',
247
+ 'x-real-ip',
248
+ 'via',
249
+ ] as const;
250
+
251
+ /**
252
+ * The STATUS is everyone's, which is all a probe reads. The body beyond the verdict is for a peer
253
+ * the app listed, on a DIRECT socket: these paths answer before the origin check, the accept
254
+ * budget and `authenticate`. This node declares no trusted proxy, so a request that says it was
255
+ * forwarded is never told the detail — a proxy on this box makes every caller's socket loopback.
256
+ */
257
+ function health(
258
+ deps: UpgradeDeps,
259
+ request: Request,
260
+ server: UpgradeTarget,
261
+ payload: HealthPayload,
262
+ ): Response {
263
+ const forwarded = FORWARDED_HEADERS.some((header) => request.headers.has(header));
264
+ const detailed =
265
+ !forwarded &&
266
+ healthPeerListed(deps.healthDetailPeers, server.requestIP(request)?.address ?? null);
267
+ return new Response(JSON.stringify(healthBody(payload.body, 'sync', detailed)), {
268
+ status: payload.status,
269
+ headers: { 'content-type': 'application/json', 'cache-control': 'no-store' },
270
+ });
271
+ }
272
+
273
+ function callerAddress(deps: UpgradeDeps, request: Request, server: UpgradeTarget): string | null {
274
+ const socketAddress = server.requestIP(request)?.address ?? null;
275
+ return deps.clientAddressOf === undefined
276
+ ? socketAddress
277
+ : deps.clientAddressOf(request, socketAddress);
278
+ }
279
+
180
280
  /** Load shedding with a delay attached: refusing without one just moves the herd next door. */
181
281
  function shed(deps: UpgradeDeps): Response {
182
282
  return new Response('retry', {
@@ -185,6 +285,13 @@ function shed(deps: UpgradeDeps): Response {
185
285
  });
186
286
  }
187
287
 
288
+ /** What the refusal says this node admits: the declared list, and under `x dev` the reached-on origin. */
289
+ function admittedList(deps: UpgradeDeps): readonly string[] {
290
+ const declared = deps.allowedOrigins ?? [];
291
+ if (declared.length === 0 || deps.admitReachedOrigin !== true) return declared;
292
+ return [...declared, 'the origin it was reached on'];
293
+ }
294
+
188
295
  function wireErrorResponse(status: number, error: unknown): Response {
189
296
  return json({ status, body: { error: toWireError(error) } });
190
297
  }
package/src/type-pins.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import type { AsyncState, Row } from '@ultimat3/core';
7
7
  import type { LiveHandle, Unsubscribe } from './client-contract';
8
8
  import type { QueryAccessor, QueryRef } from './use-query';
9
- import type { RecordAccessor } from './use-record';
9
+ import type { RecordAccessor, useRecords } from './use-record';
10
10
 
11
11
  /** Fails to compile when `T` is anything but `true`. The whole mechanism. */
12
12
  type Assert<T extends true> = T;
@@ -31,6 +31,14 @@ export type _RecordAnswersAsyncState = Assert<
31
31
  Equals<ReturnType<RecordAccessor<FeedRow>>, AsyncState<FeedRow | undefined>>
32
32
  >;
33
33
 
34
+ /**
35
+ * `useRecords` takes its selection EXPLICITLY — a key list, or `{}` / `{ where, order }` for the
36
+ * whole type. A default turned a call that forgot its keys into "every record of the type".
37
+ */
38
+ export type _UseRecordsSelectionRequired = Assert<
39
+ Equals<Parameters<typeof useRecords>['length'], 2>
40
+ >;
41
+
34
42
  /**
35
43
  * A query ref carries no server field: a `Query` VALUE in an island drags its whole read path into
36
44
  * the bundle, so the ref is a name and two declared facts. A `sql` key is refused.
@@ -1,9 +1,10 @@
1
1
  // A declared channel, held by a component. Every holder of one topic on the page shares ONE
2
- // membership on the page socket; its `records` land in the store (read them with `useRecord` /
3
- // `useQuery`), and only `events` and presence reach the handlers given here.
2
+ // membership on the page socket — the page runtime's, reached off the page, never bundled here;
3
+ // its `records` land in the store (read them with `useRecord` / `useQuery`), and only `events`
4
+ // and presence reach the handlers given here.
4
5
 
5
6
  import type { ChannelHandlers, ChannelRef, ChannelState } from './client-channels';
6
- import { pageSocket } from './page-socket';
7
+ import { installedPage } from './page-store';
7
8
  import { isServerRender, signalFor } from './reactivity';
8
9
  import type { PresenceMember } from './sync-protocol';
9
10
 
@@ -32,7 +33,9 @@ export function useChannel<K extends string>(
32
33
  [Symbol.dispose]: nothing,
33
34
  });
34
35
  }
35
- const membership = pageSocket('useChannel').holdChannel(declared, params, handlers);
36
+ const membership = installedPage('useChannel')
37
+ .services.socket('useChannel')
38
+ .holdChannel(declared, params, handlers);
36
39
  const [version, setVersion] = signal(0);
37
40
  const off = membership.onChange(() => setVersion(version() + 1));
38
41
  const read = (): ChannelState => {
@@ -70,16 +73,18 @@ export function usePresence<K extends string>(
70
73
  }
71
74
  const [version, setVersion] = signal(0);
72
75
  let members = new Map<string, PresenceMember>();
73
- const membership = pageSocket('usePresence').holdChannel(declared, params, {
74
- onPresence: (event) => {
75
- if (event.presence === 'sync') members = new Map();
76
- for (const member of event.members) {
77
- if (event.presence === 'leave') members.delete(member.id);
78
- else members.set(member.id, member);
79
- }
80
- setVersion(version() + 1);
81
- },
82
- });
76
+ const membership = installedPage('usePresence')
77
+ .services.socket('usePresence')
78
+ .holdChannel(declared, params, {
79
+ onPresence: (event) => {
80
+ if (event.presence === 'sync') members = new Map();
81
+ for (const member of event.members) {
82
+ if (event.presence === 'leave') members.delete(member.id);
83
+ else members.set(member.id, member);
84
+ }
85
+ setVersion(version() + 1);
86
+ },
87
+ });
83
88
  const read = (): readonly PresenceMember[] => {
84
89
  version();
85
90
  return [...members.values()];
@@ -1,8 +1,8 @@
1
1
  // The page socket, as four getters: whether it is up, when it redials, and whether a newer build
2
2
  // is live. Getters, never a snapshot, so a read inside a tracking scope stays live. Asking opens
3
- // the socket — this hook IS a question about it.
3
+ // the socket — this hook IS a question about it — and the socket is the page runtime's.
4
4
 
5
- import { pageSocket } from './page-socket';
5
+ import { installedPage } from './page-store';
6
6
  import { isServerRender, signalFor } from './reactivity';
7
7
 
8
8
  export interface Connection extends Disposable {
@@ -33,7 +33,7 @@ const SERVER_RENDER: Connection = Object.freeze({
33
33
  export function useConnection(): Connection {
34
34
  const signal = signalFor('useConnection');
35
35
  if (isServerRender()) return SERVER_RENDER;
36
- const client = pageSocket('useConnection');
36
+ const client = installedPage('useConnection').services.socket('useConnection');
37
37
  const [version, setVersion] = signal(0);
38
38
  const release = client.onStatus(() => setVersion(version() + 1));
39
39
  return {
@@ -1,20 +1,14 @@
1
1
  // A client write, the one way: the mutator's optimistic twin into the store's OVERLAY (visible to
2
- // every island before this returns), then its action over HTTP through core's one transport with
3
- // an idempotency key. The answer's records are adopted before the overlay goes — no flicker — and
4
- // a refusal takes the overlay back. The socket carries no writes.
2
+ // every island before this returns), then its action over HTTP through core's one transport — the
3
+ // page runtime's, called off the page — with an idempotency key. The answer's records are adopted
4
+ // before the overlay goes — no flicker — and a refusal takes the overlay back. No socket writes.
5
5
 
6
6
  import type { ConflictPolicy } from '@ultimat3/core/page';
7
- import {
8
- actionPath,
9
- clientTransport,
10
- isSuperseded,
11
- isUltimateError,
12
- uuid,
13
- } from '@ultimat3/core/page';
7
+ import { actionPath, isSuperseded, isUltimateError, pageClient, uuidV7 } from '@ultimat3/core/page';
14
8
  import type { JsonValue } from './json';
15
9
  import { type OutboxHandle, peekOutbox } from './outbox-slot';
16
- import { ServerRenderLiveError } from './page-errors';
17
- import { type PageWrites, pageRealtime } from './page-store';
10
+ import { OfflineQueueAbandonedError, ServerRenderLiveError } from './page-errors';
11
+ import { installedPage, type PageWrites, pageRealtime } from './page-store';
18
12
  import { isServerRender, signalFor } from './reactivity';
19
13
  import { carriedBy } from './record-store';
20
14
 
@@ -92,7 +86,7 @@ function watch(hook: string, writes: PageWrites | undefined): [() => number, ()
92
86
  function restoreOverlays(mutator: MutatorLike): void {
93
87
  const local = mutator.local;
94
88
  if (local === undefined) return;
95
- const page = pageRealtime();
89
+ const page = installedPage('useMutation');
96
90
  // The boot opens the outbox; after it, so a queue the previous load left is on disk to read.
97
91
  void page.booted.then(async () => {
98
92
  const outbox = peekOutbox();
@@ -126,8 +120,9 @@ export function useMutation(mutator: MutatorLike): Mutate {
126
120
  const [version, release] = watch('useMutation', writes);
127
121
  const call = async (input: JsonValue): Promise<unknown> => {
128
122
  if (writes === undefined) throw new ServerRenderLiveError({ operation: 'useMutation()' });
129
- const page = pageRealtime();
130
- const key = `${mutator.name}:${uuid()}`;
123
+ const page = installedPage('useMutation');
124
+ const issued = pageClient().scope.epoch;
125
+ const key = `${mutator.name}:${uuidV7()}`;
131
126
  const local = mutator.local;
132
127
  // Called back through the mutator: `local` may be a method, and an unbound one loses `this`.
133
128
  if (local !== undefined) {
@@ -138,9 +133,31 @@ export function useMutation(mutator: MutatorLike): Mutate {
138
133
  // them over HTTP — a like queued offline and the unlike made once the network was back could
139
134
  // otherwise land swapped. The replay sends the queue in order, this write last, under its key.
140
135
  const queued = peekOutbox();
136
+ // The outbox may REFUSE: its queue's principal left mid-write (`X_OFFLINE_QUEUE_ABANDONED`),
137
+ // or the disk would not take it. Nothing will ever send this write then, so its twin comes
138
+ // off the screen and the caller is told — a rejected `mutate`, by code.
139
+ //
140
+ // And the hook has an await of its own — the POST — so WHOSE write this is was decided at the
141
+ // call (`issued`): asked for after a principal change, the outbox is the next principal's, and
142
+ // a write handed to it then would be queued, and sent, as them.
143
+ const enqueue = async (outbox: OutboxHandle): Promise<void> => {
144
+ try {
145
+ if (pageClient().scope.epoch !== issued) {
146
+ throw new OfflineQueueAbandonedError({ name: mutator.name });
147
+ }
148
+ await outbox.enqueue({ key, name: mutator.name, input });
149
+ } catch (error) {
150
+ page.store.drop(key);
151
+ // A principal change is nobody's failure to report; a disk that refused the write is.
152
+ if (!isUltimateError(error) || error.code !== 'X_OFFLINE_QUEUE_ABANDONED') {
153
+ writes.failed += 1;
154
+ }
155
+ throw error;
156
+ }
157
+ };
141
158
  if (queued !== undefined && queued.pending().length > 0) {
142
159
  try {
143
- await queued.enqueue({ key, name: mutator.name, input });
160
+ await enqueue(queued);
144
161
  void queued.replay().catch(() => undefined);
145
162
  return undefined;
146
163
  } finally {
@@ -151,7 +168,7 @@ export function useMutation(mutator: MutatorLike): Mutate {
151
168
  /** The records the answer carried, `type:key` — what the overlay may be settled against. */
152
169
  const carried = new Set<string>();
153
170
  try {
154
- output = await clientTransport({
171
+ output = await page.services.send({
155
172
  method: 'POST',
156
173
  url: actionPath(mutator.name),
157
174
  body: input,
@@ -160,13 +177,17 @@ export function useMutation(mutator: MutatorLike): Mutate {
160
177
  });
161
178
  } catch (error) {
162
179
  const failure = transportFailure(error);
163
- // No response at all: the write is the outbox's now, under the SAME idempotency key, and its
164
- // overlay stays on screen until the replay settles or refuses it. Only a page the boot opened
180
+ // No response at all — which is NOT "the write did not land": the POST may have committed
181
+ // and lost its answer. The write is the outbox's now, under the SAME idempotency key, and
182
+ // that replay is safe only because `mutator()` refuses a declaration without
183
+ // `idempotent: true` (`X_MUTATOR_NOT_IDEMPOTENT`): the server reads the key and answers the
184
+ // replay from its idempotency store, so the handler never runs twice. The overlay stays on
185
+ // screen until the replay settles or refuses it. Only a page the boot opened
165
186
  // an outbox on can promise that; with none (no boot, so nothing on this page persists) the
166
187
  // write is refused like any other, never held in a memory queue a reload would silently lose.
167
188
  const outbox = failure === 'network' ? await bootedOutbox(page) : undefined;
168
189
  if (outbox !== undefined) {
169
- await outbox.enqueue({ key, name: mutator.name, input });
190
+ await enqueue(outbox);
170
191
  return undefined;
171
192
  }
172
193
  if (failure === 'body') {