slopkit 0.1.2 → 0.3.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 (41) hide show
  1. package/DEPS.md +2 -1
  2. package/README.md +51 -0
  3. package/dist/client.d.ts +31 -4
  4. package/dist/client.js +25 -2
  5. package/dist/conformance/client.test.js +7 -7
  6. package/dist/conformance/fixtures.d.ts +55 -3
  7. package/dist/conformance/fixtures.js +9 -5
  8. package/dist/conformance/legacy/replica-012.d.ts +52 -0
  9. package/dist/conformance/legacy/replica-012.js +403 -0
  10. package/dist/conformance/outbox-bound.test.d.ts +1 -0
  11. package/dist/conformance/outbox-bound.test.js +268 -0
  12. package/dist/conformance/paging-budget.test.d.ts +1 -0
  13. package/dist/conformance/paging-budget.test.js +101 -0
  14. package/dist/conformance/paging-fixtures.d.ts +80 -0
  15. package/dist/conformance/paging-fixtures.js +135 -0
  16. package/dist/conformance/paging-network.test.d.ts +1 -0
  17. package/dist/conformance/paging-network.test.js +123 -0
  18. package/dist/conformance/paging-state.test.d.ts +1 -0
  19. package/dist/conformance/paging-state.test.js +180 -0
  20. package/dist/conformance/paging-storage.test.d.ts +1 -0
  21. package/dist/conformance/paging-storage.test.js +255 -0
  22. package/dist/conformance/paging.test.d.ts +1 -0
  23. package/dist/conformance/paging.test.js +209 -0
  24. package/dist/conformance/pull.d.ts +74 -0
  25. package/dist/conformance/pull.js +400 -0
  26. package/dist/conformance/push-bytes.test.d.ts +1 -0
  27. package/dist/conformance/push-bytes.test.js +174 -0
  28. package/dist/conformance/push-refusal.test.d.ts +1 -0
  29. package/dist/conformance/push-refusal.test.js +644 -0
  30. package/dist/contract.d.ts +19 -6
  31. package/dist/contract.js +76 -9
  32. package/dist/server/ledger.d.ts +13 -0
  33. package/dist/server/ledger.js +18 -0
  34. package/dist/server/pull.d.ts +85 -0
  35. package/dist/server/pull.js +324 -0
  36. package/dist/server/web.d.ts +9 -5
  37. package/dist/server/web.js +29 -12
  38. package/dist/sync.d.ts +221 -16
  39. package/dist/sync.js +565 -96
  40. package/dist/web/replica.js +93 -16
  41. package/package.json +17 -1
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. */
@@ -49,40 +144,128 @@ const BACKOFF_MAX_MS = 30_000;
49
144
  export const STORAGE_DEADLINE_MS = 3000;
50
145
  /** Commands per push unless the spec says otherwise; the rest wait for the next push. */
51
146
  export const PUSH_MAX = 50;
147
+ /** UTF-8 bytes of one push request unless the spec says otherwise (48 KiB). */
148
+ export const PUSH_BYTES = 49_152;
149
+ /** Commands the outbox admits unless the spec says otherwise; updates to kept ones always are. */
150
+ export const OUTBOX_MAX = 500;
52
151
  /** Settled commands stay drawn this long. */
53
152
  export const DONE_KEEP_MS = 60_000;
