@procedures/collections 0.14.1 → 0.15.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.
@@ -25,10 +25,23 @@
25
25
  * With `createWithQueryPolicy`, the suite also tests the opt-in of `$regex` and each limit of a
26
26
  * policy. `observe()` gives one batch for each write that changes the storage. `patch()` changes
27
27
  * an item in one step, in each of its two forms. A storage without them skips their cases.
28
+ *
29
+ * The preconditions of the writes (roadmap CLI-16): a storage of `create()` cannot test an
30
+ * expected version, so it rejects such a write and changes nothing. With `createWithVersions`, the
31
+ * suite tests `expectedVersion` of `set` and `delete`, `expectedVersions` of `setBatch`, the
32
+ * conflicts and their current items, and two writes at the same time.
28
33
  */
29
34
 
30
35
  import type { CollectionStorage, StorageChangeBatch, StorageChanges } from "../storage/interface.js";
31
- import { QUERY_OPERATORS, queryEntries, type StorageQueryOptions, type StorageQueryPolicy, type StorageSelector } from "@procedures/client/query";
36
+ import {
37
+ QUERY_OPERATORS,
38
+ queryEntries,
39
+ versionConflictsOf,
40
+ type StorageQueryOptions,
41
+ type StorageQueryPolicy,
42
+ type StorageSelector,
43
+ type VersionConflict,
44
+ } from "@procedures/client/query";
32
45
  import { holds, rejects, same, show } from "./check.js";
33
46
 
34
47
  /** The item of the suite. Its `id` is its key, so a `HybridStorage` can sync it. */
@@ -41,6 +54,14 @@ export interface ContractItem {
41
54
  /** A storage of the suite. */
42
55
  export type ContractStorage = CollectionStorage<ContractItem>;
43
56
 
57
+ /**
58
+ * The version of an item of the suite: `v` and the field `n`. A storage of `createWithVersions`
59
+ * gives it as its `versionOf` (roadmap CLI-16).
60
+ */
61
+ export function contractVersionOf(item: ContractItem): string | undefined {
62
+ return typeof item?.n === "number" ? `v${item.n}` : undefined;
63
+ }
64
+
44
65
  /** The options of the suite: the factories of the storage. */
45
66
  export interface CollectionStorageContractOptions {
46
67
  /**
@@ -59,6 +80,12 @@ export interface CollectionStorageContractOptions {
59
80
  * and each limit of a policy.
60
81
  */
61
82
  createWithQueryPolicy?: ((policy: StorageQueryPolicy) => ContractStorage | Promise<ContractStorage>) | undefined;
83
+ /**
84
+ * This function makes a new, empty storage that gives each item the version of `versionOf` (the
85
+ * storage of the server for a remote storage). With it, the suite tests the preconditions of
86
+ * the writes (roadmap CLI-16). `versionOf` is `contractVersionOf`.
87
+ */
88
+ createWithVersions?: ((versionOf: (item: ContractItem) => string | undefined) => ContractStorage | Promise<ContractStorage>) | undefined;
62
89
  }
63
90
 
64
91
  /** The context that a test framework gives to a case. Vitest gives `skip`. */
@@ -1156,9 +1183,125 @@ export function collectionStorageContract(options: CollectionStorageContractOpti
1156
1183
  }),
1157
1184
  );
1158
1185
 
1186
+ addPreconditionCases(add, create, options.createWithVersions);
1187
+
1159
1188
  return cases;
1160
1189
  }
1161
1190
 
