slopkit 0.1.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEPS.md +2 -1
- package/README.md +51 -0
- package/dist/client.d.ts +31 -4
- package/dist/client.js +25 -2
- package/dist/conformance/client.test.js +7 -7
- package/dist/conformance/fixtures.d.ts +55 -3
- package/dist/conformance/fixtures.js +9 -5
- package/dist/conformance/legacy/replica-012.d.ts +52 -0
- package/dist/conformance/legacy/replica-012.js +403 -0
- package/dist/conformance/outbox-bound.test.d.ts +1 -0
- package/dist/conformance/outbox-bound.test.js +268 -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/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/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 +324 -0
- package/dist/server/web.d.ts +9 -5
- package/dist/server/web.js +29 -12
- package/dist/sync.d.ts +221 -16
- package/dist/sync.js +565 -96
- package/dist/web/replica.js +93 -16
- package/package.json +17 -1
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
/* oxlint-disable anti-slop/no-unknown-parameters, anti-slop/no-runtime-typeof, anti-slop/no-reflect-get -- frozen released storage boundary, not a new decoder */
|
|
2
|
+
// Released 0.1.2 adapter and commit decision, kept unchanged for cross-release read/write checks.
|
|
3
|
+
/**
|
|
4
|
+
* The sync engine's storage port over IndexedDB: the replica (one store per table, out-of-line keys, and a checkpoint)
|
|
5
|
+
* and the outbox (one record per command, keyed by client and number), in one database shared by every tab.
|
|
6
|
+
*
|
|
7
|
+
* - A commit is decided inside its own read-write transaction from the checkpoint the disk holds (`commitPlan`): a
|
|
8
|
+
* stale tab can't make the disk go back, and a patch whose base the disk never reached is written as the whole
|
|
9
|
+
* replica instead, so the checkpoint never covers rows the disk doesn't have.
|
|
10
|
+
* - Every write checks the database's generation, read at load: after a forget in another tab this page's writes
|
|
11
|
+
* fail as `Replaced` instead of bringing the forgotten copy back.
|
|
12
|
+
* - The outbox is written per command, never replaced whole, so tabs can't overwrite each other's intents.
|
|
13
|
+
* - Rows are read back only under the row shapes that wrote them (each table's name and schema `shape`, kept with the
|
|
14
|
+
* checkpoint): any change to a table or its schema starts the rows over, with no number to bump by hand. The next
|
|
15
|
+
* commit then writes the whole replica, so the disk is repaired, not just the memory. Rows that don't parse are
|
|
16
|
+
* treated the same. The server's facts are not rows: facts an older page kept that no longer fit load as null and
|
|
17
|
+
* never cost the copy; facts that change at the same revision are written too.
|
|
18
|
+
* - A version upgrade starts the replica over (it comes back from the server) but keeps the outbox and the generation:
|
|
19
|
+
* an intent on disk is never erased by an upgrade. One this version can't read stays on disk and is counted.
|
|
20
|
+
* - A newer version opening in another tab closes this page's connection; its writes then fail as `Replaced`.
|
|
21
|
+
*
|
|
22
|
+
* `factory` is the browser's `indexedDB`, or any implementation of the same API (the headless simulation passes an
|
|
23
|
+
* in-memory one). `interfere` is for tests: it sees each write transaction before it commits and may abort it.
|
|
24
|
+
*/
|
|
25
|
+
import { Replaced } from "../../client.js";
|
|
26
|
+
import { check } from "../../schema.js";
|
|
27
|
+
import { committed, openDatabase, request } from "../../web/idb.js";
|
|
28
|
+
function commitPlan(stored, commit) {
|
|
29
|
+
const same = stored.dataset === commit.dataset;
|
|
30
|
+
if (!same && stored.dataset !== null && stored.dataset !== commit.replaces) {
|
|
31
|
+
return "stale";
|
|
32
|
+
}
|
|
33
|
+
if (same && stored.revision >= commit.revision) {
|
|
34
|
+
return "skip";
|
|
35
|
+
}
|
|
36
|
+
if (!commit.reset && same && stored.revision >= commit.base) {
|
|
37
|
+
return "delta";
|
|
38
|
+
}
|
|
39
|
+
return "whole";
|
|
40
|
+
}
|
|
41
|
+
export function indexedReplica(options) {
|
|
42
|
+
const names = options.tables.map((lens) => lens.name);
|
|
43
|
+
const shapes = options.tables.map((lens) => lens.shape).join("\n");
|
|
44
|
+
/** The checkpoint a load found whose rows didn't parse: the next commit at it rewrites the whole replica. */
|
|
45
|
+
let damaged;
|
|
46
|
+
/** The dataset of a copy this page loaded but couldn't read (other row shapes, rows that don't parse): its own to replace. */
|
|
47
|
+
let discarded = null;
|
|
48
|
+
/**
|
|
49
|
+
* The checkpoint this page loaded, as written: a copy under other row shapes is this page's to replace only while it
|
|
50
|
+
* is still that one. Once another release's tab writes its own, that release owns the copy on this device.
|
|
51
|
+
*/
|
|
52
|
+
let loaded;
|
|
53
|
+
let opened;
|
|
54
|
+
/** The generation this page loaded; undefined until it has. */
|
|
55
|
+
let generation;
|
|
56
|
+
/** A newer version took the database over: this page must reload. */
|
|
57
|
+
let superseded = false;
|
|
58
|
+
/**
|
|
59
|
+
* The version to open: `version`, or past what the browser has when that lacks a store these tables need (a table
|
|
60
|
+
* added without a bump) or is already newer (another release's tab moved it): the layout follows the tables, so a
|
|
61
|
+
* forgotten bump can't leave the copy unopenable.
|
|
62
|
+
*/
|
|
63
|
+
const layout = async () => {
|
|
64
|
+
const probe = await new Promise((resolve, reject) => {
|
|
65
|
+
const opening = options.factory.open(options.name);
|
|
66
|
+
opening.onsuccess = () => resolve(opening.result);
|
|
67
|
+
opening.onerror = () => reject(opening.error ?? new Error(`can't open ${options.name}`));
|
|
68
|
+
});
|
|
69
|
+
const missing = [...names, "meta", "outbox"].some((name) => !probe.objectStoreNames.contains(name));
|
|
70
|
+
const current = probe.version;
|
|
71
|
+
probe.close();
|
|
72
|
+
if (current >= options.version && !missing) {
|
|
73
|
+
return current;
|
|
74
|
+
}
|
|
75
|
+
return Math.max(current + (missing ? 1 : 0), options.version);
|
|
76
|
+
};
|
|
77
|
+
const open = () => {
|
|
78
|
+
opened ??= layout()
|
|
79
|
+
.then((version) => openDatabase(options.factory, options.name, version, (database, _from, upgrade) => {
|
|
80
|
+
// Replica tables start over (the server has them); every other store an earlier version kept may hold work
|
|
81
|
+
// nobody else has, so its records move into the outbox: converted by `legacy`, else kept as they were under
|
|
82
|
+
// their own key, counted unreadable. Nothing is dropped unseen.
|
|
83
|
+
const legacy = options.legacy;
|
|
84
|
+
const own = new Set(["meta", "outbox"]);
|
|
85
|
+
if (!database.objectStoreNames.contains("outbox")) {
|
|
86
|
+
database.createObjectStore("outbox");
|
|
87
|
+
}
|
|
88
|
+
if (database.objectStoreNames.contains("meta")) {
|
|
89
|
+
upgrade.objectStore("meta").delete("checkpoint");
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
database.createObjectStore("meta");
|
|
93
|
+
}
|
|
94
|
+
const outbox = upgrade.objectStore("outbox");
|
|
95
|
+
// A snapshot: deleting a store changes the live list being walked.
|
|
96
|
+
const existing = [...database.objectStoreNames];
|
|
97
|
+
for (const name of existing) {
|
|
98
|
+
if (own.has(name)) {
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (names.includes(name)) {
|
|
102
|
+
database.deleteObjectStore(name);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
const cursor = upgrade.objectStore(name).openCursor();
|
|
106
|
+
let at = 0;
|
|
107
|
+
cursor.onsuccess = () => {
|
|
108
|
+
const current = cursor.result;
|
|
109
|
+
if (current === null) {
|
|
110
|
+
database.deleteObjectStore(name);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const value = current.value;
|
|
114
|
+
const command = legacy?.store === name ? legacy.convert(value) : undefined;
|
|
115
|
+
if (command === undefined) {
|
|
116
|
+
outbox.put(value, ["legacy", name, at++]);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
outbox.put(command, [command.client, command.id]);
|
|
120
|
+
}
|
|
121
|
+
current.continue();
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
for (const name of names) {
|
|
125
|
+
database.createObjectStore(name);
|
|
126
|
+
}
|
|
127
|
+
}))
|
|
128
|
+
.then((database) => {
|
|
129
|
+
// The browser closed it under us (iOS Safari, cleared site data): the next call opens it again.
|
|
130
|
+
database.onclose = () => {
|
|
131
|
+
opened = undefined;
|
|
132
|
+
};
|
|
133
|
+
database.onversionchange = () => {
|
|
134
|
+
superseded = true;
|
|
135
|
+
database.close();
|
|
136
|
+
};
|
|
137
|
+
return database;
|
|
138
|
+
});
|
|
139
|
+
// A failed open may succeed later (a blocked upgrade): try again next time.
|
|
140
|
+
opened.catch(() => (opened = undefined));
|
|
141
|
+
return opened;
|
|
142
|
+
};
|
|
143
|
+
const db = async () => {
|
|
144
|
+
if (superseded) {
|
|
145
|
+
throw new Replaced();
|
|
146
|
+
}
|
|
147
|
+
return await open();
|
|
148
|
+
};
|
|
149
|
+
/** Inside a write transaction: refuse if the database was forgotten since this page loaded. */
|
|
150
|
+
async function guard(transaction) {
|
|
151
|
+
const stored = await request(transaction.objectStore("meta").get("generation"));
|
|
152
|
+
if ((typeof stored === "number" ? stored : 0) !== (generation ?? 0)) {
|
|
153
|
+
transaction.abort();
|
|
154
|
+
throw new Replaced();
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/** The checkpoint on disk as this version may trust it: one of other row shapes (or none) holds nothing. */
|
|
158
|
+
const checkpointOf = (value) => {
|
|
159
|
+
if (typeof value !== "object" || value === null) {
|
|
160
|
+
return { dataset: null, revision: 0 };
|
|
161
|
+
}
|
|
162
|
+
const dataset = Reflect.get(value, "dataset");
|
|
163
|
+
const revision = Reflect.get(value, "revision");
|
|
164
|
+
if (Reflect.get(value, "shapes") !== shapes) {
|
|
165
|
+
return { dataset: null, revision: 0 };
|
|
166
|
+
}
|
|
167
|
+
const at = {
|
|
168
|
+
dataset: typeof dataset === "string" ? dataset : null,
|
|
169
|
+
revision: typeof revision === "number" ? revision : 0,
|
|
170
|
+
};
|
|
171
|
+
if (damaged !== undefined &&
|
|
172
|
+
damaged.dataset === at.dataset &&
|
|
173
|
+
damaged.revision === at.revision) {
|
|
174
|
+
return { dataset: null, revision: 0 };
|
|
175
|
+
}
|
|
176
|
+
return at;
|
|
177
|
+
};
|
|
178
|
+
return {
|
|
179
|
+
async load() {
|
|
180
|
+
const database = await db();
|
|
181
|
+
const transaction = database.transaction([...names, "meta", "outbox"], "readonly");
|
|
182
|
+
const entries = (name) => Promise.all([
|
|
183
|
+
request(transaction.objectStore(name).getAllKeys()),
|
|
184
|
+
request(transaction.objectStore(name).getAll()),
|
|
185
|
+
]).then(([keys, rows]) => keys.flatMap((key, at) => typeof key === "string" || typeof key === "number" ? [{ key, row: rows[at] }] : []));
|
|
186
|
+
const [stored, point, outbox, ...tables] = await Promise.all([
|
|
187
|
+
request(transaction.objectStore("meta").get("generation")),
|
|
188
|
+
request(transaction.objectStore("meta").get("checkpoint")),
|
|
189
|
+
request(transaction.objectStore("outbox").getAll()),
|
|
190
|
+
...names.map(entries),
|
|
191
|
+
]);
|
|
192
|
+
generation = typeof stored === "number" ? stored : 0;
|
|
193
|
+
// A tap kept before taps recorded their copy's revision was made on the copy beside it on the disk: it takes
|
|
194
|
+
// that checkpoint's revision now, before the rows can be discarded (other row shapes, rows that don't parse),
|
|
195
|
+
// so a copy started over never makes it look like it was made on nothing.
|
|
196
|
+
const disk = typeof point === "object" && point !== null ? Reflect.get(point, "revision") : 0;
|
|
197
|
+
const commands = outbox.flatMap((each) => {
|
|
198
|
+
const parsed = check(options.pending, each);
|
|
199
|
+
if (!parsed.ok) {
|
|
200
|
+
return [];
|
|
201
|
+
}
|
|
202
|
+
return [
|
|
203
|
+
parsed.value.since === undefined && typeof disk === "number" && disk > 0
|
|
204
|
+
? { ...parsed.value, since: disk }
|
|
205
|
+
: parsed.value,
|
|
206
|
+
];
|
|
207
|
+
});
|
|
208
|
+
const unreadable = outbox.length - commands.length;
|
|
209
|
+
const empty = {
|
|
210
|
+
dataset: null,
|
|
211
|
+
revision: 0,
|
|
212
|
+
tables: options.empty,
|
|
213
|
+
server: null,
|
|
214
|
+
};
|
|
215
|
+
const at = checkpointOf(point);
|
|
216
|
+
const raw = typeof point === "object" && point !== null ? Reflect.get(point, "dataset") : null;
|
|
217
|
+
discarded = typeof raw === "string" && raw !== at.dataset ? raw : null;
|
|
218
|
+
loaded =
|
|
219
|
+
typeof point === "object" && point !== null
|
|
220
|
+
? {
|
|
221
|
+
dataset: raw,
|
|
222
|
+
revision: disk,
|
|
223
|
+
shapes: Reflect.get(point, "shapes"),
|
|
224
|
+
}
|
|
225
|
+
: undefined;
|
|
226
|
+
const server = typeof point === "object" && point !== null
|
|
227
|
+
? check(options.server, Reflect.get(point, "server"))
|
|
228
|
+
: undefined;
|
|
229
|
+
let restored = options.empty;
|
|
230
|
+
for (const [index, lens] of options.tables.entries()) {
|
|
231
|
+
restored = restored === undefined ? undefined : lens.restore(restored, tables[index] ?? []);
|
|
232
|
+
}
|
|
233
|
+
// Rows written under other shapes, or that no longer fit: start over rather than show wrong data, and let the
|
|
234
|
+
// next commit rewrite the disk. The server's facts are not rows (every pull brings them again), so facts an
|
|
235
|
+
// older page kept that no longer fit wait for the next pull instead of costing the copy.
|
|
236
|
+
if (restored === undefined && at.dataset !== null) {
|
|
237
|
+
damaged = at;
|
|
238
|
+
discarded = at.dataset;
|
|
239
|
+
}
|
|
240
|
+
if (restored === undefined || at.dataset === null) {
|
|
241
|
+
return { replica: empty, commands, unreadable };
|
|
242
|
+
}
|
|
243
|
+
return {
|
|
244
|
+
replica: {
|
|
245
|
+
dataset: at.dataset,
|
|
246
|
+
revision: at.revision,
|
|
247
|
+
tables: restored,
|
|
248
|
+
server: server?.ok === true ? server.value : null,
|
|
249
|
+
},
|
|
250
|
+
commands,
|
|
251
|
+
unreadable,
|
|
252
|
+
};
|
|
253
|
+
},
|
|
254
|
+
async commit(commit) {
|
|
255
|
+
const database = await db();
|
|
256
|
+
const transaction = database.transaction([...names, "meta"], "readwrite");
|
|
257
|
+
const done = committed(transaction);
|
|
258
|
+
const meta = transaction.objectStore("meta");
|
|
259
|
+
const target = {
|
|
260
|
+
dataset: commit.replica.dataset,
|
|
261
|
+
revision: commit.replica.revision,
|
|
262
|
+
server: commit.replica.server,
|
|
263
|
+
shapes,
|
|
264
|
+
};
|
|
265
|
+
try {
|
|
266
|
+
await guard(transaction);
|
|
267
|
+
const point = await request(meta.get("checkpoint"));
|
|
268
|
+
// The disk holds another dataset this page never saw (the server replaced the one it is on): refused,
|
|
269
|
+
// whatever row shapes wrote it, so a tab of another release can't write a replaced dataset back.
|
|
270
|
+
const held = typeof point === "object" && point !== null ? Reflect.get(point, "dataset") : null;
|
|
271
|
+
if (typeof held === "string" &&
|
|
272
|
+
held !== commit.replica.dataset &&
|
|
273
|
+
held !== commit.replaces &&
|
|
274
|
+
held !== discarded) {
|
|
275
|
+
transaction.abort();
|
|
276
|
+
throw new Replaced();
|
|
277
|
+
}
|
|
278
|
+
const stored = checkpointOf(point);
|
|
279
|
+
const plan = commitPlan(stored, {
|
|
280
|
+
...target,
|
|
281
|
+
reset: commit.reset,
|
|
282
|
+
base: commit.base,
|
|
283
|
+
replaces: commit.replaces,
|
|
284
|
+
});
|
|
285
|
+
// The disk holds a dataset this page never saw: the server replaced the one this page is on.
|
|
286
|
+
if (plan === "stale") {
|
|
287
|
+
transaction.abort();
|
|
288
|
+
throw new Replaced();
|
|
289
|
+
}
|
|
290
|
+
const revision = typeof point === "object" && point !== null ? Reflect.get(point, "revision") : 0;
|
|
291
|
+
const written = typeof point === "object" && point !== null ? Reflect.get(point, "shapes") : undefined;
|
|
292
|
+
// Another release's tab wrote this dataset under its own row shapes since this page loaded: that release owns
|
|
293
|
+
// the copy now, at any revision, and this page stops writing until it reloads.
|
|
294
|
+
if (held === commit.replica.dataset &&
|
|
295
|
+
written !== shapes &&
|
|
296
|
+
(loaded === undefined ||
|
|
297
|
+
loaded.dataset !== held ||
|
|
298
|
+
loaded.revision !== revision ||
|
|
299
|
+
loaded.shapes !== written)) {
|
|
300
|
+
transaction.abort();
|
|
301
|
+
throw new Replaced();
|
|
302
|
+
}
|
|
303
|
+
// The copy this page loaded and couldn't read (other row shapes, rows that don't parse) still fences it: never
|
|
304
|
+
// rewritten below its revision. The page catches up from the server and writes it then.
|
|
305
|
+
if (plan === "whole" &&
|
|
306
|
+
held === commit.replica.dataset &&
|
|
307
|
+
typeof revision === "number" &&
|
|
308
|
+
revision > commit.replica.revision) {
|
|
309
|
+
transaction.abort();
|
|
310
|
+
throw new Error("this device holds a newer copy than this page");
|
|
311
|
+
}
|
|
312
|
+
const write = (ops) => {
|
|
313
|
+
for (const op of ops) {
|
|
314
|
+
if ("drop" in op) {
|
|
315
|
+
transaction.objectStore(op.table).delete(op.key);
|
|
316
|
+
}
|
|
317
|
+
else {
|
|
318
|
+
transaction.objectStore(op.table).put(op.row, op.key);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
if (plan === "delta") {
|
|
323
|
+
write(commit.ops);
|
|
324
|
+
}
|
|
325
|
+
if (plan === "whole") {
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
transaction.objectStore(name).clear();
|
|
328
|
+
}
|
|
329
|
+
for (const lens of options.tables) {
|
|
330
|
+
write(lens.snapshot(commit.replica.tables));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
// At the same revision the rows are already there, but the server's facts may have moved: they are kept too.
|
|
334
|
+
const facts = plan === "skip" && stored.revision === target.revision;
|
|
335
|
+
if (plan !== "skip" || facts) {
|
|
336
|
+
meta.put(target, "checkpoint");
|
|
337
|
+
}
|
|
338
|
+
options.interfere?.("commit", transaction);
|
|
339
|
+
await done;
|
|
340
|
+
if (plan === "whole") {
|
|
341
|
+
damaged = undefined;
|
|
342
|
+
discarded = null;
|
|
343
|
+
loaded = undefined;
|
|
344
|
+
}
|
|
345
|
+
const now = plan === "skip" ? stored : target;
|
|
346
|
+
if (now.dataset === null) {
|
|
347
|
+
throw new Error("the replica has no dataset");
|
|
348
|
+
}
|
|
349
|
+
return { dataset: now.dataset, revision: now.revision };
|
|
350
|
+
}
|
|
351
|
+
catch (error) {
|
|
352
|
+
await done.catch(() => undefined);
|
|
353
|
+
throw error;
|
|
354
|
+
}
|
|
355
|
+
},
|
|
356
|
+
async keep(pending) {
|
|
357
|
+
const database = await db();
|
|
358
|
+
const transaction = database.transaction(["meta", "outbox"], "readwrite");
|
|
359
|
+
const done = committed(transaction);
|
|
360
|
+
try {
|
|
361
|
+
await guard(transaction);
|
|
362
|
+
transaction.objectStore("outbox").put(pending, [pending.client, pending.id]);
|
|
363
|
+
options.interfere?.("keep", transaction);
|
|
364
|
+
await done;
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
await done.catch(() => undefined);
|
|
368
|
+
throw error;
|
|
369
|
+
}
|
|
370
|
+
},
|
|
371
|
+
async drop(keys) {
|
|
372
|
+
const database = await db();
|
|
373
|
+
const transaction = database.transaction(["meta", "outbox"], "readwrite");
|
|
374
|
+
const done = committed(transaction);
|
|
375
|
+
try {
|
|
376
|
+
await guard(transaction);
|
|
377
|
+
for (const key of keys) {
|
|
378
|
+
transaction.objectStore("outbox").delete([key.client, key.id]);
|
|
379
|
+
}
|
|
380
|
+
options.interfere?.("drop", transaction);
|
|
381
|
+
await done;
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
await done.catch(() => undefined);
|
|
385
|
+
throw error;
|
|
386
|
+
}
|
|
387
|
+
},
|
|
388
|
+
/** Drop this device's copy and its outbox, and move to a new generation so no open tab writes it back. */
|
|
389
|
+
async forget() {
|
|
390
|
+
const database = await db();
|
|
391
|
+
const transaction = database.transaction([...names, "meta", "outbox"], "readwrite");
|
|
392
|
+
const done = committed(transaction);
|
|
393
|
+
const meta = transaction.objectStore("meta");
|
|
394
|
+
const stored = await request(meta.get("generation"));
|
|
395
|
+
for (const name of [...names, "outbox"]) {
|
|
396
|
+
transaction.objectStore(name).clear();
|
|
397
|
+
}
|
|
398
|
+
meta.delete("checkpoint");
|
|
399
|
+
meta.put((typeof stored === "number" ? stored : 0) + 1, "generation");
|
|
400
|
+
await done;
|
|
401
|
+
},
|
|
402
|
+
};
|
|
403
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { expect, test } from "vitest";
|
|
2
|
+
import { createClient } from "../client.js";
|
|
3
|
+
import * as syncApi from "../sync.js";
|
|
4
|
+
import { createSync } from "../sync.js";
|
|
5
|
+
import { database, deferred, pending, rowsLens } from "./fixtures.js";
|
|
6
|
+
function bounded(max = 3, joins = false) {
|
|
7
|
+
return createSync({
|
|
8
|
+
tables: [rowsLens],
|
|
9
|
+
empty: { rows: new Map() },
|
|
10
|
+
onlineOnly: () => false,
|
|
11
|
+
refused: (_command, reason) => ({ kind: "failed", reason }),
|
|
12
|
+
outboxMax: max,
|
|
13
|
+
joins: (waiting, next) => joins && waiting.value === next.value,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
const offline = {
|
|
17
|
+
pull: async () => ({ ok: false, failure: { kind: "offline", message: "offline" } }),
|
|
18
|
+
push: async () => {
|
|
19
|
+
throw new Error("no offline intent may be sent");
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
function client(stored, id, max = 3, joins = false) {
|
|
23
|
+
const answers = [];
|
|
24
|
+
const runtime = createClient({
|
|
25
|
+
sync: bounded(max, joins),
|
|
26
|
+
client: id,
|
|
27
|
+
ports: {
|
|
28
|
+
storage: stored.port,
|
|
29
|
+
network: offline,
|
|
30
|
+
now: () => 0,
|
|
31
|
+
fingerprint: async () => "",
|
|
32
|
+
answered: (answer) => {
|
|
33
|
+
answers.push(answer);
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
return { runtime, answers };
|
|
38
|
+
}
|
|
39
|
+
test("outbox has a shared default of 500 at the storage boundary", async () => {
|
|
40
|
+
const stored = database();
|
|
41
|
+
await stored.port.load();
|
|
42
|
+
const results = await Promise.all(Array.from({ length: 501 }, (_, at) => stored.port.keep(pending("page", at + 1))));
|
|
43
|
+
expect(results.slice(0, 500)).toStrictEqual(Array.from({ length: 500 }, () => undefined));
|
|
44
|
+
expect(results[500]).toStrictEqual({ kind: "full", waiting: 500, finished: 0, max: 500 });
|
|
45
|
+
expect((await stored.port.load()).commands).toHaveLength(500);
|
|
46
|
+
expect(syncApi.OUTBOX_MAX).toBe(500);
|
|
47
|
+
});
|
|
48
|
+
test("outboxMax must be a positive safe integer", () => {
|
|
49
|
+
for (const max of [0, -1, 1.5, Number.NaN]) {
|
|
50
|
+
expect(() => bounded(max)).toThrow(/outboxMax must be a positive integer/);
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
test("keep admits and counts in one transaction, but always permits an existing key's outcome", async () => {
|
|
54
|
+
const stored = database();
|
|
55
|
+
await stored.port.load();
|
|
56
|
+
const first = pending("page", 1);
|
|
57
|
+
expect(await stored.port.keep(first, 1)).toBeUndefined();
|
|
58
|
+
expect(await stored.port.keep(pending("page", 2), 1)).toStrictEqual({
|
|
59
|
+
kind: "full",
|
|
60
|
+
waiting: 1,
|
|
61
|
+
finished: 0,
|
|
62
|
+
max: 1,
|
|
63
|
+
});
|
|
64
|
+
expect(await stored.port.keep({ ...first, state: { kind: "done", outcome: { kind: "ok", resource: "r1" }, revision: 0 } }, 1)).toBeUndefined();
|
|
65
|
+
expect(await stored.port.keep(pending("page", 3), 1)).toStrictEqual({
|
|
66
|
+
kind: "full",
|
|
67
|
+
waiting: 0,
|
|
68
|
+
finished: 1,
|
|
69
|
+
max: 1,
|
|
70
|
+
});
|
|
71
|
+
expect((await stored.port.load()).commands).toHaveLength(1);
|
|
72
|
+
});
|
|
73
|
+
test("two offline clients share one transactional admission bound; every unadmitted tap is answered without a record", async () => {
|
|
74
|
+
const a = database();
|
|
75
|
+
const b = database({ factory: a.factory, name: a.name });
|
|
76
|
+
const ca = client(a, "tab-a");
|
|
77
|
+
const cb = client(b, "tab-b");
|
|
78
|
+
ca.runtime.start();
|
|
79
|
+
cb.runtime.start();
|
|
80
|
+
await Promise.all([ca.runtime.settle(), cb.runtime.settle()]);
|
|
81
|
+
// Neither page has heard of the other's taps. All six keeps overlap before either page gets an acknowledgement.
|
|
82
|
+
for (const value of [1, 2, 3]) {
|
|
83
|
+
ca.runtime.command({ name: "put", value });
|
|
84
|
+
cb.runtime.command({ name: "put", value: value + 10 });
|
|
85
|
+
}
|
|
86
|
+
await Promise.all([ca.runtime.settle(), cb.runtime.settle()]);
|
|
87
|
+
const disk = await a.port.load();
|
|
88
|
+
const models = [...ca.runtime.model().commands, ...cb.runtime.model().commands];
|
|
89
|
+
const refused = [...ca.answers, ...cb.answers];
|
|
90
|
+
expect(disk.commands).toHaveLength(3);
|
|
91
|
+
expect(models).toHaveLength(3);
|
|
92
|
+
expect(new Set(disk.commands.map((each) => `${each.client}:${each.id}`)).size).toBe(3);
|
|
93
|
+
expect(models
|
|
94
|
+
.map((each) => `${each.client}:${each.id}`)
|
|
95
|
+
.toSorted((left, right) => left.localeCompare(right))).toStrictEqual(disk.commands
|
|
96
|
+
.map((each) => `${each.client}:${each.id}`)
|
|
97
|
+
.toSorted((left, right) => left.localeCompare(right)));
|
|
98
|
+
expect(refused).toHaveLength(3);
|
|
99
|
+
for (const answer of refused) {
|
|
100
|
+
expect(answer.state).toMatchObject({
|
|
101
|
+
kind: "done",
|
|
102
|
+
revision: 0,
|
|
103
|
+
outcome: { kind: "failed" },
|
|
104
|
+
});
|
|
105
|
+
if (answer.state.kind !== "done" || answer.state.outcome.kind !== "failed") {
|
|
106
|
+
throw new Error("not a refused tap");
|
|
107
|
+
}
|
|
108
|
+
expect(answer.state.outcome.reason).toMatch(/outbox.*full/i);
|
|
109
|
+
expect(models).not.toContainEqual(answer);
|
|
110
|
+
expect(disk.commands.some((each) => each.client === answer.client && each.id === answer.id)).toBe(false);
|
|
111
|
+
}
|
|
112
|
+
expect(ca.runtime.model().storage.kind).toBe("ok");
|
|
113
|
+
expect(cb.runtime.model().storage.kind).toBe("ok");
|
|
114
|
+
expect([...ca.runtime.faults, ...cb.runtime.faults]).toStrictEqual([]);
|
|
115
|
+
const denied = ca.answers.length > 0 ? ca : cb;
|
|
116
|
+
expect(syncApi.deriveOutboxState(denied.runtime.model())).toStrictEqual({
|
|
117
|
+
kind: "full",
|
|
118
|
+
waiting: 3,
|
|
119
|
+
finished: 0,
|
|
120
|
+
max: 3,
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
test("a full outbox leaves model and disk unchanged; joins coalesces before admission", async () => {
|
|
124
|
+
const stored = database();
|
|
125
|
+
const app = client(stored, "page", 1, true);
|
|
126
|
+
app.runtime.start();
|
|
127
|
+
await app.runtime.settle();
|
|
128
|
+
app.runtime.command({ name: "put", value: 1 });
|
|
129
|
+
await app.runtime.settle();
|
|
130
|
+
const before = (await stored.port.load()).commands;
|
|
131
|
+
app.runtime.command({ name: "put", value: 1 });
|
|
132
|
+
await app.runtime.settle();
|
|
133
|
+
expect(app.answers).toStrictEqual([]);
|
|
134
|
+
app.runtime.command({ name: "put", value: 2 });
|
|
135
|
+
await app.runtime.settle();
|
|
136
|
+
expect(app.runtime.model().commands).toHaveLength(1);
|
|
137
|
+
expect((await stored.port.load()).commands).toStrictEqual(before);
|
|
138
|
+
expect(app.answers).toHaveLength(1);
|
|
139
|
+
expect(app.runtime.model().storage.kind).toBe("ok");
|
|
140
|
+
});
|
|
141
|
+
test("a late load plus concurrent keeps obeys the disk count, not the page's pre-load count", async () => {
|
|
142
|
+
const stored = database();
|
|
143
|
+
const old = client(stored, "old");
|
|
144
|
+
old.runtime.start();
|
|
145
|
+
await old.runtime.settle();
|
|
146
|
+
for (const value of [1, 2, 3]) {
|
|
147
|
+
old.runtime.command({ name: "put", value });
|
|
148
|
+
}
|
|
149
|
+
await old.runtime.settle();
|
|
150
|
+
const loaded = deferred();
|
|
151
|
+
const answers = [];
|
|
152
|
+
const late = createClient({
|
|
153
|
+
sync: bounded(),
|
|
154
|
+
client: "late",
|
|
155
|
+
ports: {
|
|
156
|
+
storage: { ...stored.port, load: () => loaded.promise },
|
|
157
|
+
network: offline,
|
|
158
|
+
now: () => 3001,
|
|
159
|
+
fingerprint: async () => "",
|
|
160
|
+
answered: (answer) => {
|
|
161
|
+
answers.push(answer);
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
late.start();
|
|
166
|
+
late.dispatch({ type: "tick", now: 6002 });
|
|
167
|
+
late.command({ name: "put", value: 4 });
|
|
168
|
+
loaded.resolve(await stored.port.load());
|
|
169
|
+
await late.settle();
|
|
170
|
+
expect((await stored.port.load()).commands.map((each) => each.command.value)).toStrictEqual([
|
|
171
|
+
1, 2, 3,
|
|
172
|
+
]);
|
|
173
|
+
expect(late.model().commands.map((each) => each.command.value)).toStrictEqual([1, 2, 3]);
|
|
174
|
+
expect(answers).toHaveLength(1);
|
|
175
|
+
});
|
|
176
|
+
test("grandfathered records are never dropped; outcomes update at capacity and dismissal permits a new admission", async () => {
|
|
177
|
+
const stored = database();
|
|
178
|
+
const old = client(stored, "old", 4);
|
|
179
|
+
old.runtime.start();
|
|
180
|
+
await old.runtime.settle();
|
|
181
|
+
for (const value of [1, 2, 3, 4]) {
|
|
182
|
+
old.runtime.command({ name: "put", value });
|
|
183
|
+
}
|
|
184
|
+
await old.runtime.settle();
|
|
185
|
+
const reopened = database({ factory: stored.factory, name: stored.name });
|
|
186
|
+
const app = client(reopened, "new", 3);
|
|
187
|
+
app.runtime.start();
|
|
188
|
+
await app.runtime.settle();
|
|
189
|
+
// A late receipt is an update of an existing key, even when the current bound is below the existing count.
|
|
190
|
+
app.runtime.dispatch({
|
|
191
|
+
type: "pushed",
|
|
192
|
+
client: "old",
|
|
193
|
+
ids: [1],
|
|
194
|
+
revision: 0,
|
|
195
|
+
receipts: [{ id: 1, outcome: { kind: "ok", resource: "r1" }, revision: 0 }],
|
|
196
|
+
now: 0,
|
|
197
|
+
});
|
|
198
|
+
await app.runtime.settle();
|
|
199
|
+
expect((await stored.port.load()).commands).toHaveLength(4);
|
|
200
|
+
expect((await stored.port.load()).commands[0]?.state).toStrictEqual({
|
|
201
|
+
kind: "done",
|
|
202
|
+
outcome: { kind: "ok", resource: "r1" },
|
|
203
|
+
revision: 0,
|
|
204
|
+
});
|
|
205
|
+
app.runtime.command({ name: "put", value: 5 });
|
|
206
|
+
await app.runtime.settle();
|
|
207
|
+
expect((await stored.port.load()).commands).toHaveLength(4);
|
|
208
|
+
expect(syncApi.deriveOutboxState(app.runtime.model())).toStrictEqual({
|
|
209
|
+
kind: "full",
|
|
210
|
+
waiting: 3,
|
|
211
|
+
finished: 1,
|
|
212
|
+
max: 3,
|
|
213
|
+
});
|
|
214
|
+
// The count must be below max, not merely equal to max.
|
|
215
|
+
app.runtime.dispatch({ type: "dismiss", client: "old", id: 1, now: 0 });
|
|
216
|
+
await app.runtime.settle();
|
|
217
|
+
app.runtime.command({ name: "put", value: 6 });
|
|
218
|
+
await app.runtime.settle();
|
|
219
|
+
expect((await stored.port.load()).commands).toHaveLength(3);
|
|
220
|
+
app.runtime.dispatch({
|
|
221
|
+
type: "pushed",
|
|
222
|
+
client: "old",
|
|
223
|
+
ids: [2],
|
|
224
|
+
revision: 0,
|
|
225
|
+
receipts: [{ id: 2, outcome: { kind: "ok", resource: "r2" }, revision: 0 }],
|
|
226
|
+
now: 0,
|
|
227
|
+
});
|
|
228
|
+
await app.runtime.settle();
|
|
229
|
+
app.runtime.dispatch({ type: "dismiss", client: "old", id: 2, now: 0 });
|
|
230
|
+
await app.runtime.settle();
|
|
231
|
+
app.runtime.command({ name: "put", value: 7 });
|
|
232
|
+
await app.runtime.settle();
|
|
233
|
+
expect((await stored.port.load()).commands.map((each) => each.command.value).toSorted((a, b) => a - b)).toStrictEqual([3, 4, 7]);
|
|
234
|
+
expect(app.runtime.faults).toStrictEqual([]);
|
|
235
|
+
});
|
|
236
|
+
test("the outbox subject distinguishes waiting work from finished work needing dismissal", async () => {
|
|
237
|
+
const stored = database();
|
|
238
|
+
const app = client(stored, "page", 1);
|
|
239
|
+
app.runtime.start();
|
|
240
|
+
await app.runtime.settle();
|
|
241
|
+
expect(syncApi.deriveOutboxState(app.runtime.model())).toStrictEqual({ kind: "ok" });
|
|
242
|
+
app.runtime.command({ name: "put", value: 1 });
|
|
243
|
+
await app.runtime.settle();
|
|
244
|
+
expect(syncApi.deriveOutboxState(app.runtime.model())).toStrictEqual({
|
|
245
|
+
kind: "full",
|
|
246
|
+
waiting: 1,
|
|
247
|
+
finished: 0,
|
|
248
|
+
max: 1,
|
|
249
|
+
});
|
|
250
|
+
app.runtime.dispatch({
|
|
251
|
+
type: "pushed",
|
|
252
|
+
client: "page",
|
|
253
|
+
ids: [1],
|
|
254
|
+
revision: 0,
|
|
255
|
+
receipts: [{ id: 1, outcome: { kind: "ok", resource: "r1" }, revision: 0 }],
|
|
256
|
+
now: 0,
|
|
257
|
+
});
|
|
258
|
+
await app.runtime.settle();
|
|
259
|
+
expect(syncApi.deriveOutboxState(app.runtime.model())).toStrictEqual({
|
|
260
|
+
kind: "full",
|
|
261
|
+
waiting: 0,
|
|
262
|
+
finished: 1,
|
|
263
|
+
max: 1,
|
|
264
|
+
});
|
|
265
|
+
app.runtime.dispatch({ type: "dismiss", client: "page", id: 1, now: 0 });
|
|
266
|
+
await app.runtime.settle();
|
|
267
|
+
expect(syncApi.deriveOutboxState(app.runtime.model())).toStrictEqual({ kind: "ok" });
|
|
268
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|