153
+ /** A string's length in UTF-8 bytes, as it goes on the wire. */
154
+ function utf8(text) {
155
+ let bytes = 0;
156
+ // By code point: a surrogate pair is one character of four bytes, a lone surrogate encodes as three.
157
+ for (const char of text) {
158
+ const code = char.codePointAt(0) ?? 0;
159
+ bytes += code < 0x80 ? 1 : code < 0x8_00 ? 2 : code < 0x1_00_00 ? 3 : 4;
160
+ }
161
+ return bytes;
162
+ }
163
+ /** A push's bytes as `call` encodes it: this envelope, each command's bytes, and a comma between commands. */
164
+ const envelope = (client, dataset, since) => utf8(JSON.stringify({ client, dataset, since, commands: [] }));
165
+ const bytesOf = (pending) => utf8(JSON.stringify({ id: pending.id, command: pending.command }));
166
+ /**
167
+ * Where the outbox stands: full once it holds `max` commands, by this page's count or, failing that, by the store's
168
+ * last refusal (other tabs' commands count too). Unreadable kept commands take room as well.
169
+ */
170
+ export function deriveOutboxState(model) {
171
+ const finished = model.commands.filter((each) => each.state.kind === "done").length;
172
+ const waiting = model.commands.length - finished + model.unreadable;
173
+ if (waiting + finished >= model.outbox.max) {
174
+ return { kind: "full", waiting, finished, max: model.outbox.max };
175
+ }
176
+ return model.outbox.full ?? { kind: "ok" };
177
+ }
178
+ /** The command a push's schema path names (`request.commands[2].command.value` names 2), if it names one. */
179
+ function commandAt(path) {
180
+ const found = /^request\.commands\[(\d+)\](?:$|[.[])/.exec(path ?? "");
181
+ return found?.[1] === undefined ? undefined : Number(found[1]);
182
+ }
54
183
  /** Whether the server can be asked now. */
55
184
  export function reachable(connection) {
56
185
  return connection.kind === "live" || connection.kind === "stale";
57
186
  }
58
- /** Whether the replica holds this command's effects. */
187
+ /**
188
+ * Whether the replica holds this command's effects: it is done, and the copy is at its revision and either complete or
189
+ * holding the rows its receipt names (a pull reply carried them). One that failed on the page has none.
190
+ */
59
191
  export function settled(model, pending) {
60
- return pending.state.kind === "done" && model.replica.revision >= pending.state.revision;
192
+ return (pending.state.kind === "done" &&
193
+ (pending.state.revision === 0 ||
194
+ (model.replica.revision >= pending.state.revision &&
195
+ (historyOf(model.replica).kind === "complete" ||
196
+ model.carried.has(`${pending.client}:${pending.id}`)))));
61
197
  }
62
- /** Whether what the page shows is also on the disk. */
198
+ /**
199
+ * Whether what the page shows is also on the disk: by revision only, so tabs at different cursors never chase each
200
+ * other's writes. A failed write is owed again even at the same revision (a page that failed to land).
201
+ */
63
202
  export function saved(model) {
64
- return (model.saved.dataset === model.replica.dataset && model.saved.revision >= model.replica.revision);
203
+ return (model.saved.dataset === model.replica.dataset &&
204
+ model.saved.revision >= model.replica.revision &&
205
+ !model.saved.owed);
65
206
  }
66
207
  /**
67
208
  * 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
209
+ * already has this or newer (another tab got there; a complete copy at the same revision is newer than any page), the
210
+ * patch alone when the disk is at least at its base, else the whole replica, so a skipped or failed patch is never
211
+ * covered by a later checkpoint. A delta keeps the disk's own history, and takes the patch's only when the disk is at
212
+ * the cursor the patch moved from (`adopt`): coverage never lands without its rows. A page may replace the disk's
70
213
  * dataset only with one it moved to from that very dataset (`replaces`): a tab still on a dataset the server has since
71
214
  * replaced is `stale` and never writes it back.
72
215
  */
73
216
  export function commitPlan(stored, commit) {
74
217
  const same = stored.dataset === commit.dataset;
218
+ const disk = stored.history ?? COMPLETE;
75
219
  if (!same && stored.dataset !== null && stored.dataset !== commit.replaces) {
76
220
  return "stale";
77
221
  }
78
- if (same && stored.revision >= commit.revision) {
222
+ if (same &&
223
+ (stored.revision > commit.revision ||
224
+ (stored.revision === commit.revision && disk.kind === "complete"))) {
79
225
  return "skip";
80
226
  }
81
227
  if (!commit.reset && same && stored.revision >= commit.base) {
82
- return "delta";
228
+ return disk.kind === "partial" && disk.next === commit.from ? "adopt" : "delta";
83
229
  }
84
230
  return "whole";
85
231
  }
232
+ /**
233
+ * The copy's history: loading while storage loads, none without a dataset, else partial or complete. Partial is a claim
234
+ * beside the numbers ("history from <through>, loading older · held of total"), and totals cover only `through` and
235
+ * above. Complete says nothing about storage: that is `deriveDurabilityState`, its own subject.
236
+ */
237
+ export function deriveHistoryState(model) {
238
+ if (model.boot.kind === "loading") {
239
+ return { kind: "loading" };
240
+ }
241
+ if (model.replica.dataset === null) {
242
+ return { kind: "none" };
243
+ }
244
+ const history = historyOf(model.replica);
245
+ return history.kind === "complete"
246
+ ? history
247
+ : { kind: "partial", through: history.through, held: history.held, total: history.total };
248
+ }
249
+ /**
250
+ * The copy's durability at the model's clock: loading, replaced by another tab, a failing store (lost while the page
251
+ * shows more than the disk holds), unsaved past the storage deadline, commands kept that this version can't read, or ok.
252
+ */
253
+ export function deriveDurabilityState(model) {
254
+ const behind = model.saved.behindSince;
255
+ if (model.boot.kind === "loading") {
256
+ return { kind: "loading" };
257
+ }
258
+ if (model.storage.kind === "replaced") {
259
+ return { kind: "replaced" };
260
+ }
261
+ if (model.storage.kind === "failed") {
262
+ return { kind: behind === null ? "failing" : "lost", reason: model.storage.reason };
263
+ }
264
+ if (behind !== null && model.now - behind >= STORAGE_DEADLINE_MS) {
265
+ return { kind: "unsaved" };
266
+ }
267
+ return model.unreadable > 0 ? { kind: "unreadable", count: model.unreadable } : { kind: "ok" };
268
+ }
86
269
  /**
87
270
  * Storage after a write that worked: a failure that only a keep or a drop had (the replica's own writes aren't
88
271
  * failing) is over; a replaced copy stays replaced.
@@ -100,7 +283,8 @@ function failedConnection(connection, failure) {
100
283
  case "signed-out": {
101
284
  return { kind: "signed-out" };
102
285
  }
103
- case "invalid": {
286
+ case "invalid":
287
+ case "unreadable": {
104
288
  return { kind: "incompatible", failure: failure.message };
105
289
  }
106
290
  case "forbidden":
@@ -117,14 +301,23 @@ function failedConnection(connection, failure) {
117
301
  }
118
302
  export function createSync(spec) {
119
303
  const pushMax = spec.pushMax ?? PUSH_MAX;
120
- if (!Number.isSafeInteger(pushMax) || pushMax < 1) {
121
- throw new Error(`sync: pushMax must be a positive integer, got ${pushMax}`);
304
+ const pushBytes = spec.pushBytes ?? PUSH_BYTES;
305
+ const outboxMax = spec.outboxMax ?? OUTBOX_MAX;
306
+ for (const [name, value] of [
307
+ ["pushMax", pushMax],
308
+ ["pushBytes", pushBytes],
309
+ ["outboxMax", outboxMax],
310
+ ]) {
311
+ if (!Number.isSafeInteger(value) || value < 1) {
312
+ throw new Error(`sync: ${name} must be a positive integer, got ${value}`);
313
+ }
122
314
  }
123
315
  const emptyReplica = () => ({
124
316
  dataset: null,
125
317
  revision: 0,
126
318
  tables: spec.empty,
127
319
  server: null,
320
+ history: COMPLETE,
128
321
  });
129
322
  function initial(client, now) {
130
323
  return {
@@ -140,14 +333,19 @@ export function createSync(spec) {
140
333
  writing: 0,
141
334
  failures: 0,
142
335
  nextAt: now,
336
+ owed: false,
143
337
  },
144
338
  target: 0,
339
+ restart: null,
145
340
  reconciledAt: null,
146
341
  connection: { kind: "starting" },
147
342
  socket: { open: false, heardAt: 0 },
148
343
  pull: { kind: "idle", nextAt: now, failures: 0 },
149
344
  push: { kind: "idle", nextAt: now, failures: 0 },
150
345
  commands: [],
346
+ outbox: { max: outboxMax, full: null },
347
+ asking: 0,
348
+ carried: new Set(),
151
349
  unreadable: 0,
152
350
  visible: true,
153
351
  verify: { kind: "pending", tries: 0 },
@@ -155,22 +353,37 @@ export function createSync(spec) {
155
353
  };
156
354
  }
157
355
  /**
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.
356
+ * The one merge: a patch applied to a replica, with its disk operations; undefined when it is older than the replica
357
+ * (a late reply), `gap` when it can't follow from it. Only a reset (or the answer to `since` 0, which holds
358
+ * everything) replaces the copy, with its own history. A delta applies only to the same dataset with
359
+ * `since ≤ copy.revision ≤ reply.revision` (equal only with a page or new server facts); it moves the cursor only when
360
+ * it answers the copy's own `next`, and a reply without a page never changes the history.
160
361
  */
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 };
362
+ function merge(replica, patch, since) {
363
+ const reset = patch.reset || since === 0;
364
+ const page = patch.page;
365
+ const was = historyOf(replica);
366
+ let history = was;
367
+ if (reset) {
368
+ // A reset older than what we hold of the same dataset is a late reply.
369
+ if (patch.dataset === replica.dataset && patch.revision < replica.revision) {
370
+ return undefined;
168
371
  }
169
- return undefined;
372
+ history = covered(page);
170
373
  }
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;
374
+ else if (patch.dataset !== replica.dataset || since > replica.revision) {
375
+ return "gap";
376
+ }
377
+ else if (patch.revision < replica.revision ||
378
+ (patch.revision === replica.revision && (page === undefined || was.kind === "complete"))) {
379
+ // Nothing newer, but the server's own facts (version, settings) may have moved.
380
+ return patch.revision === replica.revision &&
381
+ JSON.stringify(patch.server) !== JSON.stringify(replica.server)
382
+ ? { replica: { ...replica, server: patch.server }, ops: [], reset: false }
383
+ : undefined;
384
+ }
385
+ else if (page !== undefined && was.kind === "partial" && page.from === was.next) {
386
+ history = covered(page);
174
387
  }
175
388
  let tables = reset ? spec.empty : replica.tables;
176
389
  const ops = [];
@@ -179,12 +392,51 @@ export function createSync(spec) {
179
392
  ops.push(...lens.ops(patch.rows));
180
393
  }
181
394
  return {
182
- replica: { dataset: patch.dataset, revision: patch.revision, server: patch.server, tables },
395
+ replica: {
396
+ dataset: patch.dataset,
397
+ revision: patch.revision,
398
+ server: patch.server,
399
+ tables,
400
+ history,
401
+ },
183
402
  ops,
184
403
  reset,
185
404
  };
186
405
  }
187
406
  const ready = (model) => model.boot.kind === "ready";
407
+ const keep = (pending) => ({ kind: "keep", pending, max: outboxMax });
408
+ /** Waiting to be sent: queued, or unknown and not parked (asked again under its key). */
409
+ const sendable = (each) => each.state.kind === "queued" || (each.state.kind === "unknown" && each.state.parked !== true);
410
+ const tooLarge = (bytes) => `this is too large to send: ${bytes} bytes, where a push holds at most ${pushBytes}`;
411
+ /** A command that failed on the page: done with that outcome, never sent, and its kept intent dropped. */
412
+ function fail(pending, message, now) {
413
+ return {
414
+ ...pending,
415
+ at: now,
416
+ state: { kind: "done", outcome: spec.refused(pending.command, message), revision: 0 },
417
+ };
418
+ }
419
+ /**
420
+ * A command the server refused by name, or one too large to send: final if no attempt of it may have acted; else
421
+ * parked, since a refusal proves only that attempt didn't act. Kept either way, and passed over by every push.
422
+ */
423
+ function refuse(pending, doubt, message, now, effects) {
424
+ if (doubt) {
425
+ const parked = {
426
+ ...pending,
427
+ state: { kind: "unknown", failure: message, parked: true },
428
+ };
429
+ effects.push(keep(parked), {
430
+ kind: "telemetry",
431
+ name: "command.parked",
432
+ attributes: { reason: message },
433
+ });
434
+ return parked;
435
+ }
436
+ const done = fail(pending, message, now);
437
+ effects.push(keep(done), { kind: "answered", pending: done });
438
+ return done;
439
+ }
188
440
  /** Start what is due: the obligations are read off the model after every message, never remembered. */
189
441
  function schedule(model, effects) {
190
442
  let next = model;
@@ -196,56 +448,103 @@ export function createSync(spec) {
196
448
  const behind = next.replica.revision < next.target;
197
449
  const unsettled = next.commands.some((each) => each.state.kind === "done" && !settled(next, each));
198
450
  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)) {
451
+ const history = historyOf(next.replica);
452
+ const whole = next.restart === "whole";
453
+ // Only a complete copy is checked: a fingerprint of everything can't certify part of it.
454
+ const verifying = next.verify.kind === "pending" && (history.kind === "complete" || whole);
455
+ // A partial copy owes its next page, back to back, until it is complete.
456
+ const owed = behind || verifying || history.kind === "partial" || next.restart !== null;
457
+ const since = next.restart === null ? next.replica.revision : 0;
458
+ if ((owed && (failures === 0 || due)) || (polling && due)) {
459
+ const current = next;
460
+ // Commands waiting on an outcome or on their effects, at most a push's worth: their receipts come back with
461
+ // the rows they name. When more wait, each pull names the next ones round, so every one is asked about in
462
+ // turn (a parked command has no other way to settle).
463
+ const waiting = current.commands
464
+ .filter((each) => each.state.kind === "sending" ||
465
+ each.state.kind === "unknown" ||
466
+ (each.state.kind === "done" && !settled(current, each)))
467
+ .map((each) => ({ client: each.client, id: each.id }));
468
+ const turn = waiting.length > pushMax ? current.asking % waiting.length : 0;
469
+ const commands = [...waiting.slice(turn), ...waiting.slice(0, turn)].slice(0, pushMax);
201
470
  out.push({
202
471
  kind: "pull",
203
472
  client: next.client,
204
473
  dataset: next.replica.dataset,
205
- since: next.replica.revision,
474
+ since,
206
475
  verify: verifying,
476
+ ...(whole ? {} : { page: history.kind === "partial" && since > 0 ? history.next : null }),
477
+ commands,
207
478
  });
208
479
  next = {
209
480
  ...next,
210
- pull: { kind: "pulling", since: next.replica.revision, failures },
211
- verify: next.verify.kind === "pending"
481
+ asking: waiting.length > pushMax ? turn + pushMax : 0,
482
+ pull: { kind: "pulling", since, failures },
483
+ verify: verifying && next.verify.kind === "pending"
212
484
  ? { kind: "requested", tries: next.verify.tries }
213
485
  : next.verify,
214
486
  };
215
487
  }
216
488
  }
217
489
  if (next.push.kind === "idle" && now >= next.push.nextAt && reachable(next.connection)) {
218
- const first = next.commands.find((each) => each.state.kind === "queued" || each.state.kind === "unknown");
490
+ const tapped = (each) => each.since ?? next.replica.revision;
491
+ let first = next.commands.find(sendable);
492
+ // A kept command too large to send even alone (an older release kept it, or the bound was lowered) is refused,
493
+ // or parked if an attempt may have acted, and passed over: it never strands the commands behind it.
494
+ while (first !== undefined) {
495
+ const alone = envelope(first.client, first.dataset, tapped(first)) + bytesOf(first);
496
+ if (alone <= pushBytes) {
497
+ break;
498
+ }
499
+ const large = first;
500
+ const told = [];
501
+ const withdrawn = refuse(large, large.state.kind === "unknown", tooLarge(alone), now, told);
502
+ out.push(...told);
503
+ next = {
504
+ ...next,
505
+ commands: next.commands.map((each) => (each === large ? withdrawn : each)),
506
+ };
507
+ first = next.commands.find(sendable);
508
+ }
219
509
  if (first !== undefined) {
220
510
  // One push per client, dataset and tapped-against revision: the server judges each by what it was made on.
221
- // At most `pushMax`, oldest first; the rest go in the next push.
222
- const tapped = (each) => each.since ?? next.replica.revision;
223
- const ids = new Set(next.commands
224
- .filter((each) => each.client === first.client &&
225
- each.dataset === first.dataset &&
226
- tapped(each) === tapped(first) &&
227
- (each.state.kind === "queued" || each.state.kind === "unknown"))
228
- .slice(0, pushMax)
229
- .map((each) => each.id));
230
- const mine = (each) => each.client === first.client && ids.has(each.id);
511
+ // Oldest first, at most `pushMax` commands and `pushBytes` bytes: it stops at the first command that doesn't
512
+ // fit, never skipping ahead to a smaller one, and the rest go in the next push.
513
+ const lead = first;
514
+ const batch = [];
515
+ let bytes = envelope(lead.client, lead.dataset, tapped(lead));
516
+ for (const each of next.commands) {
517
+ if (each.client !== lead.client ||
518
+ each.dataset !== lead.dataset ||
519
+ tapped(each) !== tapped(lead) ||
520
+ !sendable(each)) {
521
+ continue;
522
+ }
523
+ const size = bytesOf(each) + (batch.length > 0 ? 1 : 0);
524
+ if (batch.length >= pushMax || bytes + size > pushBytes) {
525
+ break;
526
+ }
527
+ bytes += size;
528
+ batch.push(each);
529
+ }
530
+ const sent = new Set(batch);
231
531
  out.push({
232
532
  kind: "push",
233
- client: first.client,
234
- dataset: first.dataset,
235
- since: tapped(first),
236
- commands: next.commands
237
- .filter(mine)
238
- .map((each) => ({ id: each.id, command: each.command })),
533
+ client: lead.client,
534
+ dataset: lead.dataset,
535
+ since: tapped(lead),
536
+ commands: batch.map((each) => ({ id: each.id, command: each.command })),
239
537
  });
240
538
  next = {
241
539
  ...next,
242
540
  push: {
243
541
  kind: "pushing",
244
- client: first.client,
245
- ids: [...ids],
542
+ client: lead.client,
543
+ ids: batch.map((each) => each.id),
544
+ was: batch.map((each) => each.state),
246
545
  failures: next.push.failures,
247
546
  },
248
- commands: next.commands.map((each) => mine(each) ? { ...each, state: { kind: "sending" } } : each),
547
+ commands: next.commands.map((each) => sent.has(each) ? { ...each, state: { kind: "sending" } } : each),
249
548
  };
250
549
  }
251
550
  }
@@ -289,19 +588,16 @@ export function createSync(spec) {
289
588
  at: model.now,
290
589
  state: { kind: "done", outcome: receipt.outcome, revision: receipt.revision },
291
590
  };
292
- effects.push({ kind: "keep", pending: done }, { kind: "answered", pending: done }, { kind: "telemetry", name: "command.done", attributes: { ms: model.now - each.at } });
591
+ effects.push(keep(done), { kind: "answered", pending: done }, { kind: "telemetry", name: "command.done", attributes: { ms: model.now - each.at } });
293
592
  return done;
294
593
  });
