@alexify/migronaut 2.1.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.
- package/CHANGELOG.md +223 -0
- package/README.md +68 -10
- package/bullmq.d.ts +465 -7
- package/index.d.ts +1272 -18
- package/migronaut.schema.json +150 -2
- package/package.json +8 -2
- package/src/bullmq/background-processor.js +469 -0
- package/src/bullmq/index.js +12 -0
- package/src/bullmq/jobs.js +254 -7
- package/src/bullmq/processor.js +153 -15
- package/src/bullmq/producer.js +202 -27
- package/src/bullmq/service.js +480 -45
- package/src/cli/commands/background.js +500 -0
- package/src/cli/commands/converge.js +38 -10
- 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/cli/table.js +68 -9
- package/src/core/audit.js +98 -3
- package/src/core/background-audit.js +139 -0
- package/src/core/background-drift.js +126 -0
- package/src/core/background-dry-run.js +366 -0
- package/src/core/background-engine.js +818 -0
- package/src/core/background-kit.js +425 -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 +605 -0
- package/src/core/background.js +1121 -0
- package/src/core/bson-peer.js +23 -0
- package/src/core/changelog.js +32 -0
- package/src/core/collections.js +125 -31
- package/src/core/config.js +133 -13
- package/src/core/converge-plan.js +343 -61
- package/src/core/converge-search-run.js +440 -0
- package/src/core/converge-search.js +404 -0
- package/src/core/converge.js +428 -183
- package/src/core/index-spec.js +27 -16
- package/src/core/lock.js +97 -32
- package/src/core/migrator.js +951 -26
- package/src/core/options.js +32 -1
- package/src/core/run.js +26 -12
- package/src/core/runner.js +1 -1
- package/src/core/search-index-spec.js +758 -0
- package/src/core/server-info.js +70 -0
- package/src/core/shard-info.js +76 -0
- package/src/core/versioning-spec.js +181 -0
- package/src/errors/index.js +97 -5
- package/src/index.js +16 -0
- package/src/utils/canonical.js +34 -1
- package/src/utils/error.js +11 -2
- package/src/utils/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/telemetry.js +125 -1
- package/src/utils/template.js +69 -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,326 @@
|
|
|
1
|
+
const { ConfigInvalidError, ShapeVersionError } = require('../errors/index.js');
|
|
2
|
+
const { VERSIONING_DEFAULTS, fieldNameIssue, versioningOf } = require('./config.js');
|
|
3
|
+
const {
|
|
4
|
+
cloneDocument,
|
|
5
|
+
isPlainObject,
|
|
6
|
+
sameValue,
|
|
7
|
+
setOwn,
|
|
8
|
+
toCount,
|
|
9
|
+
touchedFields,
|
|
10
|
+
} = require('./internal.js');
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The document-level contract shared by everything that writes a versioned
|
|
14
|
+
* collection — the repository helpers, the Mongoose plugin and the
|
|
15
|
+
* background-migration engine. They must agree on one model, or a background
|
|
16
|
+
* update would silently overwrite an application write:
|
|
17
|
+
*
|
|
18
|
+
* - a missing (or `null`) version or revision field reads as **0**, so legacy
|
|
19
|
+
* documents need no backfill before the first guarded write;
|
|
20
|
+
* - every write to a collection with revisions bumps the revision by one
|
|
21
|
+
* (`$inc`), so an optimistic-concurrency filter `{ _id, rev }` notices any
|
|
22
|
+
* write that happened in between;
|
|
23
|
+
* - the version is set to an exact number, never incremented — two releases
|
|
24
|
+
* writing the same shape agree on it.
|
|
25
|
+
*
|
|
26
|
+
* `names` below is `{ field, revisionField }` from `resolveFieldNames` (or a
|
|
27
|
+
* resolved `versioning`); `revisionField` is `null` when revisions are off.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The system field names, validated: `{ field, revisionField }` with the
|
|
32
|
+
* defaults filled in and `revisionField: null` under `revision: false`.
|
|
33
|
+
*
|
|
34
|
+
* @throws {ConfigInvalidError} on an invalid name, or both naming one field
|
|
35
|
+
*/
|
|
36
|
+
function resolveFieldNames({ field, revisionField, revision } = {}) {
|
|
37
|
+
const versionName = field ?? VERSIONING_DEFAULTS.field;
|
|
38
|
+
const revisionName =
|
|
39
|
+
revision === false ? null : (revisionField ?? VERSIONING_DEFAULTS.revisionField);
|
|
40
|
+
for (const [key, name] of [
|
|
41
|
+
['field', versionName],
|
|
42
|
+
['revisionField', revisionName],
|
|
43
|
+
]) {
|
|
44
|
+
if (name === null) continue;
|
|
45
|
+
const issue = fieldNameIssue(name);
|
|
46
|
+
if (issue) throw new ConfigInvalidError(`${key} ${issue}`, { key });
|
|
47
|
+
}
|
|
48
|
+
if (versionName === revisionName) {
|
|
49
|
+
throw new ConfigInvalidError('field and revisionField must differ', { key: 'revisionField' });
|
|
50
|
+
}
|
|
51
|
+
return { field: versionName, revisionField: revisionName };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A document's count field: 0 when missing or `null`, `null` when not a non-negative integer */
|
|
55
|
+
function countOf(doc, field) {
|
|
56
|
+
if (!isPlainObject(doc)) return null;
|
|
57
|
+
const value = doc[field];
|
|
58
|
+
return value === undefined || value === null ? 0 : toCount(value);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** The shape version of `doc` — 0 when the field is missing, `null` when it is not a count */
|
|
62
|
+
const versionOf = (doc, field = VERSIONING_DEFAULTS.field) => countOf(doc, field);
|
|
63
|
+
|
|
64
|
+
/** The revision of `doc` — 0 when the field is missing, `null` when it is not a count */
|
|
65
|
+
const revisionOf = (doc, revisionField = VERSIONING_DEFAULTS.revisionField) =>
|
|
66
|
+
countOf(doc, revisionField);
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Whether `doc` is at exactly `version` — a missing or `null` field is
|
|
70
|
+
* version 0. A document that is not a plain object (a hydrated Mongoose
|
|
71
|
+
* document) is refused: its fields are not its own properties, and a silent
|
|
72
|
+
* `false` would read as "an old shape".
|
|
73
|
+
*/
|
|
74
|
+
function isVersion(doc, version, { field = VERSIONING_DEFAULTS.field } = {}) {
|
|
75
|
+
if (doc === null || doc === undefined) return false;
|
|
76
|
+
assertPlainDocument(doc);
|
|
77
|
+
return versionOf(doc, field) === version;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A plain document, or a ConfigInvalidError that says how to get one */
|
|
81
|
+
function assertPlainDocument(doc) {
|
|
82
|
+
if (!isPlainObject(doc)) {
|
|
83
|
+
throw new ConfigInvalidError(
|
|
84
|
+
'A plain document is expected — read it with .lean(), or call .toObject() on a Mongoose ' +
|
|
85
|
+
'document',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The revision field alone, validated (`__rev` by default) — for a write
|
|
92
|
+
* that never sets the version, whose field name it then has no use for: a
|
|
93
|
+
* caller naming only its revision field (`__v`, as a Mongoose app may keep
|
|
94
|
+
* it) is not refused over a version field it never touches.
|
|
95
|
+
*/
|
|
96
|
+
function resolveRevisionField(revisionField) {
|
|
97
|
+
const name = revisionField ?? VERSIONING_DEFAULTS.revisionField;
|
|
98
|
+
const issue = fieldNameIssue(name);
|
|
99
|
+
if (issue) throw new ConfigInvalidError(`revisionField ${issue}`, { key: 'revisionField' });
|
|
100
|
+
return name;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The revision after one more write */
|
|
104
|
+
const nextRevision = (revision) => (toCount(revision) ?? 0) + 1;
|
|
105
|
+
|
|
106
|
+
function assertCount(value, what) {
|
|
107
|
+
if (!Number.isSafeInteger(value) || value < 0) {
|
|
108
|
+
throw new ConfigInvalidError(`${what} must be an integer ≥ 0`, { [what]: value });
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** `{ [field]: n }`, or — for 0 — a match on the field being 0, `null` or missing */
|
|
113
|
+
function countFilter(field, value) {
|
|
114
|
+
return value === 0 ? { [field]: { $in: [null, 0] } } : { [field]: value };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Documents at exactly `version` (0 = no version field) */
|
|
118
|
+
function versionFilter(names, version) {
|
|
119
|
+
assertCount(version, 'version');
|
|
120
|
+
return countFilter(names.field, version);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Documents at exactly `revision` (0 = no revision field) */
|
|
124
|
+
function revisionFilter(names, revision) {
|
|
125
|
+
assertCount(revision, 'revision');
|
|
126
|
+
return countFilter(names.revisionField, revision);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Documents below `version`: missing or `null` fields, smaller numbers, and
|
|
131
|
+
* anything that is not a number at all (which no validator would accept
|
|
132
|
+
* either). One `$not` range — two intervals of one index scan — rather than
|
|
133
|
+
* an `$or`. `null` for version 0: nothing is below it.
|
|
134
|
+
*/
|
|
135
|
+
function belowVersionFilter(names, version) {
|
|
136
|
+
assertCount(version, 'version');
|
|
137
|
+
return version === 0 ? null : { [names.field]: { $not: { $gte: version } } };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** The key of the index every version-filtered scan uses */
|
|
141
|
+
const versionIndexKey = (names) => ({ [names.field]: 1, _id: 1 });
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The optimistic-concurrency filter for rewriting `prev`: its `_id`, the
|
|
145
|
+
* exact version it was read at (`from`, by default its own) and — with
|
|
146
|
+
* revisions — the exact revision. A concurrent write moves the revision (or
|
|
147
|
+
* the version), so the rewrite then matches nothing instead of losing it.
|
|
148
|
+
*/
|
|
149
|
+
function occFilter(prev, names, { from } = {}) {
|
|
150
|
+
const version = from ?? versionOf(prev, names.field);
|
|
151
|
+
const filter = { _id: prev._id };
|
|
152
|
+
// A value that is not a count is matched as it is stored — `$eq`, so a
|
|
153
|
+
// stored object with `$`-keys is never read as an operator.
|
|
154
|
+
if (version === null) filter[names.field] = { $eq: prev[names.field] };
|
|
155
|
+
else Object.assign(filter, countFilter(names.field, version));
|
|
156
|
+
if (names.revisionField !== null) {
|
|
157
|
+
const revision = revisionOf(prev, names.revisionField);
|
|
158
|
+
if (revision === null) filter[names.revisionField] = { $eq: prev[names.revisionField] };
|
|
159
|
+
else Object.assign(filter, countFilter(names.revisionField, revision));
|
|
160
|
+
}
|
|
161
|
+
return filter;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Why `key` cannot be written as a top-level field with `$set`, or `null` */
|
|
165
|
+
function topLevelKeyIssue(key) {
|
|
166
|
+
if (key.length === 0) return 'an empty field name';
|
|
167
|
+
if (key.startsWith('$')) return `a field name starting with '$' ("${key}")`;
|
|
168
|
+
if (key.includes('.')) return `a field name with a dot ("${key}")`;
|
|
169
|
+
if (key.includes('\0')) return 'a field name with NUL';
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function invalidShape(detail, extra) {
|
|
174
|
+
return new ShapeVersionError(`The transformation returned ${detail}`, {
|
|
175
|
+
reason: 'invalid',
|
|
176
|
+
...extra,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const hasKeys = (object) => {
|
|
181
|
+
for (const key in object) if (Object.hasOwn(object, key)) return true;
|
|
182
|
+
return false;
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* The operator update that turns `prev` into `next` and stamps it: `$set`
|
|
187
|
+
* for every top-level field whose BSON value changed, `$unset` for every one
|
|
188
|
+
* that disappeared (or became `undefined`), the version set to `to` (unset
|
|
189
|
+
* for `to: 0`), and the revision bumped.
|
|
190
|
+
*
|
|
191
|
+
* Fields that did not change are left out — so the stored BSON type of
|
|
192
|
+
* everything a transformation did not touch survives (a document read with
|
|
193
|
+
* promoted values and written back whole would turn a `Double 1.0` into an
|
|
194
|
+
* `Int32`, a `Long` into a number). A changed nested field rewrites its
|
|
195
|
+
* whole top-level subdocument. `next`'s own version and revision fields are
|
|
196
|
+
* ignored: the engine owns them.
|
|
197
|
+
*
|
|
198
|
+
* @throws {ShapeVersionError} (`reason: 'invalid'`) when `next` is not a
|
|
199
|
+
* document, changes `_id`, or names a field `$set` cannot write
|
|
200
|
+
*/
|
|
201
|
+
function stampedDiff(prev, next, names, { to }) {
|
|
202
|
+
if (!isPlainObject(next)) throw invalidShape('something that is not a document');
|
|
203
|
+
assertCount(to, 'to');
|
|
204
|
+
const { field, revisionField } = names;
|
|
205
|
+
const $set = {};
|
|
206
|
+
const $unset = {};
|
|
207
|
+
for (const key of Object.keys(next)) {
|
|
208
|
+
if (key === '_id') {
|
|
209
|
+
if (!sameValue(prev._id, next._id)) throw invalidShape('a document with a different _id');
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (key === field || key === revisionField) continue;
|
|
213
|
+
const issue = topLevelKeyIssue(key);
|
|
214
|
+
if (issue) throw invalidShape(issue, { field: key });
|
|
215
|
+
const value = next[key];
|
|
216
|
+
if (value === undefined) {
|
|
217
|
+
if (Object.hasOwn(prev, key)) setOwn($unset, key, '');
|
|
218
|
+
} else if (!Object.hasOwn(prev, key) || !sameValue(prev[key], value)) {
|
|
219
|
+
setOwn($set, key, value);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
for (const key of Object.keys(prev)) {
|
|
223
|
+
if (key === '_id' || key === field || key === revisionField) continue;
|
|
224
|
+
if (!Object.hasOwn(next, key)) setOwn($unset, key, '');
|
|
225
|
+
}
|
|
226
|
+
if (to === 0) $unset[field] = '';
|
|
227
|
+
else $set[field] = to;
|
|
228
|
+
const update = {};
|
|
229
|
+
if (hasKeys($set)) update.$set = $set;
|
|
230
|
+
if (hasKeys($unset)) update.$unset = $unset;
|
|
231
|
+
if (revisionField !== null) update.$inc = { [revisionField]: 1 };
|
|
232
|
+
return update;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function refuseTouch(fields, name, role) {
|
|
236
|
+
if (name !== null && fields.has(name)) {
|
|
237
|
+
throw new ConfigInvalidError(
|
|
238
|
+
`The update must not write the ${role} field "${name}" — migronaut sets it`,
|
|
239
|
+
{ field: name },
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Stamp an update the caller wrote: the version set to `to` (left alone when
|
|
246
|
+
* `to` is `undefined`; unset for 0) and the revision bumped. Works on an
|
|
247
|
+
* operator update (merged into its `$set`/`$unset`/`$inc`) and on a pipeline
|
|
248
|
+
* (one stage appended). A replacement document — or a pipeline that projects
|
|
249
|
+
* or replaces the root — is refused: it would drop the revision.
|
|
250
|
+
*
|
|
251
|
+
* @throws {ConfigInvalidError} on a replacement, or an update that writes a
|
|
252
|
+
* system field itself
|
|
253
|
+
*/
|
|
254
|
+
function stampedUpdate(names, { to, update }) {
|
|
255
|
+
const { field, revisionField } = names;
|
|
256
|
+
if (to !== undefined) assertCount(to, 'to');
|
|
257
|
+
if (!Array.isArray(update) && !isPlainObject(update)) {
|
|
258
|
+
throw new ConfigInvalidError('The update must be an update document or a pipeline');
|
|
259
|
+
}
|
|
260
|
+
const touched = touchedFields(update);
|
|
261
|
+
if (touched.whole) {
|
|
262
|
+
throw new ConfigInvalidError(
|
|
263
|
+
Array.isArray(update)
|
|
264
|
+
? 'A pipeline that projects or replaces the document cannot keep its revision'
|
|
265
|
+
: 'A replacement document is not an update — use replaceWithRevision',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
refuseTouch(touched.fields, revisionField, 'revision');
|
|
269
|
+
if (to !== undefined) refuseTouch(touched.fields, field, 'version');
|
|
270
|
+
|
|
271
|
+
if (Array.isArray(update)) {
|
|
272
|
+
const stage = {};
|
|
273
|
+
if (to !== undefined && to !== 0) stage[field] = { $literal: to };
|
|
274
|
+
if (revisionField !== null) {
|
|
275
|
+
stage[revisionField] = { $add: [{ $ifNull: [`$${revisionField}`, 0] }, 1] };
|
|
276
|
+
}
|
|
277
|
+
const out = [...update];
|
|
278
|
+
if (hasKeys(stage)) out.push({ $set: stage });
|
|
279
|
+
if (to === 0) out.push({ $unset: field });
|
|
280
|
+
return out;
|
|
281
|
+
}
|
|
282
|
+
const out = { ...update };
|
|
283
|
+
if (to === 0) out.$unset = { ...update.$unset, [field]: '' };
|
|
284
|
+
else if (to !== undefined) out.$set = { ...update.$set, [field]: to };
|
|
285
|
+
if (revisionField !== null) out.$inc = { ...update.$inc, [revisionField]: 1 };
|
|
286
|
+
return out;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* `doc` with its version set to `version` and its revision to 0 — each only
|
|
291
|
+
* when the document does not carry it yet, so stamping is idempotent and a
|
|
292
|
+
* document someone already stamped keeps its fields.
|
|
293
|
+
*/
|
|
294
|
+
function stampDocument(doc, names, version) {
|
|
295
|
+
if (!isPlainObject(doc)) throw new ConfigInvalidError('Only a plain document can be stamped');
|
|
296
|
+
assertCount(version, 'version');
|
|
297
|
+
const out = { ...doc };
|
|
298
|
+
if (out[names.field] === undefined) out[names.field] = version;
|
|
299
|
+
if (names.revisionField !== null && out[names.revisionField] === undefined) {
|
|
300
|
+
out[names.revisionField] = 0;
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
module.exports = {
|
|
306
|
+
assertPlainDocument,
|
|
307
|
+
belowVersionFilter,
|
|
308
|
+
cloneDocument,
|
|
309
|
+
fieldNameIssue,
|
|
310
|
+
isVersion,
|
|
311
|
+
nextRevision,
|
|
312
|
+
occFilter,
|
|
313
|
+
refuseTouch,
|
|
314
|
+
resolveFieldNames,
|
|
315
|
+
resolveRevisionField,
|
|
316
|
+
revisionFilter,
|
|
317
|
+
revisionOf,
|
|
318
|
+
sameValue,
|
|
319
|
+
stampDocument,
|
|
320
|
+
stampedDiff,
|
|
321
|
+
stampedUpdate,
|
|
322
|
+
versionFilter,
|
|
323
|
+
versionIndexKey,
|
|
324
|
+
versionOf,
|
|
325
|
+
versioningOf,
|
|
326
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
const {
|
|
2
|
+
ConfigInvalidError,
|
|
3
|
+
MigronautError,
|
|
4
|
+
RevisionConflictError,
|
|
5
|
+
ShapeVersionError,
|
|
6
|
+
} = require('../errors/index.js');
|
|
7
|
+
const {
|
|
8
|
+
bumpRevision,
|
|
9
|
+
findOneAndUpdateWithRevision,
|
|
10
|
+
replaceWithRevision,
|
|
11
|
+
retryOnConflict,
|
|
12
|
+
updateWithRevision,
|
|
13
|
+
} = require('./occ.js');
|
|
14
|
+
const { isVersion } = require('./document.js');
|
|
15
|
+
const { versioningPlugin } = require('./mongoose.js');
|
|
16
|
+
const { defineShapes } = require('./registry.js');
|
|
17
|
+
const { upcaster } = require('./upcaster.js');
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* `@alexify/migronaut/versioning` — document versioning and optimistic
|
|
21
|
+
* concurrency for an application's repository layer.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately small: it requires nothing but its own modules and the error
|
|
24
|
+
* classes — not the migration engine, not the driver, not mongoose — so a
|
|
25
|
+
* service that only reads and writes documents pays for nothing else. The
|
|
26
|
+
* error classes are the package root's own, so `instanceof` agrees whichever
|
|
27
|
+
* entry point a caller imports them from.
|
|
28
|
+
*/
|
|
29
|
+
module.exports = {
|
|
30
|
+
// The application's view of its versioned collections
|
|
31
|
+
defineShapes,
|
|
32
|
+
upcaster,
|
|
33
|
+
isVersion,
|
|
34
|
+
|
|
35
|
+
// Optimistic concurrency
|
|
36
|
+
updateWithRevision,
|
|
37
|
+
replaceWithRevision,
|
|
38
|
+
findOneAndUpdateWithRevision,
|
|
39
|
+
retryOnConflict,
|
|
40
|
+
bumpRevision,
|
|
41
|
+
|
|
42
|
+
// Mongoose
|
|
43
|
+
versioningPlugin,
|
|
44
|
+
|
|
45
|
+
// Errors — the same classes the package root exports
|
|
46
|
+
MigronautError,
|
|
47
|
+
ConfigInvalidError,
|
|
48
|
+
RevisionConflictError,
|
|
49
|
+
ShapeVersionError,
|
|
50
|
+
};
|
|
@@ -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
|
+
};
|