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,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 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { IDBFactory } from "fake-indexeddb";
|
|
2
|
+
import { expect, test } from "vitest";
|
|
3
|
+
import { createClient } from "../client.js";
|
|
4
|
+
import { literal, nullable, number, object, optional, string, variant } from "../schema.js";
|
|
5
|
+
import * as syncApi from "../sync.js";
|
|
6
|
+
import { createSync, POLL_MS } from "../sync.js";
|
|
7
|
+
import { indexedReplica } from "../web/replica.js";
|
|
8
|
+
import { outcomeSchema } from "./fixtures.js";
|
|
9
|
+
const pendingSchema = object({
|
|
10
|
+
client: string(),
|
|
11
|
+
id: number(),
|
|
12
|
+
dataset: nullable(string()),
|
|
13
|
+
since: optional(number()),
|
|
14
|
+
command: object({ text: string() }),
|
|
15
|
+
at: number(),
|
|
16
|
+
state: variant("kind", {
|
|
17
|
+
saving: object({ kind: literal("saving") }),
|
|
18
|
+
queued: object({ kind: literal("queued") }),
|
|
19
|
+
sending: object({ kind: literal("sending") }),
|
|
20
|
+
unknown: object({
|
|
21
|
+
kind: literal("unknown"),
|
|
22
|
+
failure: string(),
|
|
23
|
+
parked: optional(literal(true)),
|
|
24
|
+
}),
|
|
25
|
+
done: object({ kind: literal("done"), outcome: outcomeSchema, revision: number() }),
|
|
26
|
+
}),
|
|
27
|
+
});
|
|
28
|
+
function engine(pushBytes) {
|
|
29
|
+
return createSync({
|
|
30
|
+
tables: [],
|
|
31
|
+
empty: {},
|
|
32
|
+
onlineOnly: () => false,
|
|
33
|
+
refused: (_command, reason) => ({ kind: "failed", reason }),
|
|
34
|
+
...(pushBytes === undefined ? {} : { pushBytes }),
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
function storage() {
|
|
38
|
+
return indexedReplica({
|
|
39
|
+
factory: new IDBFactory(),
|
|
40
|
+
name: "bytes",
|
|
41
|
+
version: 1,
|
|
42
|
+
tables: [],
|
|
43
|
+
empty: {},
|
|
44
|
+
server: object({}),
|
|
45
|
+
pending: pendingSchema,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
const queued = (id, text) => ({
|
|
49
|
+
client: "earlier",
|
|
50
|
+
id,
|
|
51
|
+
dataset: "d",
|
|
52
|
+
since: 1,
|
|
53
|
+
command: { text },
|
|
54
|
+
at: 0,
|
|
55
|
+
state: { kind: "queued" },
|
|
56
|
+
});
|
|
57
|
+
async function run(commands, pushBytes) {
|
|
58
|
+
const disk = storage();
|
|
59
|
+
await disk.load();
|
|
60
|
+
for (const each of commands) {
|
|
61
|
+
await disk.keep(each);
|
|
62
|
+
}
|
|
63
|
+
const requests = [];
|
|
64
|
+
const answered = [];
|
|
65
|
+
const client = createClient({
|
|
66
|
+
sync: engine(pushBytes),
|
|
67
|
+
client: "new",
|
|
68
|
+
ports: {
|
|
69
|
+
storage: disk,
|
|
70
|
+
now: () => 0,
|
|
71
|
+
fingerprint: async () => "fixture",
|
|
72
|
+
answered: (each) => {
|
|
73
|
+
answered.push(each);
|
|
74
|
+
},
|
|
75
|
+
network: {
|
|
76
|
+
pull: async () => ({
|
|
77
|
+
ok: true,
|
|
78
|
+
value: {
|
|
79
|
+
patch: {
|
|
80
|
+
dataset: "d",
|
|
81
|
+
revision: 1,
|
|
82
|
+
reset: true,
|
|
83
|
+
rows: {},
|
|
84
|
+
server: {},
|
|
85
|
+
checksum: "fixture",
|
|
86
|
+
},
|
|
87
|
+
receipts: [],
|
|
88
|
+
},
|
|
89
|
+
}),
|
|
90
|
+
push: async (input) => {
|
|
91
|
+
requests.push({
|
|
92
|
+
bytes: new TextEncoder().encode(JSON.stringify(input)).byteLength,
|
|
93
|
+
ids: input.commands.map((each) => each.id),
|
|
94
|
+
});
|
|
95
|
+
return {
|
|
96
|
+
ok: true,
|
|
97
|
+
value: {
|
|
98
|
+
revision: 1,
|
|
99
|
+
receipts: input.commands.map((each) => ({
|
|
100
|
+
id: each.id,
|
|
101
|
+
outcome: { kind: "ok", resource: `r${each.id}` },
|
|
102
|
+
revision: 1,
|
|
103
|
+
})),
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
client.start();
|
|
111
|
+
await client.settle();
|
|
112
|
+
return { client, disk, requests, answered };
|
|
113
|
+
}
|
|
114
|
+
test("the default push budget is 48 KiB of the whole encoded request, including multibyte commands", async () => {
|
|
115
|
+
const app = await run([
|
|
116
|
+
queued(1, "é".repeat(13_000)),
|
|
117
|
+
queued(2, "€".repeat(9000)),
|
|
118
|
+
queued(3, "x"),
|
|
119
|
+
]);
|
|
120
|
+
expect(app.requests.map((each) => each.ids)).toStrictEqual([[1], [2, 3]]);
|
|
121
|
+
expect(app.requests.every((each) => each.bytes <= 49_152)).toBe(true);
|
|
122
|
+
expect(app.answered.map((each) => each.id)).toStrictEqual([1, 2, 3]);
|
|
123
|
+
expect(app.client.faults).toStrictEqual([]);
|
|
124
|
+
expect(syncApi.PUSH_BYTES).toBe(49_152);
|
|
125
|
+
});
|
|
126
|
+
test("packing stops at the first non-fitting command, rather than skipping it for a smaller later command", async () => {
|
|
127
|
+
// Literal wire sizes: envelope 58, each encoded command 30 + its text bytes, separators 1.
|
|
128
|
+
// First+second is over 300, but first+third fits. Oldest-first must not choose first+third.
|
|
129
|
+
const app = await run([queued(1, "é".repeat(50)), queued(2, "€".repeat(40)), queued(3, "x")], 300);
|
|
130
|
+
expect(app.requests.map((each) => each.ids)).toStrictEqual([[1], [2, 3]]);
|
|
131
|
+
expect(app.requests.every((each) => each.bytes <= 300)).toBe(true);
|
|
132
|
+
expect(app.answered.map((each) => each.id)).toStrictEqual([1, 2, 3]);
|
|
133
|
+
});
|
|
134
|
+
test.each([false, true])("a kept oversize oldest command does not strand later work (uncertain: %s)", async (uncertain) => {
|
|
135
|
+
const old = queued(1, "é".repeat(200));
|
|
136
|
+
if (uncertain) {
|
|
137
|
+
old.state = { kind: "unknown", failure: "an earlier reply was lost" };
|
|
138
|
+
}
|
|
139
|
+
const app = await run([old, queued(2, "small")], 300);
|
|
140
|
+
expect(app.requests.map((each) => each.ids)).toStrictEqual([[2]]);
|
|
141
|
+
const first = app.client.model().commands[0];
|
|
142
|
+
expect(first?.state).toMatchObject(uncertain
|
|
143
|
+
? { kind: "unknown", parked: true }
|
|
144
|
+
: { kind: "done", revision: 0, outcome: { kind: "failed" } });
|
|
145
|
+
expect(app.client.model().commands[1]?.state).toStrictEqual({
|
|
146
|
+
kind: "done",
|
|
147
|
+
outcome: { kind: "ok", resource: "r2" },
|
|
148
|
+
revision: 1,
|
|
149
|
+
});
|
|
150
|
+
expect((await app.disk.load()).commands[0]?.state).toStrictEqual(first?.state);
|
|
151
|
+
expect(app.client.faults).toStrictEqual([]);
|
|
152
|
+
app.client.dispatch({ type: "tick", now: POLL_MS * 3 });
|
|
153
|
+
await app.client.settle();
|
|
154
|
+
expect(app.requests.map((each) => each.ids)).toStrictEqual([[2]]);
|
|
155
|
+
});
|
|
156
|
+
test("a new oversize tap is answered without being retained or sent", async () => {
|
|
157
|
+
const app = await run([], 300);
|
|
158
|
+
app.client.command({ text: "é".repeat(200) });
|
|
159
|
+
await app.client.settle();
|
|
160
|
+
expect(app.requests).toStrictEqual([]);
|
|
161
|
+
expect(app.client.model().commands).toStrictEqual([]);
|
|
162
|
+
expect((await app.disk.load()).commands).toStrictEqual([]);
|
|
163
|
+
expect(app.answered).toHaveLength(1);
|
|
164
|
+
expect(app.answered[0]?.state).toMatchObject({
|
|
165
|
+
kind: "done",
|
|
166
|
+
revision: 0,
|
|
167
|
+
outcome: { kind: "failed" },
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
test("pushBytes must be a positive safe integer", () => {
|
|
171
|
+
for (const bytes of [0, -1, 1.5, Number.NaN]) {
|
|
172
|
+
expect(() => engine(bytes)).toThrow(/pushBytes must be a positive integer/);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|