slopkit 0.3.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.
@@ -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
  };
@@ -19,8 +19,13 @@
19
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
20
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
21
21
  * never cost the copy; facts that change at the same revision are written too.
22
- * - A version upgrade starts the replica over (it comes back from the server) but keeps the outbox and the generation:
23
- * 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.
24
29
  * - A newer version opening in another tab closes this page's connection; its writes then fail as `Replaced`.
25
30
  *
26
31
  * `factory` is the browser's `indexedDB`, or any implementation of the same API (the headless simulation passes an
@@ -39,6 +44,18 @@ const partialSchema = object({
39
44
  held: count(),
40
45
  total: count(),
41
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
+ }
42
59
  export function indexedReplica(options) {
43
60
  const names = options.tables.map((lens) => lens.name);
44
61
  const shapes = options.tables.map((lens) => lens.shape).join("\n");
@@ -70,7 +87,10 @@ export function indexedReplica(options) {
70
87
  opening.onsuccess = () => resolve(opening.result);
71
88
  opening.onerror = () => reject(opening.error ?? new Error(`can't open ${options.name}`));
72
89
  });
73
- 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");
74
94
  const current = probe.version;
75
95
  probe.close();
76
96
  if (current >= options.version && !missing) {
@@ -81,52 +101,62 @@ export function indexedReplica(options) {
81
101
  const open = () => {
82
102
  opened ??= layout()
83
103
  .then((version) => openDatabase(options.factory, options.name, version, (database, _from, upgrade) => {
84
- // Replica tables start over (the server has them); every other store an earlier version kept may hold work
85
- // nobody else has, so its records move into the outbox: converted by `legacy`, else kept as they were under
86
- // 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.
87
108
  const legacy = options.legacy;
88
109
  const own = new Set(["meta", "outbox"]);
89
110
  if (!database.objectStoreNames.contains("outbox")) {
90
111
  database.createObjectStore("outbox");
91
112
  }
92
- if (database.objectStoreNames.contains("meta")) {
93
- upgrade.objectStore("meta").delete("checkpoint");
94
- }
95
- else {
113
+ const kept = database.objectStoreNames.contains("meta");
114
+ if (!kept) {
96
115
  database.createObjectStore("meta");
97
116
  }
98
117
  const outbox = upgrade.objectStore("outbox");
99
118
  // A snapshot: deleting a store changes the live list being walked.
100
119
  const existing = [...database.objectStoreNames];
101
- for (const name of existing) {
102
- if (own.has(name)) {
103
- continue;
104
- }
105
- if (names.includes(name)) {
106
- database.deleteObjectStore(name);
107
- continue;
108
- }
109
- const cursor = upgrade.objectStore(name).openCursor();
110
- let at = 0;
111
- cursor.onsuccess = () => {
112
- const current = cursor.result;
113
- if (current === null) {
114
- database.deleteObjectStore(name);
115
- return;
120
+ const walk = (replicated) => {
121
+ for (const name of existing) {
122
+ if (own.has(name) || names.includes(name)) {
123
+ continue;
116
124
  }
117
- const value = current.value;
118
- const command = legacy?.store === name ? legacy.convert(value) : undefined;
119
- if (command === undefined) {
120
- outbox.put(value, ["legacy", name, at++]);
121
- }
122
- else {
123
- outbox.put(command, [command.client, command.id]);
125
+ if (replicated.has(name)) {
126
+ database.deleteObjectStore(name);
127
+ continue;
124
128
  }
125
- current.continue();
126
- };
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());
127
155
  }
128
156
  for (const name of names) {
129
- database.createObjectStore(name);
157
+ if (!database.objectStoreNames.contains(name)) {
158
+ database.createObjectStore(name);
159
+ }
130
160
  }
131
161
  }))
132
162
  .then((database) => {
@@ -183,7 +213,97 @@ export function indexedReplica(options) {
183
213
  }
184
214
  return at;
185
215
  };
186
- 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 = {
187
307
  async load() {
188
308
  const database = await db();
189
309
  const transaction = database.transaction([...names, "meta", "outbox"], "readonly");
@@ -221,6 +341,41 @@ export function indexedReplica(options) {
221
341
  server: null,
222
342
  history: COMPLETE,
223
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
+ }
224
379
  const at = checkpointOf(point);
225
380
  const raw = typeof point === "object" && point !== null ? Reflect.get(point, "dataset") : null;
226
381
  discarded = typeof raw === "string" && raw !== at.dataset ? raw : null;
@@ -249,6 +404,7 @@ export function indexedReplica(options) {
249
404
  if (restored === undefined || at.dataset === null) {
250
405
  return { replica: empty, commands, unreadable };
251
406
  }
407
+ const lifted = typeof point === "object" && point !== null && Reflect.get(point, "lifted") === true;
252
408
  return {
253
409
  replica: {
254
410
  dataset: at.dataset,
@@ -259,6 +415,7 @@ export function indexedReplica(options) {
259
415
  },
260
416
  commands,
261
417
  unreadable,
418
+ ...(lifted ? { restart: "paged" } : {}),
262
419
  };
263
420
  },
264
421
  async commit(commit) {
@@ -285,13 +442,22 @@ export function indexedReplica(options) {
285
442
  throw new Replaced();
286
443
  }
287
444
  const stored = checkpointOf(point);
288
- const plan = commitPlan(stored, {
289
- ...target,
290
- reset: commit.reset,
291
- base: commit.base,
292
- replaces: commit.replaces,
293
- ...(commit.from === undefined ? {} : { from: commit.from }),
294
- });
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
+ });
295
461
  // The disk holds a dataset this page never saw: the server replaced the one this page is on.
296
462
  if (plan === "stale") {
297
463
  transaction.abort();
@@ -346,8 +512,12 @@ export function indexedReplica(options) {
346
512
  const history = plan === "whole" || plan === "adopt"
347
513
  ? (commit.replica.history ?? COMPLETE)
348
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.
349
518
  const checkpoint = {
350
519
  ...target,
520
+ ...(lifted ? { server: null, lifted: true } : {}),
351
521
  ...(history.kind === "partial" ? { history, shapes: partialShapes } : { shapes }),
352
522
  };
353
523
  if (plan !== "skip" || facts) {
@@ -463,4 +633,5 @@ export function indexedReplica(options) {
463
633
  await done;
464
634
  },
465
635
  };
636
+ return port;
466
637
  }