@ultimat3/realtime 27.10.2 → 27.10.4
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 +10 -9
- package/package.json +5 -5
- package/src/catch-up-hold.ts +76 -0
- package/src/client-channels.ts +43 -7
- package/src/outbox-slot.ts +16 -0
- package/src/page-outbox.ts +7 -17
- package/src/record-await.ts +10 -0
- package/src/record-landings.ts +69 -0
- package/src/record-store.ts +36 -0
- package/src/use-mutation.ts +22 -7
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
|
|
298
|
-
`
|
|
299
|
-
`
|
|
300
|
-
`
|
|
301
|
-
`
|
|
302
|
-
`
|
|
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.
|
|
3
|
+
"version": "27.10.4",
|
|
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.
|
|
44
|
-
"@ultimat3/entity": "27.10.
|
|
45
|
-
"@ultimat3/policy": "27.10.
|
|
46
|
-
"@ultimat3/query": "27.10.
|
|
43
|
+
"@ultimat3/core": "27.10.4",
|
|
44
|
+
"@ultimat3/entity": "27.10.4",
|
|
45
|
+
"@ultimat3/policy": "27.10.4",
|
|
46
|
+
"@ultimat3/query": "27.10.4",
|
|
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
|
+
}
|
package/src/client-channels.ts
CHANGED
|
@@ -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
|
|
314
|
-
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
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) =>
|
|
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
|
|
package/src/outbox-slot.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/page-outbox.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
}
|
package/src/record-await.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/record-store.ts
CHANGED
|
@@ -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
|
|
package/src/use-mutation.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 {
|