@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.
- package/CLAUDE.md +186 -122
- package/README.md +121 -126
- package/package.json +7 -4
- package/src/apply-patches.ts +1 -1
- package/src/boot.ts +72 -0
- package/src/browser-socket.ts +42 -0
- package/src/changefeed.ts +7 -0
- package/src/channel-authz.ts +33 -0
- package/src/channel-bridge.ts +34 -0
- package/src/channel-decl.ts +144 -0
- package/src/channel-describe.ts +33 -0
- package/src/channel-gaps.ts +57 -0
- package/src/channel-logs.ts +116 -0
- package/src/channel-presence.ts +68 -0
- package/src/channel-records.ts +79 -0
- package/src/channel-ref.ts +83 -0
- package/src/channel-registry.ts +35 -0
- package/src/channel-render.ts +37 -0
- package/src/channel-ring.ts +75 -0
- package/src/channel-wire.ts +66 -0
- package/src/channel.ts +147 -157
- package/src/client-channels.ts +289 -0
- package/src/client-contract.ts +35 -65
- package/src/client-frames.ts +42 -110
- package/src/client.ts +138 -195
- package/src/cursor.ts +2 -2
- package/src/errors.ts +34 -101
- package/src/frame-lanes.ts +9 -5
- package/src/idb-fake.ts +113 -0
- package/src/idb-types.ts +41 -0
- package/src/index.ts +80 -74
- package/src/json.ts +5 -0
- package/src/live-contract.ts +5 -0
- package/src/live-definition.ts +10 -3
- package/src/live-fanout.ts +30 -4
- package/src/live-record-type.ts +19 -0
- package/src/live-rows.ts +70 -67
- package/src/local-store-idb.ts +250 -0
- package/src/offline-queue.ts +9 -18
- package/src/outbox-slot.ts +31 -0
- package/src/page-errors.ts +124 -0
- package/src/page-outbox.ts +242 -0
- package/src/page-socket.ts +108 -0
- package/src/page-store.ts +138 -0
- package/src/pg-replication.ts +9 -2
- package/src/pgoutput.ts +37 -2
- package/src/presence.ts +17 -9
- package/src/query-window.ts +3 -0
- package/src/reactivity.ts +70 -0
- package/src/realtime-error.ts +1 -1
- package/src/record-await.ts +102 -0
- package/src/record-key.ts +34 -0
- package/src/record-names.ts +45 -0
- package/src/record-persister.ts +156 -0
- package/src/record-store.ts +364 -0
- package/src/record-synced.ts +100 -0
- package/src/record-tx.ts +145 -0
- package/src/replicator.ts +7 -1
- package/src/server.ts +2 -8
- package/src/socket-engine.ts +332 -0
- package/src/socket-host.ts +126 -0
- package/src/socket-port.ts +55 -0
- package/src/socket-routes.ts +170 -0
- package/src/socket.ts +51 -12
- package/src/sync-auth.ts +2 -2
- package/src/sync-frames.ts +41 -114
- package/src/sync-meta.ts +42 -0
- package/src/sync-node-contract.ts +100 -0
- package/src/sync-node.ts +24 -107
- package/src/sync-protocol.ts +63 -212
- package/src/sync-worker.ts +12 -0
- package/src/thundering-herd.ts +19 -1
- package/src/type-pins.ts +30 -61
- package/src/use-channel.ts +88 -0
- package/src/use-connection.ts +59 -0
- package/src/use-mutation.ts +214 -0
- package/src/use-query.ts +255 -0
- package/src/use-record.ts +121 -0
- package/src/wire-channel.ts +116 -0
- package/src/wire-read.ts +86 -0
- package/src/wire-version.ts +44 -0
- package/src/client-mutations.ts +0 -114
- package/src/client-topics.ts +0 -54
- package/src/hooks.ts +0 -277
- package/src/identity-map.ts +0 -141
- package/src/local-store.ts +0 -241
- package/src/query-hook.ts +0 -56
- package/src/rebase.ts +0 -263
- package/src/server-render-client.ts +0 -96
package/src/pg-replication.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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 {
|
|
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<
|
|
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.
|
|
263
|
+
await this.#hub.emit(name, presenceEvent(op, members));
|
|
260
264
|
}
|
|
261
265
|
}
|
|
262
266
|
|
|
263
267
|
/**
|
|
264
|
-
* `
|
|
265
|
-
*
|
|
266
|
-
*
|
|
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
|
-
):
|
|
274
|
-
|
|
275
|
-
|
|
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 {
|
package/src/query-window.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/realtime-error.ts
CHANGED
|
@@ -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
|
+
}
|