@ultimat3/realtime 20.2.1 → 22.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 (103) hide show
  1. package/CLAUDE.md +300 -952
  2. package/README.md +192 -131
  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 +14 -1
  8. package/src/channel-authz.ts +52 -0
  9. package/src/channel-bridge.ts +34 -0
  10. package/src/channel-decl.ts +155 -0
  11. package/src/channel-describe.ts +35 -0
  12. package/src/channel-gaps.ts +57 -0
  13. package/src/channel-logs.ts +134 -0
  14. package/src/channel-presence.ts +68 -0
  15. package/src/channel-records.ts +87 -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 +359 -0
  23. package/src/client-contract.ts +35 -65
  24. package/src/client-frames.ts +42 -110
  25. package/src/client.ts +150 -195
  26. package/src/cursor.ts +7 -2
  27. package/src/errors.ts +55 -101
  28. package/src/frame-lanes.ts +9 -5
  29. package/src/idb-fake.ts +133 -0
  30. package/src/idb-types.ts +48 -0
  31. package/src/index.ts +80 -75
  32. package/src/json.ts +5 -0
  33. package/src/live-contract.ts +5 -0
  34. package/src/live-definition.ts +15 -4
  35. package/src/live-fanout.ts +81 -6
  36. package/src/live-query.ts +11 -0
  37. package/src/live-record-type.ts +19 -0
  38. package/src/live-replicator.ts +160 -0
  39. package/src/live-rows.ts +70 -67
  40. package/src/local-store-idb.ts +324 -0
  41. package/src/matcher-bridge.ts +5 -0
  42. package/src/nats-fake.ts +10 -1
  43. package/src/nats-jetstream.ts +36 -14
  44. package/src/nats-transport.ts +2 -2
  45. package/src/offline-queue.ts +85 -39
  46. package/src/outbox-slot.ts +31 -0
  47. package/src/page-errors.ts +124 -0
  48. package/src/page-outbox.ts +312 -0
  49. package/src/page-socket.ts +139 -0
  50. package/src/page-store.ts +138 -0
  51. package/src/pg-entity-row.ts +37 -184
  52. package/src/pg-preflight.ts +24 -2
  53. package/src/pg-replication.ts +28 -8
  54. package/src/pg-wire.ts +51 -15
  55. package/src/pgoutput.ts +37 -2
  56. package/src/policy-fake.ts +14 -0
  57. package/src/presence.ts +17 -9
  58. package/src/query-window.ts +38 -21
  59. package/src/reactivity.ts +70 -0
  60. package/src/realtime-error.ts +1 -1
  61. package/src/record-await.ts +102 -0
  62. package/src/record-key.ts +34 -0
  63. package/src/record-names.ts +45 -0
  64. package/src/record-persister.ts +156 -0
  65. package/src/record-store.ts +364 -0
  66. package/src/record-synced.ts +100 -0
  67. package/src/record-tx.ts +145 -0
  68. package/src/replicator.ts +20 -4
  69. package/src/server.ts +10 -11
  70. package/src/socket-drops.ts +30 -0
  71. package/src/socket-engine.ts +344 -0
  72. package/src/socket-host.ts +225 -0
  73. package/src/socket-idle.ts +21 -0
  74. package/src/socket-port.ts +55 -0
  75. package/src/socket-routes.ts +170 -0
  76. package/src/socket.ts +91 -49
  77. package/src/subscriber-gate.ts +92 -3
  78. package/src/sync-auth.ts +2 -2
  79. package/src/sync-frames.ts +41 -114
  80. package/src/sync-meta.ts +42 -0
  81. package/src/sync-node-contract.ts +100 -0
  82. package/src/sync-node.ts +26 -114
  83. package/src/sync-protocol.ts +63 -212
  84. package/src/sync-worker.ts +12 -0
  85. package/src/thundering-herd.ts +31 -12
  86. package/src/transport-env.ts +55 -14
  87. package/src/type-pins.ts +30 -61
  88. package/src/use-channel.ts +88 -0
  89. package/src/use-connection.ts +59 -0
  90. package/src/use-mutation.ts +227 -0
  91. package/src/use-query.ts +260 -0
  92. package/src/use-record.ts +121 -0
  93. package/src/wire-channel.ts +116 -0
  94. package/src/wire-read.ts +86 -0
  95. package/src/wire-version.ts +44 -0
  96. package/src/client-mutations.ts +0 -114
  97. package/src/client-topics.ts +0 -54
  98. package/src/hooks.ts +0 -277
  99. package/src/identity-map.ts +0 -141
  100. package/src/local-store.ts +0 -241
  101. package/src/query-hook.ts +0 -56
  102. package/src/rebase.ts +0 -263
  103. package/src/server-render-client.ts +0 -96