1191
+ /**
1192
+ * This function gives the conflicts of a rejected write with a precondition. It checks the code
1193
+ * `VERSION_CONFLICT` first.
1194
+ */
1195
+ function conflictsOfRejection(error: unknown, what: string): readonly VersionConflict[] {
1196
+ const code = (error as { code?: unknown } | null)?.code;
1197
+ holds(code === "VERSION_CONFLICT", `${what}: the error must have the code VERSION_CONFLICT, not ${show(code)} (${show(error instanceof Error ? error.message : error)})`);
1198
+ const conflicts = versionConflictsOf(error);
1199
+ holds(conflicts !== undefined, `${what}: the error must have its conflicts`);
1200
+ return conflicts!;
1201
+ }
1202
+
1203
+ /** The cases of the preconditions of the writes (roadmap CLI-16). */
1204
+ function addPreconditionCases(
1205
+ add: (name: string, run: (context?: ContractTestContext) => Promise<void>) => void,
1206
+ create: () => ContractStorage | Promise<ContractStorage>,
1207
+ createWithVersions: CollectionStorageContractOptions["createWithVersions"],
1208
+ ): void {
1209
+ add("a storage that cannot test an expected version rejects the write, and changes nothing", () =>
1210
+ withStorage(create, async (storage) => {
1211
+ await storage.set("a", item("a", 1));
1212
+ await rejects(() => storage.set("a", item("a", 2), { expectedVersion: "v1" }), "set() with expectedVersion on a storage without versions");
1213
+ await rejects(() => storage.delete("a", { expectedVersion: "v1" }), "delete() with expectedVersion on a storage without versions");
1214
+ await rejects(() => storage.setBatch([["a", item("a", 3)]], { expectedVersions: [["a", "v1"]] }), "setBatch() with expectedVersions on a storage without versions");
1215
+ same(await storage.get("a"), item("a", 1), "the item after the refused writes");
1216
+ // A write without a precondition is the write of before
1217
+ await storage.set("a", item("a", 4));
1218
+ same(await storage.get("a"), item("a", 4), "a write without expectedVersion");
1219
+ }),
1220
+ );
1221
+
1222
+ if (!createWithVersions) return;
1223
+ const versioned = (): ContractStorage | Promise<ContractStorage> => createWithVersions(contractVersionOf);
1224
+
1225
+ add("a write with the expected version of the current item writes, and a write without one writes as before", () =>
1226
+ withStorage(versioned, async (storage) => {
1227
+ await storage.set("a", item("a", 1));
1228
+ await storage.set("a", item("a", 2), { expectedVersion: "v1" });
1229
+ same(await storage.get("a"), item("a", 2), "the item after set() with the expected version");
1230
+ await storage.set("a", item("a", 3));
1231
+ same(await storage.get("a"), item("a", 3), "the item after set() without expectedVersion");
1232
+ same(await storage.delete("a", { expectedVersion: "v3" }), true, "delete() with the expected version");
1233
+ same(await storage.get("a"), undefined, "the item after delete() with the expected version");
1234
+ }),
1235
+ );
1236
+
1237
+ add("a write with another expected version changes nothing, and rejects with VERSION_CONFLICT and the current item", () =>
1238
+ withStorage(versioned, async (storage) => {
1239
+ await storage.set("a", item("a", 1));
1240
+ let batches = 0;
1241
+ const stop = storage.observe?.(() => {
1242
+ batches++;
1243
+ });
1244
+ const before = storage.getChangesSince ? (await storage.getChangesSince()).version : undefined;
1245
+ const expected: VersionConflict = { id: "a", expected: "v2", found: true, value: item("a", 1), version: "v1" };
1246
+ const setError = await rejects(() => storage.set("a", item("a", 3), { expectedVersion: "v2" }), "set() with a stale version");
1247
+ same(conflictsOfRejection(setError, "set() with a stale version"), [expected], "the conflicts of set() with a stale version");
1248
+ const deleteError = await rejects(() => storage.delete("a", { expectedVersion: "v2" }), "delete() with a stale version");
1249
+ same(conflictsOfRejection(deleteError, "delete() with a stale version"), [expected], "the conflicts of delete() with a stale version");
1250
+ same(await storage.get("a"), item("a", 1), "the item after the refused writes");
1251
+ stop?.();
1252
+ same(batches, 0, "the batches of observe() for the refused writes");
1253
+ if (storage.getChangesSince && before !== undefined) {
1254
+ const after = await storage.getChangesSince(before);
1255
+ same({ changed: after.changed, deleted: after.deleted }, { changed: [], deleted: [] }, "the changes after the refused writes");
1256
+ }
1257
+ }),
1258
+ );
1259
+
1260
+ add("the expected version null expects no item", () =>
1261
+ withStorage(versioned, async (storage) => {
1262
+ await storage.set("b", item("b", 1), { expectedVersion: null });
1263
+ same(await storage.get("b"), item("b", 1), "set() with expectedVersion null of a missing id");
1264
+ const exists = await rejects(() => storage.set("b", item("b", 2), { expectedVersion: null }), "set() with expectedVersion null of an existing id");
1265
+ same(conflictsOfRejection(exists, "set() with expectedVersion null of an existing id"), [{ id: "b", expected: null, found: true, value: item("b", 1), version: "v1" }], "the conflict of an existing id");
1266
+ const missing = await rejects(() => storage.set("c", item("c", 1), { expectedVersion: "v1" }), "set() with a version of a missing id");
1267
+ same(conflictsOfRejection(missing, "set() with a version of a missing id"), [{ id: "c", expected: "v1", found: false, version: null }], "the conflict of a missing id");
1268
+ same(await storage.has("c"), false, "the missing id after the refused set()");
1269
+ same(await storage.delete("x", { expectedVersion: null }), false, "delete() with expectedVersion null of a missing id");
1270
+ }),
1271
+ );
1272
+
1273
+ add("setBatch() tests the expected version of each id, and a conflict changes no item", () =>
1274
+ withStorage(versioned, async (storage) => {
1275
+ await storage.setBatch([["a", item("a", 1)], ["b", item("b", 1)]]);
1276
+ const error = await rejects(
1277
+ () => storage.setBatch([["a", item("a", 2)], ["b", item("b", 2)], ["c", item("c", 1)]], { expectedVersions: [["a", "v1"], ["b", "v9"]] }),
1278
+ "setBatch() with one stale version",
1279
+ );
1280
+ same(conflictsOfRejection(error, "setBatch() with one stale version"), [{ id: "b", expected: "v9", found: true, value: item("b", 1), version: "v1" }], "the conflicts of setBatch()");
1281
+ same(await storage.getBatch(["a", "b", "c"]), new Map([["a", item("a", 1)], ["b", item("b", 1)]]), "the items after the refused batch");
1282
+ // An id without an expected version has no precondition
1283
+ await storage.setBatch([["a", item("a", 2)], ["b", item("b", 2)], ["c", item("c", 1)]], { expectedVersions: [["a", "v1"], ["b", "v1"]] });
1284
+ same(await storage.getBatch(["a", "b", "c"]), new Map([["a", item("a", 2)], ["b", item("b", 2)], ["c", item("c", 1)]]), "the items after the batch");
1285
+ }),
1286
+ );
1287
+
1288
+ add("two writes with the same expected version at the same time: one writes, and the other gets VERSION_CONFLICT", () =>
1289
+ withStorage(versioned, async (storage) => {
1290
+ await storage.set("a", item("a", 1));
1291
+ const results = await Promise.allSettled([
1292
+ storage.set("a", item("a", 2), { expectedVersion: "v1" }),
1293
+ storage.set("a", item("a", 3), { expectedVersion: "v1" }),
1294
+ ]);
1295
+ const won = results.flatMap((result, index) => (result.status === "fulfilled" ? [index] : []));
1296
+ same(won.length, 1, "the number of writes that won");
1297
+ const lost = results.find((result): result is PromiseRejectedResult => result.status === "rejected")!;
1298
+ const conflicts = conflictsOfRejection(lost.reason, "the write that lost");
1299
+ same(conflicts.map((conflict) => conflict.id), ["a"], "the conflict of the write that lost");
1300
+ same(await storage.get("a"), item("a", won[0] === 0 ? 2 : 3), "the item of the write that won");
1301
+ }),
1302
+ );
1303
+ }
1304
+
1162
1305
  /**
1163
1306
  * This function declares the suite in a test framework: one `describe` with one `it` for each
1164
1307
  * case. Vitest gives `skip` to a case, so a case for a missing optional method shows as skipped.