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/DEPS.md +2 -2
- package/README.md +10 -0
- package/dist/client.d.ts +16 -3
- package/dist/client.js +21 -2
- package/dist/conformance/client.test.js +7 -7
- package/dist/conformance/fixtures.d.ts +1 -0
- package/dist/conformance/fixtures.js +5 -1
- package/dist/conformance/outbox-bound.test.d.ts +1 -0
- package/dist/conformance/outbox-bound.test.js +268 -0
- package/dist/conformance/push-bytes.test.d.ts +1 -0
- package/dist/conformance/push-bytes.test.js +174 -0
- package/dist/conformance/push-refusal.test.d.ts +1 -0
- package/dist/conformance/push-refusal.test.js +644 -0
- package/dist/contract.d.ts +19 -6
- package/dist/contract.js +76 -9
- package/dist/server/pull.js +158 -67
- package/dist/server/web.d.ts +9 -5
- package/dist/server/web.js +29 -12
- package/dist/sync.d.ts +84 -2
- package/dist/sync.js +276 -59
- package/dist/web/replica.js +46 -4
- package/package.json +1 -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. */
|
|
@@ -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
|
-
|
|
270
|
-
|
|
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,
|
|
383
|
-
//
|
|
384
|
-
|
|
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
|
|
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
|
-
//
|
|
413
|
-
|
|
414
|
-
const
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
(each.
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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:
|
|
425
|
-
dataset:
|
|
426
|
-
since: tapped(
|
|
427
|
-
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:
|
|
436
|
-
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) =>
|
|
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(
|
|
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
|
-
/**
|
|
490
|
-
function
|
|
491
|
-
|
|
492
|
-
|
|
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(
|
|
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 +
|
|
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
|
-
|
|
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
|
|
808
|
+
if (each.client !== msg.client || !ids.has(each.id) || each.state.kind !== "sending") {
|
|
694
809
|
return each;
|
|
695
810
|
}
|
|
696
|
-
|
|
697
|
-
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
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
|
-
[
|
|
1093
|
+
[keep({ ...pending, state: { kind: "queued" } })],
|
|
877
1094
|
];
|
|
878
1095
|
}
|
|
879
1096
|
case "dismiss": {
|