slopkit 0.1.1 → 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. */
@@ -47,40 +142,96 @@ export const POLL_MS = 4000;
47
142
  const BACKOFF_MAX_MS = 30_000;
48
143
  /** Storage gets this long to load, or to keep an intent, before the page goes on without it. */
49
144
  export const STORAGE_DEADLINE_MS = 3000;
145
+ /** Commands per push unless the spec says otherwise; the rest wait for the next push. */
146
+ export const PUSH_MAX = 50;
50
147
  /** Settled commands stay drawn this long. */
51
148
  export const DONE_KEEP_MS = 60_000;
52
149
  /** Whether the server can be asked now. */
53
150
  export function reachable(connection) {
54
151
  return connection.kind === "live" || connection.kind === "stale";
55
152
  }
56
- /** 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
+ */
57
157
  export function settled(model, pending) {
58
- 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}`)))));
59
163
  }
60
- /** 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
+ */
61
168
  export function saved(model) {
62
- 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);
63
172
  }
64
173
  /**
65
174
  * What a commit does to the disk, decided inside the disk's own transaction from what it holds: nothing when the disk
66
- * already has this or newer (another tab got there), the patch alone when the disk is at least at its base, else the
67
- * 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
68
179
  * dataset only with one it moved to from that very dataset (`replaces`): a tab still on a dataset the server has since
69
180
  * replaced is `stale` and never writes it back.
70
181
  */
71
182
  export function commitPlan(stored, commit) {
72
183
  const same = stored.dataset === commit.dataset;
184
+ const disk = stored.history ?? COMPLETE;
73
185
  if (!same && stored.dataset !== null && stored.dataset !== commit.replaces) {
74
186
  return "stale";
75
187
  }
76
- if (same && stored.revision >= commit.revision) {
188
+ if (same &&
189
+ (stored.revision > commit.revision ||
190
+ (stored.revision === commit.revision && disk.kind === "complete"))) {
77
191
  return "skip";
78
192
  }
79
193
  if (!commit.reset && same && stored.revision >= commit.base) {
80
- return "delta";
194
+ return disk.kind === "partial" && disk.next === commit.from ? "adopt" : "delta";
81
195
  }
82
196
  return "whole";
83
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
+ }
84
235
  /**
85
236
  * Storage after a write that worked: a failure that only a keep or a drop had (the replica's own writes aren't
86
237
  * failing) is over; a replaced copy stays replaced.
@@ -114,11 +265,16 @@ function failedConnection(connection, failure) {
114
265
  }
115
266
  }
116
267
  export function createSync(spec) {
268
+ const pushMax = spec.pushMax ?? PUSH_MAX;
269
+ if (!Number.isSafeInteger(pushMax) || pushMax < 1) {
270
+ throw new Error(`sync: pushMax must be a positive integer, got ${pushMax}`);
271
+ }
117
272
  const emptyReplica = () => ({
118
273
  dataset: null,
119
274
  revision: 0,
120
275
  tables: spec.empty,
121
276
  server: null,
277
+ history: COMPLETE,
122
278
  });
123
279
  function initial(client, now) {
124
280
  return {
@@ -134,14 +290,17 @@ export function createSync(spec) {
134
290
  writing: 0,
135
291
  failures: 0,
136
292
  nextAt: now,
293
+ owed: false,
137
294
  },
138
295
  target: 0,
296
+ restart: null,
139
297
  reconciledAt: null,
140
298
  connection: { kind: "starting" },
141
299
  socket: { open: false, heardAt: 0 },
142
300
  pull: { kind: "idle", nextAt: now, failures: 0 },
143
301
  push: { kind: "idle", nextAt: now, failures: 0 },
144
302
  commands: [],
303
+ carried: new Set(),
145
304
  unreadable: 0,
146
305
  visible: true,
147
306
  verify: { kind: "pending", tries: 0 },
@@ -149,22 +308,37 @@ export function createSync(spec) {
149
308
  };
150
309
  }
151
310
  /**
152
- * The one merge: a patch applied to a replica, with its disk operations, or undefined when it is older than the
153
- * 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.
154
316
  */
155
- function merge(replica, patch) {
156
- const reset = patch.reset || patch.dataset !== replica.dataset;
157
- if (!reset && patch.revision <= replica.revision) {
158
- // Nothing newer, but the server's own facts (version, settings) may have moved.
159
- if (patch.revision === replica.revision &&
160
- JSON.stringify(patch.server) !== JSON.stringify(replica.server)) {
161
- 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;
162
326
  }
163
- return undefined;
327
+ history = covered(page);
328
+ }
329
+ else if (patch.dataset !== replica.dataset || since > replica.revision) {
330
+ return "gap";
164
331
  }
165
- // A reset older than what we hold of the same dataset is a late reply.
166
- if (reset && patch.dataset === replica.dataset && patch.revision < replica.revision) {
167
- return undefined;
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;
339
+ }
340
+ else if (page !== undefined && was.kind === "partial" && page.from === was.next) {
341
+ history = covered(page);
168
342
  }
169
343
  let tables = reset ? spec.empty : replica.tables;
170
344
  const ops = [];
@@ -173,7 +347,13 @@ export function createSync(spec) {
173
347
  ops.push(...lens.ops(patch.rows));
174
348
  }
175
349
  return {
176
- 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
+ },
177
357
  ops,
178
358
  reset,
179
359
  };
@@ -190,19 +370,36 @@ export function createSync(spec) {
190
370
  const behind = next.replica.revision < next.target;
191
371
  const unsettled = next.commands.some((each) => each.state.kind === "done" && !settled(next, each));
192
372
  const polling = next.connection.kind !== "live" || unsettled || (spec.outstanding?.(next.replica) ?? false);
193
- const verifying = next.verify.kind === "pending";
194
- 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 }));
195
390
  out.push({
196
391
  kind: "pull",
197
392
  client: next.client,
198
393
  dataset: next.replica.dataset,
199
- since: next.replica.revision,
394
+ since,
200
395
  verify: verifying,
396
+ ...(whole ? {} : { page: history.kind === "partial" && since > 0 ? history.next : null }),
397
+ commands,
201
398
  });
202
399
  next = {
203
400
  ...next,
204
- pull: { kind: "pulling", since: next.replica.revision, failures },
205
- verify: next.verify.kind === "pending"
401
+ pull: { kind: "pulling", since, failures },
402
+ verify: verifying && next.verify.kind === "pending"
206
403
  ? { kind: "requested", tries: next.verify.tries }
207
404
  : next.verify,
208
405
  };
@@ -212,12 +409,14 @@ export function createSync(spec) {
212
409
  const first = next.commands.find((each) => each.state.kind === "queued" || each.state.kind === "unknown");
213
410
  if (first !== undefined) {
214
411
  // One push per client, dataset and tapped-against revision: the server judges each by what it was made on.
412
+ // At most `pushMax`, oldest first; the rest go in the next push.
215
413
  const tapped = (each) => each.since ?? next.replica.revision;
216
414
  const ids = new Set(next.commands
217
415
  .filter((each) => each.client === first.client &&
218
416
  each.dataset === first.dataset &&
219
417
  tapped(each) === tapped(first) &&
220
418
  (each.state.kind === "queued" || each.state.kind === "unknown"))
419
+ .slice(0, pushMax)
221
420
  .map((each) => each.id));
222
421
  const mine = (each) => each.client === first.client && ids.has(each.id);
223
422
  out.push({
@@ -347,20 +546,60 @@ export function createSync(spec) {
347
546
  ];
348
547
  }
349
548
  case "pulled": {
350
- const merged = merge(at.replica, msg.patch);
549
+ const result = merge(at.replica, msg.patch, msg.since);
550
+ const merged = result === "gap" ? undefined : result;
351
551
  const replica = merged?.replica ?? at.replica;
352
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
+ }
353
581
  let next = {
354
582
  ...at,
355
583
  replica,
584
+ carried,
356
585
  target: msg.patch.dataset === at.replica.dataset
357
586
  ? Math.max(at.target, msg.patch.revision)
358
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,
359
596
  reconciledAt: now,
360
597
  connection: at.socket.open && now - at.socket.heardAt < SOCKET_DEADLINE_MS
361
598
  ? { kind: "live" }
362
599
  : { kind: "stale" },
363
- 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 },
364
603
  };
365
604
  if (merged !== undefined && next.storage.kind !== "replaced") {
366
605
  effects.push({
@@ -370,12 +609,15 @@ export function createSync(spec) {
370
609
  reset: merged.reset,
371
610
  base: msg.since,
372
611
  replaces: at.saved.dataset,
612
+ from: was.kind === "partial" ? was.next : null,
373
613
  });
374
614
  next = { ...next, saved: { ...next.saved, writing: next.saved.writing + 1 } };
375
615
  }
376
616
  if (at.verify.kind === "requested") {
377
- // 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).
378
619
  if (msg.patch.checksum !== undefined &&
620
+ history.kind === "complete" &&
379
621
  replica.revision === msg.patch.revision &&
380
622
  replica.dataset === msg.patch.dataset) {
381
623
  effects.push({ kind: "check", replica, checksum: msg.patch.checksum });
@@ -384,13 +626,21 @@ export function createSync(spec) {
384
626
  next = { ...next, verify: { kind: "pending", tries: at.verify.tries } };
385
627
  }
386
628
  }
387
- 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
+ }
388
637
  // Intents tapped against a store the server no longer has never go to its replacement (the server refuses
389
638
  // them too). One that may already have run there says so.
390
639
  const replaced = answered.commands.map((each) => {
391
640
  if (each.state.kind === "done" ||
392
641
  each.dataset === null ||
393
- each.dataset === msg.patch.dataset) {
642
+ replica.dataset === null ||
643
+ each.dataset === replica.dataset) {
394
644
  return each;
395
645
  }
396
646
  const done = fail(each, each.state.kind === "unknown"
@@ -466,10 +716,12 @@ export function createSync(spec) {
466
716
  }
467
717
  case "committed": {
468
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;
469
721
  return [
470
722
  {
471
723
  ...at,
472
- storage: at.storage.kind === "replaced" ? at.storage : { kind: "ok" },
724
+ storage: at.storage.kind === "replaced" || owed ? at.storage : { kind: "ok" },
473
725
  saved: {
474
726
  dataset: msg.dataset,
475
727
  revision: same ? Math.max(at.saved.revision, msg.revision) : msg.revision,
@@ -477,6 +729,7 @@ export function createSync(spec) {
477
729
  writing: Math.max(0, at.saved.writing - 1),
478
730
  failures: 0,
479
731
  nextAt: now,
732
+ owed,
480
733
  },
481
734
  },
482
735
  [],
@@ -493,6 +746,7 @@ export function createSync(spec) {
493
746
  writing: Math.max(0, at.saved.writing - 1),
494
747
  failures,
495
748
  nextAt: now + backoff(failures),
749
+ owed: true,
496
750
  },
497
751
  },
498
752
  [{ kind: "telemetry", name: "replica.save-failed", attributes: { reason: msg.reason } }],
@@ -677,6 +931,12 @@ export function createSync(spec) {
677
931
  let marked = since === next.saved.behindSince
678
932
  ? next
679
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
+ }
680
940
  // A command that resolves only now (its result came later than its receipt) is drawn its full time from now.
681
941
  const resolved = spec.resolved;
682
942
  if (resolved !== undefined) {