@procedures/mongo 0.14.1 → 0.15.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.
@@ -38,21 +38,35 @@
38
38
  *
39
39
  * The aggregations: `aggregate()` runs a read-only pipeline on the items of the partition, with
40
40
  * the guard of `scopePipeline()` and the limits of time and size.
41
+ *
42
+ * The preconditions (roadmap CLI-16): with the option `versionOf`, `set`, `delete` and `setBatch`
43
+ * take an expected version. On a replica set, the test reads the items in the transaction of the
44
+ * write, so the test and the write are one step. On a single server, each write of an item with a
45
+ * precondition has the `_seq` that the test read in its filter (a compare-and-set). Another writer
46
+ * between the test and the write makes the write fail with `VersionConflictError`. Then a batch of
47
+ * more than one item can keep a part of its writes: only a transaction makes a batch all or nothing.
41
48
  */
42
49
 
43
50
  import { randomUUID } from "node:crypto";
44
51
  import type { AnyBulkWriteOperation, ClientSession, Collection, Db, Document, Filter, MongoClient } from "mongodb";
45
52
  import {
53
+ VersionConflictError,
46
54
  applyPatch,
55
+ preconditionNotSupported,
47
56
  queryEntries,
57
+ versionConflict,
48
58
  type CollectionStorage,
59
+ type CurrentItem,
60
+ type StorageBatchWriteOptions,
49
61
  type StorageChanges,
62
+ type StorageItemWriteOptions,
50
63
  type StorageMetadata,
51
64
  type StoragePatch,
52
65
  type StorageQueryOptions,
53
66
  type StorageQueryPolicy,
54
67
  type StorageSelector,
55
68
  type StorageWriteOptions,
69
+ type VersionConflict,
56
70
  } from "@procedures/collections";
57
71
  import { optionalMs, toMs, type Duration } from "@procedures/client";
58
72
  import { ensureConnection } from "../connection.js";
