@ultimat3/realtime 27.10.1 → 27.10.3

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
@@ -236,9 +236,13 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
236
236
  - **The socket is READ-ONLY (protocol 3)**. A write is `useMutation`: the twin into the overlay, then
237
237
  `POST actionPath(name)` through core's `clientTransport` with an idempotency key; the answer's
238
238
  records are adopted inside the transport call, before `settle`. An `ack` is only a refusal.
239
+ KNOWN offline (`knownOffline()`), it is queued with no POST and painted once on disk (#777).
239
240
  - **`LiveClient` holds no signal** — plain reads plus `onStatus` / `onChange`; hooks wrap them.
240
241
  - **An answer that did not carry a touched row keeps that row's overlay** until the server's next row
241
242
  for it, bounded by `DEFAULT_AWAIT_SERVER_MS` (10 s).
243
+ - **A catch-up read waits for the page's writes in flight over its types**
244
+ (`RecordStore.afterWrites`): it lands once each is answered or echoed, within that bound; a row
245
+ moved meanwhile is kept (#778).
242
246
  - **A frame names the write that produced it**: `records.write`, `patch.writes` (a live patch
243
247
  one, a resume each it replays, off `RowPatch.write` in the ring) — `writeDigest(key)`, from a
244
248
  REQUIRED `ChangeEvent.write: string | null`. `RecordStore.push` digests each overlay key first
@@ -294,15 +298,12 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
294
298
 
295
299
  ## Map
296
300
 
297
- `index.ts` / `server.ts` are the two barrels. Server side: `sync-node.ts` (+ `sync-auth`,
298
- `sync-frames`, `sync-upgrade`, `sync-listen`, `drain-evictions`), `channel.ts`, `presence.ts`,
299
- `socket.ts`, `live-query.ts` (+ `live-definition`, `live-refusal`, `live-tenant`, `live-resume`, `query-window`, `live-fanout`, `window-lock`,
300
- `subscriber-gate`, `policy-gate` — the only authz seam — and `matcher-bridge`), `replicator.ts` (+ `replicator-envelope`) /
301
- `live-replicator.ts`, the Postgres client (`pg-*.ts`, `pgoutput.ts`), the bus (`nats-*.ts`,
302
- `transport-env.ts`, `fanout.ts`). Client side: `client*.ts`, `socket-engine.ts` / `socket-host.ts` /
303
- `sync-worker.ts`, `record-*.ts`, `page-*.ts`, `boot.ts`, `offline-queue.ts`, `use-*.ts`,
304
- `reactivity.ts`. Shared: `sync-protocol.ts` (the wire), `cursor.ts`, `change-buffer.ts`,
305
- `thundering-herd.ts`, `live-contract.ts`, `json.ts` (no hash), `type-pins.ts`.
301
+ `index.ts` / `server.ts` are the two barrels. Server side: `sync-*.ts`, `channel*.ts`, `presence.ts`,
302
+ `socket.ts`, `live-*.ts` (`policy-gate.ts` is the only authz seam), `replicator*.ts`, the Postgres
303
+ client (`pg-*.ts`, `pgoutput.ts`), the bus (`nats-*.ts`, `transport-env.ts`, `fanout.ts`). Client
304
+ side: `client*.ts`, `socket-*.ts`, `sync-worker.ts`, `record-*.ts`, `page-*.ts`, `catch-up-hold.ts`,
305
+ `boot.ts`, `offline-queue.ts`, `use-*.ts`, `reactivity.ts`. Shared: `sync-protocol.ts` (the wire),
306
+ `cursor.ts`, `change-buffer.ts`, `thundering-herd.ts`, `live-contract.ts`, `json.ts`, `type-pins.ts`.
306
307
 
307
308
  ## Commands
308
309
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "27.10.1",
3
+ "version": "27.10.3",
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",
@@ -40,10 +40,10 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/core": "27.10.1",
44
- "@ultimat3/entity": "27.10.1",
45
- "@ultimat3/policy": "27.10.1",
46
- "@ultimat3/query": "27.10.1",
43
+ "@ultimat3/core": "27.10.3",
44
+ "@ultimat3/entity": "27.10.3",
45
+ "@ultimat3/policy": "27.10.3",
46
+ "@ultimat3/query": "27.10.3",
47
47
  "nats": "2.29.3"
48
48
  }
49
49
  }
