@procedures/collections 0.8.0 → 0.8.1

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.
@@ -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: "getEntries" | "getIds" | "getChangesSince" | "flush" | "getMetadata", context: ContractTestContext | undefined): boolean {
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