slopkit 0.1.1 → 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.
@@ -0,0 +1,400 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { describe, expect, test } from "vitest";
3
+ const limits = { firstRows: 2, pageRows: 3, maxRows: 16, maxBytes: 8192 };
4
+ const request = (dataset = null, since = 0, page = null) => ({ client: "pull-contract", dataset, since, page, verify: false });
5
+ const item = (id, value, deleted = false) => ({ id, value, deleted });
6
+ const seed = Array.from({ length: 11 }, (_, index) => item(`row-${String(index).padStart(2, "0")}`, Math.floor(index / 2)));
7
+ const sorted = (rows) => [...rows]
8
+ .filter((row) => !row.deleted)
9
+ .toSorted((a, b) => b.value - a.value || b.id.localeCompare(a.id));
10
+ /** Independent rows-per-revision reference, deliberately not a sync engine or a cursor decoder. */
11
+ class Rows {
12
+ snapshots = new Map();
13
+ write(revision, changes) {
14
+ const next = new Map([...this.snapshots.values()].at(-1));
15
+ for (const row of changes) {
16
+ if (row.deleted) {
17
+ next.delete(row.id);
18
+ }
19
+ else {
20
+ next.set(row.id, { ...row, revision });
21
+ }
22
+ }
23
+ this.snapshots.set(revision, next);
24
+ }
25
+ at(revision) {
26
+ const value = this.snapshots.get(revision);
27
+ if (value === undefined) {
28
+ throw new Error(`missing reference revision ${revision}`);
29
+ }
30
+ return value;
31
+ }
32
+ }
33
+ function bounded(reply, caps = limits) {
34
+ expect(reply.patch.rows.rows.length + reply.patch.rows.unpaged.length).toBeLessThanOrEqual(caps.maxRows);
35
+ expect(reply.bytes).toBeLessThanOrEqual(caps.maxBytes);
36
+ expect(reply.bytes).toBeGreaterThan(0);
37
+ }
38
+ function pageOf(reply) {
39
+ expect(reply.patch.page, "paging-aware replies carry page metadata").toBeDefined();
40
+ if (reply.patch.page === undefined) {
41
+ throw new Error("missing page metadata");
42
+ }
43
+ return reply.patch.page;
44
+ }
45
+ function claim(reply, held, reference, boundary) {
46
+ const expected = reference.at(reply.patch.revision);
47
+ for (const row of held.values()) {
48
+ expect(row).toStrictEqual(expected.get(row.id));
49
+ }
50
+ const page = pageOf(reply);
51
+ const covered = [...expected.values()].filter((row) => page.next === null || (page.through !== null && row.value >= Number(page.through)));
52
+ for (const row of covered) {
53
+ expect(held.get(row.id)).toStrictEqual(row);
54
+ }
55
+ if (page.next === null) {
56
+ expect(page.held).toBe(expected.size);
57
+ }
58
+ else if (boundary !== undefined) {
59
+ const progress = sorted(expected.values()).filter((row) => row.value > boundary.value || (row.value === boundary.value && row.id >= boundary.id));
60
+ expect(page.held).toBe(progress.length);
61
+ const wholeTie = [...expected.values()]
62
+ .filter((row) => row.value === boundary.value)
63
+ .every((row) => progress.some((kept) => kept.id === row.id));
64
+ const fullyCovered = wholeTie
65
+ ? boundary.value
66
+ : sorted(expected.values()).findLast((row) => row.value > boundary.value)?.value;
67
+ expect(page.through).toBe(fullyCovered === undefined ? null : String(fullyCovered));
68
+ }
69
+ expect(page.total).toBe(expected.size);
70
+ if (page.next === null) {
71
+ expect(sorted(held.values())).toStrictEqual(sorted(expected.values()));
72
+ }
73
+ }
74
+ function apply(reply, held) {
75
+ if (reply.patch.reset) {
76
+ held.clear();
77
+ }
78
+ for (const row of reply.patch.rows.rows) {
79
+ if (row.deleted) {
80
+ held.delete(row.id);
81
+ }
82
+ else {
83
+ held.set(row.id, row);
84
+ }
85
+ }
86
+ }
87
+ /** Registers black-box pull requirements against a fresh real application store for each case. */
88
+ export function pullConformance(input) {
89
+ const scenario = (name, run) => test(name, async () => {
90
+ const fixture = await input.create(limits);
91
+ try {
92
+ await run(fixture);
93
+ }
94
+ finally {
95
+ await fixture.close();
96
+ }
97
+ });
98
+ describe(`${input.name}: paged pull contract`, () => {
99
+ scenario("16: newest first, ties stable, every row once; request count matches declared caps", async (app) => {
100
+ const point = await app.write({ rows: seed });
101
+ const reference = new Rows();
102
+ reference.write(point.revision, seed);
103
+ const held = new Map();
104
+ const seen = [];
105
+ let reply = await app.pull(request());
106
+ let count = 0;
107
+ let offset = 0;
108
+ const expected = sorted(reference.at(point.revision).values());
109
+ for (;;) {
110
+ count++;
111
+ bounded(reply);
112
+ const page = pageOf(reply);
113
+ const size = count === 1 ? limits.firstRows : limits.pageRows;
114
+ expect(reply.patch.rows.rows).toStrictEqual(expected.slice(offset, offset + size));
115
+ seen.push(...reply.patch.rows.rows.map((row) => row.id));
116
+ offset += reply.patch.rows.rows.length;
117
+ apply(reply, held);
118
+ claim(reply, held, reference, expected[offset - 1]);
119
+ if (page.next === null) {
120
+ break;
121
+ }
122
+ expect(count).toBeLessThanOrEqual(1 + Math.ceil((seed.length - limits.firstRows) / limits.pageRows));
123
+ // oxlint-disable-next-line no-await-in-loop -- each request consumes the preceding opaque cursor
124
+ reply = await app.pull(request(reply.patch.dataset, reply.patch.revision, page.next));
125
+ }
126
+ expect(seen).toStrictEqual(expected.map((row) => row.id));
127
+ expect(new Set(seen).size).toBe(seed.length);
128
+ expect(count).toBe(1 + Math.ceil((seed.length - limits.firstRows) / limits.pageRows));
129
+ });
130
+ scenario("16: edited and tombstoned anchors do not skip older rows; writes remain current in every chunk", async (app) => {
131
+ const point = await app.write({ rows: seed });
132
+ const reference = new Rows();
133
+ reference.write(point.revision, seed);
134
+ const held = new Map();
135
+ let reply = await app.pull(request());
136
+ bounded(reply);
137
+ apply(reply, held);
138
+ claim(reply, held, reference, sorted(reference.at(point.revision).values())[limits.firstRows - 1]);
139
+ const anchor = reply.patch.rows.rows.at(-1);
140
+ if (anchor === undefined) {
141
+ throw new Error("no anchor");
142
+ }
143
+ const changes = [
144
+ item(anchor.id, -10),
145
+ item("insert-above", 100),
146
+ item("insert-below", -20),
147
+ item("row-00", -30),
148
+ item("row-02", 1, true),
149
+ ];
150
+ let point2 = await app.write({ rows: changes });
151
+ reference.write(point2.revision, changes);
152
+ let pages = 0;
153
+ let boundary = anchor;
154
+ while (pageOf(reply).next !== null) {
155
+ const previous = reply;
156
+ // oxlint-disable-next-line no-await-in-loop -- concurrent writes are scheduled between successive chunk reads
157
+ reply = await app.pull(request(previous.patch.dataset, previous.patch.revision, pageOf(previous).next));
158
+ bounded(reply);
159
+ expect(pageOf(reply).from).toBe(pageOf(previous).next);
160
+ const previousBoundary = boundary;
161
+ const older = sorted(reference.at(point2.revision).values())
162
+ .filter((row) => row.value < previousBoundary.value ||
163
+ (row.value === previousBoundary.value && row.id < previousBoundary.id))
164
+ .slice(0, limits.pageRows);
165
+ for (const row of older) {
166
+ expect(reply.patch.rows.rows).toContainEqual(row);
167
+ }
168
+ boundary = older.at(-1) ?? boundary;
169
+ apply(reply, held);
170
+ claim(reply, held, reference, boundary);
171
+ if (pages === 0) {
172
+ const deletion = [item(anchor.id, -10, true)];
173
+ // oxlint-disable-next-line no-await-in-loop -- delete the edited anchor while download is still partial
174
+ point2 = await app.write({ rows: deletion });
175
+ reference.write(point2.revision, deletion);
176
+ }
177
+ pages++;
178
+ expect(pages).toBeLessThanOrEqual(12);
179
+ }
180
+ expect(sorted(held.values())).toStrictEqual(sorted(reference.at(point2.revision).values()));
181
+ });
182
+ scenario("16: overlapping writes and pulls read one coherent revision", async (app) => {
183
+ const point = await app.write({ rows: seed });
184
+ const reference = new Rows();
185
+ reference.write(point.revision, seed);
186
+ const changes = [item("overlap-newest", 100), item("row-10", 5, true)];
187
+ const [reply, written] = await Promise.all([
188
+ app.pull(request()),
189
+ app.write({ rows: changes }),
190
+ ]);
191
+ reference.write(written.revision, changes);
192
+ bounded(reply);
193
+ const expected = sorted(reference.at(reply.patch.revision).values());
194
+ expect(reply.patch.rows.rows).toStrictEqual(expected.slice(0, limits.firstRows));
195
+ const held = new Map();
196
+ apply(reply, held);
197
+ claim(reply, held, reference, expected[limits.firstRows - 1]);
198
+ });
199
+ scenario("16: a boundary tie is not a covered date; null means no complete order group yet", async (app) => {
200
+ const tied = Array.from({ length: 7 }, (_, index) => item(`tie-${index}`, 50));
201
+ const point = await app.write({ rows: tied });
202
+ const reference = new Rows();
203
+ reference.write(point.revision, tied);
204
+ let reply = await app.pull(request());
205
+ const held = new Map();
206
+ bounded(reply);
207
+ apply(reply, held);
208
+ claim(reply, held, reference, sorted(reference.at(point.revision).values())[1]);
209
+ expect(pageOf(reply)).toMatchObject({ through: null, held: 2, total: 7 });
210
+ reply = await app.pull(request(reply.patch.dataset, reply.patch.revision, pageOf(reply).next));
211
+ bounded(reply);
212
+ apply(reply, held);
213
+ claim(reply, held, reference, sorted(reference.at(point.revision).values())[4]);
214
+ expect(pageOf(reply)).toMatchObject({ through: null, held: 5, total: 7 });
215
+ reply = await app.pull(request(reply.patch.dataset, reply.patch.revision, pageOf(reply).next));
216
+ bounded(reply);
217
+ apply(reply, held);
218
+ claim(reply, held, reference);
219
+ expect(pageOf(reply)).toMatchObject({ next: null, held: 7, total: 7 });
220
+ });
221
+ scenario("16: the byte cap alone also turns an oversized delta into a paged reset", async (app) => {
222
+ const point = await app.write({ rows: seed });
223
+ const changes = Array.from({ length: limits.maxRows - 1 }, (_, index) => item(`bytes-${index}`, 100 + index));
224
+ await app.write({ rows: changes });
225
+ const legacy = {
226
+ client: "pull-contract",
227
+ dataset: point.dataset,
228
+ since: point.revision,
229
+ verify: false,
230
+ };
231
+ const delta = await app.pull(legacy);
232
+ const first = await app.pull(request());
233
+ expect(delta.bytes).toBeGreaterThan(first.bytes);
234
+ // Calibrate from the application's actual wire encoding, not oversized synthetic ids or normalized JSON.
235
+ const caps = { ...limits, maxBytes: Math.floor((delta.bytes + first.bytes) / 2) };
236
+ const boundedApp = await input.create(caps);
237
+ try {
238
+ const base = await boundedApp.write({ rows: seed });
239
+ await boundedApp.write({ rows: changes });
240
+ const reply = await boundedApp.pull(request(base.dataset, base.revision));
241
+ bounded(reply, caps);
242
+ expect(reply.patch.reset).toBe(true);
243
+ expect(pageOf(reply)).toMatchObject({ from: null, held: limits.firstRows });
244
+ expect(reply.patch.rows.rows.map((row) => row.id)).toStrictEqual([
245
+ changes.at(-1)?.id,
246
+ changes.at(-2)?.id,
247
+ ]);
248
+ }
249
+ finally {
250
+ await boundedApp.close();
251
+ }
252
+ });
253
+ scenario("16: oversized deltas reset to a bounded newest-first page", async (app) => {
254
+ const point = await app.write({ rows: seed });
255
+ const changes = Array.from({ length: limits.maxRows + 1 }, (_, index) => item(`delta-${index}`, 100 + index));
256
+ const after = await app.write({ rows: changes });
257
+ const reply = await app.pull(request(point.dataset, point.revision));
258
+ bounded(reply);
259
+ expect(reply.patch.reset).toBe(true);
260
+ expect(reply.patch.revision).toBe(after.revision);
261
+ expect(pageOf(reply)).toMatchObject({
262
+ from: null,
263
+ held: limits.firstRows,
264
+ total: seed.length + changes.length,
265
+ });
266
+ expect(reply.patch.rows.rows.map((row) => row.id)).toStrictEqual(["delta-16", "delta-15"]);
267
+ });
268
+ scenario("16: small steady-state deltas do not restart complete copies, for new and old clients", async (app) => {
269
+ const point = await app.write({ rows: seed });
270
+ const changes = [item("row-00", 20), item("row-02", 1, true), item("steady-new", 90)];
271
+ const written = await app.write({ rows: changes });
272
+ const expected = changes
273
+ .map((row) => ({ ...row, revision: written.revision }))
274
+ .toSorted((a, b) => a.id.localeCompare(b.id));
275
+ const modern = await app.pull(request(point.dataset, point.revision));
276
+ bounded(modern);
277
+ expect(modern.patch.reset).toBe(false);
278
+ expect(modern.patch.rows.rows.toSorted((a, b) => a.id.localeCompare(b.id))).toStrictEqual(expected);
279
+ const legacy = await app.pull({
280
+ client: "pull-contract",
281
+ dataset: point.dataset,
282
+ since: point.revision,
283
+ verify: false,
284
+ });
285
+ expect(legacy.patch.reset).toBe(false);
286
+ expect(legacy.patch.page).toBeUndefined();
287
+ expect(legacy.patch.rows.rows.toSorted((a, b) => a.id.localeCompare(b.id))).toStrictEqual(expected);
288
+ });
289
+ scenario("16: the combined delta, older page and receipt effect obey the row cap", async (app) => {
290
+ const rows = Array.from({ length: 20 }, (_, index) => item(`combined-${String(index).padStart(2, "0")}`, index));
291
+ await app.write({ rows });
292
+ const first = await app.pull(request());
293
+ const changes = Array.from({ length: 13 }, (_, index) => item(`above-${index}`, 100 + index));
294
+ await app.write({
295
+ rows: changes,
296
+ receipt: { client: "pull-contract", id: 1, resource: "combined-00" },
297
+ });
298
+ const reply = await app.pull(request(first.patch.dataset, first.patch.revision, pageOf(first).next));
299
+ // 13 delta rows and the receipt's resource fit (14 of 16): the older page shrinks to the 2 rows left, with no
300
+ // restart. A full 3-row page would make 17.
301
+ bounded(reply);
302
+ expect(reply.patch.reset).toBe(false);
303
+ expect(pageOf(reply).from).toBe(pageOf(first).next);
304
+ expect(pageOf(reply).next).not.toBe(pageOf(first).next);
305
+ const ids = reply.patch.rows.rows.map((row) => row.id);
306
+ expect(ids).toStrictEqual(expect.arrayContaining([...changes.map((row) => row.id), "combined-00"]));
307
+ const older = ids.filter((id) => id.startsWith("combined-") && id !== "combined-00");
308
+ expect(older.length).toBeGreaterThan(0);
309
+ expect(older).toStrictEqual(["combined-17", "combined-16"].slice(0, older.length));
310
+ expect(reply.receipts).toHaveLength(1);
311
+ });
312
+ scenario("16: an older page also shrinks to fit its encoded byte cap without restarting", async (app) => {
313
+ await app.write({ rows: seed });
314
+ const first = await app.pull(request());
315
+ const next = await app.pull(request(first.patch.dataset, first.patch.revision, pageOf(first).next));
316
+ expect(next.bytes).toBeGreaterThan(first.bytes);
317
+ const caps = { ...limits, maxBytes: Math.floor((first.bytes + next.bytes) / 2) };
318
+ const boundedApp = await input.create(caps);
319
+ try {
320
+ await boundedApp.write({ rows: seed });
321
+ const start = await boundedApp.pull(request());
322
+ bounded(start, caps);
323
+ const reply = await boundedApp.pull(request(start.patch.dataset, start.patch.revision, pageOf(start).next));
324
+ bounded(reply, caps);
325
+ expect(reply.patch.reset).toBe(false);
326
+ expect(reply.patch.rows.rows.length).toBeGreaterThan(0);
327
+ expect(reply.patch.rows.rows.length).toBeLessThan(limits.pageRows);
328
+ expect(pageOf(reply).next).not.toBe(pageOf(start).next);
329
+ }
330
+ finally {
331
+ await boundedApp.close();
332
+ }
333
+ });
334
+ scenario("16: unknown and previous-dataset cursors produce paged resets", async (app) => {
335
+ await app.write({ rows: seed });
336
+ const first = await app.pull(request());
337
+ const cursor = pageOf(first).next;
338
+ const unknown = await app.pull(request(first.patch.dataset, first.patch.revision, "not-a-cursor"));
339
+ bounded(unknown);
340
+ expect(unknown.patch.reset).toBe(true);
341
+ expect(pageOf(unknown).from).toBeNull();
342
+ await app.replace();
343
+ const point = await app.write({ rows: seed.slice(0, 4) });
344
+ const replaced = await app.pull(request(point.dataset, point.revision, cursor));
345
+ bounded(replaced);
346
+ expect(replaced.patch.reset).toBe(true);
347
+ expect(pageOf(replaced).from).toBeNull();
348
+ expect(replaced.patch.dataset).not.toBe(first.patch.dataset);
349
+ });
350
+ scenario("16 compatibility: absence of page returns the whole legacy copy, even above the paging bound", async (app) => {
351
+ const rows = Array.from({ length: limits.maxRows + 5 }, (_, index) => item(`legacy-${index}`, index));
352
+ const point = await app.write({ rows });
353
+ const legacy = {
354
+ client: "legacy-client",
355
+ dataset: null,
356
+ since: 0,
357
+ verify: false,
358
+ };
359
+ const reply = await app.pull(legacy);
360
+ expect(reply.patch.reset).toBe(true);
361
+ expect(sorted(reply.patch.rows.rows)).toStrictEqual(sorted(rows.map((row) => ({ ...row, revision: point.revision }))));
362
+ expect(reply.patch.page).toBeUndefined();
363
+ });
364
+ scenario("16: receipts name rows actually carried by a reset, including effects below its page", async (app) => {
365
+ await app.write({ rows: seed });
366
+ const point = await app.write({
367
+ rows: [item("receipt-effect", -100)],
368
+ receipt: { client: "pull-contract", id: 1, resource: "receipt-effect" },
369
+ });
370
+ const reply = await app.pull(request());
371
+ bounded(reply);
372
+ expect(reply.receipts).toContainEqual({
373
+ id: 1,
374
+ revision: point.revision,
375
+ outcome: { resource: "receipt-effect" },
376
+ });
377
+ expect(reply.patch.rows.rows).toContainEqual({
378
+ ...item("receipt-effect", -100),
379
+ revision: point.revision,
380
+ });
381
+ expect(pageOf(reply).held).toBe(limits.firstRows);
382
+ expect(pageOf(reply).total).toBe(seed.length + 1);
383
+ });
384
+ scenario("budgets: first reply independent of total history; unpaged rows and receipt rows count toward caps", async (app) => {
385
+ const rows = Array.from({ length: 1000 }, (_, index) => item(`large-${index}`, index));
386
+ await app.write({ rows, unpaged: [item("settings", 1)] });
387
+ const reply = await app.pull(request());
388
+ bounded(reply);
389
+ expect(reply.patch.rows.rows).toHaveLength(limits.firstRows);
390
+ expect(reply.patch.rows.unpaged).toHaveLength(1);
391
+ expect(pageOf(reply)).toMatchObject({ total: rows.length, held: limits.firstRows });
392
+ expect(reply.patch.rows.rows.map((row) => row.id)).toStrictEqual([
393
+ "large-999",
394
+ "large-998",
395
+ ]);
396
+ // The encoded-size measurement is the application's actual JSON, not JSON of the normalized result.
397
+ expect(reply.bytes).toBeGreaterThan(Buffer.byteLength(JSON.stringify(reply.patch.rows.rows)));
398
+ });
399
+ });
400
+ }
@@ -0,0 +1,2 @@
1
+ /** A push carries at most `PUSH_MAX` commands, so a long outbox drains through a push endpoint bounded the same way. */
2
+ export {};
@@ -0,0 +1,66 @@
1
+ /** A push carries at most `PUSH_MAX` commands, so a long outbox drains through a push endpoint bounded the same way. */
2
+ import { expect, test } from "vitest";
3
+ import { createClient } from "../client.js";
4
+ import { createSync, PUSH_MAX } from "../sync.js";
5
+ import { copy, database, patch, pending, sync } from "./fixtures.js";
6
+ test("120 kept commands go out as pushes of 50, 50 and 20, each once, and all reach an outcome", async () => {
7
+ const stored = database();
8
+ await stored.port.load();
9
+ await stored.port.commit({
10
+ replica: copy(29, []),
11
+ reset: true,
12
+ base: 0,
13
+ replaces: "dataset-a",
14
+ ops: [],
15
+ });
16
+ for (let id = 1; id <= 120; id++) {
17
+ await stored.port.keep({ ...pending("older-page", id, id), since: 29 });
18
+ }
19
+ const reopened = database({ factory: stored.factory, name: stored.name });
20
+ const pushes = [];
21
+ const runtime = createClient({
22
+ sync,
23
+ client: "newer-page",
24
+ ports: {
25
+ storage: reopened.port,
26
+ network: {
27
+ pull: async () => ({ ok: true, value: { patch: patch(29, []), receipts: [] } }),
28
+ push: async (request) => {
29
+ pushes.push(request.commands.map((each) => each.id));
30
+ // The server's contract: a push of more than 50 commands is refused before anything acts.
31
+ if (request.commands.length > 50) {
32
+ return { ok: false, failure: { kind: "invalid", message: "commands: at most 50" } };
33
+ }
34
+ return {
35
+ ok: true,
36
+ value: {
37
+ receipts: request.commands.map((each) => ({
38
+ id: each.id,
39
+ outcome: { kind: "ok", resource: `r${each.id}` },
40
+ revision: 29,
41
+ })),
42
+ revision: 29,
43
+ },
44
+ };
45
+ },
46
+ },
47
+ now: () => 0,
48
+ fingerprint: async () => "fixture-fingerprint",
49
+ },
50
+ });
51
+ runtime.start();
52
+ await runtime.settle();
53
+ expect(pushes.map((each) => each.length)).toStrictEqual([50, 50, 20]);
54
+ expect(pushes.flat()).toStrictEqual(Array.from({ length: 120 }, (_, at) => at + 1));
55
+ expect(runtime
56
+ .model()
57
+ .commands.filter((each) => each.state.kind === "done")
58
+ .map((each) => each.id)).toHaveLength(120);
59
+ expect(runtime.faults).toStrictEqual([]);
60
+ expect(PUSH_MAX).toBe(50);
61
+ });
62
+ test("a push bound that isn't a positive integer is refused when sync is made", () => {
63
+ for (const pushMax of [0, -1, 1.5, Number.NaN]) {
64
+ expect(() => createSync({ tables: [], empty: {}, onlineOnly: () => false, refused: () => null, pushMax })).toThrow(/pushMax must be a positive integer/);
65
+ }
66
+ });
@@ -115,6 +115,19 @@ export declare class Ledger<Receipt> {
115
115
  receipt: Receipt;
116
116
  revision: number;
117
117
  }[];
