@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/live-definition.ts
CHANGED
|
@@ -14,6 +14,7 @@ import { type AnyQuery, queryHash, queryName } from '@ultimat3/query';
|
|
|
14
14
|
import { LiveRowUnidentifiedError } from './errors';
|
|
15
15
|
import { isRow, type JsonValue, type Row } from './json';
|
|
16
16
|
import type { LiveQueryDefinition, SnapshotResult } from './live-contract';
|
|
17
|
+
import { liveRecords } from './live-record-type';
|
|
17
18
|
import { type IncrementalMatcher, matcherFor, type Projection } from './matcher-bridge';
|
|
18
19
|
import { authorizeWithPolicy, visibleWithPolicy } from './policy-gate';
|
|
19
20
|
|
|
@@ -47,6 +48,8 @@ interface SharedWindow {
|
|
|
47
48
|
readonly matcher: IncrementalMatcher;
|
|
48
49
|
/** The compiled shape's root entity — the client's identity scope for every row of this read. */
|
|
49
50
|
readonly rowEntity: string;
|
|
51
|
+
/** Its projection's record key; `null` for a plain table keyed by `id`. */
|
|
52
|
+
readonly rowKey: ((row: Row) => string) | null;
|
|
50
53
|
read(): Promise<readonly Row[]>;
|
|
51
54
|
}
|
|
52
55
|
|
|
@@ -109,11 +112,14 @@ export function liveQueryDefinition(
|
|
|
109
112
|
// a second subject-less copy — which is what this did — paid for the parse and the `sql()`
|
|
110
113
|
// twice per query id and left two descriptions of one read that agreed only by luck.
|
|
111
114
|
const projection = learnProjection();
|
|
115
|
+
const records = liveRecords(live.shape.entity);
|
|
112
116
|
const built: SharedWindow = {
|
|
113
117
|
matcher: matcherFor(live, projection.read),
|
|
114
|
-
// `assertMatchable` already refused a shape without one
|
|
115
|
-
//
|
|
116
|
-
|
|
118
|
+
// `assertMatchable` already refused a shape without one. The shape names the TABLE (what a
|
|
119
|
+
// `ChangeEvent` carries); the client store keys records by ENTITY name, so the snapshot tells
|
|
120
|
+
// it the record type, and every frame carries the key the entity's projection renders.
|
|
121
|
+
rowEntity: records.type,
|
|
122
|
+
rowKey: records.key,
|
|
117
123
|
read: async () => {
|
|
118
124
|
const rows = rowsOf(name, await live.execute());
|
|
119
125
|
projection.teach(rows);
|
|
@@ -142,6 +148,7 @@ export function liveQueryDefinition(
|
|
|
142
148
|
// Read off the same resolved window as the matcher, so the scope the client keys rows under and
|
|
143
149
|
// the entity the matcher patches them from can never be two different names.
|
|
144
150
|
rowEntity: (input) => windows.get(queryHash(name, input))?.rowEntity ?? null,
|
|
151
|
+
rowKey: (input) => windows.get(queryHash(name, input))?.rowKey ?? null,
|
|
145
152
|
// The two per-subscriber gates, both through the package's one authz seam. Neither result is
|
|
146
153
|
// memoised anywhere: `authorize` runs on every subscribe, `visible` on every row of every
|
|
147
154
|
// delivery, and there is no key here an actor could share with another actor.
|
package/src/live-fanout.ts
CHANGED
|
@@ -44,8 +44,10 @@ export async function fanoutChange(
|
|
|
44
44
|
// that arrived behind the snapshot that already included it — rewound every subscriber's cursor
|
|
45
45
|
// to it and asked them to fold state they had already folded over newer rows.
|
|
46
46
|
if (entry.lsn !== '' && change.lsn <= entry.lsn) return { sent: 0, stale: 1 };
|
|
47
|
-
const
|
|
48
|
-
if (!
|
|
47
|
+
const bridged = bridgeChange(entry.shape, entry.matcher, change, entry.rows);
|
|
48
|
+
if (!bridged) return { sent: 0, stale: 0 };
|
|
49
|
+
// Keyed ONCE, here, before the retained window stores them: a resume replays the same key.
|
|
50
|
+
const result = { ...bridged, patches: keyPatches(entry, change, bridged.patches) };
|
|
49
51
|
entry.lsn = change.lsn;
|
|
50
52
|
entry.rows = applyToWindow(entry.rows, result.patches);
|
|
51
53
|
// The window lost its tail, so what it holds is a guess — the next delivery re-reads it rather
|
|
@@ -138,7 +140,11 @@ async function resnapshot(
|
|
|
138
140
|
return true;
|
|
139
141
|
}
|
|
140
142
|
|
|
141
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* The one place a snapshot frame is built, so the identity scope and the record keys cannot be
|
|
145
|
+
* told to one caller only. `keys` rides only when some key differs from its row's `id` — an entity
|
|
146
|
+
* keyed by `id` sends the frame it always sent.
|
|
147
|
+
*/
|
|
142
148
|
export function snapshotFrame(
|
|
143
149
|
entry: QueryEntry,
|
|
144
150
|
sid: string,
|
|
@@ -146,5 +152,25 @@ export function snapshotFrame(
|
|
|
146
152
|
cursor: LiveCursor,
|
|
147
153
|
): Frame {
|
|
148
154
|
const base = { type: 'snapshot', v: PROTOCOL_VERSION, sid, rows, cursor } as const;
|
|
149
|
-
|
|
155
|
+
const scoped = entry.rowEntity === null ? base : { ...base, entity: entry.rowEntity };
|
|
156
|
+
const keyOf = entry.rowKey;
|
|
157
|
+
if (keyOf === null) return scoped;
|
|
158
|
+
const keys = rows.map((row) => keyOf(row));
|
|
159
|
+
return keys.every((key, index) => key === rows[index]?.id) ? scoped : { ...scoped, keys };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* A patch's record key, from the change's WHOLE row — an update patch carries only the changed
|
|
164
|
+
* columns, and a key needs every primary-key column. Only stamped where it differs from `id`.
|
|
165
|
+
*/
|
|
166
|
+
function keyPatches(
|
|
167
|
+
entry: QueryEntry,
|
|
168
|
+
change: ChangeEvent,
|
|
169
|
+
patches: readonly RowPatch[],
|
|
170
|
+
): readonly RowPatch[] {
|
|
171
|
+
const keyOf = entry.rowKey;
|
|
172
|
+
const whole = change.after ?? change.before;
|
|
173
|
+
if (keyOf === null || whole === null) return patches;
|
|
174
|
+
const key = keyOf(whole);
|
|
175
|
+
return patches.map((patch) => (key === patch.id ? patch : { ...patch, key }));
|
|
150
176
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Which record type a live query's rows are, and the key each row travels under. A changefeed and
|
|
2
|
+
// a snapshot speak TABLES; the page store keys records by the entity's NAME and PRIMARY KEY. Both
|
|
3
|
+
// come from the entity's own projection here, on the server — the browser never derives a key.
|
|
4
|
+
|
|
5
|
+
import type { Row } from '@ultimat3/core/page';
|
|
6
|
+
import { recordProjectionForTable } from '@ultimat3/entity/record';
|
|
7
|
+
|
|
8
|
+
export interface LiveRecords {
|
|
9
|
+
/** The entity name — or the table itself when no registered entity owns it. */
|
|
10
|
+
readonly type: string;
|
|
11
|
+
/** The row's record key, or `null` for a plain table, whose rows are keyed by `id`. */
|
|
12
|
+
readonly key: ((row: Row) => string) | null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function liveRecords(table: string): LiveRecords {
|
|
16
|
+
const projection = recordProjectionForTable(table);
|
|
17
|
+
if (projection === undefined) return { type: table, key: null };
|
|
18
|
+
return { type: projection.type, key: (row) => projection.key(row) };
|
|
19
|
+
}
|
package/src/live-rows.ts
CHANGED
|
@@ -1,90 +1,101 @@
|
|
|
1
|
-
// One live subscription's window
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// One live subscription's window over the page's record store. The registration owns the ORDER
|
|
2
|
+
// (its ids) and the store owns the VALUES — which is what makes post #7 one object however many
|
|
3
|
+
// queries returned it, and what makes a write through any of them reach all of them.
|
|
4
4
|
|
|
5
|
+
import type { Row } from '@ultimat3/core/page';
|
|
5
6
|
import { orderAfterPatches } from './apply-patches';
|
|
6
7
|
import type { LiveCursor } from './cursor';
|
|
7
|
-
import
|
|
8
|
-
import type
|
|
8
|
+
import type { JsonValue, RowPatch } from './json';
|
|
9
|
+
import { type RecordKey, type RecordStore, recordKey } from './record-store';
|
|
9
10
|
|
|
10
|
-
export type LiveState = 'loading' | 'live' | 'stale' | 'offline';
|
|
11
|
+
export type LiveState = 'loading' | 'live' | 'stale' | 'offline' | 'failed';
|
|
11
12
|
|
|
12
13
|
/** One live query this client holds. Mutable: the ids and cursor a frame advances live here. */
|
|
13
14
|
export interface Registration {
|
|
14
15
|
readonly sid: string;
|
|
15
16
|
readonly name: string;
|
|
16
17
|
readonly input: JsonValue;
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
/** Where this window's rows live in the map: the entity the server named, or a private scope. */
|
|
21
|
-
scope: RowScope;
|
|
22
|
-
/** Membership and order. The values are the map's — never a second copy of them. */
|
|
18
|
+
/** The record type the server named for this window; `unnamedType(name)` until it does. */
|
|
19
|
+
type: string;
|
|
20
|
+
/** Membership and order. The values are the store's — never a second copy of them. */
|
|
23
21
|
ids: readonly string[];
|
|
24
22
|
cursor: LiveCursor | null;
|
|
23
|
+
state: LiveState;
|
|
24
|
+
/** What the node answered when it refused this subscription. Set with `state: 'failed'`. */
|
|
25
|
+
error: unknown;
|
|
26
|
+
/** Called after anything a reader of this window renders has moved. */
|
|
27
|
+
readonly notify: () => void;
|
|
25
28
|
}
|
|
26
29
|
|
|
27
30
|
/**
|
|
28
|
-
*
|
|
29
|
-
*
|
|
31
|
+
* Where a window's rows live when the server named no record type: `?` starts no entity name, so
|
|
32
|
+
* two unnamed windows sharing an id never merge two entities' rows. Not a record type — only a
|
|
33
|
+
* snapshot from a node that cannot name the entity lands here.
|
|
34
|
+
*/
|
|
35
|
+
export function unnamedType(queryName: string): string {
|
|
36
|
+
return `?query:${queryName}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Every open window over the one store. It is the only writer of `Registration.ids`, so the
|
|
41
|
+
* retain/release pairs that keep the store from growing without end cannot be forgotten.
|
|
30
42
|
*/
|
|
31
43
|
export class RowWindows {
|
|
32
|
-
readonly #
|
|
33
|
-
/** The window a write is running for, so its own listener does not
|
|
44
|
+
readonly #store: RecordStore;
|
|
45
|
+
/** The window a write is running for, so its own listener does not notify it twice. */
|
|
34
46
|
#writing: Registration | null = null;
|
|
35
47
|
|
|
36
|
-
constructor(
|
|
37
|
-
this.#
|
|
48
|
+
constructor(store: RecordStore) {
|
|
49
|
+
this.#store = store;
|
|
38
50
|
}
|
|
39
51
|
|
|
40
|
-
/**
|
|
41
|
-
* Start rendering this registration out of the map. The returned close releases its rows and
|
|
42
|
-
* drops its listener — an unsubscribed component must stop holding rows and stop hearing about
|
|
43
|
-
* them in the same call, or one of the two outlives the other.
|
|
44
|
-
*/
|
|
52
|
+
/** Render this registration out of the store; the returned close releases every row it held. */
|
|
45
53
|
open(registration: Registration): () => void {
|
|
46
|
-
const unsubscribe = this.#
|
|
54
|
+
const unsubscribe = this.#store.subscribe((changed) => {
|
|
47
55
|
if (this.#writing === registration) return;
|
|
48
|
-
if (
|
|
49
|
-
this.#emit(registration);
|
|
56
|
+
if (holds(registration, changed)) registration.notify();
|
|
50
57
|
});
|
|
51
58
|
return () => {
|
|
52
59
|
unsubscribe();
|
|
53
|
-
this.#
|
|
54
|
-
for (const id of registration.ids) this.#
|
|
60
|
+
this.#store.batch(() => {
|
|
61
|
+
for (const id of registration.ids) this.#store.release(registration.type, id);
|
|
55
62
|
registration.ids = [];
|
|
56
63
|
});
|
|
57
64
|
};
|
|
58
65
|
}
|
|
59
66
|
|
|
60
|
-
/**
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
+
/** A snapshot: server truth for the whole window, under the record type the server named. */
|
|
68
|
+
snapshot(
|
|
69
|
+
registration: Registration,
|
|
70
|
+
type: string | null,
|
|
71
|
+
rows: readonly Row[],
|
|
72
|
+
keys?: readonly string[],
|
|
73
|
+
): void {
|
|
74
|
+
const next = type ?? registration.type;
|
|
75
|
+
// The server's record key where it sent one; a row's `id` IS its key everywhere else.
|
|
76
|
+
const keyed = rows.map((row, index) => [keys?.[index] ?? String(row['id']), row] as const);
|
|
67
77
|
this.#reseat(
|
|
68
78
|
registration,
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
(
|
|
72
|
-
for (const row of
|
|
73
|
-
if (held.has(row.id)) this.#identity.merge(scope, row.id, row);
|
|
74
|
-
}
|
|
79
|
+
next,
|
|
80
|
+
keyed.map(([id]) => id),
|
|
81
|
+
() => {
|
|
82
|
+
for (const [id, row] of keyed) this.#store.merge(next, id, row);
|
|
75
83
|
},
|
|
76
84
|
);
|
|
77
85
|
}
|
|
78
86
|
|
|
79
|
-
/** A patch list: values merged into the
|
|
80
|
-
patch(registration: Registration,
|
|
81
|
-
const
|
|
82
|
-
// A
|
|
83
|
-
|
|
84
|
-
|
|
87
|
+
/** A patch list: values merged into the store, membership and order folded over the ids. */
|
|
88
|
+
patch(registration: Registration, sent: readonly RowPatch[]): void {
|
|
89
|
+
const type = registration.type;
|
|
90
|
+
// A window holds RECORD keys: a patch the server keyed is folded under its key, not its id.
|
|
91
|
+
const patches = sent.map((patch) =>
|
|
92
|
+
patch.key === undefined ? patch : { ...patch, id: patch.key },
|
|
93
|
+
);
|
|
94
|
+
// A `delete` is this window losing the row, never the store losing it: another holder keeps it.
|
|
95
|
+
this.#reseat(registration, type, orderAfterPatches(registration.ids, patches), (held) => {
|
|
85
96
|
for (const patch of patches) {
|
|
86
97
|
if (patch.op === 'delete' || patch.row === null) continue;
|
|
87
|
-
if (held.has(patch.id)) this.#
|
|
98
|
+
if (held.has(patch.id)) this.#store.merge(type, patch.id, patch.row);
|
|
88
99
|
}
|
|
89
100
|
});
|
|
90
101
|
}
|
|
@@ -93,51 +104,43 @@ export class RowWindows {
|
|
|
93
104
|
rows(registration: Registration): readonly Row[] {
|
|
94
105
|
const out: Row[] = [];
|
|
95
106
|
for (const id of registration.ids) {
|
|
96
|
-
const row = this.#
|
|
107
|
+
const row = this.#store.peek(registration.type, id);
|
|
97
108
|
if (row !== undefined) out.push(row);
|
|
98
109
|
}
|
|
99
110
|
return out;
|
|
100
111
|
}
|
|
101
112
|
|
|
102
113
|
/**
|
|
103
|
-
* Move the window to `nextIds` under `
|
|
104
|
-
* and one emit for the window that caused it.
|
|
105
|
-
*
|
|
114
|
+
* Move the window to `nextIds` under `type`, writing values in between — one batch, one notify.
|
|
106
115
|
* The retain comes before the write and the release after it, so a row this window keeps across
|
|
107
|
-
* the move never reaches zero holds and
|
|
108
|
-
* given. `write` only touches ids the window ends up holding — a value nobody holds is a value
|
|
109
|
-
* no release will ever reclaim.
|
|
116
|
+
* the move never reaches zero holds and is evicted out from under the value it is being given.
|
|
110
117
|
*/
|
|
111
118
|
#reseat(
|
|
112
119
|
registration: Registration,
|
|
113
|
-
|
|
120
|
+
type: string,
|
|
114
121
|
nextIds: readonly string[],
|
|
115
122
|
write: (held: ReadonlySet<string>) => void,
|
|
116
123
|
): void {
|
|
117
124
|
const previous = this.#writing;
|
|
118
125
|
this.#writing = registration;
|
|
119
126
|
try {
|
|
120
|
-
this.#
|
|
121
|
-
for (const id of nextIds) this.#
|
|
127
|
+
this.#store.batch(() => {
|
|
128
|
+
for (const id of nextIds) this.#store.retain(type, id);
|
|
122
129
|
write(new Set(nextIds));
|
|
123
|
-
for (const id of registration.ids) this.#
|
|
124
|
-
registration.
|
|
130
|
+
for (const id of registration.ids) this.#store.release(registration.type, id);
|
|
131
|
+
registration.type = type;
|
|
125
132
|
registration.ids = nextIds;
|
|
126
133
|
});
|
|
127
134
|
} finally {
|
|
128
135
|
this.#writing = previous;
|
|
129
136
|
}
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
#emit(registration: Registration): void {
|
|
134
|
-
registration.setRows(this.rows(registration));
|
|
137
|
+
registration.notify();
|
|
135
138
|
}
|
|
136
139
|
}
|
|
137
140
|
|
|
138
|
-
function holds(registration: Registration, changed: ReadonlySet<
|
|
141
|
+
function holds(registration: Registration, changed: ReadonlySet<RecordKey>): boolean {
|
|
139
142
|
for (const id of registration.ids) {
|
|
140
|
-
if (changed.has(
|
|
143
|
+
if (changed.has(recordKey(registration.type, id))) return true;
|
|
141
144
|
}
|
|
142
145
|
return false;
|
|
143
146
|
}
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The page's durable client store: persisted records and the one outbox, in IndexedDB, every entry
|
|
3
|
+
* keyed by the principal it belongs to so one principal's cache can never restore into another's.
|
|
4
|
+
* IndexedDB blocked (a private window, storage disabled) falls back to memory with ONE warning,
|
|
5
|
+
* `X_LOCAL_STORE_UNAVAILABLE` — a page never breaks because it cannot remember.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ClientScope, RecordRows, Row } from '@ultimat3/core/page';
|
|
9
|
+
import { isJsonObject, renderThrowable, type UltimateError } from '@ultimat3/core/page';
|
|
10
|
+
import type { IdbDatabaseLike, IdbFactoryLike, IdbRequestLike } from './idb-types';
|
|
11
|
+
import type { QueueState } from './offline-queue';
|
|
12
|
+
import { LocalStoreUnavailableError } from './page-errors';
|
|
13
|
+
|
|
14
|
+
/** One persisted row, by record type and record key. */
|
|
15
|
+
export interface PersistedRow {
|
|
16
|
+
readonly type: string;
|
|
17
|
+
readonly key: string;
|
|
18
|
+
readonly row: Row;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface LocalStore {
|
|
22
|
+
readonly kind: 'indexeddb' | 'memory';
|
|
23
|
+
/** Every persisted row of one scope: record type -> record key -> row. */
|
|
24
|
+
rows(scope: string): Promise<ReadonlyMap<string, RecordRows>>;
|
|
25
|
+
write(
|
|
26
|
+
scope: string,
|
|
27
|
+
puts: readonly PersistedRow[],
|
|
28
|
+
deletes: readonly Omit<PersistedRow, 'row'>[],
|
|
29
|
+
): Promise<void>;
|
|
30
|
+
queue(scope: string): Promise<QueueState | undefined>;
|
|
31
|
+
saveQueue(scope: string, state: QueueState): Promise<void>;
|
|
32
|
+
/** Everything of one scope — its rows AND its outbox. Sign-out, or any principal change. */
|
|
33
|
+
wipe(scope: string): Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Everything of every scope but `keep` — rows and outboxes. The page boot's answer to a sign-out
|
|
36
|
+
* by full navigation, which never calls `rescope()` and so never reaches `wipe`.
|
|
37
|
+
*/
|
|
38
|
+
wipeOthers(keep: string): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The storage key of a principal, or `undefined` for an UNSCOPED page (rendered for nobody):
|
|
43
|
+
* nothing is persisted there, because there is no principal to key it by. Prefixed so a principal
|
|
44
|
+
* spelled `anon` can never share the anonymous visitor's rows.
|
|
45
|
+
*/
|
|
46
|
+
export function scopeKey(principal: ClientScope['principal']): string | undefined {
|
|
47
|
+
if (principal === undefined) return undefined;
|
|
48
|
+
return principal === null ? 'anon' : `p:${principal}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const RECORDS = 'records';
|
|
52
|
+
const OUTBOX = 'outbox';
|
|
53
|
+
/** JSON, never a joined string: a principal is opaque and may hold any separator. */
|
|
54
|
+
const rowKey = (scope: string, type: string, key: string): string =>
|
|
55
|
+
JSON.stringify([scope, type, key]);
|
|
56
|
+
|
|
57
|
+
/** Memory: tests, SSR, and the fallback when IndexedDB is unavailable. */
|
|
58
|
+
export class MemoryLocalStore implements LocalStore {
|
|
59
|
+
readonly kind = 'memory';
|
|
60
|
+
readonly #rows = new Map<string, PersistedRow & { readonly scope: string }>();
|
|
61
|
+
readonly #queues = new Map<string, QueueState>();
|
|
62
|
+
|
|
63
|
+
async rows(scope: string): Promise<ReadonlyMap<string, RecordRows>> {
|
|
64
|
+
return group([...this.#rows.values()].filter((entry) => entry.scope === scope));
|
|
65
|
+
}
|
|
66
|
+
async write(
|
|
67
|
+
scope: string,
|
|
68
|
+
puts: readonly PersistedRow[],
|
|
69
|
+
deletes: readonly Omit<PersistedRow, 'row'>[],
|
|
70
|
+
): Promise<void> {
|
|
71
|
+
for (const { type, key } of deletes) this.#rows.delete(rowKey(scope, type, key));
|
|
72
|
+
for (const put of puts) this.#rows.set(rowKey(scope, put.type, put.key), { ...put, scope });
|
|
73
|
+
}
|
|
74
|
+
async queue(scope: string): Promise<QueueState | undefined> {
|
|
75
|
+
return this.#queues.get(scope);
|
|
76
|
+
}
|
|
77
|
+
async saveQueue(scope: string, state: QueueState): Promise<void> {
|
|
78
|
+
this.#queues.set(scope, structuredClone(state));
|
|
79
|
+
}
|
|
80
|
+
async wipe(scope: string): Promise<void> {
|
|
81
|
+
for (const [key, entry] of this.#rows) if (entry.scope === scope) this.#rows.delete(key);
|
|
82
|
+
this.#queues.delete(scope);
|
|
83
|
+
}
|
|
84
|
+
async wipeOthers(keep: string): Promise<void> {
|
|
85
|
+
for (const [key, entry] of this.#rows) if (entry.scope !== keep) this.#rows.delete(key);
|
|
86
|
+
for (const scope of [...this.#queues.keys()]) if (scope !== keep) this.#queues.delete(scope);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class IdbLocalStore implements LocalStore {
|
|
91
|
+
readonly kind = 'indexeddb';
|
|
92
|
+
constructor(private readonly db: IdbDatabaseLike) {}
|
|
93
|
+
|
|
94
|
+
async rows(scope: string): Promise<ReadonlyMap<string, RecordRows>> {
|
|
95
|
+
const tx = this.db.transaction(RECORDS, 'readonly');
|
|
96
|
+
const store = tx.objectStore(RECORDS);
|
|
97
|
+
const [keys, values] = await Promise.all([answer(store.getAllKeys()), answer(store.getAll())]);
|
|
98
|
+
const found: PersistedRow[] = [];
|
|
99
|
+
keys.forEach((raw, index) => {
|
|
100
|
+
const parts: unknown = typeof raw === 'string' ? JSON.parse(raw) : undefined;
|
|
101
|
+
const row = values[index];
|
|
102
|
+
if (!Array.isArray(parts) || parts[0] !== scope || !isJsonObject(row)) return;
|
|
103
|
+
found.push({ type: String(parts[1]), key: String(parts[2]), row });
|
|
104
|
+
});
|
|
105
|
+
return group(found);
|
|
106
|
+
}
|
|
107
|
+
async write(
|
|
108
|
+
scope: string,
|
|
109
|
+
puts: readonly PersistedRow[],
|
|
110
|
+
deletes: readonly Omit<PersistedRow, 'row'>[],
|
|
111
|
+
): Promise<void> {
|
|
112
|
+
const tx = this.db.transaction(RECORDS, 'readwrite');
|
|
113
|
+
const store = tx.objectStore(RECORDS);
|
|
114
|
+
for (const { type, key } of deletes) store.delete(rowKey(scope, type, key));
|
|
115
|
+
for (const put of puts) store.put(put.row, rowKey(scope, put.type, put.key));
|
|
116
|
+
await done(tx);
|
|
117
|
+
}
|
|
118
|
+
async queue(scope: string): Promise<QueueState | undefined> {
|
|
119
|
+
const tx = this.db.transaction(OUTBOX, 'readonly');
|
|
120
|
+
const store = tx.objectStore(OUTBOX);
|
|
121
|
+
const [keys, values] = await Promise.all([answer(store.getAllKeys()), answer(store.getAll())]);
|
|
122
|
+
const at = keys.indexOf(scope);
|
|
123
|
+
return at === -1 ? undefined : (values[at] as QueueState);
|
|
124
|
+
}
|
|
125
|
+
async saveQueue(scope: string, state: QueueState): Promise<void> {
|
|
126
|
+
const tx = this.db.transaction(OUTBOX, 'readwrite');
|
|
127
|
+
tx.objectStore(OUTBOX).put(state, scope);
|
|
128
|
+
await done(tx);
|
|
129
|
+
}
|
|
130
|
+
async wipe(scope: string): Promise<void> {
|
|
131
|
+
const tx = this.db.transaction([RECORDS, OUTBOX], 'readwrite');
|
|
132
|
+
const records = tx.objectStore(RECORDS);
|
|
133
|
+
const keys = await answer(records.getAllKeys());
|
|
134
|
+
for (const raw of keys) {
|
|
135
|
+
if (typeof raw !== 'string') continue;
|
|
136
|
+
const parts: unknown = JSON.parse(raw);
|
|
137
|
+
if (Array.isArray(parts) && parts[0] === scope) records.delete(raw);
|
|
138
|
+
}
|
|
139
|
+
tx.objectStore(OUTBOX).delete(scope);
|
|
140
|
+
await done(tx);
|
|
141
|
+
}
|
|
142
|
+
async wipeOthers(keep: string): Promise<void> {
|
|
143
|
+
const tx = this.db.transaction([RECORDS, OUTBOX], 'readwrite');
|
|
144
|
+
const records = tx.objectStore(RECORDS);
|
|
145
|
+
const outbox = tx.objectStore(OUTBOX);
|
|
146
|
+
const [rowKeys, queueKeys] = await Promise.all([
|
|
147
|
+
answer(records.getAllKeys()),
|
|
148
|
+
answer(outbox.getAllKeys()),
|
|
149
|
+
]);
|
|
150
|
+
for (const raw of rowKeys) {
|
|
151
|
+
if (typeof raw !== 'string') continue;
|
|
152
|
+
const parts: unknown = JSON.parse(raw);
|
|
153
|
+
if (Array.isArray(parts) && parts[0] !== keep) records.delete(raw);
|
|
154
|
+
}
|
|
155
|
+
for (const scope of queueKeys) {
|
|
156
|
+
if (typeof scope === 'string' && scope !== keep) outbox.delete(scope);
|
|
157
|
+
}
|
|
158
|
+
await done(tx);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface OpenLocalStoreOptions {
|
|
163
|
+
/** Default `globalThis.indexedDB`, read at call time. */
|
|
164
|
+
readonly indexedDB?: IdbFactoryLike | undefined;
|
|
165
|
+
/** Where the one fallback warning goes. Default `console.warn`. */
|
|
166
|
+
readonly warn?: ((error: UltimateError) => void) | undefined;
|
|
167
|
+
readonly name?: string | undefined;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** IndexedDB when the page has it and it opens; memory, warned once, when it does not. */
|
|
171
|
+
export async function openLocalStore(options: OpenLocalStoreOptions = {}): Promise<LocalStore> {
|
|
172
|
+
// The browser's `IDBFactory` IS this shape at runtime; its DOM typings (event-typed handlers)
|
|
173
|
+
// are wider than the slice `idb-types.ts` declares, so the ambient value is narrowed once here.
|
|
174
|
+
const ambient: unknown = Reflect.get(globalThis, 'indexedDB');
|
|
175
|
+
const factory = options.indexedDB ?? (ambient as IdbFactoryLike | undefined);
|
|
176
|
+
const warn = options.warn ?? ((error: UltimateError) => console.warn(error));
|
|
177
|
+
if (factory === undefined) {
|
|
178
|
+
warn(new LocalStoreUnavailableError({ reason: 'this runtime has no indexedDB' }));
|
|
179
|
+
return new MemoryLocalStore();
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
return new IdbLocalStore(await openDatabase(factory, options.name ?? 'ultimate-client'));
|
|
183
|
+
} catch (error) {
|
|
184
|
+
warn(
|
|
185
|
+
new LocalStoreUnavailableError({
|
|
186
|
+
reason: `indexedDB.open failed: ${renderThrowable(error)}`,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
return new MemoryLocalStore();
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function openDatabase(factory: IdbFactoryLike, name: string): Promise<IdbDatabaseLike> {
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
const request = factory.open(name, 1);
|
|
196
|
+
request.onupgradeneeded = (): void => {
|
|
197
|
+
const db = request.result;
|
|
198
|
+
if (!db.objectStoreNames.contains(RECORDS)) db.createObjectStore(RECORDS);
|
|
199
|
+
if (!db.objectStoreNames.contains(OUTBOX)) db.createObjectStore(OUTBOX);
|
|
200
|
+
};
|
|
201
|
+
request.onsuccess = (): void => resolve(request.result);
|
|
202
|
+
request.onerror = (): void => reject(request.error);
|
|
203
|
+
request.onblocked = (): void => reject(request.error ?? 'blocked by another open tab');
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function answer<T>(request: IdbRequestLike<T>): Promise<T> {
|
|
208
|
+
return new Promise((resolve, reject) => {
|
|
209
|
+
request.onsuccess = (): void => resolve(request.result);
|
|
210
|
+
request.onerror = (): void => reject(request.error);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function done(tx: {
|
|
215
|
+
oncomplete: (() => void) | null;
|
|
216
|
+
onerror: (() => void) | null;
|
|
217
|
+
error: unknown;
|
|
218
|
+
}): Promise<void> {
|
|
219
|
+
return new Promise((resolve, reject) => {
|
|
220
|
+
tx.oncomplete = (): void => resolve();
|
|
221
|
+
tx.onerror = (): void => reject(tx.error);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function group(entries: readonly PersistedRow[]): ReadonlyMap<string, RecordRows> {
|
|
226
|
+
const out = new Map<string, Record<string, Row>>();
|
|
227
|
+
for (const { type, key, row } of entries) {
|
|
228
|
+
const rows = out.get(type) ?? (Object.create(null) as Record<string, Row>);
|
|
229
|
+
Object.defineProperty(rows, key, { value: row, enumerable: true });
|
|
230
|
+
out.set(type, rows);
|
|
231
|
+
}
|
|
232
|
+
return out;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const PAGE_KEY: unique symbol = Symbol.for('ultimate.local-store');
|
|
236
|
+
type PageHost = { [PAGE_KEY]?: Promise<LocalStore> };
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The page's ONE durable store, opened once per tab whichever island asks first — the persister and
|
|
240
|
+
* the outbox share it, so a wipe on a principal change reaches both. On `globalThis`, like the
|
|
241
|
+
* record store, because every island bundle carries its own copy of this module.
|
|
242
|
+
*/
|
|
243
|
+
export function pageLocalStore(): Promise<LocalStore> {
|
|
244
|
+
const host = globalThis as PageHost;
|
|
245
|
+
const existing = host[PAGE_KEY];
|
|
246
|
+
if (existing !== undefined) return existing;
|
|
247
|
+
const opened = openLocalStore();
|
|
248
|
+
Object.defineProperty(host, PAGE_KEY, { value: opened, configurable: true });
|
|
249
|
+
return opened;
|
|
250
|
+
}
|
package/src/offline-queue.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
// to a socket and nothing more, so a drained mutation is `inflight` — not `acked` — until an
|
|
10
10
|
// `ack`/`fail` frame settles it, or a lost connection returns it to the queue.
|
|
11
11
|
|
|
12
|
-
import { renderThrowable, stringField } from '@ultimat3/core';
|
|
12
|
+
import { renderThrowable, stringField } from '@ultimat3/core/page';
|
|
13
13
|
import type { JsonValue } from './json';
|
|
14
|
-
import
|
|
14
|
+
import type { WireError } from './sync-protocol';
|
|
15
15
|
|
|
16
16
|
export type MutationStatus = 'pending' | 'inflight' | 'acked' | 'failed';
|
|
17
17
|
|
|
@@ -33,7 +33,7 @@ export interface QueueState {
|
|
|
33
33
|
readonly nextSeq: number;
|
|
34
34
|
}
|
|
35
35
|
|
|
36
|
-
/** Durability seam:
|
|
36
|
+
/** Durability seam: IndexedDB in the browser (`page-outbox.ts`), memory in tests. */
|
|
37
37
|
export interface QueueStore {
|
|
38
38
|
load(): Promise<QueueState>;
|
|
39
39
|
save(state: QueueState): Promise<void>;
|
|
@@ -131,8 +131,8 @@ export class OfflineQueue {
|
|
|
131
131
|
// UI — collapsing onto it makes an explicit idempotency key unusable for the rest of the
|
|
132
132
|
// session, because nothing ever retries a denial. Re-issuing one is a NEW intent, so the old
|
|
133
133
|
// entry is dropped and this one takes a new sequence at the back of the queue.
|
|
134
|
-
|
|
135
|
-
|
|
134
|
+
// By identity: `existing` IS the entry for this key, found above — no second key comparison.
|
|
135
|
+
if (existing) this.#mutations = this.#mutations.filter((entry) => entry !== existing);
|
|
136
136
|
const mutation: QueuedMutation = {
|
|
137
137
|
key: args.key,
|
|
138
138
|
seq: this.#nextSeq,
|
|
@@ -204,7 +204,8 @@ export class OfflineQueue {
|
|
|
204
204
|
const mutation = this.find(key);
|
|
205
205
|
if (!mutation) return;
|
|
206
206
|
mutation.status = 'acked';
|
|
207
|
-
|
|
207
|
+
// By identity: `find` already matched it, and a second comparison of the key is the same question.
|
|
208
|
+
this.#mutations = this.#mutations.filter((entry) => entry !== mutation);
|
|
208
209
|
await this.#persist();
|
|
209
210
|
}
|
|
210
211
|
|
|
@@ -316,18 +317,8 @@ export class OfflineQueue {
|
|
|
316
317
|
}
|
|
317
318
|
}
|
|
318
319
|
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
type: 'mutate',
|
|
322
|
-
v: PROTOCOL_VERSION,
|
|
323
|
-
key: mutation.key,
|
|
324
|
-
seq: mutation.seq,
|
|
325
|
-
name: mutation.name,
|
|
326
|
-
input: mutation.input,
|
|
327
|
-
};
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
function toQueueError(error: unknown): WireError {
|
|
320
|
+
/** A thrown value as the queue records it — `code`, `cause`, `fix`, never a throw of its own. */
|
|
321
|
+
export function toQueueError(error: unknown): WireError {
|
|
331
322
|
return {
|
|
332
323
|
// `stringField`, not `shape?.code`: the sender is a transport the app supplied, so the probe
|
|
333
324
|
// for "did it throw a coded error" is itself a property read on an app value. A getter that
|