@ultimat3/realtime 20.2.1 → 21.0.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.
Files changed (89) hide show
  1. package/CLAUDE.md +186 -122
  2. package/README.md +121 -126
  3. package/package.json +7 -4
  4. package/src/apply-patches.ts +1 -1
  5. package/src/boot.ts +72 -0
  6. package/src/browser-socket.ts +42 -0
  7. package/src/changefeed.ts +7 -0
  8. package/src/channel-authz.ts +33 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +144 -0
  11. package/src/channel-describe.ts +33 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +116 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +79 -0
  16. package/src/channel-ref.ts +83 -0
  17. package/src/channel-registry.ts +35 -0
  18. package/src/channel-render.ts +37 -0
  19. package/src/channel-ring.ts +75 -0
  20. package/src/channel-wire.ts +66 -0
  21. package/src/channel.ts +147 -157
  22. package/src/client-channels.ts +289 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +138 -195
  26. package/src/cursor.ts +2 -2
  27. package/src/errors.ts +34 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +113 -0
  30. package/src/idb-types.ts +41 -0
  31. package/src/index.ts +80 -74
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +10 -3
  35. package/src/live-fanout.ts +30 -4
  36. package/src/live-record-type.ts +19 -0
  37. package/src/live-rows.ts +70 -67
  38. package/src/local-store-idb.ts +250 -0
  39. package/src/offline-queue.ts +9 -18
  40. package/src/outbox-slot.ts +31 -0
  41. package/src/page-errors.ts +124 -0
  42. package/src/page-outbox.ts +242 -0
  43. package/src/page-socket.ts +108 -0
  44. package/src/page-store.ts +138 -0
  45. package/src/pg-replication.ts +9 -2
  46. package/src/pgoutput.ts +37 -2
  47. package/src/presence.ts +17 -9
  48. package/src/query-window.ts +3 -0
  49. package/src/reactivity.ts +70 -0
  50. package/src/realtime-error.ts +1 -1
  51. package/src/record-await.ts +102 -0
  52. package/src/record-key.ts +34 -0
  53. package/src/record-names.ts +45 -0
  54. package/src/record-persister.ts +156 -0
  55. package/src/record-store.ts +364 -0
  56. package/src/record-synced.ts +100 -0
  57. package/src/record-tx.ts +145 -0
  58. package/src/replicator.ts +7 -1
  59. package/src/server.ts +2 -8
  60. package/src/socket-engine.ts +332 -0
  61. package/src/socket-host.ts +126 -0
  62. package/src/socket-port.ts +55 -0
  63. package/src/socket-routes.ts +170 -0
  64. package/src/socket.ts +51 -12
  65. package/src/sync-auth.ts +2 -2
  66. package/src/sync-frames.ts +41 -114
  67. package/src/sync-meta.ts +42 -0
  68. package/src/sync-node-contract.ts +100 -0
  69. package/src/sync-node.ts +24 -107
  70. package/src/sync-protocol.ts +63 -212
  71. package/src/sync-worker.ts +12 -0
  72. package/src/thundering-herd.ts +19 -1
  73. package/src/type-pins.ts +30 -61
  74. package/src/use-channel.ts +88 -0
  75. package/src/use-connection.ts +59 -0
  76. package/src/use-mutation.ts +214 -0
  77. package/src/use-query.ts +255 -0
  78. package/src/use-record.ts +121 -0
  79. package/src/wire-channel.ts +116 -0
  80. package/src/wire-read.ts +86 -0
  81. package/src/wire-version.ts +44 -0
  82. package/src/client-mutations.ts +0 -114
  83. package/src/client-topics.ts +0 -54
  84. package/src/hooks.ts +0 -277
  85. package/src/identity-map.ts +0 -141
  86. package/src/local-store.ts +0 -241
  87. package/src/query-hook.ts +0 -56
  88. package/src/rebase.ts +0 -263
  89. package/src/server-render-client.ts +0 -96
@@ -13,7 +13,7 @@ import { entityRow } from './pg-entity-row';
13
13
  import { assertIdentifier, preflight } from './pg-preflight';
14
14
  import { bunPgStream, parsePgUrl } from './pg-socket';
15
15
  import type { PhysicalRow } from './pg-values';
