slopkit 0.1.2 → 0.2.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/dist/sync.js CHANGED
@@ -1,5 +1,93 @@
1
1
  import { unreachable } from "./machine.js";
2
2
  import { check } from "./schema.js";
3
+ /** A key a chunk removed, in a table's layer of changes. */
4
+ const GONE = Symbol("gone");
5
+ /**
6
+ * A table's rows after a chunk, without copying the rows it already had: the chunk's changes are a layer over them, so
7
+ * merging a chunk costs its own size, not the table's. Layers merge when a newer one is as big as the one below it, and
8
+ * fold into a plain map once the top one holds half as many keys as it, so lookups pass a logarithmic number of layers
9
+ * and a changed row is copied a logarithmic number of times. Immutable: a published value never changes.
10
+ */
11
+ class Layered {
12
+ size;
13
+ base;
14
+ /** Newest first. */
15
+ layers;
16
+ constructor(base, layers, size) {
17
+ this.base = base;
18
+ this.layers = layers;
19
+ this.size = size;
20
+ }
21
+ find(key) {
22
+ for (const each of this.layers) {
23
+ if (each.has(key)) {
24
+ return each.get(key);
25
+ }
26
+ }
27
+ return this.base.get(key);
28
+ }
29
+ get(key) {
30
+ const value = this.find(key);
31
+ return value === GONE ? undefined : value;
32
+ }
33
+ // Rows are never undefined.
34
+ has(key) {
35
+ return this.get(key) !== undefined;
36
+ }
37
+ // The base's keys in place, then new keys in the order they first came.
38
+ *entries() {
39
+ for (const key of this.base.keys()) {
40
+ const value = this.get(key);
41
+ if (value !== undefined) {
42
+ yield [key, value];
43
+ }
44
+ }
45
+ const seen = new Set();
46
+ for (let at = this.layers.length - 1; at >= 0; at--) {
47
+ for (const key of this.layers[at]?.keys() ?? []) {
48
+ const value = this.get(key);
49
+ if (!seen.has(key) && !this.base.has(key) && value !== undefined) {
50
+ seen.add(key);
51
+ yield [key, value];
52
+ }
53
+ }
54
+ }
55
+ }
56
+ *keys() {
57
+ for (const [key] of this.entries()) {
58
+ yield key;
59
+ }
60
+ }
61
+ *values() {
62
+ for (const [, value] of this.entries()) {
63
+ yield value;
64
+ }
65
+ }
66
+ [Symbol.iterator]() {
67
+ return this.entries();
68
+ }
69
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- Map's receiver is whatever the caller passes
70
+ forEach(each, receiver) {
71
+ for (const [key, value] of this.entries()) {
72
+ each.call(receiver, value, key, this);
73
+ }
74
+ }
75
+ }
76
+ /** `rows` with `changes` over it, as a new value; `rows` is left as it was. */
77
+ function overlay(rows, changes) {
78
+ let size = rows.size;
79
+ for (const [key, value] of changes) {
80
+ size += (value === GONE ? 0 : 1) - (rows.has(key) ? 1 : 0);
81
+ }
82
+ const base = rows instanceof Layered ? rows.base : rows;
83
+ const below = rows instanceof Layered ? [...rows.layers] : [];
84
+ let top = changes;
85
+ while (below[0] !== undefined && below[0].size <= top.size) {
86
+ top = new Map([...(below.shift() ?? []), ...top]);
87
+ }
88
+ const layered = new Layered(base, [top, ...below], size);
89
+ return top.size * 2 < base.size ? layered : new Map(layered);
90
+ }
3
91
  export function table(def) {
4
92
  const gone = (row) => def.gone?.(row) === true;
5
93
  return {
@@ -15,16 +103,11 @@ export function table(def) {
15
103
  if (changed.length === 0 && !reset) {
16
104
  return tables;
17
105
  }
18
- const next = new Map(reset ? [] : def.get(tables));
106
+ const changes = new Map();
19
107
  for (const row of changed) {
20
- if (gone(row)) {
21
- next.delete(def.key(row));
22
- }
23
- else {
24
- next.set(def.key(row), row);
25
- }
108
+ changes.set(def.key(row), gone(row) ? GONE : row);
26
109
  }
27
- return def.set(tables, next);
110
+ return def.set(tables, overlay(reset ? new Map() : def.get(tables), changes));
28
111
  },
29
112
  snapshot: (tables) => [...def.get(tables).entries()].map(([key, row]) => ({ table: def.name, key, row })),
30
113
  restore: (tables, values) => {
@@ -40,6 +123,18 @@ export function table(def) {
40
123
  },
41
124
  };
42
125
  }
126
+ const COMPLETE = { kind: "complete" };
127
+ const historyOf = (replica) => replica.history ?? COMPLETE;
128
+ /** The history a page leaves: complete without one (an old server's whole copy) or once no older page is left. */
129
+ const covered = (page) => page === undefined || page.next === null
130
+ ? COMPLETE
131
+ : {
132
+ kind: "partial",
133
+ next: page.next,
134
+ through: page.through,
135
+ held: page.held,
136
+ total: page.total,
137
+ };
43
138
  /** The socket must be heard this often (the server pokes every 20 s); quieter, the page polls. */
44
139
  export const SOCKET_DEADLINE_MS = 45_000;
45
140
  /** Polling while the socket can't be trusted or work is in flight, and the first retry after a failure. */
@@ -55,34 +150,88 @@ export const DONE_KEEP_MS = 60_000;
55
150
  export function reachable(connection) {
56
151
  return connection.kind === "live" || connection.kind === "stale";
57
152
  }
58
- /** Whether the replica holds this command's effects. */
153
+ /**
154
+ * Whether the replica holds this command's effects: it is done, and the copy is at its revision and either complete or
155
+ * holding the rows its receipt names (a pull reply carried them). One that failed on the page has none.
156
+ */
59
157
  export function settled(model, pending) {
60
- return pending.state.kind === "done" && model.replica.revision >= pending.state.revision;
158
+ return (pending.state.kind === "done" &&
159
+ (pending.state.revision === 0 ||
160
+ (model.replica.revision >= pending.state.revision &&
161
+ (historyOf(model.replica).kind === "complete" ||
162
+ model.carried.has(`${pending.client}:${pending.id}`)))));
61
163
  }
62
- /** Whether what the page shows is also on the disk. */
164
+ /**
165
+ * Whether what the page shows is also on the disk: by revision only, so tabs at different cursors never chase each
166
+ * other's writes. A failed write is owed again even at the same revision (a page that failed to land).
167
+ */
63
168
  export function saved(model) {
64
- return (model.saved.dataset === model.replica.dataset && model.saved.revision >= model.replica.revision);
169
+ return (model.saved.dataset === model.replica.dataset &&
170
+ model.saved.revision >= model.replica.revision &&
171
+ !model.saved.owed);
65
172
  }
66
173
  /**
67
174
  * What a commit does to the disk, decided inside the disk's own transaction from what it holds: nothing when the disk
68
- * already has this or newer (another tab got there), the patch alone when the disk is at least at its base, else the
69
- * whole replica, so a skipped or failed patch is never covered by a later checkpoint. A page may replace the disk's
175
+ * already has this or newer (another tab got there; a complete copy at the same revision is newer than any page), the
176
+ * patch alone when the disk is at least at its base, else the whole replica, so a skipped or failed patch is never
177
+ * covered by a later checkpoint. A delta keeps the disk's own history, and takes the patch's only when the disk is at
178
+ * the cursor the patch moved from (`adopt`): coverage never lands without its rows. A page may replace the disk's
70
179
  * dataset only with one it moved to from that very dataset (`replaces`): a tab still on a dataset the server has since
71
180
  * replaced is `stale` and never writes it back.
72
181
  */
73
182
  export function commitPlan(stored, commit) {
74
183
  const same = stored.dataset === commit.dataset;
184
+ const disk = stored.history ?? COMPLETE;
75
185
  if (!same && stored.dataset !== null && stored.dataset !== commit.replaces) {
76
186
  return "stale";
77
187
  }
78
- if (same && stored.revision >= commit.revision) {
188
+ if (same &&
189
+ (stored.revision > commit.revision ||
190
+ (stored.revision === commit.revision && disk.kind === "complete"))) {
79
191
  return "skip";
80
192
  }
81
193
  if (!commit.reset && same && stored.revision >= commit.base) {
82
- return "delta";
194
+ return disk.kind === "partial" && disk.next === commit.from ? "adopt" : "delta";
83
195
  }
84
196
  return "whole";
85
197
  }
198
+ /**
199
+ * The copy's history: loading while storage loads, none without a dataset, else partial or complete. Partial is a claim
200
+ * beside the numbers ("history from <through>, loading older · held of total"), and totals cover only `through` and
201
+ * above. Complete says nothing about storage: that is `deriveDurabilityState`, its own subject.
202
+ */
203
+ export function deriveHistoryState(model) {
204
+ if (model.boot.kind === "loading") {
205
+ return { kind: "loading" };
206
+ }
207
+ if (model.replica.dataset === null) {
208
+ return { kind: "none" };
209
+ }
210
+ const history = historyOf(model.replica);
211
+ return history.kind === "complete"
212
+ ? history
213
+ : { kind: "partial", through: history.through, held: history.held, total: history.total };
214
+ }
215
+ /**
216
+ * The copy's durability at the model's clock: loading, replaced by another tab, a failing store (lost while the page
217
+ * shows more than the disk holds), unsaved past the storage deadline, commands kept that this version can't read, or ok.
218
+ */
219
+ export function deriveDurabilityState(model) {
220
+ const behind = model.saved.behindSince;
221
+ if (model.boot.kind === "loading") {
222
+ return { kind: "loading" };
223
+ }
224
+ if (model.storage.kind === "replaced") {
225
+ return { kind: "replaced" };
226
+ }
227
+ if (model.storage.kind === "failed") {
228
+ return { kind: behind === null ? "failing" : "lost", reason: model.storage.reason };
229
+ }
230
+ if (behind !== null && model.now - behind >= STORAGE_DEADLINE_MS) {
231
+ return { kind: "unsaved" };
232
+ }
233
+ return model.unreadable > 0 ? { kind: "unreadable", count: model.unreadable } : { kind: "ok" };
234
+ }
86
235
  /**
87
236
  * Storage after a write that worked: a failure that only a keep or a drop had (the replica's own writes aren't
88
237
  * failing) is over; a replaced copy stays replaced.
@@ -125,6 +274,7 @@ export function createSync(spec) {
125
274
  revision: 0,
126
275
  tables: spec.empty,
127
276
  server: null,
277
+ history: COMPLETE,
128
278
  });
129
279
  function initial(client, now) {
130
280
  return {
@@ -140,14 +290,17 @@ export function createSync(spec) {
140
290
  writing: 0,
141
291
  failures: 0,
142
292
  nextAt: now,
293
+ owed: false,
143
294
  },
144
295
  target: 0,
296
+ restart: null,
145
297
  reconciledAt: null,
146
298
  connection: { kind: "starting" },
147
299
  socket: { open: false, heardAt: 0 },
148
300
  pull: { kind: "idle", nextAt: now, failures: 0 },
149
301
  push: { kind: "idle", nextAt: now, failures: 0 },
150
302
  commands: [],
303
+ carried: new Set(),
151
304
  unreadable: 0,
152
305
  visible: true,
153
306
  verify: { kind: "pending", tries: 0 },
@@ -155,22 +308,37 @@ export function createSync(spec) {
155
308
  };
156
309
  }
157
310
  /**
158
- * The one merge: a patch applied to a replica, with its disk operations, or undefined when it is older than the
159
- * replica (a late reply). A reset or another dataset replaces everything.
311
+ * The one merge: a patch applied to a replica, with its disk operations; undefined when it is older than the replica
312
+ * (a late reply), `gap` when it can't follow from it. Only a reset (or the answer to `since` 0, which holds
313
+ * everything) replaces the copy, with its own history. A delta applies only to the same dataset with
314
+ * `since ≤ copy.revision ≤ reply.revision` (equal only with a page or new server facts); it moves the cursor only when
315
+ * it answers the copy's own `next`, and a reply without a page never changes the history.
160
316
  */
161
- function merge(replica, patch) {
162
- const reset = patch.reset || patch.dataset !== replica.dataset;
163
- if (!reset && patch.revision <= replica.revision) {
164
- // Nothing newer, but the server's own facts (version, settings) may have moved.
165
- if (patch.revision === replica.revision &&
166
- JSON.stringify(patch.server) !== JSON.stringify(replica.server)) {
167
- return { replica: { ...replica, server: patch.server }, ops: [], reset: false };
317
+ function merge(replica, patch, since) {
318
+ const reset = patch.reset || since === 0;
319
+ const page = patch.page;
320
+ const was = historyOf(replica);
321
+ let history = was;
322
+ if (reset) {
323
+ // A reset older than what we hold of the same dataset is a late reply.
324
+ if (patch.dataset === replica.dataset && patch.revision < replica.revision) {
325
+ return undefined;
168
326
  }
169
- return undefined;
327
+ history = covered(page);
328
+ }
329
+ else if (patch.dataset !== replica.dataset || since > replica.revision) {
330
+ return "gap";
331
+ }
332
+ else if (patch.revision < replica.revision ||
333
+ (patch.revision === replica.revision && (page === undefined || was.kind === "complete"))) {
334
+ // Nothing newer, but the server's own facts (version, settings) may have moved.
335
+ return patch.revision === replica.revision &&
336
+ JSON.stringify(patch.server) !== JSON.stringify(replica.server)
337
+ ? { replica: { ...replica, server: patch.server }, ops: [], reset: false }
338
+ : undefined;
170
339
  }
171
- // A reset older than what we hold of the same dataset is a late reply.
172
- if (reset && patch.dataset === replica.dataset && patch.revision < replica.revision) {
173
- return undefined;
340
+ else if (page !== undefined && was.kind === "partial" && page.from === was.next) {
341
+ history = covered(page);
174
342
  }
175
343
  let tables = reset ? spec.empty : replica.tables;
176
344
  const ops = [];
@@ -179,7 +347,13 @@ export function createSync(spec) {
179
347
  ops.push(...lens.ops(patch.rows));
180
348
  }
181
349
  return {
182
- replica: { dataset: patch.dataset, revision: patch.revision, server: patch.server, tables },
350
+ replica: {
351
+ dataset: patch.dataset,
352
+ revision: patch.revision,
353
+ server: patch.server,
354
+ tables,
355
+ history,
356
+ },
183
357
  ops,
184
358
  reset,
185
359
  };
@@ -196,19 +370,36 @@ export function createSync(spec) {
196
370
  const behind = next.replica.revision < next.target;
197
371
  const unsettled = next.commands.some((each) => each.state.kind === "done" && !settled(next, each));
198
372
  const polling = next.connection.kind !== "live" || unsettled || (spec.outstanding?.(next.replica) ?? false);
199
- const verifying = next.verify.kind === "pending";
200
- if (((behind || verifying) && (failures === 0 || due)) || (polling && due)) {
373
+ const history = historyOf(next.replica);
374
+ const whole = next.restart === "whole";
375
+ // Only a complete copy is checked: a fingerprint of everything can't certify part of it.
376
+ const verifying = next.verify.kind === "pending" && (history.kind === "complete" || whole);
377
+ // A partial copy owes its next page, back to back, until it is complete.
378
+ const owed = behind || verifying || history.kind === "partial" || next.restart !== null;
379
+ const since = next.restart === null ? next.replica.revision : 0;
380
+ if ((owed && (failures === 0 || due)) || (polling && due)) {
381
+ const current = next;
382
+ // Commands waiting on an outcome or on their effects, oldest first, at most a push's worth: their receipts
383
+ // come back with the rows they name; the rest in the next pull.
384
+ const commands = current.commands
385
+ .filter((each) => each.state.kind === "sending" ||
386
+ each.state.kind === "unknown" ||
387
+ (each.state.kind === "done" && !settled(current, each)))
388
+ .slice(0, pushMax)
389
+ .map((each) => ({ client: each.client, id: each.id }));
201
390
  out.push({
202
391
  kind: "pull",
203
392
  client: next.client,
204
393
  dataset: next.replica.dataset,
205
- since: next.replica.revision,
394
+ since,
206
395
  verify: verifying,
396
+ ...(whole ? {} : { page: history.kind === "partial" && since > 0 ? history.next : null }),
397
+ commands,
207
398
  });
208
399
  next = {
209
400
  ...next,
210
- pull: { kind: "pulling", since: next.replica.revision, failures },
211
- verify: next.verify.kind === "pending"
401
+ pull: { kind: "pulling", since, failures },
402
+ verify: verifying && next.verify.kind === "pending"
212
403
  ? { kind: "requested", tries: next.verify.tries }
213
404
  : next.verify,
214
405
  };
@@ -355,20 +546,60 @@ export function createSync(spec) {
355
546
  ];
356
547
  }
357
548
  case "pulled": {
358
- const merged = merge(at.replica, msg.patch);
549
+ const result = merge(at.replica, msg.patch, msg.since);
550
+ const merged = result === "gap" ? undefined : result;
359
551
  const replica = merged?.replica ?? at.replica;
360
552
  const effects = [];
553
+ const was = historyOf(at.replica);
554
+ const history = historyOf(replica);
555
+ const failures = at.pull.kind === "pulling" ? at.pull.failures : 0;
556
+ // An old server answering a partial copy (with rows or without): only its whole copy completes it.
557
+ const old = was.kind === "partial" &&
558
+ msg.patch.page === undefined &&
559
+ !msg.patch.reset &&
560
+ msg.since > 0 &&
561
+ result !== "gap";
562
+ // A partial copy this reply didn't move on (the same page again, a repeated first page at the same revision, a
563
+ // reply it couldn't take) is a pull that failed, for backoff: never a busy loop on a server that doesn't move.
564
+ const stuck = was.kind === "partial" &&
565
+ !old &&
566
+ (merged === undefined ||
567
+ (msg.patch.page !== undefined &&
568
+ history.kind === "partial" &&
569
+ history.next === was.next &&
570
+ replica.dataset === at.replica.dataset &&
571
+ !(merged.reset && replica.revision > at.replica.revision)));
572
+ // Receipts that came with an applied reply came with the rows they name.
573
+ const carried = new Set(merged?.reset === true ? [] : at.carried);
574
+ if (merged !== undefined) {
575
+ for (const each of msg.receipts) {
576
+ if (each.revision <= replica.revision) {
577
+ carried.add(`${each.client ?? at.client}:${each.id}`);
578
+ }
579
+ }
580
+ }
361
581
  let next = {
362
582
  ...at,
363
583
  replica,
584
+ carried,
364
585
  target: msg.patch.dataset === at.replica.dataset
365
586
  ? Math.max(at.target, msg.patch.revision)
366
587
  : msg.patch.revision,
588
+ // A reply it couldn't follow asks a real reset; an old server's reply to a partial copy, its whole copy.
589
+ restart: result === "gap"
590
+ ? (at.restart ?? "paged")
591
+ : merged?.reset === true
592
+ ? null
593
+ : old
594
+ ? "whole"
595
+ : at.restart,
367
596
  reconciledAt: now,
368
597
  connection: at.socket.open && now - at.socket.heardAt < SOCKET_DEADLINE_MS
369
598
  ? { kind: "live" }
370
599
  : { kind: "stale" },
371
- pull: { kind: "idle", nextAt: now + POLL_MS, failures: 0 },
600
+ pull: stuck
601
+ ? { kind: "idle", nextAt: now + backoff(failures + 1), failures: failures + 1 }
602
+ : { kind: "idle", nextAt: now + POLL_MS, failures: 0 },
372
603
  };
373
604
  if (merged !== undefined && next.storage.kind !== "replaced") {
374
605
  effects.push({
@@ -378,12 +609,15 @@ export function createSync(spec) {
378
609
  reset: merged.reset,
379
610
  base: msg.since,
380
611
  replaces: at.saved.dataset,
612
+ from: was.kind === "partial" ? was.next : null,
381
613
  });
382
614
  next = { ...next, saved: { ...next.saved, writing: next.saved.writing + 1 } };
383
615
  }
384
616
  if (at.verify.kind === "requested") {
385
- // The fingerprint describes the server at the reply's revision: comparable only if the replica is there.
617
+ // The fingerprint describes the server at the reply's revision: comparable only if the replica is there, and
618
+ // whole (a fingerprint of everything never certifies part of it).
386
619
  if (msg.patch.checksum !== undefined &&
620
+ history.kind === "complete" &&
387
621
  replica.revision === msg.patch.revision &&
388
622
  replica.dataset === msg.patch.dataset) {
389
623
  effects.push({ kind: "check", replica, checksum: msg.patch.checksum });
@@ -392,13 +626,21 @@ export function createSync(spec) {
392
626
  next = { ...next, verify: { kind: "pending", tries: at.verify.tries } };
393
627
  }
394
628
  }
395
- const [answered, more] = answer(next, next.client, msg.receipts);
629
+ // Receipts by client: an earlier page's commands are answered too.
630
+ let answered = next;
631
+ const more = [];
632
+ for (const client of new Set(msg.receipts.map((each) => each.client ?? at.client))) {
633
+ const [after, told] = answer(answered, client, msg.receipts.filter((each) => (each.client ?? at.client) === client));
634
+ answered = after;
635
+ more.push(...told);
636
+ }
396
637
  // Intents tapped against a store the server no longer has never go to its replacement (the server refuses
397
638
  // them too). One that may already have run there says so.
398
639
  const replaced = answered.commands.map((each) => {
399
640
  if (each.state.kind === "done" ||
400
641
  each.dataset === null ||
401
- each.dataset === msg.patch.dataset) {
642
+ replica.dataset === null ||
643
+ each.dataset === replica.dataset) {
402
644
  return each;
403
645
  }
404
646
  const done = fail(each, each.state.kind === "unknown"
@@ -474,10 +716,12 @@ export function createSync(spec) {
474
716
  }
475
717
  case "committed": {
476
718
  const same = msg.dataset === at.saved.dataset;
719
+ // A delta the disk took under its own history doesn't repair a failed write: the whole one stays owed.
720
+ const owed = at.saved.owed && msg.whole === false;
477
721
  return [
478
722
  {
479
723
  ...at,
480
- storage: at.storage.kind === "replaced" ? at.storage : { kind: "ok" },
724
+ storage: at.storage.kind === "replaced" || owed ? at.storage : { kind: "ok" },
481
725
  saved: {
482
726
  dataset: msg.dataset,
483
727
  revision: same ? Math.max(at.saved.revision, msg.revision) : msg.revision,
@@ -485,6 +729,7 @@ export function createSync(spec) {
485
729
  writing: Math.max(0, at.saved.writing - 1),
486
730
  failures: 0,
487
731
  nextAt: now,
732
+ owed,
488
733
  },
489
734
  },
490
735
  [],
@@ -501,6 +746,7 @@ export function createSync(spec) {
501
746
  writing: Math.max(0, at.saved.writing - 1),
502
747
  failures,
503
748
  nextAt: now + backoff(failures),
749
+ owed: true,
504
750
  },
505
751
  },
506
752
  [{ kind: "telemetry", name: "replica.save-failed", attributes: { reason: msg.reason } }],
@@ -685,6 +931,12 @@ export function createSync(spec) {
685
931
  let marked = since === next.saved.behindSince
686
932
  ? next
687
933
  : { ...next, saved: { ...next.saved, behindSince: since } };
934
+ // Commands that left the page leave what was carried for them.
935
+ const kept = new Set(marked.commands.map((each) => `${each.client}:${each.id}`));
936
+ const carried = [...marked.carried].filter((key) => kept.has(key));
937
+ if (carried.length < marked.carried.size) {
938
+ marked = { ...marked, carried: new Set(carried) };
939
+ }
688
940
  // A command that resolves only now (its result came later than its receipt) is drawn its full time from now.
689
941
  const resolved = spec.resolved;
690
942
  if (resolved !== undefined) {
@@ -11,7 +11,11 @@
11
11
  * - Rows are read back only under the row shapes that wrote them (each table's name and schema `shape`, kept with the
12
12
  * checkpoint): any change to a table or its schema starts the rows over, with no number to bump by hand. The next
13
13
  * commit then writes the whole replica, so the disk is repaired, not just the memory. Rows that don't parse are
14
- * treated the same. The server's facts are not rows: facts an older page kept that no longer fit load as null and
14
+ * treated the same.
15
+ * - A partial copy (a first copy still paging in) keeps its history in the checkpoint, written in the same transaction
16
+ * as its rows, and marks its row shapes as partial: a release from before paging reads it as another release's copy,
17
+ * 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
+ * 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
15
19
  * never cost the copy; facts that change at the same revision are written too.
16
20
  * - A version upgrade starts the replica over (it comes back from the server) but keeps the outbox and the generation:
17
21
  * an intent on disk is never erased by an upgrade. One this version can't read stays on disk and is counted.
@@ -21,12 +25,24 @@
21
25
  * in-memory one). `interfere` is for tests: it sees each write transaction before it commits and may abort it.
22
26
  */
23
27
  import { Replaced } from "../client.js";
24
- import { check } from "../schema.js";
28
+ import { check, literal, nullable, number, object, string } from "../schema.js";
25
29
  import { commitPlan } from "../sync.js";
26
30
  import { committed, openDatabase, request } from "./idb.js";
31
+ const COMPLETE = { kind: "complete" };
32
+ const count = () => number({ integer: true, min: 0 });
33
+ const partialSchema = object({
34
+ kind: literal("partial"),
35
+ next: string(),
36
+ through: nullable(string()),
37
+ held: count(),
38
+ total: count(),
39
+ });
27
40
  export function indexedReplica(options) {
28
41
  const names = options.tables.map((lens) => lens.name);
29
42
  const shapes = options.tables.map((lens) => lens.shape).join("\n");
43
+ /** A partial copy's row shapes: foreign to a release from before paging. */
44
+ const partialShapes = `${shapes}\npartial`;
45
+ const ours = (written) => written === shapes || written === partialShapes;
30
46
  /** The checkpoint a load found whose rows didn't parse: the next commit at it rewrites the whole replica. */
31
47
  let damaged;
32
48
  /** The dataset of a copy this page loaded but couldn't read (other row shapes, rows that don't parse): its own to replace. */
@@ -142,22 +158,26 @@ export function indexedReplica(options) {
142
158
  }
143
159
  /** The checkpoint on disk as this version may trust it: one of other row shapes (or none) holds nothing. */
144
160
  const checkpointOf = (value) => {
161
+ const none = { dataset: null, revision: 0, history: COMPLETE };
145
162
  if (typeof value !== "object" || value === null) {
146
- return { dataset: null, revision: 0 };
163
+ return none;
147
164
  }
148
165
  const dataset = Reflect.get(value, "dataset");
149
166
  const revision = Reflect.get(value, "revision");
150
- if (Reflect.get(value, "shapes") !== shapes) {
151
- return { dataset: null, revision: 0 };
167
+ const written = Reflect.get(value, "shapes");
168
+ const partial = check(partialSchema, Reflect.get(value, "history"));
169
+ if (written !== shapes && (written !== partialShapes || !partial.ok)) {
170
+ return none;
152
171
  }
153
172
  const at = {
154
173
  dataset: typeof dataset === "string" ? dataset : null,
155
174
  revision: typeof revision === "number" ? revision : 0,
175
+ history: written === partialShapes && partial.ok ? partial.value : COMPLETE,
156
176
  };
157
177
  if (damaged !== undefined &&
158
178
  damaged.dataset === at.dataset &&
159
179
  damaged.revision === at.revision) {
160
- return { dataset: null, revision: 0 };
180
+ return none;
161
181
  }
162
182
  return at;
163
183
  };
@@ -197,6 +217,7 @@ export function indexedReplica(options) {
197
217
  revision: 0,
198
218
  tables: options.empty,
199
219
  server: null,
220
+ history: COMPLETE,
200
221
  };
201
222
  const at = checkpointOf(point);
202
223
  const raw = typeof point === "object" && point !== null ? Reflect.get(point, "dataset") : null;
@@ -232,6 +253,7 @@ export function indexedReplica(options) {
232
253
  revision: at.revision,
233
254
  tables: restored,
234
255
  server: server?.ok === true ? server.value : null,
256
+ history: at.history,
235
257
  },
236
258
  commands,
237
259
  unreadable,
@@ -246,7 +268,6 @@ export function indexedReplica(options) {
246
268
  dataset: commit.replica.dataset,
247
269
  revision: commit.replica.revision,
248
270
  server: commit.replica.server,
249
- shapes,
250
271
  };
251
272
  try {
252
273
  await guard(transaction);
@@ -267,6 +288,7 @@ export function indexedReplica(options) {
267
288
  reset: commit.reset,
268
289
  base: commit.base,
269
290
  replaces: commit.replaces,
291
+ ...(commit.from === undefined ? {} : { from: commit.from }),
270
292
  });
271
293
  // The disk holds a dataset this page never saw: the server replaced the one this page is on.
272
294
  if (plan === "stale") {
@@ -278,7 +300,7 @@ export function indexedReplica(options) {
278
300
  // Another release's tab wrote this dataset under its own row shapes since this page loaded: that release owns
279
301
  // the copy now, at any revision, and this page stops writing until it reloads.
280
302
  if (held === commit.replica.dataset &&
281
- written !== shapes &&
303
+ !ours(written) &&
282
304
  (loaded === undefined ||
283
305
  loaded.dataset !== held ||
284
306
  loaded.revision !== revision ||
@@ -305,7 +327,7 @@ export function indexedReplica(options) {
305
327
  }
306
328
  }
307
329
  };
308
- if (plan === "delta") {
330
+ if (plan === "delta" || plan === "adopt") {
309
331
  write(commit.ops);
310
332
  }
311
333
  if (plan === "whole") {
@@ -318,8 +340,16 @@ export function indexedReplica(options) {
318
340
  }
319
341
  // At the same revision the rows are already there, but the server's facts may have moved: they are kept too.
320
342
  const facts = plan === "skip" && stored.revision === target.revision;
343
+ // A delta keeps the disk's history; a whole write or a delta from the disk's own cursor takes the page's.
344
+ const history = plan === "whole" || plan === "adopt"
345
+ ? (commit.replica.history ?? COMPLETE)
346
+ : stored.history;
347
+ const checkpoint = {
348
+ ...target,
349
+ ...(history.kind === "partial" ? { history, shapes: partialShapes } : { shapes }),
350
+ };
321
351
  if (plan !== "skip" || facts) {
322
- meta.put(target, "checkpoint");
352
+ meta.put(checkpoint, "checkpoint");
323
353
  }
324
354
  options.interfere?.("commit", transaction);
325
355
  await done;
@@ -328,11 +358,16 @@ export function indexedReplica(options) {
328
358
  discarded = null;
329
359
  loaded = undefined;
330
360
  }
331
- const now = plan === "skip" ? stored : target;
361
+ const now = plan === "skip" ? stored : checkpoint;
332
362
  if (now.dataset === null) {
333
363
  throw new Error("the replica has no dataset");
334
364
  }
335
- return { dataset: now.dataset, revision: now.revision };
365
+ // A delta under the disk's own history leaves out rows of this page's copy: said, so a failed write stays owed.
366
+ return {
367
+ dataset: now.dataset,
368
+ revision: now.revision,
369
+ ...(plan === "delta" && stored.history.kind === "partial" ? { whole: false } : {}),
370
+ };
336
371
  }
337
372
  catch (error) {
338
373
  await done.catch(() => undefined);