@ultimat3/realtime 27.10.1 → 27.10.2
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/package.json +5 -5
- package/src/channel-catch-up.ts +35 -0
- package/src/client-channels.ts +43 -10
- package/src/client-contract.ts +8 -3
- package/src/page-socket.ts +2 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/realtime",
|
|
3
|
-
"version": "27.10.
|
|
3
|
+
"version": "27.10.2",
|
|
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.2",
|
|
44
|
+
"@ultimat3/entity": "27.10.2",
|
|
45
|
+
"@ultimat3/policy": "27.10.2",
|
|
46
|
+
"@ultimat3/query": "27.10.2",
|
|
47
47
|
"nats": "2.29.3"
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// A channel's catch-up read, as the page's socket runs it: an ordinary query read whose records the
|
|
2
|
+
// transport does NOT adopt. They come back to the channel book, which adopts them in ONE
|
|
3
|
+
// notification with the frames it held behind the read — the echo of a pending write among them.
|
|
4
|
+
|
|
5
|
+
import type { RecordEnvelope } from '@ultimat3/core/page';
|
|
6
|
+
import { pageClient } from '@ultimat3/core/page';
|
|
7
|
+
import { queryClientMethodFor } from '@ultimat3/query/client';
|
|
8
|
+
|
|
9
|
+
/** What a catch-up read answered for the store; `undefined` for a read that answers no records. */
|
|
10
|
+
export interface CatchUpRecords extends Pick<RecordEnvelope, 'records' | 'removed'> {
|
|
11
|
+
/**
|
|
12
|
+
* Whether the principal the read was made for is still the page's. The transport fences the read
|
|
13
|
+
* itself; these rows are adopted later, by the book, so the book asks again at adoption — the
|
|
14
|
+
* fence `useMutation` keeps on its own POST. Absent: always current.
|
|
15
|
+
*/
|
|
16
|
+
readonly current?: (() => boolean) | undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function catchUpRead(
|
|
20
|
+
query: string,
|
|
21
|
+
params: Readonly<Record<string, string>>,
|
|
22
|
+
): Promise<CatchUpRecords | undefined> {
|
|
23
|
+
const issued = pageClient().scope.epoch;
|
|
24
|
+
let answered: CatchUpRecords | undefined;
|
|
25
|
+
await queryClientMethodFor(query, { baseUrl: '' })(params, {
|
|
26
|
+
adopt: false,
|
|
27
|
+
onEnvelope: (envelope) => {
|
|
28
|
+
answered = {
|
|
29
|
+
...envelope,
|
|
30
|
+
current: () => pageClient().scope.epoch === issued,
|
|
31
|
+
};
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
return answered;
|
|
35
|
+
}
|
package/src/client-channels.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
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 type { CatchUpRecords } from './channel-catch-up';
|
|
5
6
|
import { type PresenceEvent, readPresence } from './channel-presence';
|
|
6
7
|
import type { JsonObject } from './json';
|
|
7
8
|
import { carriedBy, type RecordKey } from './record-key';
|
|
@@ -35,8 +36,14 @@ export interface ChannelBookDeps {
|
|
|
35
36
|
readonly store: RecordStore;
|
|
36
37
|
send(frame: SubscribeFrame): void;
|
|
37
38
|
connected(): boolean;
|
|
38
|
-
/**
|
|
39
|
-
|
|
39
|
+
/**
|
|
40
|
+
* Re-run the channel's catch-up read and answer its records UNADOPTED (`channel-catch-up.ts`): the
|
|
41
|
+
* book adopts them in one notification with the frames it held behind the read.
|
|
42
|
+
*/
|
|
43
|
+
catchUp(
|
|
44
|
+
query: string,
|
|
45
|
+
params: Readonly<Record<string, string>>,
|
|
46
|
+
): Promise<CatchUpRecords | undefined>;
|
|
40
47
|
report(error: unknown): void;
|
|
41
48
|
/**
|
|
42
49
|
* When a failed catch-up tries again on its own, on the client's reconnect curve. Absent, a
|
|
@@ -303,10 +310,14 @@ export class ChannelBook {
|
|
|
303
310
|
entry.disarm?.();
|
|
304
311
|
entry.disarm = null;
|
|
305
312
|
this.#set(entry, 'catching-up');
|
|
306
|
-
//
|
|
307
|
-
//
|
|
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).
|
|
308
319
|
this.#deps.catchUp(entry.catchUp, entry.params).then(
|
|
309
|
-
() => this.#drain(entry),
|
|
320
|
+
(answered) => this.#drain(entry, answered),
|
|
310
321
|
(error: unknown) => {
|
|
311
322
|
this.#deps.report(error);
|
|
312
323
|
// NOT live: the frames held behind the read would land over a store it never refreshed.
|
|
@@ -326,7 +337,10 @@ export class ChannelBook {
|
|
|
326
337
|
);
|
|
327
338
|
}
|
|
328
339
|
|
|
329
|
-
#drain(entry: Entry): void {
|
|
340
|
+
#drain(entry: Entry, answered?: CatchUpRecords): void {
|
|
341
|
+
// Released while the read was out: its rows and frames are a seat nobody holds, and a new hold
|
|
342
|
+
// of the topic may already hold newer rows. Nothing lands and nothing is re-read.
|
|
343
|
+
if (this.#byTopic.get(entry.topic) !== entry) return;
|
|
330
344
|
const held = entry.buffered ?? [];
|
|
331
345
|
// A newer epoch arrived while the read was in flight (the node restarted mid-read): the read
|
|
332
346
|
// answered for the OLD one, so it is re-read in the new one, keeping only that epoch's frames.
|
|
@@ -335,6 +349,9 @@ export class ChannelBook {
|
|
|
335
349
|
if (entry.again !== null || newer !== undefined) {
|
|
336
350
|
const epoch = newer?.epoch ?? entry.again?.epoch ?? entry.epoch;
|
|
337
351
|
entry.again = null;
|
|
352
|
+
// Its rows land now, alone: they are newer than the store's, and a re-read that fails would
|
|
353
|
+
// otherwise leave the older ones for the whole retry curve. The frames wait for the re-read.
|
|
354
|
+
this.#adopt(answered);
|
|
338
355
|
entry.buffered = held.filter((frame) => frame.epoch === epoch);
|
|
339
356
|
this.#restart(entry, epoch);
|
|
340
357
|
this.#read(entry);
|
|
@@ -343,13 +360,29 @@ export class ChannelBook {
|
|
|
343
360
|
entry.buffered = null;
|
|
344
361
|
entry.failures = 0;
|
|
345
362
|
const current = [...held].sort((a, b) => a.seq - b.seq);
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
363
|
+
const store = this.#deps.store;
|
|
364
|
+
// `#adopt` and `#apply` open batches of their own inside this one: `RecordStore.batch` nests,
|
|
365
|
+
// so the read's rows and every held frame are still ONE notification.
|
|
366
|
+
store.batch(() => {
|
|
367
|
+
this.#adopt(answered);
|
|
368
|
+
for (const frame of current) {
|
|
369
|
+
if (entry.contiguous === null) entry.contiguous = frame.seq - 1;
|
|
370
|
+
this.#apply(entry, frame);
|
|
371
|
+
}
|
|
372
|
+
});
|
|
350
373
|
if (entry.state === 'catching-up') this.#set(entry, 'live');
|
|
351
374
|
}
|
|
352
375
|
|
|
376
|
+
/** A catch-up read's rows, unless the principal it was read for has left (`CatchUpRecords`). */
|
|
377
|
+
#adopt(answered: CatchUpRecords | undefined): void {
|
|
378
|
+
if (answered === undefined || answered.current?.() === false) return;
|
|
379
|
+
const store = this.#deps.store;
|
|
380
|
+
store.batch(() => {
|
|
381
|
+
for (const [type, rows] of Object.entries(answered.records ?? {})) store.adopt(type, rows);
|
|
382
|
+
for (const [type, keys] of Object.entries(answered.removed ?? {})) store.remove(type, keys);
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
353
386
|
#set(entry: Entry, state: ChannelState): void {
|
|
354
387
|
entry.state = state;
|
|
355
388
|
for (const listener of entry.listeners) listener();
|
package/src/client-contract.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// shapes without importing the connection lifecycle underneath — and the bytes that come with it.
|
|
4
4
|
|
|
5
5
|
import type { Clock, Row } from '@ultimat3/core/page';
|
|
6
|
+
import type { CatchUpRecords } from './channel-catch-up';
|
|
6
7
|
import type { LiveCursor } from './cursor';
|
|
7
8
|
import type { LiveState } from './live-rows';
|
|
8
9
|
import type { RecordStore } from './record-store';
|
|
@@ -71,8 +72,12 @@ export interface LiveClientOptions {
|
|
|
71
72
|
/** Where a failure nobody awaits is reported. Defaults to `console.error`. */
|
|
72
73
|
readonly onError?: (error: unknown) => void;
|
|
73
74
|
/**
|
|
74
|
-
* A channel's catch-up read, re-run on `replay-gap` or a new epoch: the named query,
|
|
75
|
-
*
|
|
75
|
+
* A channel's catch-up read, re-run on `replay-gap` or a new epoch: the named query, answering its
|
|
76
|
+
* records UNADOPTED for the channel book to adopt with the frames held behind the read.
|
|
77
|
+
* `page-socket.ts` supplies the real one (`channel-catch-up.ts`).
|
|
76
78
|
*/
|
|
77
|
-
readonly catchUp: (
|
|
79
|
+
readonly catchUp: (
|
|
80
|
+
query: string,
|
|
81
|
+
params: Readonly<Record<string, string>>,
|
|
82
|
+
) => Promise<CatchUpRecords | undefined>;
|
|
78
83
|
}
|
package/src/page-socket.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// the connection lifecycle: a hook reaches it as `services.socket`, off the page object.
|
|
4
4
|
|
|
5
5
|
import { onRescope, pageClient } from '@ultimat3/core/page';
|
|
6
|
-
import {
|
|
6
|
+
import { catchUpRead } from './channel-catch-up';
|
|
7
7
|
import { LiveClient } from './client';
|
|
8
8
|
import { DEFAULT_HEARTBEAT_MS } from './client-heartbeat';
|
|
9
9
|
import { peekOutbox } from './outbox-slot';
|
|
@@ -33,8 +33,7 @@ export function pageSocket(hook: string): LiveClient {
|
|
|
33
33
|
buildId: target.buildId,
|
|
34
34
|
actorId: pageClient().scope.principal ?? null,
|
|
35
35
|
store: page.store,
|
|
36
|
-
|
|
37
|
-
catchUp: (query, params) => queryClientMethodFor(query, { baseUrl: '' })(params),
|
|
36
|
+
catchUp: catchUpRead,
|
|
38
37
|
});
|
|
39
38
|
page.socket = client;
|
|
40
39
|
pageClient().socket = client;
|