16
- import { PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
16
+ import { keyedWrite, PgOutputDecoder, type PgOutputMessage, type PgRelation } from './pgoutput';
17
17
 
18
18
  const DEFAULT_STATUS_INTERVAL_MS = 10_000;
19
19
 
@@ -64,6 +64,8 @@ interface Transaction {
64
64
  readonly xid: number;
65
65
  /** Position of the next row inside this transaction. Reproducible, which is what makes it usable. */
66
66
  sequence: number;
67
+ /** The keyed write this transaction is (`ChangeEvent.write`), from its opening WAL message. */
68
+ write?: string | undefined;
67
69
  }
68
70
 
69
71
  /**
@@ -171,7 +173,7 @@ export class PgReplicationStream {
171
173
  this.#confirmed = from === undefined ? 0n : commitPositionOf(from);
172
174
  await connection.startCopyBoth(
173
175
  `START_REPLICATION SLOT ${slot} LOGICAL ${printLsn(this.#confirmed)} ` +
174
- `(proto_version '1', publication_names '${publication}')`,
176
+ `(proto_version '1', publication_names '${publication}', messages 'true')`,
175
177
  );
176
178
  } catch (failure) {
177
179
  // The dial failure is the one that explains the boot, so a teardown that also failed must
@@ -318,6 +320,10 @@ export class PgReplicationStream {
318
320
  sequence: 0,
319
321
  };
320
322
  return;
323
+ case 'message':
324
+ // The driver's first statement in a keyed write: every row after it is that write's.
325
+ if (this.#transaction !== null) this.#transaction.write ??= keyedWrite(message);
326
+ return;
321
327
  case 'commit':
322
328
  this.#transaction = null;
323
329
  if (message.endLsn > this.#confirmed) this.#confirmed = message.endLsn;
@@ -381,6 +387,7 @@ export class PgReplicationStream {
381
387
  txid: transaction.xid.toString(10),
382
388
  orgId: tenantOf(after ?? before),
383
389
  at: transaction.commitAt,
390
+ ...(transaction.write === undefined ? {} : { write: transaction.write }),
384
391
  };
385
392
  await handlers.onChange(event);
386
393
  this.#lastLsn = lsn;
package/src/pgoutput.ts CHANGED
@@ -5,6 +5,7 @@
5
5
  // What a tuple's TEXT means is `pg-values.ts`'s: this file frames messages, that one owns the type
6
6
  // catalogue that turns postgres' text into the value a repository row holds.
7
7
 
8
+ import { isWriteDigest, WRITE_ORIGIN_WAL_PREFIX } from '@ultimat3/core';
8
9
  import { ReplicationProtocolError } from './errors';
9
10
  import { ByteReader, pgTimestampToEpochMs } from './pg-bytes';
10
11
  import { decodeValue, type PhysicalRow } from './pg-values';
@@ -50,7 +51,14 @@ export type PgOutputMessage =
50
51
  }
51
52
  | { readonly kind: 'delete'; readonly relation: PgRelation; readonly before: PhysicalRow }
52
53
  | { readonly kind: 'truncate'; readonly relations: readonly PgRelation[] }
53
- /** origin / type / logical message — decoded far enough to be skipped safely. */
54
+ /** `pg_logical_emit_message` — sent only when START_REPLICATION asks with `messages 'true'`. */
55
+ | {
56
+ readonly kind: 'message';
57
+ readonly transactional: boolean;
58
+ readonly prefix: string;
59
+ readonly content: string;
60
+ }
61
+ /** origin / type — decoded far enough to be skipped safely. */
54
62
  | { readonly kind: 'other'; readonly tag: string };
55
63
 
56
64
  /**
@@ -103,6 +111,31 @@ function decodeTupleData(reader: ByteReader, relation: PgRelation): PhysicalRow
103
111
  return row;
104
112
  }
105
113
 
114
+ /**
115
+ * Int8 flags (bit 1: transactional) · Int64 lsn · String prefix · Int32 length · Byte[length]. No
116
+ * xid: that field exists only inside a streamed transaction, which this stream never asks for.
117
+ */
118
+ function decodeMessage(reader: ByteReader): PgOutputMessage {
119
+ const transactional = (reader.uint8() & 1) === 1;
120
+ reader.int64();
121
+ const prefix = reader.cstring();
122
+ const content = reader.utf8(reader.int32());
123
+ return { kind: 'message', transactional, prefix, content };
124
+ }
125
+
126
+ /**
127
+ * The write a transactional message names — `@ultimat3/entity`'s Postgres driver opens a keyed
128
+ * write's transaction with one — or `undefined` for any other message an app or extension emits.
129
+ */
130
+ export function keyedWrite(message: {
131
+ readonly transactional: boolean;
132
+ readonly prefix: string;
133
+ readonly content: string;
134
+ }): string | undefined {
135
+ const named = message.transactional && message.prefix === WRITE_ORIGIN_WAL_PREFIX;
136
+ return named && isWriteDigest(message.content) ? message.content : undefined;
137
+ }
138
+
106
139
  /**
107
140
  * Holds the relation cache: postgres sends a `Relation` message once per table per connection and
108
141
  * every later tuple references it by oid, so a decoder instance is per-connection and is thrown
@@ -129,7 +162,9 @@ export class PgOutputDecoder {
129
162
  return this.#decodeDelete(reader);
130
163
  case 'T':
131
164
  return this.#decodeTruncate(reader);
132
- // 'O' (origin), 'Y' (type), 'M' (logical message), and any tag a newer server invents:
165
+ case 'M':
166
+ return decodeMessage(reader);
167
+ // 'O' (origin), 'Y' (type), and any tag a newer server invents:
133
168
  // nothing downstream needs them decoded, and guessing at an unknown tag's shape is how a
134
169
  // truncated read turns into a silent misread instead of a clean skip.
135
170
  default:
package/src/presence.ts CHANGED
@@ -3,12 +3,16 @@
3
3
  // Presence lives in `transport.shared`, never in a node's heap: when a `sync` node dies its members
4
4
  // simply stop heartbeating and expire, and every other node already sees the same set. Ephemeral
5
5
  // state is never modelled as rows — that rule is what keeps presence off the write path entirely.
6
+ // On the wire it is not a frame kind: a roster change is an `events` frame on the channel the member
7
+ // joined (`channel-presence.ts` is the payload), so only a channel declared `events: true` has one.
6
8
 
7
9
  import { type Clock, finiteOption, systemClock, uuid } from '@ultimat3/core';
8
10
  import type { ChannelHub, Topic } from './channel';
11
+ import { presenceEvent } from './channel-presence';
12
+ import type { ChannelEventsFrame } from './channel-wire';
9
13
  import type { Transport } from './fanout';
10
14
  import type { JsonObject } from './json';
11
- import { type Frame, PROTOCOL_VERSION, type PresenceMember } from './sync-protocol';
15
+ import { PROTOCOL_VERSION, type PresenceMember } from './sync-protocol';
12
16
 
13
17
  export const PRESENCE_KEY_PREFIX = 'presence';
14
18
  /** Separate namespace: the sweep lease is one member per *node*, never one per participant. */
@@ -218,7 +222,7 @@ export class PresenceRegistry {
218
222
  }
219
223
 
220
224
  /** Full-set frame for a client that just (re)connected — presence has no delta protocol. */
221
- async syncFrame(name: Topic): Promise<Frame> {
225
+ async syncFrame(name: Topic): Promise<ChannelEventsFrame> {
222
226
  const roster = await this.roster(name);
223
227
  return presenceFrame(name, 'sync', roster.members, roster.total);
224
228
  }
@@ -256,23 +260,27 @@ export class PresenceRegistry {
256
260
  members: readonly PresenceMember[],
257
261
  ): Promise<void> {
258
262
  if (!this.#hub) return;
259
- await this.#hub.publishFrame(name, presenceFrame(name, op, members));
263
+ await this.#hub.emit(name, presenceEvent(op, members));
260
264
  }
261
265
  }
262
266
 
263
267
  /**
264
- * `total` belongs to a **full set** and to nothing else: a `join`/`leave`/`update` frame carries the
265
- * members that changed, so a count beside them would read as "and the rest were truncated". Absent
266
- * is a defined answer — the client renders what it was sent.
268
+ * The roster as the `events` frame ONE socket is sent directly — the join reply. Everything else
269
+ * reaches sockets through `ChannelHub.emit`, the channel's own events path. `total` belongs to a
270
+ * full `sync` set and to nothing else: a delta carries the members that changed.
267
271
  */
268
272
  export function presenceFrame(
269
273
  name: Topic,
270
274
  op: 'join' | 'leave' | 'update' | 'sync',
271
275
  members: readonly PresenceMember[],
272
276
  total?: number,
273
- ): Frame {
274
- const base = { type: 'presence', v: PROTOCOL_VERSION, topic: name, op, members } as const;
275
- return total === undefined ? base : { ...base, total };
277
+ ): ChannelEventsFrame {
278
+ return {
279
+ type: 'events',
280
+ v: PROTOCOL_VERSION,
281
+ channel: name,
282
+ event: presenceEvent(op, members, total),
283
+ };
276
284
  }
277
285
 
278
286
  function parseMember(id: string, value: string): PresenceMember | null {
@@ -32,6 +32,8 @@ export interface QueryEntry {
32
32
  readonly input: JsonValue;
33
33
  /** Told to the client on every snapshot: the identity scope its rows belong under. */
34
34
  readonly rowEntity: string | null;
35
+ /** The record key a row travels under; `null` = its `id` (a plain table). */
36
+ readonly rowKey: ((row: Row) => string) | null;
35
37
  readonly shape: SubscriptionShape;
36
38
  readonly matcher: IncrementalMatcher;
37
39
  readonly subscribers: Map<string, LiveSubscription>;
@@ -87,6 +89,7 @@ export function createEntry(
87
89
  // Resolved with the matcher, from the same build: `prepare` has already run, so a definition
88
90
  // that compiles its shape per input can answer.
89
91
  rowEntity: definition.rowEntity?.(input) ?? null,
92
+ rowKey: definition.rowKey?.(input) ?? null,
90
93
  shape: {
91
94
  qid,
92
95
  // The matcher knows the dependency set this *input* produced; `definition.entities` is the
@@ -0,0 +1,70 @@
1
+ // What a hook renders through: THIS island bundle's signal factory. Module scope on purpose —
2
+ // every island carries its own solid-js, so a signal must come from the bundle whose effects read
3
+ // it, while the store behind it is the page's (`page-store.ts`). The island bootstrap installs it.
4
+
5
+ import type { SignalFactory } from './client-contract';
6
+ import { RealtimeUninstalledError } from './page-errors';
7
+ import { pageRealtime, type SyncTarget } from './page-store';
8
+
9
+ export interface RealtimeInstall {
10
+ /** `createSignal` from this island's solid-js, narrowed to two functions. */
11
+ readonly signal: SignalFactory;
12
+ /** Where the page's socket dials. Omitted by an island that holds no live hook. */
13
+ readonly sync?: SyncTarget;
14
+ }
15
+
16
+ let installed: SignalFactory | undefined;
17
+
18
+ /**
19
+ * Called by the island bootstrap `x build` prepends — never by an island's own code. Per bundle
20
+ * for the signal, per page for the sync target (the first one given wins; every island on a page
21
+ * was rendered by one server and names one node).
22
+ */
23
+ export function installRealtime(install: RealtimeInstall): void {
24
+ installed = install.signal;
25
+ const page = pageRealtime();
26
+ if (install.sync !== undefined && page.sync === undefined) page.sync = install.sync;
27
+ }
28
+
29
+ /**
30
+ * A DOM is the whole question, the same probe and the same words as `@ultimat3/ui`'s `solid()`:
31
+ * with one, a hook that finds nothing installed is a real bug; without one it is a server render.
32
+ */
33
+ export function hasDom(): boolean {
34
+ return typeof document !== 'undefined' && typeof window !== 'undefined';
35
+ }
36
+
37
+ /**
38
+ * A signal that never changes, because nothing on the server can change it: one render, one pass.
39
+ * The setter is kept so a caller reads its own write back — a signal that swallowed writes would
40
+ * be a different lie.
41
+ */
42
+ const inertSignal: SignalFactory = <T>(initial: T): [() => T, (next: T) => void] => {
43
+ let held = initial;
44
+ return [
45
+ (): T => held,
46
+ (next: T): void => {
47
+ held = next;
48
+ },
49
+ ];
50
+ };
51
+
52
+ /** The factory `hook` renders through: this bundle's, or the inert one on a server render. */
53
+ export function signalFor(hook: string): SignalFactory {
54
+ if (installed !== undefined) return installed;
55
+ if (hasDom()) throw new RealtimeUninstalledError({ hook });
56
+ return inertSignal;
57
+ }
58
+
59
+ /**
60
+ * A server render: nothing installed and no DOM. A hook answers the honest server state and never
61
+ * touches the page state — on a server that would be ONE store shared by every request.
62
+ */
63
+ export function isServerRender(): boolean {
64
+ return installed === undefined && !hasDom();
65
+ }
66
+
67
+ /** For tests: forget this bundle's install so cases stay independent. */
68
+ export function uninstallRealtime(): void {
69
+ installed = undefined;
70
+ }
@@ -6,7 +6,7 @@
6
6
  // that would be a cycle: `extends` runs at module evaluation, imports hoist above it, and the base
7
7
  // would be in its temporal dead zone by the time the subclass module was evaluated.
8
8
 
9
- import { UltimateError } from '@ultimat3/core';
9
+ import { UltimateError } from '@ultimat3/core/page';
10
10
  import type { RealtimeErrorCode } from './errors';
11
11
 
12
12
  /**
@@ -0,0 +1,102 @@
1
+ // The overlays waiting on server truth: a write whose answer did not carry every row it touched
2
+ // keeps its overlay until the server next reaches one of those rows, bounded by a timer. Also what
3
+ // the server reached while a write was still in flight — the frame routinely beats the answer.
4
+
5
+ import { finiteCount } from '@ultimat3/core/page';
6
+ import type { RecordKey } from './record-key';
7
+ import type { Scheduler } from './thundering-herd';
8
+
9
+ /** An overlay whose answer did not carry every row it wrote waits this long for one, no longer. */
10
+ export const DEFAULT_AWAIT_SERVER_MS = 10_000;
11
+
12
+ export class ServerWait {
13
+ /** Each waiting overlay: the rows it waits for, and the timer bounding the wait. */
14
+ readonly #awaiting = new Map<string, { readonly rows: ReadonlySet<RecordKey>; cancel(): void }>();
15
+ /**
16
+ * Rows server truth reached while each overlay's write was still in flight. The node fans a
17
+ * commit out before the response is written, so the frame routinely beats the answer — and a
18
+ * settle that then waited for ANOTHER server write replayed the twin over a row that already
19
+ * held it, counting the write twice until the bound.
20
+ */
21
+ readonly #heard = new Map<string, Set<RecordKey>>();
22
+ readonly #schedule: Scheduler;
23
+ readonly #ms: number;
24
+
25
+ constructor(schedule: Scheduler | undefined, awaitMs: number | undefined) {
26
+ // Inline rather than `thundering-herd`'s `timeoutScheduler`: that module carries the backoff,
27
+ // and a `useRecord`-only island would pay for it to arm one timer.
28
+ this.#schedule =
29
+ schedule ??
30
+ ((fn, ms) => {
31
+ const timer = setTimeout(fn, ms);
32
+ return () => clearTimeout(timer);
33
+ });
34
+ this.#ms = finiteCount('RecordStore', 'awaitMs', awaitMs ?? DEFAULT_AWAIT_SERVER_MS, 1);
35
+ }
36
+
37
+ /** Wait for any of `rows`; `expire` runs if none is reached in time. */
38
+ start(key: string, rows: ReadonlySet<RecordKey>, expire: () => void): void {
39
+ this.#awaiting.get(key)?.cancel();
40
+ const cancel = this.#schedule(() => {
41
+ // Nothing answered in time: the caller drops the overlay and server truth stands.
42
+ if (this.#awaiting.delete(key)) expire();
43
+ }, this.#ms);
44
+ this.#awaiting.set(key, { rows, cancel });
45
+ }
46
+
47
+ /** The overlay went some other way: its wait and what it heard go with it. */
48
+ forget(key: string): void {
49
+ this.#awaiting.get(key)?.cancel();
50
+ this.#awaiting.delete(key);
51
+ this.#heard.delete(key);
52
+ }
53
+
54
+ /** A replay dropped the overlay: what it heard goes; a wait ends when `resolve` sees it gone. */
55
+ unhear(key: string): void {
56
+ this.#heard.delete(key);
57
+ }
58
+
59
+ /** What the server reached while `key` was in flight — taken, so a second settle starts over. */
60
+ takeHeard(key: string): ReadonlySet<RecordKey> | undefined {
61
+ const heard = this.#heard.get(key);
62
+ this.#heard.delete(key);
63
+ return heard;
64
+ }
65
+
66
+ /** Note, per in-flight overlay (one not already waiting), which of its rows the server reached. */
67
+ hear(
68
+ inFlight: Iterable<[string, ReadonlySet<RecordKey>]>,
69
+ reached: (rk: RecordKey) => boolean,
70
+ ): void {
71
+ for (const [key, touched] of inFlight) {
72
+ if (this.#awaiting.has(key)) continue;
73
+ const wrote = [...touched].filter(reached);
74
+ if (wrote.length === 0) continue;
75
+ const heard = this.#heard.get(key) ?? new Set<RecordKey>();
76
+ for (const rk of wrote) heard.add(rk);
77
+ this.#heard.set(key, heard);
78
+ }
79
+ }
80
+
81
+ /**
82
+ * The waits the server just answered — or whose overlay is already gone — ended. Answers the
83
+ * keys whose overlay the caller must now drop because server truth reached it.
84
+ */
85
+ resolve(pending: (key: string) => boolean, moved: ReadonlySet<RecordKey>): readonly string[] {
86
+ const answered: string[] = [];
87
+ for (const [key, waiting] of [...this.#awaiting]) {
88
+ const gone = !pending(key);
89
+ if (!gone && ![...waiting.rows].some((rk) => moved.has(rk))) continue;
90
+ waiting.cancel();
91
+ this.#awaiting.delete(key);
92
+ if (!gone) answered.push(key);
93
+ }
94
+ return answered;
95
+ }
96
+
97
+ clear(): void {
98
+ for (const waiting of this.#awaiting.values()) waiting.cancel();
99
+ this.#awaiting.clear();
100
+ this.#heard.clear();
101
+ }
102
+ }
@@ -0,0 +1,34 @@
1
+ // How a record is named in the page store: `type:key`, and the set of names an answer carried.
2
+ // Apart from the store so a module that only names records never loads the store's machinery.
3
+
4
+ import type { RecordRows, Row } from '@ultimat3/core/page';
5
+
6
+ /** `type:key`. A record type is an entity name, which never holds `:`, so the split is unambiguous. */
7
+ export type RecordKey = string;
8
+
9
+ export function recordKey(type: string, key: string): RecordKey {
10
+ return `${type}:${key}`;
11
+ }
12
+
13
+ /**
14
+ * Every record an answer's envelope names — adopted or removed — as `type:key`, added to `into`.
15
+ * What an overlay settles against: a row the answer did not name keeps its overlay.
16
+ */
17
+ export function carriedBy(
18
+ envelope: {
19
+ readonly records?: Readonly<Record<string, RecordRows>>;
20
+ readonly removed?: Readonly<Record<string, readonly string[]>>;
21
+ },
22
+ into: Set<RecordKey>,
23
+ ): void {
24
+ for (const [type, rows] of Object.entries(envelope.records ?? {})) {
25
+ for (const key of Object.keys(rows)) into.add(recordKey(type, key));
26
+ }
27
+ for (const [type, keys] of Object.entries(envelope.removed ?? {})) {
28
+ for (const key of keys) into.add(recordKey(type, key));
29
+ }
30
+ }
31
+
32
+ /** A row is a plain object: an array, `null` or a scalar off the wire is never merged. */
33
+ export const isRow = (value: unknown): value is Row =>
34
+ typeof value === 'object' && value !== null && !Array.isArray(value);
@@ -0,0 +1,45 @@
1
+ // Which pending overlay a `records` frame's `write` names. The node stamps a frame with the digest
2
+ // of the idempotency key its write arrived under (`writeDigest`, `@ultimat3/core`); the page digests
3
+ // its own keys as it pushes them, so a frame is recognised as this page's own echo by lookup.
4
+
5
+ import { writeDigest } from '@ultimat3/core/page';
6
+
7
+ export class WriteNames {
8
+ readonly #byDigest = new Map<string, string>();
9
+ readonly #byKey = new Map<string, string>();
10
+
11
+ /**
12
+ * Digest `key` and remember it while `pending(key)` still holds. Started when the overlay is
13
+ * pushed, before its request is sent: the digest resolves in microseconds and the echo needs the
14
+ * request to reach the node, commit and fan back out first. No `crypto.subtle` (plain HTTP off
15
+ * `localhost`) names nothing, and the echo is a plain merge — the behaviour before frames named
16
+ * their write.
17
+ */
18
+ name(key: string, pending: (key: string) => boolean): void {
19
+ void writeDigest(key).then(
20
+ (digest) => {
21
+ if (digest === undefined || !pending(key)) return;
22
+ this.#byDigest.set(digest, key);
23
+ this.#byKey.set(key, digest);
24
+ },
25
+ () => undefined,
26
+ );
27
+ }
28
+
29
+ /** The overlay `digest` names, if it is one this page pushed and still holds. */
30
+ keyOf(digest: string): string | undefined {
31
+ return this.#byDigest.get(digest);
32
+ }
33
+
34
+ forget(key: string): void {
35
+ const digest = this.#byKey.get(key);
36
+ if (digest === undefined) return;
37
+ this.#byKey.delete(key);
38
+ this.#byDigest.delete(digest);
39
+ }
40
+
41
+ clear(): void {
42
+ this.#byDigest.clear();
43
+ this.#byKey.clear();
44
+ }
45
+ }
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Keeps the record types an app marked `persist: true` on disk, per principal, and puts them back
3
+ * on the next load BEFORE the socket connects. Writes are debounced and flushed when the page is
4
+ * hidden or leaves; a restored row is stale until the server confirms it, and the first server row
5
+ * for that key wins outright.
6
+ *
7
+ * Only SYNCED truth is written — never an optimistic overlay: a write the server later refuses must
8
+ * not survive a reload as if it had landed. Its intent survives in the outbox instead.
9
+ */
10
+
11
+ import type { ClientScope, RecordRows, Row } from '@ultimat3/core/page';
12
+ import { CLIENT_PERSIST_META, finiteCount, onRescope, pageClient } from '@ultimat3/core/page';
13
+ import type { LocalStore } from './local-store-idb';
14
+ import { scopeKey } from './local-store-idb';
15
+
16
+ /**
17
+ * What the persister needs from the page's store. `synced` and `restore` are the two methods
18
+ * `RecordStore` owes this module (plan 101 slice 12): the server's row without the overlay, and a
19
+ * restore that yields to — and is replaced by — the first server row for the same key.
20
+ */
21
+ export interface PersistableStore {
22
+ subscribe(listener: (changed: ReadonlySet<string>) => void): () => void;
23
+ synced(type: string, key: string): Row | undefined;
24
+ restore(type: string, rows: RecordRows): void;
25
+ }
26
+
27
+ export interface RecordPersisterOptions {
28
+ readonly store: PersistableStore;
29
+ readonly local: LocalStore;
30
+ /** The record types to keep — `persistedTypes()` in a browser. */
31
+ readonly types: ReadonlySet<string>;
32
+ readonly principal?: (() => ClientScope['principal']) | undefined;
33
+ readonly debounceMs?: number | undefined;
34
+ readonly schedule?: ((fn: () => void, ms: number) => () => void) | undefined;
35
+ /** Subscribes to "the page is going away": `pagehide`, and `visibilitychange` to hidden. */
36
+ readonly onLeave?: ((flush: () => void) => () => void) | undefined;
37
+ }
38
+
39
+ export interface RecordPersister {
40
+ /** Puts this principal's rows back. Resolves with how many were restored. */
41
+ restore(): Promise<number>;
42
+ /** Writes everything changed since the last flush, now. */
43
+ flush(): Promise<void>;
44
+ stop(): void;
45
+ }
46
+
47
+ export function recordPersister(options: RecordPersisterOptions): RecordPersister {
48
+ const principal =
49
+ options.principal ?? ((): ClientScope['principal'] => pageClient().scope.principal);
50
+ const schedule =
51
+ options.schedule ??
52
+ ((fn: () => void, ms: number): (() => void) => {
53
+ const timer = setTimeout(fn, ms);
54
+ return () => clearTimeout(timer);
55
+ });
56
+ // A whole number of ms, at least 1: `NaN` would arm a timer that fires at once, forever.
57
+ const debounceMs = finiteCount('recordPersister', 'debounceMs', options.debounceMs ?? 250, 1);
58
+ const dirty = new Set<string>();
59
+ let cancel: (() => void) | undefined;
60
+
61
+ const flush = async (): Promise<void> => {
62
+ cancel?.();
63
+ cancel = undefined;
64
+ const scope = scopeKey(principal());
65
+ const changed = [...dirty];
66
+ dirty.clear();
67
+ if (scope === undefined || changed.length === 0) return;
68
+ const puts: { type: string; key: string; row: Row }[] = [];
69
+ const deletes: { type: string; key: string }[] = [];
70
+ for (const rk of changed) {
71
+ const at = rk.indexOf(':');
72
+ const type = rk.slice(0, at);
73
+ const key = rk.slice(at + 1);
74
+ const row = options.store.synced(type, key);
75
+ if (row === undefined) deletes.push({ type, key });
76
+ else puts.push({ type, key, row });
77
+ }
78
+ await options.local.write(scope, puts, deletes);
79
+ };
80
+
81
+ const unsubscribe = options.store.subscribe((changed) => {
82
+ for (const rk of changed) {
83
+ if (options.types.has(rk.slice(0, rk.indexOf(':')))) dirty.add(rk);
84
+ }
85
+ if (dirty.size > 0 && cancel === undefined) {
86
+ cancel = schedule(() => void flush(), debounceMs);
87
+ }
88
+ });
89
+
90
+ const restore = async (): Promise<number> => {
91
+ const scope = scopeKey(principal());
92
+ if (scope === undefined) return 0;
93
+ let restored = 0;
94
+ for (const [type, rows] of await options.local.rows(scope)) {
95
+ if (!options.types.has(type)) continue;
96
+ options.store.restore(type, rows);
97
+ restored += Object.keys(rows).length;
98
+ }
99
+ return restored;
100
+ };
101
+
102
+ // A principal change: the previous principal's rows leave the disk with it, nothing it changed is
103
+ // written under the next one, and the next one's own rows come back.
104
+ const unscope = onRescope((_next, prev) => {
105
+ cancel?.();
106
+ cancel = undefined;
107
+ dirty.clear();
108
+ const gone = scopeKey(prev.principal);
109
+ void (gone === undefined ? Promise.resolve() : options.local.wipe(gone)).then(restore);
110
+ });
111
+ const unleave = (options.onLeave ?? onPageLeave)(() => void flush());
112
+
113
+ return {
114
+ restore,
115
+ flush,
116
+ stop: (): void => {
117
+ cancel?.();
118
+ unsubscribe();
119
+ unscope();
120
+ unleave();
121
+ },
122
+ };
123
+ }
124
+
125
+ /** `pagehide` and a hidden `visibilitychange` — the last moments a page can still write. */
126
+ function onPageLeave(flush: () => void): () => void {
127
+ const doc: (EventTarget & { visibilityState?: string }) | undefined = Reflect.get(
128
+ globalThis,
129
+ 'document',
130
+ );
131
+ // A partial `document` (a test's stand-in) has no events to hear: nothing to subscribe to.
132
+ if (doc === undefined || typeof doc.addEventListener !== 'function') return () => {};
133
+ const hidden = (): void => {
134
+ if (doc.visibilityState === 'hidden') flush();
135
+ };
136
+ globalThis.addEventListener('pagehide', flush);
137
+ doc.addEventListener('visibilitychange', hidden);
138
+ return () => {
139
+ globalThis.removeEventListener('pagehide', flush);
140
+ doc.removeEventListener('visibilitychange', hidden);
141
+ };
142
+ }
143
+
144
+ /** The types the server rendered as persisted (`<meta name="ultimate-persist">`). None = none. */
145
+ export function persistedTypes(): ReadonlySet<string> {
146
+ const doc: { querySelector?: (selector: string) => { content?: unknown } | null } | undefined =
147
+ Reflect.get(globalThis, 'document');
148
+ const content = doc?.querySelector?.(`meta[name="${CLIENT_PERSIST_META}"]`)?.content;
149
+ if (typeof content !== 'string') return new Set();
150
+ return new Set(
151
+ content
152
+ .split(',')
153
+ .map((type) => type.trim())
154
+ .filter((type) => type !== ''),
155
+ );
156
+ }