@ultimat3/realtime 25.1.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 +6 -6
- package/README.md +6 -2
- package/package.json +5 -5
- package/src/client-channels.ts +2 -2
- package/src/index.ts +1 -0
- package/src/offline-queue.ts +57 -8
- package/src/outbox-slot.ts +12 -0
- package/src/outbox-tabs.ts +62 -0
- package/src/page-outbox.ts +68 -6
- package/src/use-mutation.ts +12 -0
- package/src/use-outbox.ts +49 -0
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
|
|
174
|
-
|
|
175
|
-
`
|
|
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
|
-
`
|
|
291
|
-
- **An island never carries the outbox**: `boot.ts` builds it; `useMutation`
|
|
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.
|
|
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.
|
|
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.
|
|
44
|
-
"@ultimat3/entity": "25.
|
|
45
|
-
"@ultimat3/policy": "25.
|
|
46
|
-
"@ultimat3/query": "25.
|
|
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
|
}
|
package/src/client-channels.ts
CHANGED
|
@@ -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
|
|
296
|
-
// reflects settles on its own HTTP answer.
|
|
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,
|
package/src/offline-queue.ts
CHANGED
|
@@ -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
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
package/src/outbox-slot.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/page-outbox.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
|
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 (
|
|
393
|
+
if (browser) {
|
|
332
394
|
listenForDrain(outbox);
|
|
333
395
|
if (!knownOffline()) void outbox.replay().catch(() => undefined);
|
|
334
396
|
}
|
package/src/use-mutation.ts
CHANGED
|
@@ -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
|
+
}
|