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
package/dist/web/replica.js
CHANGED
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
* replica instead, so the checkpoint never covers rows the disk doesn't have.
|
|
8
8
|
* - Every write checks the database's generation, read at load: after a forget in another tab this page's writes
|
|
9
9
|
* fail as `Replaced` instead of bringing the forgotten copy back.
|
|
10
|
-
* - The outbox is written per command, never replaced whole, so tabs can't overwrite each other's intents.
|
|
10
|
+
* - The outbox is written per command, never replaced whole, so tabs can't overwrite each other's intents. A new
|
|
11
|
+
* command is admitted inside its own write's transaction, from the store's count, so every tab shares one bound;
|
|
12
|
+
* a kept one always updates, and nothing kept is dropped for room.
|
|
11
13
|
* - Rows are read back only under the row shapes that wrote them (each table's name and schema `shape`, kept with the
|
|
12
14
|
* checkpoint): any change to a table or its schema starts the rows over, with no number to bump by hand. The next
|
|
13
15
|
* 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.
|
|
16
|
+
* treated the same.
|
|
17
|
+
* - A partial copy (a first copy still paging in) keeps its history in the checkpoint, written in the same transaction
|
|
18
|
+
* as its rows, and marks its row shapes as partial: a release from before paging reads it as another release's copy,
|
|
19
|
+
* 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
|
|
20
|
+
* 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
21
|
* never cost the copy; facts that change at the same revision are written too.
|
|
16
22
|
* - A version upgrade starts the replica over (it comes back from the server) but keeps the outbox and the generation:
|
|
17
23
|
* an intent on disk is never erased by an upgrade. One this version can't read stays on disk and is counted.
|
|
@@ -21,12 +27,24 @@
|
|
|
21
27
|
* in-memory one). `interfere` is for tests: it sees each write transaction before it commits and may abort it.
|
|
22
28
|
*/
|
|
23
29
|
import { Replaced } from "../client.js";
|
|
24
|
-
import { check } from "../schema.js";
|
|
25
|
-
import { commitPlan } from "../sync.js";
|
|
30
|
+
import { check, literal, nullable, number, object, string } from "../schema.js";
|
|
31
|
+
import { commitPlan, OUTBOX_MAX } from "../sync.js";
|
|
26
32
|
import { committed, openDatabase, request } from "./idb.js";
|
|
33
|
+
const COMPLETE = { kind: "complete" };
|
|
34
|
+
const count = () => number({ integer: true, min: 0 });
|
|
35
|
+
const partialSchema = object({
|
|
36
|
+
kind: literal("partial"),
|
|
37
|
+
next: string(),
|
|
38
|
+
through: nullable(string()),
|
|
39
|
+
held: count(),
|
|
40
|
+
total: count(),
|
|
41
|
+
});
|
|
27
42
|
export function indexedReplica(options) {
|
|
28
43
|
const names = options.tables.map((lens) => lens.name);
|
|
29
44
|
const shapes = options.tables.map((lens) => lens.shape).join("\n");
|
|
45
|
+
/** A partial copy's row shapes: foreign to a release from before paging. */
|
|
46
|
+
const partialShapes = `${shapes}\npartial`;
|
|
47
|
+
const ours = (written) => written === shapes || written === partialShapes;
|
|
30
48
|
/** The checkpoint a load found whose rows didn't parse: the next commit at it rewrites the whole replica. */
|
|
31
49
|
let damaged;
|
|
32
50
|
/** 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 +160,26 @@ export function indexedReplica(options) {
|
|
|
142
160
|
}
|
|
143
161
|
/** The checkpoint on disk as this version may trust it: one of other row shapes (or none) holds nothing. */
|
|
144
162
|
const checkpointOf = (value) => {
|
|
163
|
+
const none = { dataset: null, revision: 0, history: COMPLETE };
|
|
145
164
|
if (typeof value !== "object" || value === null) {
|
|
146
|
-
return
|
|
165
|
+
return none;
|
|
147
166
|
}
|
|
148
167
|
const dataset = Reflect.get(value, "dataset");
|
|
149
168
|
const revision = Reflect.get(value, "revision");
|
|
150
|
-
|
|
151
|
-
|
|
169
|
+
const written = Reflect.get(value, "shapes");
|
|
170
|
+
const partial = check(partialSchema, Reflect.get(value, "history"));
|
|
171
|
+
if (written !== shapes && (written !== partialShapes || !partial.ok)) {
|
|
172
|
+
return none;
|
|
152
173
|
}
|
|
153
174
|
const at = {
|
|
154
175
|
dataset: typeof dataset === "string" ? dataset : null,
|
|
155
176
|
revision: typeof revision === "number" ? revision : 0,
|
|
177
|
+
history: written === partialShapes && partial.ok ? partial.value : COMPLETE,
|
|
156
178
|
};
|
|
157
179
|
if (damaged !== undefined &&
|
|
158
180
|
damaged.dataset === at.dataset &&
|
|
159
181
|
damaged.revision === at.revision) {
|
|
160
|
-
return
|
|
182
|
+
return none;
|
|
161
183
|
}
|
|
162
184
|
return at;
|
|
163
185
|
};
|
|
@@ -197,6 +219,7 @@ export function indexedReplica(options) {
|
|
|
197
219
|
revision: 0,
|
|
198
220
|
tables: options.empty,
|
|
199
221
|
server: null,
|
|
222
|
+
history: COMPLETE,
|
|
200
223
|
};
|
|
201
224
|
const at = checkpointOf(point);
|
|
202
225
|
const raw = typeof point === "object" && point !== null ? Reflect.get(point, "dataset") : null;
|
|
@@ -232,6 +255,7 @@ export function indexedReplica(options) {
|
|
|
232
255
|
revision: at.revision,
|
|
233
256
|
tables: restored,
|
|
234
257
|
server: server?.ok === true ? server.value : null,
|
|
258
|
+
history: at.history,
|
|
235
259
|
},
|
|
236
260
|
commands,
|
|
237
261
|
unreadable,
|
|
@@ -246,7 +270,6 @@ export function indexedReplica(options) {
|
|
|
246
270
|
dataset: commit.replica.dataset,
|
|
247
271
|
revision: commit.replica.revision,
|
|
248
272
|
server: commit.replica.server,
|
|
249
|
-
shapes,
|
|
250
273
|
};
|
|
251
274
|
try {
|
|
252
275
|
await guard(transaction);
|
|
@@ -267,6 +290,7 @@ export function indexedReplica(options) {
|
|
|
267
290
|
reset: commit.reset,
|
|
268
291
|
base: commit.base,
|
|
269
292
|
replaces: commit.replaces,
|
|
293
|
+
...(commit.from === undefined ? {} : { from: commit.from }),
|
|
270
294
|
});
|
|
271
295
|
// The disk holds a dataset this page never saw: the server replaced the one this page is on.
|
|
272
296
|
if (plan === "stale") {
|
|
@@ -278,7 +302,7 @@ export function indexedReplica(options) {
|
|
|
278
302
|
// Another release's tab wrote this dataset under its own row shapes since this page loaded: that release owns
|
|
279
303
|
// the copy now, at any revision, and this page stops writing until it reloads.
|
|
280
304
|
if (held === commit.replica.dataset &&
|
|
281
|
-
written
|
|
305
|
+
!ours(written) &&
|
|
282
306
|
(loaded === undefined ||
|
|
283
307
|
loaded.dataset !== held ||
|
|
284
308
|
loaded.revision !== revision ||
|
|
@@ -305,7 +329,7 @@ export function indexedReplica(options) {
|
|
|
305
329
|
}
|
|
306
330
|
}
|
|
307
331
|
};
|
|
308
|
-
if (plan === "delta") {
|
|
332
|
+
if (plan === "delta" || plan === "adopt") {
|
|
309
333
|
write(commit.ops);
|
|
310
334
|
}
|
|
311
335
|
if (plan === "whole") {
|
|
@@ -318,8 +342,16 @@ export function indexedReplica(options) {
|
|
|
318
342
|
}
|
|
319
343
|
// At the same revision the rows are already there, but the server's facts may have moved: they are kept too.
|
|
320
344
|
const facts = plan === "skip" && stored.revision === target.revision;
|
|
345
|
+
// A delta keeps the disk's history; a whole write or a delta from the disk's own cursor takes the page's.
|
|
346
|
+
const history = plan === "whole" || plan === "adopt"
|
|
347
|
+
? (commit.replica.history ?? COMPLETE)
|
|
348
|
+
: stored.history;
|
|
349
|
+
const checkpoint = {
|
|
350
|
+
...target,
|
|
351
|
+
...(history.kind === "partial" ? { history, shapes: partialShapes } : { shapes }),
|
|
352
|
+
};
|
|
321
353
|
if (plan !== "skip" || facts) {
|
|
322
|
-
meta.put(
|
|
354
|
+
meta.put(checkpoint, "checkpoint");
|
|
323
355
|
}
|
|
324
356
|
options.interfere?.("commit", transaction);
|
|
325
357
|
await done;
|
|
@@ -328,26 +360,47 @@ export function indexedReplica(options) {
|
|
|
328
360
|
discarded = null;
|
|
329
361
|
loaded = undefined;
|
|
330
362
|
}
|
|
331
|
-
const now = plan === "skip" ? stored :
|
|
363
|
+
const now = plan === "skip" ? stored : checkpoint;
|
|
332
364
|
if (now.dataset === null) {
|
|
333
365
|
throw new Error("the replica has no dataset");
|
|
334
366
|
}
|
|
335
|
-
|
|
367
|
+
// A delta under the disk's own history leaves out rows of this page's copy: said, so a failed write stays owed.
|
|
368
|
+
return {
|
|
369
|
+
dataset: now.dataset,
|
|
370
|
+
revision: now.revision,
|
|
371
|
+
...(plan === "delta" && stored.history.kind === "partial" ? { whole: false } : {}),
|
|
372
|
+
};
|
|
336
373
|
}
|
|
337
374
|
catch (error) {
|
|
338
375
|
await done.catch(() => undefined);
|
|
339
376
|
throw error;
|
|
340
377
|
}
|
|
341
378
|
},
|
|
342
|
-
async keep(pending) {
|
|
379
|
+
async keep(pending, max = OUTBOX_MAX) {
|
|
343
380
|
const database = await db();
|
|
344
381
|
const transaction = database.transaction(["meta", "outbox"], "readwrite");
|
|
345
382
|
const done = committed(transaction);
|
|
383
|
+
const outbox = transaction.objectStore("outbox");
|
|
384
|
+
const key = [pending.client, pending.id];
|
|
346
385
|
try {
|
|
347
386
|
await guard(transaction);
|
|
348
|
-
|
|
387
|
+
// Admission is decided here, from the store's own count, so every tab shares one bound. A kept key always
|
|
388
|
+
// updates; records already past the bound (an older release, a larger one) stay, and keep new ones out.
|
|
389
|
+
if ((await request(outbox.count(key))) === 0 &&
|
|
390
|
+
(await request(outbox.count())) >= max) {
|
|
391
|
+
const kept = await request(outbox.getAll());
|
|
392
|
+
// Unreadable records take room too: counted as waiting, since what they hold is unknown.
|
|
393
|
+
const finished = kept.filter((each) => {
|
|
394
|
+
const parsed = check(options.pending, each);
|
|
395
|
+
return parsed.ok && parsed.value.state.kind === "done";
|
|
396
|
+
}).length;
|
|
397
|
+
await done;
|
|
398
|
+
return { kind: "full", waiting: kept.length - finished, finished, max };
|
|
399
|
+
}
|
|
400
|
+
outbox.put(pending, key);
|
|
349
401
|
options.interfere?.("keep", transaction);
|
|
350
402
|
await done;
|
|
403
|
+
return undefined;
|
|
351
404
|
}
|
|
352
405
|
catch (error) {
|
|
353
406
|
await done.catch(() => undefined);
|
|
@@ -371,6 +424,30 @@ export function indexedReplica(options) {
|
|
|
371
424
|
throw error;
|
|
372
425
|
}
|
|
373
426
|
},
|
|
427
|
+
async mark(keys) {
|
|
428
|
+
const database = await db();
|
|
429
|
+
const transaction = database.transaction(["meta", "outbox"], "readwrite");
|
|
430
|
+
const done = committed(transaction);
|
|
431
|
+
const outbox = transaction.objectStore("outbox");
|
|
432
|
+
try {
|
|
433
|
+
await guard(transaction);
|
|
434
|
+
const kept = await Promise.all(keys.map((key) => request(outbox.get([key.client, key.id]))));
|
|
435
|
+
// A push's commands, in flight, in one write: only records kept already, so nothing is admitted here.
|
|
436
|
+
for (const [at, record] of kept.entries()) {
|
|
437
|
+
const key = keys[at];
|
|
438
|
+
if (key !== undefined && typeof record === "object" && record !== null) {
|
|
439
|
+
outbox.put({ ...record, state: { kind: "sending" } }, [key.client, key.id]);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// An outbox write, as a keep is.
|
|
443
|
+
options.interfere?.("keep", transaction);
|
|
444
|
+
await done;
|
|
445
|
+
}
|
|
446
|
+
catch (error) {
|
|
447
|
+
await done.catch(() => undefined);
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
},
|
|
374
451
|
/** Drop this device's copy and its outbox, and move to a new generation so no open tab writes it back. */
|
|
375
452
|
async forget() {
|
|
376
453
|
const database = await db();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slopkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Opinionated standards and libraries for fast, local-first personal software: state machines, exhaustive state tables, sync, a command ledger, jobs, a guarded server, and the conformance suite that proves them",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"conformance",
|
|
@@ -82,6 +82,10 @@
|
|
|
82
82
|
"types": "./dist/server/jobs.d.ts",
|
|
83
83
|
"default": "./dist/server/jobs.js"
|
|
84
84
|
},
|
|
85
|
+
"./server/pull": {
|
|
86
|
+
"types": "./dist/server/pull.d.ts",
|
|
87
|
+
"default": "./dist/server/pull.js"
|
|
88
|
+
},
|
|
85
89
|
"./server/web": {
|
|
86
90
|
"types": "./dist/server/web.d.ts",
|
|
87
91
|
"default": "./dist/server/web.js"
|
|
@@ -118,6 +122,18 @@
|
|
|
118
122
|
"types": "./dist/conformance/fixtures.d.ts",
|
|
119
123
|
"default": "./dist/conformance/fixtures.js"
|
|
120
124
|
},
|
|
125
|
+
"./conformance/legacy/replica-012": {
|
|
126
|
+
"types": "./dist/conformance/legacy/replica-012.d.ts",
|
|
127
|
+
"default": "./dist/conformance/legacy/replica-012.js"
|
|
128
|
+
},
|
|
129
|
+
"./conformance/paging-fixtures": {
|
|
130
|
+
"types": "./dist/conformance/paging-fixtures.d.ts",
|
|
131
|
+
"default": "./dist/conformance/paging-fixtures.js"
|
|
132
|
+
},
|
|
133
|
+
"./conformance/pull": {
|
|
134
|
+
"types": "./dist/conformance/pull.d.ts",
|
|
135
|
+
"default": "./dist/conformance/pull.js"
|
|
136
|
+
},
|
|
121
137
|
"./package.json": "./package.json"
|
|
122
138
|
},
|
|
123
139
|
"dependencies": {
|