@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,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 };
|