@@ -0,0 +1,76 @@
1
+ // A channel's catch-up read, held until this page's writes in flight over its record types are
2
+ // answered (#778): the read names no write, and its snapshot may already hold one whose twin is
3
+ // still on screen — adopted then, the like is counted twice until the write's answer or echo.
4
+ // The store bounds the hold (`afterWrites`); a row the server moved while it was held is left alone.
5
+
6
+ import type { Row } from '@ultimat3/core/page';
7
+ import type { CatchUpRecords } from './channel-catch-up';
8
+ import { recordKey } from './record-key';
9
+ import type { HeldLanding } from './record-landings';
10
+ import type { RecordStore } from './record-store';
11
+
12
+ /**
13
+ * `land` gets the read once nothing blocks it, minus every COLUMN whose server truth moved
14
+ * meanwhile: that value ARRIVED after the read did — a write's own answer, another subscription's
15
+ * frame — and the store's rule is that the later arrival stands. A column nothing moved is still
16
+ * the read's (the store merges by column: a narrower query moving `likes` says nothing of `title`).
17
+ * A row gone since — removed, or evicted — is not brought back, and a removal is skipped for a row
18
+ * that moved at all.
19
+ */
20
+ export function holdCatchUp(
21
+ store: RecordStore,
22
+ answered: CatchUpRecords | undefined,
23
+ echoed: () => Iterable<string>,
24
+ land: (fresh: CatchUpRecords | undefined) => void,
25
+ ): HeldLanding {
26
+ if (answered === undefined) {
27
+ land(undefined);
28
+ return { recheck: () => undefined };
29
+ }
30
+ const seen = new Map<string, Row | undefined>();
31
+ const types = new Set<string>();
32
+ for (const [type, rows] of Object.entries(answered.records ?? {})) {
33
+ types.add(type);
34
+ for (const key of Object.keys(rows)) seen.set(recordKey(type, key), store.synced(type, key));
35
+ }
36
+ for (const [type, keys] of Object.entries(answered.removed ?? {})) {
37
+ types.add(type);
38
+ for (const key of keys) seen.set(recordKey(type, key), store.synced(type, key));
39
+ }
40
+ return store.afterWrites(types, () => land(unmovedOnly(store, answered, seen)), echoed);
41
+ }
42
+
43
+ /** Built with `fromEntries`, never by assignment: a record type and a column are the server's data. */
44
+ function unmovedOnly(
45
+ store: RecordStore,
46
+ answered: CatchUpRecords,
47
+ seen: ReadonlyMap<string, Row | undefined>,
48
+ ): CatchUpRecords {
49
+ const records = Object.fromEntries(
50
+ Object.entries(answered.records ?? {}).map(([type, rows]) => [
51
+ type,
52
+ Object.fromEntries(
53
+ Object.entries(rows).flatMap(([key, row]) => {
54
+ const kept = unmovedColumns(seen.get(recordKey(type, key)), store.synced(type, key), row);
55
+ return kept === undefined ? [] : [[key, kept]];
56
+ }),
57
+ ),
58
+ ]),
59
+ );
60
+ const removed = Object.fromEntries(
61
+ Object.entries(answered.removed ?? {}).map(([type, keys]) => [
62
+ type,
63
+ keys.filter((key) => seen.get(recordKey(type, key)) === store.synced(type, key)),
64
+ ]),
65
+ );
66
+ return { ...answered, records, removed };
67
+ }
68
+
69
+ /** The read's row without the columns that moved between `before` and `now`; none if it is gone. */
70
+ function unmovedColumns(before: Row | undefined, now: Row | undefined, read: Row): Row | undefined {
71
+ if (before === now) return read;
72
+ if (now === undefined) return undefined;
73
+ return Object.fromEntries(
74
+ Object.entries(read).filter(([column]) => before?.[column] === now[column]),
75
+ );
76
+ }
@@ -0,0 +1,35 @@
1
+ // A channel's catch-up read, as the page's socket runs it: an ordinary query read whose records the
2
+ // transport does NOT adopt. They come back to the channel book, which adopts them in ONE
3
+ // notification with the frames it held behind the read — the echo of a pending write among them.
4
+
5
+ import type { RecordEnvelope } from '@ultimat3/core/page';
6
+ import { pageClient } from '@ultimat3/core/page';
7
+ import { queryClientMethodFor } from '@ultimat3/query/client';
8
+
9
+ /** What a catch-up read answered for the store; `undefined` for a read that answers no records. */
10
+ export interface CatchUpRecords extends Pick<RecordEnvelope, 'records' | 'removed'> {
11
+ /**
12
+ * Whether the principal the read was made for is still the page's. The transport fences the read
13
+ * itself; these rows are adopted later, by the book, so the book asks again at adoption — the
14
+ * fence `useMutation` keeps on its own POST. Absent: always current.
15
+ */
16
+ readonly current?: (() => boolean) | undefined;
17
+ }
18
+
19
+ export async function catchUpRead(
20
+ query: string,
21
+ params: Readonly<Record<string, string>>,
22
+ ): Promise<CatchUpRecords | undefined> {
23
+ const issued = pageClient().scope.epoch;
24
+ let answered: CatchUpRecords | undefined;
25
+ await queryClientMethodFor(query, { baseUrl: '' })(params, {
26
+ adopt: false,
27
+ onEnvelope: (envelope) => {
28
+ answered = {
29
+ ...envelope,
30
+ current: () => pageClient().scope.epoch === issued,
31
+ };
32
+ },
33
+ });
34
+ return answered;
35
+ }
@@ -2,9 +2,12 @@
2
2
  // per-channel cursor that decides duplicate from new, and the catch-up read a `replay-gap` or a
