@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
|
@@ -29,4 +29,36 @@ function assertMigrationName(name, context = {}) {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Why a file's `requires` export is not valid: an array of bare migration
|
|
34
|
+
* file names, no duplicates, each sorting strictly before the file itself
|
|
35
|
+
* (`name`). Files run in name order, so an edge that only ever points
|
|
36
|
+
* backwards can never close a cycle — the whole "is it a DAG?" question,
|
|
37
|
+
* answered by the name. Returns `{ path, message }` issues.
|
|
38
|
+
*/
|
|
39
|
+
function requiresIssues(requires, name) {
|
|
40
|
+
if (requires === undefined) return [];
|
|
41
|
+
if (!Array.isArray(requires)) {
|
|
42
|
+
return [{ path: 'requires', message: 'must be an array of migration file names' }];
|
|
43
|
+
}
|
|
44
|
+
const issues = [];
|
|
45
|
+
const seen = new Set();
|
|
46
|
+
for (const [position, required] of requires.entries()) {
|
|
47
|
+
const path = `requires[${position}]`;
|
|
48
|
+
if (!isBareFilename(required)) {
|
|
49
|
+
issues.push({ path, message: 'must be a bare migration file name' });
|
|
50
|
+
} else if (seen.has(required)) {
|
|
51
|
+
issues.push({ path, message: `names "${required}" twice` });
|
|
52
|
+
} else if (name !== undefined && required >= name) {
|
|
53
|
+
issues.push({
|
|
54
|
+
path,
|
|
55
|
+
message: `must name an earlier migration ("${required}" does not sort before "${name}")`,
|
|
56
|
+
});
|
|
57
|
+
} else {
|
|
58
|
+
seen.add(required);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return issues;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { assertMigrationName, isBareFilename, requiresIssues };
|
package/src/utils/telemetry.js
CHANGED
|
@@ -13,6 +13,8 @@ const SPAN_STATUS_ERROR = 2;
|
|
|
13
13
|
const SPANS = {
|
|
14
14
|
RUN: 'migronaut.run',
|
|
15
15
|
MIGRATION: 'migronaut.migration',
|
|
16
|
+
BACKGROUND_SLICE: 'migronaut.background.slice',
|
|
17
|
+
BACKGROUND_COORDINATE: 'migronaut.background.coordinate',
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
/** Every attribute key migronaut sets, on spans and on metric points */
|
|
@@ -28,12 +30,18 @@ const ATTRIBUTES = {
|
|
|
28
30
|
LOCK_SKIPPED: 'migronaut.lock.skipped',
|
|
29
31
|
LOCK_LOST_REASON: 'migronaut.lock.lost_reason',
|
|
30
32
|
LOCK_WAIT_OUTCOME: 'migronaut.lock.wait.outcome',
|
|
33
|
+
SEARCH_WAIT_OUTCOME: 'migronaut.converge.search.wait.outcome',
|
|
31
34
|
MIGRATION_NAME: 'migronaut.migration.name',
|
|
32
35
|
MIGRATION_DIRECTION: 'migronaut.migration.direction',
|
|
33
36
|
MIGRATION_BATCH: 'migronaut.migration.batch',
|
|
34
37
|
MIGRATION_INDEX: 'migronaut.migration.index',
|
|
35
38
|
MIGRATION_TOTAL: 'migronaut.migration.total',
|
|
36
39
|
MIGRATION_TRANSACTION: 'migronaut.migration.transaction',
|
|
40
|
+
BACKGROUND_NAME: 'migronaut.background.name',
|
|
41
|
+
BACKGROUND_OUTCOME: 'migronaut.background.outcome',
|
|
42
|
+
BACKGROUND_RESULT: 'migronaut.background.result',
|
|
43
|
+
BACKGROUND_REASON: 'migronaut.background.reason',
|
|
44
|
+
BACKGROUND_SHARD: 'migronaut.background.shard',
|
|
37
45
|
ERROR_TYPE: 'error.type',
|
|
38
46
|
/** The database a run is against — OpenTelemetry's database semantic convention */
|
|
39
47
|
DB_NAMESPACE: 'db.namespace',
|
|
@@ -46,6 +54,15 @@ const METRICS = {
|
|
|
46
54
|
LOCK_WAIT_DURATION: 'migronaut.lock.wait.duration',
|
|
47
55
|
LOCK_REFUSED: 'migronaut.lock.refused',
|
|
48
56
|
LOCK_LOST: 'migronaut.lock.lost',
|
|
57
|
+
SEARCH_WAIT_DURATION: 'migronaut.converge.search.wait.duration',
|
|
58
|
+
BACKGROUND_DOCUMENTS: 'migronaut.background.documents',
|
|
59
|
+
BACKGROUND_SLICE_DURATION: 'migronaut.background.slice.duration',
|
|
60
|
+
BACKGROUND_BATCH_WRITE_DURATION: 'migronaut.background.batch.write.duration',
|
|
61
|
+
BACKGROUND_THROTTLE: 'migronaut.background.throttled',
|
|
62
|
+
BACKGROUND_DRIFT: 'migronaut.background.drift.detected',
|
|
63
|
+
BACKGROUND_TRANSACTION_RETRIES: 'migronaut.background.transaction.retried',
|
|
64
|
+
BACKGROUND_LEASES_RECLAIMED: 'migronaut.background.leases.reclaimed',
|
|
65
|
+
BACKGROUND_WATCH_DELAY: 'migronaut.background.watch.delay',
|
|
49
66
|
};
|
|
50
67
|
|
|
51
68
|
/**
|
|
@@ -130,6 +147,9 @@ function failureText(error) {
|
|
|
130
147
|
|
|
131
148
|
/** The parts of `telemetry` — anything else in it is a typo, mentioned at debug level */
|
|
132
149
|
const TELEMETRY_KEYS = Object.freeze(['tracer', 'meter', 'attributes']);
|
|
150
|
+
|
|
151
|
+
/** The types a static attribute's value may have */
|
|
152
|
+
const ATTRIBUTE_VALUE_TYPES = new Set(['string', 'number', 'boolean']);
|
|
133
153
|
/** Static attributes are dimensions: a handful at most */
|
|
134
154
|
const MAX_STATIC_ATTRIBUTES = 20;
|
|
135
155
|
|
|
@@ -184,7 +204,7 @@ function telemetryIssues(telemetry) {
|
|
|
184
204
|
}
|
|
185
205
|
for (const key of keys) {
|
|
186
206
|
const value = attributes[key];
|
|
187
|
-
if (!
|
|
207
|
+
if (!ATTRIBUTE_VALUE_TYPES.has(typeof value)) {
|
|
188
208
|
issues.push({
|
|
189
209
|
path: `telemetry.attributes.${key}`,
|
|
190
210
|
message: 'must be a string, a number or a boolean',
|
|
@@ -329,6 +349,51 @@ function createTelemetry(telemetry, { dbName } = {}) {
|
|
|
329
349
|
'{refusal}',
|
|
330
350
|
);
|
|
331
351
|
const lockLost = counter(METRICS.LOCK_LOST, 'Migration locks lost mid-run', '{loss}');
|
|
352
|
+
const searchWaitDuration = histogram(
|
|
353
|
+
METRICS.SEARCH_WAIT_DURATION,
|
|
354
|
+
'Time a converge waited for its search index builds, by how the wait ended',
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
const backgroundDocuments = counter(
|
|
358
|
+
METRICS.BACKGROUND_DOCUMENTS,
|
|
359
|
+
'Documents a background migration handled, by result (migrated, skipped, conflict, failed)',
|
|
360
|
+
'{document}',
|
|
361
|
+
);
|
|
362
|
+
const backgroundSliceDuration = histogram(
|
|
363
|
+
METRICS.BACKGROUND_SLICE_DURATION,
|
|
364
|
+
'Duration of one background migration slice, by outcome',
|
|
365
|
+
);
|
|
366
|
+
const backgroundBatchWrite = histogram(
|
|
367
|
+
METRICS.BACKGROUND_BATCH_WRITE_DURATION,
|
|
368
|
+
'Time to write one background migration batch',
|
|
369
|
+
);
|
|
370
|
+
const backgroundThrottle = counter(
|
|
371
|
+
METRICS.BACKGROUND_THROTTLE,
|
|
372
|
+
'Adaptive throttle changes of background migrations, by reason',
|
|
373
|
+
'{change}',
|
|
374
|
+
);
|
|
375
|
+
const backgroundDrift = counter(
|
|
376
|
+
METRICS.BACKGROUND_DRIFT,
|
|
377
|
+
'Old-shape documents found after a background migration completed',
|
|
378
|
+
'{finding}',
|
|
379
|
+
);
|
|
380
|
+
const backgroundTransactionRetries = counter(
|
|
381
|
+
METRICS.BACKGROUND_TRANSACTION_RETRIES,
|
|
382
|
+
'Transactional background batches retried, by reason',
|
|
383
|
+
'{retry}',
|
|
384
|
+
);
|
|
385
|
+
const backgroundLeasesReclaimed = counter(
|
|
386
|
+
METRICS.BACKGROUND_LEASES_RECLAIMED,
|
|
387
|
+
'Partition leases reclaimed from a lane that stopped renewing',
|
|
388
|
+
'{lease}',
|
|
389
|
+
);
|
|
390
|
+
const backgroundWatchDelay = histogram(
|
|
391
|
+
METRICS.BACKGROUND_WATCH_DELAY,
|
|
392
|
+
'Time from an old-shape write to its upgrade by the live drift watcher',
|
|
393
|
+
);
|
|
394
|
+
const add = (instrument, value, attributes) => {
|
|
395
|
+
if (instrument && value > 0) safe(() => instrument.add(value, withBase(attributes)));
|
|
396
|
+
};
|
|
332
397
|
|
|
333
398
|
// Durations are measured in milliseconds everywhere in migronaut and
|
|
334
399
|
// reported in seconds, the unit OpenTelemetry's conventions settle on.
|
|
@@ -374,6 +439,65 @@ function createTelemetry(telemetry, { dbName } = {}) {
|
|
|
374
439
|
lockLost() {
|
|
375
440
|
increment(lockLost);
|
|
376
441
|
},
|
|
442
|
+
/**
|
|
443
|
+
* A converge's wait for search index builds ended — `outcome` is
|
|
444
|
+
* `'ready'`, `'failed'`, `'timeout'`, `'unreadable'` or `'aborted'`. One
|
|
445
|
+
* point per wait, however many polls.
|
|
446
|
+
*/
|
|
447
|
+
searchWaited({ waitedMs, outcome }) {
|
|
448
|
+
record(searchWaitDuration, waitedMs, { [ATTRIBUTES.SEARCH_WAIT_OUTCOME]: outcome });
|
|
449
|
+
},
|
|
450
|
+
/**
|
|
451
|
+
* A background migration slice ended: its duration by outcome, and the
|
|
452
|
+
* documents it handled by result. The partition is never an attribute —
|
|
453
|
+
* dimensions stay low-cardinality.
|
|
454
|
+
*/
|
|
455
|
+
backgroundSliceEnded({ name, durationMs, outcome, counters = {}, error }) {
|
|
456
|
+
const at = { [ATTRIBUTES.BACKGROUND_NAME]: name };
|
|
457
|
+
record(backgroundSliceDuration, durationMs, {
|
|
458
|
+
...at,
|
|
459
|
+
[ATTRIBUTES.BACKGROUND_OUTCOME]: outcome,
|
|
460
|
+
...failure(error),
|
|
461
|
+
});
|
|
462
|
+
for (const [key, result] of [
|
|
463
|
+
['migrated', 'migrated'],
|
|
464
|
+
['skipped', 'skipped'],
|
|
465
|
+
['conflicts', 'conflict'],
|
|
466
|
+
['failed', 'failed'],
|
|
467
|
+
]) {
|
|
468
|
+
add(backgroundDocuments, counters[key] ?? 0, {
|
|
469
|
+
...at,
|
|
470
|
+
[ATTRIBUTES.BACKGROUND_RESULT]: result,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
},
|
|
474
|
+
backgroundBatchWritten({ name, durationMs, shard }) {
|
|
475
|
+
record(backgroundBatchWrite, durationMs, {
|
|
476
|
+
[ATTRIBUTES.BACKGROUND_NAME]: name,
|
|
477
|
+
[ATTRIBUTES.BACKGROUND_SHARD]: shard,
|
|
478
|
+
});
|
|
479
|
+
},
|
|
480
|
+
backgroundThrottled({ name, reason }) {
|
|
481
|
+
add(backgroundThrottle, 1, {
|
|
482
|
+
[ATTRIBUTES.BACKGROUND_NAME]: name,
|
|
483
|
+
[ATTRIBUTES.BACKGROUND_REASON]: reason,
|
|
484
|
+
});
|
|
485
|
+
},
|
|
486
|
+
backgroundDrift({ name, count = 1 }) {
|
|
487
|
+
add(backgroundDrift, count, { [ATTRIBUTES.BACKGROUND_NAME]: name });
|
|
488
|
+
},
|
|
489
|
+
backgroundTransactionRetried({ name, reason, count = 1 }) {
|
|
490
|
+
add(backgroundTransactionRetries, count, {
|
|
491
|
+
[ATTRIBUTES.BACKGROUND_NAME]: name,
|
|
492
|
+
[ATTRIBUTES.BACKGROUND_REASON]: reason,
|
|
493
|
+
});
|
|
494
|
+
},
|
|
495
|
+
backgroundLeasesReclaimed({ name, count }) {
|
|
496
|
+
add(backgroundLeasesReclaimed, count, { [ATTRIBUTES.BACKGROUND_NAME]: name });
|
|
497
|
+
},
|
|
498
|
+
backgroundWatchDelay({ name, delayMs }) {
|
|
499
|
+
record(backgroundWatchDelay, delayMs, { [ATTRIBUTES.BACKGROUND_NAME]: name });
|
|
500
|
+
},
|
|
377
501
|
};
|
|
378
502
|
}
|
|
379
503
|
|
package/src/utils/template.js
CHANGED
|
@@ -145,6 +145,51 @@ module.exports = { description, up, down };
|
|
|
145
145
|
`;
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
/**
|
|
149
|
+
* The built-in background migration template (`create --background`): the
|
|
150
|
+
* declarative form, with the knobs most worth knowing about spelled out.
|
|
151
|
+
*/
|
|
152
|
+
function defaultBackgroundTemplate(js, esm = false) {
|
|
153
|
+
const typed = js
|
|
154
|
+
? "/** @type {import('@alexify/migronaut').DeclarativeBackgroundMigration} */\n"
|
|
155
|
+
: '';
|
|
156
|
+
const typeImport = js
|
|
157
|
+
? ''
|
|
158
|
+
: "import type { DeclarativeBackgroundMigration } from '@alexify/migronaut';\n\n";
|
|
159
|
+
const annotation = js ? '' : ': DeclarativeBackgroundMigration';
|
|
160
|
+
const body = `{
|
|
161
|
+
// The collection to rewrite (\`up\` refuses this placeholder).
|
|
162
|
+
collection: 'TODO',
|
|
163
|
+
// Documents at version \`from\` (0: no version field yet) become version \`to\`.
|
|
164
|
+
from: 1,
|
|
165
|
+
to: 2,
|
|
166
|
+
// The new document for one old one — return it reshaped; migronaut sets the
|
|
167
|
+
// version, bumps the revision and writes only the fields that changed.
|
|
168
|
+
migrate: (doc) => {
|
|
169
|
+
// TODO: reshape doc, and return it
|
|
170
|
+
throw new Error('migrate is not written yet');
|
|
171
|
+
},
|
|
172
|
+
// The way back, for \`down\`. Without one, \`down\` refuses once documents
|
|
173
|
+
// were rewritten. Never \`(doc) => doc\`: that would stamp the old version
|
|
174
|
+
// on documents still in the new shape.
|
|
175
|
+
// revert: ({ shipping, ...doc }) => ({ ...doc, address: shipping.address }),
|
|
176
|
+
// Partitions worked at once, across every process (default 1).
|
|
177
|
+
// maxParallel: 4,
|
|
178
|
+
}`;
|
|
179
|
+
if (esm) {
|
|
180
|
+
return `${typeImport}export const description = '';
|
|
181
|
+
|
|
182
|
+
${typed}export const background${annotation} = ${body};
|
|
183
|
+
`;
|
|
184
|
+
}
|
|
185
|
+
return `${typeImport}const description = '';
|
|
186
|
+
|
|
187
|
+
${typed}const background${annotation} = ${body};
|
|
188
|
+
|
|
189
|
+
module.exports = { description, background };
|
|
190
|
+
`;
|
|
191
|
+
}
|
|
192
|
+
|
|
148
193
|
/** Extensions a custom `--template` file may have — anything else is refused */
|
|
149
194
|
const TEMPLATE_EXTENSIONS = ['.ts', '.js', '.cjs', '.mjs'];
|
|
150
195
|
|
|
@@ -152,7 +197,8 @@ const TEMPLATE_EXTENSIONS = ['.ts', '.js', '.cjs', '.mjs'];
|
|
|
152
197
|
const MAX_TEMPLATE_BYTES = 1024 * 1024;
|
|
153
198
|
|
|
154
199
|
/** Resolve template file contents — a custom template if provided, else the built-in */
|
|
155
|
-
async function resolveTemplateContent(templatePath, js, esm = false) {
|
|
200
|
+
async function resolveTemplateContent(templatePath, js, esm = false, background = false) {
|
|
201
|
+
if (background) return defaultBackgroundTemplate(js, esm);
|
|
156
202
|
if (templatePath) {
|
|
157
203
|
const ext = path.extname(templatePath);
|
|
158
204
|
if (!TEMPLATE_EXTENSIONS.includes(ext)) {
|
|
@@ -198,6 +244,7 @@ async function createMigrationFile(options) {
|
|
|
198
244
|
// Match the project's module system, so a generated migration never makes
|
|
199
245
|
// Node reparse it and warn.
|
|
200
246
|
await isEsmProject(options.dir),
|
|
247
|
+
options.background === true,
|
|
201
248
|
);
|
|
202
249
|
try {
|
|
203
250
|
// 'wx' fails if the path exists — creating a migration must never silently
|
|
@@ -358,6 +405,27 @@ function configBody(values, createExtension) {
|
|
|
358
405
|
// collectionsDir: './collections',
|
|
359
406
|
// Converge at the end of every bulk \`migronaut up\`.
|
|
360
407
|
// convergeAfterUp: false,
|
|
408
|
+
// Search indexes declared for a server without Atlas Search: refuse the
|
|
409
|
+
// converge ('fail'), or converge everything else and skip them ('skip').
|
|
410
|
+
// onSearchUnavailable: 'fail',
|
|
411
|
+
// Hold converge until every declared search index is queryable — for at
|
|
412
|
+
// most searchIndexWaitTimeoutMs (10 minutes by default).
|
|
413
|
+
// waitForSearchIndexes: false,
|
|
414
|
+
// searchIndexWaitTimeoutMs: 600000,
|
|
415
|
+
|
|
416
|
+
// ── Background migrations (experimental) ────────────────────
|
|
417
|
+
// A migration file with \`export const background = {…}\` is registered by
|
|
418
|
+
// \`up\` and runs in partitions (BullMQ, \`migronaut background run\`, or an
|
|
419
|
+
// in-process runner) without holding the migration lock.
|
|
420
|
+
// backgroundCollection: '_migronaut_background',
|
|
421
|
+
// Run it to the end inside the \`up\` that registers it instead.
|
|
422
|
+
// backgroundInline: false,
|
|
423
|
+
// Old-shape documents after it completed: reopen it ('reopen') or report.
|
|
424
|
+
// backgroundOnDrift: 'reopen',
|
|
425
|
+
// How drift is watched: 'poll' (every 10 minutes), 'stream', or 'both'.
|
|
426
|
+
// backgroundDrift: 'poll',
|
|
427
|
+
// Partition a sharded collection by its shard key ('auto') or not ('off').
|
|
428
|
+
// backgroundShardAware: 'auto',
|
|
361
429
|
|
|
362
430
|
// ── Lifecycle hooks (code only — not available in JSON config) ──
|
|
363
431
|
// hooks: {
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
const { ConfigInvalidError } = require('../errors/index.js');
|
|
2
|
+
const { isPlainObject } = require('./internal.js');
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `versioning` block of a collection definition — one source of truth for
|
|
6
|
+
* the shape version a collection is at, read by converge (validator, index,
|
|
7
|
+
* the `min` guard), by the background-migration engine and by the
|
|
8
|
+
* application's repository layer through `defineShapes`.
|
|
9
|
+
*
|
|
10
|
+
* Validated strictly, like every other definition key: a typo (`currnet`)
|
|
11
|
+
* must not read as "not versioned".
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const VERSIONING_KEYS = ['current', 'min', 'field', 'revision', 'revisionField', 'index'];
|
|
15
|
+
const VERSIONING_KEY_SET = new Set(VERSIONING_KEYS);
|
|
16
|
+
|
|
17
|
+
const VERSIONING_DEFAULTS = Object.freeze({
|
|
18
|
+
min: 1,
|
|
19
|
+
field: '__v',
|
|
20
|
+
revision: true,
|
|
21
|
+
revisionField: '__rev',
|
|
22
|
+
index: true,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
/** Longest field name accepted for the version or revision field */
|
|
26
|
+
const MAX_FIELD_NAME = 64;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Why `name` cannot be a system field, or `null`. Top-level only: the engine
|
|
30
|
+
* writes it with `$set`/`$inc`, so a dot would address a nested path and a
|
|
31
|
+
* leading `$` an operator; `_id` is immutable.
|
|
32
|
+
*/
|
|
33
|
+
function fieldNameIssue(name) {
|
|
34
|
+
if (typeof name !== 'string' || name.length === 0) return 'must be a non-empty string';
|
|
35
|
+
if (name.length > MAX_FIELD_NAME) return `must be at most ${MAX_FIELD_NAME} characters`;
|
|
36
|
+
if (name.startsWith('$')) return "must not start with '$'";
|
|
37
|
+
if (name.includes('.')) return "must be a top-level field (no '.')";
|
|
38
|
+
if (name.includes('\0')) return 'must not contain NUL';
|
|
39
|
+
if (name === '_id') return 'must not be _id';
|
|
40
|
+
// As a JavaScript key it names the prototype: every write of it would be lost.
|
|
41
|
+
if (name === '__proto__') return 'must not be __proto__';
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const isCount = (value, floor) => Number.isSafeInteger(value) && value >= floor;
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Validate a `versioning` value, returning `{ path, message }` issues (empty
|
|
49
|
+
* when valid). `path` is where it sits — `collections[2].versioning`,
|
|
50
|
+
* `orders.ts: versioning`.
|
|
51
|
+
*/
|
|
52
|
+
function versioningIssues(value, path = 'versioning') {
|
|
53
|
+
if (!isPlainObject(value)) {
|
|
54
|
+
return [
|
|
55
|
+
{
|
|
56
|
+
path,
|
|
57
|
+
message: 'must be an object: { current, min?, field?, revision?, revisionField?, index? }',
|
|
58
|
+
},
|
|
59
|
+
];
|
|
60
|
+
}
|
|
61
|
+
const issues = [];
|
|
62
|
+
const report = (key, message) => issues.push({ path: `${path}.${key}`, message });
|
|
63
|
+
for (const key of Object.keys(value)) {
|
|
64
|
+
if (!VERSIONING_KEY_SET.has(key)) {
|
|
65
|
+
report(key, `is not a versioning key (expected one of: ${VERSIONING_KEYS.join(', ')})`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const { current, min, field, revision, revisionField, index } = value;
|
|
69
|
+
const currentValid = isCount(current, 1);
|
|
70
|
+
if (current === undefined) report('current', 'is required');
|
|
71
|
+
else if (!currentValid) report('current', 'must be an integer ≥ 1');
|
|
72
|
+
// The default min (1) never exceeds a valid current (≥ 1).
|
|
73
|
+
if (min !== undefined) {
|
|
74
|
+
if (!isCount(min, 0)) report('min', 'must be an integer ≥ 0');
|
|
75
|
+
else if (currentValid && min > current) {
|
|
76
|
+
report('min', `must not exceed current (${current})`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (field !== undefined) {
|
|
80
|
+
const issue = fieldNameIssue(field);
|
|
81
|
+
if (issue) report('field', issue);
|
|
82
|
+
}
|
|
83
|
+
if (revision !== undefined && typeof revision !== 'boolean') {
|
|
84
|
+
report('revision', 'must be a boolean');
|
|
85
|
+
}
|
|
86
|
+
if (revisionField !== undefined) {
|
|
87
|
+
const issue = fieldNameIssue(revisionField);
|
|
88
|
+
if (issue) report('revisionField', issue);
|
|
89
|
+
else if (revision === false) report('revisionField', 'has no effect with revision: false');
|
|
90
|
+
}
|
|
91
|
+
if (revision !== false) {
|
|
92
|
+
const versionName = field ?? VERSIONING_DEFAULTS.field;
|
|
93
|
+
const revisionName = revisionField ?? VERSIONING_DEFAULTS.revisionField;
|
|
94
|
+
if (versionName === revisionName) {
|
|
95
|
+
report('revisionField', `must differ from the version field ("${versionName}")`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (index !== undefined && typeof index !== 'boolean') report('index', 'must be a boolean');
|
|
99
|
+
return issues;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A validated `versioning` value with every default filled in, frozen.
|
|
104
|
+
* `revisionField` is `null` when revisions are off.
|
|
105
|
+
*
|
|
106
|
+
* @throws {ConfigInvalidError} with every issue in `context.issues`
|
|
107
|
+
*/
|
|
108
|
+
function resolveVersioning(value, { path = 'versioning' } = {}) {
|
|
109
|
+
const issues = versioningIssues(value, path);
|
|
110
|
+
if (issues.length > 0) {
|
|
111
|
+
throw new ConfigInvalidError(`Invalid ${path}: ${issues[0].path} ${issues[0].message}`, {
|
|
112
|
+
issues,
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
const revision = value.revision ?? VERSIONING_DEFAULTS.revision;
|
|
116
|
+
return Object.freeze({
|
|
117
|
+
current: value.current,
|
|
118
|
+
min: value.min ?? VERSIONING_DEFAULTS.min,
|
|
119
|
+
field: value.field ?? VERSIONING_DEFAULTS.field,
|
|
120
|
+
revision,
|
|
121
|
+
revisionField: revision ? (value.revisionField ?? VERSIONING_DEFAULTS.revisionField) : null,
|
|
122
|
+
index: value.index ?? VERSIONING_DEFAULTS.index,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The resolved versioning of `collection` among `definitions` — an array of
|
|
128
|
+
* definitions (each with `name`) or a `{ name: definition }` map — or `null`
|
|
129
|
+
* when it is not declared or not versioned.
|
|
130
|
+
*/
|
|
131
|
+
function versioningOf(definitions, collection) {
|
|
132
|
+
let definition;
|
|
133
|
+
if (Array.isArray(definitions)) {
|
|
134
|
+
for (const candidate of definitions) {
|
|
135
|
+
if (isPlainObject(candidate) && candidate.name === collection) {
|
|
136
|
+
definition = candidate;
|
|
137
|
+
break;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
} else if (isPlainObject(definitions) && Object.hasOwn(definitions, collection)) {
|
|
141
|
+
definition = definitions[collection];
|
|
142
|
+
}
|
|
143
|
+
if (!isPlainObject(definition) || definition.versioning === undefined) return null;
|
|
144
|
+
return resolveVersioning(definition.versioning, { path: `${collection}.versioning` });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
MAX_FIELD_NAME,
|
|
149
|
+
VERSIONING_DEFAULTS,
|
|
150
|
+
VERSIONING_KEYS,
|
|
151
|
+
fieldNameIssue,
|
|
152
|
+
resolveVersioning,
|
|
153
|
+
versioningIssues,
|
|
154
|
+
versioningOf,
|
|
155
|
+
};
|