slopkit 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPS.md +2 -1
- package/README.md +41 -0
- package/dist/client.d.ts +15 -1
- package/dist/client.js +4 -0
- package/dist/conformance/fixtures.d.ts +54 -3
- package/dist/conformance/fixtures.js +4 -4
- package/dist/conformance/legacy/replica-012.d.ts +52 -0
- package/dist/conformance/legacy/replica-012.js +403 -0
- package/dist/conformance/paging-budget.test.d.ts +1 -0
- package/dist/conformance/paging-budget.test.js +101 -0
- package/dist/conformance/paging-fixtures.d.ts +80 -0
- package/dist/conformance/paging-fixtures.js +135 -0
- package/dist/conformance/paging-network.test.d.ts +1 -0
- package/dist/conformance/paging-network.test.js +123 -0
- package/dist/conformance/paging-state.test.d.ts +1 -0
- package/dist/conformance/paging-state.test.js +180 -0
- package/dist/conformance/paging-storage.test.d.ts +1 -0
- package/dist/conformance/paging-storage.test.js +255 -0
- package/dist/conformance/paging.test.d.ts +1 -0
- package/dist/conformance/paging.test.js +209 -0
- package/dist/conformance/pull.d.ts +74 -0
- package/dist/conformance/pull.js +400 -0
- package/dist/server/ledger.d.ts +13 -0
- package/dist/server/ledger.js +18 -0
- package/dist/server/pull.d.ts +85 -0
- package/dist/server/pull.js +233 -0
- package/dist/sync.d.ts +137 -14
- package/dist/sync.js +293 -41
- package/dist/web/replica.js +47 -12
- package/package.json +17 -1
package/dist/sync.js
CHANGED
|
@@ -1,5 +1,93 @@
|
|
|
1
1
|
import { unreachable } from "./machine.js";
|
|
2
2
|
import { check } from "./schema.js";
|
|
3
|
+
/** A key a chunk removed, in a table's layer of changes. */
|
|
4
|
+
const GONE = Symbol("gone");
|
|
5
|
+
/**
|
|
6
|
+
* A table's rows after a chunk, without copying the rows it already had: the chunk's changes are a layer over them, so
|
|
7
|
+
* merging a chunk costs its own size, not the table's. Layers merge when a newer one is as big as the one below it, and
|
|
8
|
+
* fold into a plain map once the top one holds half as many keys as it, so lookups pass a logarithmic number of layers
|
|
9
|
+
* and a changed row is copied a logarithmic number of times. Immutable: a published value never changes.
|
|
10
|
+
*/
|
|
11
|
+
class Layered {
|
|
12
|
+
size;
|
|
13
|
+
base;
|
|
14
|
+
/** Newest first. */
|
|
15
|
+
layers;
|
|
16
|
+
constructor(base, layers, size) {
|
|
17
|
+
this.base = base;
|
|
18
|
+
this.layers = layers;
|
|
19
|
+
this.size = size;
|
|
20
|
+
}
|
|
21
|
+
find(key) {
|
|
22
|
+
for (const each of this.layers) {
|
|
23
|
+
if (each.has(key)) {
|
|
24
|
+
return each.get(key);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return this.base.get(key);
|
|
28
|
+
}
|
|
29
|
+
get(key) {
|
|
30
|
+
const value = this.find(key);
|
|
31
|
+
return value === GONE ? undefined : value;
|
|
32
|
+
}
|
|
33
|
+
// Rows are never undefined.
|
|
34
|
+
has(key) {
|
|
35
|
+
return this.get(key) !== undefined;
|
|
36
|
+
}
|
|
37
|
+
// The base's keys in place, then new keys in the order they first came.
|
|
38
|
+
*entries() {
|
|
39
|
+
for (const key of this.base.keys()) {
|
|
40
|
+
const value = this.get(key);
|
|
41
|
+
if (value !== undefined) {
|
|
42
|
+
yield [key, value];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (let at = this.layers.length - 1; at >= 0; at--) {
|
|
47
|
+
for (const key of this.layers[at]?.keys() ?? []) {
|
|
48
|
+
const value = this.get(key);
|
|
49
|
+
if (!seen.has(key) && !this.base.has(key) && value !== undefined) {
|
|
50
|
+
seen.add(key);
|
|
51
|
+
yield [key, value];
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
*keys() {
|
|
57
|
+
for (const [key] of this.entries()) {
|
|
58
|
+
yield key;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
*values() {
|
|
62
|
+
for (const [, value] of this.entries()) {
|
|
63
|
+
yield value;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
[Symbol.iterator]() {
|
|
67
|
+
return this.entries();
|
|
68
|
+
}
|
|
69
|
+
// oxlint-disable-next-line anti-slop/no-unknown-parameters -- Map's receiver is whatever the caller passes
|
|
70
|
+
forEach(each, receiver) {
|
|
71
|
+
for (const [key, value] of this.entries()) {
|
|
72
|
+
each.call(receiver, value, key, this);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** `rows` with `changes` over it, as a new value; `rows` is left as it was. */
|
|
77
|
+
function overlay(rows, changes) {
|
|
78
|
+
let size = rows.size;
|
|
79
|
+
for (const [key, value] of changes) {
|
|
80
|
+
size += (value === GONE ? 0 : 1) - (rows.has(key) ? 1 : 0);
|
|
81
|
+
}
|
|
82
|
+
const base = rows instanceof Layered ? rows.base : rows;
|
|
83
|
+
const below = rows instanceof Layered ? [...rows.layers] : [];
|
|
84
|
+
let top = changes;
|
|
85
|
+
while (below[0] !== undefined && below[0].size <= top.size) {
|
|
86
|
+
top = new Map([...(below.shift() ?? []), ...top]);
|
|
87
|
+
}
|
|
88
|
+
const layered = new Layered(base, [top, ...below], size);
|
|
89
|
+
return top.size * 2 < base.size ? layered : new Map(layered);
|
|
90
|
+
}
|
|
3
91
|
export function table(def) {
|
|
4
92
|
const gone = (row) => def.gone?.(row) === true;
|
|
5
93
|
return {
|
|
@@ -15,16 +103,11 @@ export function table(def) {
|
|
|
15
103
|
if (changed.length === 0 && !reset) {
|
|
16
104
|
return tables;
|
|
17
105
|
}
|
|
18
|
-
const
|
|
106
|
+
const changes = new Map();
|
|
19
107
|
for (const row of changed) {
|
|
20
|
-
|
|
21
|
-
next.delete(def.key(row));
|
|
22
|
-
}
|
|
23
|
-
else {
|
|
24
|
-
next.set(def.key(row), row);
|
|
25
|
-
}
|
|
108
|
+
changes.set(def.key(row), gone(row) ? GONE : row);
|
|
26
109
|
}
|
|
27
|
-
return def.set(tables,
|
|
110
|
+
return def.set(tables, overlay(reset ? new Map() : def.get(tables), changes));
|
|
28
111
|
},
|
|
29
112
|
snapshot: (tables) => [...def.get(tables).entries()].map(([key, row]) => ({ table: def.name, key, row })),
|
|
30
113
|
restore: (tables, values) => {
|
|
@@ -40,6 +123,18 @@ export function table(def) {
|
|
|
40
123
|
},
|
|
41
124
|
};
|
|
42
125
|
}
|
|
126
|
+
const COMPLETE = { kind: "complete" };
|
|
127
|
+
const historyOf = (replica) => replica.history ?? COMPLETE;
|
|
128
|
+
/** The history a page leaves: complete without one (an old server's whole copy) or once no older page is left. */
|
|
129
|
+
const covered = (page) => page === undefined || page.next === null
|
|
130
|
+
? COMPLETE
|
|
131
|
+
: {
|
|
132
|
+
kind: "partial",
|
|
133
|
+
next: page.next,
|
|
134
|
+
through: page.through,
|
|
135
|
+
held: page.held,
|
|
136
|
+
total: page.total,
|
|
137
|
+
};
|
|
43
138
|
/** The socket must be heard this often (the server pokes every 20 s); quieter, the page polls. */
|
|
44
139
|
export const SOCKET_DEADLINE_MS = 45_000;
|
|
45
140
|
/** Polling while the socket can't be trusted or work is in flight, and the first retry after a failure. */
|
|
@@ -55,34 +150,88 @@ export const DONE_KEEP_MS = 60_000;
|
|
|
55
150
|
export function reachable(connection) {
|
|
56
151
|
return connection.kind === "live" || connection.kind === "stale";
|
|
57
152
|
}
|
|
58
|
-
/**
|
|
153
|
+
/**
|
|
154
|
+
* Whether the replica holds this command's effects: it is done, and the copy is at its revision and either complete or
|
|
155
|
+
* holding the rows its receipt names (a pull reply carried them). One that failed on the page has none.
|
|
156
|
+
*/
|
|
59
157
|
export function settled(model, pending) {
|
|
60
|
-
return pending.state.kind === "done" &&
|
|
158
|
+
return (pending.state.kind === "done" &&
|
|
159
|
+
(pending.state.revision === 0 ||
|
|
160
|
+
(model.replica.revision >= pending.state.revision &&
|
|
161
|
+
(historyOf(model.replica).kind === "complete" ||
|
|
162
|
+
model.carried.has(`${pending.client}:${pending.id}`)))));
|
|
61
163
|
}
|
|
62
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Whether what the page shows is also on the disk: by revision only, so tabs at different cursors never chase each
|
|
166
|
+
* other's writes. A failed write is owed again even at the same revision (a page that failed to land).
|
|
167
|
+
*/
|
|
63
168
|
export function saved(model) {
|
|
64
|
-
return (model.saved.dataset === model.replica.dataset &&
|
|
169
|
+
return (model.saved.dataset === model.replica.dataset &&
|
|
170
|
+
model.saved.revision >= model.replica.revision &&
|
|
171
|
+
!model.saved.owed);
|
|
65
172
|
}
|
|
66
173
|
/**
|
|
67
174
|
* What a commit does to the disk, decided inside the disk's own transaction from what it holds: nothing when the disk
|
|
68
|
-
* already has this or newer (another tab got there
|
|
69
|
-
* whole replica, so a skipped or failed patch is never
|
|
175
|
+
* already has this or newer (another tab got there; a complete copy at the same revision is newer than any page), the
|
|
176
|
+
* patch alone when the disk is at least at its base, else the whole replica, so a skipped or failed patch is never
|
|
177
|
+
* covered by a later checkpoint. A delta keeps the disk's own history, and takes the patch's only when the disk is at
|
|
178
|
+
* the cursor the patch moved from (`adopt`): coverage never lands without its rows. A page may replace the disk's
|
|
70
179
|
* dataset only with one it moved to from that very dataset (`replaces`): a tab still on a dataset the server has since
|
|
71
180
|
* replaced is `stale` and never writes it back.
|
|
72
181
|
*/
|
|
73
182
|
export function commitPlan(stored, commit) {
|
|
74
183
|
const same = stored.dataset === commit.dataset;
|
|
184
|
+
const disk = stored.history ?? COMPLETE;
|
|
75
185
|
if (!same && stored.dataset !== null && stored.dataset !== commit.replaces) {
|
|
76
186
|
return "stale";
|
|
77
187
|
}
|
|
78
|
-
if (same &&
|
|
188
|
+
if (same &&
|
|
189
|
+
(stored.revision > commit.revision ||
|
|
190
|
+
(stored.revision === commit.revision && disk.kind === "complete"))) {
|
|
79
191
|
return "skip";
|
|
80
192
|
}
|
|
81
193
|
if (!commit.reset && same && stored.revision >= commit.base) {
|
|
82
|
-
return "delta";
|
|
194
|
+
return disk.kind === "partial" && disk.next === commit.from ? "adopt" : "delta";
|
|
83
195
|
}
|
|
84
196
|
return "whole";
|
|
85
197
|
}
|
|
198
|
+
/**
|
|
199
|
+
* The copy's history: loading while storage loads, none without a dataset, else partial or complete. Partial is a claim
|
|
200
|
+
* beside the numbers ("history from <through>, loading older · held of total"), and totals cover only `through` and
|
|
201
|
+
* above. Complete says nothing about storage: that is `deriveDurabilityState`, its own subject.
|
|
202
|
+
*/
|
|
203
|
+
export function deriveHistoryState(model) {
|
|
204
|
+
if (model.boot.kind === "loading") {
|
|
205
|
+
return { kind: "loading" };
|
|
206
|
+
}
|
|
207
|
+
if (model.replica.dataset === null) {
|
|
208
|
+
return { kind: "none" };
|
|
209
|
+
}
|
|
210
|
+
const history = historyOf(model.replica);
|
|
211
|
+
return history.kind === "complete"
|
|
212
|
+
? history
|
|
213
|
+
: { kind: "partial", through: history.through, held: history.held, total: history.total };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The copy's durability at the model's clock: loading, replaced by another tab, a failing store (lost while the page
|
|
217
|
+
* shows more than the disk holds), unsaved past the storage deadline, commands kept that this version can't read, or ok.
|
|
218
|
+
*/
|
|
219
|
+
export function deriveDurabilityState(model) {
|
|
220
|
+
const behind = model.saved.behindSince;
|
|
221
|
+
if (model.boot.kind === "loading") {
|
|
222
|
+
return { kind: "loading" };
|
|
223
|
+
}
|
|
224
|
+
if (model.storage.kind === "replaced") {
|
|
225
|
+
return { kind: "replaced" };
|
|
226
|
+
}
|
|
227
|
+
if (model.storage.kind === "failed") {
|
|
228
|
+
return { kind: behind === null ? "failing" : "lost", reason: model.storage.reason };
|
|
229
|
+
}
|
|
230
|
+
if (behind !== null && model.now - behind >= STORAGE_DEADLINE_MS) {
|
|
231
|
+
return { kind: "unsaved" };
|
|
232
|
+
}
|
|
233
|
+
return model.unreadable > 0 ? { kind: "unreadable", count: model.unreadable } : { kind: "ok" };
|
|
234
|
+
}
|
|
86
235
|
/**
|
|
87
236
|
* Storage after a write that worked: a failure that only a keep or a drop had (the replica's own writes aren't
|
|
88
237
|
* failing) is over; a replaced copy stays replaced.
|
|
@@ -125,6 +274,7 @@ export function createSync(spec) {
|
|
|
125
274
|
revision: 0,
|
|
126
275
|
tables: spec.empty,
|
|
127
276
|
server: null,
|
|
277
|
+
history: COMPLETE,
|
|
128
278
|
});
|
|
129
279
|
function initial(client, now) {
|
|
130
280
|
return {
|
|
@@ -140,14 +290,17 @@ export function createSync(spec) {
|
|
|
140
290
|
writing: 0,
|
|
141
291
|
failures: 0,
|
|
142
292
|
nextAt: now,
|
|
293
|
+
owed: false,
|
|
143
294
|
},
|
|
144
295
|
target: 0,
|
|
296
|
+
restart: null,
|
|
145
297
|
reconciledAt: null,
|
|
146
298
|
connection: { kind: "starting" },
|
|
147
299
|
socket: { open: false, heardAt: 0 },
|
|
148
300
|
pull: { kind: "idle", nextAt: now, failures: 0 },
|
|
149
301
|
push: { kind: "idle", nextAt: now, failures: 0 },
|
|
150
302
|
commands: [],
|
|
303
|
+
carried: new Set(),
|
|
151
304
|
unreadable: 0,
|
|
152
305
|
visible: true,
|
|
153
306
|
verify: { kind: "pending", tries: 0 },
|
|
@@ -155,22 +308,37 @@ export function createSync(spec) {
|
|
|
155
308
|
};
|
|
156
309
|
}
|
|
157
310
|
/**
|
|
158
|
-
* The one merge: a patch applied to a replica, with its disk operations
|
|
159
|
-
*
|
|
311
|
+
* The one merge: a patch applied to a replica, with its disk operations; undefined when it is older than the replica
|
|
312
|
+
* (a late reply), `gap` when it can't follow from it. Only a reset (or the answer to `since` 0, which holds
|
|
313
|
+
* everything) replaces the copy, with its own history. A delta applies only to the same dataset with
|
|
314
|
+
* `since ≤ copy.revision ≤ reply.revision` (equal only with a page or new server facts); it moves the cursor only when
|
|
315
|
+
* it answers the copy's own `next`, and a reply without a page never changes the history.
|
|
160
316
|
*/
|
|
161
|
-
function merge(replica, patch) {
|
|
162
|
-
const reset = patch.reset ||
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
317
|
+
function merge(replica, patch, since) {
|
|
318
|
+
const reset = patch.reset || since === 0;
|
|
319
|
+
const page = patch.page;
|
|
320
|
+
const was = historyOf(replica);
|
|
321
|
+
let history = was;
|
|
322
|
+
if (reset) {
|
|
323
|
+
// A reset older than what we hold of the same dataset is a late reply.
|
|
324
|
+
if (patch.dataset === replica.dataset && patch.revision < replica.revision) {
|
|
325
|
+
return undefined;
|
|
168
326
|
}
|
|
169
|
-
|
|
327
|
+
history = covered(page);
|
|
328
|
+
}
|
|
329
|
+
else if (patch.dataset !== replica.dataset || since > replica.revision) {
|
|
330
|
+
return "gap";
|
|
331
|
+
}
|
|
332
|
+
else if (patch.revision < replica.revision ||
|
|
333
|
+
(patch.revision === replica.revision && (page === undefined || was.kind === "complete"))) {
|
|
334
|
+
// Nothing newer, but the server's own facts (version, settings) may have moved.
|
|
335
|
+
return patch.revision === replica.revision &&
|
|
336
|
+
JSON.stringify(patch.server) !== JSON.stringify(replica.server)
|
|
337
|
+
? { replica: { ...replica, server: patch.server }, ops: [], reset: false }
|
|
338
|
+
: undefined;
|
|
170
339
|
}
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
return undefined;
|
|
340
|
+
else if (page !== undefined && was.kind === "partial" && page.from === was.next) {
|
|
341
|
+
history = covered(page);
|
|
174
342
|
}
|
|
175
343
|
let tables = reset ? spec.empty : replica.tables;
|
|
176
344
|
const ops = [];
|
|
@@ -179,7 +347,13 @@ export function createSync(spec) {
|
|
|
179
347
|
ops.push(...lens.ops(patch.rows));
|
|
180
348
|
}
|
|
181
349
|
return {
|
|
182
|
-
replica: {
|
|
350
|
+
replica: {
|
|
351
|
+
dataset: patch.dataset,
|
|
352
|
+
revision: patch.revision,
|
|
353
|
+
server: patch.server,
|
|
354
|
+
tables,
|
|
355
|
+
history,
|
|
356
|
+
},
|
|
183
357
|
ops,
|
|
184
358
|
reset,
|
|
185
359
|
};
|
|
@@ -196,19 +370,36 @@ export function createSync(spec) {
|
|
|
196
370
|
const behind = next.replica.revision < next.target;
|
|
197
371
|
const unsettled = next.commands.some((each) => each.state.kind === "done" && !settled(next, each));
|
|
198
372
|
const polling = next.connection.kind !== "live" || unsettled || (spec.outstanding?.(next.replica) ?? false);
|
|
199
|
-
const
|
|
200
|
-
|
|
373
|
+
const history = historyOf(next.replica);
|
|
374
|
+
const whole = next.restart === "whole";
|
|
375
|
+
// Only a complete copy is checked: a fingerprint of everything can't certify part of it.
|
|
376
|
+
const verifying = next.verify.kind === "pending" && (history.kind === "complete" || whole);
|
|
377
|
+
// A partial copy owes its next page, back to back, until it is complete.
|
|
378
|
+
const owed = behind || verifying || history.kind === "partial" || next.restart !== null;
|
|
379
|
+
const since = next.restart === null ? next.replica.revision : 0;
|
|
380
|
+
if ((owed && (failures === 0 || due)) || (polling && due)) {
|
|
381
|
+
const current = next;
|
|
382
|
+
// Commands waiting on an outcome or on their effects, oldest first, at most a push's worth: their receipts
|
|
383
|
+
// come back with the rows they name; the rest in the next pull.
|
|
384
|
+
const commands = current.commands
|
|
385
|
+
.filter((each) => each.state.kind === "sending" ||
|
|
386
|
+
each.state.kind === "unknown" ||
|
|
387
|
+
(each.state.kind === "done" && !settled(current, each)))
|
|
388
|
+
.slice(0, pushMax)
|
|
389
|
+
.map((each) => ({ client: each.client, id: each.id }));
|
|
201
390
|
out.push({
|
|
202
391
|
kind: "pull",
|
|
203
392
|
client: next.client,
|
|
204
393
|
dataset: next.replica.dataset,
|
|
205
|
-
since
|
|
394
|
+
since,
|
|
206
395
|
verify: verifying,
|
|
396
|
+
...(whole ? {} : { page: history.kind === "partial" && since > 0 ? history.next : null }),
|
|
397
|
+
commands,
|
|
207
398
|
});
|
|
208
399
|
next = {
|
|
209
400
|
...next,
|
|
210
|
-
pull: { kind: "pulling", since
|
|
211
|
-
verify: next.verify.kind === "pending"
|
|
401
|
+
pull: { kind: "pulling", since, failures },
|
|
402
|
+
verify: verifying && next.verify.kind === "pending"
|
|
212
403
|
? { kind: "requested", tries: next.verify.tries }
|
|
213
404
|
: next.verify,
|
|
214
405
|
};
|
|
@@ -355,20 +546,60 @@ export function createSync(spec) {
|
|
|
355
546
|
];
|
|
356
547
|
}
|
|
357
548
|
case "pulled": {
|
|
358
|
-
const
|
|
549
|
+
const result = merge(at.replica, msg.patch, msg.since);
|
|
550
|
+
const merged = result === "gap" ? undefined : result;
|
|
359
551
|
const replica = merged?.replica ?? at.replica;
|
|
360
552
|
const effects = [];
|
|
553
|
+
const was = historyOf(at.replica);
|
|
554
|
+
const history = historyOf(replica);
|
|
555
|
+
const failures = at.pull.kind === "pulling" ? at.pull.failures : 0;
|
|
556
|
+
// An old server answering a partial copy (with rows or without): only its whole copy completes it.
|
|
557
|
+
const old = was.kind === "partial" &&
|
|
558
|
+
msg.patch.page === undefined &&
|
|
559
|
+
!msg.patch.reset &&
|
|
560
|
+
msg.since > 0 &&
|
|
561
|
+
result !== "gap";
|
|
562
|
+
// A partial copy this reply didn't move on (the same page again, a repeated first page at the same revision, a
|
|
563
|
+
// reply it couldn't take) is a pull that failed, for backoff: never a busy loop on a server that doesn't move.
|
|
564
|
+
const stuck = was.kind === "partial" &&
|
|
565
|
+
!old &&
|
|
566
|
+
(merged === undefined ||
|
|
567
|
+
(msg.patch.page !== undefined &&
|
|
568
|
+
history.kind === "partial" &&
|
|
569
|
+
history.next === was.next &&
|
|
570
|
+
replica.dataset === at.replica.dataset &&
|
|
571
|
+
!(merged.reset && replica.revision > at.replica.revision)));
|
|
572
|
+
// Receipts that came with an applied reply came with the rows they name.
|
|
573
|
+
const carried = new Set(merged?.reset === true ? [] : at.carried);
|
|
574
|
+
if (merged !== undefined) {
|
|
575
|
+
for (const each of msg.receipts) {
|
|
576
|
+
if (each.revision <= replica.revision) {
|
|
577
|
+
carried.add(`${each.client ?? at.client}:${each.id}`);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
361
581
|
let next = {
|
|
362
582
|
...at,
|
|
363
583
|
replica,
|
|
584
|
+
carried,
|
|
364
585
|
target: msg.patch.dataset === at.replica.dataset
|
|
365
586
|
? Math.max(at.target, msg.patch.revision)
|
|
366
587
|
: msg.patch.revision,
|
|
588
|
+
// A reply it couldn't follow asks a real reset; an old server's reply to a partial copy, its whole copy.
|
|
589
|
+
restart: result === "gap"
|
|
590
|
+
? (at.restart ?? "paged")
|
|
591
|
+
: merged?.reset === true
|
|
592
|
+
? null
|
|
593
|
+
: old
|
|
594
|
+
? "whole"
|
|
595
|
+
: at.restart,
|
|
367
596
|
reconciledAt: now,
|
|
368
597
|
connection: at.socket.open && now - at.socket.heardAt < SOCKET_DEADLINE_MS
|
|
369
598
|
? { kind: "live" }
|
|
370
599
|
: { kind: "stale" },
|
|
371
|
-
pull:
|
|
600
|
+
pull: stuck
|
|
601
|
+
? { kind: "idle", nextAt: now + backoff(failures + 1), failures: failures + 1 }
|
|
602
|
+
: { kind: "idle", nextAt: now + POLL_MS, failures: 0 },
|
|
372
603
|
};
|
|
373
604
|
if (merged !== undefined && next.storage.kind !== "replaced") {
|
|
374
605
|
effects.push({
|
|
@@ -378,12 +609,15 @@ export function createSync(spec) {
|
|
|
378
609
|
reset: merged.reset,
|
|
379
610
|
base: msg.since,
|
|
380
611
|
replaces: at.saved.dataset,
|
|
612
|
+
from: was.kind === "partial" ? was.next : null,
|
|
381
613
|
});
|
|
382
614
|
next = { ...next, saved: { ...next.saved, writing: next.saved.writing + 1 } };
|
|
383
615
|
}
|
|
384
616
|
if (at.verify.kind === "requested") {
|
|
385
|
-
// The fingerprint describes the server at the reply's revision: comparable only if the replica is there
|
|
617
|
+
// The fingerprint describes the server at the reply's revision: comparable only if the replica is there, and
|
|
618
|
+
// whole (a fingerprint of everything never certifies part of it).
|
|
386
619
|
if (msg.patch.checksum !== undefined &&
|
|
620
|
+
history.kind === "complete" &&
|
|
387
621
|
replica.revision === msg.patch.revision &&
|
|
388
622
|
replica.dataset === msg.patch.dataset) {
|
|
389
623
|
effects.push({ kind: "check", replica, checksum: msg.patch.checksum });
|
|
@@ -392,13 +626,21 @@ export function createSync(spec) {
|
|
|
392
626
|
next = { ...next, verify: { kind: "pending", tries: at.verify.tries } };
|
|
393
627
|
}
|
|
394
628
|
}
|
|
395
|
-
|
|
629
|
+
// Receipts by client: an earlier page's commands are answered too.
|
|
630
|
+
let answered = next;
|
|
631
|
+
const more = [];
|
|
632
|
+
for (const client of new Set(msg.receipts.map((each) => each.client ?? at.client))) {
|
|
633
|
+
const [after, told] = answer(answered, client, msg.receipts.filter((each) => (each.client ?? at.client) === client));
|
|
634
|
+
answered = after;
|
|
635
|
+
more.push(...told);
|
|
636
|
+
}
|
|
396
637
|
// Intents tapped against a store the server no longer has never go to its replacement (the server refuses
|
|
397
638
|
// them too). One that may already have run there says so.
|
|
398
639
|
const replaced = answered.commands.map((each) => {
|
|
399
640
|
if (each.state.kind === "done" ||
|
|
400
641
|
each.dataset === null ||
|
|
401
|
-
|
|
642
|
+
replica.dataset === null ||
|
|
643
|
+
each.dataset === replica.dataset) {
|
|
402
644
|
return each;
|
|
403
645
|
}
|
|
404
646
|
const done = fail(each, each.state.kind === "unknown"
|
|
@@ -474,10 +716,12 @@ export function createSync(spec) {
|
|
|
474
716
|
}
|
|
475
717
|
case "committed": {
|
|
476
718
|
const same = msg.dataset === at.saved.dataset;
|
|
719
|
+
// A delta the disk took under its own history doesn't repair a failed write: the whole one stays owed.
|
|
720
|
+
const owed = at.saved.owed && msg.whole === false;
|
|
477
721
|
return [
|
|
478
722
|
{
|
|
479
723
|
...at,
|
|
480
|
-
storage: at.storage.kind === "replaced" ? at.storage : { kind: "ok" },
|
|
724
|
+
storage: at.storage.kind === "replaced" || owed ? at.storage : { kind: "ok" },
|
|
481
725
|
saved: {
|
|
482
726
|
dataset: msg.dataset,
|
|
483
727
|
revision: same ? Math.max(at.saved.revision, msg.revision) : msg.revision,
|
|
@@ -485,6 +729,7 @@ export function createSync(spec) {
|
|
|
485
729
|
writing: Math.max(0, at.saved.writing - 1),
|
|
486
730
|
failures: 0,
|
|
487
731
|
nextAt: now,
|
|
732
|
+
owed,
|
|
488
733
|
},
|
|
489
734
|
},
|
|
490
735
|
[],
|
|
@@ -501,6 +746,7 @@ export function createSync(spec) {
|
|
|
501
746
|
writing: Math.max(0, at.saved.writing - 1),
|
|
502
747
|
failures,
|
|
503
748
|
nextAt: now + backoff(failures),
|
|
749
|
+
owed: true,
|
|
504
750
|
},
|
|
505
751
|
},
|
|
506
752
|
[{ kind: "telemetry", name: "replica.save-failed", attributes: { reason: msg.reason } }],
|
|
@@ -685,6 +931,12 @@ export function createSync(spec) {
|
|
|
685
931
|
let marked = since === next.saved.behindSince
|
|
686
932
|
? next
|
|
687
933
|
: { ...next, saved: { ...next.saved, behindSince: since } };
|
|
934
|
+
// Commands that left the page leave what was carried for them.
|
|
935
|
+
const kept = new Set(marked.commands.map((each) => `${each.client}:${each.id}`));
|
|
936
|
+
const carried = [...marked.carried].filter((key) => kept.has(key));
|
|
937
|
+
if (carried.length < marked.carried.size) {
|
|
938
|
+
marked = { ...marked, carried: new Set(carried) };
|
|
939
|
+
}
|
|
688
940
|
// A command that resolves only now (its result came later than its receipt) is drawn its full time from now.
|
|
689
941
|
const resolved = spec.resolved;
|
|
690
942
|
if (resolved !== undefined) {
|
package/dist/web/replica.js
CHANGED
|
@@ -11,7 +11,11 @@
|
|
|
11
11
|
* - Rows are read back only under the row shapes that wrote them (each table's name and schema `shape`, kept with the
|
|
12
12
|
* checkpoint): any change to a table or its schema starts the rows over, with no number to bump by hand. The next
|
|
13
13
|
* commit then writes the whole replica, so the disk is repaired, not just the memory. Rows that don't parse are
|
|
14
|
-
* treated the same.
|
|
14
|
+
* treated the same.
|
|
15
|
+
* - A partial copy (a first copy still paging in) keeps its history in the checkpoint, written in the same transaction
|
|
16
|
+
* as its rows, and marks its row shapes as partial: a release from before paging reads it as another release's copy,
|
|
17
|
+
* so it never takes part of a history for all of it. A delta takes the patch's history only when the disk is at the
|
|
18
|
+
* cursor the patch moved from, and a complete copy at a revision is never narrowed to a partial one. The server's facts are not rows: facts an older page kept that no longer fit load as null and
|
|
15
19
|
* never cost the copy; facts that change at the same revision are written too.
|
|
16
20
|
* - A version upgrade starts the replica over (it comes back from the server) but keeps the outbox and the generation:
|
|
17
21
|
* an intent on disk is never erased by an upgrade. One this version can't read stays on disk and is counted.
|
|
@@ -21,12 +25,24 @@
|
|
|
21
25
|
* in-memory one). `interfere` is for tests: it sees each write transaction before it commits and may abort it.
|
|
22
26
|
*/
|
|
23
27
|
import { Replaced } from "../client.js";
|
|
24
|
-
import { check } from "../schema.js";
|
|
28
|
+
import { check, literal, nullable, number, object, string } from "../schema.js";
|
|
25
29
|
import { commitPlan } from "../sync.js";
|
|
26
30
|
import { committed, openDatabase, request } from "./idb.js";
|
|
31
|
+
const COMPLETE = { kind: "complete" };
|
|
32
|
+
const count = () => number({ integer: true, min: 0 });
|
|
33
|
+
const partialSchema = object({
|
|
34
|
+
kind: literal("partial"),
|
|
35
|
+
next: string(),
|
|
36
|
+
through: nullable(string()),
|
|
37
|
+
held: count(),
|
|
38
|
+
total: count(),
|
|
39
|
+
});
|
|
27
40
|
export function indexedReplica(options) {
|
|
28
41
|
const names = options.tables.map((lens) => lens.name);
|
|
29
42
|
const shapes = options.tables.map((lens) => lens.shape).join("\n");
|
|
43
|
+
/** A partial copy's row shapes: foreign to a release from before paging. */
|
|
44
|
+
const partialShapes = `${shapes}\npartial`;
|
|
45
|
+
const ours = (written) => written === shapes || written === partialShapes;
|
|
30
46
|
/** The checkpoint a load found whose rows didn't parse: the next commit at it rewrites the whole replica. */
|
|
31
47
|
let damaged;
|
|
32
48
|
/** The dataset of a copy this page loaded but couldn't read (other row shapes, rows that don't parse): its own to replace. */
|
|
@@ -142,22 +158,26 @@ export function indexedReplica(options) {
|
|
|
142
158
|
}
|
|
143
159
|
/** The checkpoint on disk as this version may trust it: one of other row shapes (or none) holds nothing. */
|
|
144
160
|
const checkpointOf = (value) => {
|
|
161
|
+
const none = { dataset: null, revision: 0, history: COMPLETE };
|
|
145
162
|
if (typeof value !== "object" || value === null) {
|
|
146
|
-
return
|
|
163
|
+
return none;
|
|
147
164
|
}
|
|
148
165
|
const dataset = Reflect.get(value, "dataset");
|
|
149
166
|
const revision = Reflect.get(value, "revision");
|
|
150
|
-
|
|
151
|
-
|
|
167
|
+
const written = Reflect.get(value, "shapes");
|
|
168
|
+
const partial = check(partialSchema, Reflect.get(value, "history"));
|
|
169
|
+
if (written !== shapes && (written !== partialShapes || !partial.ok)) {
|
|
170
|
+
return none;
|
|
152
171
|
}
|
|
153
172
|
const at = {
|
|
154
173
|
dataset: typeof dataset === "string" ? dataset : null,
|
|
155
174
|
revision: typeof revision === "number" ? revision : 0,
|
|
175
|
+
history: written === partialShapes && partial.ok ? partial.value : COMPLETE,
|
|
156
176
|
};
|
|
157
177
|
if (damaged !== undefined &&
|
|
158
178
|
damaged.dataset === at.dataset &&
|
|
159
179
|
damaged.revision === at.revision) {
|
|
160
|
-
return
|
|
180
|
+
return none;
|
|
161
181
|
}
|
|
162
182
|
return at;
|
|
163
183
|
};
|
|
@@ -197,6 +217,7 @@ export function indexedReplica(options) {
|
|
|
197
217
|
revision: 0,
|
|
198
218
|
tables: options.empty,
|
|
199
219
|
server: null,
|
|
220
|
+
history: COMPLETE,
|
|
200
221
|
};
|
|
201
222
|
const at = checkpointOf(point);
|
|
202
223
|
const raw = typeof point === "object" && point !== null ? Reflect.get(point, "dataset") : null;
|
|
@@ -232,6 +253,7 @@ export function indexedReplica(options) {
|
|
|
232
253
|
revision: at.revision,
|
|
233
254
|
tables: restored,
|
|
234
255
|
server: server?.ok === true ? server.value : null,
|
|
256
|
+
history: at.history,
|
|
235
257
|
},
|
|
236
258
|
commands,
|
|
237
259
|
unreadable,
|
|
@@ -246,7 +268,6 @@ export function indexedReplica(options) {
|
|
|
246
268
|
dataset: commit.replica.dataset,
|
|
247
269
|
revision: commit.replica.revision,
|
|
248
270
|
server: commit.replica.server,
|
|
249
|
-
shapes,
|
|
250
271
|
};
|
|
251
272
|
try {
|
|
252
273
|
await guard(transaction);
|
|
@@ -267,6 +288,7 @@ export function indexedReplica(options) {
|
|
|
267
288
|
reset: commit.reset,
|
|
268
289
|
base: commit.base,
|
|
269
290
|
replaces: commit.replaces,
|
|
291
|
+
...(commit.from === undefined ? {} : { from: commit.from }),
|
|
270
292
|
});
|
|
271
293
|
// The disk holds a dataset this page never saw: the server replaced the one this page is on.
|
|
272
294
|
if (plan === "stale") {
|
|
@@ -278,7 +300,7 @@ export function indexedReplica(options) {
|
|
|
278
300
|
// Another release's tab wrote this dataset under its own row shapes since this page loaded: that release owns
|
|
279
301
|
// the copy now, at any revision, and this page stops writing until it reloads.
|
|
280
302
|
if (held === commit.replica.dataset &&
|
|
281
|
-
written
|
|
303
|
+
!ours(written) &&
|
|
282
304
|
(loaded === undefined ||
|
|
283
305
|
loaded.dataset !== held ||
|
|
284
306
|
loaded.revision !== revision ||
|
|
@@ -305,7 +327,7 @@ export function indexedReplica(options) {
|
|
|
305
327
|
}
|
|
306
328
|
}
|
|
307
329
|
};
|
|
308
|
-
if (plan === "delta") {
|
|
330
|
+
if (plan === "delta" || plan === "adopt") {
|
|
309
331
|
write(commit.ops);
|
|
310
332
|
}
|
|
311
333
|
if (plan === "whole") {
|
|
@@ -318,8 +340,16 @@ export function indexedReplica(options) {
|
|
|
318
340
|
}
|
|
319
341
|
// At the same revision the rows are already there, but the server's facts may have moved: they are kept too.
|
|
320
342
|
const facts = plan === "skip" && stored.revision === target.revision;
|
|
343
|
+
// A delta keeps the disk's history; a whole write or a delta from the disk's own cursor takes the page's.
|
|
344
|
+
const history = plan === "whole" || plan === "adopt"
|
|
345
|
+
? (commit.replica.history ?? COMPLETE)
|
|
346
|
+
: stored.history;
|
|
347
|
+
const checkpoint = {
|
|
348
|
+
...target,
|
|
349
|
+
...(history.kind === "partial" ? { history, shapes: partialShapes } : { shapes }),
|
|
350
|
+
};
|
|
321
351
|
if (plan !== "skip" || facts) {
|
|
322
|
-
meta.put(
|
|
352
|
+
meta.put(checkpoint, "checkpoint");
|
|
323
353
|
}
|
|
324
354
|
options.interfere?.("commit", transaction);
|
|
325
355
|
await done;
|
|
@@ -328,11 +358,16 @@ export function indexedReplica(options) {
|
|
|
328
358
|
discarded = null;
|
|
329
359
|
loaded = undefined;
|
|
330
360
|
}
|
|
331
|
-
const now = plan === "skip" ? stored :
|
|
361
|
+
const now = plan === "skip" ? stored : checkpoint;
|
|
332
362
|
if (now.dataset === null) {
|
|
333
363
|
throw new Error("the replica has no dataset");
|
|
334
364
|
}
|
|
335
|
-
|
|
365
|
+
// A delta under the disk's own history leaves out rows of this page's copy: said, so a failed write stays owed.
|
|
366
|
+
return {
|
|
367
|
+
dataset: now.dataset,
|
|
368
|
+
revision: now.revision,
|
|
369
|
+
...(plan === "delta" && stored.history.kind === "partial" ? { whole: false } : {}),
|
|
370
|
+
};
|
|
336
371
|
}
|
|
337
372
|
catch (error) {
|
|
338
373
|
await done.catch(() => undefined);
|