@@ -73,7 +87,7 @@ const STORAGE_FIELDS: ReadonlySet<string> = new Set(["_id", KEY, PART, SEQ, ORD,
73
87
  export const SEPARATOR = "\u001f";
74
88
 
75
89
  /** The options of a `MongoDocumentStorage`. */
76
- export interface MongoDocumentStorageOptions {
90
+ export interface MongoDocumentStorageOptions<T = unknown> {
77
91
  /** The MongoDB collection. */
78
92
  collection: string;
79
93
  /** The database. The default is the database of the default connection, for each operation. */
@@ -101,6 +115,21 @@ export interface MongoDocumentStorageOptions {
101
115
  * the watermark on a single server. `never`: each write uses the watermark.
102
116
  */
103
117
  transactions?: "auto" | "never" | undefined;
118
+ /**
119
+ * The version of an item, for example `(item) => item.rev` (roadmap CLI-16). With it, `set`,
120
+ * `delete` and `setBatch` take an expected version: a write whose current item has another
121
+ * version changes nothing and rejects with `VersionConflictError`. Without it, a write with an
122
+ * expected version rejects with a `TypeError`. `versionOf(id)` of the storage is another thing:
123
+ * the sequence number of the last write.
124
+ */
125
+ versionOf?: ((item: T) => string | undefined) | undefined;
126
+ }
127
+
128
+ /** True for the error of a duplicate key: an insert of an upsert whose filter found no document. */
129
+ function isDuplicateKey(error: unknown): boolean {
130
+ // A bulk write error has the code of its first write error, and the list of the write errors
131
+ const { code, writeErrors } = (error ?? {}) as { code?: unknown; writeErrors?: unknown };
132
+ return code === 11000 || [writeErrors].flat().some((entry) => (entry as { code?: unknown } | null)?.code === 11000);
104
133
  }
105
134
 
106
135
  /** A stored document. */
@@ -257,7 +286,7 @@ interface Counter {
257
286
  * ```
258
287
  */
259
288
  export class MongoDocumentStorage<T> implements CollectionStorage<T> {
260
- private readonly options: MongoDocumentStorageOptions;
289
+ private readonly options: MongoDocumentStorageOptions<T>;
261
290
  private readonly collectionName: string;
262
291
  private readonly partition: string | undefined;
263
292
  private readonly maxTombstones: number;
@@ -268,7 +297,7 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
268
297
  private indexes: Promise<void> | undefined;
269
298
  private closed = false;
270
299
 
271
- constructor(options: MongoDocumentStorageOptions, partitions?: Map<string, MongoDocumentStorage<T>>) {
300
+ constructor(options: MongoDocumentStorageOptions<T>, partitions?: Map<string, MongoDocumentStorage<T>>) {
272
301
  if (options.partition !== undefined && (options.partition === "" || options.partition.includes(SEPARATOR))) {
273
302
  throw new Error("A partition must be a text without the character U+001F");
274
303
  }
@@ -673,11 +702,16 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
673
702
  return enqueue(db, this.collectionName, () => task(db, items));
674
703
  }
675
704
 
676
- async set(id: string, value: T, _options?: StorageWriteOptions): Promise<void> {
677
- await this.setBatch([[id, value]]);
705
+ async set(id: string, value: T, options?: StorageItemWriteOptions): Promise<void> {
706
+ await this.setBatch([[id, value]], options?.expectedVersion === undefined ? undefined : { expectedVersions: [[id, options.expectedVersion]] });
678
707
  }
679
708
 
680
- async setBatch(items: Array<[string, T]>, _options?: StorageWriteOptions): Promise<void> {
709
+ /**
710
+ * This method writes several items. With `expectedVersions`, a conflict of one id rejects with
711
+ * `VersionConflictError` (roadmap CLI-16): the class comment tells when a batch is all or nothing.
712
+ */
713
+ async setBatch(items: Array<[string, T]>, options?: StorageBatchWriteOptions): Promise<void> {
714
+ const expected = this.preconditions(options?.expectedVersions ?? []);
681
715
  if (items.length === 0) {
682
716
  this.checkOpen();
683
717
  return;
@@ -690,14 +724,23 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
690
724
  await this.write(async (db, collection) => {
691
725
  const places = await this.placesOf(collection, items.map(([id]) => id));
692
726
  await this.withNumbers(db, collection, items.length, async (first, session) => {
727
+ const guards = await this.checkVersions(collection, expected, session);
693
728
  const placed = new Map(places);
694
729
  const operations: AnyBulkWriteOperation<Stored>[] = items.map(([id, value], index) => {
695
730
  const seq = first + index;
696
731
  // A new id takes its place at its first write; a later write of the batch keeps it
697
732
  if (!placed.has(id)) placed.set(id, seq);
698
- return { replaceOne: { filter: this.writeFilter(id), replacement: this.encode(id, value, seq, placed.get(id)!), upsert: true } };
733
+ // The guard of an id with a precondition: the write fails when another writer came first
734
+ const guard = guards.get(id);
735
+ guards.delete(id);
736
+ return { replaceOne: { filter: { ...this.writeFilter(id), ...guard }, replacement: this.encode(id, value, seq, placed.get(id)!), upsert: true } };
699
737
  });
700
- await collection.bulkWrite(operations, { ordered: true, ...(session ? { session } : {}) });
738
+ try {
739
+ await collection.bulkWrite(operations, { ordered: true, ...(session ? { session } : {}) });
740
+ } catch (error) {
741
+ if (expected.length > 0 && !session && isDuplicateKey(error)) throw await this.lateConflict(collection, expected);
742
+ throw error;
743
+ }
701
744
  });
702
745
  });
703
746
  }
@@ -714,8 +757,23 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
714
757
  });
715
758
  }
716
759
 
717
- async delete(id: string, options?: StorageWriteOptions): Promise<boolean> {
718
- return (await this.deleteBatch([id], options)) > 0;
760
+ /**
761
+ * This method deletes an item. With `expectedVersion`, a conflict rejects with
762
+ * `VersionConflictError` and deletes nothing (roadmap CLI-16).
763
+ */
764
+ async delete(id: string, options?: StorageItemWriteOptions): Promise<boolean> {
765
+ if (options?.expectedVersion === undefined) return (await this.deleteBatch([id], options)) > 0;
766
+ const expected = this.preconditions([[id, options.expectedVersion]]);
767
+ return this.write(async (db, collection) => {
768
+ const live = await collection.findOne(this.liveFilter({ _id: this.storedId(id) }), { projection: { _id: 1 } });
769
+ if (!live) {
770
+ // No item to delete: the precondition holds only for the expected version null
771
+ await this.checkVersions(collection, expected);
772
+ return false;
773
+ }
774
+ await this.tombstone(db, collection, [id], expected);
775
+ return true;
776
+ });
719
777
  }
720
778
 
721
779
  async deleteBatch(ids: string[], _options?: StorageWriteOptions): Promise<number> {
@@ -748,19 +806,91 @@ export class MongoDocumentStorage<T> implements CollectionStorage<T> {
748
806
  });
749
807
  }
750
808
 
751
- /** This method replaces the items of the ids with tombstones, with new numbers, in the queue of the collection. */
752
- private async tombstone(db: Db, collection: Collection<Stored>, ids: string[]): Promise<void> {
809
+ /**
810
+ * This method replaces the items of the ids with tombstones, with new numbers, in the queue of the
811
+ * collection. With preconditions, it tests them first (roadmap CLI-16).
812
+ */
813
+ private async tombstone(db: Db, collection: Collection<Stored>, ids: string[], expected: ReadonlyArray<readonly [string, string | null]> = []): Promise<void> {
753
814
  await this.withNumbers(db, collection, ids.length, async (first, session) => {
815
+ const guards = await this.checkVersions(collection, expected, session);
754
816
  const operations: AnyBulkWriteOperation<Stored>[] = ids.map((id, index) => {
755
817
  const tomb: Stored = { _id: this.storedId(id), [KEY]: id, [SEQ]: first + index, [TOMB]: true };
756
818
  if (this.partition !== undefined) tomb[PART] = this.partition;
757
- return { replaceOne: { filter: this.writeFilter(id), replacement: tomb } };
819
+ return { replaceOne: { filter: { ...this.writeFilter(id), ...guards.get(id) }, replacement: tomb } };
758
820
  });
759
- await collection.bulkWrite(operations, { ordered: true, ...(session ? { session } : {}) });
821
+ const result = await collection.bulkWrite(operations, { ordered: true, ...(session ? { session } : {}) });
822
+ // A guard that found no document: another writer came between the test and the write
823
+ if (expected.length > 0 && !session && result.matchedCount < operations.length) throw await this.lateConflict(collection, expected);
760
824
  });
761
825
  await this.pruneTombstones(db, collection);
762
826
  }
763
827
 
828
+ /**
829
+ * The preconditions of a write (roadmap CLI-16), after a check of their ids. A storage without
830
+ * `versionOf` throws a `TypeError` before the write starts.
831
+ */
832
+ private preconditions(expected: ReadonlyArray<readonly [string, string | null]>): ReadonlyArray<readonly [string, string | null]> {
833
+ if (expected.length === 0) return expected;
834
+ if (!this.options.versionOf) throw preconditionNotSupported(`The MongoDocumentStorage of the collection ${this.collectionName}`);
835
+ for (const [id] of expected) this.storedId(id);
836
+ return expected;
837
+ }
838
+
839
+ /** The current state of each id of the preconditions: the item, or no item for a tombstone or a missing document. */
840
+ private async currentDocs(collection: Collection<Stored>, ids: readonly string[], session?: ClientSession): Promise<Map<string, Document>> {
841
+ const docs = await collection
842
+ .find({ ...this.partitionFilter(), _id: { $in: [...new Set(ids)].map((id) => this.storedId(id)) } } as Filter<Stored>, session ? { session } : {})
843
+ .toArray();
844
+ return new Map(docs.map((doc) => [String(doc[KEY]), doc]));
845
+ }
846
+
847
+ /** The current item of a stored document: no item for a tombstone or a missing document. */
848
+ private currentOf(doc: Document | undefined): CurrentItem<T> {
849
+ return doc && doc[TOMB] !== true ? { found: true, value: this.decode(doc)[1] } : { found: false };
850
+ }
851
+
852
+ /**
853
+ * This method tests the expected versions (roadmap CLI-16). A conflict throws
854
+ * `VersionConflictError`. It gives the guard of each id for a write without a transaction: the
855
+ * `_seq` that the test read, or no document. In a transaction, the guards are empty.
856
+ */
857
+ private async checkVersions(collection: Collection<Stored>, expected: ReadonlyArray<readonly [string, string | null]>, session?: ClientSession): Promise<Map<string, Document>> {
858
+ const guards = new Map<string, Document>();
859
+ if (expected.length === 0 || !this.options.versionOf) return guards;
860
+ const versionOf = this.options.versionOf;
861
+ const docs = await this.currentDocs(collection, expected.map(([id]) => id), session);
862
+ const conflicts: VersionConflict[] = [];
863
+ for (const [id, version] of expected) {
864
+ const conflict = versionConflict(id, version, this.currentOf(docs.get(id)), versionOf);
865
+ if (conflict) conflicts.push(conflict);
866
+ }
867
+ if (conflicts.length > 0) throw new VersionConflictError(conflicts);
868
+ if (session) return guards;
869
+ for (const [id] of expected) {
870
+ const doc = docs.get(id);
871
+ guards.set(id, doc ? { [SEQ]: doc[SEQ] } : { [SEQ]: { $exists: false } });
872
+ }
873
+ return guards;
874
+ }
875
+
876
+ /**
877
+ * The error of a guard that failed: another writer changed an id between the test and the write.
878
+ * It has the current item of each id of the preconditions.
879
+ */
880
+ private async lateConflict(collection: Collection<Stored>, expected: ReadonlyArray<readonly [string, string | null]>): Promise<VersionConflictError> {
881
+ const versionOf = this.options.versionOf!;
882
+ const docs = await this.currentDocs(collection, expected.map(([id]) => id));
883
+ const conflicts = expected.map(([id, version]): VersionConflict => {
884
+ const current = this.currentOf(docs.get(id));
885
+ const conflict = versionConflict(id, version, current, versionOf);
886
+ if (conflict) return conflict;
887
+ // The current item has the expected version again, after a write of another writer
888
+ const now = current.found ? versionOf(current.value) : null;
889
+ return current.found ? { id, expected: version, found: true, value: current.value, ...(now !== undefined && { version: now }) } : { id, expected: version, found: false, version: null };
890
+ });
891
+ return new VersionConflictError(conflicts, "Another writer changed an item between the test of its version and the write");
892
+ }
893
+
764
894
  // ===========================================================================
765
895
  // The life of the storage
766
896
  // ===========================================================================
@@ -6,7 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { Collection, Document, Filter } from "mongodb";
9
- import type { CollectionStorage, StorageMetadata } from "@procedures/collections";
9
+ import { preconditionNotSupported, type CollectionStorage, type StorageBatchWriteOptions, type StorageItemWriteOptions, type StorageMetadata } from "@procedures/collections";
10
10
  import { ensureConnection } from "../connection.js";
11
11
 
12
12
  /**
@@ -85,7 +85,8 @@ export class MongoStorage<T> implements CollectionStorage<T> {
85
85
  return (await this.getCollection()).countDocuments({});
86
86
  }
87
87
 
88
- async set(id: string, value: T): Promise<void> {
88
+ async set(id: string, value: T, options?: StorageItemWriteOptions): Promise<void> {
89
+ this.refusePrecondition(options?.expectedVersion !== undefined);
89
90
  await (await this.getCollection()).updateOne(
90
91
  { _id: id } as Filter<MongoDoc<T>>,
91
92
  { $set: { _id: id, data: value } as unknown as Document },
@@ -93,7 +94,8 @@ export class MongoStorage<T> implements CollectionStorage<T> {
93
94
  );
94
95
  }
95
96
 
96
- async delete(id: string): Promise<boolean> {
97
+ async delete(id: string, options?: StorageItemWriteOptions): Promise<boolean> {
98
+ this.refusePrecondition(options?.expectedVersion !== undefined);
97
99
  const result = await (await this.getCollection()).deleteOne({ _id: id } as Filter<MongoDoc<T>>);
98
100
  return result.deletedCount > 0;
99
101
  }
@@ -102,8 +104,9 @@ export class MongoStorage<T> implements CollectionStorage<T> {
102
104
  await (await this.getCollection()).deleteMany({});
103
105
  }
104
106
 
105
- async setBatch(items: Array<[string, T]>): Promise<void> {
107
+ async setBatch(items: Array<[string, T]>, options?: StorageBatchWriteOptions): Promise<void> {
106
108
  this.checkOpen();
109
+ this.refusePrecondition((options?.expectedVersions?.length ?? 0) > 0);
107
110
  if (items.length === 0) return;
108
111
 
109
112
  const operations = items.map(([id, value]) => ({
@@ -151,6 +154,14 @@ export class MongoStorage<T> implements CollectionStorage<T> {
151
154
  this.closed = true;
152
155
  }
153
156
 
157
+ /**
158
+ * This method refuses a write with an expected version (roadmap CLI-16). The `{ _id, data }`
159
+ * documents have no version: `MongoDocumentStorage` with the option `versionOf` tests it.
160
+ */
161
+ private refusePrecondition(has: boolean): void {
162
+ if (has) throw preconditionNotSupported(`The MongoStorage of the collection ${this.collectionName}`, "use MongoDocumentStorage with the option versionOf");
163
+ }
164
+
154
165
  /** This method throws after `close()`, so the promise of the operation rejects. */
155
166
  private checkOpen(): void {
156
167
  if (this.closed) throw new Error(`The MongoStorage of the collection ${this.collectionName} is closed`);