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