@ultimat3/realtime 29.4.0 → 30.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.
@@ -2,6 +2,7 @@
2
2
  // read once for N subscribers, and how one that is known to be wrong is replaced. The authz
3
3
  // decision is never here — `live-query.ts` owns that, once per subscriber, over what this returns.
4
4
 
5
+ import type { LiveReadPhase } from '@ultimat3/query';
5
6
  import { WindowReadTimeoutError } from './errors';
6
7
  import type { JsonValue, Row } from './json';
7
8
  import type { LiveQueryDefinition, LiveSubscription, SnapshotResult } from './live-contract';
@@ -64,10 +65,16 @@ export interface QueryEntry {
64
65
  * can answer: a definition with no lsn provider returns `''` from every snapshot.
65
66
  */
66
67
  generation: number;
67
- /** The generation of the newest read whose rows are in `rows`. `0` before the first one lands. */
68
+ /** The newest read settled into `rows`, or discarded as behind them. `0` before the first. */
68
69
  applied: number;
69
70
  readonly readDeadlineMs: number;
70
71
  readonly schedule: Scheduler;
72
+ /**
73
+ * The newest subscriber's `traceparent`, raw (`noteTraceparent`). A subscribe's read is that
74
+ * trace's child; a re-run a change forced LINKS to it. One per window, because the read is: N
75
+ * subscribers share one read, and it can parent on one trace only.
76
+ */
77
+ traceparent: string | null;
71
78
  }
72
79
 
73
80
  /**
@@ -119,9 +126,24 @@ export function queryEntry(
119
126
  applied: 0,
120
127
  readDeadlineMs: usableDeadline(options?.readDeadlineMs),
121
128
  schedule: options?.schedule ?? timeoutScheduler,
129
+ traceparent: null,
122
130
  };
123
131
  }
124
132
 
133
+ /**
134
+ * Remember the subscriber's `traceparent` for the window's next read. Bounded and never parsed
135
+ * here: a value is either a string a W3C header could be or it is dropped, and `traceLiveRead`
136
+ * ignores one that does not parse — telemetry never fails a subscribe.
137
+ */
138
+ export function noteTraceparent(entry: QueryEntry, traceparent: string | null | undefined): void {
139
+ if (typeof traceparent === 'string' && traceparent.length <= TRACEPARENT_MAX) {
140
+ entry.traceparent = traceparent;
141
+ }
142
+ }
143
+
144
+ /** `00-<32>-<16>-<2>` is 55 characters; anything far past it is not one. */
145
+ const TRACEPARENT_MAX = 128;
146
+
125
147
  /**
126
148
  * The window this subscriber is served from, read once per entry. A subscriber arriving while
127
149
  * another's read is in flight joins that read rather than issuing its own — N cold subscribers on
@@ -137,7 +159,7 @@ export async function fillWindow(entry: QueryEntry): Promise<FilledWindow> {
137
159
  // Read before `startRead` clears it: a second caller arriving during the read joins it and is
138
160
  // not the one that forced it, which is what keeps one forced read from becoming N.
139
161
  const forced = entry.stale;
140
- const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
162
+ const pending = forced || entry.reading === null ? startRead(entry, 'snapshot') : entry.reading;
141
163
  const result = await pending.result;
142
164
  const again = await entry.lock.run(async () => {
143
165
  // Two rules, and neither can stand in for the other. Against another READ it is identity —
@@ -156,6 +178,10 @@ export async function fillWindow(entry: QueryEntry): Promise<FilledWindow> {
156
178
  applyRead(entry, pending, result);
157
179
  // A forced read outranks a first one: what it replaced was known to be wrong.
158
180
  landed = forced || landed === 'forced' ? 'forced' : first ? 'first' : landed;
181
+ } else if (isNewestRead(entry, pending)) {
182
+ // Discarded: the window is already past this answer. Settled all the same, or `applied`
183
+ // reads as a read still out and no repair ever trusts this window again.
184
+ entry.applied = pending.generation;
159
185
  }
160
186
  // A change reached this window while its first read was in flight and could not be folded,
161
187
  // so what just landed may predate it: read once more before serving anyone a partial window.
@@ -189,7 +215,8 @@ const COLD_REREADS = 3;
189
215
  * that repairs a stale window mid-fanout is spelled here rather than deadlocking on the other.
190
216
  */
