@ultimat3/realtime 27.10.2 → 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.2",
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.2",
44
- "@ultimat3/entity": "27.10.2",
45
- "@ultimat3/policy": "27.10.2",
46
- "@ultimat3/query": "27.10.2",
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
+ }
@@ -2,10 +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';
5
6
  import type { CatchUpRecords } from './channel-catch-up';
6
7
  import { type PresenceEvent, readPresence } from './channel-presence';
7
8
  import type { JsonObject } from './json';
8
9
  import { carriedBy, type RecordKey } from './record-key';
10
+ import type { HeldLanding } from './record-landings';
9
11
  import type { RecordStore } from './record-store';
10
12
  import type {
11
13
  ChannelEventsFrame,
@@ -80,6 +82,8 @@ interface Entry {
80
82
  failures: number;
81
83
  /** Disarms the scheduled retry. */
82
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;
83
87
  }
84
88
 
85
89
  export interface ChannelMembership extends Disposable {
@@ -124,6 +128,7 @@ export class ChannelBook {
124
128
  waiting: false,
125
129
  failures: 0,
126
130
  disarm: null,
131
+ landing: null,
127
132
  };
128
133
  this.#byTopic.set(topic, entry);
129
134
  if (this.#deps.connected()) this.#deps.send(this.#frame(entry, 'add', true));
@@ -206,6 +211,8 @@ export class ChannelBook {
206
211
  if (entry === undefined) return;
207
212
  if (entry.buffered !== null) {
208
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();
209
216
  return;
210
217
  }
211
218
  if (entry.epoch !== null && entry.epoch !== frame.epoch) {
@@ -310,14 +317,26 @@ export class ChannelBook {
310
317
  entry.disarm?.();
311
318
  entry.disarm = null;
312
319
  this.#set(entry, 'catching-up');
313
- // The read names no write (O-507, reopened 2026-10-14 on an observed double paint). FIXED: a
314
- // pending write whose echo is HELD behind the read is settled in the same notification as the
315
- // read's rows (`#drain`). NOT fixed: an echo that arrives AFTER the read answered (replication
316
- // slower than the read's round trip) — the rows are truth under the still-pending twin until
317
- // that echo or the write's own answer settles it. Closing it needs the read to name the writes
318
- // its snapshot reflects, which is a server-side change (O-507 in full).
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.
319
324
  this.#deps.catchUp(entry.catchUp, entry.params).then(
320
- (answered) => this.#drain(entry, answered),
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
+ },
321
340
  (error: unknown) => {
322
341
  this.#deps.report(error);
323
342
  // NOT live: the frames held behind the read would land over a store it never refreshed.
@@ -341,6 +360,16 @@ export class ChannelBook {
341
360
  // Released while the read was out: its rows and frames are a seat nobody holds, and a new hold
342
361
  // of the topic may already hold newer rows. Nothing lands and nothing is re-read.
343
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
+ }
344
373
  const held = entry.buffered ?? [];
345
374
  // A newer epoch arrived while the read was in flight (the node restarted mid-read): the read
346
375
  // answered for the OLD one, so it is re-read in the new one, keeping only that epoch's frames.
@@ -416,6 +445,13 @@ export function isTransientRefusal(error: unknown): boolean {
416
445
  );
417
446
  }
418
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
+
419
455
  /** A channel membership's sid is its topic behind this — what the socket engine routes on too. */
420
456
  export const CHANNEL_SID_PREFIX = 'channel:';
421
457
 
@@ -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
- }
@@ -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 {