@alexify/migronaut 2.2.0 → 2.3.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/README.md +33 -2
  3. package/bullmq.d.ts +449 -6
  4. package/index.d.ts +1010 -9
  5. package/migronaut.schema.json +93 -1
  6. package/package.json +8 -2
  7. package/src/bullmq/background-processor.js +469 -0
  8. package/src/bullmq/index.js +12 -0
  9. package/src/bullmq/jobs.js +254 -7
  10. package/src/bullmq/processor.js +128 -14
  11. package/src/bullmq/producer.js +185 -13
  12. package/src/bullmq/service.js +480 -45
  13. package/src/cli/commands/background.js +500 -0
  14. package/src/cli/commands/create.js +6 -0
  15. package/src/cli/exit-codes.js +6 -0
  16. package/src/cli/index.js +2 -0
  17. package/src/core/audit.js +11 -1
  18. package/src/core/background-audit.js +139 -0
  19. package/src/core/background-drift.js +126 -0
  20. package/src/core/background-dry-run.js +366 -0
  21. package/src/core/background-engine.js +818 -0
  22. package/src/core/background-kit.js +425 -0
  23. package/src/core/background-partition.js +298 -0
  24. package/src/core/background-runner.js +305 -0
  25. package/src/core/background-sandbox.js +701 -0
  26. package/src/core/background-shard.js +542 -0
  27. package/src/core/background-spec.js +597 -0
  28. package/src/core/background-store.js +951 -0
  29. package/src/core/background-throttle.js +269 -0
  30. package/src/core/background-watch-plan.js +164 -0
  31. package/src/core/background-watch-store.js +78 -0
  32. package/src/core/background-watch.js +605 -0
  33. package/src/core/background.js +1121 -0
  34. package/src/core/bson-peer.js +23 -0
  35. package/src/core/changelog.js +32 -0
  36. package/src/core/collections.js +78 -8
  37. package/src/core/config.js +102 -12
  38. package/src/core/converge-plan.js +86 -7
  39. package/src/core/converge.js +88 -0
  40. package/src/core/lock.js +48 -21
  41. package/src/core/migrator.js +904 -12
  42. package/src/core/options.js +16 -0
  43. package/src/core/run.js +26 -12
  44. package/src/core/runner.js +1 -1
  45. package/src/core/server-info.js +9 -2
  46. package/src/core/shard-info.js +76 -0
  47. package/src/core/versioning-spec.js +181 -0
  48. package/src/errors/index.js +88 -0
  49. package/src/index.js +16 -0
  50. package/src/utils/error.js +11 -2
  51. package/src/utils/loader.js +77 -9
  52. package/src/utils/migration-name.js +33 -1
  53. package/src/utils/telemetry.js +107 -0
  54. package/src/utils/template.js +62 -1
  55. package/src/versioning/config.js +155 -0
  56. package/src/versioning/document.js +326 -0
  57. package/src/versioning/index.js +50 -0
  58. package/src/versioning/internal.js +279 -0
  59. package/src/versioning/mongoose.js +151 -0
  60. package/src/versioning/occ.js +318 -0
  61. package/src/versioning/registry.js +187 -0
  62. package/src/versioning/upcaster.js +213 -0
  63. package/versioning.d.ts +666 -0
  64. package/versioning.js +1 -0
