slopkit 0.2.0 → 0.4.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/DEPS.md +5 -3
  2. package/README.md +103 -1
  3. package/RULES.md +26 -6
  4. package/dist/api.d.ts +93 -0
  5. package/dist/api.js +111 -0
  6. package/dist/client.d.ts +21 -4
  7. package/dist/client.js +21 -2
  8. package/dist/conformance/agent-access.test.d.ts +1 -0
  9. package/dist/conformance/agent-access.test.js +74 -0
  10. package/dist/conformance/client.test.js +7 -7
  11. package/dist/conformance/fixtures.d.ts +1 -0
  12. package/dist/conformance/fixtures.js +5 -1
  13. package/dist/conformance/outbox-bound.test.d.ts +1 -0
  14. package/dist/conformance/outbox-bound.test.js +268 -0
  15. package/dist/conformance/paging-state.test.js +1 -0
  16. package/dist/conformance/push-bytes.test.d.ts +1 -0
  17. package/dist/conformance/push-bytes.test.js +174 -0
  18. package/dist/conformance/push-refusal.test.d.ts +1 -0
  19. package/dist/conformance/push-refusal.test.js +644 -0
  20. package/dist/conformance/states.test.js +19 -8
  21. package/dist/contract.d.ts +19 -6
  22. package/dist/contract.js +76 -9
  23. package/dist/server/auth.d.ts +4 -3
  24. package/dist/server/auth.js +14 -6
  25. package/dist/server/cli.d.ts +117 -0
  26. package/dist/server/cli.js +1534 -0
  27. package/dist/server/config.d.ts +4 -1
  28. package/dist/server/config.js +2 -2
  29. package/dist/server/keys.d.ts +17 -0
  30. package/dist/server/keys.js +127 -0
  31. package/dist/server/pull.js +158 -67
  32. package/dist/server/store.d.ts +101 -0
  33. package/dist/server/store.js +347 -0
  34. package/dist/server/sync.d.ts +158 -0
  35. package/dist/server/sync.js +293 -0
  36. package/dist/server/web.d.ts +62 -9
  37. package/dist/server/web.js +260 -35
  38. package/dist/states.d.ts +52 -4
  39. package/dist/states.js +20 -0
  40. package/dist/sync.d.ts +86 -2
  41. package/dist/sync.js +277 -59
  42. package/dist/test/agents.d.ts +19 -0
  43. package/dist/test/agents.js +62 -0
  44. package/dist/test/page.d.ts +91 -0
  45. package/dist/test/page.js +763 -0
  46. package/dist/test/states.d.ts +44 -7
  47. package/dist/test/states.js +152 -0
  48. package/dist/web/replica.d.ts +7 -0
  49. package/dist/web/replica.js +260 -47
  50. package/dist/web/runtime.d.ts +102 -0
  51. package/dist/web/runtime.js +335 -0
  52. package/package.json +39 -1
package/dist/sync.d.ts CHANGED
@@ -27,6 +27,8 @@
27
27
  * - **A command id is never reused.** Each page load is a new client with its own numbers; commands loaded from an
28
28
  * earlier page keep theirs.
29
29
  * - **Nothing is sent before it is kept.** An intent the device can't keep fails at once, visibly, and is never sent.
30
+ * A push is journalled before it goes: its commands are kept as `sending` (one write per push), so a page that dies
31
+ * mid-push leaves them uncertain on disk, and none goes unless that write lands.
30
32
  * - **Every command reaches an outcome, and is settled only once the replica holds its effects** (a receipt names the
31
33
  * revision that made it visible; on a partial copy, only a pull reply that carried the receipt, with the rows it
32
34
  * names, settles it); until then a pull is owed, and names the commands it waits on, any page's.
@@ -34,6 +36,16 @@
34
36
  * to load or keep is said (the page goes on from the server meanwhile), and its late answer still counts: a slow
35
37
  * write is not a failed one.
36
38
  * - **An intent belongs to the dataset it was made against**: the server refuses it against a replacement.
