slopkit 0.2.0 → 0.4.0-rc.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/DEPS.md +5 -3
- package/README.md +103 -1
- package/RULES.md +26 -6
- package/dist/api.d.ts +93 -0
- package/dist/api.js +111 -0
- package/dist/client.d.ts +21 -4
- package/dist/client.js +21 -2
- package/dist/conformance/agent-access.test.d.ts +1 -0
- package/dist/conformance/agent-access.test.js +74 -0
- package/dist/conformance/client.test.js +7 -7
- package/dist/conformance/fixtures.d.ts +1 -0
- package/dist/conformance/fixtures.js +5 -1
- package/dist/conformance/outbox-bound.test.d.ts +1 -0
- package/dist/conformance/outbox-bound.test.js +268 -0
- package/dist/conformance/paging-state.test.js +1 -0
- package/dist/conformance/push-bytes.test.d.ts +1 -0
- package/dist/conformance/push-bytes.test.js +174 -0
- package/dist/conformance/push-refusal.test.d.ts +1 -0
- package/dist/conformance/push-refusal.test.js +644 -0
- package/dist/conformance/states.test.js +19 -8
- package/dist/contract.d.ts +19 -6
- package/dist/contract.js +76 -9
- package/dist/server/auth.d.ts +4 -3
- package/dist/server/auth.js +14 -6
- package/dist/server/cli.d.ts +117 -0
- package/dist/server/cli.js +1534 -0
- package/dist/server/config.d.ts +4 -1
- package/dist/server/config.js +2 -2
- package/dist/server/keys.d.ts +17 -0
- package/dist/server/keys.js +127 -0
- package/dist/server/pull.js +158 -67
- package/dist/server/store.d.ts +101 -0
- package/dist/server/store.js +347 -0
- package/dist/server/sync.d.ts +158 -0
- package/dist/server/sync.js +293 -0
- package/dist/server/web.d.ts +62 -9
- package/dist/server/web.js +260 -35
- package/dist/states.d.ts +52 -4
- package/dist/states.js +20 -0
- package/dist/sync.d.ts +86 -2
- package/dist/sync.js +277 -59
- package/dist/test/agents.d.ts +19 -0
- package/dist/test/agents.js +62 -0
- package/dist/test/page.d.ts +91 -0
- package/dist/test/page.js +763 -0
- package/dist/test/states.d.ts +44 -7
- package/dist/test/states.js +152 -0
- package/dist/web/replica.d.ts +7 -0
- package/dist/web/replica.js +260 -47
- package/dist/web/runtime.d.ts +102 -0
- package/dist/web/runtime.js +335 -0
- package/package.json +39 -1
package/dist/test/states.d.ts
CHANGED
|
@@ -1,10 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
*/
|
|
6
|
-
import type { Line, Screen, Subject } from "../states.ts";
|
|
7
|
-
/** At least one sample state of every kind, for the oracle and the gallery. */
|
|
1
|
+
import type { Update } from "../machine.ts";
|
|
2
|
+
import type { Line, Meaning, Screen, Subject } from "../states.ts";
|
|
3
|
+
import type { Model } from "../sync.ts";
|
|
4
|
+
/** At least one sample state of every kind, for the tests and the gallery. */
|
|
8
5
|
export type Samples<S extends {
|
|
9
6
|
kind: string;
|
|
10
7
|
}> = {
|
|
@@ -36,3 +33,43 @@ export declare function screenProblems<A>(screen: Screen<A>, parts: readonly Lin
|
|
|
36
33
|
* that must vary most last, or pass a limit that covers it.
|
|
37
34
|
*/
|
|
38
35
|
export declare function bounded<T>(choices: readonly (readonly T[])[], limit: number): T[][];
|
|
36
|
+
/** What is under way when a view is drawn: the server's jobs, the page's sync model, and the clock. */
|
|
37
|
+
export type World = {
|
|
38
|
+
now: number;
|
|
39
|
+
/** The server's jobs (`slopkit/server/jobs`); finished ones may be listed too. */
|
|
40
|
+
jobs?: readonly {
|
|
41
|
+
kind: string;
|
|
42
|
+
key: string;
|
|
43
|
+
state: "due" | "running" | "done" | "failed";
|
|
44
|
+
dueAt: string;
|
|
45
|
+
}[];
|
|
46
|
+
/** The page's sync model: its commands and its copy. */
|
|
47
|
+
model?: Model<any, any, any, any>;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* A reached view's problems by the kit's rules: every line has text with no broken value; every screen keeps its parts
|
|
51
|
+
* (`screenProblems`); and, given what is under way (`world`), every working line's owed work is: a job running or due by
|
|
52
|
+
* now, a command not yet settled, or the page's copy loading, pulling, pushing or writing. Takes any value an app's view
|
|
53
|
+
* returns and finds the kit's lines and screens in it. Run it on every view a test reaches, not only on samples.
|
|
54
|
+
*/
|
|
55
|
+
export declare function viewProblems(shown: unknown, options: {
|
|
56
|
+
order: readonly Meaning[];
|
|
57
|
+
world?: World;
|
|
58
|
+
}): string[];
|
|
59
|
+
/** Where a replay ends (`replayProblems`): the last model, every effect, and each reached view's problems. */
|
|
60
|
+
export type ReplayChecked<M, E> = {
|
|
61
|
+
model: M;
|
|
62
|
+
effects: E[];
|
|
63
|
+
problems: string[];
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Replay `messages` through `update` from `initial` and run `viewProblems` on the view of every model reached: the
|
|
67
|
+
* initial one (step 0) and the one after each message (step n). `world` is drawn from each reached model (its sync
|
|
68
|
+
* model, its clock, the jobs the test supplies), never a snapshot. Returns the last model, every effect, and the
|
|
69
|
+
* problems, each naming its step.
|
|
70
|
+
*/
|
|
71
|
+
export declare function replayProblems<M, Msg, E>(update: Update<M, Msg, E>, initial: M, messages: readonly Msg[], options: {
|
|
72
|
+
view: (model: M) => unknown;
|
|
73
|
+
order: readonly Meaning[];
|
|
74
|
+
world?: (model: M, step: number) => World;
|
|
75
|
+
}): ReplayChecked<M, E>;
|
package/dist/test/states.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks an app's tests run on its state tables, screens and every view they reach (`slopkit/states`), for what the
|
|
3
|
+
* types can't rule out. Each returns the problems found, empty when there are none, so a test asserts `toEqual([])`
|
|
4
|
+
* and reads every problem at once. They check rules, never an app's expected texts: those stay the app's own tests.
|
|
5
|
+
*/
|
|
6
|
+
import { isLine, isScreen } from "../states.js";
|
|
7
|
+
import { deriveHistoryState, reachable, settled } from "../sync.js";
|
|
1
8
|
const BROKEN = /undefined|null|NaN|\[object |Invalid Date/;
|
|
2
9
|
/** Every sample drawn through its table, in table order. */
|
|
3
10
|
export function lines(subject, samples) {
|
|
@@ -103,3 +110,148 @@ export function bounded(choices, limit) {
|
|
|
103
110
|
}
|
|
104
111
|
return out;
|
|
105
112
|
}
|
|
113
|
+
/** Every Line and Screen inside a value: objects, arrays, Maps and Sets, each visited once. */
|
|
114
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- an app's view is any shape; only the kit's classes are read
|
|
115
|
+
function found(value) {
|
|
116
|
+
const drawn = [];
|
|
117
|
+
const screens = [];
|
|
118
|
+
const seen = new Set();
|
|
119
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- walks any shape, keeping only the kit's classes
|
|
120
|
+
const visit = (each) => {
|
|
121
|
+
// oxlint-disable-next-line anti-slop/no-runtime-typeof -- only objects can hold a Line or a Screen
|
|
122
|
+
if (typeof each !== "object" || each === null || seen.has(each)) {
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
seen.add(each);
|
|
126
|
+
if (isLine(each)) {
|
|
127
|
+
drawn.push(each);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (isScreen(each)) {
|
|
131
|
+
screens.push(each);
|
|
132
|
+
}
|
|
133
|
+
const inner = each instanceof Map
|
|
134
|
+
? [...each.keys(), ...each.values()]
|
|
135
|
+
: each instanceof Set || Array.isArray(each)
|
|
136
|
+
? each
|
|
137
|
+
: Object.values(each);
|
|
138
|
+
for (const item of inner) {
|
|
139
|
+
visit(item);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
visit(value);
|
|
143
|
+
return { lines: drawn, screens };
|
|
144
|
+
}
|
|
145
|
+
/** A pull in flight, or one the engine would start now: the page visible, the connection letting it, a pull owed. */
|
|
146
|
+
function pullUnderWay(model, now) {
|
|
147
|
+
if (model.pull.kind === "pulling") {
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
const connection = model.connection.kind;
|
|
151
|
+
const canPull = model.visible && connection !== "signed-out" && connection !== "incompatible";
|
|
152
|
+
const owed = model.restart !== null ||
|
|
153
|
+
model.target > model.replica.revision ||
|
|
154
|
+
deriveHistoryState(model).kind === "partial";
|
|
155
|
+
// As the engine starts one: an owed pull right away unless it is in backoff, any pull once its retry is due.
|
|
156
|
+
const due = model.pull.nextAt <= now;
|
|
157
|
+
return canPull && (due || (owed && model.pull.failures === 0));
|
|
158
|
+
}
|
|
159
|
+
/** A push in flight, or one the engine would start now: the connection live or stale and no backoff pending. */
|
|
160
|
+
function pushUnderWay(model, now) {
|
|
161
|
+
return (model.push.kind === "pushing" ||
|
|
162
|
+
(reachable(model.connection) && model.push.kind === "idle" && model.push.nextAt <= now));
|
|
163
|
+
}
|
|
164
|
+
function syncUnderWay(model, now) {
|
|
165
|
+
const unsent = model.commands.some((each) => each.state.kind !== "done");
|
|
166
|
+
return (model.boot.kind === "loading" ||
|
|
167
|
+
model.saved.writing > 0 ||
|
|
168
|
+
model.push.kind === "pushing" ||
|
|
169
|
+
pullUnderWay(model, now) ||
|
|
170
|
+
(unsent && pushUnderWay(model, now)));
|
|
171
|
+
}
|
|
172
|
+
function owedName(owed) {
|
|
173
|
+
if ("job" in owed) {
|
|
174
|
+
return `job ${owed.job.kind}:${owed.job.key}`;
|
|
175
|
+
}
|
|
176
|
+
return "command" in owed ? `command ${owed.command.client}:${owed.command.id}` : "sync";
|
|
177
|
+
}
|
|
178
|
+
function underWay(owed, world) {
|
|
179
|
+
if ("job" in owed) {
|
|
180
|
+
return (world.jobs ?? []).some((job) => job.kind === owed.job.kind &&
|
|
181
|
+
job.key === owed.job.key &&
|
|
182
|
+
(job.state === "running" || (job.state === "due" && Date.parse(job.dueAt) <= world.now)));
|
|
183
|
+
}
|
|
184
|
+
const model = world.model;
|
|
185
|
+
if (model === undefined) {
|
|
186
|
+
return false;
|
|
187
|
+
}
|
|
188
|
+
if ("command" in owed) {
|
|
189
|
+
const command = model.commands.find((each) => each.client === owed.command.client && each.id === owed.command.id);
|
|
190
|
+
if (command === undefined || settled(model, command)) {
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
const state = command.state;
|
|
194
|
+
// Sent or about to be: a push in flight or one the engine would start now. Answered or parked: a pull that brings it.
|
|
195
|
+
if (state.kind === "saving" || state.kind === "sending") {
|
|
196
|
+
return true;
|
|
197
|
+
}
|
|
198
|
+
return state.kind === "queued" || (state.kind === "unknown" && state.parked !== true)
|
|
199
|
+
? pushUnderWay(model, world.now)
|
|
200
|
+
: pullUnderWay(model, world.now);
|
|
201
|
+
}
|
|
202
|
+
return syncUnderWay(model, world.now);
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* A reached view's problems by the kit's rules: every line has text with no broken value; every screen keeps its parts
|
|
206
|
+
* (`screenProblems`); and, given what is under way (`world`), every working line's owed work is: a job running or due by
|
|
207
|
+
* now, a command not yet settled, or the page's copy loading, pulling, pushing or writing. Takes any value an app's view
|
|
208
|
+
* returns and finds the kit's lines and screens in it. Run it on every view a test reaches, not only on samples.
|
|
209
|
+
*/
|
|
210
|
+
export function viewProblems(
|
|
211
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- an app's view is any shape; only the kit's classes are read
|
|
212
|
+
shown, options) {
|
|
213
|
+
const problems = [];
|
|
214
|
+
const { lines: drawn, screens } = found(shown);
|
|
215
|
+
for (const line of drawn) {
|
|
216
|
+
if (line.text.trim() === "") {
|
|
217
|
+
problems.push(`${line.subject}.${line.kind}: no text`);
|
|
218
|
+
}
|
|
219
|
+
else if (BROKEN.test(line.text)) {
|
|
220
|
+
problems.push(`${line.subject}.${line.kind}: "${line.text}" shows a broken value`);
|
|
221
|
+
}
|
|
222
|
+
const world = options.world;
|
|
223
|
+
if (world !== undefined && line.owed !== null && !underWay(line.owed, world)) {
|
|
224
|
+
problems.push(`${line.subject}.${line.kind} says "${line.text}" (working) but owes ${owedName(line.owed)}, which isn't under way`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
for (const screen of screens) {
|
|
228
|
+
problems.push(...screenProblems(screen, screen.parts, options.order));
|
|
229
|
+
}
|
|
230
|
+
return problems;
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Replay `messages` through `update` from `initial` and run `viewProblems` on the view of every model reached: the
|
|
234
|
+
* initial one (step 0) and the one after each message (step n). `world` is drawn from each reached model (its sync
|
|
235
|
+
* model, its clock, the jobs the test supplies), never a snapshot. Returns the last model, every effect, and the
|
|
236
|
+
* problems, each naming its step.
|
|
237
|
+
*/
|
|
238
|
+
export function replayProblems(update, initial, messages, options) {
|
|
239
|
+
const effects = [];
|
|
240
|
+
const problems = [];
|
|
241
|
+
let model = initial;
|
|
242
|
+
const check = (step) => {
|
|
243
|
+
const world = options.world?.(model, step);
|
|
244
|
+
problems.push(...viewProblems(options.view(model), {
|
|
245
|
+
order: options.order,
|
|
246
|
+
...(world === undefined ? {} : { world }),
|
|
247
|
+
}).map((problem) => `step ${step}: ${problem}`));
|
|
248
|
+
};
|
|
249
|
+
check(0);
|
|
250
|
+
for (const [index, msg] of messages.entries()) {
|
|
251
|
+
const [next, produced] = update(model, msg);
|
|
252
|
+
model = next;
|
|
253
|
+
effects.push(...produced);
|
|
254
|
+
check(index + 1);
|
|
255
|
+
}
|
|
256
|
+
return { model, effects, problems };
|
|
257
|
+
}
|
package/dist/web/replica.d.ts
CHANGED
|
@@ -19,6 +19,13 @@ export declare function indexedReplica<T, P, S, C, O>(options: {
|
|
|
19
19
|
store: string;
|
|
20
20
|
convert: (value: unknown) => Pending<C, O> | undefined;
|
|
21
21
|
};
|
|
22
|
+
/**
|
|
23
|
+
* By table: an earlier release's row, as this one reads it (a field it added, given). A table whose row shape
|
|
24
|
+
* changed needs one, or the copy starts over; a table whose shape didn't is kept as it is. A lift may be handed any
|
|
25
|
+
* other release's row, a newer one's too (a rollback): it parses the shapes it knows strictly and throws on others,
|
|
26
|
+
* which starts the copy over.
|
|
27
|
+
*/
|
|
28
|
+
lifts?: Readonly<Record<string, (row: unknown) => unknown>>;
|
|
22
29
|
}): StoragePort<T, S, C, O> & {
|
|
23
30
|
forget: () => Promise<void>;
|
|
24
31
|
};
|
package/dist/web/replica.js
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* replica instead, so the checkpoint never covers rows the disk doesn't have.
|
|
8
8
|
* - Every write checks the database's generation, read at load: after a forget in another tab this page's writes
|
|
9
9
|
* fail as `Replaced` instead of bringing the forgotten copy back.
|
|
10
|
-
* - The outbox is written per command, never replaced whole, so tabs can't overwrite each other's intents.
|
|
10
|
+
* - The outbox is written per command, never replaced whole, so tabs can't overwrite each other's intents. A new
|
|
11
|
+
* command is admitted inside its own write's transaction, from the store's count, so every tab shares one bound;
|
|
12
|
+
* a kept one always updates, and nothing kept is dropped for room.
|
|
11
13
|
* - Rows are read back only under the row shapes that wrote them (each table's name and schema `shape`, kept with the
|
|
12
14
|
* checkpoint): any change to a table or its schema starts the rows over, with no number to bump by hand. The next
|
|
13
15
|
* commit then writes the whole replica, so the disk is repaired, not just the memory. Rows that don't parse are
|
|
@@ -17,8 +19,13 @@
|
|
|
17
19
|
* so it never takes part of a history for all of it. A delta takes the patch's history only when the disk is at the
|
|
18
20
|
* cursor the patch moved from, and a complete copy at a revision is never narrowed to a partial one. The server's facts are not rows: facts an older page kept that no longer fit load as null and
|
|
19
21
|
* never cost the copy; facts that change at the same revision are written too.
|
|
20
|
-
* -
|
|
21
|
-
*
|
|
22
|
+
* - An upgrade keeps the outbox and the generation (an intent on disk is never erased; one this version can't read
|
|
23
|
+
* stays on disk and is counted) and the copy where it can: a table whose row shape is unchanged is kept as it is, one
|
|
24
|
+
* whose shape changed goes through the app's lift (`lifts`), a table that is new starts empty. The copy is rewritten
|
|
25
|
+
* under this release's shapes in one transaction, kept only if every table then reads, keeps its dataset and
|
|
26
|
+
* revision (taps made on it carry it) but no server facts, and is marked lifted: it loads owing a real reset, so the
|
|
27
|
+
* first contact replaces it whole; offline it shows what it had. A changed table with no lift, a row that still
|
|
28
|
+
* doesn't read, or a table this release doesn't have starts the copy over.
|
|
22
29
|
* - A newer version opening in another tab closes this page's connection; its writes then fail as `Replaced`.
|
|
23
30
|
*
|
|
24
31
|
* `factory` is the browser's `indexedDB`, or any implementation of the same API (the headless simulation passes an
|
|
@@ -26,7 +33,7 @@
|
|
|
26
33
|
*/
|
|
27
34
|
import { Replaced } from "../client.js";
|
|
28
35
|
import { check, literal, nullable, number, object, string } from "../schema.js";
|
|
29
|
-
import { commitPlan } from "../sync.js";
|
|
36
|
+
import { commitPlan, OUTBOX_MAX } from "../sync.js";
|
|
30
37
|
import { committed, openDatabase, request } from "./idb.js";
|
|
31
38
|
const COMPLETE = { kind: "complete" };
|
|
32
39
|
const count = () => number({ integer: true, min: 0 });
|
|
@@ -37,6 +44,18 @@ const partialSchema = object({
|
|
|
37
44
|
held: count(),
|
|
38
45
|
total: count(),
|
|
39
46
|
});
|
|
47
|
+
/** The replica tables a checkpoint's row shapes name (each line `"<name>":<shape>`). */
|
|
48
|
+
function tablesOf(point) {
|
|
49
|
+
const written = typeof point === "object" && point !== null ? Reflect.get(point, "shapes") : undefined;
|
|
50
|
+
const found = new Set();
|
|
51
|
+
for (const line of typeof written === "string" ? written.split("\n") : []) {
|
|
52
|
+
const name = /^("(?:[^"\\]|\\.)*"):/.exec(line)?.[1];
|
|
53
|
+
if (name !== undefined) {
|
|
54
|
+
found.add(String(JSON.parse(name)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return found;
|
|
58
|
+
}
|
|
40
59
|
export function indexedReplica(options) {
|
|
41
60
|
const names = options.tables.map((lens) => lens.name);
|
|
42
61
|
const shapes = options.tables.map((lens) => lens.shape).join("\n");
|
|
@@ -68,7 +87,10 @@ export function indexedReplica(options) {
|
|
|
68
87
|
opening.onsuccess = () => resolve(opening.result);
|
|
69
88
|
opening.onerror = () => reject(opening.error ?? new Error(`can't open ${options.name}`));
|
|
70
89
|
});
|
|
71
|
-
|
|
90
|
+
// A store missing, or one this release doesn't have (a table dropped without a bump): the upgrade runs while the
|
|
91
|
+
// checkpoint still names a dropped table, so its rows are deleted, never moved into the outbox.
|
|
92
|
+
const missing = [...names, "meta", "outbox"].some((name) => !probe.objectStoreNames.contains(name)) ||
|
|
93
|
+
[...probe.objectStoreNames].some((name) => !names.includes(name) && name !== "meta" && name !== "outbox");
|
|
72
94
|
const current = probe.version;
|
|
73
95
|
probe.close();
|
|
74
96
|
if (current >= options.version && !missing) {
|
|
@@ -79,52 +101,62 @@ export function indexedReplica(options) {
|
|
|
79
101
|
const open = () => {
|
|
80
102
|
opened ??= layout()
|
|
81
103
|
.then((version) => openDatabase(options.factory, options.name, version, (database, _from, upgrade) => {
|
|
82
|
-
// Replica tables
|
|
83
|
-
// nobody else has, so its records move into the
|
|
84
|
-
// their own key, counted unreadable. Nothing is
|
|
104
|
+
// Replica tables stay for the load to lift; one the copy held that this release dropped goes (the server has
|
|
105
|
+
// it). Every other store an earlier version kept may hold work nobody else has, so its records move into the
|
|
106
|
+
// outbox: converted by `legacy`, else kept as they were under their own key, counted unreadable. Nothing is
|
|
107
|
+
// dropped unseen.
|
|
85
108
|
const legacy = options.legacy;
|
|
86
109
|
const own = new Set(["meta", "outbox"]);
|
|
87
110
|
if (!database.objectStoreNames.contains("outbox")) {
|
|
88
111
|
database.createObjectStore("outbox");
|
|
89
112
|
}
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
else {
|
|
113
|
+
const kept = database.objectStoreNames.contains("meta");
|
|
114
|
+
if (!kept) {
|
|
94
115
|
database.createObjectStore("meta");
|
|
95
116
|
}
|
|
96
117
|
const outbox = upgrade.objectStore("outbox");
|
|
97
118
|
// A snapshot: deleting a store changes the live list being walked.
|
|
98
119
|
const existing = [...database.objectStoreNames];
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (names.includes(name)) {
|
|
104
|
-
database.deleteObjectStore(name);
|
|
105
|
-
continue;
|
|
106
|
-
}
|
|
107
|
-
const cursor = upgrade.objectStore(name).openCursor();
|
|
108
|
-
let at = 0;
|
|
109
|
-
cursor.onsuccess = () => {
|
|
110
|
-
const current = cursor.result;
|
|
111
|
-
if (current === null) {
|
|
112
|
-
database.deleteObjectStore(name);
|
|
113
|
-
return;
|
|
114
|
-
}
|
|
115
|
-
const value = current.value;
|
|
116
|
-
const command = legacy?.store === name ? legacy.convert(value) : undefined;
|
|
117
|
-
if (command === undefined) {
|
|
118
|
-
outbox.put(value, ["legacy", name, at++]);
|
|
120
|
+
const walk = (replicated) => {
|
|
121
|
+
for (const name of existing) {
|
|
122
|
+
if (own.has(name) || names.includes(name)) {
|
|
123
|
+
continue;
|
|
119
124
|
}
|
|
120
|
-
|
|
121
|
-
|
|
125
|
+
if (replicated.has(name)) {
|
|
126
|
+
database.deleteObjectStore(name);
|
|
127
|
+
continue;
|
|
122
128
|
}
|
|
123
|
-
|
|
124
|
-
|
|
129
|
+
const cursor = upgrade.objectStore(name).openCursor();
|
|
130
|
+
let at = 0;
|
|
131
|
+
cursor.onsuccess = () => {
|
|
132
|
+
const current = cursor.result;
|
|
133
|
+
if (current === null) {
|
|
134
|
+
database.deleteObjectStore(name);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const value = current.value;
|
|
138
|
+
const command = legacy?.store === name ? legacy.convert(value) : undefined;
|
|
139
|
+
if (command === undefined) {
|
|
140
|
+
outbox.put(value, ["legacy", name, at++]);
|
|
141
|
+
}
|
|
142
|
+
else {
|
|
143
|
+
outbox.put(command, [command.client, command.id]);
|
|
144
|
+
}
|
|
145
|
+
current.continue();
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
if (kept) {
|
|
150
|
+
const point = upgrade.objectStore("meta").get("checkpoint");
|
|
151
|
+
point.onsuccess = () => walk(tablesOf(point.result));
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
walk(new Set());
|
|
125
155
|
}
|
|
126
156
|
for (const name of names) {
|
|
127
|
-
database.
|
|
157
|
+
if (!database.objectStoreNames.contains(name)) {
|
|
158
|
+
database.createObjectStore(name);
|
|
159
|
+
}
|
|
128
160
|
}
|
|
129
161
|
}))
|
|
130
162
|
.then((database) => {
|
|
@@ -181,7 +213,97 @@ export function indexedReplica(options) {
|
|
|
181
213
|
}
|
|
182
214
|
return at;
|
|
183
215
|
};
|
|
184
|
-
|
|
216
|
+
/**
|
|
217
|
+
* A copy an earlier release wrote under other row shapes, as this release reads it: each table whose shape changed
|
|
218
|
+
* through its lift, the others as they are, a table that is new empty. Undefined when any table can't be read.
|
|
219
|
+
*/
|
|
220
|
+
const lift = (written, stored) => {
|
|
221
|
+
const partial = written.endsWith("\npartial");
|
|
222
|
+
const before = new Set((partial ? written.slice(0, -"\npartial".length) : written).split("\n"));
|
|
223
|
+
const prefixes = options.tables.map((lens) => `${JSON.stringify(lens.name)}:`);
|
|
224
|
+
// A table this release doesn't have: a newer release's copy, or a table renamed. Neither is this one's to lift.
|
|
225
|
+
if ([...before].some((line) => !prefixes.some((prefix) => line.startsWith(prefix)))) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
let restored = options.empty;
|
|
229
|
+
for (const [index, lens] of options.tables.entries()) {
|
|
230
|
+
const rows = stored[index] ?? [];
|
|
231
|
+
const prefix = prefixes[index] ?? "";
|
|
232
|
+
const each = options.lifts?.[lens.name];
|
|
233
|
+
let read;
|
|
234
|
+
if (before.has(lens.shape)) {
|
|
235
|
+
read = rows;
|
|
236
|
+
}
|
|
237
|
+
else if (![...before].some((line) => line.startsWith(prefix))) {
|
|
238
|
+
read = [];
|
|
239
|
+
}
|
|
240
|
+
else if (each === undefined) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
else {
|
|
244
|
+
try {
|
|
245
|
+
read = rows.map((entry) => ({ key: entry.key, row: each(entry.row) }));
|
|
246
|
+
}
|
|
247
|
+
catch {
|
|
248
|
+
return undefined;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
restored = restored === undefined ? undefined : lens.restore(restored, read);
|
|
252
|
+
}
|
|
253
|
+
return restored === undefined ? undefined : { tables: restored, partial };
|
|
254
|
+
};
|
|
255
|
+
/**
|
|
256
|
+
* Write a lifted copy in place of the one it came from, in one transaction, if the disk still holds that one: its
|
|
257
|
+
* dataset and revision, marked lifted, under this release's shapes. False when the disk moved on or refused.
|
|
258
|
+
*/
|
|
259
|
+
const rewrite = async (from, tables, history) => {
|
|
260
|
+
const database = await db();
|
|
261
|
+
const transaction = database.transaction([...names, "meta"], "readwrite");
|
|
262
|
+
const done = committed(transaction);
|
|
263
|
+
const meta = transaction.objectStore("meta");
|
|
264
|
+
try {
|
|
265
|
+
await guard(transaction);
|
|
266
|
+
const point = await request(meta.get("checkpoint"));
|
|
267
|
+
const still = typeof point === "object" &&
|
|
268
|
+
point !== null &&
|
|
269
|
+
Reflect.get(point, "dataset") === from.dataset &&
|
|
270
|
+
Reflect.get(point, "revision") === from.revision &&
|
|
271
|
+
Reflect.get(point, "shapes") === from.shapes;
|
|
272
|
+
if (!still) {
|
|
273
|
+
transaction.abort();
|
|
274
|
+
await done.catch(() => undefined);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
for (const name of names) {
|
|
278
|
+
transaction.objectStore(name).clear();
|
|
279
|
+
}
|
|
280
|
+
for (const lens of options.tables) {
|
|
281
|
+
for (const op of lens.snapshot(tables)) {
|
|
282
|
+
if (!("drop" in op)) {
|
|
283
|
+
transaction.objectStore(op.table).put(op.row, op.key);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const checkpoint = {
|
|
288
|
+
dataset: from.dataset,
|
|
289
|
+
revision: from.revision,
|
|
290
|
+
server: null,
|
|
291
|
+
lifted: true,
|
|
292
|
+
...(history.kind === "partial" ? { history, shapes: partialShapes } : { shapes }),
|
|
293
|
+
};
|
|
294
|
+
meta.put(checkpoint, "checkpoint");
|
|
295
|
+
options.interfere?.("commit", transaction);
|
|
296
|
+
await done;
|
|
297
|
+
return true;
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
await done.catch(() => undefined);
|
|
301
|
+
return false;
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
/** A load that lost a lift to another tab reads again once: that tab's rewrite is readable now. */
|
|
305
|
+
let again = false;
|
|
306
|
+
const port = {
|
|
185
307
|
async load() {
|
|
186
308
|
const database = await db();
|
|
187
309
|
const transaction = database.transaction([...names, "meta", "outbox"], "readonly");
|
|
@@ -219,6 +341,41 @@ export function indexedReplica(options) {
|
|
|
219
341
|
server: null,
|
|
220
342
|
history: COMPLETE,
|
|
221
343
|
};
|
|
344
|
+
const written = typeof point === "object" && point !== null ? Reflect.get(point, "shapes") : undefined;
|
|
345
|
+
const held = typeof point === "object" && point !== null ? Reflect.get(point, "dataset") : null;
|
|
346
|
+
// Another release's copy, and this one can lift it: rewritten under this release's shapes, claiming no history.
|
|
347
|
+
if (typeof written === "string" && !ours(written) && typeof held === "string") {
|
|
348
|
+
const up = lift(written, tables);
|
|
349
|
+
const partial = check(partialSchema, Reflect.get(point ?? {}, "history"));
|
|
350
|
+
const history = up?.partial === true && partial.ok ? partial.value : COMPLETE;
|
|
351
|
+
const revision = typeof disk === "number" ? disk : 0;
|
|
352
|
+
const wrote = up !== undefined &&
|
|
353
|
+
(!up.partial || partial.ok) &&
|
|
354
|
+
(await rewrite({ dataset: held, revision, shapes: written }, up.tables, history));
|
|
355
|
+
if (up !== undefined && !wrote && !again) {
|
|
356
|
+
again = true;
|
|
357
|
+
try {
|
|
358
|
+
return await port.load();
|
|
359
|
+
}
|
|
360
|
+
finally {
|
|
361
|
+
again = false;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (up !== undefined && wrote) {
|
|
365
|
+
loaded = {
|
|
366
|
+
dataset: held,
|
|
367
|
+
revision,
|
|
368
|
+
shapes: history.kind === "partial" ? partialShapes : shapes,
|
|
369
|
+
};
|
|
370
|
+
discarded = null;
|
|
371
|
+
return {
|
|
372
|
+
replica: { dataset: held, revision, tables: up.tables, server: null, history },
|
|
373
|
+
commands,
|
|
374
|
+
unreadable,
|
|
375
|
+
restart: "paged",
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
}
|
|
222
379
|
const at = checkpointOf(point);
|
|
223
380
|
const raw = typeof point === "object" && point !== null ? Reflect.get(point, "dataset") : null;
|
|
224
381
|
discarded = typeof raw === "string" && raw !== at.dataset ? raw : null;
|
|
@@ -247,6 +404,7 @@ export function indexedReplica(options) {
|
|
|
247
404
|
if (restored === undefined || at.dataset === null) {
|
|
248
405
|
return { replica: empty, commands, unreadable };
|
|
249
406
|
}
|
|
407
|
+
const lifted = typeof point === "object" && point !== null && Reflect.get(point, "lifted") === true;
|
|
250
408
|
return {
|
|
251
409
|
replica: {
|
|
252
410
|
dataset: at.dataset,
|
|
@@ -257,6 +415,7 @@ export function indexedReplica(options) {
|
|
|
257
415
|
},
|
|
258
416
|
commands,
|
|
259
417
|
unreadable,
|
|
418
|
+
...(lifted ? { restart: "paged" } : {}),
|
|
260
419
|
};
|
|
261
420
|
},
|
|
262
421
|
async commit(commit) {
|
|
@@ -283,13 +442,22 @@ export function indexedReplica(options) {
|
|
|
283
442
|
throw new Replaced();
|
|
284
443
|
}
|
|
285
444
|
const stored = checkpointOf(point);
|
|
286
|
-
const
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
445
|
+
const liftedOnDisk = typeof point === "object" && point !== null && Reflect.get(point, "lifted") === true;
|
|
446
|
+
// The reset a lifted copy owes (a pull since 0, at or past its revision) replaces it whole even when the
|
|
447
|
+
// server hasn't moved: the disk claims the revision, not the rows. A repair write (base at the revision) can't.
|
|
448
|
+
const plan = liftedOnDisk &&
|
|
449
|
+
commit.reset &&
|
|
450
|
+
commit.base === 0 &&
|
|
451
|
+
held === commit.replica.dataset &&
|
|
452
|
+
commit.replica.revision >= stored.revision
|
|
453
|
+
? "whole"
|
|
454
|
+
: commitPlan(stored, {
|
|
455
|
+
...target,
|
|
456
|
+
reset: commit.reset,
|
|
457
|
+
base: commit.base,
|
|
458
|
+
replaces: commit.replaces,
|
|
459
|
+
...(commit.from === undefined ? {} : { from: commit.from }),
|
|
460
|
+
});
|
|
293
461
|
// The disk holds a dataset this page never saw: the server replaced the one this page is on.
|
|
294
462
|
if (plan === "stale") {
|
|
295
463
|
transaction.abort();
|
|
@@ -344,8 +512,12 @@ export function indexedReplica(options) {
|
|
|
344
512
|
const history = plan === "whole" || plan === "adopt"
|
|
345
513
|
? (commit.replica.history ?? COMPLETE)
|
|
346
514
|
: stored.history;
|
|
515
|
+
// A lifted copy stays marked until a whole write replaces it: a delta or new facts don't make it trusted.
|
|
516
|
+
const lifted = plan !== "whole" && liftedOnDisk;
|
|
517
|
+
// A lifted copy keeps no server facts either until the reset: they'd vouch for rows the server never sent.
|
|
347
518
|
const checkpoint = {
|
|
348
519
|
...target,
|
|
520
|
+
...(lifted ? { server: null, lifted: true } : {}),
|
|
349
521
|
...(history.kind === "partial" ? { history, shapes: partialShapes } : { shapes }),
|
|
350
522
|
};
|
|
351
523
|
if (plan !== "skip" || facts) {
|
|
@@ -374,15 +546,31 @@ export function indexedReplica(options) {
|
|
|
374
546
|
throw error;
|
|
375
547
|
}
|
|
376
548
|
},
|
|
377
|
-
async keep(pending) {
|
|
549
|
+
async keep(pending, max = OUTBOX_MAX) {
|
|
378
550
|
const database = await db();
|
|
379
551
|
const transaction = database.transaction(["meta", "outbox"], "readwrite");
|
|
380
552
|
const done = committed(transaction);
|
|
553
|
+
const outbox = transaction.objectStore("outbox");
|
|
554
|
+
const key = [pending.client, pending.id];
|
|
381
555
|
try {
|
|
382
556
|
await guard(transaction);
|
|
383
|
-
|
|
557
|
+
// Admission is decided here, from the store's own count, so every tab shares one bound. A kept key always
|
|
558
|
+
// updates; records already past the bound (an older release, a larger one) stay, and keep new ones out.
|
|
559
|
+
if ((await request(outbox.count(key))) === 0 &&
|
|
560
|
+
(await request(outbox.count())) >= max) {
|
|
561
|
+
const kept = await request(outbox.getAll());
|
|
562
|
+
// Unreadable records take room too: counted as waiting, since what they hold is unknown.
|
|
563
|
+
const finished = kept.filter((each) => {
|
|
564
|
+
const parsed = check(options.pending, each);
|
|
565
|
+
return parsed.ok && parsed.value.state.kind === "done";
|
|
566
|
+
}).length;
|
|
567
|
+
await done;
|
|
568
|
+
return { kind: "full", waiting: kept.length - finished, finished, max };
|
|
569
|
+
}
|
|
570
|
+
outbox.put(pending, key);
|
|
384
571
|
options.interfere?.("keep", transaction);
|
|
385
572
|
await done;
|
|
573
|
+
return undefined;
|
|
386
574
|
}
|
|
387
575
|
catch (error) {
|
|
388
576
|
await done.catch(() => undefined);
|
|
@@ -406,6 +594,30 @@ export function indexedReplica(options) {
|
|
|
406
594
|
throw error;
|
|
407
595
|
}
|
|
408
596
|
},
|
|
597
|
+
async mark(keys) {
|
|
598
|
+
const database = await db();
|
|
599
|
+
const transaction = database.transaction(["meta", "outbox"], "readwrite");
|
|
600
|
+
const done = committed(transaction);
|
|
601
|
+
const outbox = transaction.objectStore("outbox");
|
|
602
|
+
try {
|
|
603
|
+
await guard(transaction);
|
|
604
|
+
const kept = await Promise.all(keys.map((key) => request(outbox.get([key.client, key.id]))));
|
|
605
|
+
// A push's commands, in flight, in one write: only records kept already, so nothing is admitted here.
|
|
606
|
+
for (const [at, record] of kept.entries()) {
|
|
607
|
+
const key = keys[at];
|
|
608
|
+
if (key !== undefined && typeof record === "object" && record !== null) {
|
|
609
|
+
outbox.put({ ...record, state: { kind: "sending" } }, [key.client, key.id]);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// An outbox write, as a keep is.
|
|
613
|
+
options.interfere?.("keep", transaction);
|
|
614
|
+
await done;
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
await done.catch(() => undefined);
|
|
618
|
+
throw error;
|
|
619
|
+
}
|
|
620
|
+
},
|
|
409
621
|
/** Drop this device's copy and its outbox, and move to a new generation so no open tab writes it back. */
|
|
410
622
|
async forget() {
|
|
411
623
|
const database = await db();
|
|
@@ -421,4 +633,5 @@ export function indexedReplica(options) {
|
|
|
421
633
|
await done;
|
|
422
634
|
},
|
|
423
635
|
};
|
|
636
|
+
return port;
|
|
424
637
|
}
|