@procedures/collections 0.2.3 → 0.4.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,509 @@
1
+ /**
2
+ * The contract of `CollectionStorage` as one test suite (finding of roadmap R45).
3
+ *
4
+ * Each storage package runs the suite with a factory of its storage: `InMemoryStorage` in this
5
+ * package, `ApiStorage` and `HybridStorage` in `@procedures/store`, and `SqliteStorage` in
6
+ * `@procedures/sqlite`. Before, each package copied the cases of `InMemoryStorage`.
7
+ *
8
+ * The suite is in the test-only entry `@procedures/collections/testing`. It imports no test
9
+ * framework: the caller gives `describe` and `it`, and the checks throw `ContractViolation`. So a
10
+ * storage package imports the suite without a cycle, and a runtime entry never loads vitest.
11
+ *
12
+ * ```ts
13
+ * import { describe, it } from "vitest";
14
+ * import { describeCollectionStorageContract } from "@procedures/collections/testing";
15
+ *
16
+ * describeCollectionStorageContract("MyStorage", { create: () => new MyStorage() }, { describe, it });
17
+ * ```
18
+ */
19
+
20
+ import type { CollectionStorage, StorageChanges } from "../storage/interface.js";
21
+ import { byId, holds, rejects, same, show } from "./check.js";
22
+
23
+ /** The item of the suite. Its `id` is its key, so a `HybridStorage` can sync it. */
24
+ export interface ContractItem {
25
+ id: string;
26
+ n: number;
27
+ tags?: string[];
28
+ }
29
+
30
+ /** A storage of the suite. */
31
+ export type ContractStorage = CollectionStorage<ContractItem>;
32
+
33
+ /** The options of the suite: the factory of the storage, and what the storage keeps. */
34
+ export interface CollectionStorageContractOptions {
35
+ /** This function makes a new, empty storage for each case. The case closes it at its end. */
36
+ create(): ContractStorage | Promise<ContractStorage>;
37
+ /**
38
+ * This function makes a new, empty storage whose change log keeps at most `max` deletes. With it,
39
+ * the suite tests the drop of the old deletes. A storage without such a limit does not give it.
40
+ */
41
+ createWithMaxTombstones?: ((max: number) => ContractStorage | Promise<ContractStorage>) | undefined;
42
+ /**
43
+ * True when the storage keeps a copy of each value: a change of a value after `set`, or of a read
44
+ * value, does not change the stored item. The interface does not ask for copies, so the default
45
+ * is false, and the suite then does not test them.
46
+ */
47
+ copies?: boolean | undefined;
48
+ /**
49
+ * False when a stored `null` reads back as `undefined`. A remote storage can lose the difference
50
+ * on the wire. The default is true.
51
+ */
52
+ nullValues?: boolean | undefined;
53
+ }
54
+
55
+ /** The context that a test framework gives to a case. Vitest gives `skip`. */
56
+ export interface ContractTestContext {
57
+ skip?(note?: string): void;
58
+ }
59
+
60
+ /** One case of the suite. */
61
+ export interface ContractCase {
62
+ readonly name: string;
63
+ run(context?: ContractTestContext): Promise<void>;
64
+ }
65
+
66
+ /** The functions of a test framework that the suite uses: `describe` and `it` of vitest fit. */
67
+ export interface ContractTestApi {
68
+ describe(name: string, body: () => void): unknown;
69
+ it(name: string, body: (context: ContractTestContext) => Promise<void>): unknown;
70
+ }
71
+
72
+ const item = (id: string, n: number, tags?: string[]): ContractItem => (tags === undefined ? { id, n } : { id, n, tags });
73
+ const ids = (items: ContractItem[]): string[] => items.map((value) => value.id);
74
+
75
+ /** True for a value with a `then` function. */
76
+ function isPromise(value: unknown): boolean {
77
+ return typeof (value as { then?: unknown } | null)?.then === "function";
78
+ }
79
+
80
+ /** This function makes a storage, runs the body, and closes the storage. */
81
+ async function withStorage(create: () => ContractStorage | Promise<ContractStorage>, body: (storage: ContractStorage) => Promise<void>): Promise<void> {
82
+ const storage = await create();
83
+ try {
84
+ await body(storage);
85
+ } finally {
86
+ await storage.close().catch(() => undefined);
87
+ }
88
+ }
89
+
90
+ /** The changes of a storage that has `getChangesSince`. */
91
+ function changesOf(storage: ContractStorage): (since?: string) => Promise<StorageChanges<ContractItem>> {
92
+ return (since) => storage.getChangesSince!(since);
93
+ }
94
+
95
+ /** This function skips a case for a storage without an optional method, and gives true. */
96
+ function lacks(storage: ContractStorage, method: "getEntries" | "getChangesSince" | "flush" | "getMetadata", context: ContractTestContext | undefined): boolean {
97
+ if (typeof storage[method] === "function") return false;
98
+ context?.skip?.(`The storage has no ${method}()`);
99
+ return true;
100
+ }
101
+
102
+ /** A random number generator with a seed, so a failed sequence comes again in the next run. */
103
+ function seeded(seed: number): (below: number) => number {
104
+ let state = seed >>> 0;
105
+ return (below) => {
106
+ state = (Math.imul(state, 1664525) + 1013904223) >>> 0;
107
+ return state % below;
108
+ };
109
+ }
110
+
111
+ /**
112
+ * This function gives the cases of the contract for a storage. Each case makes its own storage
113
+ * with `options.create`. A case for an optional method (`getEntries`, `getChangesSince`, `flush`,
114
+ * `getMetadata`) skips a storage without the method.
115
+ */
116
+ export function collectionStorageContract(options: CollectionStorageContractOptions): ContractCase[] {
117
+ const create = (): ContractStorage | Promise<ContractStorage> => options.create();
118
+ const nullValues = options.nullValues ?? true;
119
+ const cases: ContractCase[] = [];
120
+ const add = (name: string, run: (context?: ContractTestContext) => Promise<void>): void => {
121
+ cases.push({ name, run });
122
+ };
123
+
124
+ add("each method gives a promise", () =>
125
+ withStorage(create, async (storage) => {
126
+ const results: Array<[string, unknown]> = [
127
+ ["get", storage.get("a")],
128
+ ["getAll", storage.getAll()],
129
+ ["find", storage.find(() => true)],
130
+ ["has", storage.has("a")],
131
+ ["size", storage.size()],
132
+ ["getBatch", storage.getBatch(["a"])],
133
+ ];
134
+ for (const [name, result] of results) holds(isPromise(result), `${name}() did not give a promise`);
135
+ await Promise.all(results.map(([, result]) => result));
136
+ for (const [name, start] of [
137
+ ["set", () => storage.set("a", item("a", 1))],
138
+ ["setBatch", () => storage.setBatch([["b", item("b", 2)]])],
139
+ ["delete", () => storage.delete("a")],
140
+ ["deleteBatch", () => storage.deleteBatch(["b"])],
141
+ ["clear", () => storage.clear()],
142
+ ] as Array<[string, () => Promise<unknown>]>) {
143
+ const result = start();
144
+ holds(isPromise(result), `${name}() did not give a promise`);
145
+ await result;
146
+ }
147
+ }),
148
+ );
149
+
150
+ add("an empty storage has no item", () =>
151
+ withStorage(create, async (storage) => {
152
+ same(await storage.size(), 0, "size() of an empty storage");
153
+ same(await storage.getAll(), [], "getAll() of an empty storage");
154
+ same(await storage.get("a"), undefined, "get() of a missing id");
155
+ same(await storage.has("a"), false, "has() of a missing id");
156
+ same(await storage.getBatch(["a", "b"]), new Map(), "getBatch() of missing ids");
157
+ same(await storage.find(() => true), [], "find() in an empty storage");
158
+ same(await storage.delete("a"), false, "delete() of a missing id");
159
+ same(await storage.deleteBatch(["a", "b"]), 0, "deleteBatch() of missing ids");
160
+ if (storage.getEntries) same(await storage.getEntries(), [], "getEntries() of an empty storage");
161
+ }),
162
+ );
163
+
164
+ add("set() stores an item, and a second set() of the id replaces it", () =>
165
+ withStorage(create, async (storage) => {
166
+ await storage.set("a", item("a", 1, ["x"]));
167
+ await storage.set("b", item("b", 2));
168
+ same(await storage.get("a"), item("a", 1, ["x"]), "get() after set()");
169
+ same(await storage.has("a"), true, "has() after set()");
170
+ same(await storage.size(), 2, "size() after two set() calls");
171
+ await storage.set("a", item("a", 10));
172
+ same(await storage.get("a"), item("a", 10), "get() after a second set() of the id");
173
+ same(await storage.size(), 2, "size() after a second set() of an id");
174
+ }),
175
+ );
176
+
177
+ add("delete() gives true for an item that existed, and deleteBatch() counts the items that existed", () =>
178
+ withStorage(create, async (storage) => {
179
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)], ["c", item("c", 3)]]);
180
+ same(await storage.delete("a"), true, "delete() of an item");
181
+ same(await storage.delete("a"), false, "a second delete() of the id");
182
+ same(await storage.has("a"), false, "has() after delete()");
183
+ same(await storage.get("a"), undefined, "get() after delete()");
184
+ same(await storage.deleteBatch(["b", "missing", "b"]), 1, "deleteBatch() with a missing id and a repeated id");
185
+ same(ids(await storage.getAll()), ["c"], "getAll() after the deletes");
186
+ }),
187
+ );
188
+
189
+ add("setBatch() and getBatch(): the last value of an id wins, a missing id is not in the map, and empty batches change nothing", () =>
190
+ withStorage(create, async (storage) => {
191
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)], ["a", item("a", 3)]]);
192
+ same(await storage.get("a"), item("a", 3), "get() of an id that setBatch() set two times");
193
+ same(await storage.size(), 2, "size() after setBatch() with a repeated id");
194
+ same(
195
+ await storage.getBatch(["b", "missing", "a"]),
196
+ new Map([["b", item("b", 2)], ["a", item("a", 3)]]),
197
+ "getBatch() with a missing id",
198
+ );
199
+ await storage.setBatch([]);
200
+ same(await storage.deleteBatch([]), 0, "deleteBatch() of no id");
201
+ same(await storage.getBatch([]), new Map(), "getBatch() of no id");
202
+ same(await storage.size(), 2, "size() after the empty batches");
203
+ }),
204
+ );
205
+
206
+ add("the entries keep the order of the first set() of each id, as a Map does", () =>
207
+ withStorage(create, async (storage) => {
208
+ await storage.setBatch([["c", item("c", 3)], ["a", item("a", 1)], ["b", item("b", 2)]]);
209
+ await storage.set("a", item("a", 10));
210
+ same(ids(await storage.getAll()), ["c", "a", "b"], "getAll() after a second set() of an id: the id keeps its place");
211
+ // A delete, then a set: the id goes to the end
212
+ await storage.delete("c");
213
+ await storage.set("c", item("c", 30));
214
+ const expected: Array<[string, ContractItem]> = [["a", item("a", 10)], ["b", item("b", 2)], ["c", item("c", 30)]];
215
+ same(await storage.getAll(), expected.map(([, value]) => value), "getAll() after a delete and a new set() of an id");
216
+ same(ids(await storage.find(() => true)), ["a", "b", "c"], "find() keeps the order of the entries");
217
+ if (storage.getEntries) same(await storage.getEntries(), expected, "getEntries() keeps the order of the entries");
218
+ }),
219
+ );
220
+
221
+ add("find() gives the matching items in order, and an error of the predicate rejects the promise", () =>
222
+ withStorage(create, async (storage) => {
223
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)], ["c", item("c", 3)], ["d", item("d", 4)]]);
224
+ same(ids(await storage.find((value) => value.n % 2 === 0)), ["b", "d"], "find() of the even items");
225
+ same(await storage.find(() => false), [], "find() without a match");
226
+ const error = await rejects(
227
+ () =>
228
+ storage.find(() => {
229
+ throw new Error("the predicate failed");
230
+ }),
231
+ "find() with a predicate that throws",
232
+ );
233
+ holds(error instanceof Error && error.message.includes("the predicate failed"), `find() must reject with the error of the predicate, not ${show(error instanceof Error ? error.message : error)}`);
234
+ }),
235
+ );
236
+
237
+ add("clear() deletes every item, and the storage takes new items after it", () =>
238
+ withStorage(create, async (storage) => {
239
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)], ["c", item("c", 3)]]);
240
+ await storage.clear();
241
+ same(await storage.size(), 0, "size() after clear()");
242
+ same(await storage.getAll(), [], "getAll() after clear()");
243
+ same(await storage.has("a"), false, "has() after clear()");
244
+ await storage.clear();
245
+ await storage.set("d", item("d", 4));
246
+ same(await storage.getAll(), [item("d", 4)], "getAll() after clear() and set()");
247
+ }),
248
+ );
249
+
250
+ add("each result is a new array or map: a change of a result does not change the storage", () =>
251
+ withStorage(create, async (storage) => {
252
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)]]);
253
+ const all = await storage.getAll();
254
+ all.pop();
255
+ all.push(item("z", 0));
256
+ const found = await storage.find(() => true);
257
+ found.length = 0;
258
+ const batch = await storage.getBatch(["a"]);
259
+ batch.delete("a");
260
+ batch.set("z", item("z", 0));
261
+ if (storage.getEntries) (await storage.getEntries()).splice(0);
262
+ same(ids(await storage.getAll()), ["a", "b"], "getAll() after changes of the results");
263
+ same(await storage.has("z"), false, "has() of an id that only a result got");
264
+ same(await storage.getBatch(["a"]), new Map([["a", item("a", 1)]]), "getBatch() after a change of a result");
265
+ }),
266
+ );
267
+
268
+ if (options.copies) {
269
+ add("the storage keeps copies: a change of a written or a read value does not change the item", () =>
270
+ withStorage(create, async (storage) => {
271
+ const value = item("a", 1, ["x"]);
272
+ await storage.set("a", value);
273
+ value.n = 99;
274
+ value.tags!.push("changed after set");
275
+ const read = (await storage.get("a"))!;
276
+ same(read, item("a", 1, ["x"]), "get() after a change of the value that set() took");
277
+ read.n = 98;
278
+ read.tags!.push("changed after get");
279
+ (await storage.getAll())[0]!.n = 97;
280
+ (await storage.getBatch(["a"])).get("a")!.n = 96;
281
+ (await storage.find(() => true))[0]!.n = 95;
282
+ same(await storage.get("a"), item("a", 1, ["x"]), "get() after changes of the read values");
283
+ }),
284
+ );
285
+ }
286
+
287
+ add(`the storage keeps JSON values${nullValues ? ", also null" : ""}`, () =>
288
+ withStorage(create, async (typed) => {
289
+ const storage = typed as unknown as CollectionStorage<unknown>;
290
+ const values: Array<[string, unknown]> = [
291
+ ["object", { text: "é ✓ 😀", list: [1, null, { deep: true }], n: 1.5, empty: {}, none: [] }],
292
+ ["text", "plain"],
293
+ ["empty", ""],
294
+ ["zero", 0],
295
+ ["negative", -2.25],
296
+ ["false", false],
297
+ ["list", [1, "two", [3]]],
298
+ ];
299
+ if (nullValues) values.push(["null", null]);
300
+ await storage.setBatch(values);
301
+ for (const [id, value] of values) same(await storage.get(id), value, `get("${id}")`);
302
+ same(await storage.getAll(), values.map(([, value]) => value), "getAll() of the JSON values");
303
+ same(await storage.getBatch(values.map(([id]) => id)), new Map(values), "getBatch() of the JSON values");
304
+ same(await storage.size(), values.length, "size() with the JSON values");
305
+ if (nullValues) same(await storage.has("null"), true, "has() of an id whose value is null");
306
+ }),
307
+ );
308
+
309
+ add("writes that run at the same time keep each write", () =>
310
+ withStorage(create, async (storage) => {
311
+ const count = 20;
312
+ await Promise.all(Array.from({ length: count }, (_, n) => storage.set(`k${n}`, item(`k${n}`, n))));
313
+ same(await storage.size(), count, `size() after ${count} writes at the same time`);
314
+ same(ids(await storage.getAll()).sort(), Array.from({ length: count }, (_, n) => `k${n}`).sort(), "the ids after the writes at the same time");
315
+ }),
316
+ );
317
+
318
+ add("getChangesSince() without a version gives every item", (context) =>
319
+ withStorage(create, async (storage) => {
320
+ if (lacks(storage, "getChangesSince", context)) return;
321
+ const changes = changesOf(storage);
322
+ const entries: Array<[string, ContractItem]> = [["a", item("a", 1)], ["b", item("b", 2)], ["c", item("c", 3)]];
323
+ await storage.setBatch(entries);
324
+ const first = await changes();
325
+ holds(typeof first.version === "string" && first.version !== "", `the version must be a text that is not empty, not ${show(first.version)}`);
326
+ same(first.full, true, "full of the first call");
327
+ same(byId(first.changed), entries, "changed of the first call: every item");
328
+ same(first.deleted, [], "deleted of a full result");
329
+ }),
330
+ );
331
+
332
+ add("getChangesSince(version) gives the items that changed after the version and the ids that were deleted", (context) =>
333
+ withStorage(create, async (storage) => {
334
+ if (lacks(storage, "getChangesSince", context)) return;
335
+ const changes = changesOf(storage);
336
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)], ["c", item("c", 3)]]);
337
+ const start = await changes();
338
+ await storage.set("a", item("a", 10));
339
+ await storage.delete("b");
340
+ await storage.delete("missing");
341
+ await storage.set("d", item("d", 4));
342
+ const delta = await changes(start.version);
343
+ same(delta.full, false, "full of a delta");
344
+ same(byId(delta.changed), [["a", item("a", 10)], ["d", item("d", 4)]], "changed of a delta");
345
+ same(delta.deleted, ["b"], "deleted of a delta");
346
+ holds(delta.version !== start.version, "the version must change after a write");
347
+ }),
348
+ );
349
+
350
+ add("getChangesSince(): a deleted id that comes back is a change, clear() deletes each id, and no write gives no change", (context) =>
351
+ withStorage(create, async (storage) => {
352
+ if (lacks(storage, "getChangesSince", context)) return;
353
+ const changes = changesOf(storage);
354
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)], ["c", item("c", 3)]]);
355
+ const start = await changes();
356
+ await storage.delete("b");
357
+ await storage.set("b", item("b", 20));
358
+ const back = await changes(start.version);
359
+ same({ full: back.full, changed: byId(back.changed), deleted: back.deleted }, { full: false, changed: [["b", item("b", 20)]], deleted: [] }, "a delete, then a set of the id");
360
+
361
+ await storage.clear();
362
+ const cleared = await changes(back.version);
363
+ same({ full: cleared.full, changed: cleared.changed, deleted: [...cleared.deleted].sort() }, { full: false, changed: [], deleted: ["a", "b", "c"] }, "the changes of clear()");
364
+ const quiet = await changes(cleared.version);
365
+ same({ full: quiet.full, changed: quiet.changed, deleted: quiet.deleted }, { full: false, changed: [], deleted: [] }, "the changes without a write");
366
+ }),
367
+ );
368
+
369
+ add("getChangesSince() gives every item for an unknown version and for a version of another storage", (context) =>
370
+ withStorage(create, async (storage) => {
371
+ if (lacks(storage, "getChangesSince", context)) return;
372
+ const changes = changesOf(storage);
373
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)]]);
374
+ await changes();
375
+ for (const unknown of ["not a version", ""]) {
376
+ const result = await changes(unknown);
377
+ same({ full: result.full, changed: byId(result.changed), deleted: result.deleted }, { full: true, changed: [["a", item("a", 1)], ["b", item("b", 2)]], deleted: [] }, `the changes since ${show(unknown)}`);
378
+ }
379
+ await withStorage(create, async (other) => {
380
+ await other.set("x", item("x", 9));
381
+ const foreign = (await changesOf(other)()).version;
382
+ same((await changes(foreign)).full, true, "full for a version of another storage");
383
+ });
384
+ }),
385
+ );
386
+
387
+ if (options.createWithMaxTombstones) {
388
+ const withLimit = options.createWithMaxTombstones;
389
+ add("getChangesSince() gives every item for a version older than the deletes that the log dropped", (context) =>
390
+ withStorage(() => withLimit(2), async (storage) => {
391
+ if (lacks(storage, "getChangesSince", context)) return;
392
+ const changes = changesOf(storage);
393
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)], ["c", item("c", 3)], ["d", item("d", 4)]]);
394
+ const start = await changes();
395
+ // Two deletes fit in the log
396
+ await storage.deleteBatch(["a", "b"]);
397
+ const two = await changes(start.version);
398
+ same({ full: two.full, changed: two.changed, deleted: [...two.deleted].sort() }, { full: false, changed: [], deleted: ["a", "b"] }, "two deletes in a log of two");
399
+ // The third delete drops the oldest one: the start version gives all items now
400
+ await storage.delete("c");
401
+ const after = await changes(start.version);
402
+ same({ full: after.full, changed: byId(after.changed), deleted: after.deleted }, { full: true, changed: [["d", item("d", 4)]], deleted: [] }, "a version older than a dropped delete");
403
+ same((await changes(after.version)).full, false, "a version after the dropped deletes still gives a delta");
404
+ }),
405
+ );
406
+ }
407
+
408
+ add("a random sequence of writes agrees with a Map, and each version gives the right changes", () =>
409
+ withStorage(create, async (storage) => {
410
+ const random = seeded(45);
411
+ const pool = ["a", "b", "c", "d", "e", "f", "g"];
412
+ const pick = (): string => pool[random(pool.length)]!;
413
+ const model = new Map<string, ContractItem>();
414
+ const versions: Array<{ version: string; entries: Map<string, ContractItem> }> = [];
415
+ const hasChanges = typeof storage.getChangesSince === "function";
416
+ for (let step = 0; step < 80; step++) {
417
+ const kind = random(20);
418
+ let label: string;
419
+ if (kind < 8) {
420
+ const id = pick();
421
+ const value = item(id, step);
422
+ label = `set(${id})`;
423
+ await storage.set(id, value);
424
+ model.set(id, value);
425
+ } else if (kind < 12) {
426
+ const id = pick();
427
+ label = `delete(${id})`;
428
+ same(await storage.delete(id), model.delete(id), `the result of step ${step}: ${label}`);
429
+ } else if (kind < 15) {
430
+ const batch: Array<[string, ContractItem]> = Array.from({ length: 1 + random(3) }, (_, index) => {
431
+ const id = pick();
432
+ return [id, item(id, step * 10 + index)];
433
+ });
434
+ label = `setBatch(${batch.map(([id]) => id).join(", ")})`;
435
+ await storage.setBatch(batch);
436
+ for (const [id, value] of batch) model.set(id, value);
437
+ } else if (kind < 18) {
438
+ const batch = Array.from({ length: 1 + random(3) }, pick);
439
+ label = `deleteBatch(${batch.join(", ")})`;
440
+ const expected = batch.filter((id) => model.delete(id)).length;
441
+ same(await storage.deleteBatch(batch), expected, `the result of step ${step}: ${label}`);
442
+ } else if (kind < 19) {
443
+ label = "clear()";
444
+ await storage.clear();
445
+ model.clear();
446
+ } else {
447
+ label = "a saved version";
448
+ if (hasChanges) versions.push({ version: (await storage.getChangesSince!()).version, entries: new Map(model) });
449
+ }
450
+ same(await storage.getAll(), [...model.values()], `getAll() after step ${step}: ${label}`);
451
+ }
452
+ same(await storage.size(), model.size, "size() after the sequence");
453
+ if (storage.getEntries) same(await storage.getEntries(), [...model.entries()], "getEntries() after the sequence");
454
+ if (!hasChanges) return;
455
+ holds(versions.length > 0, "the sequence saved no version: change the seed");
456
+ for (const [index, saved] of versions.entries()) {
457
+ const result = await storage.getChangesSince!(saved.version);
458
+ const deleted = new Set(result.deleted);
459
+ holds(result.changed.every(([id]) => !deleted.has(id)), `the changes since version ${index} have an id in changed and in deleted`);
460
+ const rebuilt = result.full ? new Map<string, ContractItem>() : new Map(saved.entries);
461
+ for (const id of result.deleted) rebuilt.delete(id);
462
+ for (const [id, value] of result.changed) rebuilt.set(id, value);
463
+ same(byId([...rebuilt.entries()]), byId([...model.entries()]), `the items of version ${index} with its changes`);
464
+ }
465
+ }),
466
+ );
467
+
468
+ add("flush() resolves, and the items stay", (context) =>
469
+ withStorage(create, async (storage) => {
470
+ if (lacks(storage, "flush", context)) return;
471
+ await storage.flush!();
472
+ await storage.set("a", item("a", 1));
473
+ await storage.flush!();
474
+ same(await storage.get("a"), item("a", 1), "get() after flush()");
475
+ }),
476
+ );
477
+
478
+ add("getMetadata() gives a known type and the size", (context) =>
479
+ withStorage(create, async (storage) => {
480
+ if (lacks(storage, "getMetadata", context)) return;
481
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)]]);
482
+ const metadata = await storage.getMetadata!();
483
+ holds(["memory", "api", "hybrid", "synced", "custom"].includes(metadata.type), `getMetadata() gave the unknown type ${show(metadata.type)}`);
484
+ same(metadata.size, 2, "the size of getMetadata()");
485
+ }),
486
+ );
487
+
488
+ add("close() resolves, and a second close() resolves too", () =>
489
+ withStorage(create, async (storage) => {
490
+ await storage.set("a", item("a", 1));
491
+ await storage.close();
492
+ await storage.close();
493
+ }),
494
+ );
495
+
496
+ return cases;
497
+ }
498
+
499
+ /**
500
+ * This function declares the suite in a test framework: one `describe` with one `it` for each
501
+ * case. Vitest gives `skip` to a case, so a case for a missing optional method shows as skipped.
502
+ */
503
+ export function describeCollectionStorageContract(name: string, options: CollectionStorageContractOptions, api: ContractTestApi): void {
504
+ api.describe(`${name} follows the CollectionStorage contract`, () => {
505
+ for (const contractCase of collectionStorageContract(options)) {
506
+ api.it(contractCase.name, (context) => contractCase.run(context));
507
+ }
508
+ });
509
+ }