39
+ * - **A refusal proves only that attempt didn't act.** Only the kit's own error body is final; anything else may have
40
+ * acted and leaves the command unknown, kept so. A command an attempt of which may have acted never becomes a final
41
+ * failure: a refusal naming it parks it (never sent again, still named in pulls, done when a receipt comes). A schema
42
+ * refusal naming one command of a push is that command's alone; the rest return to what they were and go again.
43
+ * - **A push fits the server's bounds**: at most `pushMax` commands and `pushBytes` bytes, oldest first, stopping at
44
+ * the first command that doesn't fit. One too large even alone is answered at the tap and never kept; one already
45
+ * kept is refused (or parked) and passed over, so it never strands the rest.
46
+ * - **The outbox is bounded across tabs.** The store admits a new command inside its own write transaction, from its
47
+ * own count; one it has no room for is answered and never retained. Kept commands always update and are never
48
+ * dropped for room.
37
49
  */
38
50
  import type { Failure } from "./contract.ts";
39
51
  import type { Schema } from "./schema.ts";
@@ -138,9 +150,16 @@ export type CommandState<O> = {
138
150
  kind: "queued";
139
151
  } | {
140
152
  kind: "sending";
141
- } | {
153
+ }
154
+ /**
155
+ * An attempt's outcome was lost: it may have acted. `parked`: the server refused it by name since, which proves only
156
+ * that attempt didn't act, so it is never sent again and waits on a pull to bring the receipt an earlier one left; it
157
+ * needs the person's attention meanwhile.
158
+ */
159
+ | {
142
160
  kind: "unknown";
143
161
  failure: string;
162
+ parked?: true;
144
163
  } | {
145
164
  kind: "done";
146
165
  outcome: O;
@@ -176,6 +195,22 @@ export type Connection = {
176
195
  kind: "incompatible";
177
196
  failure: string;
178
197
  };
198
+ /**
199
+ * The outbox's room, as a closed subject for the app's state table: full when it holds `max` commands. `waiting`
200
+ * (unsent or unknown) needs a connection; `finished` (done, kept until dismissed) needs the person to dismiss them.
201
+ */
202
+ export type OutboxState = {
203
+ kind: "ok";
204
+ } | {
205
+ kind: "full";
206
+ waiting: number;
207
+ finished: number;
208
+ max: number;
209
+ };
210
+ /** The store's answer to a new command it had no room for: its own counts, across every tab. */
211
+ export type OutboxFull = Extract<OutboxState, {
212
+ kind: "full";
213
+ }>;
179
214
  /** This device's copy: fine, failing (writes are retried), or forgotten under this page by another tab. */
180
215
  export type Storage = {
181
216
  kind: "ok";
@@ -241,9 +276,21 @@ export type Model<T, S, C, O> = {
241
276
  kind: "pushing";
242
277
  client: string;
243
278
  ids: readonly number[];
279
+ /** Each command's state before this attempt, by position in `ids`: what a refusal restores. */
280
+ was: readonly CommandState<O>[];
244
281
  failures: number;
245
282
  };
246
283
  commands: readonly Pending<C, O>[];
284
+ /**
285
+ * The most commands the outbox admits, and the store's counts when it last refused one for room (it is shared with
286
+ * other tabs, whose commands this page doesn't hold); cleared once one is admitted or this page drops some.
287
+ */
288
+ outbox: {
289
+ max: number;
290
+ full: OutboxFull | null;
291
+ };
292
+ /** Where the next pull's list of waiting commands starts, when more wait than one pull names. */
293
+ asking: number;
247
294
  /** Commands (`client:id`) whose receipt came in a pull reply this copy applied, so with the rows it names. */
248
295
  carried: ReadonlySet<string>;
249
296
  /** Commands an earlier page kept that this version can't read: left on disk, and said. */
@@ -270,6 +317,8 @@ export type Msg<T, P, S, C, O> = {
270
317
  replica: Replica<T, S>;
271
318
  commands: Pending<C, O>[];
272
319
  unreadable: number;
320
+ /** The copy owes a real reset before it is trusted (lifted from an earlier release's row shapes). */
321
+ restart?: "paged";
273
322
  now: number;
274
323
  } | {
275
324
  type: "loadFailed";
@@ -299,6 +348,15 @@ export type Msg<T, P, S, C, O> = {
299
348
  failure: Failure;
300
349
  now: number;
301
350
  }
351
+ /** A push's in-flight mark couldn't be kept, so it was never sent. */
352
+ | {
353
+ type: "unsent";
354
+ client: string;
355
+ ids: number[];
356
+ reason: string;
357
+ replaced: boolean;
358
+ now: number;
359
+ }
302
360
  /**
303
361
  * The disk holds the replica at `revision` (perhaps newer than asked, from another tab); `whole: false` when it took
304
362
  * the patch as a delta under its own, other history.
@@ -326,6 +384,13 @@ export type Msg<T, P, S, C, O> = {
326
384
  reason: string;
327
385
  now: number;
328
386
  }
387
+ /** The outbox had no room for a new command: it was not kept. */
388
+ | ({
389
+ type: "full";
390
+ client: string;
391
+ id: number;
392
+ now: number;
393
+ } & Omit<OutboxFull, "kind">)
329
394
  /** Finished commands left the outbox (`failure` if not: they fade again on the next load). */
330
395
  | {
331
396
  type: "dropped";
@@ -407,9 +472,12 @@ export type Effect<T, S, C, O> = {
407
472
  }[];
408
473
  } | ({
409
474
  kind: "commit";
410
- } & Commit<T, S>) | {
475
+ } & Commit<T, S>)
476
+ /** Keep a command: a new one only while the outbox holds fewer than `max` (decided by the store). */
477
+ | {
411
478
  kind: "keep";
412
479
  pending: Pending<C, O>;
480
+ max: number;
413
481
  } | {
414
482
  kind: "drop";
415
483
  keys: {
@@ -450,6 +518,13 @@ export type Spec<T, P, S, C, O> = {
450
518
  resolved?: (pending: Pending<C, O>, model: Model<T, S, C, O>) => boolean;
451
519
  /** The most commands one push carries: the push endpoint's own bound. Default `PUSH_MAX`. */
452
520
  pushMax?: number;
521
+ /**
522
+ * The most UTF-8 bytes of one push request, encoded whole: the push endpoint's body limit (`serveWeb.bodyLimit`)
523
+ * must be at least this. Default `PUSH_BYTES`.
524
+ */
525
+ pushBytes?: number;
526
+ /** The most commands the device's outbox admits, shared by every tab. Default `OUTBOX_MAX`. */
527
+ outboxMax?: number;
453
528
  };
454
529
  /** The socket must be heard this often (the server pokes every 20 s); quieter, the page polls. */
455
530
  export declare const SOCKET_DEADLINE_MS = 45000;
@@ -459,8 +534,17 @@ export declare const POLL_MS = 4000;
459
534
  export declare const STORAGE_DEADLINE_MS = 3000;
460
535
  /** Commands per push unless the spec says otherwise; the rest wait for the next push. */
461
536
  export declare const PUSH_MAX = 50;
537
+ /** UTF-8 bytes of one push request unless the spec says otherwise (48 KiB). */
538
+ export declare const PUSH_BYTES = 49152;
539
+ /** Commands the outbox admits unless the spec says otherwise; updates to kept ones always are. */
540
+ export declare const OUTBOX_MAX = 500;
462
541
  /** Settled commands stay drawn this long. */
463
542
  export declare const DONE_KEEP_MS = 60000;
543
+ /**
544
+ * Where the outbox stands: full once it holds `max` commands, by this page's count or, failing that, by the store's
545
+ * last refusal (other tabs' commands count too). Unreadable kept commands take room as well.
546
+ */
547
+ export declare function deriveOutboxState<T, S, C, O>(model: Model<T, S, C, O>): OutboxState;
464
548
  /** Whether the server can be asked now. */
465
549
  export declare function reachable(connection: Connection): boolean;
466
550
  /**
package/dist/sync.js CHANGED
@@ -144,8 +144,42 @@ const BACKOFF_MAX_MS = 30_000;
144
144
  export const STORAGE_DEADLINE_MS = 3000;
145
145
  /** Commands per push unless the spec says otherwise; the rest wait for the next push. */
146
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;
147
151
  /** Settled commands stay drawn this long. */
148
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
+ }
149
183
  /** Whether the server can be asked now. */
150
184
  export function reachable(connection) {
151
185
  return connection.kind === "live" || connection.kind === "stale";
@@ -249,7 +283,8 @@ function failedConnection(connection, failure) {
249
283
  case "signed-out": {
250
284
  return { kind: "signed-out" };
251
285
  }
252
- case "invalid": {
286
+ case "invalid":
287
+ case "unreadable": {
253
288
  return { kind: "incompatible", failure: failure.message };
254
289
  }
255
290
  case "forbidden":
@@ -266,8 +301,16 @@ function failedConnection(connection, failure) {
266
301
  }
267
302
  export function createSync(spec) {
268
303
  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}`);
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
+ }
271
314
  }
272
315
  const emptyReplica = () => ({
273
316
  dataset: null,
@@ -300,6 +343,8 @@ export function createSync(spec) {
300
343
  pull: { kind: "idle", nextAt: now, failures: 0 },
301
344
  push: { kind: "idle", nextAt: now, failures: 0 },
302
345
  commands: [],
346
+ outbox: { max: outboxMax, full: null },
347
+ asking: 0,
303
348
  carried: new Set(),
304
349
  unreadable: 0,
305
350
  visible: true,
@@ -359,6 +404,39 @@ export function createSync(spec) {
359
404
  };
360
405
  }
361
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
+ }
362
440
  /** Start what is due: the obligations are read off the model after every message, never remembered. */
363
441
  function schedule(model, effects) {
364
442
  let next = model;
@@ -379,14 +457,16 @@ export function createSync(spec) {
379
457
  const since = next.restart === null ? next.replica.revision : 0;
380
458
  if ((owed && (failures === 0 || due)) || (polling && due)) {
381
459
  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
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
385
464
  .filter((each) => each.state.kind === "sending" ||
386
465
  each.state.kind === "unknown" ||
387
466
  (each.state.kind === "done" && !settled(current, each)))
388
- .slice(0, pushMax)
389
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);
390
470
  out.push({
391
471
  kind: "pull",
392
472
  client: next.client,
@@ -398,6 +478,7 @@ export function createSync(spec) {
398
478
  });
399
479
  next = {
400
480
  ...next,
481
+ asking: waiting.length > pushMax ? turn + pushMax : 0,
401
482
  pull: { kind: "pulling", since, failures },
402
483
  verify: verifying && next.verify.kind === "pending"
403
484
  ? { kind: "requested", tries: next.verify.tries }
@@ -406,37 +487,64 @@ export function createSync(spec) {
406
487
  }
407
488
  }
408
489
  if (next.push.kind === "idle" && now >= next.push.nextAt && reachable(next.connection)) {
409
- 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
+ }
410
509
  if (first !== undefined) {
411
510
  // 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.
413
- const tapped = (each) => each.since ?? next.replica.revision;
414
- const ids = new Set(next.commands
415
- .filter((each) => each.client === first.client &&
416
- each.dataset === first.dataset &&
417
- tapped(each) === tapped(first) &&
418
- (each.state.kind === "queued" || each.state.kind === "unknown"))
419
- .slice(0, pushMax)
420
- .map((each) => each.id));
421
- 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);
422
531
  out.push({
423
532
  kind: "push",
424
- client: first.client,
425
- dataset: first.dataset,
426
- since: tapped(first),
427
- commands: next.commands
428
- .filter(mine)
429
- .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 })),
430
537
  });
431
538
  next = {
432
539
  ...next,
433
540
  push: {
434
541
  kind: "pushing",
435
- client: first.client,
436
- ids: [...ids],
542
+ client: lead.client,
543
+ ids: batch.map((each) => each.id),
544
+ was: batch.map((each) => each.state),
437
545
  failures: next.push.failures,
438
546
  },
439
- 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),
440
548
  };
441
549
  }
442
550
  }
@@ -480,19 +588,16 @@ export function createSync(spec) {
480
588
  at: model.now,
481
589
  state: { kind: "done", outcome: receipt.outcome, revision: receipt.revision },
482
590
  };
483
- 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 } });
484
592
  return done;
485
593
  });
486
594
  const target = Math.max(model.target, ...receipts.map((each) => each.revision));
487
595
  return [{ ...model, commands, target }, effects];
488
596
  }
489
- /** A command that failed on the page: done with that outcome, never sent, and its kept intent dropped. */
490
- function fail(pending, message, now) {
491
- return {
492
- ...pending,
493
- at: now,
494
- state: { kind: "done", outcome: spec.refused(pending.command, message), revision: 0 },
495
- };
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" };
496
601
  }
497
602
  function reduce(model, msg) {
498
603
  const now = msg.now;
@@ -530,6 +635,7 @@ export function createSync(spec) {
530
635
  replica: msg.replica,
531
636
  saved: { ...at.saved, dataset: msg.replica.dataset, revision: msg.replica.revision },
532
637
  target: Math.max(at.target, msg.replica.revision),
638
+ restart: msg.restart ?? at.restart,
533
639
  commands: [...resumed, ...at.commands],
534
640
  unreadable: msg.unreadable,
535
641
  },
@@ -646,18 +752,19 @@ export function createSync(spec) {
646
752
  const done = fail(each, each.state.kind === "unknown"
647
753
  ? "the server's data was replaced; whether this ran before is unknown"
648
754
  : "the server's data was replaced since this was tapped, so it wasn't sent", now);
649
- more.push({ kind: "keep", pending: done }, { kind: "answered", pending: done });
755
+ more.push(keep(done), { kind: "answered", pending: done });
650
756
  return done;
651
757
  });
652
758
  return [{ ...answered, commands: replaced }, [...effects, ...more]];
653
759
  }
654
760
  case "pullFailed": {
655
761
  const failures = (at.pull.kind === "pulling" ? at.pull.failures : 0) + 1;
762
+ const wait = Math.max(backoff(failures), msg.failure.retryAfterMs ?? 0);
656
763
  return [
657
764
  {
658
765
  ...at,
659
766
  connection: failedConnection(at.connection, msg.failure),
660
- pull: { kind: "idle", nextAt: now + backoff(failures), failures },
767
+ pull: { kind: "idle", nextAt: now + wait, failures },
661
768
  verify: at.verify.kind === "requested"
662
769
  ? { kind: "pending", tries: at.verify.tries }
663
770
  : at.verify,
@@ -668,7 +775,8 @@ export function createSync(spec) {
668
775
  case "pushed": {
669
776
  const ids = new Set(msg.ids);
670
777
  const [answered, effects] = answer(at, msg.client, msg.receipts, ids);
671
- // A command the reply has no receipt for is still acting elsewhere: asked again.
778
+ // A command the reply has no receipt for is still acting elsewhere: asked again. The disk holds its in-flight
779
+ // mark, so a reload knows it too.
672
780
  const commands = answered.commands.map((each) => each.client === msg.client && ids.has(each.id) && each.state.kind === "sending"
673
781
  ? { ...each, state: { kind: "unknown", failure: "the server is still on it" } }
674
782
  : each);
@@ -684,34 +792,98 @@ export function createSync(spec) {
684
792
  ];
685
793
  }
686
794
  case "pushFailed": {
687
- // Refused before anything acted: final. Anything else may have acted (an unreadable reply included), so it is
688
- // unknown and asked again under the same key, which answers with the first outcome.
689
- const final = msg.failure.kind === "forbidden" || msg.failure.kind === "conflict";
795
+ const failure = msg.failure;
690
796
  const ids = new Set(msg.ids);
797
+ const before = prior(at, msg.client);
691
798
  const effects = [];
799
+ // The server's own refusal of a request it never ran (its schema is checked before the handler, and a handler
800
+ // never refuses after anything acted): this attempt didn't act, though an earlier one may have.
801
+ const refused = failure.refused === true &&
802
+ (failure.kind === "invalid" ||
803
+ failure.kind === "forbidden" ||
804
+ failure.kind === "conflict");
805
+ // A schema refusal naming one of this push's commands is that command's alone.
806
+ const index = refused && failure.kind === "invalid" ? commandAt(failure.path) : undefined;
807
+ const named = index === undefined ? undefined : msg.ids[index];
692
808
  const commands = at.commands.map((each) => {
693
- if (each.client !== msg.client || !ids.has(each.id) || each.state.kind === "done") {
809
+ if (each.client !== msg.client || !ids.has(each.id) || each.state.kind !== "sending") {
694
810
  return each;
695
811
  }
696
- if (!final) {
697
- return { ...each, state: { kind: "unknown", failure: msg.failure.message } };
812
+ const was = before(each);
813
+ // An attempt whose outcome was lost may have acted: no later refusal makes it final.
814
+ const doubt = was.kind === "unknown";
815
+ if (named !== undefined) {
816
+ return each.id === named
817
+ ? refuse(each, doubt, failure.message, now, effects)
818
+ : { ...each, state: was };
698
819
  }
699
- const done = fail(each, msg.failure.message, now);
700
- effects.push({ kind: "keep", pending: done }, { kind: "answered", pending: done });
701
- return done;
820
+ // A kit forbidden or conflict: nothing in this attempt acted. Final only for a command no attempt of which
821
+ // may have acted.
822
+ if (refused && failure.kind !== "invalid") {
823
+ return doubt
824
+ ? { ...each, state: was }
825
+ : refuse(each, false, failure.message, now, effects);
826
+ }
827
+ // An envelope refusal (a page and server of different releases): nothing acted, and the page stops. The disk
828
+ // holds the in-flight mark: a command never uncertain is kept as it was again.
829
+ if (refused) {
830
+ const restored = { ...each, state: was };
831
+ if (!doubt) {
832
+ effects.push(keep(restored));
833
+ }
834
+ return restored;
835
+ }
836
+ // Anything else may have acted (a lost reply, an unreadable one, a proxy's answer): asked again, same key.
837
+ // The disk holds its in-flight mark.
838
+ return { ...each, state: { kind: "unknown", failure: failure.message } };
702
839
  });
703
- const failures = (at.push.kind === "pushing" ? at.push.failures : 0) + 1;
840
+ const telemetry = {
841
+ kind: "telemetry",
842
+ name: "push.failed",
843
+ attributes: {
844
+ kind: failure.kind,
845
+ refused: failure.refused === true,
846
+ path: failure.path ?? null,
847
+ },
848
+ };
849
+ const pushing = at.push.kind === "pushing" ? at.push.failures : 0;
850
+ // One command refused by name: the rest go again at once, without it.
851
+ if (named !== undefined) {
852
+ return [
853
+ { ...at, commands, push: { kind: "idle", nextAt: now, failures: pushing } },
854
+ [...effects, telemetry],
855
+ ];
856
+ }
857
+ const failures = pushing + 1;
704
858
  return [
705
859
  {
706
860
  ...at,
707
861
  commands,
708
- connection: failedConnection(at.connection, msg.failure),
862
+ connection: failedConnection(at.connection, failure),
863
+ push: {
864
+ kind: "idle",
865
+ nextAt: now + Math.max(backoff(failures), failure.retryAfterMs ?? 0),
866
+ failures,
867
+ },
868
+ },
869
+ [...effects, telemetry],
870
+ ];
871
+ }
872
+ case "unsent": {
873
+ // Never sent: each command is what it was before, and goes again once the disk keeps its mark.
874
+ const ids = new Set(msg.ids);
875
+ const before = prior(at, msg.client);
876
+ const failures = (at.push.kind === "pushing" ? at.push.failures : 0) + 1;
877
+ return [
878
+ {
879
+ ...at,
880
+ commands: at.commands.map((each) => each.client === msg.client && ids.has(each.id) && each.state.kind === "sending"
881
+ ? { ...each, state: before(each) }
882
+ : each),
883
+ storage: msg.replaced ? { kind: "replaced" } : { kind: "failed", reason: msg.reason },
709
884
  push: { kind: "idle", nextAt: now + backoff(failures), failures },
710
885
  },
711
- [
712
- ...effects,
713
- { kind: "telemetry", name: "push.failed", attributes: { kind: msg.failure.kind } },
714
- ],
886
+ [{ kind: "telemetry", name: "push.unsent", attributes: { reason: msg.reason } }],
715
887
  ];
716
888
  }
717
889
  case "committed": {
@@ -753,10 +925,50 @@ export function createSync(spec) {
753
925
  ];
754
926
  }
755
927
  case "kept": {
756
- const commands = at.commands.map((each) => each.client === msg.client && each.id === msg.id && each.state.kind === "saving"
757
- ? { ...each, state: { kind: "queued" } }
758
- : each);
759
- return [{ ...at, commands, storage: working(at) }, []];
928
+ let admitted = false;
929
+ const commands = at.commands.map((each) => {
930
+ if (each.client !== msg.client || each.id !== msg.id || each.state.kind !== "saving") {
931
+ return each;
932
+ }
933
+ admitted = true;
934
+ return { ...each, state: { kind: "queued" } };
935
+ });
936
+ return [
937
+ {
938
+ ...at,
939
+ commands,
940
+ storage: working(at),
941
+ outbox: admitted ? { ...at.outbox, full: null } : at.outbox,
942
+ },
943
+ [],
944
+ ];
945
+ }
946
+ case "full": {
947
+ // Not a storage failure: the store answered, with no room. The tap is answered and not retained.
948
+ const gone = at.commands.find((each) => each.client === msg.client && each.id === msg.id && each.state.kind === "saving");
949
+ if (gone === undefined) {
950
+ return [{ ...at, storage: working(at) }, []];
951
+ }
952
+ const refused = fail(gone, "this device's outbox is full, so it wasn't kept or sent", now);
953
+ return [
954
+ {
955
+ ...at,
956
+ commands: at.commands.filter((each) => each !== gone),
957
+ storage: working(at),
958
+ outbox: {
959
+ ...at.outbox,
960
+ full: { kind: "full", waiting: msg.waiting, finished: msg.finished, max: msg.max },
961
+ },
962
+ },
963
+ [
964
+ { kind: "answered", pending: refused },
965
+ {
966
+ kind: "telemetry",
967
+ name: "commands.full",
968
+ attributes: { waiting: msg.waiting, finished: msg.finished, max: msg.max },
969
+ },
970
+ ],
971
+ ];
760
972
  }
761
973
  case "keepFailed": {
762
974
  const effects = [
@@ -774,7 +986,7 @@ export function createSync(spec) {
774
986
  }
775
987
  case "dropped": {
776
988
  if (msg.failure === null) {
777
- return [{ ...at, storage: working(at) }, []];
989
+ return [{ ...at, storage: working(at), outbox: { ...at.outbox, full: null } }, []];
778
990
  }
779
991
  return [
780
992
  {
@@ -871,9 +1083,15 @@ export function createSync(spec) {
871
1083
  if (joined) {
872
1084
  return [at, []];
873
1085
  }
1086
+ // A tap no push could carry is answered now, never kept.
1087
+ const alone = envelope(at.client, pending.dataset, at.replica.revision) + bytesOf(pending);
1088
+ if (alone > pushBytes) {
1089
+ return [numbered, [{ kind: "answered", pending: fail(pending, tooLarge(alone), now) }]];
1090
+ }
1091
+ // Admitted by the store, inside the keep's own transaction: a full outbox answers `full`, and the tap goes.
874
1092
  return [
875
1093
  { ...numbered, commands: [...at.commands, pending] },
876
- [{ kind: "keep", pending: { ...pending, state: { kind: "queued" } } }],
1094
+ [keep({ ...pending, state: { kind: "queued" } })],
877
1095
  ];
878
1096
  }
879
1097
  case "dismiss": {