@alexify/migronaut 2.2.0 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +107 -0
- package/README.md +33 -2
- package/bullmq.d.ts +449 -6
- package/index.d.ts +1010 -9
- package/migronaut.schema.json +93 -1
- 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 +128 -14
- package/src/bullmq/producer.js +185 -13
- package/src/bullmq/service.js +480 -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 +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 +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/migrator.js +904 -12
- package/src/core/options.js +16 -0
- package/src/core/run.js +26 -12
- package/src/core/runner.js +1 -1
- 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/loader.js +77 -9
- package/src/utils/migration-name.js +33 -1
- package/src/utils/telemetry.js +107 -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
|
@@ -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 */
|
|
@@ -35,6 +37,11 @@ const ATTRIBUTES = {
|
|
|
35
37
|
MIGRATION_INDEX: 'migronaut.migration.index',
|
|
36
38
|
MIGRATION_TOTAL: 'migronaut.migration.total',
|
|
37
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',
|
|
38
45
|
ERROR_TYPE: 'error.type',
|
|
39
46
|
/** The database a run is against — OpenTelemetry's database semantic convention */
|
|
40
47
|
DB_NAMESPACE: 'db.namespace',
|
|
@@ -48,6 +55,14 @@ const METRICS = {
|
|
|
48
55
|
LOCK_REFUSED: 'migronaut.lock.refused',
|
|
49
56
|
LOCK_LOST: 'migronaut.lock.lost',
|
|
50
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',
|
|
51
66
|
};
|
|
52
67
|
|
|
53
68
|
/**
|
|
@@ -339,6 +354,47 @@ function createTelemetry(telemetry, { dbName } = {}) {
|
|
|
339
354
|
'Time a converge waited for its search index builds, by how the wait ended',
|
|
340
355
|
);
|
|
341
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
|
+
};
|
|
397
|
+
|
|
342
398
|
// Durations are measured in milliseconds everywhere in migronaut and
|
|
343
399
|
// reported in seconds, the unit OpenTelemetry's conventions settle on.
|
|
344
400
|
const record = (instrument, durationMs, attributes) => {
|
|
@@ -391,6 +447,57 @@ function createTelemetry(telemetry, { dbName } = {}) {
|
|
|
391
447
|
searchWaited({ waitedMs, outcome }) {
|
|
392
448
|
record(searchWaitDuration, waitedMs, { [ATTRIBUTES.SEARCH_WAIT_OUTCOME]: outcome });
|
|
393
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
|
+
},
|
|
394
501
|
};
|
|
395
502
|
}
|
|
396
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
|
|
@@ -366,6 +413,20 @@ function configBody(values, createExtension) {
|
|
|
366
413
|
// waitForSearchIndexes: false,
|
|
367
414
|
// searchIndexWaitTimeoutMs: 600000,
|
|
368
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',
|
|
429
|
+
|
|
369
430
|
// ── Lifecycle hooks (code only — not available in JSON config) ──
|
|
370
431
|
// hooks: {
|
|
371
432
|
// beforeAll: async (ctx) => {},
|
|
@@ -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
|
+
};
|