191
217
  export async function refillWindowInLane(entry: QueryEntry): Promise<void> {
192
- const pending = startRead(entry);
218
+ // A change asked for this read, not a subscriber: it links to the window's trace (`rerun`).
219
+ const pending = startRead(entry, 'rerun');
193
220
  const result = await pending.result;
194
221
  // Same identity rule as `fillWindow`: a read issued before this one may still be in flight, and
195
222
  // whichever was issued LAST is the one the window keeps.
@@ -219,13 +246,13 @@ function usableDeadline(declared: number | undefined): number {
219
246
  * otherwise would be a second, false promise — so the abandoned read runs to completion with
220
247
  * nobody listening, and the next caller issues its own instead of joining a corpse.
221
248
  */
222
- function startRead(entry: QueryEntry): PendingRead {
249
+ function startRead(entry: QueryEntry, phase: LiveReadPhase): PendingRead {
223
250
  // Cleared here rather than when the read lands: the read about to be issued is the one that
224
251
  // answers the staleness, so a second caller must join it instead of forcing another.
225
252
  entry.stale = false;
226
253
  entry.generation += 1;
227
254
 
228
- const read = readSnapshot(entry);
255
+ const read = readSnapshot(entry, phase);
229
256
  let expire: () => void = noop;
230
257
  const deadline = new Promise<never>((_resolve, reject) => {
231
258
  // Assigned synchronously — a Promise executor runs before the constructor returns — so `done`
@@ -272,9 +299,14 @@ function startRead(entry: QueryEntry): PendingRead {
272
299
  * to prevent is now permanent and silent. `async` so a definition that throws synchronously takes
273
300
  * the same path as one that rejects.
274
301
  */
275
- async function readSnapshot(entry: QueryEntry): Promise<SnapshotResult> {
302
+ async function readSnapshot(entry: QueryEntry, phase: LiveReadPhase): Promise<SnapshotResult> {
276
303
  try {
277
- return await entry.definition.snapshot({ input: entry.input, tenant: entry.tenant });
304
+ return await entry.definition.snapshot({
305
+ input: entry.input,
306
+ tenant: entry.tenant,
307
+ traceparent: entry.traceparent,
308
+ phase,
309
+ });
278
310
  } catch (error) {
279
311
  entry.stale = true;
280
312
  throw error;
package/src/server.ts CHANGED
@@ -50,6 +50,49 @@ export {
50
50
  resetChannelTransport,
51
51
  setChannelTransport,
52
52
  } from './channel-publish';
53
+ export {
54
+ type AgentDial,
55
+ type AgentSocket,
56
+ type DeviceAgent,
57
+ type DeviceAgentOptions,
58
+ deviceAgent,
59
+ } from './device-agent';
60
+ export {
61
+ DeviceFrameTooLargeError,
62
+ DeviceOfflineError,
63
+ DeviceRequestTimeoutError,
64
+ } from './device-errors';
65
+ export {
66
+ DEVICE_FRAME_QUEUE_MAX,
67
+ DEVICE_REQUESTS_OUTSTANDING_MAX,
68
+ type DeviceFrameHandler,
69
+ type DeviceInfo,
70
+ type DeviceWs,
71
+ } from './device-host';
72
+ export {
73
+ beginDeviceSocketGeneration,
74
+ DEFAULT_DEVICE_HEARTBEAT_MS,
75
+ DEFAULT_DEVICE_REQUEST_DEADLINE_MS,
76
+ DEFAULT_DEVICE_SOCKETS_PER_ACTOR,
77
+ DEVICE_AUTH_DEADLINE_MS,
78
+ DEVICE_MAX_PENDING_UPGRADES,
79
+ type DeviceAttachment,
80
+ type DeviceAttachOptions,
81
+ type DeviceRequestOptions,
82
+ type DeviceSocket,
83
+ type DeviceSocketOptions,
84
+ declaredDeviceSockets,
85
+ deviceSocket,
86
+ forgetDeviceSockets,
87
+ } from './device-socket';
88
+ export type {
89
+ DeviceGrant,
90
+ DeviceTokenResolver,
91
+ DeviceUpgradeFacts,
92
+ DeviceUpgradeTarget,
93
+ DeviceWsData,
94
+ } from './device-upgrade';
95
+ export { DEVICE_FRAME_MAX_BYTES } from './device-wire';
53
96
  export { RealtimeTopologyError } from './errors';
54
97
  export {
55
98
  InProcessTransport,
package/src/socket.ts CHANGED
@@ -199,7 +199,7 @@ export class SyncSocket {
199
199
  * The `hello` frame is the documented place a client names its build, and until 2026-09-07 the
200
200
  * node never read it: `clientBuildId` came from the dial's `?build=` alone and defaulted to this
201
201
  * node's OWN id when the query was absent — so a client that said `hello` from any build at all
202
- * was deemed current forever, and `update-available` never came. Measured on ai-maxxing: a page
202
+ * was deemed current forever, and `update-available` never came. Measured on one adopter: a page
203
203
  * sending `buildId: "dev"` in every hello to a node on `46db23f57d6ef969`. The last word wins,
204
204
  * and the hello is the later one; `?build=` still works for a dial that carries it.
205
205
  */
@@ -76,6 +76,13 @@ export function frameRouter(options: FrameRouterOptions): FrameRouter {
76
76
  ? await presence.roster(name)
77
77
  : await presence.join(name, { id: socket.id, actorId: socket.actorId });
78
78
  if (presence) options.busAnswered?.();
79
+ // Asked after the awaits, because the race IS the awaits: `teardown` ran during the beat, the
80
+ // beat answered false, and the `join` above wrote a member no close will ever remove — a ghost
81
+ // on every other client's roster until the TTL. Taken back here, by this write's own author.
82
+ if (presence && socket.closed) {
83
+ await presence.leave(name, socket.id);
84
+ return;
85
+ }
79
86
  // Read, though nothing here can repair it: the next beat re-sends the set, and the log is the
80
87
  // only trace a dropped one leaves anywhere.
81
88
  if (!socket.send(presenceFrame(name, 'sync', roster.members, roster.total))) {
@@ -184,6 +191,7 @@ export function frameRouter(options: FrameRouterOptions): FrameRouter {
184
191
  input: frame.target.input,
185
192
  sid: frame.sid,
186
193
  cursor: frame.target.cursor,
194
+ ...(frame.traceparent === undefined ? {} : { traceparent: frame.traceparent }),
187
195
  });
188
196
  // `subscribe` has already seated the subscription and cleared its desync mark, so a reply
189
197
  // the socket refuses leaves the server believing a client that holds no rows is in sync:
@@ -142,3 +142,23 @@ export function clientHeartbeatMs(
142
142
  const idleBound = Math.max(1_000, Math.floor(idleTimeoutMs / 4));
143
143
  return Math.min(presenceBeatMs ?? DEFAULT_HEARTBEAT_MS, idleBound);
144
144
  }
145
+
146
+ const utf8 = new TextEncoder();
147
+
148
+ /**
149
+ * Closes the socket 1009 — Bun's own code for a payload past `maxPayloadLength` — when an
150
+ * inbound frame is over `maxFrameBytes`, and says so. The node refuses it itself: on a listener a device
151
+ * socket shares, Bun's limit is the widest mount's, not this node's.
152
+ */
153
+ export function refusedOverBytes(
154
+ ws: { close(code: number, reason: string): void },
155
+ message: string | Uint8Array,
156
+ limit: number,
157
+ ): boolean {
158
+ const over =
159
+ typeof message === 'string'
160
+ ? message.length * 3 > limit && utf8.encode(message).length > limit
161
+ : message.byteLength > limit;
162
+ if (over) ws.close(1009, 'frame over maxFrameBytes');
163
+ return over;
164
+ }
package/src/sync-node.ts CHANGED
@@ -32,6 +32,7 @@ import { ackRefOf, frameRouter } from './sync-frames';
32
32
  import {
33
33
  clientHeartbeatMs,
34
34
  drainGraceMs,
35
+ refusedOverBytes,
35
36
  socketCeilings,
36
37
  syncNodeBounds,
37
38
  } from './sync-node-bounds';
@@ -267,6 +268,7 @@ export function syncNode(options: SyncNodeOptions): SyncNode {
267
268
  // 120s budget under it decided nothing.
268
269
  idling = setInterval(() => {
269
270
  for (const socket of sockets.idle()) evict(socket, CLOSE.idle, 'idle timeout');
271
+ detach(options.registry.repairMarked(), 'sync.repair_sweep');
270
272
  }, idleSweepPeriodMs(sockets.idleTimeoutMs));
271
273
  idling.unref();
272
274
  if (options.authenticate) {
@@ -398,6 +400,8 @@ export function syncNode(options: SyncNodeOptions): SyncNode {
398
400
  message(ws: SyncWs, message: string | Uint8Array): void {
399
401
  const socket = sockets.get(ws.data.socketId);
400
402
  if (!socket) return;
403
+ // Bun's `maxPayloadLength` is the widest mount's on a shared listener: the bound is ours.
404
+ if (refusedOverBytes(ws, message, bounds.maxFrameBytes)) return;
401
405
  void (async () => {
402
406
  // Decoded into a binding the failure path can read: an ack has to name the thing that
403
407
  // failed — the mutation key the client's queue holds, the sid its subscription holds —
@@ -428,9 +432,13 @@ export function syncNode(options: SyncNodeOptions): SyncNode {
428
432
 
429
433
  drain(ws: SyncWs): void {
430
434
  // A `records` frame backpressure refused left this socket gapped on that channel; the
431
- // repair is the node's verdict, sent the moment the socket can take a frame again.
435
+ // repair is the node's verdict, sent the moment the socket can take a frame again. A live
436
+ // query's refused frame is the same debt, and its only other reader is the next change to
437
+ // that query, which may never come.
432
438
  const socket = sockets.get(ws.data.socketId);
433
- if (socket) sockets.gapRepairs.repairAll(socket);
439
+ if (!socket) return;
440
+ sockets.gapRepairs.repairAll(socket);
441
+ if (socket.desynced.size > 0) detach(options.registry.repair(socket), 'sync.drain_repair');
434
442
  },
435
443
 
436
444
  close(ws: SyncWs): void {
@@ -13,7 +13,8 @@ import { type OriginVerdict, proveSameOrigin } from '@ultimat3/core';
13
13
  * declaration IS the app's origin, so it is the whole list: the origin the node sees is derived
14
14
  * from the `Host` header, which names whatever host the request was aimed at — a sibling
15
15
  * subdomain pointed at this node is "its own origin" by that reading, and a `Domain=`-wide
16
- * session cookie rides its socket;
16
+ * session cookie rides its socket. `sec-fetch-site` is withheld from the rule here for the same
17
+ * reason: a browser says `same-origin` of the host it dialled, and that host is the question;
17
18
  * - **the node's own ORIGIN, only when nothing is declared** — host and port exactly as the
18
19
  * request reached it (`x dev`, a combined-role container). A host name alone is not this app:
19
20
  * cookies are not isolated by port. `admitReached` keeps it beside a declared list (`x dev`).
@@ -28,10 +29,11 @@ export function upgradeOrigin(
28
29
  if (origin === null) return { ok: true };
29
30
  // Normalised once (`https://x/` and `https://x` are one origin), then matched as whole strings.
30
31
  const admitted = allowedOrigins.map((allowed) => URL.parse(allowed)?.origin);
32
+ const listIsWhole = admitted.length > 0 && !admitReached;
31
33
  return proveSameOrigin({
32
- selfOrigins: admitted.length > 0 && !admitReached ? [] : selfOrigins(url),
34
+ selfOrigins: listIsWhole ? [] : selfOrigins(url),
33
35
  origin,
34
- secFetchSite: request.headers.get('sec-fetch-site'),
36
+ secFetchSite: listIsWhole ? null : request.headers.get('sec-fetch-site'),
35
37
  listed: (asked) => admitted.includes(asked),
36
38
  listName: 'APP_URL or syncNode({ allowedOrigins })',
37
39
  });
@@ -101,6 +101,13 @@ export interface SubscribeFrame {
101
101
  readonly op: 'add' | 'drop';
102
102
  readonly sid: string;
103
103
  readonly target: SubscribeTarget;
104
+ /**
105
+ * The page's W3C `traceparent`, so the snapshot this subscribe causes joins the page's trace.
106
+ * Optional and **additive**, no `PROTOCOL_VERSION` bump: an old node never reads it, an old
107
+ * client never sends it. A value that is not a short string is DROPPED, never refused — a
108
+ * garbage header costs the subscriber its trace, not its subscription.
109
+ */
110
+ readonly traceparent?: string;
104
111
  }
105
112
 
106
113
  export interface SnapshotFrame {
@@ -201,6 +208,9 @@ export const FRAME_KINDS: readonly FrameKind[] = [
201
208
  'replay-gap',
202
209
  ];
203
210
 
211
+ /** `00-<32>-<16>-<2>` is 55 characters; past this bound a value is dropped unread. */
212
+ const TRACEPARENT_MAX = 128;
213
+
204
214
  export function encode(frame: Frame): string {
205
215
  return JSON.stringify(frame);
206
216
  }
@@ -233,14 +243,19 @@ export function decode(raw: string | Uint8Array): Frame {
233
243
  ? hello
234
244
  : { ...hello, heartbeatMs: num(parsed, 'heartbeatMs') };
235
245
  }
236
- case 'subscribe':
237
- return {
246
+ case 'subscribe': {
247
+ const frame = {
238
248
  type: 'subscribe',
239
249
  v: PROTOCOL_VERSION,
240
250
  op: pick(parsed, 'op', ['add', 'drop'] as const),
241
251
  sid: str(parsed, 'sid'),
242
252
  target: target(parsed['target']),
243
- };
253
+ } as const;
254
+ const trace = parsed['traceparent'];
255
+ return typeof trace === 'string' && trace.length <= TRACEPARENT_MAX
256
+ ? { ...frame, traceparent: trace }
257
+ : frame;
258
+ }
244
259
  case 'snapshot': {
245
260
  const base = {
246
261
  type: 'snapshot',
@@ -4,11 +4,18 @@
4
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 { actionPath, isSuperseded, isUltimateError, pageClient, uuidV7 } from '@ultimat3/core/page';
7
+ import {
8
+ isSuperseded,
9
+ isUltimateError,
10
+ pageClient,
11
+ renderedActionPin,
12
+ uuidV7,
13
+ } from '@ultimat3/core/page';
8
14
  import type { JsonValue } from './json';
9
15
  import { knownOffline, type OutboxHandle, peekOutbox } from './outbox-slot';
10
16
  import { OfflineQueueAbandonedError, ServerRenderLiveError } from './page-errors';
11
17
  import { installedPage, type PageWrites, pageRealtime } from './page-store';
18
+ import { mutationWire } from './pinned-wire';
12
19
  import { isServerRender, signalFor } from './reactivity';
13
20
  import { carriedBy } from './record-store';
14
21
 
@@ -121,6 +128,11 @@ export function useMutation(mutator: MutatorLike): Mutate {
121
128
  const call = async (input: JsonValue): Promise<unknown> => {
122
129
  if (writes === undefined) throw new ServerRenderLiveError({ operation: 'useMutation()' });
123
130
  const page = installedPage('useMutation');
131
+ // The pin the server stamped into this document, resolved ONCE: the outbox keeps it with the
132
+ // write, so a replay goes where the write was aimed. Before anything is painted, counted or
133
+ // queued: an input that cannot fill the pin is no write.
134
+ const http = renderedActionPin(mutator.name);
135
+ const wire = mutationWire(mutator.name, input, http);
124
136
  const issued = pageClient().scope.epoch;
125
137
  const key = `${mutator.name}:${uuidV7()}`;
126
138
  const local = mutator.local;
@@ -152,7 +164,12 @@ export function useMutation(mutator: MutatorLike): Mutate {
152
164
  if (pageClient().scope.epoch !== issued) {
153
165
  throw new OfflineQueueAbandonedError({ name: mutator.name });
154
166
  }
155
- await outbox.enqueue({ key, name: mutator.name, input });
167
+ await outbox.enqueue({
168
+ key,
169
+ name: mutator.name,
170
+ input,
171
+ ...(http === undefined ? {} : { http }),
172
+ });
156
173
  } catch (error) {
157
174
  page.store.drop(key);
158
175
  // A principal change is nobody's failure to report; a disk that refused the write is.
@@ -196,9 +213,9 @@ export function useMutation(mutator: MutatorLike): Mutate {
196
213
  const carried = new Set<string>();
197
214
  try {
198
215
  output = await page.services.send({
199
- method: 'POST',
200
- url: actionPath(mutator.name),
201
- body: input,
216
+ method: wire.method,
217
+ url: wire.url,
218
+ body: wire.body,
202
219
  idempotencyKey: key,
203
220
  onEnvelope: (envelope) => carriedBy(envelope, carried),
204
221
  });
package/src/use-query.ts CHANGED
@@ -7,6 +7,7 @@ import { type AsyncState, isSuperseded, type RecordEnvelope, type Row } from '@u
7
7
  import type { LiveHandle } from './client-contract';
8
8
  import type { JsonValue } from './json';
9
9
  import { installedPage, type PageServices } from './page-store';
10
+ import type { QueryPoll } from './query-poll';
10
11
  import { isServerRender, signalFor } from './reactivity';
11
12
  import { keptAnswer } from './record-answer';
12
13
  import { type RecordStore, recordKey } from './record-store';
@@ -30,8 +31,13 @@ export interface QueryRef {
30
31
 
31
32
  /** A callable `AsyncState` over the list, plus the two things a caller does to it. */
32
33
  export type QueryAccessor<R extends object = Row> = (() => AsyncState<readonly R[]>) & {
33
- /** Read again from the first page, keeping the current rows on screen (`refreshing`). */
34
- refetch(): void;
34
+ /**
35
+ * Read again from the first page, keeping the current rows on screen (`refreshing`). Settles
36
+ * once the answer is on screen or refused — never rejects, so a pull or a button awaits it. A
37
+ * live read settles at once — its socket is its refresh — unless its subscription was refused:
38
+ * that one is asked again, and settles on the node's next answer.
39
+ */
40
+ refetch(): Promise<void>;
35
41
  /**
36
42
  * The next page, appended — from the cursor the server's last page answered with. A no-op on a
37
43
  * live read, on a read with no `first`, and once the server said there is no next page.
@@ -45,6 +51,7 @@ export type QueryAccessor<R extends object = Row> = (() => AsyncState<readonly R
45
51
 
46
52
  const PENDING: AsyncState<never> = Object.freeze({ status: 'pending' });
47
53
  const nothing = (): void => undefined;
54
+ const settled = (): Promise<void> => Promise.resolve();
48
55
 
49
56
  /**
50
57
  * `input` is read ONCE, at call time: there is no reactive runtime here to re-run it, and a
@@ -53,8 +60,33 @@ const nothing = (): void => undefined;
53
60
  export interface QueryOptions {
54
61
  /** Read in pages of this many rows (`query.page`); `more()` appends the next. Non-live only. */
55
62
  readonly first?: number;
63
+ /**
64
+ * `pollEvery(ms)`: re-read (`refetch`) on a timer that a hidden tab and a lost session stop, and
65
+ * `release` ends. The safety read beside an at-most-once channel. A VALUE and not a number, so
66
+ * the timer ships only in an island that polls. Non-live only, by type: a live read has its socket.
67
+ */
68
+ readonly poll?: QueryPoll;
56
69
  }
57
70
 
71
+ /** A live read's options: no `poll`, because the socket is its refresh. */
72
+ export type LiveQueryOptions = Omit<QueryOptions, 'poll'> & { readonly poll?: never };
73
+
74
+ export function useQuery<R extends object = Row>(
75
+ ref: QueryRef & { readonly live: true },
76
+ input: JsonValue,
77
+ options?: LiveQueryOptions,
78
+ ): QueryAccessor<R>;
79
+ export function useQuery<R extends object = Row>(
80
+ ref: QueryRef & { readonly live?: false },
81
+ input: JsonValue,
82
+ options?: QueryOptions,
83
+ ): QueryAccessor<R>;
84
+ /** A ref whose liveness the type does not know may be live, so it takes no `poll` either. */
85
+ export function useQuery<R extends object = Row>(
86
+ ref: QueryRef,
87
+ input: JsonValue,
88
+ options?: LiveQueryOptions,
89
+ ): QueryAccessor<R>;
58
90
  export function useQuery<R extends object = Row>(
59
91
  ref: QueryRef,
60
92
  input: JsonValue,
@@ -63,7 +95,7 @@ export function useQuery<R extends object = Row>(
63
95
  const signal = signalFor('useQuery');
64
96
  if (isServerRender()) {
65
97
  return Object.assign((): AsyncState<readonly R[]> => PENDING, {
66
- refetch: nothing,
98
+ refetch: settled,
67
99
  more: nothing,
68
100
  hasMore: () => false,
69
101
  release: nothing,
@@ -85,10 +117,6 @@ function liveAccessor<R extends object>(
85
117
  ): QueryAccessor<R> {
86
118
  // Seen once a snapshot landed — read or not — so a drop after it keeps the rows on screen.
87
119
  let seen = handle.state() === 'live';
88
- const off = handle.onChange(() => {
89
- if (handle.state() === 'live') seen = true;
90
- bump();
91
- });
92
120
  /** The last answer handed out: the same rows back keep its `data` array (`keptAnswer`). */
93
121
  let answer: AsyncState<readonly R[]> | null = null;
94
122
  const read = (): AsyncState<readonly R[]> => {
@@ -101,12 +129,32 @@ function liveAccessor<R extends object>(
101
129
  answer = keptAnswer(answer, state === 'live' ? 'ready' : 'refreshing', handle.rows());
102
130
  return answer;
103
131
  };
132
+ /** Settled by the next move after a retry — the snapshot, or a second refusal. */
133
+ let waiting: (() => void)[] = [];
134
+ const settle = (): void => {
135
+ const done = waiting;
136
+ waiting = [];
137
+ for (const resolve of done) resolve();
138
+ };
139
+ const off = handle.onChange(() => {
140
+ if (handle.state() === 'live') seen = true;
141
+ bump();
142
+ if (handle.state() !== 'loading') settle();
143
+ });
144
+ // The socket is a live read's refresh — except for a refused subscription on an open socket,
145
+ // which stays refused until the socket reconnects: that one is asked again, here. A second pull
146
+ // while the retry is out waits on the same answer.
147
+ const refetch = (): Promise<void> =>
148
+ waiting.length > 0 || (handle.state() === 'failed' && handle.retry())
149
+ ? new Promise((resolve) => waiting.push(resolve))
150
+ : settled();
104
151
  const release = (): void => {
105
152
  off();
153
+ settle();
106
154
  handle.unsubscribe();
107
155
  };
108
156
  return Object.assign(read, {
109
- refetch: nothing,
157
+ refetch,
110
158
  more: nothing,
111
159
  hasMore: () => false,
112
160
  release,
@@ -159,7 +207,8 @@ function readAccessor<R extends object>(
159
207
 
160
208
  /**
161
209
  * One answer: its rows, and — when the server sent the records envelope — the KEYS of this
162
- * read's records in answer order (`records[type]`, which `rowsOf` fills first-seen = data order).
210
+ * read's records in answer order: `order[type]` where the server sent one, because an object
211
+ * enumerates integer-like keys ascending; `records[type]`'s own key order where it did not.
163
212
  * The browser never derives a key: an answer with no records envelope holds its rows itself.
164
213
  */
165
214
  const fetch = (
@@ -167,8 +216,9 @@ function readAccessor<R extends object>(
167
216
  ): Promise<{ rows: readonly Row[]; keys: string[] | null; next?: string | null }> => {
168
217
  let keysOf: string[] | null = null;
169
218
  const onEnvelope = (envelope: RecordEnvelope): void => {
170
- const records = type === undefined ? undefined : envelope.records?.[type];
171
- keysOf = records === undefined ? null : Object.keys(records);
219
+ if (type === undefined) return;
220
+ const records = envelope.records?.[type];
221
+ keysOf = records === undefined ? null : [...(envelope.order?.[type] ?? Object.keys(records))];
172
222
  };
173
223
  const answered = (rows: readonly Row[]) => ({ rows, keys: keysOf });
174
224
  if (options.first === undefined) {
@@ -185,11 +235,12 @@ function readAccessor<R extends object>(
185
235
  }));
186
236
  };
187
237
 
188
- const load = (append = false): void => {
238
+ /** Settles when this read's answer is applied or refused — never rejects. */
239
+ const load = (append = false): Promise<void> => {
189
240
  const mine = ++generation;
190
241
  if (state.status === 'ready') state = { status: 'refreshing', data: rows() };
191
242
  bump();
192
- fetch(append).then(
243
+ return fetch(append).then(
193
244
  (answer) => {
194
245
  if (released || mine !== generation) return;
195
246
  if (answer.next !== undefined) after = answer.next;
@@ -217,17 +268,15 @@ function readAccessor<R extends object>(
217
268
  if (released || mine !== generation) return;
218
269
  // The page changed principal under this read: its answer was the previous one's, and the
219
270
  // store it would have landed in is already empty. Read again, as the new principal.
220
- if (isSuperseded(error)) {
221
- load();
222
- return;
223
- }
271
+ // Returned, so a poll waiting on this read waits on the re-read too.
272
+ if (isSuperseded(error)) return load();
224
273
  state = { status: 'failed', error };
225
274
  bump();
226
275
  },
227
276
  );
228
277
  };
229
278
 
230
- load();
279
+ void load();
231
280
  /**
232
281
  * The last answer handed out: a re-read that answered the same rows (a poll, a refetch, a return
233
282
  * to the tab) hands back its `data` array, so nothing derived from it runs again.
@@ -240,20 +289,30 @@ function readAccessor<R extends object>(
240
289
  answer = keptAnswer(answer, state.status, rows() as readonly R[]);
241
290
  return answer;
242
291
  };
292
+ const refetch = (): Promise<void> => {
293
+ after = null;
294
+ return load();
295
+ };
296
+ // The poll awaits each read, so a slow answer is never overlapped by the next one.
297
+ // A read that ended `failed` rejects for the poll alone, so `maxMs` backs it off; `load` and
298
+ // `refetch` keep resolving for everyone else.
299
+ const stopPoll = options.poll?.start(() =>
300
+ refetch().then(() => {
301
+ if (state.status === 'failed') throw state.error;
302
+ }),
303
+ );
243
304
  const release = (): void => {
244
305
  if (released) return;
245
306
  released = true;
307
+ stopPoll?.();
246
308
  off();
247
309
  hold([]);
248
310
  keys = [];
249
311
  };
250
312
  return Object.assign(read, {
251
- refetch: () => {
252
- after = null;
253
- load();
254
- },
313
+ refetch,
255
314
  more: () => {
256
- if (after !== null && state.status === 'ready') load(true);
315
+ if (after !== null && state.status === 'ready') void load(true);
257
316
  },
258
317
  hasMore: () => {
259
318
  version();