slopkit 0.2.0 → 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.
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. */
@@ -299,6 +346,15 @@ export type Msg<T, P, S, C, O> = {
299
346
  failure: Failure;
300
347
  now: number;
301
348
  }
349
+ /** A push's in-flight mark couldn't be kept, so it was never sent. */
350
+ | {
351
+ type: "unsent";
352
+ client: string;
353
+ ids: number[];
354
+ reason: string;
355
+ replaced: boolean;
356
+ now: number;
357
+ }
302
358
  /**
303
359
  * The disk holds the replica at `revision` (perhaps newer than asked, from another tab); `whole: false` when it took
304
360
  * the patch as a delta under its own, other history.
@@ -326,6 +382,13 @@ export type Msg<T, P, S, C, O> = {
326
382
  reason: string;
327
383
  now: number;
328
384
  }
385
+ /** The outbox had no room for a new command: it was not kept. */
386
+ | ({
387
+ type: "full";
388
+ client: string;
389
+ id: number;
390
+ now: number;
391
+ } & Omit<OutboxFull, "kind">)
329
392
  /** Finished commands left the outbox (`failure` if not: they fade again on the next load). */
330
393
  | {
331
394
  type: "dropped";
@@ -407,9 +470,12 @@ export type Effect<T, S, C, O> = {
407
470
  }[];
408
471
  } | ({
409
472
  kind: "commit";
410
- } & Commit<T, S>) | {
473
+ } & Commit<T, S>)
474
+ /** Keep a command: a new one only while the outbox holds fewer than `max` (decided by the store). */
475
+ | {
411
476
  kind: "keep";
412
477
  pending: Pending<C, O>;
478
+ max: number;
413
479
  } | {
414
480
  kind: "drop";
415
481
  keys: {
@@ -450,6 +516,13 @@ export type Spec<T, P, S, C, O> = {
450
516
  resolved?: (pending: Pending<C, O>, model: Model<T, S, C, O>) => boolean;
451
517
  /** The most commands one push carries: the push endpoint's own bound. Default `PUSH_MAX`. */
452
518
  pushMax?: number;
519
+ /**
520
+ * The most UTF-8 bytes of one push request, encoded whole: the push endpoint's body limit (`serveWeb.bodyLimit`)
521
+ * must be at least this. Default `PUSH_BYTES`.
522
+ */
523
+ pushBytes?: number;
524
+ /** The most commands the device's outbox admits, shared by every tab. Default `OUTBOX_MAX`. */
525
+ outboxMax?: number;
453
526
  };
454
527
  /** The socket must be heard this often (the server pokes every 20 s); quieter, the page polls. */
455
528
  export declare const SOCKET_DEADLINE_MS = 45000;
@@ -459,8 +532,17 @@ export declare const POLL_MS = 4000;
459
532
  export declare const STORAGE_DEADLINE_MS = 3000;
460
533
  /** Commands per push unless the spec says otherwise; the rest wait for the next push. */
461
534
  export declare const PUSH_MAX = 50;
535
+ /** UTF-8 bytes of one push request unless the spec says otherwise (48 KiB). */
536
+ export declare const PUSH_BYTES = 49152;
537
+ /** Commands the outbox admits unless the spec says otherwise; updates to kept ones always are. */
538
+ export declare const OUTBOX_MAX = 500;
462
539
  /** Settled commands stay drawn this long. */
463
540
  export declare const DONE_KEEP_MS = 60000;
541
+ /**
542
+ * Where the outbox stands: full once it holds `max` commands, by this page's count or, failing that, by the store's
543
+ * last refusal (other tabs' commands count too). Unreadable kept commands take room as well.
544
+ */
545
+ export declare function deriveOutboxState<T, S, C, O>(model: Model<T, S, C, O>): OutboxState;
464
546
  /** Whether the server can be asked now. */
465
547
  export declare function reachable(connection: Connection): boolean;
466
548
  /**
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;
@@ -646,18 +751,19 @@ export function createSync(spec) {
646
751
  const done = fail(each, each.state.kind === "unknown"
647
752
  ? "the server's data was replaced; whether this ran before is unknown"
648
753
  : "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 });
754
+ more.push(keep(done), { kind: "answered", pending: done });
650
755
  return done;
651
756
  });
652
757
  return [{ ...answered, commands: replaced }, [...effects, ...more]];
653
758
  }
654
759
  case "pullFailed": {
655
760
  const failures = (at.pull.kind === "pulling" ? at.pull.failures : 0) + 1;
761
+ const wait = Math.max(backoff(failures), msg.failure.retryAfterMs ?? 0);
656
762
  return [
657
763
  {
658
764
  ...at,
659
765
  connection: failedConnection(at.connection, msg.failure),
660
- pull: { kind: "idle", nextAt: now + backoff(failures), failures },
766
+ pull: { kind: "idle", nextAt: now + wait, failures },
661
767
  verify: at.verify.kind === "requested"
662
768
  ? { kind: "pending", tries: at.verify.tries }
663
769
  : at.verify,
@@ -668,7 +774,8 @@ export function createSync(spec) {
668
774
  case "pushed": {
669
775
  const ids = new Set(msg.ids);
670
776
  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.
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.
672
779
  const commands = answered.commands.map((each) => each.client === msg.client && ids.has(each.id) && each.state.kind === "sending"
673
780
  ? { ...each, state: { kind: "unknown", failure: "the server is still on it" } }
674
781
  : each);
@@ -684,34 +791,98 @@ export function createSync(spec) {
684
791
  ];
685
792
  }
686
793
  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";
794
+ const failure = msg.failure;
690
795
  const ids = new Set(msg.ids);
796
+ const before = prior(at, msg.client);
691
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];
692
807
  const commands = at.commands.map((each) => {
693
- 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") {
694
809
  return each;
695
810
  }
696
- if (!final) {
697
- 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 };
698
818
  }
699
- const done = fail(each, msg.failure.message, now);
700
- effects.push({ kind: "keep", pending: done }, { kind: "answered", pending: done });
701
- 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 } };
702
838
  });
703
- 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;
704
857
  return [
705
858
  {
706
859
  ...at,
707
860
  commands,
708
- 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 },
709
883
  push: { kind: "idle", nextAt: now + backoff(failures), failures },
710
884
  },
711
- [
712
- ...effects,
713
- { kind: "telemetry", name: "push.failed", attributes: { kind: msg.failure.kind } },
714
- ],
885
+ [{ kind: "telemetry", name: "push.unsent", attributes: { reason: msg.reason } }],
715
886
  ];
716
887
  }
717
888
  case "committed": {
@@ -753,10 +924,50 @@ export function createSync(spec) {
753
924
  ];
754
925
  }
755
926
  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) }, []];
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
+ ];
760
971
  }
761
972
  case "keepFailed": {
762
973
  const effects = [
@@ -774,7 +985,7 @@ export function createSync(spec) {
774
985
  }
775
986
  case "dropped": {
776
987
  if (msg.failure === null) {
777
- return [{ ...at, storage: working(at) }, []];
988
+ return [{ ...at, storage: working(at), outbox: { ...at.outbox, full: null } }, []];
778
989
  }
779
990
  return [
780
991
  {
@@ -871,9 +1082,15 @@ export function createSync(spec) {
871
1082
  if (joined) {
872
1083
  return [at, []];
873
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.
874
1091
  return [
875
1092
  { ...numbered, commands: [...at.commands, pending] },
876
- [{ kind: "keep", pending: { ...pending, state: { kind: "queued" } } }],
1093
+ [keep({ ...pending, state: { kind: "queued" } })],
877
1094
  ];
878
1095
  }
879
1096
  case "dismiss": {