@ultimat3/realtime 25.0.0 → 25.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -170,9 +170,9 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
170
170
  - **A dropped `records` frame is counted AND repaired**: per-node `seq`/`epoch` per channel; a refused
171
171
  frame marks the socket gapped, and the `drain` handler sends `replay-gap`
172
172
  (`channel_replay_gaps_total`). The client re-runs `catchUp`, holding frames meanwhile.
173
- - **A channel is a DECLARATION**: `channel(name, { params, policy, catchUp, records?, events? })`.
174
- Deny by default, and `policy` is REQUIRED (`X_CHANNEL_DECLARATION_INVALID`; a public channel says
175
- `policy: allow('public')`). Presence rides `events: true` channels.
173
+ - **A channel is a DECLARATION, no primitive** (docs/history/primitive-factories.md):
174
+ `channel(name, { params, policy, catchUp, records?, events? })`, deny by default; `policy`
175
+ REQUIRED (`X_CHANNEL_DECLARATION_INVALID`; public: `allow('public')`). Presence: `events`.
176
176
  - **A guard that FAILS on re-auth SUSPENDS the seat** (`guardFailures`): kept, silent until a pass.
177
177
  A `null` actor on a `row` channel and a tenancy refusal are DENIALS.
178
178
  - **A seat is decided for the actor ON the socket when the guard resolves** (`#settle`); a denied
@@ -287,9 +287,9 @@ Tier 3 package. Channels, live queries, local-first sync. One protocol for all t
287
287
  - **The disk boot is ONE page script (`boot.ts`, `./boot`)**, served at `/_x/page-boot/<hash>.js`
288
288
  on a document with the scope tag and a realtime island; it installs the runtime, wipes every other
289
289
  principal's stored scope (read at the wipe), then restores. `booted` reads its promise off
290
- `globalThis[Symbol.for('ultimate.page-boot')]`. No boot: islands load its chunk.
291
- - **An island never carries the outbox**: `boot.ts` builds it; `useMutation` and the page socket read
292
- it through `outbox-slot.ts` after `page.booted`. No boot ⇒ a refused write is rejected, not queued.
290
+ `Symbol.for('ultimate.page-boot')`. No boot: islands load its chunk.
291
+ - **An island never carries the outbox**: `boot.ts` builds it; `useMutation`, `useOutbox`, the page socket
292
+ read it through `outbox-slot.ts` after `page.booted`. No boot ⇒ a refused write is rejected, not queued.
293
293
 
294
294
  ## Map
295
295
 
package/README.md CHANGED
@@ -84,7 +84,7 @@ class — stay on `.`.
84
84
  | wire | `.` | `PROTOCOL_VERSION` (3), `encode`, `decode`, `Frame` |
85
85
  | the node | `./server` | `syncNode` / `listenSyncNode` (`sync` role) |
86
86
  | a socket's identity | `./server` | `SyncAuthenticator`, `SyncGrant`, `GrantBook`, `sweepGrants`, `DEFAULT_REAUTH_INTERVAL_MS` |
87
- | hooks | `.` | `useQuery`, `useRecord`, `useRecords` (by key, or a whole type: `RecordSelection`), `useMutation`, `useMutationQueue`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
87
+ | hooks | `.` | `useQuery`, `useRecord`, `useRecords` (by key, or a whole type: `RecordSelection`), `useMutation`, `useMutationQueue`, `useOutbox`, `useConnection`, `useChannel`, `usePresence`, `hasPageSocket`, `installRealtime` |
88
88
  | channel records, no replicator | `./server` | `recordPublisher` — an app's committed rows as channel `records`, sequenced per producer ([below](#channel-records-with-no-replicator)) |
89
89
  | channels | `.` | `channel`, `channelRef`, `ChannelHandle`, `topic`, `readPresence`, the channel frame types |