295
594
  const target = Math.max(model.target, ...receipts.map((each) => each.revision));
296
595
  return [{ ...model, commands, target }, effects];
297
596
  }
298
- /** A command that failed on the page: done with that outcome, never sent, and its kept intent dropped. */
299
- function fail(pending, message, now) {
300
- return {
301
- ...pending,
302
- at: now,
303
- state: { kind: "done", outcome: spec.refused(pending.command, message), revision: 0 },
304
- };
597
+ /** Each command's state before the push in flight for `client`; unknown when this page can't tell. */
598
+ function prior(model, client) {
599
+ const pushing = model.push.kind === "pushing" && model.push.client === client ? model.push : null;
600
+ return (each) => pushing?.was[pushing.ids.indexOf(each.id)] ?? { kind: "unknown", failure: "sent before" };
305
601
  }
306
602
  function reduce(model, msg) {
307
603
  const now = msg.now;
@@ -355,20 +651,60 @@ export function createSync(spec) {
355
651
  ];
356
652
  }
357
653
  case "pulled": {
358
- const merged = merge(at.replica, msg.patch);
654
+ const result = merge(at.replica, msg.patch, msg.since);
655
+ const merged = result === "gap" ? undefined : result;
359
656
  const replica = merged?.replica ?? at.replica;
360
657
  const effects = [];
658
+ const was = historyOf(at.replica);
659
+ const history = historyOf(replica);
660
+ const failures = at.pull.kind === "pulling" ? at.pull.failures : 0;
661
+ // An old server answering a partial copy (with rows or without): only its whole copy completes it.
662
+ const old = was.kind === "partial" &&
663
+ msg.patch.page === undefined &&
664
+ !msg.patch.reset &&
665
+ msg.since > 0 &&
666
+ result !== "gap";
667
+ // A partial copy this reply didn't move on (the same page again, a repeated first page at the same revision, a
668
+ // reply it couldn't take) is a pull that failed, for backoff: never a busy loop on a server that doesn't move.
669
+ const stuck = was.kind === "partial" &&
670
+ !old &&
671
+ (merged === undefined ||
672
+ (msg.patch.page !== undefined &&
673
+ history.kind === "partial" &&
674
+ history.next === was.next &&
675
+ replica.dataset === at.replica.dataset &&
676
+ !(merged.reset && replica.revision > at.replica.revision)));
677
+ // Receipts that came with an applied reply came with the rows they name.
678
+ const carried = new Set(merged?.reset === true ? [] : at.carried);
679
+ if (merged !== undefined) {
680
+ for (const each of msg.receipts) {
681
+ if (each.revision <= replica.revision) {
682
+ carried.add(`${each.client ?? at.client}:${each.id}`);
683
+ }
684
+ }
685
+ }
361
686
  let next = {
362
687
  ...at,
363
688
  replica,
689
+ carried,
364
690
  target: msg.patch.dataset === at.replica.dataset
365
691
  ? Math.max(at.target, msg.patch.revision)
366
692
  : msg.patch.revision,
693
+ // A reply it couldn't follow asks a real reset; an old server's reply to a partial copy, its whole copy.
694
+ restart: result === "gap"
695
+ ? (at.restart ?? "paged")
696
+ : merged?.reset === true
697
+ ? null
698
+ : old
699
+ ? "whole"
700
+ : at.restart,
367
701
  reconciledAt: now,
368
702
  connection: at.socket.open && now - at.socket.heardAt < SOCKET_DEADLINE_MS
369
703
  ? { kind: "live" }
370
704
  : { kind: "stale" },
371
- pull: { kind: "idle", nextAt: now + POLL_MS, failures: 0 },
705
+ pull: stuck
706
+ ? { kind: "idle", nextAt: now + backoff(failures + 1), failures: failures + 1 }
707
+ : { kind: "idle", nextAt: now + POLL_MS, failures: 0 },
372
708
  };
373
709
  if (merged !== undefined && next.storage.kind !== "replaced") {
374
710
  effects.push({
@@ -378,12 +714,15 @@ export function createSync(spec) {
378
714
  reset: merged.reset,
379
715
  base: msg.since,
380
716
  replaces: at.saved.dataset,
717
+ from: was.kind === "partial" ? was.next : null,
381
718
  });
382
719
  next = { ...next, saved: { ...next.saved, writing: next.saved.writing + 1 } };
383
720
  }
384
721
  if (at.verify.kind === "requested") {
385
- // The fingerprint describes the server at the reply's revision: comparable only if the replica is there.
722
+ // The fingerprint describes the server at the reply's revision: comparable only if the replica is there, and
723
+ // whole (a fingerprint of everything never certifies part of it).
386
724
  if (msg.patch.checksum !== undefined &&
725
+ history.kind === "complete" &&
387
726
  replica.revision === msg.patch.revision &&
388
727
  replica.dataset === msg.patch.dataset) {
389
728
  effects.push({ kind: "check", replica, checksum: msg.patch.checksum });
@@ -392,30 +731,39 @@ export function createSync(spec) {
392
731
  next = { ...next, verify: { kind: "pending", tries: at.verify.tries } };
393
732
  }
394
733
  }
395
- const [answered, more] = answer(next, next.client, msg.receipts);
734
+ // Receipts by client: an earlier page's commands are answered too.
735
+ let answered = next;
736
+ const more = [];
737
+ for (const client of new Set(msg.receipts.map((each) => each.client ?? at.client))) {
738
+ const [after, told] = answer(answered, client, msg.receipts.filter((each) => (each.client ?? at.client) === client));
739
+ answered = after;
740
+ more.push(...told);
741
+ }
396
742
  // Intents tapped against a store the server no longer has never go to its replacement (the server refuses
397
743
  // them too). One that may already have run there says so.
398
744
  const replaced = answered.commands.map((each) => {
399
745
  if (each.state.kind === "done" ||
400
746
  each.dataset === null ||
401
- each.dataset === msg.patch.dataset) {
747
+ replica.dataset === null ||
748
+ each.dataset === replica.dataset) {
402
749
  return each;
403
750
  }
404
751
  const done = fail(each, each.state.kind === "unknown"
405
752
  ? "the server's data was replaced; whether this ran before is unknown"
406
753
  : "the server's data was replaced since this was tapped, so it wasn't sent", now);
407
- more.push({ kind: "keep", pending: done }, { kind: "answered", pending: done });
754
+ more.push(keep(done), { kind: "answered", pending: done });
408
755
  return done;
409
756
  });
410
757
  return [{ ...answered, commands: replaced }, [...effects, ...more]];
411
758
  }
412
759
  case "pullFailed": {
413
760
  const failures = (at.pull.kind === "pulling" ? at.pull.failures : 0) + 1;
761
+ const wait = Math.max(backoff(failures), msg.failure.retryAfterMs ?? 0);
414
762
  return [
415
763
  {
416
764
  ...at,
417
765
  connection: failedConnection(at.connection, msg.failure),
418
- pull: { kind: "idle", nextAt: now + backoff(failures), failures },
766
+ pull: { kind: "idle", nextAt: now + wait, failures },
419
767
  verify: at.verify.kind === "requested"
420
768
  ? { kind: "pending", tries: at.verify.tries }
421
769
  : at.verify,
@@ -426,7 +774,8 @@ export function createSync(spec) {
426
774
  case "pushed": {
427
775
  const ids = new Set(msg.ids);
428
776
  const [answered, effects] = answer(at, msg.client, msg.receipts, ids);
429
- // A command the reply has no receipt for is still acting elsewhere: asked again.
777
+ // A command the reply has no receipt for is still acting elsewhere: asked again. The disk holds its in-flight
778
+ // mark, so a reload knows it too.
430
779
  const commands = answered.commands.map((each) => each.client === msg.client && ids.has(each.id) && each.state.kind === "sending"
431
780
  ? { ...each, state: { kind: "unknown", failure: "the server is still on it" } }
432
781
  : each);
@@ -442,42 +791,108 @@ export function createSync(spec) {
442
791
  ];
443
792
  }
444
793
  case "pushFailed": {
445
- // Refused before anything acted: final. Anything else may have acted (an unreadable reply included), so it is
446
- // unknown and asked again under the same key, which answers with the first outcome.
447
- const final = msg.failure.kind === "forbidden" || msg.failure.kind === "conflict";
794
+ const failure = msg.failure;
448
795
  const ids = new Set(msg.ids);
796
+ const before = prior(at, msg.client);
449
797
  const effects = [];
798
+ // The server's own refusal of a request it never ran (its schema is checked before the handler, and a handler
799
+ // never refuses after anything acted): this attempt didn't act, though an earlier one may have.
800
+ const refused = failure.refused === true &&
801
+ (failure.kind === "invalid" ||
802
+ failure.kind === "forbidden" ||
803
+ failure.kind === "conflict");
804
+ // A schema refusal naming one of this push's commands is that command's alone.
805
+ const index = refused && failure.kind === "invalid" ? commandAt(failure.path) : undefined;
806
+ const named = index === undefined ? undefined : msg.ids[index];
450
807
  const commands = at.commands.map((each) => {
451
- if (each.client !== msg.client || !ids.has(each.id) || each.state.kind === "done") {
808
+ if (each.client !== msg.client || !ids.has(each.id) || each.state.kind !== "sending") {
452
809
  return each;
453
810
  }
454
- if (!final) {
455
- return { ...each, state: { kind: "unknown", failure: msg.failure.message } };
811
+ const was = before(each);
812
+ // An attempt whose outcome was lost may have acted: no later refusal makes it final.
813
+ const doubt = was.kind === "unknown";
814
+ if (named !== undefined) {
815
+ return each.id === named
816
+ ? refuse(each, doubt, failure.message, now, effects)
817
+ : { ...each, state: was };
456
818
  }
457
- const done = fail(each, msg.failure.message, now);
458
- effects.push({ kind: "keep", pending: done }, { kind: "answered", pending: done });
459
- return done;
819
+ // A kit forbidden or conflict: nothing in this attempt acted. Final only for a command no attempt of which
820
+ // may have acted.
821
+ if (refused && failure.kind !== "invalid") {
822
+ return doubt
823
+ ? { ...each, state: was }
824
+ : refuse(each, false, failure.message, now, effects);
825
+ }
826
+ // An envelope refusal (a page and server of different releases): nothing acted, and the page stops. The disk
827
+ // holds the in-flight mark: a command never uncertain is kept as it was again.
828
+ if (refused) {
829
+ const restored = { ...each, state: was };
830
+ if (!doubt) {
831
+ effects.push(keep(restored));
832
+ }
833
+ return restored;
834
+ }
835
+ // Anything else may have acted (a lost reply, an unreadable one, a proxy's answer): asked again, same key.
836
+ // The disk holds its in-flight mark.
837
+ return { ...each, state: { kind: "unknown", failure: failure.message } };
460
838
  });
461
- const failures = (at.push.kind === "pushing" ? at.push.failures : 0) + 1;
839
+ const telemetry = {
840
+ kind: "telemetry",
841
+ name: "push.failed",
842
+ attributes: {
843
+ kind: failure.kind,
844
+ refused: failure.refused === true,
845
+ path: failure.path ?? null,
846
+ },
847
+ };
848
+ const pushing = at.push.kind === "pushing" ? at.push.failures : 0;
849
+ // One command refused by name: the rest go again at once, without it.
850
+ if (named !== undefined) {
851
+ return [
852
+ { ...at, commands, push: { kind: "idle", nextAt: now, failures: pushing } },
853
+ [...effects, telemetry],
854
+ ];
855
+ }
856
+ const failures = pushing + 1;
462
857
  return [
463
858
  {
464
859
  ...at,
465
860
  commands,
466
- connection: failedConnection(at.connection, msg.failure),
861
+ connection: failedConnection(at.connection, failure),
862
+ push: {
863
+ kind: "idle",
864
+ nextAt: now + Math.max(backoff(failures), failure.retryAfterMs ?? 0),
865
+ failures,
866
+ },
867
+ },
868
+ [...effects, telemetry],
869
+ ];
870
+ }
871
+ case "unsent": {
872
+ // Never sent: each command is what it was before, and goes again once the disk keeps its mark.
873
+ const ids = new Set(msg.ids);
874
+ const before = prior(at, msg.client);
875
+ const failures = (at.push.kind === "pushing" ? at.push.failures : 0) + 1;
876
+ return [
877
+ {
878
+ ...at,
879
+ commands: at.commands.map((each) => each.client === msg.client && ids.has(each.id) && each.state.kind === "sending"
880
+ ? { ...each, state: before(each) }
881
+ : each),
882
+ storage: msg.replaced ? { kind: "replaced" } : { kind: "failed", reason: msg.reason },
467
883
  push: { kind: "idle", nextAt: now + backoff(failures), failures },
468
884
  },
469
- [
470
- ...effects,
471
- { kind: "telemetry", name: "push.failed", attributes: { kind: msg.failure.kind } },
472
- ],
885
+ [{ kind: "telemetry", name: "push.unsent", attributes: { reason: msg.reason } }],
473
886
  ];
474
887
  }
475
888
  case "committed": {
476
889
  const same = msg.dataset === at.saved.dataset;
890
+ // A delta the disk took under its own history doesn't repair a failed write: the whole one stays owed.
891
+ const owed = at.saved.owed && msg.whole === false;
477
892
  return [
478
893
  {
479
894
  ...at,
480
- storage: at.storage.kind === "replaced" ? at.storage : { kind: "ok" },
895
+ storage: at.storage.kind === "replaced" || owed ? at.storage : { kind: "ok" },
481
896
  saved: {
482
897
  dataset: msg.dataset,
483
898
  revision: same ? Math.max(at.saved.revision, msg.revision) : msg.revision,
@@ -485,6 +900,7 @@ export function createSync(spec) {
485
900
  writing: Math.max(0, at.saved.writing - 1),
486
901
  failures: 0,
487
902
  nextAt: now,
903
+ owed,
488
904
  },
489
905
  },
490
906
  [],
@@ -501,16 +917,57 @@ export function createSync(spec) {
501
917
  writing: Math.max(0, at.saved.writing - 1),
502
918
  failures,
503
919
  nextAt: now + backoff(failures),
920
+ owed: true,
504
921
  },
505
922
  },
506
923
  [{ kind: "telemetry", name: "replica.save-failed", attributes: { reason: msg.reason } }],
507
924
  ];
508
925
  }
509
926
  case "kept": {
510
- const commands = at.commands.map((each) => each.client === msg.client && each.id === msg.id && each.state.kind === "saving"
511
- ? { ...each, state: { kind: "queued" } }
512
- : each);
513
- return [{ ...at, commands, storage: working(at) }, []];
927
+ let admitted = false;
928
+ const commands = at.commands.map((each) => {
929
+ if (each.client !== msg.client || each.id !== msg.id || each.state.kind !== "saving") {
930
+ return each;
931
+ }
932
+ admitted = true;
933
+ return { ...each, state: { kind: "queued" } };
934
+ });
935
+ return [
936
+ {
937
+ ...at,
938
+ commands,
939
+ storage: working(at),
940
+ outbox: admitted ? { ...at.outbox, full: null } : at.outbox,
941
+ },
942
+ [],
943
+ ];
944
+ }
945
+ case "full": {
946
+ // Not a storage failure: the store answered, with no room. The tap is answered and not retained.
947
+ const gone = at.commands.find((each) => each.client === msg.client && each.id === msg.id && each.state.kind === "saving");
948
+ if (gone === undefined) {
949
+ return [{ ...at, storage: working(at) }, []];
950
+ }
951
+ const refused = fail(gone, "this device's outbox is full, so it wasn't kept or sent", now);
952
+ return [
953
+ {
954
+ ...at,
955
+ commands: at.commands.filter((each) => each !== gone),
956
+ storage: working(at),
957
+ outbox: {
958
+ ...at.outbox,
959
+ full: { kind: "full", waiting: msg.waiting, finished: msg.finished, max: msg.max },
960
+ },
961
+ },
962
+ [
963
+ { kind: "answered", pending: refused },
964
+ {
965
+ kind: "telemetry",
966
+ name: "commands.full",
967
+ attributes: { waiting: msg.waiting, finished: msg.finished, max: msg.max },
968
+ },
969
+ ],
970
+ ];
514
971
  }
515
972
  case "keepFailed": {
516
973
  const effects = [
@@ -528,7 +985,7 @@ export function createSync(spec) {
528
985
  }
529
986
  case "dropped": {
530
987
  if (msg.failure === null) {
531
- return [{ ...at, storage: working(at) }, []];
988
+ return [{ ...at, storage: working(at), outbox: { ...at.outbox, full: null } }, []];
532
989
  }
533
990
  return [
534
991
  {
@@ -625,9 +1082,15 @@ export function createSync(spec) {
625
1082
  if (joined) {
626
1083
  return [at, []];
627
1084
  }
1085
+ // A tap no push could carry is answered now, never kept.
1086
+ const alone = envelope(at.client, pending.dataset, at.replica.revision) + bytesOf(pending);
1087
+ if (alone > pushBytes) {
1088
+ return [numbered, [{ kind: "answered", pending: fail(pending, tooLarge(alone), now) }]];
1089
+ }
1090
+ // Admitted by the store, inside the keep's own transaction: a full outbox answers `full`, and the tap goes.
628
1091
  return [
629
1092
  { ...numbered, commands: [...at.commands, pending] },
630
- [{ kind: "keep", pending: { ...pending, state: { kind: "queued" } } }],
1093
+ [keep({ ...pending, state: { kind: "queued" } })],
631
1094
  ];
632
1095
  }
633
1096
  case "dismiss": {
@@ -685,6 +1148,12 @@ export function createSync(spec) {
685
1148
  let marked = since === next.saved.behindSince
686
1149
  ? next
687
1150
  : { ...next, saved: { ...next.saved, behindSince: since } };
1151
+ // Commands that left the page leave what was carried for them.
1152
+ const kept = new Set(marked.commands.map((each) => `${each.client}:${each.id}`));
1153
+ const carried = [...marked.carried].filter((key) => kept.has(key));
1154
+ if (carried.length < marked.carried.size) {
1155
+ marked = { ...marked, carried: new Set(carried) };
1156
+ }
688
1157
  // A command that resolves only now (its result came later than its receipt) is drawn its full time from now.
689
1158
  const resolved = spec.resolved;
690
1159
  if (resolved !== undefined) {