@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/realtime",
3
- "version": "27.10.1",
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.1",
44
- "@ultimat3/entity": "27.10.1",
45
- "@ultimat3/policy": "27.10.1",
46
- "@ultimat3/query": "27.10.1",
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
+ }
@@ -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
- /** Re-run the channel's catch-up read; its records land in the store through the transport. */
39
- catchUp(query: string, params: Readonly<Record<string, string>>): Promise<unknown>;
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
- // Names no write, by decision (O-507, #648, 2026-10-07): a pending write this read already
307
- // reflects settles on its own HTTP answer. Reopen only on an observed double paint.
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
- for (const frame of current) {
347
- if (entry.contiguous === null) entry.contiguous = frame.seq - 1;
348
- this.#apply(entry, frame);
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();
@@ -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, through
75
- * core's transport, so its records land in the store. `page-socket.ts` supplies the real one.
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: (query: string, params: Readonly<Record<string, string>>) => Promise<unknown>;
79
+ readonly catchUp: (
80
+ query: string,
81
+ params: Readonly<Record<string, string>>,
82
+ ) => Promise<CatchUpRecords | undefined>;
78
83
  }
@@ -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 { queryClientMethodFor } from '@ultimat3/query/client';
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
- // The channel's catch-up read is an ordinary query read: core's transport adopts its records.
37
- catchUp: (query, params) => queryClientMethodFor(query, { baseUrl: '' })(params),
36
+ catchUp: catchUpRead,
38
37
  });
39
38
  page.socket = client;
40
39
  pageClient().socket = client;