@@ -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
@@ -122,29 +125,43 @@ export function createEntry(
122
125
  export async function fillWindow(
123
126
  entry: QueryEntry,
124
127
  ): Promise<{ rows: readonly Row[]; lsn: string }> {
125
- // Read before `startRead` clears it: a second caller arriving during the read joins it and is
126
- // not the one that forced it, which is what keeps one forced read from becoming N.
127
- const forced = entry.stale;
128
- const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
129
- const result = await pending.result;
130
- return await entry.lock.run(async () => {
131
- // Two rules, and neither can stand in for the other. Against another READ it is identity —
132
- // the same check `startRead` makes on `entry.reading` one function down, and the one
133
- // `packages/cache/src/single-flight.ts` makes for the same reason — because an lsn cannot
134
- // order two reads at all: a definition with no lsn provider answers `''` for both, and
135
- // `'' >= ''` let the older one overwrite the gap repair the newer one had just landed, with
136
- // `stale` already cleared by its issue and therefore nothing left to re-read. Against a
137
- // CHANGE it is still the lsn, because a fanout moved `entry.lsn` forwards while this read was
138
- // in flight and rewinding to what the read saw hands that subscriber rows the fanout has
139
- // moved past — except for a forced read, which was issued *because* what is under it is
140
- // wrong.
141
- if (isNewestRead(entry, pending) && (forced || result.lsn >= entry.lsn)) {
142
- applyRead(entry, pending, result);
143
- }
144
- return { rows: entry.rows, lsn: entry.lsn };
145
- });
128
+ for (let attempt = 0; ; attempt += 1) {
129
+ // Read before `startRead` clears it: a second caller arriving during the read joins it and is
130
+ // not the one that forced it, which is what keeps one forced read from becoming N.
131
+ const forced = entry.stale;
132
+ const pending = forced || entry.reading === null ? startRead(entry) : entry.reading;
133
+ const result = await pending.result;
134
+ const again = await entry.lock.run(async () => {
135
+ // Two rules, and neither can stand in for the other. Against another READ it is identity —
136
+ // the same check `startRead` makes on `entry.reading` one function down, and the one
137
+ // `packages/cache/src/single-flight.ts` makes for the same reason — because an lsn cannot
138
+ // order two reads at all: a definition with no lsn provider answers `''` for both, and
139
+ // `'' >= ''` let the older one overwrite the gap repair the newer one had just landed, with
140
+ // `stale` already cleared by its issue and therefore nothing left to re-read. Against a
141
+ // CHANGE it is still the lsn, because a fanout moved `entry.lsn` forwards while this read was
142
+ // in flight and rewinding to what the read saw hands that subscriber rows the fanout has
143
+ // moved past — except for a forced read, which was issued *because* what is under it is
144
+ // wrong, and for the FIRST read, which has no window under it to rewind: a fanout never
145
+ // patches a window no read has landed in (`live-fanout.ts`), it marks it stale instead.
146
+ const first = entry.applied === 0;
147
+ if (isNewestRead(entry, pending) && (forced || first || result.lsn >= entry.lsn)) {
148
+ applyRead(entry, pending, result);
149
+ }
150
+ // A change reached this window while its first read was in flight and could not be folded,
151
+ // so what just landed may predate it: read once more before serving anyone a partial window.
152
+ return first && entry.stale && attempt < COLD_REREADS;
153
+ });
154
+ if (!again) return { rows: entry.rows, lsn: entry.lsn };
155
+ }
146
156
  }
147
157
 
158
+ /**
159
+ * How many times a cold window re-reads because writes kept landing during its read. Bounded: a
160
+ * table written faster than it can be read would otherwise never serve a subscriber, and after the
161
+ * bound the window stays `stale`, so the next change re-reads it anyway.
162
+ */
163
+ const COLD_REREADS = 3;
164
+
148
165
  /**
149
166
  * The same replacement, for a caller that is already holding the lane. A fanout cannot call
150
167
  * `fillWindow` — that takes the entry's own lane, and a lane is not reentrant — so the one path
@@ -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
+ }