@procedures/collections 0.8.0 → 0.9.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 +41 -0
- package/README.md +104 -10
- package/dist/.tsbuildinfo +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/overlay/index.d.ts +6 -0
- package/dist/overlay/index.d.ts.map +1 -0
- package/dist/overlay/index.js +6 -0
- package/dist/overlay/index.js.map +1 -0
- package/dist/overlay/overlay.d.ts +55 -0
- package/dist/overlay/overlay.d.ts.map +1 -0
- package/dist/overlay/overlay.js +82 -0
- package/dist/overlay/overlay.js.map +1 -0
- package/dist/overlay/stacks.d.ts +44 -0
- package/dist/overlay/stacks.d.ts.map +1 -0
- package/dist/overlay/stacks.js +43 -0
- package/dist/overlay/stacks.js.map +1 -0
- package/dist/storage/index.d.ts +13 -8
- package/dist/storage/index.d.ts.map +1 -1
- package/dist/storage/index.js +13 -7
- package/dist/storage/index.js.map +1 -1
- package/dist/storage/interface.d.ts +105 -13
- package/dist/storage/interface.d.ts.map +1 -1
- package/dist/storage/interface.js +10 -8
- package/dist/storage/interface.js.map +1 -1
- package/dist/storage/listeners.d.ts +39 -0
- package/dist/storage/listeners.d.ts.map +1 -0
- package/dist/storage/listeners.js +65 -0
- package/dist/storage/listeners.js.map +1 -0
- package/dist/storage/memory.d.ts +46 -6
- package/dist/storage/memory.d.ts.map +1 -1
- package/dist/storage/memory.js +81 -10
- package/dist/storage/memory.js.map +1 -1
- package/dist/testing/storage-contract.d.ts +13 -0
- package/dist/testing/storage-contract.d.ts.map +1 -1
- package/dist/testing/storage-contract.js +472 -0
- package/dist/testing/storage-contract.js.map +1 -1
- package/package.json +4 -1
- package/src/index.ts +3 -0
- package/src/overlay/index.ts +15 -0
- package/src/overlay/overlay.ts +119 -0
- package/src/overlay/stacks.ts +76 -0
- package/src/storage/index.ts +54 -8
- package/src/storage/interface.ts +117 -13
- package/src/storage/listeners.ts +72 -0
- package/src/storage/memory.ts +110 -11
- package/src/testing/storage-contract.ts +522 -2
|
@@ -19,9 +19,16 @@
|
|
|
19
19
|
* The suite has no option that hides a difference of a storage. Each storage keeps copies, keeps
|
|
20
20
|
* a stored `null`, gives a delta in the order of the last change of each id, and rejects each
|
|
21
21
|
* operation after `close()`.
|
|
22
|
+
*
|
|
23
|
+
* The optional `query()` gives the result of `queryEntries()` of `@procedures/client/query` for the
|
|
24
|
+
* entries of the storage: the reference of the query language. Without a policy, `$regex` is off.
|
|
25
|
+
* With `createWithQueryPolicy`, the suite also tests the opt-in of `$regex` and each limit of a
|
|
26
|
+
* policy. `observe()` gives one batch for each write that changes the storage. `patch()` changes
|
|
27
|
+
* an item in one step, in each of its two forms. A storage without them skips their cases.
|
|
22
28
|
*/
|
|
23
29
|
|
|
24
|
-
import type { CollectionStorage, StorageChanges } from "../storage/interface.js";
|
|
30
|
+
import type { CollectionStorage, StorageChangeBatch, StorageChanges } from "../storage/interface.js";
|
|
31
|
+
import { QUERY_OPERATORS, queryEntries, type StorageQueryOptions, type StorageQueryPolicy, type StorageSelector } from "@procedures/client/query";
|
|
25
32
|
import { holds, rejects, same, show } from "./check.js";
|
|
26
33
|
|
|
27
34
|
/** The item of the suite. Its `id` is its key, so a `HybridStorage` can sync it. */
|
|
@@ -46,6 +53,12 @@ export interface CollectionStorageContractOptions {
|
|
|
46
53
|
* the suite tests the drop of the old deletes. A storage without such a limit does not give it.
|
|
47
54
|
*/
|
|
48
55
|
createWithMaxTombstones?: ((max: number) => ContractStorage | Promise<ContractStorage>) | undefined;
|
|
56
|
+
/**
|
|
57
|
+
* This function makes a new, empty storage whose queries follow the policy: the policy of the
|
|
58
|
+
* storage, or of the server of a remote storage. With it, the suite tests the opt-in of `$regex`
|
|
59
|
+
* and each limit of a policy.
|
|
60
|
+
*/
|
|
61
|
+
createWithQueryPolicy?: ((policy: StorageQueryPolicy) => ContractStorage | Promise<ContractStorage>) | undefined;
|
|
49
62
|
}
|
|
50
63
|
|
|
51
64
|
/** The context that a test framework gives to a case. Vitest gives `skip`. */
|
|
@@ -88,13 +101,171 @@ function changesOf(storage: ContractStorage): (since?: string) => Promise<Storag
|
|
|
88
101
|
return (since) => storage.getChangesSince!(since);
|
|
89
102
|
}
|
|
90
103
|
|
|
104
|
+
/** The optional methods of a storage. */
|
|
105
|
+
type OptionalMethod = "getEntries" | "getIds" | "getChangesSince" | "flush" | "getMetadata" | "query" | "observe" | "patch";
|
|
106
|
+
|
|
91
107
|
/** This function skips a case for a storage without an optional method, and gives true. */
|
|
92
|
-
function lacks(storage: ContractStorage, method:
|
|
108
|
+
function lacks(storage: ContractStorage, method: OptionalMethod, context: ContractTestContext | undefined): boolean {
|
|
93
109
|
if (typeof storage[method] === "function") return false;
|
|
94
110
|
context?.skip?.(`The storage has no ${method}()`);
|
|
95
111
|
return true;
|
|
96
112
|
}
|
|
97
113
|
|
|
114
|
+
/** A storage of JSON values, for the cases of the query language. */
|
|
115
|
+
type JsonStorage = CollectionStorage<unknown>;
|
|
116
|
+
|
|
117
|
+
/** The documents of the cases of the query language, in the order of the entries. */
|
|
118
|
+
const DOCUMENTS: ReadonlyArray<[string, unknown]> = [
|
|
119
|
+
["a", { id: "a", n: 1, tags: ["x"], meta: { kind: "k1", score: 10 }, items: [{ name: "p", qty: 1 }] }],
|
|
120
|
+
["b", { id: "b", n: 2, tags: ["x", "y"], meta: { kind: "k2" }, items: [{ name: "q", qty: 5 }, { name: "p", qty: 2 }] }],
|
|
121
|
+
["c", { id: "c", n: 3, tags: [], meta: null }],
|
|
122
|
+
["d", { id: "d", n: 4, label: "Delta" }],
|
|
123
|
+
["e", { id: "e", n: "5", tags: ["y"] }],
|
|
124
|
+
["f", { id: "f", n: null, flag: true }],
|
|
125
|
+
["g", { id: "g", flag: false, meta: { kind: "k1" } }],
|
|
126
|
+
];
|
|
127
|
+
|
|
128
|
+
/** The selectors of the cases, with the ids that each one matches. */
|
|
129
|
+
const SELECTOR_CASES: ReadonlyArray<[StorageSelector, string[]]> = [
|
|
130
|
+
[{}, ["a", "b", "c", "d", "e", "f", "g"]],
|
|
131
|
+
[{ n: 2 }, ["b"]],
|
|
132
|
+
[{ n: { $gt: 1, $lte: 3 } }, ["b", "c"]],
|
|
133
|
+
[{ n: { $gte: "4" } }, ["e"]],
|
|
134
|
+
[{ tags: "x" }, ["a", "b"]],
|
|
135
|
+
[{ tags: ["x", "y"] }, ["b"]],
|
|
136
|
+
[{ "tags.0": "y" }, ["e"]],
|
|
137
|
+
[{ tags: { $size: 0 } }, ["c"]],
|
|
138
|
+
[{ tags: { $all: ["x", "y"] } }, ["b"]],
|
|
139
|
+
[{ tags: { $exists: false } }, ["d", "f", "g"]],
|
|
140
|
+
[{ n: null }, ["f", "g"]],
|
|
141
|
+
[{ n: { $ne: null } }, ["a", "b", "c", "d", "e"]],
|
|
142
|
+
[{ n: { $in: [1, 3, "5"] } }, ["a", "c", "e"]],
|
|
143
|
+
[{ n: { $nin: [1, 3] } }, ["b", "d", "e", "f", "g"]],
|
|
144
|
+
[{ "meta.kind": "k1" }, ["a", "g"]],
|
|
145
|
+
[{ meta: { kind: "k2" } }, ["b"]],
|
|
146
|
+
[{ meta: null }, ["c", "d", "e", "f"]],
|
|
147
|
+
[{ "meta.score": { $lt: 20 } }, ["a"]],
|
|
148
|
+
[{ "items.name": "p" }, ["a", "b"]],
|
|
149
|
+
[{ "items.qty": { $gt: 4 } }, ["b"]],
|
|
150
|
+
[{ "items.1.name": "p" }, ["b"]],
|
|
151
|
+
[{ items: { $elemMatch: { name: "p", qty: { $gte: 2 } } } }, ["b"]],
|
|
152
|
+
[{ tags: { $elemMatch: { $eq: "y" } } }, ["b", "e"]],
|
|
153
|
+
[{ $or: [{ n: 1 }, { flag: true }] }, ["a", "f"]],
|
|
154
|
+
[{ $and: [{ "meta.kind": "k1" }, { flag: false }] }, ["g"]],
|
|
155
|
+
[{ $nor: [{ n: { $exists: true } }, { flag: true }] }, ["g"]],
|
|
156
|
+
[{ n: { $not: { $gt: 2 } } }, ["a", "b", "e", "f", "g"]],
|
|
157
|
+
[{ flag: { $exists: true }, n: null }, ["f", "g"]],
|
|
158
|
+
];
|
|
159
|
+
|
|
160
|
+
/** The selectors with `$regex`, for a policy that permits it, with the ids that each one matches. */
|
|
161
|
+
const REGEX_CASES: ReadonlyArray<[StorageSelector, string[]]> = [
|
|
162
|
+
[{ label: { $regex: "^del", $options: "i" } }, ["d"]],
|
|
163
|
+
[{ id: { $regex: "^[ab]$" } }, ["a", "b"]],
|
|
164
|
+
[{ tags: { $regex: "y" } }, ["b", "e"]],
|
|
165
|
+
[{ id: { $not: { $regex: "^[a-e]" } } }, ["f", "g"]],
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
/** A policy that permits `$regex`, with the default limits. */
|
|
169
|
+
const REGEX_POLICY: StorageQueryPolicy = { operators: QUERY_OPERATORS };
|
|
170
|
+
|
|
171
|
+
/** A selector that can take an exponential time: a repetition of a repetition. */
|
|
172
|
+
const REDOS: StorageSelector = { label: { $regex: "^(a+)+$" } };
|
|
173
|
+
|
|
174
|
+
/** The options of the cases, with the ids of the result for the selector `{}`. */
|
|
175
|
+
const OPTION_CASES: ReadonlyArray<[StorageQueryOptions, string[]]> = [
|
|
176
|
+
[{ sort: { n: 1 } }, ["f", "g", "a", "b", "c", "d", "e"]],
|
|
177
|
+
[{ sort: { n: -1 } }, ["e", "d", "c", "b", "a", "f", "g"]],
|
|
178
|
+
[{ sort: { "meta.kind": 1, n: -1 } }, ["e", "d", "c", "f", "a", "g", "b"]],
|
|
179
|
+
[{ sort: { tags: 1 } }, ["c", "d", "f", "g", "a", "b", "e"]],
|
|
180
|
+
[{ sort: { tags: -1 } }, ["b", "e", "a", "c", "d", "f", "g"]],
|
|
181
|
+
[{ sort: { n: 1 }, skip: 2, limit: 3 }, ["a", "b", "c"]],
|
|
182
|
+
[{ skip: 5 }, ["f", "g"]],
|
|
183
|
+
[{ limit: 2 }, ["a", "b"]],
|
|
184
|
+
[{ skip: 7 }, []],
|
|
185
|
+
];
|
|
186
|
+
|
|
187
|
+
/** The ids of the entries of a result. */
|
|
188
|
+
const entryIds = (entries: ReadonlyArray<readonly [string, unknown]>): string[] => entries.map(([id]) => id);
|
|
189
|
+
|
|
190
|
+
/** The random parts of the query language, from a seeded random number generator. */
|
|
191
|
+
function randomQueries(random: (below: number) => number): {
|
|
192
|
+
selector: (depth: number) => StorageSelector;
|
|
193
|
+
options: () => StorageQueryOptions;
|
|
194
|
+
document: (id: string) => unknown;
|
|
195
|
+
} {
|
|
196
|
+
const fields = ["n", "tags", "tags.0", "meta", "meta.kind", "meta.score", "items.name", "items.qty", "flag", "label", "missing"];
|
|
197
|
+
const values: unknown[] = [0, 1, 2, 3, 4, 10, "5", "x", "y", "z", "k1", "k2", "p", "q", true, false, null, ["x"], ["x", "y"], { kind: "k1" }];
|
|
198
|
+
const scalars: unknown[] = [0, 1, 2, 3, 4, 10, "4", "5", "k1", "x", "y", true, false, null];
|
|
199
|
+
const pick = <V>(list: readonly V[]): V => list[random(list.length)]!;
|
|
200
|
+
const operators = (): Record<string, unknown> => {
|
|
201
|
+
switch (random(11)) {
|
|
202
|
+
case 0:
|
|
203
|
+
return { $eq: pick(values) };
|
|
204
|
+
case 1:
|
|
205
|
+
return { $ne: pick(values) };
|
|
206
|
+
case 2:
|
|
207
|
+
return { [pick(["$gt", "$gte", "$lt", "$lte"])]: pick(scalars) };
|
|
208
|
+
case 3:
|
|
209
|
+
return { $in: [pick(values), pick(values)] };
|
|
210
|
+
case 4:
|
|
211
|
+
return { $nin: [pick(values)] };
|
|
212
|
+
case 5:
|
|
213
|
+
return { $exists: random(2) === 0 };
|
|
214
|
+
case 6:
|
|
215
|
+
return { $size: random(3) };
|
|
216
|
+
case 7:
|
|
217
|
+
return { $all: [pick(["x", "y", "z", "p"])] };
|
|
218
|
+
case 8:
|
|
219
|
+
return { $regex: pick(["^k", "1$", "^[a-c]", "Y"]), ...(random(2) === 0 ? { $options: "i" } : {}) };
|
|
220
|
+
case 9:
|
|
221
|
+
return { $elemMatch: random(2) === 0 ? { $gte: pick(["x", "y", 1]) } : { name: pick(["p", "q"]) } };
|
|
222
|
+
default:
|
|
223
|
+
return { $gt: pick(scalars), $lt: pick(scalars) };
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
const condition = (): unknown => {
|
|
227
|
+
const kind = random(5);
|
|
228
|
+
if (kind === 0) return pick(values);
|
|
229
|
+
if (kind === 1) return { $not: operators() };
|
|
230
|
+
return operators();
|
|
231
|
+
};
|
|
232
|
+
const selector = (depth: number): StorageSelector => {
|
|
233
|
+
const result: Record<string, unknown> = {};
|
|
234
|
+
for (let count = random(3); count > 0; count--) result[pick(fields)] = condition();
|
|
235
|
+
if (depth > 0 && random(3) === 0) {
|
|
236
|
+
result[pick(["$and", "$or", "$nor"])] = Array.from({ length: 1 + random(2) }, () => selector(depth - 1));
|
|
237
|
+
}
|
|
238
|
+
return result as StorageSelector;
|
|
239
|
+
};
|
|
240
|
+
const options = (): StorageQueryOptions => {
|
|
241
|
+
const result: { sort?: Record<string, 1 | -1>; skip?: number; limit?: number } = {};
|
|
242
|
+
if (random(2) === 0) {
|
|
243
|
+
const sort: Record<string, 1 | -1> = {};
|
|
244
|
+
for (let count = 1 + random(2); count > 0; count--) sort[pick(fields)] = random(2) === 0 ? 1 : -1;
|
|
245
|
+
result.sort = sort;
|
|
246
|
+
}
|
|
247
|
+
if (random(3) === 0) result.skip = random(4);
|
|
248
|
+
if (random(3) === 0) result.limit = 1 + random(5);
|
|
249
|
+
return result;
|
|
250
|
+
};
|
|
251
|
+
const document = (id: string): unknown => {
|
|
252
|
+
const result: Record<string, unknown> = { id };
|
|
253
|
+
if (random(4) !== 0) result["n"] = pick([0, 1, 2, 3, "5", null, true]);
|
|
254
|
+
if (random(3) !== 0) result["tags"] = Array.from({ length: random(3) }, () => pick(["x", "y", "z"]));
|
|
255
|
+
if (random(3) !== 0) result["meta"] = random(4) === 0 ? null : { kind: pick(["k1", "k2"]), ...(random(2) === 0 ? { score: random(20) } : {}) };
|
|
256
|
+
if (random(3) === 0) result["items"] = Array.from({ length: 1 + random(2) }, () => ({ name: pick(["p", "q"]), qty: random(6) }));
|
|
257
|
+
if (random(2) === 0) result["flag"] = random(2) === 0;
|
|
258
|
+
if (random(4) === 0) result["label"] = pick(["Delta", "kilo", "Y"]);
|
|
259
|
+
return result;
|
|
260
|
+
};
|
|
261
|
+
return { selector, options, document };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** The changes of a batch without the keys whose values are undefined, for a comparison. */
|
|
265
|
+
function plainBatch(batch: StorageChangeBatch<unknown>): unknown[] {
|
|
266
|
+
return batch.map((change) => Object.fromEntries(Object.entries(change).filter(([, value]) => value !== undefined)));
|
|
267
|
+
}
|
|
268
|
+
|
|
98
269
|
/** A random number generator with a seed, so a failed sequence comes again in the next run. */
|
|
99
270
|
function seeded(seed: number): (below: number) => number {
|
|
100
271
|
let state = seed >>> 0;
|
|
@@ -300,6 +471,22 @@ export function collectionStorageContract(options: CollectionStorageContractOpti
|
|
|
300
471
|
}))[0]!.n = 95;
|
|
301
472
|
if (storage.getEntries) (await storage.getEntries())[0]![1].n = 94;
|
|
302
473
|
if (storage.getChangesSince) (await storage.getChangesSince()).changed[0]![1].n = 93;
|
|
474
|
+
if (storage.query) (await storage.query({}))[0]![1].n = 92;
|
|
475
|
+
if (storage.patch) {
|
|
476
|
+
const fields = { tags: ["x"] };
|
|
477
|
+
const patched = (await storage.patch("a", fields))!;
|
|
478
|
+
fields.tags.push("changed after patch");
|
|
479
|
+
patched.n = 91;
|
|
480
|
+
patched.tags!.push("changed after patch");
|
|
481
|
+
}
|
|
482
|
+
if (storage.observe) {
|
|
483
|
+
const stop = storage.observe((batch) => {
|
|
484
|
+
for (const change of batch) if (change.type === "set") change.value.tags?.push("changed by an observer");
|
|
485
|
+
});
|
|
486
|
+
await storage.set("c", item("c", 3, ["z"]));
|
|
487
|
+
stop();
|
|
488
|
+
same(await storage.get("c"), item("c", 3, ["z"]), "get() after an observer changed the value of a change");
|
|
489
|
+
}
|
|
303
490
|
same(await storage.get("a"), item("a", 1, ["x"]), "get() after changes of the read values");
|
|
304
491
|
same(await storage.get("b"), item("b", 2, ["y"]), "get() of the other item after changes of the read values");
|
|
305
492
|
}),
|
|
@@ -546,6 +733,325 @@ export function collectionStorageContract(options: CollectionStorageContractOpti
|
|
|
546
733
|
}),
|
|
547
734
|
);
|
|
548
735
|
|
|
736
|
+
//
|
|
737
|
+
// ═══ query() ═══
|
|
738
|
+
//
|
|
739
|
+
|
|
740
|
+
add("query() gives the entries whose items match the selector, in the order of the entries", (context) =>
|
|
741
|
+
withStorage(create, async (typed) => {
|
|
742
|
+
if (lacks(typed, "query", context)) return;
|
|
743
|
+
const storage = typed as unknown as JsonStorage;
|
|
744
|
+
await storage.setBatch([...DOCUMENTS]);
|
|
745
|
+
for (const [selector, expected] of SELECTOR_CASES) {
|
|
746
|
+
const result = await storage.query!(selector);
|
|
747
|
+
same(entryIds(result), expected, `the ids of query(${show(selector)})`);
|
|
748
|
+
same(result, queryEntries(DOCUMENTS, selector), `the entries of query(${show(selector)})`);
|
|
749
|
+
}
|
|
750
|
+
}),
|
|
751
|
+
);
|
|
752
|
+
|
|
753
|
+
add("query() sorts, then skips, then limits, and items with equal keys keep the order of the entries", (context) =>
|
|
754
|
+
withStorage(create, async (typed) => {
|
|
755
|
+
if (lacks(typed, "query", context)) return;
|
|
756
|
+
const storage = typed as unknown as JsonStorage;
|
|
757
|
+
await storage.setBatch([...DOCUMENTS]);
|
|
758
|
+
for (const [options, expected] of OPTION_CASES) {
|
|
759
|
+
same(entryIds(await storage.query!({}, options)), expected, `the ids of query({}, ${show(options)})`);
|
|
760
|
+
}
|
|
761
|
+
same(entryIds(await storage.query!({ n: { $exists: true } }, { sort: { n: -1 }, limit: 2 })), ["e", "d"], "a selector with a sort and a limit");
|
|
762
|
+
}),
|
|
763
|
+
);
|
|
764
|
+
|
|
765
|
+
add("query() rejects a selector or options that are not correct", (context) =>
|
|
766
|
+
withStorage(create, async (typed) => {
|
|
767
|
+
if (lacks(typed, "query", context)) return;
|
|
768
|
+
const storage = typed as unknown as JsonStorage;
|
|
769
|
+
await storage.setBatch([...DOCUMENTS]);
|
|
770
|
+
const wrong: Array<[unknown, unknown]> = [
|
|
771
|
+
[{ n: { $where: "1" } }, undefined],
|
|
772
|
+
[{ $or: [] }, undefined],
|
|
773
|
+
[{ n: { $gt: 1, x: 2 } }, undefined],
|
|
774
|
+
[{ n: { $regex: "(" } }, undefined],
|
|
775
|
+
[{ n: { $gt: [1] } }, undefined],
|
|
776
|
+
[{ $text: "x" }, undefined],
|
|
777
|
+
[{}, { limit: 0 }],
|
|
778
|
+
[{}, { skip: -1 }],
|
|
779
|
+
[{}, { sort: { n: 2 } }],
|
|
780
|
+
[{}, { fields: { n: 1 } }],
|
|
781
|
+
];
|
|
782
|
+
for (const [selector, options] of wrong) {
|
|
783
|
+
await rejects(() => storage.query!(selector as StorageSelector, options as StorageQueryOptions), `query(${show(selector)}, ${show(options)})`);
|
|
784
|
+
}
|
|
785
|
+
}),
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
const createWithQueryPolicy = options.createWithQueryPolicy;
|
|
789
|
+
const randomPolicy: StorageQueryPolicy | undefined = createWithQueryPolicy ? REGEX_POLICY : undefined;
|
|
790
|
+
add("query() agrees with queryEntries() for random selectors, orders, skips and limits", (context) =>
|
|
791
|
+
withStorage(createWithQueryPolicy ? () => createWithQueryPolicy(REGEX_POLICY) : create, async (typed) => {
|
|
792
|
+
if (lacks(typed, "query", context)) return;
|
|
793
|
+
const storage = typed as unknown as JsonStorage;
|
|
794
|
+
const random = randomQueries(seeded(20));
|
|
795
|
+
const entries: Array<[string, unknown]> = [...DOCUMENTS];
|
|
796
|
+
for (let index = 0; index < 13; index++) entries.push([`r${index}`, random.document(`r${index}`)]);
|
|
797
|
+
await storage.setBatch(entries);
|
|
798
|
+
for (let round = 0; round < 60; round++) {
|
|
799
|
+
const selector = random.selector(2);
|
|
800
|
+
const options = random.options();
|
|
801
|
+
const label = `query(${show(selector)}, ${show(options)})`;
|
|
802
|
+
let expected: Array<[string, unknown]> | undefined;
|
|
803
|
+
try {
|
|
804
|
+
expected = queryEntries(entries, selector, options, randomPolicy);
|
|
805
|
+
} catch {
|
|
806
|
+
expected = undefined;
|
|
807
|
+
}
|
|
808
|
+
if (expected === undefined) await rejects(() => storage.query!(selector, options), label);
|
|
809
|
+
else same(await storage.query!(selector, options), expected, label);
|
|
810
|
+
}
|
|
811
|
+
}),
|
|
812
|
+
);
|
|
813
|
+
|
|
814
|
+
add("query() refuses $regex without a policy that permits it, and a pattern that repeats a repetition", (context) =>
|
|
815
|
+
withStorage(create, async (typed) => {
|
|
816
|
+
if (lacks(typed, "query", context)) return;
|
|
817
|
+
const storage = typed as unknown as JsonStorage;
|
|
818
|
+
await storage.setBatch([...DOCUMENTS]);
|
|
819
|
+
for (const [selector] of REGEX_CASES) await rejects(() => storage.query!(selector), `query(${show(selector)}) without a policy`);
|
|
820
|
+
await rejects(() => storage.query!(REDOS), `query(${show(REDOS)}) without a policy`);
|
|
821
|
+
}),
|
|
822
|
+
);
|
|
823
|
+
|
|
824
|
+
if (createWithQueryPolicy) {
|
|
825
|
+
add("query() runs $regex under a policy that permits it, and still refuses a pattern that repeats a repetition", (context) =>
|
|
826
|
+
withStorage(() => createWithQueryPolicy(REGEX_POLICY), async (typed) => {
|
|
827
|
+
if (lacks(typed, "query", context)) return;
|
|
828
|
+
const storage = typed as unknown as JsonStorage;
|
|
829
|
+
await storage.setBatch([...DOCUMENTS]);
|
|
830
|
+
for (const [selector, expected] of REGEX_CASES) {
|
|
831
|
+
const result = await storage.query!(selector);
|
|
832
|
+
same(entryIds(result), expected, `the ids of query(${show(selector)})`);
|
|
833
|
+
same(result, queryEntries(DOCUMENTS, selector, {}, REGEX_POLICY), `the entries of query(${show(selector)})`);
|
|
834
|
+
}
|
|
835
|
+
await rejects(() => storage.query!(REDOS), `query(${show(REDOS)}) under a policy with $regex`);
|
|
836
|
+
}),
|
|
837
|
+
);
|
|
838
|
+
|
|
839
|
+
add("query() refuses each part that the policy does not permit, and a query without a limit gets maxLimit", (context) =>
|
|
840
|
+
withStorage(
|
|
841
|
+
() =>
|
|
842
|
+
createWithQueryPolicy({
|
|
843
|
+
operators: ["$eq", "$in", "$all", "$and", "$or", "$not", "$regex", "$exists"],
|
|
844
|
+
maxRegexLength: 4,
|
|
845
|
+
regexFlags: "i",
|
|
846
|
+
maxClauses: 4,
|
|
847
|
+
maxDepth: 2,
|
|
848
|
+
maxListLength: 3,
|
|
849
|
+
maxLimit: 3,
|
|
850
|
+
}),
|
|
851
|
+
async (typed) => {
|
|
852
|
+
if (lacks(typed, "query", context)) return;
|
|
853
|
+
const storage = typed as unknown as JsonStorage;
|
|
854
|
+
await storage.setBatch([...DOCUMENTS]);
|
|
855
|
+
const refused: Array<[string, StorageSelector, StorageQueryOptions]> = [
|
|
856
|
+
["an operator that the policy does not permit", { n: { $gt: 1 } }, {}],
|
|
857
|
+
["a logical operator that the policy does not permit", { $nor: [{ n: 1 }] }, {}],
|
|
858
|
+
["a pattern longer than maxRegexLength", { label: { $regex: "^Delt" + "a" } }, {}],
|
|
859
|
+
["a flag that the policy does not permit", { label: { $regex: "^D", $options: "m" } }, {}],
|
|
860
|
+
["more clauses than maxClauses", { n: 1, id: "a", flag: true, label: "x", meta: null }, {}],
|
|
861
|
+
["a selector deeper than maxDepth", { $and: [{ $or: [{ n: { $not: { $eq: 1 } } }] }] }, {}],
|
|
862
|
+
["a list longer than maxListLength", { n: { $in: [1, 2, 3, 4] } }, {}],
|
|
863
|
+
["a list of $all longer than maxListLength", { tags: { $all: ["x", "y", "z", "w"] } }, {}],
|
|
864
|
+
["a limit above maxLimit", {}, { limit: 4 }],
|
|
865
|
+
];
|
|
866
|
+
for (const [what, selector, query] of refused) {
|
|
867
|
+
await rejects(() => storage.query!(selector, query), `query(${show(selector)}, ${show(query)}): ${what}`);
|
|
868
|
+
}
|
|
869
|
+
const permitted: Array<[StorageSelector, StorageQueryOptions, string[]]> = [
|
|
870
|
+
[{ label: { $regex: "^Del", $options: "i" } }, {}, ["d"]],
|
|
871
|
+
[{ n: { $in: [1, 2, 3] }, flag: { $exists: false } }, {}, ["a", "b", "c"]],
|
|
872
|
+
[{ $and: [{ $or: [{ n: 1 }, { n: 2 }] }] }, {}, ["a", "b"]],
|
|
873
|
+
[{ n: { $not: { $eq: 1 } } }, { limit: 3 }, ["b", "c", "d"]],
|
|
874
|
+
// A query without a limit gets maxLimit
|
|
875
|
+
[{}, {}, ["a", "b", "c"]],
|
|
876
|
+
[{}, { skip: 5 }, ["f", "g"]],
|
|
877
|
+
];
|
|
878
|
+
for (const [selector, query, expected] of permitted) {
|
|
879
|
+
same(entryIds(await storage.query!(selector, query)), expected, `the ids of query(${show(selector)}, ${show(query)})`);
|
|
880
|
+
}
|
|
881
|
+
},
|
|
882
|
+
),
|
|
883
|
+
);
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
//
|
|
887
|
+
// ═══ patch() ═══
|
|
888
|
+
//
|
|
889
|
+
|
|
890
|
+
add("patch() merges the fields into the item, gives the new item, and leaves a missing id missing", (context) =>
|
|
891
|
+
withStorage(create, async (typed) => {
|
|
892
|
+
if (lacks(typed, "patch", context)) return;
|
|
893
|
+
const storage = typed as unknown as JsonStorage;
|
|
894
|
+
await storage.set("a", { id: "a", n: 1, tags: ["x"], meta: { kind: "k1", score: 10 } });
|
|
895
|
+
same(await storage.patch!("a", { n: 2 }), { id: "a", n: 2, tags: ["x"], meta: { kind: "k1", score: 10 } }, "patch() of one field");
|
|
896
|
+
same(await storage.patch!("a", { meta: { score: 11, extra: true }, tags: null }), { id: "a", n: 2, meta: { kind: "k1", score: 11, extra: true } }, "patch() of an object field and a null field");
|
|
897
|
+
same(await storage.patch!("a", { tags: ["y", "z"], meta: { extra: null } }), { id: "a", n: 2, meta: { kind: "k1", score: 11 }, tags: ["y", "z"] }, "patch() of an array field and a nested null");
|
|
898
|
+
same(await storage.get("a"), { id: "a", n: 2, meta: { kind: "k1", score: 11 }, tags: ["y", "z"] }, "get() after the patches");
|
|
899
|
+
same(await storage.patch!("missing", { n: 1 }), undefined, "patch() of a missing id");
|
|
900
|
+
same(await storage.has("missing"), false, "has() of a missing id after patch()");
|
|
901
|
+
same(await storage.size(), 1, "size() after patch() of a missing id");
|
|
902
|
+
await storage.set("plain", 5);
|
|
903
|
+
same(await storage.patch!("plain", { n: 1 }), { n: 1 }, "patch() of an item that is not an object");
|
|
904
|
+
for (const fields of [null, [1], "x", 3]) {
|
|
905
|
+
await rejects(() => storage.patch!("a", fields as never), `patch() with the fields ${show(fields)}`);
|
|
906
|
+
}
|
|
907
|
+
same(await storage.get("a"), { id: "a", n: 2, meta: { kind: "k1", score: 11 }, tags: ["y", "z"] }, "get() after the patches that were not correct");
|
|
908
|
+
}),
|
|
909
|
+
);
|
|
910
|
+
|
|
911
|
+
add("patch() with the operations $set and $unset sets null and nested paths, and a patch of two forms rejects", (context) =>
|
|
912
|
+
withStorage(create, async (typed) => {
|
|
913
|
+
if (lacks(typed, "patch", context)) return;
|
|
914
|
+
const storage = typed as unknown as JsonStorage;
|
|
915
|
+
await storage.set("a", { id: "a", n: 1, tags: ["x", "y"], meta: { kind: "k1", score: 10 } });
|
|
916
|
+
same(
|
|
917
|
+
await storage.patch!("a", { $set: { "meta.kind": "k2", note: null, "tags.1": "z", "deep.new.path": 1 }, $unset: ["meta.score"] }),
|
|
918
|
+
{ id: "a", n: 1, tags: ["x", "z"], meta: { kind: "k2" }, note: null, deep: { new: { path: 1 } } },
|
|
919
|
+
"patch() with $set and $unset",
|
|
920
|
+
);
|
|
921
|
+
same(
|
|
922
|
+
await storage.patch!("a", { $unset: ["tags.0", "missing.path", "deep"], $set: { "tags.2": "w" } }),
|
|
923
|
+
{ id: "a", n: 1, tags: [null, "z", "w"], meta: { kind: "k2" }, note: null },
|
|
924
|
+
"patch() that removes an element of an array, a missing path and a field, and adds an element",
|
|
925
|
+
);
|
|
926
|
+
const after = { id: "a", n: 1, tags: [null, "z", "w"], meta: { kind: "k2" }, note: null };
|
|
927
|
+
const wrong: Array<[string, unknown]> = [
|
|
928
|
+
["a patch of two forms", { $set: { n: 2 }, n: 3 }],
|
|
929
|
+
["an operation that is not known", { $inc: { n: 1 } }],
|
|
930
|
+
["paths that overlap", { $set: { meta: {}, "meta.kind": "k3" } }],
|
|
931
|
+
["a $set that is not an object", { $set: [1] }],
|
|
932
|
+
["an $unset that is not a list of paths", { $unset: "n" }],
|
|
933
|
+
["a path through a value that is not an object", { $set: { "n.x": 1 } }],
|
|
934
|
+
["a path past the end of an array", { $set: { "tags.7": "v" } }],
|
|
935
|
+
];
|
|
936
|
+
for (const [what, fields] of wrong) await rejects(() => storage.patch!("a", fields as never), `patch() with ${what}`);
|
|
937
|
+
same(await storage.get("a"), after, "get() after the patches that were not correct");
|
|
938
|
+
same(await storage.patch!("missing", { $set: { n: 1 } }), undefined, "patch() with $set of a missing id");
|
|
939
|
+
same(await storage.has("missing"), false, "has() of a missing id after patch() with $set");
|
|
940
|
+
}),
|
|
941
|
+
);
|
|
942
|
+
|
|
943
|
+
add("patch() runs in one step: patches of one item at the same time keep each field", (context) =>
|
|
944
|
+
withStorage(create, async (typed) => {
|
|
945
|
+
if (lacks(typed, "patch", context)) return;
|
|
946
|
+
const storage = typed as unknown as JsonStorage;
|
|
947
|
+
await storage.set("a", { id: "a" });
|
|
948
|
+
const count = 12;
|
|
949
|
+
await Promise.all(Array.from({ length: count }, (_, n) => storage.patch!("a", { [`f${n}`]: n })));
|
|
950
|
+
const expected: Record<string, unknown> = { id: "a" };
|
|
951
|
+
for (let n = 0; n < count; n++) expected[`f${n}`] = n;
|
|
952
|
+
same(await storage.get("a"), expected, `get() after ${count} patches at the same time`);
|
|
953
|
+
}),
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
add("patch() is a change: getChangesSince() gives the patched item", (context) =>
|
|
957
|
+
withStorage(create, async (storage) => {
|
|
958
|
+
if (lacks(storage, "patch", context) || lacks(storage, "getChangesSince", context)) return;
|
|
959
|
+
await storage.setBatch([["a", item("a", 1)], ["b", item("b", 2)]]);
|
|
960
|
+
const start = await storage.getChangesSince!();
|
|
961
|
+
await storage.patch!("a", { n: 10 });
|
|
962
|
+
await storage.patch!("missing", { n: 3 });
|
|
963
|
+
const delta = await storage.getChangesSince!(start.version);
|
|
964
|
+
same({ full: delta.full, changed: delta.changed, deleted: delta.deleted }, { full: false, changed: [["a", item("a", 10)]], deleted: [] }, "the changes after a patch");
|
|
965
|
+
}),
|
|
966
|
+
);
|
|
967
|
+
|
|
968
|
+
//
|
|
969
|
+
// ═══ observe() ═══
|
|
970
|
+
//
|
|
971
|
+
|
|
972
|
+
add("observe() gives one batch for each write that changes the storage, before the write resolves", (context) =>
|
|
973
|
+
withStorage(create, async (storage) => {
|
|
974
|
+
if (lacks(storage, "observe", context)) return;
|
|
975
|
+
const batches: unknown[][] = [];
|
|
976
|
+
const stop = storage.observe!((batch) => batches.push(plainBatch(batch)));
|
|
977
|
+
await storage.set("a", item("a", 1));
|
|
978
|
+
same(batches.length, 1, "the batches when set() resolves");
|
|
979
|
+
await storage.setBatch([["b", item("b", 2)], ["c", item("c", 3)]]);
|
|
980
|
+
await storage.delete("a");
|
|
981
|
+
await storage.deleteBatch(["b", "missing"]);
|
|
982
|
+
if (storage.patch) await storage.patch("c", { n: 30 });
|
|
983
|
+
await storage.clear();
|
|
984
|
+
stop();
|
|
985
|
+
same(
|
|
986
|
+
batches,
|
|
987
|
+
[
|
|
988
|
+
[{ type: "set", id: "a", value: item("a", 1) }],
|
|
989
|
+
[
|
|
990
|
+
{ type: "set", id: "b", value: item("b", 2) },
|
|
991
|
+
{ type: "set", id: "c", value: item("c", 3) },
|
|
992
|
+
],
|
|
993
|
+
[{ type: "delete", id: "a" }],
|
|
994
|
+
[{ type: "delete", id: "b" }],
|
|
995
|
+
...(storage.patch ? [[{ type: "set", id: "c", value: item("c", 30) }]] : []),
|
|
996
|
+
[{ type: "clear" }],
|
|
997
|
+
],
|
|
998
|
+
"the batches of the writes",
|
|
999
|
+
);
|
|
1000
|
+
}),
|
|
1001
|
+
);
|
|
1002
|
+
|
|
1003
|
+
add("observe(): a write that changes nothing gives no batch, and each change has the origin of its write", (context) =>
|
|
1004
|
+
withStorage(create, async (storage) => {
|
|
1005
|
+
if (lacks(storage, "observe", context)) return;
|
|
1006
|
+
const batches: unknown[][] = [];
|
|
1007
|
+
const stop = storage.observe!((batch) => batches.push(plainBatch(batch)));
|
|
1008
|
+
await storage.delete("missing");
|
|
1009
|
+
await storage.deleteBatch(["missing"]);
|
|
1010
|
+
await storage.deleteBatch([]);
|
|
1011
|
+
await storage.setBatch([]);
|
|
1012
|
+
await storage.clear();
|
|
1013
|
+
if (storage.patch) await storage.patch("missing", { n: 1 });
|
|
1014
|
+
same(batches, [], "the batches of the writes that change nothing");
|
|
1015
|
+
await storage.set("a", item("a", 1), { origin: "sync" });
|
|
1016
|
+
await storage.setBatch([["b", item("b", 2)]], { origin: "app" });
|
|
1017
|
+
await storage.delete("a", { origin: "sync" });
|
|
1018
|
+
await storage.deleteBatch(["b"], { origin: "app" });
|
|
1019
|
+
await storage.set("c", item("c", 3), {});
|
|
1020
|
+
await storage.clear({ origin: "reset" });
|
|
1021
|
+
stop();
|
|
1022
|
+
same(
|
|
1023
|
+
batches,
|
|
1024
|
+
[
|
|
1025
|
+
[{ type: "set", id: "a", value: item("a", 1), origin: "sync" }],
|
|
1026
|
+
[{ type: "set", id: "b", value: item("b", 2), origin: "app" }],
|
|
1027
|
+
[{ type: "delete", id: "a", origin: "sync" }],
|
|
1028
|
+
[{ type: "delete", id: "b", origin: "app" }],
|
|
1029
|
+
[{ type: "set", id: "c", value: item("c", 3) }],
|
|
1030
|
+
[{ type: "clear", origin: "reset" }],
|
|
1031
|
+
],
|
|
1032
|
+
"the batches with their origins",
|
|
1033
|
+
);
|
|
1034
|
+
}),
|
|
1035
|
+
);
|
|
1036
|
+
|
|
1037
|
+
add("observe(): each observer gets each batch, and the function that observe() gives stops the batches", (context) =>
|
|
1038
|
+
withStorage(create, async (storage) => {
|
|
1039
|
+
if (lacks(storage, "observe", context)) return;
|
|
1040
|
+
const first: string[] = [];
|
|
1041
|
+
const second: string[] = [];
|
|
1042
|
+
const stopFirst = storage.observe!((batch) => first.push(...batch.map((change) => change.type)));
|
|
1043
|
+
const stopSecond = storage.observe!((batch) => second.push(...batch.map((change) => change.type)));
|
|
1044
|
+
await storage.set("a", item("a", 1));
|
|
1045
|
+
stopFirst();
|
|
1046
|
+
stopFirst();
|
|
1047
|
+
await storage.delete("a");
|
|
1048
|
+
stopSecond();
|
|
1049
|
+
await storage.set("b", item("b", 2));
|
|
1050
|
+
same(first, ["set"], "the changes of the first observer");
|
|
1051
|
+
same(second, ["set", "delete"], "the changes of the second observer");
|
|
1052
|
+
}),
|
|
1053
|
+
);
|
|
1054
|
+
|
|
549
1055
|
add("flush() resolves, and the items stay", (context) =>
|
|
550
1056
|
withStorage(create, async (storage) => {
|
|
551
1057
|
if (lacks(storage, "flush", context)) return;
|
|
@@ -591,6 +1097,8 @@ export function collectionStorageContract(options: CollectionStorageContractOpti
|
|
|
591
1097
|
if (storage.getChangesSince) operations.push(["getChangesSince", () => storage.getChangesSince!()]);
|
|
592
1098
|
if (storage.flush) operations.push(["flush", () => storage.flush!()]);
|
|
593
1099
|
if (storage.getMetadata) operations.push(["getMetadata", () => storage.getMetadata!()]);
|
|
1100
|
+
if (storage.query) operations.push(["query", () => storage.query!({})]);
|
|
1101
|
+
if (storage.patch) operations.push(["patch", () => storage.patch!("a", { n: 2 })]);
|
|
594
1102
|
const check = async (when: string): Promise<void> => {
|
|
595
1103
|
for (const [name, start] of operations) {
|
|
596
1104
|
const error = await rejects(start, `${name}() ${when}`);
|
|
@@ -600,10 +1108,22 @@ export function collectionStorageContract(options: CollectionStorageContractOpti
|
|
|
600
1108
|
);
|
|
601
1109
|
}
|
|
602
1110
|
};
|
|
1111
|
+
const checkObserve = (when: string): void => {
|
|
1112
|
+
if (!storage.observe) return;
|
|
1113
|
+
let error: unknown;
|
|
1114
|
+
try {
|
|
1115
|
+
storage.observe(() => undefined);
|
|
1116
|
+
} catch (thrown) {
|
|
1117
|
+
error = thrown;
|
|
1118
|
+
}
|
|
1119
|
+
holds(error instanceof Error && /closed/i.test(error.message), `observe() ${when} must throw an Error whose message says "closed", not ${show(error instanceof Error ? error.message : error)}`);
|
|
1120
|
+
};
|
|
603
1121
|
await check("after close()");
|
|
1122
|
+
checkObserve("after close()");
|
|
604
1123
|
// A second close() resolves, and the storage stays closed
|
|
605
1124
|
await storage.close();
|
|
606
1125
|
await check("after a second close()");
|
|
1126
|
+
checkObserve("after a second close()");
|
|
607
1127
|
}),
|
|
608
1128
|
);
|
|
609
1129
|
|