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.
Files changed (52) hide show
  1. package/DEPS.md +5 -3
  2. package/README.md +103 -1
  3. package/RULES.md +26 -6
  4. package/dist/api.d.ts +93 -0
  5. package/dist/api.js +111 -0
  6. package/dist/client.d.ts +21 -4
  7. package/dist/client.js +21 -2
  8. package/dist/conformance/agent-access.test.d.ts +1 -0
  9. package/dist/conformance/agent-access.test.js +74 -0
  10. package/dist/conformance/client.test.js +7 -7
  11. package/dist/conformance/fixtures.d.ts +1 -0
  12. package/dist/conformance/fixtures.js +5 -1
  13. package/dist/conformance/outbox-bound.test.d.ts +1 -0
  14. package/dist/conformance/outbox-bound.test.js +268 -0
  15. package/dist/conformance/paging-state.test.js +1 -0
  16. package/dist/conformance/push-bytes.test.d.ts +1 -0
  17. package/dist/conformance/push-bytes.test.js +174 -0
  18. package/dist/conformance/push-refusal.test.d.ts +1 -0
  19. package/dist/conformance/push-refusal.test.js +644 -0
  20. package/dist/conformance/states.test.js +19 -8
  21. package/dist/contract.d.ts +19 -6
  22. package/dist/contract.js +76 -9
  23. package/dist/server/auth.d.ts +4 -3
  24. package/dist/server/auth.js +14 -6
  25. package/dist/server/cli.d.ts +117 -0
  26. package/dist/server/cli.js +1534 -0
  27. package/dist/server/config.d.ts +4 -1
  28. package/dist/server/config.js +2 -2
  29. package/dist/server/keys.d.ts +17 -0
  30. package/dist/server/keys.js +127 -0
  31. package/dist/server/pull.js +158 -67
  32. package/dist/server/store.d.ts +101 -0
  33. package/dist/server/store.js +347 -0
  34. package/dist/server/sync.d.ts +158 -0
  35. package/dist/server/sync.js +293 -0
  36. package/dist/server/web.d.ts +62 -9
  37. package/dist/server/web.js +260 -35
  38. package/dist/states.d.ts +52 -4
  39. package/dist/states.js +20 -0
  40. package/dist/sync.d.ts +86 -2
  41. package/dist/sync.js +277 -59
  42. package/dist/test/agents.d.ts +19 -0
  43. package/dist/test/agents.js +62 -0
  44. package/dist/test/page.d.ts +91 -0
  45. package/dist/test/page.js +763 -0
  46. package/dist/test/states.d.ts +44 -7
  47. package/dist/test/states.js +152 -0
  48. package/dist/web/replica.d.ts +7 -0
  49. package/dist/web/replica.js +260 -47
  50. package/dist/web/runtime.d.ts +102 -0
  51. package/dist/web/runtime.js +335 -0
  52. package/package.json +39 -1
@@ -1,10 +1,7 @@
1
- /**
2
- * Checks an app's oracle runs on its state tables and screens (`slopkit/states`), for what the types can't rule out.
3
- * Each returns the problems found, empty when there are none, so a test asserts `toEqual([])` and reads every problem
4
- * at once. They check rules, never an app's expected texts: those stay the app's hand-written oracle.
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>;
@@ -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
+ }
@@ -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
  };
@@ -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
- * - A version upgrade starts the replica over (it comes back from the server) but keeps the outbox and the generation:
21
- * an intent on disk is never erased by an upgrade. One this version can't read stays on disk and is counted.
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
- const missing = [...names, "meta", "outbox"].some((name) => !probe.objectStoreNames.contains(name));
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 start over (the server has them); every other store an earlier version kept may hold work
83
- // nobody else has, so its records move into the outbox: converted by `legacy`, else kept as they were under
84
- // their own key, counted unreadable. Nothing is dropped unseen.
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
- if (database.objectStoreNames.contains("meta")) {
91
- upgrade.objectStore("meta").delete("checkpoint");
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
- for (const name of existing) {
100
- if (own.has(name)) {
101
- continue;
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
- else {
121
- outbox.put(command, [command.client, command.id]);
125
+ if (replicated.has(name)) {
126
+ database.deleteObjectStore(name);
127
+ continue;
122
128
  }
123
- current.continue();
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.createObjectStore(name);
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
- return {
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 plan = commitPlan(stored, {
287
- ...target,
288
- reset: commit.reset,
289
- base: commit.base,
290
- replaces: commit.replaces,
291
- ...(commit.from === undefined ? {} : { from: commit.from }),
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
- transaction.objectStore("outbox").put(pending, [pending.client, pending.id]);
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
  }