@@ -0,0 +1,151 @@
1
+ const { ConfigInvalidError } = require('../errors/index.js');
2
+ const { resolveVersioning } = require('./config.js');
3
+ const { isPlainObject, topField, unwrapDefinition } = require('./internal.js');
4
+
5
+ /**
6
+ * A Mongoose schema plugin for a versioned collection — it never requires
7
+ * mongoose: the schema is the caller's, used only through its own methods.
8
+ *
9
+ * ordersSchema.plugin(versioningPlugin, require('./collections/orders'));
10
+ *
11
+ * - The revision becomes Mongoose's `versionKey` with `optimisticConcurrency`,
12
+ * so a `save()` of a document someone changed since it was loaded throws
13
+ * Mongoose's `VersionError` — and every save bumps it, as the background
14
+ * migration engine's guard needs.
15
+ * - The version field is a plain `Number` path **without a default**:
16
+ * Mongoose applies defaults to documents it loads, which would stamp a
17
+ * legacy document as current without upgrading it. A new document is
18
+ * stamped before it is validated instead.
19
+ * - `updateOne`/`updateMany`/`findOneAndUpdate` bump the revision, and an
20
+ * upsert stamps the version of a document it inserts.
21
+ *
22
+ * Not covered (documented): lean `insertMany`, `bulkWrite`, `replaceOne` /
23
+ * `findOneAndReplace` and pipeline updates — the collection's validator is
24
+ * the safety net there.
25
+ */
26
+
27
+ const UPDATE_HOOKS = ['updateOne', 'updateMany', 'findOneAndUpdate'];
28
+
29
+ function assertSchema(schema) {
30
+ for (const method of ['set', 'get', 'add', 'path', 'pre']) {
31
+ if (typeof schema?.[method] !== 'function') {
32
+ throw new ConfigInvalidError('versioningPlugin takes a Mongoose schema');
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * The top-level fields a Mongoose update writes. Plain keys are `$set` paths
39
+ * to Mongoose (it wraps them at cast time), and the `$setOnInsert` of the
40
+ * version key that Mongoose itself adds to every upsert is not the caller's.
41
+ */
42
+ function updatedFields(update, revisionField) {
43
+ const fields = new Set();
44
+ for (const [key, spec] of Object.entries(update)) {
45
+ if (!key.startsWith('$')) {
46
+ fields.add(topField(key));
47
+ continue;
48
+ }
49
+ if (!isPlainObject(spec)) continue;
50
+ for (const [path, value] of Object.entries(spec)) {
51
+ if (key === '$setOnInsert' && path === revisionField && value === 0) continue;
52
+ fields.add(topField(path));
53
+ if (key === '$rename' && typeof value === 'string') fields.add(topField(value));
54
+ }
55
+ }
56
+ return fields;
57
+ }
58
+
59
+ /** The plugin over already-resolved versioning — `defineShapes().plugin()` passes its own */
60
+ function applyVersioningPlugin(schema, versioning) {
61
+ assertSchema(schema);
62
+ const { current, field, revisionField } = versioning;
63
+ const existing = schema.path(field);
64
+ if (existing && existing.defaultValue !== undefined) {
65
+ throw new ConfigInvalidError(
66
+ `versioningPlugin: the schema gives "${field}" a default — Mongoose applies defaults to ` +
67
+ 'documents it loads, which would mark legacy documents as current; remove it',
68
+ { field },
69
+ );
70
+ }
71
+
72
+ if (revisionField !== null) {
73
+ schema.set('versionKey', revisionField);
74
+ schema.set('optimisticConcurrency', true);
75
+ } else if (schema.get('versionKey') === field) {
76
+ // Mongoose would count array changes in the version field.
77
+ schema.set('versionKey', false);
78
+ }
79
+ if (!existing) schema.add({ [field]: { type: Number } });
80
+
81
+ function stamp() {
82
+ if (this.isNew && this.get(field) == null) this.set(field, current);
83
+ }
84
+ // Validation may be skipped (`validateBeforeSave: false`): stamp on save too.
85
+ schema.pre('validate', stamp);
86
+ schema.pre('save', stamp);
87
+
88
+ if (revisionField === null) return;
89
+
90
+ // A document loaded without a revision (one that predates versioning) is
91
+ // revision 0: its save is guarded like any other, or a background
92
+ // migration's rewrite in between would be silently overwritten.
93
+ schema.pre('save', function guardLegacy() {
94
+ if (this.isNew) return;
95
+ // Loaded without its revision (a projection): Mongoose would neither
96
+ // guard the save nor bump the revision — and "not selected" is not
97
+ // "legacy". Refused, rather than a write an optimistic filter cannot see.
98
+ if (typeof this.isSelected === 'function' && !this.isSelected(revisionField)) {
99
+ throw new ConfigInvalidError(
100
+ `versioningPlugin: this document was loaded without "${revisionField}" — select it ` +
101
+ '(or every field) to save it',
102
+ { field: revisionField },
103
+ );
104
+ }
105
+ const where = isPlainObject(this.$where) ? { ...this.$where } : {};
106
+ if (this.get(revisionField) == null) where[revisionField] = { $in: [null, 0] };
107
+ else delete where[revisionField];
108
+ this.$where = where;
109
+ });
110
+
111
+ schema.pre(UPDATE_HOOKS, function bumpRevision() {
112
+ const update = this.getUpdate();
113
+ if (!isPlainObject(update)) return;
114
+ const fields = updatedFields(update, revisionField);
115
+ if (fields.has(revisionField)) return;
116
+ const next = { ...update, $inc: { ...update.$inc, [revisionField]: 1 } };
117
+ // Mongoose adds `$setOnInsert: { versionKey: 0 }` to an upsert, which
118
+ // would conflict with the `$inc`: an inserted document starts at 1.
119
+ if (isPlainObject(next.$setOnInsert) && Object.hasOwn(next.$setOnInsert, revisionField)) {
120
+ const { [revisionField]: _dropped, ...rest } = next.$setOnInsert;
121
+ next.$setOnInsert = rest;
122
+ }
123
+ if (this.getOptions?.().upsert === true && !fields.has(field)) {
124
+ next.$setOnInsert = { ...next.$setOnInsert, [field]: current };
125
+ }
126
+ if (isPlainObject(next.$setOnInsert) && Object.keys(next.$setOnInsert).length === 0) {
127
+ delete next.$setOnInsert;
128
+ }
129
+ this.setUpdate(next);
130
+ });
131
+ }
132
+
133
+ /**
134
+ * The plugin, for `schema.plugin(versioningPlugin, definition)`: the second
135
+ * argument is a collection definition (`{ versioning }`) or a versioning block.
136
+ *
137
+ * @throws {ConfigInvalidError} on a non-schema, invalid versioning, or a
138
+ * version field the schema already gives a default
139
+ */
140
+ function versioningPlugin(schema, definitionOrModule) {
141
+ const definition = unwrapDefinition(definitionOrModule);
142
+ if (!isPlainObject(definition)) {
143
+ throw new ConfigInvalidError(
144
+ 'versioningPlugin needs the collection definition: schema.plugin(versioningPlugin, definition)',
145
+ );
146
+ }
147
+ const source = isPlainObject(definition.versioning) ? definition.versioning : definition;
148
+ applyVersioningPlugin(schema, resolveVersioning(source));
149
+ }
150
+
151
+ module.exports = { applyVersioningPlugin, versioningPlugin };
@@ -0,0 +1,318 @@
1
+ const { ConfigInvalidError, RevisionConflictError } = require('../errors/index.js');
2
+ const {
3
+ resolveFieldNames,
4
+ resolveRevisionField,
5
+ revisionFilter,
6
+ revisionOf,
7
+ stampedUpdate,
8
+ } = require('./document.js');
9
+ const { filterTouches, isPlainObject, toCount } = require('./internal.js');
10
+
11
+ /**
12
+ * Optimistic concurrency for a repository layer: a write that only lands if
13
+ * the document is still at the revision the caller read, and bumps it.
14
+ *
15
+ * await updateWithRevision(orders, { _id }, order.__rev, { $set: { status } });
16
+ *
17
+ * matches `{ _id, __rev: order.__rev }` and adds `$inc: { __rev: 1 }`. When it
18
+ * matches nothing, someone else wrote in between (or the document is gone):
19
+ * a `RevisionConflictError` says which, and the caller re-reads and retries
20
+ * (`retryOnConflict`) or answers its own caller with a conflict.
21
+ *
22
+ * The collection is used structurally — anything with the driver's
23
+ * `updateOne`/`replaceOne`/`findOneAndUpdate`/`findOne`, a Mongoose model's
24
+ * collection included. Nothing here imports the driver.
25
+ */
26
+
27
+ /** Options of ours, kept away from the driver */
28
+ const OWN_OPTIONS = new Set(['field', 'revisionField', 'version', 'verify']);
29
+
30
+ /** The write's options its explaining re-read takes along, to match the same way */
31
+ const REREAD_OPTIONS = ['session', 'collation', 'hint', 'let'];
32
+
33
+ /** `{ names, version, verify, driverOptions }` from the caller's options */
34
+ function splitOptions(options = {}) {
35
+ if (!isPlainObject(options)) throw new ConfigInvalidError('options must be an object');
36
+ const driverOptions = {};
37
+ for (const [key, value] of Object.entries(options)) {
38
+ if (!OWN_OPTIONS.has(key)) driverOptions[key] = value;
39
+ }
40
+ if (driverOptions.upsert === true) {
41
+ throw new ConfigInvalidError(
42
+ 'A revision-guarded write cannot upsert: a miss would insert a second document instead ' +
43
+ 'of reporting the conflict',
44
+ );
45
+ }
46
+ const w = driverOptions.writeConcern?.w ?? driverOptions.w;
47
+ if (w === 0) {
48
+ throw new ConfigInvalidError(
49
+ 'A revision-guarded write needs an acknowledged write concern — with w: 0 a conflict ' +
50
+ 'cannot be seen',
51
+ );
52
+ }
53
+ // The version field matters only to a write that sets the version.
54
+ const names =
55
+ options.version === undefined && options.field === undefined
56
+ ? { field: null, revisionField: resolveRevisionField(options.revisionField) }
57
+ : resolveFieldNames({ field: options.field, revisionField: options.revisionField });
58
+ if (options.version !== undefined) assertCount(options.version, 'version');
59
+ return { names, version: options.version, verify: options.verify !== false, driverOptions };
60
+ }
61
+
62
+ function assertCount(value, what) {
63
+ if (!Number.isSafeInteger(value) || value < 0) {
64
+ throw new ConfigInvalidError(`${what} must be an integer ≥ 0`, { [what]: value });
65
+ }
66
+ }
67
+
68
+ /**
69
+ * The revision the caller read, as a number: an Int32, a Long or a bigint
70
+ * (a document read with `promoteValues: false`, or `useBigInt64`) counts too.
71
+ */
72
+ function expectedOf(expected) {
73
+ const count = toCount(expected);
74
+ if (count === null) {
75
+ throw new ConfigInvalidError('expectedRevision must be an integer ≥ 0', {
76
+ expectedRevision: typeof expected,
77
+ });
78
+ }
79
+ return count;
80
+ }
81
+
82
+ /** An `_id` given as an operator — anything but a single `$eq` of a value */
83
+ function operatorId(id) {
84
+ if (!isPlainObject(id)) return false;
85
+ const keys = Object.keys(id);
86
+ if (keys.length === 1 && keys[0] === '$eq') return operatorId(id.$eq);
87
+ for (const key of keys) if (key.startsWith('$')) return true;
88
+ return false;
89
+ }
90
+
91
+ /**
92
+ * The caller's filter, refused if it already constrains the revision, plus
93
+ * ours. A revision guards one document: an `_id` given as an operator
94
+ * (`{ $ne: null }` from a request body, say) would let it land on whichever
95
+ * document is at that revision — every legacy one is at 0 — so it is refused.
96
+ */
97
+ function guardedFilter(filter, names, expected) {
98
+ if (!isPlainObject(filter)) throw new ConfigInvalidError('filter must be an object');
99
+ if (operatorId(filter._id)) {
100
+ throw new ConfigInvalidError(
101
+ 'filter._id must be a value, not an operator — a revision guards one document',
102
+ );
103
+ }
104
+ if (filterTouches(filter, names.revisionField)) {
105
+ throw new ConfigInvalidError(
106
+ `The filter must not constrain "${names.revisionField}" — pass the revision as ` +
107
+ 'expectedRevision',
108
+ );
109
+ }
110
+ return { ...filter, ...revisionFilter(names, expected) };
111
+ }
112
+
113
+ /**
114
+ * A miss, explained: read the document without the revision guard and say
115
+ * whether it moved on (`conflict`, with `actual`), is gone (`not-found`), or
116
+ * cannot be told (`unknown` — the read was turned off, or it failed). The
117
+ * filter itself never goes into the error: it may carry PII.
118
+ */
119
+ async function conflictError(collection, filter, names, expected, { verify, driverOptions }) {
120
+ const context = { expected, reason: 'unknown' };
121
+ if (collection?.collectionName !== undefined) context.collection = collection.collectionName;
122
+ if (verify) {
123
+ try {
124
+ // Matched the way the write matched (its collation, hint, variables),
125
+ // on the primary: a secondary may not have seen the write that won.
126
+ const read = { projection: { [names.revisionField]: 1 }, readPreference: 'primary' };
127
+ for (const key of REREAD_OPTIONS) {
128
+ if (driverOptions[key] !== undefined) read[key] = driverOptions[key];
129
+ }
130
+ const current = await collection.findOne(filter, read);
131
+ if (current === null || current === undefined) {
132
+ context.reason = 'not-found';
133
+ } else {
134
+ const actual = revisionOf(current, names.revisionField);
135
+ // The same revision again means the guard missed on something else.
136
+ if (actual !== expected) {
137
+ context.reason = 'conflict';
138
+ context.actual = actual;
139
+ }
140
+ }
141
+ } catch {
142
+ // `unknown` it stays.
143
+ }
144
+ }
145
+ const what =
146
+ context.reason === 'conflict'
147
+ ? `is at revision ${context.actual}, not ${expected}`
148
+ : context.reason === 'not-found'
149
+ ? 'does not exist'
150
+ : `was not at revision ${expected}, or no longer exists`;
151
+ return new RevisionConflictError(`Revision conflict: the document ${what}`, context);
152
+ }
153
+
154
+ /**
155
+ * `updateOne` guarded by the revision the caller read: `expectedRevision` is
156
+ * `doc.__rev` (0 for a document without one). The update is an operator
157
+ * document or a pipeline; the revision is bumped, and `version` (when given)
158
+ * set. Returns the driver's result plus `revision`, the new one.
159
+ *
160
+ * @throws {RevisionConflictError} when nothing matched
161
+ * @throws {ConfigInvalidError} on an upsert, a `w: 0` write, a filter or an
162
+ * update that handles the revision itself, or a replacement document
163
+ */
164
+ async function updateWithRevision(collection, filter, expectedRevision, update, options) {
165
+ const { names, version, verify, driverOptions } = splitOptions(options);
166
+ const expected = expectedOf(expectedRevision);
167
+ const guarded = guardedFilter(filter, names, expected);
168
+ const stamped = stampedUpdate(names, { to: version, update });
169
+ const result = await collection.updateOne(guarded, stamped, driverOptions);
170
+ assertAcknowledged(result);
171
+ if (result.matchedCount === 0) {
172
+ throw await conflictError(collection, filter, names, expected, { verify, driverOptions });
173
+ }
174
+ return { ...result, revision: expected + 1 };
175
+ }
176
+
177
+ /**
178
+ * An unacknowledged write (`w: 0` from the collection's or the client's
179
+ * defaults, which no option of the call shows) reports no match count — it
180
+ * cannot be told from a success, so it is refused after the fact.
181
+ */
182
+ function assertAcknowledged(result) {
183
+ if (result?.acknowledged === false || typeof result?.matchedCount !== 'number') {
184
+ throw new ConfigInvalidError(
185
+ 'A revision-guarded write needs an acknowledged write concern — this one was not ' +
186
+ "acknowledged (w: 0 from the collection's or the client's defaults?)",
187
+ );
188
+ }
189
+ }
190
+
191
+ /** A plain document none of whose top-level keys is an operator */
192
+ function isReplacement(value) {
193
+ if (!isPlainObject(value)) return false;
194
+ for (const key of Object.keys(value)) if (key.startsWith('$')) return false;
195
+ return true;
196
+ }
197
+
198
+ /**
199
+ * `replaceOne` guarded by the revision the caller read. The replacement's
200
+ * own revision field (usually the stale one it was read with) is overwritten
201
+ * with the next revision; `version`, when given, sets the version field.
202
+ *
203
+ * @throws {RevisionConflictError} when nothing matched
204
+ */
205
+ async function replaceWithRevision(collection, filter, expectedRevision, replacement, options) {
206
+ const { names, version, verify, driverOptions } = splitOptions(options);
207
+ const expected = expectedOf(expectedRevision);
208
+ if (!isReplacement(replacement)) {
209
+ throw new ConfigInvalidError(
210
+ 'The replacement must be a plain document without update operators',
211
+ );
212
+ }
213
+ const guarded = guardedFilter(filter, names, expected);
214
+ const document = { ...replacement, [names.revisionField]: expected + 1 };
215
+ if (version !== undefined) document[names.field] = version;
216
+ const result = await collection.replaceOne(guarded, document, driverOptions);
217
+ assertAcknowledged(result);
218
+ if (result.matchedCount === 0) {
219
+ throw await conflictError(collection, filter, names, expected, { verify, driverOptions });
220
+ }
221
+ return { ...result, revision: expected + 1 };
222
+ }
223
+
224
+ /**
225
+ * `findOneAndUpdate` guarded by the revision the caller read. Returns the
226
+ * document — after the update by default (`returnDocument: 'before'` to get
227
+ * the old one) — the same way on every driver version: the raw result is
228
+ * always requested with its metadata and unwrapped here.
229
+ *
230
+ * @throws {RevisionConflictError} when nothing matched
231
+ */
232
+ async function findOneAndUpdateWithRevision(collection, filter, expectedRevision, update, options) {
233
+ const { names, version, verify, driverOptions } = splitOptions(options);
234
+ const expected = expectedOf(expectedRevision);
235
+ const guarded = guardedFilter(filter, names, expected);
236
+ const stamped = stampedUpdate(names, { to: version, update });
237
+ const result = await collection.findOneAndUpdate(guarded, stamped, {
238
+ returnDocument: 'after',
239
+ ...driverOptions,
240
+ includeResultMetadata: true,
241
+ });
242
+ const value = result?.value ?? null;
243
+ if (value === null) {
244
+ throw await conflictError(collection, filter, names, expected, { verify, driverOptions });
245
+ }
246
+ return value;
247
+ }
248
+
249
+ /** Full-jitter exponential backoff: anywhere up to `min(maxMs, baseMs · 2^attempt)` */
250
+ function jitter({ baseMs = 10, maxMs = 1000 } = {}) {
251
+ return (attempt) => Math.random() * Math.min(maxMs, baseMs * 2 ** attempt);
252
+ }
253
+
254
+ const sleep = (ms, signal) =>
255
+ new Promise((resolve, reject) => {
256
+ if (signal?.aborted) {
257
+ reject(signal.reason);
258
+ return;
259
+ }
260
+ const timer = setTimeout(() => {
261
+ signal?.removeEventListener('abort', onAbort);
262
+ resolve();
263
+ }, ms);
264
+ const onAbort = () => {
265
+ clearTimeout(timer);
266
+ reject(signal.reason);
267
+ };
268
+ signal?.addEventListener('abort', onAbort, { once: true });
269
+ });
270
+
271
+ /**
272
+ * Run `fn(attempt)` — which reads, decides and writes with a revision guard —
273
+ * again after a conflict, up to `attempts` times in all (default 3), with a
274
+ * full-jitter backoff between tries. A `not-found` is never retried: the
275
+ * document is gone, and reading it again will not bring it back. Anything
276
+ * that is not a `RevisionConflictError` is thrown at once.
277
+ *
278
+ * `backoff` is `{ baseMs, maxMs }` (default 10 and 1000) or a function of
279
+ * the attempt number returning milliseconds; `signal` cuts a wait short.
280
+ */
281
+ async function retryOnConflict(fn, { attempts = 3, backoff, signal } = {}) {
282
+ if (typeof fn !== 'function') throw new ConfigInvalidError('retryOnConflict needs a function');
283
+ if (!Number.isSafeInteger(attempts) || attempts < 1) {
284
+ throw new ConfigInvalidError('attempts must be an integer ≥ 1', { attempts });
285
+ }
286
+ const delay = typeof backoff === 'function' ? backoff : jitter(backoff);
287
+ for (let attempt = 0; ; attempt++) {
288
+ try {
289
+ return await fn(attempt);
290
+ } catch (error) {
291
+ const retryable =
292
+ error instanceof RevisionConflictError && error.context?.reason !== 'not-found';
293
+ if (!retryable || attempt + 1 >= attempts) throw error;
294
+ await sleep(delay(attempt), signal);
295
+ }
296
+ }
297
+ }
298
+
299
+ /**
300
+ * `update` with the revision bumped — for a write that is not guarded by a
301
+ * revision but must still move it. In a collection with revisions every write
302
+ * has to: a write that leaves the revision alone is one an optimistic
303
+ * filter — the application's, or a background migration's — cannot see.
304
+ */
305
+ function bumpRevision(update, { revisionField } = {}) {
306
+ return stampedUpdate(
307
+ { field: null, revisionField: resolveRevisionField(revisionField) },
308
+ { update },
309
+ );
310
+ }
311
+
312
+ module.exports = {
313
+ bumpRevision,
314
+ findOneAndUpdateWithRevision,
315
+ replaceWithRevision,
316
+ retryOnConflict,
317
+ updateWithRevision,
318
+ };
@@ -0,0 +1,187 @@
1
+ const { ConfigInvalidError, ShapeVersionError } = require('../errors/index.js');
2
+ const { resolveVersioning } = require('./config.js');
3
+ const { refuseTouch, stampDocument, versionOf } = require('./document.js');
4
+ const { isPlainObject, touchedFields, unwrapDefinition } = require('./internal.js');
5
+ const { applyVersioningPlugin } = require('./mongoose.js');
6
+ const {
7
+ bumpRevision,
8
+ findOneAndUpdateWithRevision,
9
+ replaceWithRevision,
10
+ updateWithRevision,
11
+ } = require('./occ.js');
12
+ const { createUpcaster } = require('./upcaster.js');
13
+
14
+ /**
15
+ * `defineShapes` — the application's view of its versioned collections, read
16
+ * from the very definition files converge declares them with:
17
+ *
18
+ * const shapes = defineShapes({ orders: require('./collections/orders') });
19
+ * await orders.insertOne(shapes.stamp('orders', doc)); // __v: current, __rev: 0
20
+ *
21
+ * so the version a repository writes and the version the validator demands
22
+ * can never drift apart.
23
+ */
24
+
25
+ /** `[name, definition]` pairs from a `{ name: definition }` map or a definition list */
26
+ function entriesOf(definitions) {
27
+ if (Array.isArray(definitions)) {
28
+ const entries = [];
29
+ for (const [position, item] of definitions.entries()) {
30
+ const definition = unwrapDefinition(item);
31
+ if (!isPlainObject(definition) || typeof definition.name !== 'string') {
32
+ throw new ConfigInvalidError(
33
+ `defineShapes: definitions[${position}] needs a name — or pass { name: definition }`,
34
+ );
35
+ }
36
+ // A collections list may hold unversioned collections too.
37
+ if (definition.versioning !== undefined) entries.push([definition.name, definition]);
38
+ }
39
+ return entries;
40
+ }
41
+ if (!isPlainObject(definitions)) {
42
+ throw new ConfigInvalidError(
43
+ 'defineShapes takes { name: definition } or an array of definitions with a name',
44
+ );
45
+ }
46
+ const entries = [];
47
+ for (const [name, definition] of Object.entries(definitions)) {
48
+ const resolved = unwrapDefinition(definition);
49
+ if (!isPlainObject(resolved) || resolved.versioning === undefined) {
50
+ throw new ConfigInvalidError(`defineShapes: "${name}" declares no versioning`);
51
+ }
52
+ entries.push([name, resolved]);
53
+ }
54
+ return entries;
55
+ }
56
+
57
+ /**
58
+ * Called without arguments — `defineShapes<Shapes>()(definitions)` — it
59
+ * returns itself: TypeScript then takes the shape map from the first call
60
+ * and infers the definitions from the second. Nothing changes at run time.
61
+ */
62
+ function defineShapes(definitions) {
63
+ if (arguments.length === 0) return (defs) => defineShapes(defs);
64
+ const registry = new Map();
65
+ for (const [name, definition] of entriesOf(definitions)) {
66
+ if (registry.has(name)) {
67
+ throw new ConfigInvalidError(`defineShapes: "${name}" is declared twice`);
68
+ }
69
+ registry.set(name, resolveVersioning(definition.versioning, { path: `${name}.versioning` }));
70
+ }
71
+ const names = Object.freeze([...registry.keys()]);
72
+
73
+ const get = (name) => {
74
+ const versioning = registry.get(name);
75
+ if (versioning === undefined) {
76
+ throw new ConfigInvalidError(
77
+ `"${name}" is not a versioned collection (known: ${names.join(', ') || 'none'})`,
78
+ { collection: name },
79
+ );
80
+ }
81
+ return versioning;
82
+ };
83
+
84
+ const docVersion = (name, doc) => {
85
+ const versioning = get(name);
86
+ const version = versionOf(doc, versioning.field);
87
+ if (version === null) {
88
+ throw new ShapeVersionError(
89
+ `${name}: the document's ${versioning.field} is not a non-negative integer`,
90
+ { collection: name, reason: 'invalid', current: versioning.current },
91
+ );
92
+ }
93
+ return version;
94
+ };
95
+
96
+ const stamp = (name, doc) => {
97
+ const versioning = get(name);
98
+ return stampDocument(doc, versioning, versioning.current);
99
+ };
100
+
101
+ return Object.freeze({
102
+ names,
103
+ has: (name) => registry.has(name),
104
+ get,
105
+ current: (name) => get(name).current,
106
+ versionOf: docVersion,
107
+ isCurrent: (name, doc) => docVersion(name, doc) === get(name).current,
108
+ isVersion: (name, doc, version) => docVersion(name, doc) === version,
109
+ stamp,
110
+ /** The Mongoose plugin for this collection: `schema.plugin(shapes.plugin('orders'))` */
111
+ plugin: (name) => {
112
+ const versioning = get(name);
113
+ return (schema) => applyVersioningPlugin(schema, versioning);
114
+ },
115
+ /** An upcaster over this collection's versioning — see upcaster.js */
116
+ upcaster: (name, steps, options) =>
117
+ createUpcaster(get(name), steps, { ...options, collection: name }),
118
+ /**
119
+ * The revision guards bound to this collection's field names, so no call
120
+ * can forget them — a custom `revisionField` left out of one write would
121
+ * guard nothing (no document has `__rev`, so the filter matches them all).
122
+ */
123
+ occ: (name) => {
124
+ const { field, revisionField } = get(name);
125
+ if (revisionField === null) {
126
+ throw new ConfigInvalidError(
127
+ `"${name}" declares revision: false — it has no revision to guard`,
128
+ { collection: name },
129
+ );
130
+ }
131
+ const bound = (options = {}) => {
132
+ if (!isPlainObject(options)) throw new ConfigInvalidError('options must be an object');
133
+ for (const [key, value] of [
134
+ ['field', field],
135
+ ['revisionField', revisionField],
136
+ ]) {
137
+ if (options[key] !== undefined && options[key] !== value) {
138
+ throw new ConfigInvalidError(
139
+ `${name}: ${key} is "${value}" — the guards bound to it take no other`,
140
+ { collection: name, [key]: options[key] },
141
+ );
142
+ }
143
+ }
144
+ return { ...options, field, revisionField };
145
+ };
146
+ return Object.freeze({
147
+ // Async, like the helpers: a refused option rejects, it does not throw.
148
+ updateWithRevision: async (collection, filter, expected, update, options) =>
149
+ updateWithRevision(collection, filter, expected, update, bound(options)),
150
+ replaceWithRevision: async (collection, filter, expected, replacement, options) =>
151
+ replaceWithRevision(collection, filter, expected, replacement, bound(options)),
152
+ findOneAndUpdateWithRevision: async (collection, filter, expected, update, options) =>
153
+ findOneAndUpdateWithRevision(collection, filter, expected, update, bound(options)),
154
+ bumpRevision: (update) => bumpRevision(update, { revisionField }),
155
+ });
156
+ },
157
+ /** `stamp` for one document or every document of an array — for `insertMany` */
158
+ onInsert: (name, docs) =>
159
+ Array.isArray(docs) ? docs.map((doc) => stamp(name, doc)) : stamp(name, docs),
160
+ /**
161
+ * An upsert's update with the stamp of a document it may insert: the
162
+ * version on `$setOnInsert` (unless the update sets it itself) and the
163
+ * revision bumped — an inserted document starts at revision 1. A
164
+ * replacement or a pipeline is refused: `$setOnInsert` has no place there.
165
+ */
166
+ stampUpsert: (name, update) => {
167
+ const versioning = get(name);
168
+ if (Array.isArray(update) || !isPlainObject(update)) {
169
+ throw new ConfigInvalidError('stampUpsert takes an operator update, not a pipeline');
170
+ }
171
+ const { fields, whole } = touchedFields(update);
172
+ if (whole) {
173
+ throw new ConfigInvalidError('stampUpsert takes an operator update, not a replacement');
174
+ }
175
+ const { field, revisionField } = versioning;
176
+ refuseTouch(fields, revisionField, 'revision');
177
+ const out = { ...update };
178
+ if (!fields.has(field)) {
179
+ out.$setOnInsert = { ...update.$setOnInsert, [field]: versioning.current };
180
+ }
181
+ if (revisionField !== null) out.$inc = { ...update.$inc, [revisionField]: 1 };
182
+ return out;
183
+ },
184
+ });
185
+ }
186
+
187
+ module.exports = { defineShapes };