90
90
  | first paint | `.` | `holdFirstPaint`, `FIRST_PAINT_HOLD_MS` (1 s) — what the island bootstrap awaits before `mount`: the boot's restore and the open outbox, capped (#506) |
@@ -196,6 +196,7 @@ import {
196
196
  useConnection,
197
197
  useMutation,
198
198
  useMutationQueue,
199
+ useOutbox,
199
200
  usePresence,
200
201
  useQuery,
201
202
  useRecord,
@@ -220,6 +221,7 @@ const runs = useRecords<Run>('runs', { // EVERY `ru
220
221
  const like = useMutation(LIKE_POST); // await like(input); like.pending
221
222
  const connection = useConnection(); // .offline .online .reconnectAt .updateAvailable
222
223
  const writes = useMutationQueue(); // .pending .failed
224
+ const outbox = useOutbox(); // .size — queued, not yet sent
223
225
  const feedChannel = useChannel(orgFeed, { orgId }, { onEvent }); // records → the store; events → onEvent
224
226
  const room = usePresence(orgFeed, { orgId }); // the channel's roster
225
227
  ```
@@ -227,7 +229,9 @@ const room = usePresence(orgFeed, { orgId }); // the channe
227
229
  **One socket per origin and principal**: the page's socket lives in a `SharedWorker`
228
230
  (`@ultimat3/realtime/sync-worker`, bundled by `x build`) shared by every tab; with no worker the
229
231
  same engine runs in-page. Writes that find no network go to the page's outbox — overlay kept — and
230
- replay over HTTP, under their original idempotency keys, when the socket comes back. The outbox is
232
+ replay over HTTP, under their original idempotency keys, when the socket comes back; `useOutbox().size`
233
+ counts them until the server takes each one, so an island's "sent when you reconnect" notice reads
234
+ the queue itself. The outbox is
231
235
  the page boot's (`@ultimat3/realtime/boot`): an island only reads it off the page. On a page the
232
236
  CLI renders no boot for (no scope tag, so nothing on it persists) the island's runtime chunk opens
233
237
  no outbox, and such a write is refused like any other — rejected, overlay taken back — never held
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "25.0.0",
3
+ "version": "25.2.0",
4
4
  "description": "Three-tier realtime: channels, live queries, local-first sync — one protocol, one mutator shape",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -40,10 +40,10 @@
40
40
  "test": "bun test"
41
41
  },
42
42
  "dependencies": {
43
- "@ultimat3/core": "25.0.0",
44
- "@ultimat3/entity": "25.0.0",
45
- "@ultimat3/policy": "25.0.0",
46
- "@ultimat3/query": "25.0.0",
43
+ "@ultimat3/core": "25.2.0",
44
+ "@ultimat3/entity": "25.2.0",
45
+ "@ultimat3/policy": "25.2.0",
46
+ "@ultimat3/query": "25.2.0",
47
47
  "nats": "2.29.3"
48
48
  }
49
49
  }
@@ -292,8 +292,8 @@ export class ChannelBook {
292
292
  entry.disarm?.();
293
293
  entry.disarm = null;
294
294
  this.#set(entry, 'catching-up');
295
- // Names no write (#507 pt 2: owner O-507, default none) — a pending write this read already
296
- // reflects settles on its own HTTP answer. Where a catch-up names its writes, it goes here.
295
+ // Names no write, by decision (O-507, #648, 2026-10-07): a pending write this read already
296
+ // reflects settles on its own HTTP answer. Reopen only on an observed double paint.
297
297
  this.#deps.catchUp(entry.catchUp, entry.params).then(
298
298
  () => this.#drain(entry),
299
299
  (error: unknown) => {
package/src/index.ts CHANGED
@@ -193,6 +193,7 @@ export {
193
193
  useMutation,
194
194
  useMutationQueue,
195
195
  } from './use-mutation';
196
+ export { type OutboxView, useOutbox } from './use-outbox';
196
197
  export { type QueryAccessor, type QueryOptions, type QueryRef, useQuery } from './use-query';
197
198
  export {
198
199
  type RecordAccessor,
@@ -99,6 +99,9 @@ export class OfflineQueue {
99
99
  #epoch = 0;
100
100
  /** Set by `abandon`, never cleared: this queue's principal is gone. */
101
101
  #abandoned = false;
102
+ /** Durable writes in progress, and every one ever started: what a `refresh` must not race. */
103
+ #writing = 0;
104
+ #written = 0;
102
105
 
103
106
  private constructor(store: QueueStore, state: QueueState) {
104
107
  this.#store = store;
@@ -136,6 +139,42 @@ export class OfflineQueue {
136
139
  await this.#reclaimInflight();
137
140
  }
138
141
 
142
+ /**
143
+ * READ-ONLY re-read, safe without the drain lock: another tab queued, sent or refused writes this
144
+ * one holds a stale copy of. Unlike `reload` it reclaims nothing and claims nothing — an entry
145
+ * this tab has `inflight` keeps its state, an entry gone from the disk was settled elsewhere.
146
+ * A read that a write of this tab's raced is retried: its snapshot may predate that write.
147
+ */
148
+ async refresh(): Promise<void> {
149
+ for (let attempt = 0; attempt < 3 && !this.#abandoned; attempt += 1) {
150
+ const started = this.#written;
151
+ const state = await this.#store.load();
152
+ if (this.#abandoned) return;
153
+ if (this.#writing > 0 || this.#written !== started) continue;
154
+ this.#adopt(state);
155
+ return;
156
+ }
157
+ }
158
+
159
+ #adopt(state: QueueState): void {
160
+ const onDisk = new Map(state.mutations.map((mutation) => [mutation.key, mutation]));
161
+ const kept: QueuedMutation[] = [];
162
+ for (const mutation of this.#mutations) {
163
+ const disk = onDisk.get(mutation.key);
164
+ onDisk.delete(mutation.key);
165
+ // Gone from the disk: another tab's pass settled it. One on THIS tab's wire stays its own.
166
+ if (disk === undefined && mutation.status !== 'inflight') continue;
167
+ if (disk !== undefined && mutation.status !== 'inflight') {
168
+ mutation.status = disk.status;
169
+ mutation.error = disk.error;
170
+ }
171
+ kept.push(mutation);
172
+ }
173
+ for (const disk of onDisk.values()) kept.push({ ...disk });
174
+ this.#mutations = kept;
175
+ this.#nextSeq = Math.max(this.#nextSeq, state.nextSeq);
176
+ }
177
+
139
178
  async #reclaimInflight(): Promise<void> {
140
179
  const reclaimed = this.#mutations.filter((mutation) => mutation.status === 'inflight');
141
180
  for (const mutation of reclaimed) mutation.status = 'pending';
@@ -371,9 +410,13 @@ export class OfflineQueue {
371
410
  mutation.error = null;
372
411
  sent += 1;
373
412
  } catch (error) {
374
- mutation.status = 'pending';
375
- mutation.error = toQueueError(error);
376
- await this.#persist([mutation]);
413
+ // Settled inside `send` before it threw (the page outbox acks or fails it, then a merge may
414
+ // raise): writing it back as `pending` would replay what the server already decided.
415
+ if (this.#mutations.includes(mutation) && statusOf(mutation) !== 'failed') {
416
+ mutation.status = 'pending';
417
+ mutation.error = toQueueError(error);
418
+ await this.#persist([mutation]);
419
+ }
377
420
  return {
378
421
  sent,
379
422
  collapsed: this.#collapsed,
@@ -407,11 +450,17 @@ export class OfflineQueue {
407
450
  */
408
451
  async #persist(puts: readonly QueuedMutation[], deletes: readonly string[] = []): Promise<void> {
409
452
  if (this.#abandoned) return;
410
- await this.#store.write({
411
- puts: puts.map((mutation) => ({ ...mutation })),
412
- deletes,
413
- nextSeq: this.#nextSeq,
414
- });
453
+ this.#writing += 1;
454
+ this.#written += 1;
455
+ try {
456
+ await this.#store.write({
457
+ puts: puts.map((mutation) => ({ ...mutation })),
458
+ deletes,
459
+ nextSeq: this.#nextSeq,
460
+ });
461
+ } finally {
462
+ this.#writing -= 1;
463
+ }
415
464
  }
416
465
  }
417
466
 
@@ -17,6 +17,18 @@ export interface OutboxHandle {
17
17
  enqueue(entry: OutboxEntry): Promise<void>;
18
18
  replay(): Promise<unknown>;
19
19
  pending(): readonly OutboxEntry[];
20
+ /** Writes not yet taken by the server. `0` until the store has opened. */
21
+ readonly size: number;
22
+ /**
23
+ * `listener` runs whenever `size` may have moved: a write queued, acked or refused, a queue
24
+ * opened, a principal change. Returns the release.
25
+ */
26
+ subscribe(listener: () => void): () => void;
27
+ /**
28
+ * Re-reads the durable queue, which another tab of this user may have changed since, and tells
29
+ * the listeners. What a write asks before deciding to queue behind older writes or to send.
30
+ */
31
+ refresh(): Promise<void>;
20
32
  /** Settles once the current principal's queue is open. */
21
33
  readonly ready: Promise<void>;
22
34
  }
@@ -0,0 +1,62 @@
1
+ /**
2
+ * The page outbox's tie to the origin's other tabs: one `BroadcastChannel`, on which a tab names
3
+ * the scope whose durable queue it just changed, so every other tab re-reads it. Closed while the
4
+ * page sits in the back/forward cache (an open channel keeps a page out of it); reopened on return.
5
+ */
6
+
7
+ /** One channel per origin; a message is the scope key whose queue moved. */
8
+ export const OUTBOX_CHANNEL = 'ultimate:outbox';
9
+
10
+ export interface OutboxTabs {
11
+ /** Tell every other tab that `scope`'s queue changed on disk. */
12
+ announce(scope: string): void;
13
+ stop(): void;
14
+ }
15
+
16
+ /** What this needs of a window: the channel class, when there is one, and the page events. */
17
+ export interface OutboxTabsWindow {
18
+ readonly BroadcastChannel?: typeof BroadcastChannel;
19
+ addEventListener(type: string, listener: (event: Event) => void): void;
20
+ removeEventListener(type: string, listener: (event: Event) => void): void;
21
+ }
22
+
23
+ /**
24
+ * `heard(scope)` for every other tab's announcement; `heard(null)` on a page restored from the
25
+ * back/forward cache, which missed everything sent while it was frozen.
26
+ */
27
+ export function outboxTabs(
28
+ win: OutboxTabsWindow,
29
+ heard: (scope: string | null) => void,
30
+ ): OutboxTabs {
31
+ const Channel = win.BroadcastChannel;
32
+ const open = (): BroadcastChannel | undefined => {
33
+ if (Channel === undefined) return undefined;
34
+ const opened = new Channel(OUTBOX_CHANNEL);
35
+ opened.onmessage = (event: MessageEvent) => {
36
+ if (typeof event.data === 'string') heard(event.data);
37
+ };
38
+ return opened;
39
+ };
40
+ let channel = open();
41
+ // `pagehide`, not `unload`: it fires for a page entering the back/forward cache too, and a page
42
+ // holding an open channel is refused by it.
43
+ const onHide = (): void => {
44
+ channel?.close();
45
+ channel = undefined;
46
+ };
47
+ const onShow = (event: Event): void => {
48
+ if (Reflect.get(event, 'persisted') !== true || channel !== undefined) return;
49
+ channel = open();
50
+ heard(null);
51
+ };
52
+ win.addEventListener('pagehide', onHide);
53
+ win.addEventListener('pageshow', onShow);
54
+ return {
55
+ announce: (scope) => channel?.postMessage(scope),
56
+ stop() {
57
+ win.removeEventListener('pagehide', onHide);
58
+ win.removeEventListener('pageshow', onShow);
59
+ onHide();
60
+ },
61
+ };
62
+ }
@@ -19,6 +19,7 @@ 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
21
  import { OUTBOX_KEY, type OutboxEntry, type OutboxHandle, type OutboxHost } from './outbox-slot';
22
+ import { type OutboxTabs, type OutboxTabsWindow, outboxTabs } from './outbox-tabs';
22
23
  import { LocalStoreUnavailableError, OfflineQueueAbandonedError } from './page-errors';
23
24
  import { peekPageRealtime } from './page-store';
24
25
  import { carriedBy } from './record-store';
@@ -29,8 +30,6 @@ export interface PageOutbox extends OutboxHandle {
29
30
  enqueue(entry: OutboxEntry): Promise<void>;
30
31
  /** Sends everything queued, in order, stopping at the first write the network could not take. */
31
32
  replay(): Promise<DrainReport>;
32
- /** Writes not yet taken by the server. `0` until the store has opened. */
33
- readonly size: number;
34
33
  /**
35
34
  * Those writes, in queue order — what `useMutation` re-applies as overlays after a reload, so a
36
35
  * queued write is ON SCREEN until its replay settles or refuses it. Empty until `ready`.
@@ -62,6 +61,11 @@ export interface OutboxOptions {
62
61
  * the write queued and the post it liked missing, and showed the old count.
63
62
  */
64
63
  readonly beforeEnqueue?: (() => Promise<void>) | undefined;
64
+ /**
65
+ * The tie to this origin's other tabs, which share the durable queue: given `heard`, returns how
66
+ * to announce this tab's own changes. `pageOutbox` passes a `BroadcastChannel`; none, no sync.
67
+ */
68
+ readonly tabs?: ((heard: (scope: string | null) => void) => OutboxTabs) | undefined;
65
69
  /** Where a disk that refused the queue is said, by code. Default `console.warn`. */
66
70
  readonly warn?: ((error: UltimateError) => void) | undefined;
67
71
  }
@@ -91,6 +95,38 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
91
95
  */
92
96
  let epoch = 0;
93
97
  const warn = options.warn ?? ((error: UltimateError): void => console.warn(error));
98
+ /** Re-read the durable queue (read-only) and tell this tab's listeners what it holds now. */
99
+ const refreshHeld = async (): Promise<void> => {
100
+ const now = held;
101
+ if (now === undefined) return;
102
+ await now.queue.refresh();
103
+ if (held === now) notify();
104
+ };
105
+ const tabs = options.tabs?.((scope) => {
106
+ // Another principal's queue is another scope's disk; `null` is "re-read whatever is held".
107
+ if (held === undefined || (scope !== null && scope !== held.scope)) return;
108
+ refreshHeld().catch((error: unknown) => unavailable('the outbox could not be re-read', error));
109
+ });
110
+ /** After this tab changed the disk: every other tab holding that scope re-reads it. */
111
+ const announce = (scope: string | undefined): void => {
112
+ if (scope !== undefined) tabs?.announce(scope);
113
+ };
114
+ /**
115
+ * Told whenever the count may have moved. A listener that throws is rethrown on its own task:
116
+ * reported like any uncaught error, and never the refusal of the write that moved the count.
117
+ */
118
+ const listeners = new Set<() => void>();
119
+ const notify = (): void => {
120
+ for (const listener of listeners) {
121
+ try {
122
+ listener();
123
+ } catch (error) {
124
+ queueMicrotask(() => {
125
+ throw error;
126
+ });
127
+ }
128
+ }
129
+ };
94
130
  const unavailable = (what: string, error: unknown): void =>
95
131
  warn(new LocalStoreUnavailableError({ reason: `${what}: ${renderThrowable(error)}` }));
96
132
 
@@ -113,6 +149,7 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
113
149
  }
114
150
  held?.queue.abandon();
115
151
  held = { queue, scope };
152
+ notify();
116
153
  };
117
154
  let ready = open();
118
155
  /** The current principal's queue: `ready` is re-made by a rescope, so it is re-read until still. */
@@ -128,7 +165,7 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
128
165
  let again = false;
129
166
 
130
167
  const deliver =
131
- (queue: OfflineQueue) =>
168
+ ({ queue, scope }: Held) =>
132
169
  async (mutation: QueuedMutation): Promise<void> => {
133
170
  const carried = new Set<string>();
134
171
  try {
@@ -140,6 +177,8 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
140
177
  if (kind === 'retryable' || kind === 'retry-after' || queue.abandoned) throw error;
141
178
  // Anything else is the server's decision about this write — kept for the UI, never resent.
142
179
  await queue.fail(mutation.key, toQueueError(error));
180
+ notify();
181
+ announce(scope);
143
182
  overlays()?.drop(mutation.key);
144
183
  return;
145
184
  }
@@ -147,6 +186,8 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
147
186
  // gone, and what is on the page now is somebody else's.
148
187
  if (queue.abandoned) return;
149
188
  await queue.ack(mutation.key);
189
+ notify();
190
+ announce(scope);
150
191
  const store = overlays();
151
192
  try {
152
193
  // Exactly as a live write settles: a row the answer did not carry keeps its overlay until
@@ -166,6 +207,7 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
166
207
  held?.queue.abandon();
167
208
  held = undefined;
168
209
  epoch += 1;
210
+ notify();
169
211
  // The single-flight slot is the principal's too: left set, the next principal's trigger JOINED
170
212
  // the abandoned pass — handed its report, another principal's key in it — and its own queue
171
213
  // was not drained until some later trigger. `settled` only clears a slot that is still its own.
@@ -198,6 +240,8 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
198
240
  await options.beforeEnqueue?.().catch(() => undefined);
199
241
  // Abandoned meanwhile, it refuses by the same code.
200
242
  await mine.queue.enqueue(entry);
243
+ notify();
244
+ announce(mine.scope);
201
245
  },
202
246
  replay: () => {
203
247
  // Single flight: a trigger that lands while a replay is running JOINS it. Open, `online`,
@@ -222,8 +266,10 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
222
266
  // sent too, and an `inflight` entry — which only a pass holding this lock could have put
223
267
  // on the wire, and it is over — goes back to `pending`.
224
268
  return await exclusive(`ultimate-outbox:${now.scope ?? 'memory'}`, async () => {
269
+ // Re-read under the lock: what another tab queued or sent since is this count too.
225
270
  await now.queue.reload();
226
- return await now.queue.drain(deliver(now.queue));
271
+ notify();
272
+ return await now.queue.drain(deliver(now));
227
273
  });
228
274
  })();
229
275
  const settled = (report?: DrainReport): void => {
@@ -246,6 +292,16 @@ export function localOutbox(options: OutboxOptions): PageOutbox {
246
292
  },
247
293
  pending: () =>
248
294
  (held?.queue.pending() ?? []).map(({ key, name, input }) => ({ key, name, input })),
295
+ refresh: async () => {
296
+ await current();
297
+ await refreshHeld();
298
+ },
299
+ subscribe: (listener) => {
300
+ listeners.add(listener);
301
+ return () => {
302
+ listeners.delete(listener);
303
+ };
304
+ },
249
305
  get ready(): Promise<void> {
250
306
  return ready;
251
307
  },
@@ -326,9 +382,15 @@ export function pageOutbox(options: Pick<OutboxOptions, 'beforeEnqueue'> = {}):
326
382
  const existing = host[OUTBOX_KEY];
327
383
  // Only this function writes the slot, so what it holds is always a `PageOutbox`.
328
384
  if (existing !== undefined) return existing as PageOutbox;
329
- const outbox = localOutbox({ local: pageLocalStore(), beforeEnqueue: options.beforeEnqueue });
385
+ const browser = Reflect.has(globalThis, 'document');
386
+ const outbox = localOutbox({
387
+ local: pageLocalStore(),
388
+ beforeEnqueue: options.beforeEnqueue,
389
+ // Every tab of this user shares the durable queue; each re-reads it when another changes it.
390
+ ...(browser ? { tabs: (heard) => outboxTabs(globalThis as OutboxTabsWindow, heard) } : {}),
391
+ });
330
392
  Object.defineProperty(host, OUTBOX_KEY, { value: outbox, configurable: true });
331
- if (Reflect.has(globalThis, 'document')) {
393
+ if (browser) {
332
394
  listenForDrain(outbox);
333
395
  if (!knownOffline()) void outbox.replay().catch(() => undefined);
334
396
  }
@@ -155,6 +155,18 @@ export function useMutation(mutator: MutatorLike): Mutate {
155
155
  throw error;
156
156
  }
157
157
  };
158
+ // 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) {
161
+ await queued.refresh().catch(() => undefined);
162
+ // That read is an await: a principal that left meanwhile must not have this write POSTed, or
163
+ // queued, as the next one. Refused by the code `enqueue` uses for the same departure.
164
+ if (pageClient().scope.epoch !== issued) {
165
+ page.store.drop(key);
166
+ count(writes, mutator.name, -1);
167
+ throw new OfflineQueueAbandonedError({ name: mutator.name });
168
+ }
169
+ }
158
170
  if (queued !== undefined && queued.pending().length > 0) {
159
171
  try {
160
172
  await enqueue(queued);
@@ -0,0 +1,49 @@
1
+ // The page outbox's count, as a getter a component reads: how many writes wait to be sent. Read
2
+ // off the outbox the boot seated (`outbox-slot.ts`), never built here, so an island asking ships
3
+ // the slot and none of the queue. A server render holds nothing queued.
4
+
5
+ import { peekOutbox } from './outbox-slot';
6
+ import { installedPage } from './page-store';
7
+ import { isServerRender, signalFor } from './reactivity';
8
+
9
+ export interface OutboxView extends Disposable {
10
+ /** Stop listening (Solid: `onCleanup`). The outbox itself stays, and keeps its writes. */
11
+ release(): void;
12
+ /** Writes queued and not yet taken by the server; `0` before the boot has opened the outbox. */
13
+ readonly size: number;
14
+ }
15
+
16
+ const SERVER_RENDER: OutboxView = Object.freeze({
17
+ release: (): void => undefined,
18
+ [Symbol.dispose]: (): void => undefined,
19
+ size: 0,
20
+ });
21
+
22
+ export function useOutbox(): OutboxView {
23
+ const signal = signalFor('useOutbox');
24
+ if (isServerRender()) return SERVER_RENDER;
25
+ const page = installedPage('useOutbox');
26
+ const [version, setVersion] = signal(0);
27
+ const moved = (): void => setVersion(version() + 1);
28
+ let unsubscribe: () => void = () => undefined;
29
+ let released = false;
30
+ // After the boot: it seats the outbox, and a queue a previous load left is on disk until then.
31
+ void page.booted.then(() => {
32
+ const outbox = peekOutbox();
33
+ if (released || outbox === undefined) return;
34
+ unsubscribe = outbox.subscribe(moved);
35
+ moved();
36
+ });
37
+ const release = (): void => {
38
+ released = true;
39
+ unsubscribe();
40
+ };
41
+ return {
42
+ release,
43
+ [Symbol.dispose]: release,
44
+ get size() {
45
+ version();
46
+ return peekOutbox()?.size ?? 0;
47
+ },
48
+ };
49
+ }