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
@@ -0,0 +1,255 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { createClient, Replaced } from "../client.js";
3
+ import { POLL_MS } from "../sync.js";
4
+ import { copy, database, deferred, patch, pendingSchema, rowsLens, serverSchema, sync, values, } from "./fixtures.js";
5
+ import { indexedReplica as legacyReplica } from "./legacy/replica-012.js";
6
+ import { all, complete, firstPage, history, lastPage, middle, newest, oldest, paged, partialCopy, requestedPage, secondPage, } from "./paging-fixtures.js";
7
+ function commit(replica, from, rows = [], reset = false) {
8
+ return {
9
+ replica,
10
+ from,
11
+ reset,
12
+ base: replica.revision,
13
+ replaces: "dataset-a",
14
+ ops: rows.map((item) => ({ table: "rows", key: item.id, row: item })),
15
+ };
16
+ }
17
+ const legacyRowsLens = {
18
+ ...rowsLens,
19
+ shape: '"rows":object(strict,{"id":string(0,100000),"value":number(true,-1.7976931348623157e+308,1.7976931348623157e+308),"deleted":boolean,"revision":number(true,0,1.7976931348623157e+308)})',
20
+ };
21
+ function oldPort(stored) {
22
+ return legacyReplica({
23
+ factory: stored.factory,
24
+ name: stored.name,
25
+ version: 1,
26
+ tables: [legacyRowsLens],
27
+ empty: { rows: new Map() },
28
+ server: serverSchema,
29
+ pending: pendingSchema,
30
+ });
31
+ }
32
+ function runtime(stored, network, name = "page-a", clock) {
33
+ return createClient({
34
+ sync,
35
+ client: name,
36
+ ports: {
37
+ storage: stored.port,
38
+ network,
39
+ now: () => clock?.now ?? 0,
40
+ fingerprint: async () => "fixture-fingerprint",
41
+ },
42
+ });
43
+ }
44
+ const failedPush = async () => ({
45
+ ok: false,
46
+ failure: { kind: "offline", message: "no commands in this history" },
47
+ });
48
+ const unavailable = {
49
+ ok: false,
50
+ failure: { kind: "offline", message: "download paused" },
51
+ };
52
+ describe("paged checkpoints", () => {
53
+ test("12: executor persists each page and a reload requests the stored cursor, not the first page", async () => {
54
+ const stored = database();
55
+ const firstRequests = [];
56
+ const first = runtime(stored, {
57
+ push: failedPush,
58
+ pull: async (request) => {
59
+ firstRequests.push(request);
60
+ return firstRequests.length === 1
61
+ ? { ok: true, value: { patch: paged(10, newest, firstPage, true), receipts: [] } }
62
+ : unavailable;
63
+ },
64
+ });
65
+ first.start();
66
+ await first.settle();
67
+ expect(first.faults).toStrictEqual([]);
68
+ expect(firstRequests[0]).toMatchObject({ since: 0, page: null });
69
+ const disk = await stored.port.load();
70
+ expect(history(disk.replica)).toStrictEqual(partialCopy().history);
71
+ expect(values(disk.replica)).toStrictEqual(values(copy(10, newest)));
72
+ const reopened = database({ factory: stored.factory, name: stored.name });
73
+ const requests = [];
74
+ const second = runtime(reopened, {
75
+ push: failedPush,
76
+ pull: async (request) => {
77
+ requests.push(request);
78
+ const cursor = requestedPage(request);
79
+ return {
80
+ ok: true,
81
+ value: {
82
+ patch: cursor === firstPage.next
83
+ ? paged(10, middle, secondPage)
84
+ : cursor === secondPage.next
85
+ ? paged(10, oldest, lastPage)
86
+ : patch(10, [], false),
87
+ receipts: [],
88
+ },
89
+ };
90
+ },
91
+ }, "page-b");
92
+ second.start();
93
+ await second.settle();
94
+ expect(requests[0]).toMatchObject({ since: 10, page: firstPage.next, verify: false });
95
+ // Two remaining pages, at most one separate verification pull.
96
+ expect(requests.length).toBeLessThanOrEqual(3);
97
+ expect(values((await reopened.port.load()).replica)).toStrictEqual(values(copy(10, all)));
98
+ expect(history((await reopened.port.load()).replica)).toStrictEqual(complete);
99
+ expect(second.faults).toStrictEqual([]);
100
+ });
101
+ test("13: an already-open 0.1.2 tab is fenced by a newer partial checkpoint; reopening reads no partial rows", async () => {
102
+ const stored = database();
103
+ const old = oldPort(stored);
104
+ await old.load();
105
+ await old.commit(commit(copy(9, all), null, [], true));
106
+ const current = database({ factory: stored.factory, name: stored.name });
107
+ await current.port.load();
108
+ await current.port.commit(commit(partialCopy(10), null, [], true));
109
+ await expect(old.commit(commit(copy(11, all), null, [], true))).rejects.toBeInstanceOf(Replaced);
110
+ const reloaded = oldPort(stored);
111
+ const unread = await reloaded.load();
112
+ expect(unread.replica.dataset).toBeNull();
113
+ expect(values(unread.replica)).toStrictEqual([]);
114
+ // This is the old release's legacy whole pull, not a paged response.
115
+ const whole = patch(10, all, true);
116
+ await reloaded.commit(commit(copy(whole.revision, whole.rows.rows), null, [], true));
117
+ const modern = database({ factory: stored.factory, name: stored.name });
118
+ const restored = await modern.port.load();
119
+ expect(values(restored.replica)).toStrictEqual(values(copy(10, all)));
120
+ expect(history(restored.replica)).toStrictEqual(complete);
121
+ });
122
+ test("13 compatibility control: the released adapter reads and writes whole checkpoints unchanged", async () => {
123
+ const stored = database();
124
+ const old = oldPort(stored);
125
+ await old.load();
126
+ await old.commit(commit(copy(10, all), null, [], true));
127
+ const reopen = oldPort(stored);
128
+ expect(values((await reopen.load()).replica)).toStrictEqual(values(copy(10, all)));
129
+ });
130
+ test("13 compatibility control: a new complete checkpoint stays readable by the released adapter", async () => {
131
+ const stored = database();
132
+ await stored.port.load();
133
+ await stored.port.commit(commit(partialCopy(10, all, lastPage), null, [], true));
134
+ const old = await oldPort(stored).load();
135
+ expect(old.replica.dataset).toBe("dataset-a");
136
+ expect(old.replica.revision).toBe(10);
137
+ expect(values(old.replica)).toStrictEqual(values(copy(10, all)));
138
+ });
139
+ test("14: a native aborted same-revision page commit leaves a whole write owed and repairs the cursor", async () => {
140
+ let abort = false;
141
+ const stored = database({
142
+ interfere: (what, transaction) => {
143
+ if (what === "commit" && abort) {
144
+ abort = false;
145
+ transaction.abort();
146
+ }
147
+ },
148
+ });
149
+ await stored.port.load();
150
+ await stored.port.commit(commit(partialCopy(), null, [], true));
151
+ let replies = 0;
152
+ const clock = { now: 0 };
153
+ const app = runtime(stored, {
154
+ push: failedPush,
155
+ pull: async () => {
156
+ replies++;
157
+ if (replies === 1) {
158
+ abort = true;
159
+ return { ok: true, value: { patch: paged(10, middle, secondPage), receipts: [] } };
160
+ }
161
+ return unavailable;
162
+ },
163
+ }, "page-a", clock);
164
+ app.start();
165
+ await app.settle();
166
+ expect(app.model().storage.kind).toBe("failed");
167
+ expect(history((await stored.port.load()).replica)).toStrictEqual(partialCopy().history);
168
+ clock.now = POLL_MS + 1;
169
+ app.dispatch({ type: "tick", now: clock.now });
170
+ await app.settle();
171
+ const recovered = await stored.port.load();
172
+ expect(values(recovered.replica)).toStrictEqual(values(copy(10, [...newest, ...middle])));
173
+ expect(history(recovered.replica)).toStrictEqual(partialCopy(10, [], secondPage).history);
174
+ expect(app.faults).toStrictEqual([]);
175
+ });
176
+ test("15: different cursors at one revision never advance disk coverage without its rows", async () => {
177
+ const first = database();
178
+ const second = database({ factory: first.factory, name: first.name });
179
+ await Promise.all([first.port.load(), second.port.load()]);
180
+ await first.port.commit(commit(partialCopy(), null, [], true));
181
+ await second.port.commit(commit(partialCopy(10, [...newest, ...middle], secondPage), firstPage.next, middle));
182
+ const disk = await database({ factory: first.factory, name: first.name }).port.load();
183
+ expect(values(disk.replica)).toStrictEqual(values(copy(10, [...newest, ...middle])));
184
+ expect(history(disk.replica)).toStrictEqual(partialCopy(10, [], secondPage).history);
185
+ // A stale page arriving after completion may not narrow durable coverage.
186
+ await second.port.commit(commit(partialCopy(10, all, lastPage), secondPage.next, oldest));
187
+ await first.port.commit(commit(partialCopy(), null, [], true));
188
+ const finished = await database({ factory: first.factory, name: first.name }).port.load();
189
+ expect(values(finished.replica)).toStrictEqual(values(copy(10, all)));
190
+ expect(history(finished.replica)).toStrictEqual(complete);
191
+ });
192
+ test("15: two production clients at different cursors and revisions do not ping-pong whole writes", async () => {
193
+ const stored = database();
194
+ await stored.port.load();
195
+ await stored.port.commit(commit(partialCopy(), null, [], true));
196
+ const tab = database({ factory: stored.factory, name: stored.name });
197
+ const enter = deferred();
198
+ const release = deferred();
199
+ let firstCalls = 0;
200
+ let writes = 0;
201
+ const original = stored.port.commit;
202
+ stored.port.commit = async (value) => {
203
+ writes++;
204
+ if (writes > 8) {
205
+ throw new Error("busy whole-write loop");
206
+ }
207
+ return await original(value);
208
+ };
209
+ const first = runtime(stored, {
210
+ push: failedPush,
211
+ pull: async () => {
212
+ firstCalls++;
213
+ if (firstCalls === 1) {
214
+ enter.resolve();
215
+ await release.promise;
216
+ return { ok: true, value: { patch: paged(10, middle, secondPage), receipts: [] } };
217
+ }
218
+ if (firstCalls === 2) {
219
+ return { ok: true, value: { patch: paged(11, oldest, lastPage), receipts: [] } };
220
+ }
221
+ return { ok: true, value: { patch: patch(11, []), receipts: [] } };
222
+ },
223
+ });
224
+ first.start();
225
+ await enter.promise;
226
+ let secondCalls = 0;
227
+ const second = runtime(tab, {
228
+ push: failedPush,
229
+ pull: async () => {
230
+ secondCalls++;
231
+ return {
232
+ ok: true,
233
+ value: {
234
+ patch: secondCalls === 1
235
+ ? paged(11, [...middle, ...oldest], { ...lastPage, from: firstPage.next })
236
+ : patch(11, []),
237
+ receipts: [],
238
+ },
239
+ };
240
+ },
241
+ }, "page-b");
242
+ second.start();
243
+ await second.settle();
244
+ release.resolve();
245
+ await first.settle();
246
+ expect(writes).toBeLessThanOrEqual(3);
247
+ for (const app of [first, second]) {
248
+ expect(history(app.model().replica)).toStrictEqual(complete);
249
+ expect(app.faults).toStrictEqual([]);
250
+ }
251
+ const disk = await database({ factory: stored.factory, name: stored.name }).port.load();
252
+ expect(values(disk.replica)).toStrictEqual(values(copy(11, all)));
253
+ expect(history(disk.replica)).toStrictEqual(complete);
254
+ });
255
+ });
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,209 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { POLL_MS, settled } from "../sync.js";
3
+ import { copy, patch, pending, row, sync, values } from "./fixtures.js";
4
+ import { all, assertCopy, complete, deliver, firstPage, history, lastPage, loaded, middle, newest, oldest, paged, partialCopy, Reference, secondPage, } from "./paging-fixtures.js";
5
+ describe("paged first copy", () => {
6
+ test("1, 2, 5: edited and tombstoned anchors, changes on both sides, duplicates and late pages converge", () => {
7
+ const reference = new Reference(10, all);
8
+ let model = loaded();
9
+ [model] = deliver(model, paged(10, newest, firstPage, true), 0);
10
+ assertCopy(model, reference);
11
+ const changes = [
12
+ row("f", 65, 11),
13
+ row("e", 15, 11),
14
+ row("z", 70, 11),
15
+ row("a", 5, 11),
16
+ row("b", 20, 11, true),
17
+ ];
18
+ reference.write(11, changes);
19
+ const page = { ...secondPage, held: 4, total: 6 };
20
+ const reply = paged(11, [...changes, ...middle], page);
21
+ [model] = deliver(model, reply, 10);
22
+ assertCopy(model, reference);
23
+ [model] = deliver(model, reply, 10);
24
+ assertCopy(model, reference);
25
+ expect(history(model.replica)).toMatchObject({ next: secondPage.next, through: "30" });
26
+ const deletion = row("c", 30, 12, true);
27
+ reference.write(12, [deletion]);
28
+ [model] = deliver(model, paged(12, [deletion], {
29
+ ...page,
30
+ from: secondPage.next,
31
+ next: secondPage.next,
32
+ held: 3,
33
+ total: 5,
34
+ }), 11);
35
+ assertCopy(model, reference);
36
+ [model] = deliver(model, reply, 10);
37
+ assertCopy(model, reference);
38
+ expect(model.replica.tables.rows.has("c")).toBe(false);
39
+ [model] = deliver(model, paged(12, [row("e", 15, 11), row("a", 5, 11)], { ...lastPage, held: 5, total: 5 }), 12);
40
+ assertCopy(model, reference);
41
+ expect(history(model.replica)).toStrictEqual(complete);
42
+ expect(values(model.replica)).toStrictEqual([
43
+ ["a", 5, 11],
44
+ ["d", 40, 10],
45
+ ["e", 15, 11],
46
+ ["f", 65, 11],
47
+ ["z", 70, 11],
48
+ ]);
49
+ });
50
+ test("2: equal-revision pages merge; an unexpected from applies current rows but cannot advance the cursor", () => {
51
+ let model = loaded(partialCopy());
52
+ [model] = deliver(model, paged(10, middle, { ...secondPage, from: "another-cursor" }));
53
+ expect(values(model.replica)).toHaveLength(4);
54
+ expect(history(model.replica)).toStrictEqual(partialCopy().history);
55
+ [model] = deliver(model, paged(10, middle, secondPage));
56
+ expect(history(model.replica)).toStrictEqual(partialCopy(10, [], secondPage).history);
57
+ [model] = deliver(model, paged(10, oldest, lastPage));
58
+ assertCopy(model, new Reference(10, all));
59
+ });
60
+ test("3: a dataset reset starts its own history and refuses old-dataset intents", () => {
61
+ let model = loaded(partialCopy());
62
+ model = {
63
+ ...model,
64
+ commands: [
65
+ {
66
+ ...pending("page-a", 1),
67
+ dataset: "dataset-a",
68
+ state: { kind: "unknown", failure: "lost" },
69
+ },
70
+ ],
71
+ };
72
+ const replacement = [row("new-dataset", 900, 1)];
73
+ [model] = deliver(model, paged(1, replacement, { from: null, next: "b:900", through: "900", held: 1, total: 2 }, true, "dataset-b"));
74
+ expect(values(model.replica)).toStrictEqual([["new-dataset", 900, 1]]);
75
+ expect(history(model.replica)).toMatchObject({ kind: "partial", next: "b:900" });
76
+ expect(model.commands[0]?.state).toMatchObject({ kind: "done", outcome: { kind: "failed" } });
77
+ });
78
+ test("4: after a verify mismatch an in-flight delta cannot become a copy with a gap", () => {
79
+ const wholeCopy = { ...copy(10, all), history: complete };
80
+ let model = loaded(wholeCopy);
81
+ [model] = sync.update(model, {
82
+ type: "checked",
83
+ dataset: "dataset-a",
84
+ revision: 10,
85
+ match: false,
86
+ now: 1,
87
+ });
88
+ expect(model.replica.revision).toBe(0);
89
+ const [after, effects] = deliver(model, patch(11, [row("new", 80, 11)]), 10);
90
+ expect(after.replica.revision).toBe(0);
91
+ expect(values(after.replica)).toStrictEqual([]);
92
+ // The outstanding reset is sufficient; no second concurrent pull is required.
93
+ expect((after.pull.kind === "pulling" && after.pull.since === 0) ||
94
+ effects.some((effect) => effect.kind === "pull" && effect.since === 0)).toBe(true);
95
+ });
96
+ test("4: a same-dataset gap and a non-reset foreign dataset are dropped until a real reset", () => {
97
+ for (const reply of [
98
+ patch(12, [row("gap", 1, 12)]),
99
+ patch(1, [row("foreign", 2, 1)], false, "dataset-b"),
100
+ ]) {
101
+ const model = loaded(partialCopy());
102
+ const [after] = deliver(model, reply, 11);
103
+ expect(values(after.replica)).toStrictEqual(values(model.replica));
104
+ expect(after.replica.dataset).toBe("dataset-a");
105
+ expect(after.replica.revision).toBe(10);
106
+ }
107
+ });
108
+ test("6: one round trip exposes newest rows and the exact partial-history claim", () => {
109
+ const [model] = deliver(loaded(), paged(10, newest, firstPage, true), 0);
110
+ expect(values(model.replica)).toStrictEqual([
111
+ ["e", 50, 10],
112
+ ["f", 60, 10],
113
+ ]);
114
+ expect(history(model.replica)).toStrictEqual(partialCopy().history);
115
+ });
116
+ test("7: partial history owes back-to-back pulls carrying next, no-progress backs off and eventually resumes", () => {
117
+ let [model, effects] = deliver(loaded(), paged(10, newest, firstPage, true), 0);
118
+ expect(effects.filter((effect) => effect.kind === "pull")).toMatchObject([
119
+ { since: 10, page: firstPage.next, verify: false },
120
+ ]);
121
+ [model, effects] = deliver(model, paged(10, newest, firstPage, true), 10);
122
+ expect(effects.filter((effect) => effect.kind === "pull")).toStrictEqual([]);
123
+ expect(model.pull).toMatchObject({ kind: "idle", failures: 1 });
124
+ [model, effects] = sync.update(model, { type: "tick", now: model.now + POLL_MS - 1 });
125
+ expect(effects.filter((effect) => effect.kind === "pull")).toStrictEqual([]);
126
+ [model, effects] = sync.update(model, { type: "tick", now: model.now + 1 });
127
+ expect(effects.filter((effect) => effect.kind === "pull")).toMatchObject([
128
+ { page: firstPage.next },
129
+ ]);
130
+ [model, effects] = deliver(model, paged(10, newest, firstPage, true), 10);
131
+ expect(effects.filter((effect) => effect.kind === "pull")).toStrictEqual([]);
132
+ expect(model.pull).toMatchObject({
133
+ kind: "idle",
134
+ failures: 2,
135
+ nextAt: model.now + 2 * POLL_MS,
136
+ });
137
+ [model, effects] = sync.update(model, { type: "tick", now: model.now + 2 * POLL_MS });
138
+ expect(effects.filter((effect) => effect.kind === "pull")).toMatchObject([
139
+ { page: firstPage.next },
140
+ ]);
141
+ [model, effects] = deliver(model, paged(10, middle, secondPage));
142
+ expect(effects.filter((effect) => effect.kind === "pull")).toMatchObject([
143
+ { page: secondPage.next },
144
+ ]);
145
+ [model] = deliver(model, paged(10, oldest, lastPage));
146
+ expect(history(model.replica)).toStrictEqual(complete);
147
+ assertCopy(model, new Reference(10, all));
148
+ });
149
+ test("8: verification is postponed on partial history, then requested when complete", () => {
150
+ const [, firstEffects] = deliver(loaded(), paged(10, newest, firstPage, true), 0);
151
+ expect(firstEffects.filter((effect) => effect.kind === "check")).toStrictEqual([]);
152
+ const [model, effects] = sync.update(sync.initial("page-a", 0), {
153
+ type: "loaded",
154
+ replica: partialCopy(),
155
+ commands: [],
156
+ unreadable: 0,
157
+ now: 0,
158
+ });
159
+ expect(effects.filter((effect) => effect.kind === "pull")).toMatchObject([
160
+ { verify: false, page: firstPage.next },
161
+ ]);
162
+ const [middleModel, during] = deliver(model, paged(10, middle, secondPage));
163
+ expect(during.filter((effect) => effect.kind === "check")).toStrictEqual([]);
164
+ const [, finished] = deliver(middleModel, paged(10, oldest, lastPage));
165
+ expect(finished.some((effect) => effect.kind === "check" || (effect.kind === "pull" && effect.verify))).toBe(true);
166
+ });
167
+ test("9: an old server cannot certify partial history; the recovery request omits page and asks since zero", () => {
168
+ const [model, effects] = deliver(loaded(partialCopy()), patch(11, [row("f", 61, 11)]), 10);
169
+ expect(history(model.replica)).toStrictEqual(partialCopy().history);
170
+ const pulls = effects.filter((effect) => effect.kind === "pull");
171
+ expect(pulls).toHaveLength(1);
172
+ expect(pulls[0]).toMatchObject({ since: 0 });
173
+ expect(Object.hasOwn(pulls[0] ?? {}, "page")).toBe(false);
174
+ const [whole] = deliver(model, patch(11, [row("f", 61, 11), ...all.filter((item) => item.id !== "f")], true), 0);
175
+ expect(history(whole.replica)).toStrictEqual(complete);
176
+ expect(whole.replica.tables.rows.size).toBe(6);
177
+ });
178
+ test("10: a delta below through never deepens the coverage claim", () => {
179
+ const [model] = deliver(loaded(partialCopy()), paged(11, [row("a", 5, 11)], { ...firstPage, from: "unrelated", held: 2, total: 6 }));
180
+ expect(model.replica.tables.rows.get("a")).toStrictEqual(row("a", 5, 11));
181
+ expect(history(model.replica)).toStrictEqual(partialCopy().history);
182
+ });
183
+ test("11: a paged reset that settles a command holds the row named by its receipt", () => {
184
+ const model = { ...loaded(), commands: [pending("page-a", 1)] };
185
+ const effectRow = row("effect-below-page", 1, 10);
186
+ const [after] = sync.update(model, {
187
+ type: "pulled",
188
+ since: 0,
189
+ patch: paged(10, [...newest, effectRow], { ...firstPage, total: 7 }, true),
190
+ receipts: [{ id: 1, revision: 10, outcome: { kind: "ok", resource: effectRow.id } }],
191
+ now: 1,
192
+ });
193
+ const command = after.commands[0];
194
+ expect(command).toBeDefined();
195
+ if (command === undefined) {
196
+ throw new Error("missing command");
197
+ }
198
+ expect(settled(after, command)).toBe(true);
199
+ expect(after.replica.tables.rows.get(effectRow.id)).toStrictEqual(effectRow);
200
+ expect(history(after.replica)).toStrictEqual({ ...partialCopy().history, total: 7 });
201
+ });
202
+ test("compatibility: a whole legacy reply is a complete copy and late replies stay harmless", () => {
203
+ let model = loaded();
204
+ [model] = deliver(model, patch(10, all, true), 0);
205
+ expect(values(model.replica)).toStrictEqual(values(copy(10, all)));
206
+ [model] = deliver(model, patch(9, [row("f", 999, 9)], true), 0);
207
+ expect(values(model.replica)).toStrictEqual(values(copy(10, all)));
208
+ });
209
+ });
@@ -0,0 +1,74 @@
1
+ /** Synthetic rows an application's adapter maps to its own paged and unpaged tables. Order is (value, id), descending. */
2
+ export type PullRow = {
3
+ id: string;
4
+ value: number;
5
+ revision: number;
6
+ deleted: boolean;
7
+ };
8
+ export type PullWrite = {
9
+ rows: Omit<PullRow, "revision">[];
10
+ unpaged?: Omit<PullRow, "revision">[];
11
+ receipt?: {
12
+ client: string;
13
+ id: number;
14
+ resource: string;
15
+ };
16
+ };
17
+ export type PullRequest = {
18
+ client: string;
19
+ dataset: string | null;
20
+ since: number;
21
+ verify: boolean;
22
+ page?: string | null;
23
+ };
24
+ export type PullPage = {
25
+ from: string | null;
26
+ next: string | null;
27
+ through: string | null;
28
+ held: number;
29
+ total: number;
30
+ };
31
+ export type PullReply = {
32
+ patch: {
33
+ dataset: string;
34
+ revision: number;
35
+ reset: boolean;
36
+ rows: {
37
+ rows: PullRow[];
38
+ unpaged: PullRow[];
39
+ };
40
+ page?: PullPage;
41
+ };
42
+ receipts: {
43
+ id: number;
44
+ revision: number;
45
+ outcome: {
46
+ resource: string;
47
+ };
48
+ }[];
49
+ /** Exact encoded response size, before normalization by the application's adapter. */
50
+ bytes: number;
51
+ };
52
+ export type PullLimits = {
53
+ firstRows: number;
54
+ pageRows: number;
55
+ maxRows: number;
56
+ maxBytes: number;
57
+ };
58
+ export type PullFixture = {
59
+ pull: (request: PullRequest) => Promise<PullReply>;
60
+ /** One committed transaction, including any receipt. The adapter stamps the returned revision on all written rows. */
61
+ write: (changes: PullWrite) => Promise<{
62
+ dataset: string;
63
+ revision: number;
64
+ }>;
65
+ /** A new dataset, with no rows, receipts or usable cursors from the previous dataset. */
66
+ replace: () => Promise<void>;
67
+ close: () => Promise<void>;
68
+ };
69
+ export type PullConformance = {
70
+ name: string;
71
+ create: (limits: PullLimits) => Promise<PullFixture>;
72
+ };
73
+ /** Registers black-box pull requirements against a fresh real application store for each case. */
74
+ export declare function pullConformance(input: PullConformance): void;