118
+ /**
119
+ * The receipts of these commands (any of the principal's clients, so an earlier page's too), in the order asked: for
120
+ * a pull that names the commands it still waits on. One with no receipt yet (or retired) is left out.
121
+ */
122
+ receiptsOf(principal: string, keys: readonly {
123
+ client: string;
124
+ id: number;
125
+ }[]): {
126
+ client: string;
127
+ id: number;
128
+ receipt: Receipt;
129
+ revision: number;
130
+ }[];
118
131
  /** Commands accepted longer than `ms` ago and still unfinished: a stuck effect, for the invariant probe. */
119
132
  stuck(ms: number): number;
120
133
  }
@@ -150,6 +150,24 @@ export class Ledger {
150
150
  return { id: Number(row.id), receipt: decode(row.outcome), revision: Number(row.revision) };
151
151
  });
152
152
  }
153
+ /**
154
+ * The receipts of these commands (any of the principal's clients, so an earlier page's too), in the order asked: for
155
+ * a pull that names the commands it still waits on. One with no receipt yet (or retired) is left out.
156
+ */
157
+ receiptsOf(principal, keys) {
158
+ const { db, decode } = this.options;
159
+ const find = db.prepare("select outcome, revision from commands where principal = ? and client = ? and id = ? and state = 'done'");
160
+ return keys.flatMap(({ client, id }) => {
161
+ const row = find.get(principal, client, id);
162
+ if (row === undefined) {
163
+ return [];
164
+ }
165
+ if (typeof row.outcome !== "string") {
166
+ throw new TypeError("Corrupt row in commands.outcome");
167
+ }
168
+ return [{ client, id, receipt: decode(row.outcome), revision: Number(row.revision) }];
169
+ });
170
+ }
153
171
  /** Commands accepted longer than `ms` ago and still unfinished: a stuck effect, for the invariant probe. */
