@alexify/migronaut 2.0.0 → 2.1.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 +320 -0
- package/README.md +208 -6
- package/bullmq.d.ts +845 -0
- package/bullmq.js +1 -0
- package/index.d.ts +634 -18
- package/migronaut.schema.json +182 -1
- package/package.json +21 -5
- package/src/bullmq/index.js +55 -0
- package/src/bullmq/jobs.js +454 -0
- package/src/bullmq/processor.js +608 -0
- package/src/bullmq/producer.js +424 -0
- package/src/bullmq/service.js +653 -0
- package/src/bullmq/wait.js +124 -0
- package/src/cli/args.js +12 -2
- package/src/cli/commands/converge.js +160 -0
- package/src/cli/commands/down.js +2 -0
- package/src/cli/commands/lock.js +2 -1
- package/src/cli/commands/redo.js +8 -1
- package/src/cli/commands/up.js +14 -1
- package/src/cli/exit-codes.js +9 -2
- package/src/cli/index.js +2 -0
- package/src/cli/shared.js +14 -4
- package/src/cli/table.js +105 -0
- package/src/core/changelog.js +71 -6
- package/src/core/collections.js +372 -0
- package/src/core/config.js +100 -25
- package/src/core/converge-log.js +47 -0
- package/src/core/converge-plan.js +483 -0
- package/src/core/converge.js +867 -0
- package/src/core/index-spec.js +496 -0
- package/src/core/lock-wait.js +260 -0
- package/src/core/lock.js +45 -16
- package/src/core/migrator.js +563 -283
- package/src/core/options.js +251 -0
- package/src/core/run-recorder.js +157 -0
- package/src/core/run.js +58 -90
- package/src/core/sequence.js +134 -0
- package/src/errors/index.js +56 -0
- package/src/index.js +8 -0
- package/src/utils/actor.js +48 -0
- package/src/utils/canonical.js +179 -0
- package/src/utils/collection-name.js +21 -0
- package/src/utils/error.js +18 -1
- package/src/utils/id.js +77 -0
- package/src/utils/loader.js +39 -21
- package/src/utils/migration-name.js +32 -0
- package/src/utils/redact.js +21 -1
- package/src/utils/telemetry.js +393 -0
- package/src/utils/template.js +36 -2
package/src/core/changelog.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const { actorFields } = require('../utils/actor.js');
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* Reads and writes migration records in the changelog collection
|
|
3
5
|
* (`_migronaut_migrations` by default).
|
|
@@ -90,6 +92,23 @@ class Changelog {
|
|
|
90
92
|
return names;
|
|
91
93
|
}
|
|
92
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Which of `names` carry a `'failed'` trace — what tells a migration that
|
|
97
|
+
* failed (the line is stopped) from one that simply has not run yet (it may
|
|
98
|
+
* be in flight elsewhere). Served by the `status_name` index.
|
|
99
|
+
*/
|
|
100
|
+
async getFailedNames(db, names) {
|
|
101
|
+
if (names.length === 0) return [];
|
|
102
|
+
const docs = await this.#coll(db)
|
|
103
|
+
.find({ status: 'failed', name: { $in: names } })
|
|
104
|
+
.sort({ name: 1 })
|
|
105
|
+
.project({ name: 1, _id: 0 })
|
|
106
|
+
.toArray();
|
|
107
|
+
const failed = [];
|
|
108
|
+
for (const doc of docs) failed.push(doc.name);
|
|
109
|
+
return failed;
|
|
110
|
+
}
|
|
111
|
+
|
|
93
112
|
/** Return a single record by migration name, or null */
|
|
94
113
|
async getByName(db, name) {
|
|
95
114
|
return this.#coll(db).findOne({ name });
|
|
@@ -135,6 +154,29 @@ class Changelog {
|
|
|
135
154
|
.toArray();
|
|
136
155
|
}
|
|
137
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Applied records that were applied *after* `record`, newest first — the
|
|
159
|
+
* revert order `down --steps` uses (`appliedAt`, name-desc tiebreak). An
|
|
160
|
+
* `ordered` rollback refuses while any exist: undoing effects is only safe in
|
|
161
|
+
* reverse of the order they were made. A record with no `appliedAt` (a
|
|
162
|
+
* hand-edited or legacy document) treats every other applied record as
|
|
163
|
+
* newer — the conservative answer.
|
|
164
|
+
*/
|
|
165
|
+
async getAppliedNewerThan(db, { appliedAt, name }) {
|
|
166
|
+
const filter =
|
|
167
|
+
appliedAt instanceof Date
|
|
168
|
+
? {
|
|
169
|
+
status: 'applied',
|
|
170
|
+
$or: [{ appliedAt: { $gt: appliedAt } }, { appliedAt, name: { $gt: name } }],
|
|
171
|
+
}
|
|
172
|
+
: { status: 'applied', name: { $ne: name } };
|
|
173
|
+
return this.#coll(db)
|
|
174
|
+
.find(filter)
|
|
175
|
+
.sort({ appliedAt: -1, name: -1 })
|
|
176
|
+
.project({ _id: 0, name: 1, appliedAt: 1, batch: 1 })
|
|
177
|
+
.toArray();
|
|
178
|
+
}
|
|
179
|
+
|
|
138
180
|
/** Return the highest batch number among currently-applied migrations, or null */
|
|
139
181
|
async getLastBatch(db) {
|
|
140
182
|
const docs = await this.#coll(db)
|
|
@@ -184,10 +226,22 @@ class Changelog {
|
|
|
184
226
|
const { name, appliedAt, ...fields } = record;
|
|
185
227
|
const update = {
|
|
186
228
|
$set: fields,
|
|
187
|
-
// A re-apply clears the stale revert marker
|
|
188
|
-
// markFailed() from an earlier
|
|
189
|
-
|
|
229
|
+
// A re-apply clears the stale revert marker (and who asked for the
|
|
230
|
+
// revert, and why) — and the failure trace a markFailed() from an earlier
|
|
231
|
+
// crashed attempt may have left.
|
|
232
|
+
$unset: {
|
|
233
|
+
revertedAt: '',
|
|
234
|
+
revertRequestedBy: '',
|
|
235
|
+
revertReason: '',
|
|
236
|
+
failedAt: '',
|
|
237
|
+
error: '',
|
|
238
|
+
},
|
|
190
239
|
};
|
|
240
|
+
// Who asked for this apply, and why — or nobody said: then the previous
|
|
241
|
+
// apply's answer must not linger as if it were this one's.
|
|
242
|
+
for (const key of ['requestedBy', 'reason']) {
|
|
243
|
+
if (fields[key] === undefined) update.$unset[key] = '';
|
|
244
|
+
}
|
|
191
245
|
if (appliedAt !== undefined) {
|
|
192
246
|
update.$set.appliedAt = appliedAt;
|
|
193
247
|
update.$setOnInsert = { firstAppliedAt: appliedAt };
|
|
@@ -248,11 +302,22 @@ class Changelog {
|
|
|
248
302
|
* was no longer `'applied'` (a concurrent peer got there first) — the caller
|
|
249
303
|
* decides what to do with that, since this module stays logger-free.
|
|
250
304
|
*/
|
|
251
|
-
async markReverted(db, name, session) {
|
|
305
|
+
async markReverted(db, name, session, actor = {}) {
|
|
306
|
+
const update = {
|
|
307
|
+
$set: {
|
|
308
|
+
status: 'reverted',
|
|
309
|
+
...actorFields(actor, 'revert'),
|
|
310
|
+
},
|
|
311
|
+
// Server time, like markApplied's appliedAt — one clock for the whole trail.
|
|
312
|
+
$currentDate: { revertedAt: true },
|
|
313
|
+
};
|
|
314
|
+
const unset = {};
|
|
315
|
+
if (actor.requestedBy === undefined) unset.revertRequestedBy = '';
|
|
316
|
+
if (actor.reason === undefined) unset.revertReason = '';
|
|
317
|
+
if (Object.keys(unset).length > 0) update.$unset = unset;
|
|
252
318
|
return this.#coll(db).updateOne(
|
|
253
319
|
{ name, status: 'applied' },
|
|
254
|
-
|
|
255
|
-
{ $set: { status: 'reverted' }, $currentDate: { revertedAt: true } },
|
|
320
|
+
update,
|
|
256
321
|
session ? { session } : {},
|
|
257
322
|
);
|
|
258
323
|
}
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { ConfigInvalidError } = require('../errors/index.js');
|
|
4
|
+
const { isPlainObject, regExpIssue, toWire } = require('../utils/canonical.js');
|
|
5
|
+
const { isCollectionName } = require('../utils/collection-name.js');
|
|
6
|
+
const { mapLimit } = require('../utils/concurrency.js');
|
|
7
|
+
const { errorText } = require('../utils/error.js');
|
|
8
|
+
const { importUserFile, tsLoadMessageOrNull } = require('../utils/loader.js');
|
|
9
|
+
const { indexIssues, normalizeDeclaredIndex, sameDeclaredSignature } = require('./index-spec.js');
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Declared collections: validating a definition, normalizing it for the
|
|
13
|
+
* planner, and gathering definitions from their two sources — the
|
|
14
|
+
* `collections` config key and the files in `collectionsDir`.
|
|
15
|
+
*
|
|
16
|
+
* Knows nothing about the database. Definitions are validated strictly: an
|
|
17
|
+
* unknown key is an error rather than ignored, because every typo here
|
|
18
|
+
* (`indexs`, `validtor`) would otherwise read as "not managed" and silently
|
|
19
|
+
* leave the database alone.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Every key a collection definition may carry */
|
|
23
|
+
const DEFINITION_KEYS = [
|
|
24
|
+
'name',
|
|
25
|
+
'indexes',
|
|
26
|
+
'validator',
|
|
27
|
+
'validationLevel',
|
|
28
|
+
'validationAction',
|
|
29
|
+
'prune',
|
|
30
|
+
];
|
|
31
|
+
const DEFINITION_KEY_SET = new Set(DEFINITION_KEYS);
|
|
32
|
+
const VALIDATION_LEVELS = ['off', 'strict', 'moderate'];
|
|
33
|
+
const VALIDATION_ACTIONS = ['error', 'warn', 'errorAndLog'];
|
|
34
|
+
|
|
35
|
+
/** Simultaneous definition-file loads — the same EMFILE bound as every other multi-file path */
|
|
36
|
+
const FS_CONCURRENCY = 16;
|
|
37
|
+
|
|
38
|
+
/** `collections[2]` + `indexes` → `collections[2].indexes`; `users.ts:` + `indexes` → `users.ts: indexes` */
|
|
39
|
+
function join(base, key) {
|
|
40
|
+
return base.endsWith(':') ? `${base} ${key}` : `${base}.${key}`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What makes a validator unsendable — a function, a symbol, a cycle — or null */
|
|
44
|
+
function unsendable(value, seen = new Set()) {
|
|
45
|
+
if (seen.size === 0) {
|
|
46
|
+
const issue = regExpIssue(value);
|
|
47
|
+
if (issue) return issue;
|
|
48
|
+
}
|
|
49
|
+
const type = typeof value;
|
|
50
|
+
if (type === 'function') return 'must not contain functions';
|
|
51
|
+
if (type === 'symbol') return 'must not contain symbols';
|
|
52
|
+
if (value === null || type !== 'object') return null;
|
|
53
|
+
if (seen.has(value)) return 'must not contain circular references';
|
|
54
|
+
seen.add(value);
|
|
55
|
+
const items = Array.isArray(value) ? value : isPlainObject(value) ? Object.values(value) : [];
|
|
56
|
+
for (const item of items) {
|
|
57
|
+
const reason = unsendable(item, seen);
|
|
58
|
+
if (reason) return reason;
|
|
59
|
+
}
|
|
60
|
+
seen.delete(value);
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const isEmptyObject = (value) => isPlainObject(value) && Object.keys(value).length === 0;
|
|
65
|
+
|
|
66
|
+
function indexListIssues(indexes, base, issues) {
|
|
67
|
+
if (!Array.isArray(indexes)) {
|
|
68
|
+
issues.push({ path: join(base, 'indexes'), message: 'must be an array of index definitions' });
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const valid = [];
|
|
72
|
+
for (const [position, index] of indexes.entries()) {
|
|
73
|
+
const indexPath = `${join(base, 'indexes')}[${position}]`;
|
|
74
|
+
const found = indexIssues(index, indexPath);
|
|
75
|
+
issues.push(...found);
|
|
76
|
+
if (found.length === 0) {
|
|
77
|
+
valid.push({ position, path: indexPath, index: normalizeDeclaredIndex(index) });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (let a = 1; a < valid.length; a++) {
|
|
81
|
+
for (let b = 0; b < a; b++) {
|
|
82
|
+
const later = valid[a];
|
|
83
|
+
const earlier = valid[b];
|
|
84
|
+
let message;
|
|
85
|
+
if (later.index.name === earlier.index.name) {
|
|
86
|
+
message = `has the same name as indexes[${earlier.position}] ("${later.index.name}")`;
|
|
87
|
+
} else if (later.index.isText && earlier.index.isText) {
|
|
88
|
+
message = `is a second text index (after indexes[${earlier.position}]) — a collection has at most one`;
|
|
89
|
+
} else if (sameDeclaredSignature(later.index, earlier.index)) {
|
|
90
|
+
message =
|
|
91
|
+
`has the same key, partialFilterExpression and collation as indexes[${earlier.position}] ` +
|
|
92
|
+
'— the server keeps only one of them';
|
|
93
|
+
}
|
|
94
|
+
if (message) {
|
|
95
|
+
issues.push({ path: later.path, message });
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Validate one collection definition, returning `{ path, message }` issues
|
|
104
|
+
* (empty when valid). `path` prefixes every issue (`collections[2]`, or
|
|
105
|
+
* `users.ts:` for a file); `reserved` are migronaut's own collection names;
|
|
106
|
+
* `fallbackName` is the name a file-based definition gets when it sets none.
|
|
107
|
+
*/
|
|
108
|
+
function definitionIssues(definition, { path: base, reserved = [], fallbackName } = {}) {
|
|
109
|
+
const self = base.endsWith(':') ? base.slice(0, -1) : base;
|
|
110
|
+
if (!isPlainObject(definition)) {
|
|
111
|
+
return [{ path: self, message: 'must be a collection definition object' }];
|
|
112
|
+
}
|
|
113
|
+
const issues = [];
|
|
114
|
+
const report = (key, message) => issues.push({ path: join(base, key), message });
|
|
115
|
+
for (const key of Object.keys(definition)) {
|
|
116
|
+
if (!DEFINITION_KEY_SET.has(key)) {
|
|
117
|
+
report(
|
|
118
|
+
key,
|
|
119
|
+
`is not a collection definition key (expected one of: ${DEFINITION_KEYS.join(', ')})`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const name = definition.name ?? fallbackName;
|
|
125
|
+
const nameSource =
|
|
126
|
+
definition.name === undefined && fallbackName !== undefined ? 'file name' : 'name';
|
|
127
|
+
if (name === undefined) {
|
|
128
|
+
report('name', 'is required');
|
|
129
|
+
} else if (!isCollectionName(name)) {
|
|
130
|
+
report(
|
|
131
|
+
'name',
|
|
132
|
+
nameSource === 'name'
|
|
133
|
+
? "must be a valid collection name (no '$'/NUL, not system.*)"
|
|
134
|
+
: `the file name gives "${name}", which is not a valid collection name — set name explicitly`,
|
|
135
|
+
);
|
|
136
|
+
} else if (reserved.includes(name)) {
|
|
137
|
+
report('name', `"${name}" is one of migronaut's own collections`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (definition.indexes === undefined && definition.validator === undefined) {
|
|
141
|
+
issues.push({
|
|
142
|
+
path: self,
|
|
143
|
+
message: 'declares neither indexes nor a validator — nothing to manage',
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
if (definition.indexes !== undefined) indexListIssues(definition.indexes, base, issues);
|
|
147
|
+
|
|
148
|
+
const { validator } = definition;
|
|
149
|
+
if (validator !== undefined && validator !== null) {
|
|
150
|
+
if (!isPlainObject(validator)) {
|
|
151
|
+
report('validator', 'must be an object (a query or { $jsonSchema }), or null for none');
|
|
152
|
+
} else {
|
|
153
|
+
const reason = unsendable(validator);
|
|
154
|
+
if (reason) report('validator', reason);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
const hasValidator = isPlainObject(validator) && !isEmptyObject(validator);
|
|
158
|
+
for (const [key, allowed] of [
|
|
159
|
+
['validationLevel', VALIDATION_LEVELS],
|
|
160
|
+
['validationAction', VALIDATION_ACTIONS],
|
|
161
|
+
]) {
|
|
162
|
+
const value = definition[key];
|
|
163
|
+
if (value === undefined) continue;
|
|
164
|
+
if (!allowed.includes(value)) {
|
|
165
|
+
report(key, `must be ${allowed.map((item) => `'${item}'`).join(', ')}`);
|
|
166
|
+
} else if (!hasValidator) {
|
|
167
|
+
report(key, 'has no effect without a validator');
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (definition.prune !== undefined && typeof definition.prune !== 'boolean') {
|
|
171
|
+
report('prune', 'must be a boolean');
|
|
172
|
+
}
|
|
173
|
+
return issues;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Validate the `collections` config key: each definition, plus no collection
|
|
178
|
+
* declared twice. `reserved` are the changelog and lock collection names.
|
|
179
|
+
*/
|
|
180
|
+
function collectionsIssues(list, { reserved = [] } = {}) {
|
|
181
|
+
if (list === undefined) return [];
|
|
182
|
+
if (!Array.isArray(list)) {
|
|
183
|
+
return [{ path: 'collections', message: 'must be an array of collection definitions' }];
|
|
184
|
+
}
|
|
185
|
+
const issues = [];
|
|
186
|
+
const seen = new Map();
|
|
187
|
+
for (const [position, definition] of list.entries()) {
|
|
188
|
+
const base = `collections[${position}]`;
|
|
189
|
+
issues.push(...definitionIssues(definition, { path: base, reserved }));
|
|
190
|
+
const name = isPlainObject(definition) ? definition.name : undefined;
|
|
191
|
+
if (typeof name !== 'string') continue;
|
|
192
|
+
if (seen.has(name)) {
|
|
193
|
+
issues.push({
|
|
194
|
+
path: `${base}.name`,
|
|
195
|
+
message: `declares "${name}" again (already collections[${seen.get(name)}])`,
|
|
196
|
+
});
|
|
197
|
+
} else {
|
|
198
|
+
seen.set(name, position);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return issues;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A valid definition in the planner's shape: indexes normalized (effective
|
|
206
|
+
* names, server-form keys, the spec to send), the validator cleaned for the
|
|
207
|
+
* wire. `indexes`/`validator` stay `undefined` when not managed.
|
|
208
|
+
*/
|
|
209
|
+
function normalizeDefinition(definition, { name, source } = {}) {
|
|
210
|
+
const { validator } = definition;
|
|
211
|
+
return {
|
|
212
|
+
name: definition.name ?? name,
|
|
213
|
+
source,
|
|
214
|
+
indexes: definition.indexes?.map((index) => normalizeDeclaredIndex(index)),
|
|
215
|
+
validator: validator === undefined || validator === null ? validator : toWire(validator),
|
|
216
|
+
...(definition.validationLevel !== undefined
|
|
217
|
+
? { validationLevel: definition.validationLevel }
|
|
218
|
+
: {}),
|
|
219
|
+
...(definition.validationAction !== undefined
|
|
220
|
+
? { validationAction: definition.validationAction }
|
|
221
|
+
: {}),
|
|
222
|
+
...(definition.prune !== undefined ? { prune: definition.prune } : {}),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The extensions a definition file may have: `fileExtensions`, dotted, plus `.json` — longest first */
|
|
227
|
+
function definitionExtensions(extensions) {
|
|
228
|
+
const set = new Set(['.json']);
|
|
229
|
+
for (const ext of extensions) set.add(ext.startsWith('.') ? ext : `.${ext}`);
|
|
230
|
+
return [...set].sort((a, b) => b.length - a.length);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Load one definition file. JSON is parsed; anything else is imported, and
|
|
235
|
+
* its default export (or, for an ES module with only named exports, the
|
|
236
|
+
* exports themselves) is the definition. A function is refused rather than
|
|
237
|
+
* called — a Mongoose model is a function, and a definitions directory is
|
|
238
|
+
* exactly where one might be left by mistake.
|
|
239
|
+
*/
|
|
240
|
+
async function loadDefinitionFile(filepath, options) {
|
|
241
|
+
if (filepath.endsWith('.json')) {
|
|
242
|
+
const raw = await fs.readFile(filepath, 'utf8');
|
|
243
|
+
try {
|
|
244
|
+
return JSON.parse(raw);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
throw new ConfigInvalidError(
|
|
247
|
+
'Collection definition file is not valid JSON',
|
|
248
|
+
{ path: filepath, cause: errorText(error) },
|
|
249
|
+
{ cause: error },
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
let mod;
|
|
254
|
+
try {
|
|
255
|
+
mod = await importUserFile(filepath, { reload: options.reload });
|
|
256
|
+
} catch (error) {
|
|
257
|
+
throw new ConfigInvalidError(
|
|
258
|
+
tsLoadMessageOrNull(filepath, error, 'collection definition') ??
|
|
259
|
+
'Collection definition file failed to load',
|
|
260
|
+
{ path: filepath, cause: errorText(error) },
|
|
261
|
+
{ cause: error },
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
const exported = mod.default ?? mod;
|
|
265
|
+
if (typeof exported === 'function') {
|
|
266
|
+
throw new ConfigInvalidError(
|
|
267
|
+
'A collection definition file must export one definition object, not a function',
|
|
268
|
+
{ path: filepath },
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
// A shallow copy: an ES module namespace is an exotic object, not a plain one.
|
|
272
|
+
return isPlainObject(exported) || exported === mod ? { ...exported } : exported;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Load every definition file in `dir`: one collection per file, non-recursive,
|
|
277
|
+
* sorted by file name. Dotfiles and declaration files (`.d.ts`) are skipped,
|
|
278
|
+
* like in the migrations directory. An explicitly configured directory that
|
|
279
|
+
* does not exist is an error, not "no definitions".
|
|
280
|
+
*/
|
|
281
|
+
async function loadCollectionsDir(dir, { extensions, reload = false }) {
|
|
282
|
+
let entries;
|
|
283
|
+
try {
|
|
284
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
285
|
+
} catch (error) {
|
|
286
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
287
|
+
throw new ConfigInvalidError('collectionsDir not found', { path: dir });
|
|
288
|
+
}
|
|
289
|
+
throw new ConfigInvalidError(
|
|
290
|
+
'collectionsDir could not be read',
|
|
291
|
+
{ path: dir, cause: errorText(error) },
|
|
292
|
+
{ cause: error },
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
const accepted = definitionExtensions(extensions);
|
|
296
|
+
const files = [];
|
|
297
|
+
for (const entry of entries) {
|
|
298
|
+
if (!entry.isFile()) continue;
|
|
299
|
+
const file = entry.name;
|
|
300
|
+
if (file.startsWith('.')) continue;
|
|
301
|
+
if (file.endsWith('.d.ts') || file.endsWith('.d.mts') || file.endsWith('.d.cts')) continue;
|
|
302
|
+
const ext = accepted.find(
|
|
303
|
+
(candidate) => file.endsWith(candidate) && file.length > candidate.length,
|
|
304
|
+
);
|
|
305
|
+
if (ext) files.push({ file, name: file.slice(0, -ext.length) });
|
|
306
|
+
}
|
|
307
|
+
files.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
|
|
308
|
+
return mapLimit(files, FS_CONCURRENCY, async ({ file, name }) => {
|
|
309
|
+
const filepath = path.join(dir, file);
|
|
310
|
+
return { file, name, definition: await loadDefinitionFile(filepath, { reload }) };
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Every declared collection, normalized: the `collections` key first (already
|
|
316
|
+
* validated with the rest of the config), then the files in `dir`, if one is
|
|
317
|
+
* configured. A collection declared by both sources — or by two files — is
|
|
318
|
+
* an error: which declaration wins would otherwise depend on load order.
|
|
319
|
+
*
|
|
320
|
+
* @throws {ConfigInvalidError} on a missing directory, a file that does not
|
|
321
|
+
* load, or invalid definitions (all issues at once, in `context.issues`)
|
|
322
|
+
*/
|
|
323
|
+
async function resolveDefinitions({
|
|
324
|
+
inline,
|
|
325
|
+
dir,
|
|
326
|
+
extensions = ['.ts', '.js'],
|
|
327
|
+
reload,
|
|
328
|
+
reserved = [],
|
|
329
|
+
}) {
|
|
330
|
+
const definitions = [];
|
|
331
|
+
const declaredBy = new Map();
|
|
332
|
+
for (const [position, definition] of (inline ?? []).entries()) {
|
|
333
|
+
const source = `collections[${position}]`;
|
|
334
|
+
definitions.push(normalizeDefinition(definition, { source }));
|
|
335
|
+
declaredBy.set(definition.name, source);
|
|
336
|
+
}
|
|
337
|
+
if (dir === undefined) return definitions;
|
|
338
|
+
|
|
339
|
+
const files = await loadCollectionsDir(dir, { extensions, reload });
|
|
340
|
+
const issues = [];
|
|
341
|
+
for (const { file, name: fallbackName, definition } of files) {
|
|
342
|
+
const base = `${file}:`;
|
|
343
|
+
const found = definitionIssues(definition, { path: base, reserved, fallbackName });
|
|
344
|
+
if (found.length > 0) {
|
|
345
|
+
issues.push(...found);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
const name = definition.name ?? fallbackName;
|
|
349
|
+
if (declaredBy.has(name)) {
|
|
350
|
+
issues.push({
|
|
351
|
+
path: join(base, 'name'),
|
|
352
|
+
message: `declares "${name}" again (already ${declaredBy.get(name)})`,
|
|
353
|
+
});
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
declaredBy.set(name, file);
|
|
357
|
+
definitions.push(normalizeDefinition(definition, { name, source: file }));
|
|
358
|
+
}
|
|
359
|
+
if (issues.length > 0) {
|
|
360
|
+
throw new ConfigInvalidError('Invalid collection definition(s)', { path: dir, issues });
|
|
361
|
+
}
|
|
362
|
+
return definitions;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
module.exports = {
|
|
366
|
+
DEFINITION_KEYS,
|
|
367
|
+
collectionsIssues,
|
|
368
|
+
definitionIssues,
|
|
369
|
+
loadCollectionsDir,
|
|
370
|
+
normalizeDefinition,
|
|
371
|
+
resolveDefinitions,
|
|
372
|
+
};
|