3
3
  // new epoch triggers. `records` frames go to the store and nowhere else; `events` go to handlers.
4
4
 
5
+ import { holdCatchUp } from './catch-up-hold';
6
+ import type { CatchUpRecords } from './channel-catch-up';
5
7
  import { type PresenceEvent, readPresence } from './channel-presence';
6
8
  import type { JsonObject } from './json';
7
9
  import { carriedBy, type RecordKey } from './record-key';
10
+ import type { HeldLanding } from './record-landings';
8
11
  import type { RecordStore } from './record-store';
9
12
  import type {
10
13
  ChannelEventsFrame,
@@ -35,8 +38,14 @@ export interface ChannelBookDeps {
35
38
  readonly store: RecordStore;
36
39
  send(frame: SubscribeFrame): void;
37
40
  connected(): boolean;
38
- /** Re-run the channel's catch-up read; its records land in the store through the transport. */
39
- catchUp(query: string, params: Readonly<Record<string, string>>): Promise<unknown>;
41
+ /**
42
+ * Re-run the channel's catch-up read and answer its records UNADOPTED (`channel-catch-up.ts`): the
43
+ * book adopts them in one notification with the frames it held behind the read.
44
+ */
45
+ catchUp(
46
+ query: string,
47
+ params: Readonly<Record<string, string>>,
48
+ ): Promise<CatchUpRecords | undefined>;
40
49
  report(error: unknown): void;
41
50
  /**
42
51
  * When a failed catch-up tries again on its own, on the client's reconnect curve. Absent, a
@@ -73,6 +82,8 @@ interface Entry {
73
82
  failures: number;
74
83
  /** Disarms the scheduled retry. */
75
84
  disarm: (() => void) | null;
85
+ /** The read ANSWERED and waits on this page's writes in flight (`catch-up-hold.ts`). */
86
+ landing: HeldLanding | null;
76
87
  }
77
88
 
78
89
  export interface ChannelMembership extends Disposable {
@@ -117,6 +128,7 @@ export class ChannelBook {
117
128
  waiting: false,
118
129
  failures: 0,
119
130
  disarm: null,
131
+ landing: null,
120
132
  };
121
133
  this.#byTopic.set(topic, entry);
122
134
  if (this.#deps.connected()) this.#deps.send(this.#frame(entry, 'add', true));
@@ -199,6 +211,8 @@ export class ChannelBook {
199
211
  if (entry === undefined) return;
200
212
  if (entry.buffered !== null) {
201
213
  entry.buffered.push(frame);
214
+ // The echo of a write the answered read waits on: it lands with the read, settling it there.
215
+ if (frame.write !== undefined) entry.landing?.recheck();
202
216
  return;
203
217
  }
204
218
  if (entry.epoch !== null && entry.epoch !== frame.epoch) {
@@ -303,10 +317,26 @@ export class ChannelBook {
303
317
  entry.disarm?.();
304
318
  entry.disarm = null;
305
319
  this.#set(entry, 'catching-up');
306
- // Names no write, by decision (O-507, #648, 2026-10-07): a pending write this read already
307
- // reflects settles on its own HTTP answer. Reopen only on an observed double paint.
320
+ // The read names no write (O-507). A pending write whose echo is HELD behind the read is settled
321
+ // in the same notification as the read's rows (`#drain`). One whose echo comes AFTER the read
322
+ // answered (replication slower than the read) is waited for (#778): the read lands once the
323
+ // writes in flight over its types are answered or echoed, bounded by the store's server wait.
308
324
  this.#deps.catchUp(entry.catchUp, entry.params).then(
309
- () => this.#drain(entry),
325
+ (answered) => {
326
+ if (this.#byTopic.get(entry.topic) !== entry) return;
327
+ let landed = false;
328
+ const held = holdCatchUp(
329
+ this.#deps.store,
330
+ answered,
331
+ () => echoes(entry),
332
+ (fresh) => {
333
+ landed = true;
334
+ entry.landing = null;
335
+ this.#drain(entry, fresh);
336
+ },
337
+ );
338
+ if (!landed) entry.landing = held;
339
+ },
310
340
  (error: unknown) => {
311
341
  this.#deps.report(error);
312
342
  // NOT live: the frames held behind the read would land over a store it never refreshed.
@@ -326,7 +356,20 @@ export class ChannelBook {
326
356
  );
327
357
  }
328
358
 
329
- #drain(entry: Entry): void {
359
+ #drain(entry: Entry, answered?: CatchUpRecords): void {
360
+ // Released while the read was out: its rows and frames are a seat nobody holds, and a new hold
361
+ // of the topic may already hold newer rows. Nothing lands and nothing is re-read.
362
+ if (this.#byTopic.get(entry.topic) !== entry) return;
363
+ // The principal the read was made for has left (a sign-out cleared the store while the read
364
+ // was held): the frames held behind it are theirs too. Dropped, never applied to the next
365
+ // principal's store, and the channel is read again — as whoever holds the page now.
366
+ if (answered?.current?.() === false) {
367
+ entry.buffered = [];
368
+ entry.again = null;
369
+ this.#restart(entry, entry.epoch);
370
+ this.#read(entry);
371
+ return;
372
+ }
330
373
  const held = entry.buffered ?? [];
331
374
  // A newer epoch arrived while the read was in flight (the node restarted mid-read): the read
332
375
  // answered for the OLD one, so it is re-read in the new one, keeping only that epoch's frames.
@@ -335,6 +378,9 @@ export class ChannelBook {
335
378
  if (entry.again !== null || newer !== undefined) {
336
379
  const epoch = newer?.epoch ?? entry.again?.epoch ?? entry.epoch;
337
380
  entry.again = null;
381
+ // Its rows land now, alone: they are newer than the store's, and a re-read that fails would
382
+ // otherwise leave the older ones for the whole retry curve. The frames wait for the re-read.
383
+ this.#adopt(answered);
338
384
  entry.buffered = held.filter((frame) => frame.epoch === epoch);
339
385
  this.#restart(entry, epoch);
340
386
  this.#read(entry);
@@ -343,13 +389,29 @@ export class ChannelBook {
343
389
  entry.buffered = null;
344
390
  entry.failures = 0;
345
391
  const current = [...held].sort((a, b) => a.seq - b.seq);
346
- for (const frame of current) {
347
- if (entry.contiguous === null) entry.contiguous = frame.seq - 1;
348
- this.#apply(entry, frame);
349
- }
392
+ const store = this.#deps.store;
393
+ // `#adopt` and `#apply` open batches of their own inside this one: `RecordStore.batch` nests,
394
+ // so the read's rows and every held frame are still ONE notification.
395
+ store.batch(() => {
396
+ this.#adopt(answered);
397
+ for (const frame of current) {
398
+ if (entry.contiguous === null) entry.contiguous = frame.seq - 1;
399
+ this.#apply(entry, frame);
400
+ }
401
+ });
350
402
  if (entry.state === 'catching-up') this.#set(entry, 'live');
351
403
  }
352
404
 
405
+ /** A catch-up read's rows, unless the principal it was read for has left (`CatchUpRecords`). */
406
+ #adopt(answered: CatchUpRecords | undefined): void {
407
+ if (answered === undefined || answered.current?.() === false) return;
408
+ const store = this.#deps.store;
409
+ store.batch(() => {
410
+ for (const [type, rows] of Object.entries(answered.records ?? {})) store.adopt(type, rows);
411
+ for (const [type, keys] of Object.entries(answered.removed ?? {})) store.remove(type, keys);
412
+ });
413
+ }
414
+
353
415
  #set(entry: Entry, state: ChannelState): void {
354
416
  entry.state = state;
355
417
  for (const listener of entry.listeners) listener();
@@ -383,6 +445,13 @@ export function isTransientRefusal(error: unknown): boolean {
383
445
  );
384
446
  }
385
447
 
448
+ /** The writes the frames held behind a read name — each lands, and settles, with the read. */
449
+ function echoes(entry: Entry): string[] {
450
+ const named: string[] = [];
451
+ for (const frame of entry.buffered ?? []) if (frame.write !== undefined) named.push(frame.write);
452
+ return named;
453
+ }
454
+
386
455
  /** A channel membership's sid is its topic behind this — what the socket engine routes on too. */
387
456
  export const CHANNEL_SID_PREFIX = 'channel:';
388
457
 
@@ -3,6 +3,7 @@
3
3
  // shapes without importing the connection lifecycle underneath — and the bytes that come with it.
4
4
 
5
5
  import type { Clock, Row } from '@ultimat3/core/page';
6
+ import type { CatchUpRecords } from './channel-catch-up';
6
7
  import type { LiveCursor } from './cursor';
7
8
  import type { LiveState } from './live-rows';
8
9
  import type { RecordStore } from './record-store';
@@ -71,8 +72,12 @@ export interface LiveClientOptions {
71
72
  /** Where a failure nobody awaits is reported. Defaults to `console.error`. */
72
73
  readonly onError?: (error: unknown) => void;
73
74
  /**
74
- * A channel's catch-up read, re-run on `replay-gap` or a new epoch: the named query, through
75
- * core's transport, so its records land in the store. `page-socket.ts` supplies the real one.
75
+ * A channel's catch-up read, re-run on `replay-gap` or a new epoch: the named query, answering its
76
+ * records UNADOPTED for the channel book to adopt with the frames held behind the read.
77
+ * `page-socket.ts` supplies the real one (`channel-catch-up.ts`).
76
78
  */
77
- readonly catchUp: (query: string, params: Readonly<Record<string, string>>) => Promise<unknown>;
79
+ readonly catchUp: (
80
+ query: string,
81
+ params: Readonly<Record<string, string>>,
82
+ ) => Promise<CatchUpRecords | undefined>;
78
83
  }
@@ -41,3 +41,19 @@ export type OutboxHost = { [OUTBOX_KEY]?: OutboxHandle };
41
41
  export function peekOutbox(): OutboxHandle | undefined {
42
42
  return (globalThis as OutboxHost)[OUTBOX_KEY];
43
43
  }
44
+
45
+ /**
46
+ * The browser says it has no network. A request then is an attempt that can only fail — and a
47
+ * failed attempt is still a request on the wire: measured, a reload taken offline replayed on open,
48
+ * that POST failed, and the real one followed on `online`, so one write went out twice. The
49
+ * `online` event is the outbox's replay trigger, so nothing is lost by waiting for it. Unknown (no
50
+ * `navigator`) is not offline.
51
+ */
52
+ export function knownOffline(): boolean {
53
+ const navigator: unknown = Reflect.get(globalThis, 'navigator');
54
+ return (
55
+ typeof navigator === 'object' &&
56
+ navigator !== null &&
57
+ Reflect.get(navigator, 'onLine') === false
58
+ );
59
+ }
@@ -18,7 +18,13 @@ import type { LocalStore } from './local-store-idb';
18
18
  import { pageLocalStore, scopeKey } from './local-store-idb';
19
19
  import type { DrainReport, QueuedMutation, QueueState, QueueStore } from './offline-queue';
20
20
  import { memoryQueueStore, OfflineQueue, toQueueError } from './offline-queue';
21
- import { OUTBOX_KEY, type OutboxEntry, type OutboxHandle, type OutboxHost } from './outbox-slot';
21
+ import {
22
+ knownOffline,
23
+ OUTBOX_KEY,
24
+ type OutboxEntry,
25
+ type OutboxHandle,
26
+ type OutboxHost,
27
+ } from './outbox-slot';
22
28
  import { type OutboxTabs, type OutboxTabsWindow, outboxTabs } from './outbox-tabs';
23
29
  import { LocalStoreUnavailableError, OfflineQueueAbandonedError } from './page-errors';
24
30
  import { peekPageRealtime } from './page-store';
@@ -427,19 +433,3 @@ export function listenForDrain(outbox: Pick<PageOutbox, 'replay'>): () => void {
427
433
  globalThis.removeEventListener('online', replay);
428
434
  };
429
435
  }
430
-
431
- /**
432
- * The browser says it has no network. A replay then is an attempt that can only fail — and a
433
- * failed attempt is still a request on the wire: measured, a reload taken offline replayed on open,
434
- * that POST failed, and the real one followed on `online`, so one write went out twice. The
435
- * `online` event is the replay's own trigger, so nothing is lost by waiting for it. Unknown (no
436
- * `navigator`) is not offline.
437
- */
438
- function knownOffline(): boolean {
439
- const navigator: unknown = Reflect.get(globalThis, 'navigator');
440
- return (
441
- typeof navigator === 'object' &&
442
- navigator !== null &&
443
- Reflect.get(navigator, 'onLine') === false
444
- );
445
- }
@@ -3,7 +3,7 @@
3
3
  // the connection lifecycle: a hook reaches it as `services.socket`, off the page object.
4
4
 
5
5
  import { onRescope, pageClient } from '@ultimat3/core/page';
6
- import { queryClientMethodFor } from '@ultimat3/query/client';
6
+ import { catchUpRead } from './channel-catch-up';
7
7
  import { LiveClient } from './client';
8
8
  import { DEFAULT_HEARTBEAT_MS } from './client-heartbeat';
9
9
  import { peekOutbox } from './outbox-slot';
@@ -33,8 +33,7 @@ export function pageSocket(hook: string): LiveClient {
33
33
  buildId: target.buildId,
34
34
  actorId: pageClient().scope.principal ?? null,
35
35
  store: page.store,
36
- // The channel's catch-up read is an ordinary query read: core's transport adopts its records.
37
- catchUp: (query, params) => queryClientMethodFor(query, { baseUrl: '' })(params),
36
+ catchUp: catchUpRead,
38
37
  });
39
38
  page.socket = client;
40
39
  pageClient().socket = client;
@@ -44,6 +44,16 @@ export class ServerWait {
44
44
  this.#awaiting.set(key, { rows, cancel });
45
45
  }
46
46
 
47
+ /** Whether `key`'s write was answered and its overlay now waits for server truth. */
48
+ awaiting(key: string): boolean {
49
+ return this.#awaiting.has(key);
50
+ }
51
+
52
+ /** `fire` after this wait's bound — the one bound on waiting for the server (`awaitMs`). */
53
+ bound(fire: () => void): () => void {
54
+ return this.#schedule(fire, this.#ms);
55
+ }
56
+
47
57
  /** The overlay went some other way: its wait and what it heard go with it. */
48
58
  forget(key: string): void {
49
59
  this.#awaiting.get(key)?.cancel();
@@ -0,0 +1,69 @@
1
+ // Server truth that must wait for the page's own writes before it lands (#778): a channel's
2
+ // catch-up read whose snapshot may already hold a write still in flight. Adopted under that write's
3
+ // twin it is the write counted twice, so it lands once nothing blocks it — or when the store's one
4
+ // server-wait bound passes, and then exactly as it always did.
5
+
6
+ /** A held landing. `recheck` asks again now, for what changed outside the store (a held echo). */
7
+ export interface HeldLanding {
8
+ recheck(): void;
9
+ }
10
+
11
+ interface Held {
12
+ readonly blocked: () => boolean;
13
+ fire(): void;
14
+ }
15
+
16
+ const LANDED: HeldLanding = { recheck: () => undefined };
17
+
18
+ export class Landings {
19
+ readonly #held = new Set<Held>();
20
+ readonly #bound: (fire: () => void) => () => void;
21
+ readonly #report: (error: unknown) => void;
22
+
23
+ /**
24
+ * `bound` arms the ceiling on one hold: the store's server wait, never a second number. A held
25
+ * landing runs inside whatever batch released it, so what it throws is `report`ed, never raised
26
+ * into that unrelated caller (a write's settle) or left to stop the landings behind it.
27
+ */
28
+ constructor(bound: (fire: () => void) => () => void, report: (error: unknown) => void) {
29
+ this.#bound = bound;
30
+ this.#report = report;
31
+ }
32
+
33
+ /** `land` runs at once when nothing blocks it; otherwise at the first check that finds none. */
34
+ hold(blocked: () => boolean, land: () => void): HeldLanding {
35
+ if (!blocked()) {
36
+ land();
37
+ return LANDED;
38
+ }
39
+ let done = false;
40
+ let cancel = (): void => undefined;
41
+ const held: Held = {
42
+ blocked,
43
+ fire: () => {
44
+ if (done) return;
45
+ done = true;
46
+ this.#held.delete(held);
47
+ cancel();
48
+ try {
49
+ land();
50
+ } catch (error) {
51
+ this.#report(error);
52
+ }
53
+ },
54
+ };
55
+ this.#held.add(held);
56
+ cancel = this.#bound(() => held.fire());
57
+ return {
58
+ recheck: () => {
59
+ if (!done && !blocked()) held.fire();
60
+ },
61
+ };
62
+ }
63
+
64
+ /** After every store notification: whatever nothing blocks any longer lands, in hold order. */
65
+ check(): void {
66
+ if (this.#held.size === 0) return;
67
+ for (const held of [...this.#held]) if (!held.blocked()) held.fire();
68
+ }
69
+ }
@@ -8,6 +8,7 @@ import { resolveConflict } from '@ultimat3/core/page';
8
8
  import { RebaseConflictError, RecordRejectedError } from './page-errors';
9
9
  import { ServerWait } from './record-await';
10
10
  import { isRow, type RecordKey, recordKey } from './record-key';
11
+ import { type HeldLanding, Landings } from './record-landings';
11
12
  import { WriteNames } from './record-names';
12
13
  import { SyncedLayer } from './record-synced';
13
14
  import { type OverlayEntry, replayOverlays, sameRow } from './record-tx';
@@ -45,10 +46,16 @@ export class RecordStore implements RecordSink {
45
46
  readonly #wait: ServerWait;
46
47
  /** Which pending overlay each write digest names — what a frame's `write` is looked up in. */
47
48
  readonly #names = new WriteNames();
49
+ /** Server truth held behind this page's writes in flight (`afterWrites`). */
50
+ readonly #landings: Landings;
48
51
 
49
52
  constructor(options: RecordStoreOptions = {}) {
50
53
  this.#report = options.report ?? ((error) => console.error(error));
51
54
  this.#wait = new ServerWait(options.schedule, options.awaitMs);
55
+ this.#landings = new Landings(
56
+ (fire) => this.#wait.bound(fire),
57
+ (error) => this.#report(error),
58
+ );
52
59
  }
53
60
 
54
61
  /** The record as every holder sees it: the overlay's answer over synced truth. */
@@ -260,6 +267,33 @@ export class RecordStore implements RecordSink {
260
267
  this.#wait.start(key, this.#touched.get(key) ?? new Set(), () => this.drop(key));
261
268
  }
262
269
 
270
+ /**
271
+ * Runs `land` once every write IN FLIGHT NOW over a record of `types` is answered — settled,
272
+ * dropped, or waiting for server truth — or its echo is among the `echoed` digests (a frame held
273
+ * to land with `land`), or once the server-wait bound (`awaitMs`) passes, whichever is first. A
274
+ * write made later does not extend it. What a catch-up read's rows wait on (#778): adopted under
275
+ * a twin whose write the read already holds, they paint that write twice.
276
+ */
277
+ afterWrites(
278
+ types: ReadonlySet<string>,
279
+ land: () => void,
280
+ echoed: () => Iterable<string> = () => [],
281
+ ): HeldLanding {
282
+ const prefixes = [...types].map((type) => `${type}:`);
283
+ const over = (rk: RecordKey): boolean => prefixes.some((prefix) => rk.startsWith(prefix));
284
+ const writes = [...this.#overlays.keys()].filter((key) =>
285
+ [...(this.#touched.get(key) ?? [])].some(over),
286
+ );
287
+ const inFlight = (key: string): boolean => this.#overlays.has(key) && !this.#wait.awaiting(key);
288
+ return this.#landings.hold(() => {
289
+ const live = writes.filter(inFlight);
290
+ if (live.length === 0) return false;
291
+ const named = new Set<string | undefined>();
292
+ for (const digest of echoed()) named.add(this.#names.keyOf(digest));
293
+ return live.some((key) => !named.has(key));
294
+ }, land);
295
+ }
296
+
263
297
  /** Pending optimistic writes, oldest first. */
264
298
  pending(): readonly string[] {
265
299
  return [...this.#overlays.keys()];
@@ -302,6 +336,8 @@ export class RecordStore implements RecordSink {
302
336
  this.#moved.clear();
303
337
  this.#changed = null;
304
338
  if (collected.size > 0) for (const listener of this.#listeners) listener(collected);
339
+ // A write that settled in this batch may be the last a held landing waited on.
340
+ this.#landings.check();
305
341
  }
306
342
  }
307
343
 
@@ -6,7 +6,7 @@
6
6
  import type { ConflictPolicy } from '@ultimat3/core/page';
7
7
  import { actionPath, isSuperseded, isUltimateError, pageClient, uuidV7 } from '@ultimat3/core/page';
8
8
  import type { JsonValue } from './json';
9
- import { type OutboxHandle, peekOutbox } from './outbox-slot';
9
+ import { knownOffline, type OutboxHandle, peekOutbox } from './outbox-slot';
10
10
  import { OfflineQueueAbandonedError, ServerRenderLiveError } from './page-errors';
11
11
  import { installedPage, type PageWrites, pageRealtime } from './page-store';
12
12
  import { isServerRender, signalFor } from './reactivity';
@@ -125,14 +125,21 @@ export function useMutation(mutator: MutatorLike): Mutate {
125
125
  const key = `${mutator.name}:${uuidV7()}`;
126
126
  const local = mutator.local;
127
127
  // Called back through the mutator: `local` may be a method, and an unbound one loses `this`.
128
- if (local !== undefined) {
128
+ const paint = (): void => {
129
+ if (local === undefined) return;
129
130
  page.store.push(key, (tx) => mutator.local?.(tx, input), mutator.conflict ?? 'server-wins');
130
- }
131
- count(writes, mutator.name, 1);
131
+ };
132
132
  // Older writes still wait in the outbox: this one queues BEHIND them rather than overtaking
133
133
  // them over HTTP — a like queued offline and the unlike made once the network was back could
134
134
  // otherwise land swapped. The replay sends the queue in order, this write last, under its key.
135
135
  const queued = peekOutbox();
136
+ // The browser SAYS it is offline (#777): a POST can only fail, so the write goes to the outbox
137
+ // without one, and reaches the disk BEFORE its twin reaches the screen. Painted first, it was
138
+ // durable only after the failed POST, the persister's flush and the queue write — a reload in
139
+ // that window came back with the old count and an empty outbox. The `online` event replays it.
140
+ const offline = queued !== undefined && knownOffline();
141
+ if (!offline) paint();
142
+ count(writes, mutator.name, 1);
136
143
  // The outbox may REFUSE: its queue's principal left mid-write (`X_OFFLINE_QUEUE_ABANDONED`),
137
144
  // or the disk would not take it. Nothing will ever send this write then, so its twin comes
138
145
  // off the screen and the caller is told — a rejected `mutate`, by code.
@@ -156,8 +163,9 @@ export function useMutation(mutator: MutatorLike): Mutate {
156
163
  }
157
164
  };
158
165
  // Asked of the DURABLE queue, re-read now: another tab of this user may have queued writes this
159
- // tab's copy has not heard of yet, and sending past them reorders the user's intent.
160
- if (queued !== undefined) {
166
+ // tab's copy has not heard of yet, and sending past them reorders the user's intent. Offline,
167
+ // nothing is sent, so the question is moot and its read would only delay the disk write.
168
+ if (queued !== undefined && !offline) {
161
169
  await queued.refresh().catch(() => undefined);
162
170
  // That read is an await: a principal that left meanwhile must not have this write POSTed, or
163
171
  // queued, as the next one. Refused by the code `enqueue` uses for the same departure.
@@ -167,9 +175,16 @@ export function useMutation(mutator: MutatorLike): Mutate {
167
175
  throw new OfflineQueueAbandonedError({ name: mutator.name });
168
176
  }
169
177
  }
170
- if (queued !== undefined && queued.pending().length > 0) {
178
+ if (queued !== undefined && (offline || queued.pending().length > 0)) {
171
179
  try {
172
180
  await enqueue(queued);
181
+ if (offline) {
182
+ // Queued under the principal that made it; one that left since takes its page with it.
183
+ if (pageClient().scope.epoch !== issued) {
184
+ throw new OfflineQueueAbandonedError({ name: mutator.name });
185
+ }
186
+ paint();
187
+ }
173
188
  void queued.replay().catch(() => undefined);
174
189
  return undefined;
175
190
  } finally {