154
172
  stuck(ms) {
155
173
  const before = new Date(Date.now() - ms).toISOString();
@@ -0,0 +1,85 @@
1
+ import type { DatabaseSync, SQLOutputValue } from "node:sqlite";
2
+ import type { Page } from "../sync.ts";
3
+ /** The caps of a paging-aware reply: rows of the first and later pages, and the whole reply in rows and bytes. */
4
+ export type PullLimits = {
5
+ firstRows: number;
6
+ pageRows: number;
7
+ maxRows: number;
8
+ maxBytes: number;
9
+ };
10
+ /** A command by the page that sent it and its number. */
11
+ export type CommandRef = {
12
+ client: string;
13
+ id: number;
14
+ };
15
+ /**
16
+ * What `pull` reads of a request: the copy's dataset and revision, the older page it wants, and the commands it still
17
+ * waits on (any of its pages', at most a push's worth).
18
+ */
19
+ export type PullAsk = {
20
+ dataset: string | null;
21
+ since: number;
22
+ page?: string | null;
23
+ commands?: readonly CommandRef[];
24
+ };
25
+ /** A reply before the app shapes it: the history's rows, the other tables, the receipts and the page. */
26
+ export type Pulled<R, U, Rc> = {
27
+ dataset: string;
28
+ revision: number;
29
+ /** Everything (of the history, from its newest page), not changes: the copy starts over. */
30
+ reset: boolean;
31
+ /** The history table's rows: the page newest first, then the changes and the receipts' rows. */
32
+ rows: R[];
33
+ unpaged: U;
34
+ receipts: Rc[];
35
+ /** Absent for a request without `page`. */
36
+ page?: Page;
37
+ };
38
+ type Row = Record<string, SQLOutputValue>;
39
+ export type PullOptions<R, U extends Record<string, readonly unknown[]>, Rc, Caller, Res> = {
40
+ db: DatabaseSync;
41
+ dataset: () => string;
42
+ revision: () => number;
43
+ /** Whether a delta can follow from `since` (a revision this history still has). Default: any up to the head. */
44
+ known?: (since: number) => boolean;
45
+ /** The history: the table paged newest first. */
46
+ paged: {
47
+ table: string;
48
+ /** Order columns, newest first, all descending; the last one is the row's unique key. */
49
+ order: readonly string[];
50
+ /** Changes whenever the order does, so no cursor of another order is honoured. */
51
+ version: string | number;
52
+ /** SQL condition true for a row that isn't a tombstone. */
53
+ live: string;
54
+ /** A table row, as the reply carries it. */
55
+ read: (row: Row) => R;
56
+ /** A fully covered order group's values (the order without its key, or the key alone), as the app shows them. */
57
+ through: (values: readonly SQLOutputValue[]) => string;
58
+ };
59
+ /** The other tables' rows changed after `since`, or every live row for 0: always whole on a reset. */
60
+ unpaged: (since: number) => U;
61
+ /** The caller's receipts stamped after `since`, and the history rows (by key) each one names. */
62
+ /**
63
+ * The receipts a reply carries, and the history rows (by key) each one names. With `commands` (a paging client's),
64
+ * those commands' receipts, scoped to the caller's principal; without, the caller's client's receipts stamped after
65
+ * `since`, as before.
66
+ */
67
+ receipts: {
68
+ read: (ask: {
69
+ since: number;
70
+ commands?: readonly CommandRef[];
71
+ }, caller: Caller) => readonly Rc[];
72
+ names: (receipt: Rc) => readonly (string | number)[];
73
+ };
74
+ limits: PullLimits;
75
+ /** The reply the endpoint answers: the bounds measure its JSON. */
76
+ respond: (pulled: Pulled<R, U, Rc>) => Res;
77
+ };
78
+ export declare function createPull<R, U extends Record<string, readonly unknown[]>, Rc, Caller, Res>(options: PullOptions<R, U, Rc, Caller, Res>): {
79
+ pull: (request: PullAsk, caller: Caller) => {
80
+ reply: Res;
81
+ bytes: number;
82
+ pulled: Pulled<R, U, Rc>;
83
+ };
84
+ };
85
+ export {};