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.
Files changed (41) hide show
  1. package/DEPS.md +2 -1
  2. package/README.md +51 -0
  3. package/dist/client.d.ts +31 -4
  4. package/dist/client.js +25 -2
  5. package/dist/conformance/client.test.js +7 -7
  6. package/dist/conformance/fixtures.d.ts +55 -3
  7. package/dist/conformance/fixtures.js +9 -5
  8. package/dist/conformance/legacy/replica-012.d.ts +52 -0
  9. package/dist/conformance/legacy/replica-012.js +403 -0
  10. package/dist/conformance/outbox-bound.test.d.ts +1 -0
  11. package/dist/conformance/outbox-bound.test.js +268 -0
  12. package/dist/conformance/paging-budget.test.d.ts +1 -0
  13. package/dist/conformance/paging-budget.test.js +101 -0
  14. package/dist/conformance/paging-fixtures.d.ts +80 -0
  15. package/dist/conformance/paging-fixtures.js +135 -0
  16. package/dist/conformance/paging-network.test.d.ts +1 -0
  17. package/dist/conformance/paging-network.test.js +123 -0
  18. package/dist/conformance/paging-state.test.d.ts +1 -0
  19. package/dist/conformance/paging-state.test.js +180 -0
  20. package/dist/conformance/paging-storage.test.d.ts +1 -0
  21. package/dist/conformance/paging-storage.test.js +255 -0
  22. package/dist/conformance/paging.test.d.ts +1 -0
  23. package/dist/conformance/paging.test.js +209 -0
  24. package/dist/conformance/pull.d.ts +74 -0
  25. package/dist/conformance/pull.js +400 -0
  26. package/dist/conformance/push-bytes.test.d.ts +1 -0
  27. package/dist/conformance/push-bytes.test.js +174 -0
  28. package/dist/conformance/push-refusal.test.d.ts +1 -0
  29. package/dist/conformance/push-refusal.test.js +644 -0
  30. package/dist/contract.d.ts +19 -6
  31. package/dist/contract.js +76 -9
  32. package/dist/server/ledger.d.ts +13 -0
  33. package/dist/server/ledger.js +18 -0
  34. package/dist/server/pull.d.ts +85 -0
  35. package/dist/server/pull.js +324 -0
  36. package/dist/server/web.d.ts +9 -5
  37. package/dist/server/web.js +29 -12
  38. package/dist/sync.d.ts +221 -16
  39. package/dist/sync.js +565 -96
  40. package/dist/web/replica.js +93 -16
  41. package/package.json +17 -1
@@ -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. The server's facts are not rows: facts an older page kept that no longer fit load as null and
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 { dataset: null, revision: 0 };
165
+ return none;
147
166
  }
148
167
  const dataset = Reflect.get(value, "dataset");
149
168
  const revision = Reflect.get(value, "revision");
150
- if (Reflect.get(value, "shapes") !== shapes) {
151
- return { dataset: null, revision: 0 };
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 { dataset: null, revision: 0 };
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 !== shapes &&
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(target, "checkpoint");
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 : target;
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
- return { dataset: now.dataset, revision: now.revision };
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
- transaction.objectStore("outbox").put(pending, [pending.client, pending.id]);
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.1.2",
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": {