@alexify/migronaut 2.2.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +190 -0
- package/README.md +41 -3
- package/bullmq.d.ts +484 -8
- package/index.d.ts +1264 -9
- package/migronaut.schema.json +93 -1
- package/package.json +9 -2
- package/src/bullmq/background-processor.js +541 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +348 -21
- package/src/bullmq/producer.js +185 -13
- package/src/bullmq/service.js +484 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/create.js +6 -0
- package/src/cli/exit-codes.js +6 -0
- package/src/cli/index.js +2 -0
- package/src/core/audit.js +11 -1
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +375 -0
- package/src/core/background-engine.js +849 -0
- package/src/core/background-kit.js +432 -0
- package/src/core/background-partition.js +298 -0
- package/src/core/background-runner.js +305 -0
- package/src/core/background-sandbox.js +701 -0
- package/src/core/background-shard.js +542 -0
- package/src/core/background-spec.js +597 -0
- package/src/core/background-store.js +951 -0
- package/src/core/background-throttle.js +269 -0
- package/src/core/background-watch-plan.js +164 -0
- package/src/core/background-watch-store.js +78 -0
- package/src/core/background-watch.js +610 -0
- package/src/core/background.js +1127 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +78 -8
- package/src/core/config.js +102 -12
- package/src/core/converge-plan.js +86 -7
- package/src/core/converge.js +88 -0
- package/src/core/lock.js +48 -21
- package/src/core/migration-logger.js +279 -0
- package/src/core/migrator.js +1027 -22
- package/src/core/options.js +36 -0
- package/src/core/run-recorder.js +6 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +34 -8
- package/src/core/server-info.js +9 -2
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +88 -0
- package/src/index.js +16 -0
- package/src/utils/error.js +11 -2
- package/src/utils/job-ref.js +44 -0
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/redact.js +140 -3
- package/src/utils/telemetry.js +110 -0
- package/src/utils/template.js +62 -1
- package/src/versioning/config.js +155 -0
- package/src/versioning/document.js +326 -0
- package/src/versioning/index.js +50 -0
- package/src/versioning/internal.js +279 -0
- package/src/versioning/mongoose.js +151 -0
- package/src/versioning/occ.js +318 -0
- package/src/versioning/registry.js +187 -0
- package/src/versioning/upcaster.js +213 -0
- package/versioning.d.ts +666 -0
- package/versioning.js +1 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small value helpers shared by the versioning runtime. This directory is the
|
|
3
|
+
* `@alexify/migronaut/versioning` subpath: it may require its siblings and
|
|
4
|
+
* `../errors/index.js` only — never the engine, the driver or mongoose — so an
|
|
5
|
+
* application's repository layer pays for nothing else (pinned by a test).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const isPlainObject = (value) => {
|
|
9
|
+
if (value === null || typeof value !== 'object') return false;
|
|
10
|
+
const proto = Object.getPrototypeOf(value);
|
|
11
|
+
return proto === Object.prototype || proto === null;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** A BSON value class from the driver (`Int32`, `Long`, `ObjectId`, …) */
|
|
15
|
+
const bsonType = (value) =>
|
|
16
|
+
value !== null && typeof value === 'object' && typeof value._bsontype === 'string'
|
|
17
|
+
? value._bsontype
|
|
18
|
+
: undefined;
|
|
19
|
+
|
|
20
|
+
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A non-negative safe integer from whatever the driver handed back — a plain
|
|
24
|
+
* number, a `bigint` (`useBigInt64`), an `Int32`, a `Double` or a `Long`
|
|
25
|
+
* (`promoteValues: false`) — or `null` when the value is not one.
|
|
26
|
+
*/
|
|
27
|
+
function toCount(value) {
|
|
28
|
+
if (typeof value === 'number') {
|
|
29
|
+
return Number.isSafeInteger(value) && value >= 0 ? value : null;
|
|
30
|
+
}
|
|
31
|
+
if (typeof value === 'bigint') {
|
|
32
|
+
return value >= 0n && value <= MAX_SAFE ? Number(value) : null;
|
|
33
|
+
}
|
|
34
|
+
const type = bsonType(value);
|
|
35
|
+
if (type === 'Int32' || type === 'Double') return toCount(Number(value.valueOf()));
|
|
36
|
+
if (type === 'Long') {
|
|
37
|
+
if (value.isNegative()) return null;
|
|
38
|
+
const number = value.toNumber();
|
|
39
|
+
return Number.isSafeInteger(number) ? number : null;
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const bytesEqual = (a, b) => {
|
|
45
|
+
if (a.length !== b.length) return false;
|
|
46
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false;
|
|
47
|
+
return true;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const binaryBytes = (value) =>
|
|
51
|
+
value.buffer.subarray(0, typeof value.position === 'number' ? value.position : undefined);
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Two BSON values of the same class. `undefined` means "cannot tell" — the
|
|
55
|
+
* caller then treats them as different, which costs one rewritten field and
|
|
56
|
+
* never loses a change.
|
|
57
|
+
*/
|
|
58
|
+
function sameBsonValue(type, a, b) {
|
|
59
|
+
switch (type) {
|
|
60
|
+
case 'ObjectId':
|
|
61
|
+
case 'ObjectID':
|
|
62
|
+
return a.toHexString() === b.toHexString();
|
|
63
|
+
case 'Long':
|
|
64
|
+
case 'Timestamp':
|
|
65
|
+
return a.equals(b) && a.unsigned === b.unsigned;
|
|
66
|
+
case 'Int32':
|
|
67
|
+
case 'Double':
|
|
68
|
+
return Object.is(Number(a.valueOf()), Number(b.valueOf()));
|
|
69
|
+
case 'Decimal128':
|
|
70
|
+
return bytesEqual(a.bytes, b.bytes);
|
|
71
|
+
case 'Binary':
|
|
72
|
+
case 'UUID':
|
|
73
|
+
return a.sub_type === b.sub_type && bytesEqual(binaryBytes(a), binaryBytes(b));
|
|
74
|
+
case 'BSONRegExp':
|
|
75
|
+
return a.pattern === b.pattern && a.options === b.options;
|
|
76
|
+
case 'BSONSymbol':
|
|
77
|
+
return String(a.valueOf()) === String(b.valueOf());
|
|
78
|
+
case 'MinKey':
|
|
79
|
+
case 'MaxKey':
|
|
80
|
+
return true;
|
|
81
|
+
case 'Code':
|
|
82
|
+
return a.code === b.code && sameValue(a.scope ?? null, b.scope ?? null);
|
|
83
|
+
case 'DBRef':
|
|
84
|
+
return (
|
|
85
|
+
a.collection === b.collection &&
|
|
86
|
+
a.db === b.db &&
|
|
87
|
+
sameValue(a.oid, b.oid) &&
|
|
88
|
+
sameValue(a.fields ?? {}, b.fields ?? {})
|
|
89
|
+
);
|
|
90
|
+
default:
|
|
91
|
+
return undefined;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Whether two document values would serialize to the same BSON. Key order
|
|
97
|
+
* counts (`{a, b}` and `{b, a}` are different documents to the server), a
|
|
98
|
+
* `Date` compares by time, a RegExp by source and flags, and a BSON class by
|
|
99
|
+
* its own contents — `Double 1` and `Int32 1` are *different*, because
|
|
100
|
+
* keeping the stored type is the point. Anything this cannot judge is
|
|
101
|
+
* "different": a spurious difference rewrites one field, a spurious sameness
|
|
102
|
+
* would drop a change.
|
|
103
|
+
*/
|
|
104
|
+
function sameValue(a, b) {
|
|
105
|
+
if (a === b) return a !== 0 || Object.is(a, b);
|
|
106
|
+
if (typeof a !== typeof b) return false;
|
|
107
|
+
if (typeof a === 'number') return Number.isNaN(a) && Number.isNaN(b);
|
|
108
|
+
if (a === null || b === null || typeof a !== 'object') return false;
|
|
109
|
+
const typeA = bsonType(a);
|
|
110
|
+
const typeB = bsonType(b);
|
|
111
|
+
if (typeA !== undefined || typeB !== undefined) {
|
|
112
|
+
return typeA === typeB && sameBsonValue(typeA, a, b) === true;
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(a)) {
|
|
115
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
116
|
+
for (let i = 0; i < a.length; i++) if (!sameValue(a[i], b[i])) return false;
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(b)) return false;
|
|
120
|
+
if (a instanceof Date) return b instanceof Date && Object.is(a.getTime(), b.getTime());
|
|
121
|
+
if (a instanceof RegExp) {
|
|
122
|
+
return b instanceof RegExp && a.source === b.source && a.flags === b.flags;
|
|
123
|
+
}
|
|
124
|
+
if (ArrayBuffer.isView(a)) {
|
|
125
|
+
return ArrayBuffer.isView(b) && a.constructor === b.constructor && bytesEqual(a, b);
|
|
126
|
+
}
|
|
127
|
+
if (!isPlainObject(a) || !isPlainObject(b)) return false;
|
|
128
|
+
const keysA = Object.keys(a);
|
|
129
|
+
const keysB = Object.keys(b);
|
|
130
|
+
if (keysA.length !== keysB.length) return false;
|
|
131
|
+
for (let i = 0; i < keysA.length; i++) {
|
|
132
|
+
const key = keysA[i];
|
|
133
|
+
if (key !== keysB[i] || !sameValue(a[key], b[key])) return false;
|
|
134
|
+
}
|
|
135
|
+
return true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* A copy a transformation may mutate freely: plain objects and arrays are
|
|
140
|
+
* copied all the way down, a `Date` is cloned, and every other value — BSON
|
|
141
|
+
* classes included, which a structured clone would strip of their prototype
|
|
142
|
+
* — is shared.
|
|
143
|
+
*/
|
|
144
|
+
function cloneDocument(value) {
|
|
145
|
+
if (Array.isArray(value)) {
|
|
146
|
+
const out = new Array(value.length);
|
|
147
|
+
for (let i = 0; i < value.length; i++) out[i] = cloneDocument(value[i]);
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
if (value instanceof Date) return new Date(value.getTime());
|
|
151
|
+
if (!isPlainObject(value)) return value;
|
|
152
|
+
const out = {};
|
|
153
|
+
for (const key of Object.keys(value)) setOwn(out, key, cloneDocument(value[key]));
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `target[key] = value` as an own, enumerable property — `__proto__` too,
|
|
159
|
+
* which a plain assignment would turn into the object's prototype (a stored
|
|
160
|
+
* field of that name is data, as the BSON parser reads it).
|
|
161
|
+
*/
|
|
162
|
+
function setOwn(target, key, value) {
|
|
163
|
+
if (key === '__proto__') {
|
|
164
|
+
Object.defineProperty(target, key, {
|
|
165
|
+
value,
|
|
166
|
+
enumerable: true,
|
|
167
|
+
writable: true,
|
|
168
|
+
configurable: true,
|
|
169
|
+
});
|
|
170
|
+
} else {
|
|
171
|
+
target[key] = value;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* A collection definition as a module may hand it over: an ES-module
|
|
177
|
+
* namespace (`import * as orders`, or `require` of an ES module) carries it
|
|
178
|
+
* as its `default`.
|
|
179
|
+
*/
|
|
180
|
+
function unwrapDefinition(value) {
|
|
181
|
+
return isPlainObject(value?.default) ? value.default : value;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** `a.b.c` → `a`; `$[]`-style paths keep their first segment too */
|
|
185
|
+
const topField = (path) => {
|
|
186
|
+
const dot = path.indexOf('.');
|
|
187
|
+
return dot === -1 ? path : path.slice(0, dot);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const PIPELINE_FIELD_STAGES = new Set(['$set', '$addFields']);
|
|
191
|
+
const PIPELINE_WHOLE_STAGES = new Set(['$project', '$replaceRoot', '$replaceWith']);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The top-level fields an update writes: `{ fields, whole }`, where `whole`
|
|
195
|
+
* means it may rewrite the entire document (a replacement, or a pipeline that
|
|
196
|
+
* projects or replaces the root) and `fields` lists the ones it names.
|
|
197
|
+
*/
|
|
198
|
+
function touchedFields(update) {
|
|
199
|
+
const fields = new Set();
|
|
200
|
+
if (Array.isArray(update)) {
|
|
201
|
+
let whole = false;
|
|
202
|
+
for (const stage of update) {
|
|
203
|
+
if (!isPlainObject(stage)) continue;
|
|
204
|
+
for (const [operator, spec] of Object.entries(stage)) {
|
|
205
|
+
if (PIPELINE_FIELD_STAGES.has(operator) && isPlainObject(spec)) {
|
|
206
|
+
for (const key of Object.keys(spec)) fields.add(topField(key));
|
|
207
|
+
} else if (operator === '$unset') {
|
|
208
|
+
for (const key of Array.isArray(spec) ? spec : [spec]) {
|
|
209
|
+
if (typeof key === 'string') fields.add(topField(key));
|
|
210
|
+
}
|
|
211
|
+
} else if (PIPELINE_WHOLE_STAGES.has(operator)) {
|
|
212
|
+
whole = true;
|
|
213
|
+
if (operator === '$project' && isPlainObject(spec)) {
|
|
214
|
+
for (const key of Object.keys(spec)) fields.add(topField(key));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { fields, whole };
|
|
220
|
+
}
|
|
221
|
+
if (!isPlainObject(update)) return { fields, whole: false };
|
|
222
|
+
// One walk: a key without `$` makes it a replacement document, whose own
|
|
223
|
+
// keys are then the fields; operator specs name theirs.
|
|
224
|
+
const plain = new Set();
|
|
225
|
+
for (const [key, spec] of Object.entries(update)) {
|
|
226
|
+
if (!key.startsWith('$')) {
|
|
227
|
+
plain.add(key);
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (!isPlainObject(spec)) continue;
|
|
231
|
+
for (const [path, value] of Object.entries(spec)) {
|
|
232
|
+
fields.add(topField(path));
|
|
233
|
+
if (key === '$rename' && typeof value === 'string') fields.add(topField(value));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return plain.size > 0 ? { fields: plain, whole: true } : { fields, whole: false };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const LOGICAL = new Set(['$and', '$or', '$nor']);
|
|
240
|
+
|
|
241
|
+
/** `"$__rev"` or `"$__rev.x"` inside a serialized `$expr` — not `"$__revision"` */
|
|
242
|
+
const references = new Map();
|
|
243
|
+
function fieldReference(field) {
|
|
244
|
+
// Built once per field: every guarded write asks.
|
|
245
|
+
let pattern = references.get(field);
|
|
246
|
+
if (pattern === undefined) {
|
|
247
|
+
pattern = new RegExp(`"\\$${field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:"|\\.)`);
|
|
248
|
+
references.set(field, pattern);
|
|
249
|
+
}
|
|
250
|
+
return pattern;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** Whether a query filter constrains `field` (at the top level, or through `$and`/`$or`/`$nor`) */
|
|
254
|
+
function filterTouches(filter, field) {
|
|
255
|
+
if (!isPlainObject(filter)) return false;
|
|
256
|
+
for (const [key, value] of Object.entries(filter)) {
|
|
257
|
+
if (LOGICAL.has(key) && Array.isArray(value)) {
|
|
258
|
+
for (const branch of value) if (filterTouches(branch, field)) return true;
|
|
259
|
+
} else if (key === '$expr') {
|
|
260
|
+
if (fieldReference(field).test(JSON.stringify(value ?? null))) return true;
|
|
261
|
+
} else if (!key.startsWith('$') && topField(key) === field) {
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return false;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
module.exports = {
|
|
269
|
+
bsonType,
|
|
270
|
+
cloneDocument,
|
|
271
|
+
filterTouches,
|
|
272
|
+
isPlainObject,
|
|
273
|
+
sameValue,
|
|
274
|
+
setOwn,
|
|
275
|
+
toCount,
|
|
276
|
+
topField,
|
|
277
|
+
touchedFields,
|
|
278
|
+
unwrapDefinition,
|
|
279
|
+
};
|
|
@@ -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 };
|