@rpcbase/migrations 0.0.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/dist/index.js ADDED
@@ -0,0 +1,1566 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { BSON } from "mongodb";
3
+ import { existsSync, readFileSync } from "node:fs";
4
+ import { dirname, extname, isAbsolute, resolve } from "node:path";
5
+ import { ImportType, initSync, parse } from "es-module-lexer";
6
+ //#region src/canonical.ts
7
+ var normalizeSerializedValue = (value) => {
8
+ if (Array.isArray(value)) return value.map(normalizeSerializedValue);
9
+ if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== void 0).sort(([left], [right]) => left.localeCompare(right)).map(([key, item]) => [key, normalizeSerializedValue(item)]));
10
+ return value;
11
+ };
12
+ var normalizeValue = (value) => normalizeSerializedValue(BSON.EJSON.serialize(value, { relaxed: false }));
13
+ var canonicalStringify = (value) => JSON.stringify(normalizeValue(value));
14
+ var sha256 = (value) => createHash("sha256").update(value).digest("hex");
15
+ var canonicalChecksum = (value) => sha256(canonicalStringify(value));
16
+ //#endregion
17
+ //#region src/resources.ts
18
+ var scopes = /* @__PURE__ */ new Set([
19
+ "global",
20
+ "tenant",
21
+ "filesystem"
22
+ ]);
23
+ var assertName = (value, label) => {
24
+ const normalized = value.trim();
25
+ if (!normalized) throw new Error(`${label} is required`);
26
+ if (normalized.includes("\0")) throw new Error(`${label} cannot contain a null byte`);
27
+ return normalized;
28
+ };
29
+ var assertScope = (scope) => {
30
+ if (!scopes.has(scope)) throw new Error(`Invalid migration scope: ${String(scope)}`);
31
+ };
32
+ var copyDocument = (value) => {
33
+ if (!value) return void 0;
34
+ return structuredClone(value);
35
+ };
36
+ var collectionIdentity$1 = (resource) => `${resource.scope}:${resource.name}`;
37
+ var indexIdentity$1 = (resource) => `${resource.scope}:${resource.collection}:${resource.name}`;
38
+ var validatorIdentity$1 = (resource) => `${resource.scope}:${resource.collection}`;
39
+ var uniqueResources = (resources, identity, label) => {
40
+ const seen = /* @__PURE__ */ new Map();
41
+ for (const resource of resources) {
42
+ const key = identity(resource);
43
+ const canonical = canonicalStringify(resource);
44
+ const previous = seen.get(key);
45
+ if (previous && previous !== canonical) throw new Error(`Conflicting ${label} resource: ${key}`);
46
+ if (previous) throw new Error(`Duplicate ${label} resource: ${key}`);
47
+ seen.set(key, canonical);
48
+ }
49
+ return resources;
50
+ };
51
+ var normalizeCollection = (resource) => {
52
+ assertScope(resource.scope);
53
+ return {
54
+ scope: resource.scope,
55
+ name: assertName(resource.name, "Collection name"),
56
+ ...resource.options ? { options: copyDocument(resource.options) } : {}
57
+ };
58
+ };
59
+ var normalizeIndex = (resource) => {
60
+ assertScope(resource.scope);
61
+ if (Object.keys(resource.key).length === 0) throw new Error("Index key cannot be empty");
62
+ return {
63
+ scope: resource.scope,
64
+ collection: assertName(resource.collection, "Index collection"),
65
+ name: assertName(resource.name, "Index name"),
66
+ key: structuredClone(resource.key),
67
+ ...resource.options ? { options: copyDocument(resource.options) } : {}
68
+ };
69
+ };
70
+ var normalizeSearchIndex = (resource) => {
71
+ assertScope(resource.scope);
72
+ return {
73
+ scope: resource.scope,
74
+ collection: assertName(resource.collection, "Search index collection"),
75
+ name: assertName(resource.name, "Search index name"),
76
+ definition: structuredClone(resource.definition)
77
+ };
78
+ };
79
+ var normalizeValidator = (resource) => {
80
+ assertScope(resource.scope);
81
+ return {
82
+ scope: resource.scope,
83
+ collection: assertName(resource.collection, "Validator collection"),
84
+ validator: structuredClone(resource.validator),
85
+ ...resource.validationLevel ? { validationLevel: resource.validationLevel } : {},
86
+ ...resource.validationAction ? { validationAction: resource.validationAction } : {}
87
+ };
88
+ };
89
+ var sortByIdentity = (resources, identity) => [...resources].sort((left, right) => identity(left).localeCompare(identity(right)));
90
+ var checksumInput = (resources) => ({
91
+ collections: resources.collections.map((resource) => ({
92
+ ...resource,
93
+ options: resource.options ?? {}
94
+ })),
95
+ indexes: resources.indexes.map((resource) => ({
96
+ ...resource,
97
+ key: Object.entries(resource.key),
98
+ options: resource.options ?? {}
99
+ })),
100
+ searchIndexes: resources.searchIndexes,
101
+ collectionValidators: resources.collectionValidators
102
+ });
103
+ var defineMongoResources = (input = {}) => {
104
+ const resources = {
105
+ collections: sortByIdentity(uniqueResources((input.collections ?? []).map(normalizeCollection), collectionIdentity$1, "collection"), collectionIdentity$1),
106
+ indexes: sortByIdentity(uniqueResources((input.indexes ?? []).map(normalizeIndex), indexIdentity$1, "index"), indexIdentity$1),
107
+ searchIndexes: sortByIdentity(uniqueResources((input.searchIndexes ?? []).map(normalizeSearchIndex), indexIdentity$1, "Search index"), indexIdentity$1),
108
+ collectionValidators: sortByIdentity(uniqueResources((input.collectionValidators ?? []).map(normalizeValidator), validatorIdentity$1, "collection validator"), validatorIdentity$1)
109
+ };
110
+ return Object.freeze({
111
+ checksum: canonicalChecksum(checksumInput(resources)),
112
+ ...resources
113
+ });
114
+ };
115
+ var filterMongoResources = (resources, scope) => defineMongoResources({
116
+ collections: resources.collections.filter((resource) => resource.scope === scope),
117
+ indexes: resources.indexes.filter((resource) => resource.scope === scope),
118
+ searchIndexes: resources.searchIndexes.filter((resource) => resource.scope === scope),
119
+ collectionValidators: resources.collectionValidators.filter((resource) => resource.scope === scope)
120
+ });
121
+ var mergeMongoResources = (resourceSets) => {
122
+ const collections = /* @__PURE__ */ new Map();
123
+ const indexes = /* @__PURE__ */ new Map();
124
+ const searchIndexes = /* @__PURE__ */ new Map();
125
+ const validators = /* @__PURE__ */ new Map();
126
+ const merge = (target, resource, identity, label) => {
127
+ const key = identity(resource);
128
+ const previous = target.get(key);
129
+ if (previous && canonicalStringify(previous) !== canonicalStringify(resource)) throw new Error(`Conflicting ${label} resource across migration sources: ${key}`);
130
+ target.set(key, resource);
131
+ };
132
+ for (const resources of resourceSets) {
133
+ for (const resource of resources.collections) merge(collections, resource, collectionIdentity$1, "collection");
134
+ for (const resource of resources.indexes) merge(indexes, resource, indexIdentity$1, "index");
135
+ for (const resource of resources.searchIndexes) merge(searchIndexes, resource, indexIdentity$1, "Search index");
136
+ for (const resource of resources.collectionValidators) merge(validators, resource, validatorIdentity$1, "collection validator");
137
+ }
138
+ return defineMongoResources({
139
+ collections: [...collections.values()],
140
+ indexes: [...indexes.values()],
141
+ searchIndexes: [...searchIndexes.values()],
142
+ collectionValidators: [...validators.values()]
143
+ });
144
+ };
145
+ //#endregion
146
+ //#region src/diffResources.ts
147
+ var collectionIdentity = (resource) => `${resource.scope}:${resource.name}`;
148
+ var indexIdentity = (resource) => `${resource.scope}:${resource.collection}:${resource.name}`;
149
+ var validatorIdentity = (resource) => `${resource.scope}:${resource.collection}`;
150
+ var indexComparisonValue = (resource) => ({
151
+ ...resource,
152
+ key: Object.entries(resource.key)
153
+ });
154
+ var diffSet = (before, after, identity, comparisonValue = (resource) => resource) => {
155
+ const beforeById = new Map(before.map((resource) => [identity(resource), resource]));
156
+ const afterById = new Map(after.map((resource) => [identity(resource), resource]));
157
+ return [.../* @__PURE__ */ new Set([...beforeById.keys(), ...afterById.keys()])].sort().flatMap((id) => {
158
+ const previous = beforeById.get(id);
159
+ const next = afterById.get(id);
160
+ if (!previous && next) return [{
161
+ kind: "added",
162
+ after: next
163
+ }];
164
+ if (previous && !next) return [{
165
+ kind: "removed",
166
+ before: previous
167
+ }];
168
+ if (previous && next && canonicalStringify(comparisonValue(previous)) !== canonicalStringify(comparisonValue(next))) return [{
169
+ kind: "changed",
170
+ before: previous,
171
+ after: next
172
+ }];
173
+ return [];
174
+ });
175
+ };
176
+ var diffMongoResources = (before, after) => {
177
+ const collections = diffSet(before.collections, after.collections, collectionIdentity);
178
+ const indexes = diffSet(before.indexes, after.indexes, indexIdentity, indexComparisonValue);
179
+ const searchIndexes = diffSet(before.searchIndexes, after.searchIndexes, indexIdentity);
180
+ const collectionValidators = diffSet(before.collectionValidators, after.collectionValidators, validatorIdentity);
181
+ const changes = [
182
+ ...collections,
183
+ ...indexes,
184
+ ...searchIndexes,
185
+ ...collectionValidators
186
+ ];
187
+ const offline = changes.some((change) => change.kind !== "added");
188
+ return {
189
+ collections,
190
+ indexes,
191
+ searchIndexes,
192
+ collectionValidators,
193
+ changed: changes.length > 0,
194
+ mode: offline ? "offline" : "online"
195
+ };
196
+ };
197
+ //#endregion
198
+ //#region src/registry.ts
199
+ var migrationIdPattern = /^\d{8}(?:\d{6})?-[a-z0-9]+(?:-[a-z0-9]+)*$/;
200
+ var sourceNamePattern = /^(?:@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$/;
201
+ var scopeRank = {
202
+ global: 0,
203
+ tenant: 1,
204
+ filesystem: 2
205
+ };
206
+ var assertMigration = (migration) => {
207
+ if (!migrationIdPattern.test(migration.id)) throw new Error(`Invalid migration id "${migration.id}"`);
208
+ if (!(migration.scope in scopeRank)) throw new Error(`Invalid migration scope for ${migration.id}`);
209
+ if (migration.mode !== "online" && migration.mode !== "offline") throw new Error(`Invalid migration mode for ${migration.id}`);
210
+ if (migration.rollback !== void 0 && migration.rollback !== "compatible" && migration.rollback !== "incompatible") throw new Error(`Invalid migration rollback compatibility for ${migration.id}`);
211
+ if (typeof migration.up !== "function") throw new Error(`Migration ${migration.id} is missing up()`);
212
+ if (migration.resources && migration.resources.before === migration.resources.after) throw new Error(`Migration ${migration.id} has an unchanged resource transition`);
213
+ };
214
+ var defineMigration = (migration) => {
215
+ assertMigration(migration);
216
+ return Object.freeze({
217
+ ...migration,
218
+ rollback: migration.rollback ?? (migration.mode === "online" ? "compatible" : "incompatible"),
219
+ dependsOn: Object.freeze([...migration.dependsOn ?? []]),
220
+ ...migration.resources ? { resources: Object.freeze({ ...migration.resources }) } : {}
221
+ });
222
+ };
223
+ var defineMigrationSource = (source) => {
224
+ const name = source.name.trim();
225
+ if (!sourceNamePattern.test(name)) throw new Error(`Invalid migration source name "${source.name}"`);
226
+ let previousId = null;
227
+ const ids = /* @__PURE__ */ new Set();
228
+ for (const migration of source.migrations) {
229
+ assertMigration(migration);
230
+ if (ids.has(migration.id)) throw new Error(`Duplicate migration id in ${name}: ${migration.id}`);
231
+ if (previousId && migration.id <= previousId) throw new Error(`Migration ids in ${name} must be strictly increasing: ${previousId}, ${migration.id}`);
232
+ ids.add(migration.id);
233
+ previousId = migration.id;
234
+ }
235
+ return Object.freeze({
236
+ ...source,
237
+ name,
238
+ migrations: Object.freeze([...source.migrations]),
239
+ resourceSnapshots: Object.freeze([...source.resourceSnapshots ?? []]),
240
+ checksums: Object.freeze({ ...source.checksums ?? {} })
241
+ });
242
+ };
243
+ var resolveDependencyId = (source, dependency) => dependency.includes(":") ? dependency : `${source}:${dependency}`;
244
+ var migrationChecksum = (migration, source) => {
245
+ const injectedChecksum = migration.__rpcbaseIntegrity ?? source.checksums?.[migration.id];
246
+ if (injectedChecksum !== void 0 && (typeof injectedChecksum !== "string" || !/^[a-f0-9]{64}$/.test(injectedChecksum))) throw new Error(`Migration ${source.name}:${migration.id} has an invalid source checksum`);
247
+ const codeChecksum = injectedChecksum ?? canonicalChecksum(migration.up.toString());
248
+ return {
249
+ checksum: canonicalChecksum({
250
+ id: migration.id,
251
+ source: source.name,
252
+ scope: migration.scope,
253
+ mode: migration.mode,
254
+ rollback: migration.rollback ?? (migration.mode === "online" ? "compatible" : "incompatible"),
255
+ dependsOn: migration.dependsOn ?? [],
256
+ resources: migration.resources ?? null,
257
+ codeChecksum
258
+ }),
259
+ sealed: Boolean(injectedChecksum)
260
+ };
261
+ };
262
+ var sortMigrations = (migrations) => {
263
+ const byId = new Map(migrations.map((migration) => [migration.qualifiedId, migration]));
264
+ const visiting = /* @__PURE__ */ new Set();
265
+ const visited = /* @__PURE__ */ new Set();
266
+ const ordered = [];
267
+ const visit = (migration) => {
268
+ if (visited.has(migration.qualifiedId)) return;
269
+ if (visiting.has(migration.qualifiedId)) throw new Error(`Cyclic migration dependency involving ${migration.qualifiedId}`);
270
+ visiting.add(migration.qualifiedId);
271
+ for (const dependencyId of migration.dependsOn) {
272
+ const dependency = byId.get(dependencyId);
273
+ if (!dependency) throw new Error(`Unknown dependency ${dependencyId} for ${migration.qualifiedId}`);
274
+ if (scopeRank[dependency.scope] > scopeRank[migration.scope]) throw new Error(`${migration.qualifiedId} cannot depend on later scope migration ${dependencyId}`);
275
+ if (dependency.source === migration.source && dependency.scope === migration.scope && dependency.sourcePosition > migration.sourcePosition) throw new Error(`${migration.qualifiedId} cannot depend on a later migration in its source: ${dependencyId}`);
276
+ visit(dependency);
277
+ }
278
+ visiting.delete(migration.qualifiedId);
279
+ visited.add(migration.qualifiedId);
280
+ ordered.push(migration);
281
+ };
282
+ for (const migration of [...migrations].sort((left, right) => {
283
+ const scopeDifference = scopeRank[left.scope] - scopeRank[right.scope];
284
+ if (scopeDifference !== 0) return scopeDifference;
285
+ const sourceDifference = left.sourcePriority - right.sourcePriority;
286
+ if (sourceDifference !== 0) return sourceDifference;
287
+ return left.sourcePosition - right.sourcePosition;
288
+ })) visit(migration);
289
+ return Object.freeze(ordered);
290
+ };
291
+ var compileMigrationRegistry = (inputSources, options = {}) => {
292
+ const sourceNames = /* @__PURE__ */ new Set();
293
+ const migrations = [];
294
+ const compiledSources = [];
295
+ for (const [priority, inputSource] of inputSources.entries()) {
296
+ const source = defineMigrationSource(inputSource);
297
+ if (sourceNames.has(source.name)) throw new Error(`Duplicate migration source: ${source.name}`);
298
+ sourceNames.add(source.name);
299
+ const snapshots = /* @__PURE__ */ new Map();
300
+ for (const snapshot of [...source.resourceSnapshots ?? [], ...source.resources ? [source.resources] : []]) {
301
+ const normalized = defineMongoResources(snapshot);
302
+ if (normalized.checksum !== snapshot.checksum) throw new Error(`Invalid resource snapshot checksum in ${source.name}: ${snapshot.checksum}`);
303
+ snapshots.set(normalized.checksum, normalized);
304
+ }
305
+ const sourceMigrations = source.migrations.map((migration, sourcePosition) => {
306
+ if (migration.resources) {
307
+ if (migration.resources.before && !snapshots.has(migration.resources.before)) throw new Error(`Missing before resource snapshot for ${source.name}:${migration.id}`);
308
+ if (!snapshots.has(migration.resources.after)) throw new Error(`Missing after resource snapshot for ${source.name}:${migration.id}`);
309
+ }
310
+ const integrity = migrationChecksum(migration, source);
311
+ if (options.requireSealed && !integrity.sealed) throw new Error(`Migration ${source.name}:${migration.id} has no build-injected checksum`);
312
+ const compiled = Object.freeze({
313
+ ...migration,
314
+ qualifiedId: `${source.name}:${migration.id}`,
315
+ source: source.name,
316
+ sourcePosition,
317
+ sourcePriority: priority,
318
+ dependsOn: Object.freeze((migration.dependsOn ?? []).map((id) => resolveDependencyId(source.name, id))),
319
+ rollback: migration.rollback ?? (migration.mode === "online" ? "compatible" : "incompatible"),
320
+ checksum: integrity.checksum,
321
+ sealed: integrity.sealed
322
+ });
323
+ migrations.push(compiled);
324
+ return compiled;
325
+ });
326
+ for (const scope of Object.keys(scopeRank)) {
327
+ let previousChecksum = null;
328
+ let previousResources = defineMongoResources();
329
+ for (const migration of sourceMigrations.filter((item) => item.scope === scope && item.resources)) {
330
+ const transition = migration.resources;
331
+ if (!transition) continue;
332
+ const beforeResources = transition.before ? snapshots.get(transition.before) : defineMongoResources();
333
+ if (!beforeResources || filterMongoResources(beforeResources, scope).checksum !== filterMongoResources(previousResources, scope).checksum) throw new Error(`Non-contiguous resource transition for ${migration.qualifiedId}: expected ${previousChecksum ?? "null"}`);
334
+ previousChecksum = transition.after;
335
+ previousResources = snapshots.get(previousChecksum) ?? defineMongoResources();
336
+ }
337
+ const currentForScope = source.resources ? filterMongoResources(source.resources, scope) : defineMongoResources();
338
+ if (currentForScope.collections.length > 0 || currentForScope.indexes.length > 0 || currentForScope.searchIndexes.length > 0 || currentForScope.collectionValidators.length > 0 || previousChecksum) {
339
+ if (!previousChecksum) throw new Error(`Source ${source.name} has unmanaged ${scope} resources without a migration`);
340
+ const previousSnapshot = snapshots.get(previousChecksum);
341
+ if (!previousSnapshot || filterMongoResources(previousSnapshot, scope).checksum !== currentForScope.checksum) throw new Error(`Latest ${scope} resource transition for ${source.name} does not match current resources`);
342
+ }
343
+ }
344
+ compiledSources.push(Object.freeze({
345
+ ...source,
346
+ priority,
347
+ migrations: Object.freeze(sourceMigrations),
348
+ resourceSnapshots: snapshots
349
+ }));
350
+ }
351
+ const ordered = sortMigrations(migrations);
352
+ const migrationsById = /* @__PURE__ */ new Map();
353
+ for (const migration of ordered) {
354
+ if (migrationsById.has(migration.qualifiedId)) throw new Error(`Duplicate migration: ${migration.qualifiedId}`);
355
+ migrationsById.set(migration.qualifiedId, migration);
356
+ }
357
+ mergeMongoResources(compiledSources.flatMap((source) => source.resources ? [source.resources] : []));
358
+ return Object.freeze({
359
+ protocolVersion: 1,
360
+ sources: Object.freeze(compiledSources),
361
+ migrations: ordered,
362
+ migrationsById,
363
+ checksum: canonicalChecksum(ordered.map((migration) => ({
364
+ id: migration.qualifiedId,
365
+ checksum: migration.checksum
366
+ })))
367
+ });
368
+ };
369
+ //#endregion
370
+ //#region src/indexDefinition.ts
371
+ var ignoredIndexOptions = /* @__PURE__ */ new Set([
372
+ "background",
373
+ "key",
374
+ "name",
375
+ "ns",
376
+ "v"
377
+ ]);
378
+ var falseDefaultOptions = /* @__PURE__ */ new Set([
379
+ "hidden",
380
+ "sparse",
381
+ "unique"
382
+ ]);
383
+ var collationDefaults = /* @__PURE__ */ new Map([
384
+ ["alternate", "non-ignorable"],
385
+ ["backwards", false],
386
+ ["caseFirst", "off"],
387
+ ["caseLevel", false],
388
+ ["maxVariable", "punct"],
389
+ ["normalization", false],
390
+ ["numericOrdering", false],
391
+ ["strength", 3]
392
+ ]);
393
+ var isDocument = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
394
+ var hasDirection = (key, direction) => Object.values(key).some((value) => value === direction);
395
+ var normalizeCollation = (value) => {
396
+ if (!isDocument(value)) return value;
397
+ return Object.fromEntries(Object.entries(value).filter(([name, option]) => name !== "version" && collationDefaults.get(name) !== option));
398
+ };
399
+ var normalizeWeights = (value) => {
400
+ if (!isDocument(value)) return value;
401
+ return Object.fromEntries(Object.entries(value).filter(([, weight]) => Number(weight) !== 1));
402
+ };
403
+ var normalizeIndexOptions = (options, key) => {
404
+ const normalized = Object.fromEntries(Object.entries(options).filter(([name, value]) => value !== void 0 && !ignoredIndexOptions.has(name) && !(falseDefaultOptions.has(name) && value === false)));
405
+ if (normalized.collation !== void 0) normalized.collation = normalizeCollation(normalized.collation);
406
+ if (normalized.weights !== void 0) {
407
+ normalized.weights = normalizeWeights(normalized.weights);
408
+ if (isDocument(normalized.weights) && Object.keys(normalized.weights).length === 0) delete normalized.weights;
409
+ }
410
+ if (hasDirection(key, "text")) {
411
+ if (normalized.default_language === "english") delete normalized.default_language;
412
+ if (normalized.language_override === "language") delete normalized.language_override;
413
+ if (Number(normalized.textIndexVersion) === 3) delete normalized.textIndexVersion;
414
+ }
415
+ if (hasDirection(key, "2dsphere") && Number(normalized["2dsphereIndexVersion"]) === 3) delete normalized["2dsphereIndexVersion"];
416
+ return normalized;
417
+ };
418
+ var normalizeIndexKey = (key, options, declaredKey) => {
419
+ const entries = Object.entries(key);
420
+ if (!(entries.some(([name, direction]) => name === "_fts" && direction === "text") && entries.some(([name, direction]) => name === "_ftsx" && direction === 1)) || !isDocument(options.weights)) return entries;
421
+ const actualTextFields = Object.keys(options.weights);
422
+ const actualTextFieldSet = new Set(actualTextFields);
423
+ const declaredTextFields = Object.entries(declaredKey).filter(([, direction]) => direction === "text").map(([name]) => name);
424
+ const orderedTextFields = [...declaredTextFields.filter((name) => actualTextFieldSet.has(name)), ...actualTextFields.filter((name) => !declaredTextFields.includes(name)).sort()];
425
+ return entries.flatMap(([name, direction]) => {
426
+ if (name === "_fts" && direction === "text") return orderedTextFields.map((field) => [field, "text"]);
427
+ if (name === "_ftsx" && direction === 1) return [];
428
+ return [[name, direction]];
429
+ });
430
+ };
431
+ var normalizeIndexDefinition = (key, options = {}, declaredKey = key) => ({
432
+ key: normalizeIndexKey(key, options, declaredKey),
433
+ options: normalizeIndexOptions(options, key)
434
+ });
435
+ //#endregion
436
+ //#region src/inspectResources.ts
437
+ var selectKeys = (document, expected) => {
438
+ if (!expected) return {};
439
+ return Object.fromEntries(Object.keys(expected).map((key) => [key, document?.[key]]));
440
+ };
441
+ var normalizeValidatorDefinition = (document) => ({
442
+ ...document?.validator !== void 0 ? { validator: document.validator } : {},
443
+ ...document?.validationLevel !== void 0 && document.validationLevel !== "strict" ? { validationLevel: document.validationLevel } : {},
444
+ ...document?.validationAction !== void 0 && document.validationAction !== "error" ? { validationAction: document.validationAction } : {}
445
+ });
446
+ var same$1 = (left, right) => canonicalStringify(left) === canonicalStringify(right);
447
+ var message = (collection, resource, detail) => `${collection}${resource ? `.${resource}` : ""}: ${detail}`;
448
+ var inspectMongoResources = async (db, resources, options = {}) => {
449
+ const divergences = [];
450
+ const signal = options.signal;
451
+ signal?.throwIfAborted();
452
+ const collectionNames = /* @__PURE__ */ new Set([
453
+ ...resources.collections.map((resource) => resource.name),
454
+ ...resources.indexes.map((resource) => resource.collection),
455
+ ...resources.searchIndexes.map((resource) => resource.collection),
456
+ ...resources.collectionValidators.map((resource) => resource.collection)
457
+ ]);
458
+ const listedCollections = collectionNames.size > 0 ? await db.listCollections({ name: { $in: [...collectionNames] } }, { nameOnly: false }).toArray() : [];
459
+ const collectionsByName = new Map(listedCollections.map((collection) => [collection.name, collection]));
460
+ for (const expected of resources.collections) {
461
+ const actual = collectionsByName.get(expected.name);
462
+ if (!actual) {
463
+ divergences.push({
464
+ code: "missing_collection",
465
+ scope: expected.scope,
466
+ collection: expected.name,
467
+ message: message(expected.name, void 0, "managed collection is missing")
468
+ });
469
+ continue;
470
+ }
471
+ const expectedOptions = expected.options ?? {};
472
+ const actualOptions = selectKeys(actual.options, expectedOptions);
473
+ if (!same$1(actualOptions, expectedOptions)) divergences.push({
474
+ code: "collection_options_mismatch",
475
+ scope: expected.scope,
476
+ collection: expected.name,
477
+ expected: expectedOptions,
478
+ actual: actualOptions,
479
+ message: message(expected.name, void 0, "collection options differ")
480
+ });
481
+ }
482
+ const indexesByCollection = /* @__PURE__ */ new Map();
483
+ for (const expected of resources.indexes) {
484
+ if (!collectionsByName.has(expected.collection)) {
485
+ divergences.push({
486
+ code: "missing_index",
487
+ scope: expected.scope,
488
+ collection: expected.collection,
489
+ resource: expected.name,
490
+ message: message(expected.collection, expected.name, "index collection is missing")
491
+ });
492
+ continue;
493
+ }
494
+ let indexes = indexesByCollection.get(expected.collection);
495
+ if (!indexes) {
496
+ indexes = await db.collection(expected.collection).listIndexes().toArray();
497
+ indexesByCollection.set(expected.collection, indexes);
498
+ }
499
+ const actual = indexes.find((index) => index.name === expected.name);
500
+ if (!actual) {
501
+ divergences.push({
502
+ code: "missing_index",
503
+ scope: expected.scope,
504
+ collection: expected.collection,
505
+ resource: expected.name,
506
+ message: message(expected.collection, expected.name, "managed index is missing")
507
+ });
508
+ continue;
509
+ }
510
+ const actualDefinition = normalizeIndexDefinition(actual.key ?? {}, actual, expected.key);
511
+ const expectedDefinition = normalizeIndexDefinition(expected.key, expected.options ?? {});
512
+ if (!same$1(actualDefinition.key, expectedDefinition.key)) divergences.push({
513
+ code: "index_key_mismatch",
514
+ scope: expected.scope,
515
+ collection: expected.collection,
516
+ resource: expected.name,
517
+ expected: expected.key,
518
+ actual: Object.fromEntries(actualDefinition.key),
519
+ message: message(expected.collection, expected.name, "index keys differ")
520
+ });
521
+ const expectedOptions = expectedDefinition.options;
522
+ const actualOptions = actualDefinition.options;
523
+ if (!same$1(actualOptions, expectedOptions)) divergences.push({
524
+ code: "index_options_mismatch",
525
+ scope: expected.scope,
526
+ collection: expected.collection,
527
+ resource: expected.name,
528
+ expected: expectedOptions,
529
+ actual: actualOptions,
530
+ message: message(expected.collection, expected.name, "index options differ")
531
+ });
532
+ }
533
+ for (const expected of resources.collectionValidators) {
534
+ const collection = collectionsByName.get(expected.collection);
535
+ const actual = collection?.options;
536
+ const expectedValidator = normalizeValidatorDefinition({
537
+ validator: expected.validator,
538
+ ...expected.validationLevel ? { validationLevel: expected.validationLevel } : {},
539
+ ...expected.validationAction ? { validationAction: expected.validationAction } : {}
540
+ });
541
+ const actualValidator = normalizeValidatorDefinition(actual);
542
+ if (!collection || !same$1(actualValidator, expectedValidator)) divergences.push({
543
+ code: "collection_validator_mismatch",
544
+ scope: expected.scope,
545
+ collection: expected.collection,
546
+ expected: expectedValidator,
547
+ actual: collection ? actualValidator : void 0,
548
+ message: message(expected.collection, void 0, "collection validator differs")
549
+ });
550
+ }
551
+ const searchIndexesByCollection = /* @__PURE__ */ new Map();
552
+ for (const expected of resources.searchIndexes) {
553
+ let indexes = searchIndexesByCollection.get(expected.collection);
554
+ if (!indexes) {
555
+ try {
556
+ indexes = await db.collection(expected.collection).listSearchIndexes().toArray();
557
+ } catch (error) {
558
+ indexes = error instanceof Error ? error : new Error(String(error));
559
+ }
560
+ searchIndexesByCollection.set(expected.collection, indexes);
561
+ }
562
+ if (indexes instanceof Error) {
563
+ divergences.push({
564
+ code: "search_unavailable",
565
+ scope: expected.scope,
566
+ collection: expected.collection,
567
+ resource: expected.name,
568
+ actual: indexes.message,
569
+ message: message(expected.collection, expected.name, "MongoDB Search inspection is unavailable")
570
+ });
571
+ continue;
572
+ }
573
+ const actual = indexes.find((index) => index.name === expected.name);
574
+ if (!actual) {
575
+ divergences.push({
576
+ code: "missing_search_index",
577
+ scope: expected.scope,
578
+ collection: expected.collection,
579
+ resource: expected.name,
580
+ message: message(expected.collection, expected.name, "managed Search index is missing")
581
+ });
582
+ continue;
583
+ }
584
+ if (!same$1(actual.latestDefinition, expected.definition)) divergences.push({
585
+ code: "search_index_definition_mismatch",
586
+ scope: expected.scope,
587
+ collection: expected.collection,
588
+ resource: expected.name,
589
+ expected: expected.definition,
590
+ actual: actual.latestDefinition,
591
+ message: message(expected.collection, expected.name, "Search index definition differs")
592
+ });
593
+ if (options.requireSearchReady && (actual.status !== "READY" || actual.queryable !== true)) divergences.push({
594
+ code: "search_index_not_ready",
595
+ scope: expected.scope,
596
+ collection: expected.collection,
597
+ resource: expected.name,
598
+ expected: {
599
+ status: "READY",
600
+ queryable: true
601
+ },
602
+ actual: {
603
+ status: actual.status,
604
+ queryable: actual.queryable
605
+ },
606
+ message: message(expected.collection, expected.name, "Search index is not ready and queryable")
607
+ });
608
+ }
609
+ signal?.throwIfAborted();
610
+ return divergences;
611
+ };
612
+ //#endregion
613
+ //#region src/helpers.ts
614
+ var namespaceExists = (error) => {
615
+ if (!error || typeof error !== "object") return false;
616
+ const value = error;
617
+ return value.code === 48 || value.codeName === "NamespaceExists";
618
+ };
619
+ var namespaceMissing = (error) => {
620
+ if (!error || typeof error !== "object") return false;
621
+ const value = error;
622
+ return value.code === 26 || value.codeName === "NamespaceNotFound";
623
+ };
624
+ var indexMissing = (error) => {
625
+ if (!error || typeof error !== "object") return false;
626
+ const value = error;
627
+ return value.code === 27 || value.codeName === "IndexNotFound";
628
+ };
629
+ var same = (left, right) => canonicalStringify(left) === canonicalStringify(right);
630
+ var wait = async (milliseconds, signal) => {
631
+ signal.throwIfAborted();
632
+ await new Promise((resolve, reject) => {
633
+ const timer = setTimeout(() => {
634
+ signal.removeEventListener("abort", onAbort);
635
+ resolve();
636
+ }, milliseconds);
637
+ const onAbort = () => {
638
+ clearTimeout(timer);
639
+ reject(signal.reason);
640
+ };
641
+ signal.addEventListener("abort", onAbort, { once: true });
642
+ });
643
+ signal.throwIfAborted();
644
+ };
645
+ var createMigrationHelpers = (db, signal, options = {}) => {
646
+ const searchTimeoutMs = options.searchTimeoutMs ?? 10 * 6e4;
647
+ const searchPollIntervalMs = options.searchPollIntervalMs ?? 1e3;
648
+ const ensureCollection = async (name, collectionOptions = {}) => {
649
+ signal.throwIfAborted();
650
+ if (await db.listCollections({ name }, { nameOnly: true }).hasNext()) return;
651
+ try {
652
+ await db.createCollection(name, collectionOptions);
653
+ } catch (error) {
654
+ if (!namespaceExists(error)) throw error;
655
+ }
656
+ };
657
+ const dropCollectionIfExists = async (name) => {
658
+ signal.throwIfAborted();
659
+ try {
660
+ await db.dropCollection(name);
661
+ } catch (error) {
662
+ if (!namespaceMissing(error)) throw error;
663
+ }
664
+ };
665
+ const ensureIndex = async (collectionName, key, indexOptions) => {
666
+ signal.throwIfAborted();
667
+ await ensureCollection(collectionName);
668
+ const collection = db.collection(collectionName);
669
+ const existing = (await collection.listIndexes().toArray()).find((index) => index.name === indexOptions.name);
670
+ if (existing) {
671
+ if (!same(normalizeIndexDefinition(existing.key ?? {}, existing, key), normalizeIndexDefinition(key, indexOptions))) throw new Error(`Index ${collectionName}.${indexOptions.name} exists with a different definition`);
672
+ return;
673
+ }
674
+ await collection.createIndex(key, indexOptions);
675
+ };
676
+ const dropIndexIfExists = async (collectionName, name) => {
677
+ signal.throwIfAborted();
678
+ try {
679
+ await db.collection(collectionName).dropIndex(name);
680
+ } catch (error) {
681
+ if (!namespaceMissing(error) && !indexMissing(error)) throw error;
682
+ }
683
+ };
684
+ const findDuplicateKeys = async (collectionName, key, duplicateOptions = {}) => {
685
+ signal.throwIfAborted();
686
+ const id = Object.keys(key).map((path) => `$${path}`);
687
+ const pipeline = [];
688
+ if (duplicateOptions.filter) pipeline.push({ $match: duplicateOptions.filter });
689
+ pipeline.push({ $group: {
690
+ _id: id,
691
+ count: { $sum: 1 }
692
+ } }, { $match: { count: { $gt: 1 } } }, { $limit: duplicateOptions.limit ?? 20 });
693
+ return (await db.collection(collectionName).aggregate(pipeline, duplicateOptions.collation ? { collation: duplicateOptions.collation } : {}).toArray()).map((document) => ({
694
+ key: document._id,
695
+ count: Number(document.count)
696
+ }));
697
+ };
698
+ const waitForSearchIndex = async (collectionName, name) => {
699
+ const deadline = Date.now() + searchTimeoutMs;
700
+ while (Date.now() < deadline) {
701
+ signal.throwIfAborted();
702
+ const index = (await db.collection(collectionName).listSearchIndexes(name).toArray())[0];
703
+ if (index?.status === "READY" && index.queryable === true) return;
704
+ if (index?.status === "FAILED") throw new Error(`Search index ${collectionName}.${name} failed to build`);
705
+ await wait(searchPollIntervalMs, signal);
706
+ }
707
+ throw new Error(`Timed out waiting for Search index ${collectionName}.${name}`);
708
+ };
709
+ const ensureSearchIndex = async (collectionName, name, definition) => {
710
+ signal.throwIfAborted();
711
+ await ensureCollection(collectionName);
712
+ const collection = db.collection(collectionName);
713
+ const existing = (await collection.listSearchIndexes(name).toArray())[0];
714
+ if (!existing) await collection.createSearchIndex({
715
+ name,
716
+ definition
717
+ });
718
+ else if (!same(existing.latestDefinition, definition)) await collection.updateSearchIndex(name, definition);
719
+ await waitForSearchIndex(collectionName, name);
720
+ };
721
+ const dropSearchIndexIfExists = async (collectionName, name) => {
722
+ signal.throwIfAborted();
723
+ const collection = db.collection(collectionName);
724
+ if ((await collection.listSearchIndexes(name).toArray()).length === 0) return;
725
+ await collection.dropSearchIndex(name);
726
+ const deadline = Date.now() + searchTimeoutMs;
727
+ while (Date.now() < deadline) {
728
+ signal.throwIfAborted();
729
+ if ((await collection.listSearchIndexes(name).toArray()).length === 0) return;
730
+ await wait(searchPollIntervalMs, signal);
731
+ }
732
+ throw new Error(`Timed out deleting Search index ${collectionName}.${name}`);
733
+ };
734
+ const setCollectionValidator = async (collection, validator, validatorOptions = {}) => {
735
+ signal.throwIfAborted();
736
+ await ensureCollection(collection);
737
+ await db.command({
738
+ collMod: collection,
739
+ validator,
740
+ ...validatorOptions
741
+ });
742
+ };
743
+ return {
744
+ ensureCollection,
745
+ dropCollectionIfExists,
746
+ ensureIndex,
747
+ dropIndexIfExists,
748
+ findDuplicateKeys,
749
+ ensureSearchIndex,
750
+ dropSearchIndexIfExists,
751
+ setCollectionValidator,
752
+ reconcileResources: async (resources) => {
753
+ for (const collection of resources.collections) await ensureCollection(collection.name, collection.options);
754
+ for (const validator of resources.collectionValidators) await setCollectionValidator(validator.collection, validator.validator, {
755
+ ...validator.validationLevel ? { validationLevel: validator.validationLevel } : {},
756
+ ...validator.validationAction ? { validationAction: validator.validationAction } : {}
757
+ });
758
+ for (const index of resources.indexes) await ensureIndex(index.collection, index.key, {
759
+ name: index.name,
760
+ ...index.options ?? {}
761
+ });
762
+ for (const index of resources.searchIndexes) await ensureSearchIndex(index.collection, index.name, index.definition);
763
+ }
764
+ };
765
+ };
766
+ //#endregion
767
+ //#region src/errors.ts
768
+ var MigrationIntegrityError = class extends Error {
769
+ code = "RB_MIGRATION_INTEGRITY";
770
+ constructor(message) {
771
+ super(message);
772
+ this.name = "MigrationIntegrityError";
773
+ }
774
+ };
775
+ var MigrationLockUnavailableError = class extends Error {
776
+ code = "RB_MIGRATION_LOCK_UNAVAILABLE";
777
+ constructor(message) {
778
+ super(message);
779
+ this.name = "MigrationLockUnavailableError";
780
+ }
781
+ };
782
+ var MigrationLockLostError = class extends Error {
783
+ code = "RB_MIGRATION_LOCK_LOST";
784
+ constructor(message) {
785
+ super(message);
786
+ this.name = "MigrationLockLostError";
787
+ }
788
+ };
789
+ //#endregion
790
+ //#region src/history.ts
791
+ var MIGRATIONS_COLLECTION = "rbmigrations";
792
+ var readMigrationHistory = async (db) => db.collection(MIGRATIONS_COLLECTION).find({}).sort({
793
+ source: 1,
794
+ sourcePosition: 1
795
+ }).toArray();
796
+ //#endregion
797
+ //#region src/databaseProvider.ts
798
+ var normalizeAppName = (value) => {
799
+ const appName = value.trim();
800
+ if (!appName) throw new Error("Missing appName");
801
+ if (/[/\\."$*<>:|?]/.test(appName)) throw new Error(`Invalid appName: ${appName}`);
802
+ return appName;
803
+ };
804
+ var normalizeTenantId = (value) => {
805
+ const tenantId = value.trim();
806
+ if (!tenantId) throw new Error("Missing tenantId");
807
+ if (/[/\\."$*<>:|?]/.test(tenantId)) throw new Error(`Invalid tenantId: ${tenantId}`);
808
+ return tenantId;
809
+ };
810
+ var createMigrationDatabaseProvider = (options) => {
811
+ const appName = normalizeAppName(options.appName);
812
+ const globalDbName = `${appName}-global-db`;
813
+ const tenantCollection = options.tenantCollection?.trim() || "rbtenants";
814
+ return {
815
+ global: () => options.client.db(globalDbName),
816
+ tenantIds: async (signal) => {
817
+ signal.throwIfAborted();
818
+ const documents = await options.client.db(globalDbName).collection(tenantCollection).find({ $or: [{ provisioningStatus: { $exists: false } }, { provisioningStatus: "active" }] }, { projection: { tenantId: 1 } }).sort({ tenantId: 1 }).toArray();
819
+ signal.throwIfAborted();
820
+ return [...new Set(documents.flatMap((document) => typeof document.tenantId === "string" && document.tenantId.trim() ? [normalizeTenantId(document.tenantId)] : []))];
821
+ },
822
+ tenantExists: async (tenantId, signal) => {
823
+ signal.throwIfAborted();
824
+ const normalized = normalizeTenantId(tenantId);
825
+ const tenant = await options.client.db(globalDbName).collection(tenantCollection).findOne({ tenantId: normalized }, { projection: { _id: 1 } });
826
+ signal.throwIfAborted();
827
+ return Boolean(tenant);
828
+ },
829
+ tenant: (tenantId) => options.client.db(`${appName}-${normalizeTenantId(tenantId)}-db`),
830
+ filesystemRequired: options.filesystemRequired ?? (() => false),
831
+ filesystem: (tenantId) => options.client.db(`${appName}-${normalizeTenantId(tenantId)}-filesystem-db`)
832
+ };
833
+ };
834
+ var getMigrationDatabaseName = (db) => db.databaseName;
835
+ //#endregion
836
+ //#region src/planner.ts
837
+ var neverAbortedSignal = new AbortController().signal;
838
+ var toPlanItem = (migration) => ({
839
+ id: migration.qualifiedId,
840
+ checksum: migration.checksum,
841
+ source: migration.source,
842
+ scope: migration.scope,
843
+ mode: migration.mode,
844
+ rollback: migration.rollback,
845
+ dependsOn: migration.dependsOn
846
+ });
847
+ var historyById = (history) => new Map(history.map((record) => [record._id, record]));
848
+ var validateKnownRecord = (migration, record, targetScope) => {
849
+ const errors = [];
850
+ if (record.checksum !== migration.checksum) errors.push(`${migration.qualifiedId}: checksum differs`);
851
+ if (record.source !== migration.source) errors.push(`${migration.qualifiedId}: source differs`);
852
+ if (record.sourcePosition !== migration.sourcePosition) errors.push(`${migration.qualifiedId}: source position differs`);
853
+ if (record.scope !== migration.scope) errors.push(`${migration.qualifiedId}: scope differs`);
854
+ if (migration.scope !== targetScope) errors.push(`${migration.qualifiedId}: migration history is stored in a ${targetScope} database`);
855
+ if (record.mode !== migration.mode) errors.push(`${migration.qualifiedId}: mode differs`);
856
+ if (record.rollback !== migration.rollback) errors.push(`${migration.qualifiedId}: rollback compatibility differs`);
857
+ if (![
858
+ "running",
859
+ "applied",
860
+ "failed"
861
+ ].includes(record.status)) errors.push(`${migration.qualifiedId}: history status is invalid`);
862
+ const expectedBeforeHash = migration.resources?.before ?? void 0;
863
+ const expectedAfterHash = migration.resources?.after;
864
+ if (record.resourcesBeforeHash !== expectedBeforeHash) errors.push(`${migration.qualifiedId}: resource before checksum differs`);
865
+ if (record.resourcesAfterHash !== expectedAfterHash) errors.push(`${migration.qualifiedId}: resource after checksum differs`);
866
+ return errors;
867
+ };
868
+ var validateSourcePrefixes = (registry, scope, records) => {
869
+ const errors = [];
870
+ for (const source of registry.sources) {
871
+ let gap = null;
872
+ for (const migration of source.migrations.filter((item) => item.scope === scope)) {
873
+ const applied = records.get(migration.qualifiedId)?.status === "applied";
874
+ if (!applied && !gap) gap = migration;
875
+ if (applied && gap) errors.push(`${migration.qualifiedId}: applied after missing migration ${gap.qualifiedId}`);
876
+ }
877
+ }
878
+ return errors;
879
+ };
880
+ var getExpectedResources = (registry, scope, history) => {
881
+ const records = historyById(history);
882
+ const snapshots = [];
883
+ for (const source of registry.sources) {
884
+ let checksum = null;
885
+ for (const migration of source.migrations) {
886
+ if (migration.scope !== scope || !migration.resources) continue;
887
+ if (records.get(migration.qualifiedId)?.status === "applied") checksum = migration.resources.after;
888
+ }
889
+ if (!checksum) continue;
890
+ const snapshot = source.resourceSnapshots.get(checksum);
891
+ if (!snapshot) throw new Error(`Missing resource snapshot ${checksum} for ${source.name}`);
892
+ snapshots.push(filterMongoResources(snapshot, scope));
893
+ }
894
+ return mergeMongoResources(snapshots);
895
+ };
896
+ var planMigrationTarget = async (registry, target, options = {}) => {
897
+ const signal = options.signal ?? neverAbortedSignal;
898
+ signal.throwIfAborted();
899
+ const history = await readMigrationHistory(target.db);
900
+ const records = historyById(history);
901
+ const relevant = registry.migrations.filter((migration) => migration.scope === target.scope);
902
+ const integrityErrors = [];
903
+ const unknown = [];
904
+ for (const record of history) {
905
+ const migration = registry.migrationsById.get(record._id);
906
+ if (migration) {
907
+ integrityErrors.push(...validateKnownRecord(migration, record, target.scope));
908
+ continue;
909
+ }
910
+ unknown.push(record._id);
911
+ if (!(options.rollback && record.status === "applied" && record.mode === "online" && record.rollback === "compatible")) integrityErrors.push(`${record._id}: applied migration is absent from the registry`);
912
+ }
913
+ integrityErrors.push(...validateSourcePrefixes(registry, target.scope, records));
914
+ const applied = relevant.filter((migration) => records.get(migration.qualifiedId)?.status === "applied").map((migration) => migration.qualifiedId);
915
+ const pending = relevant.filter((migration) => !records.has(migration.qualifiedId)).map(toPlanItem);
916
+ const running = relevant.filter((migration) => records.get(migration.qualifiedId)?.status === "running").map((migration) => migration.qualifiedId);
917
+ const failed = relevant.filter((migration) => records.get(migration.qualifiedId)?.status === "failed").map((migration) => migration.qualifiedId);
918
+ const expectedResources = getExpectedResources(registry, target.scope, history);
919
+ const resourceDivergences = await inspectMongoResources(target.db, expectedResources, {
920
+ signal,
921
+ requireSearchReady: true
922
+ });
923
+ return {
924
+ database: target.db.databaseName,
925
+ scope: target.scope,
926
+ ...target.tenantId ? { tenantId: target.tenantId } : {},
927
+ applied,
928
+ pending,
929
+ running,
930
+ failed,
931
+ unknown,
932
+ integrityErrors,
933
+ resourceDivergences
934
+ };
935
+ };
936
+ var collectMigrationTargets = async (provider, options = {}) => {
937
+ const signal = options.signal ?? neverAbortedSignal;
938
+ const targets = [{
939
+ db: await provider.global(),
940
+ scope: "global"
941
+ }];
942
+ let tenantIds;
943
+ if (options.tenantId) {
944
+ if (provider.tenantExists && !await provider.tenantExists(options.tenantId, signal)) throw new Error(`Unknown tenant: ${options.tenantId}`);
945
+ tenantIds = [options.tenantId];
946
+ } else tenantIds = [...await provider.tenantIds(signal)];
947
+ for (const tenantId of tenantIds) targets.push({
948
+ db: await provider.tenant(tenantId),
949
+ scope: "tenant",
950
+ tenantId
951
+ });
952
+ if (provider.filesystem) {
953
+ for (const tenantId of tenantIds) if (await provider.filesystemRequired?.(tenantId, signal) ?? false) targets.push({
954
+ db: await provider.filesystem(tenantId),
955
+ scope: "filesystem",
956
+ tenantId
957
+ });
958
+ }
959
+ return targets;
960
+ };
961
+ var planMigrations = async (registry, provider, options = {}) => {
962
+ const targets = await collectMigrationTargets(provider, options);
963
+ const databases = [];
964
+ for (const target of targets) databases.push(await planMigrationTarget(registry, target, options));
965
+ return {
966
+ protocolVersion: 1,
967
+ registryChecksum: registry.checksum,
968
+ databases,
969
+ hasPending: databases.some((database) => database.pending.length > 0 || database.running.length > 0 || database.failed.length > 0),
970
+ hasOffline: databases.some((database) => database.pending.some((migration) => migration.mode === "offline") || [...database.running, ...database.failed].some((id) => registry.migrationsById.get(id)?.mode === "offline")),
971
+ hasErrors: databases.some((database) => database.integrityErrors.length > 0 || database.running.length === 0 && database.failed.length === 0 && database.resourceDivergences.length > 0)
972
+ };
973
+ };
974
+ //#endregion
975
+ //#region src/lock.ts
976
+ var MIGRATION_LOCKS_COLLECTION = "rbmigrationlocks";
977
+ var isDuplicateKey = (error) => Boolean(error && typeof error === "object" && "code" in error && error.code === 11e3);
978
+ var acquireMigrationLock = async (db, options = {}) => {
979
+ const lockId = options.lockId?.trim() || "default";
980
+ const owner = options.owner?.trim() || randomUUID();
981
+ const runId = options.runId?.trim() || randomUUID();
982
+ const leaseMs = options.leaseMs ?? 12e4;
983
+ const heartbeatMs = options.heartbeatMs ?? 3e4;
984
+ if (leaseMs <= 0) throw new Error("Migration lock lease must be positive");
985
+ if (heartbeatMs <= 0 || heartbeatMs >= leaseMs) throw new Error("Migration lock heartbeat must be positive and shorter than the lease");
986
+ const collection = db.collection(MIGRATION_LOCKS_COLLECTION);
987
+ let document;
988
+ try {
989
+ await collection.updateOne({ _id: lockId }, { $setOnInsert: {
990
+ owner: "",
991
+ runId: "",
992
+ fence: 0,
993
+ expiresAt: /* @__PURE__ */ new Date(0)
994
+ } }, {
995
+ upsert: true,
996
+ writeConcern: { w: "majority" }
997
+ });
998
+ document = await collection.findOneAndUpdate({
999
+ _id: lockId,
1000
+ $expr: { $or: [{ $lte: [{ $ifNull: ["$expiresAt", /* @__PURE__ */ new Date(0)] }, "$$NOW"] }, { $eq: ["$owner", owner] }] }
1001
+ }, [{ $set: {
1002
+ owner,
1003
+ runId,
1004
+ fence: { $add: [{ $ifNull: ["$fence", 0] }, 1] },
1005
+ acquiredAt: "$$NOW",
1006
+ heartbeatAt: "$$NOW",
1007
+ expiresAt: { $dateAdd: {
1008
+ startDate: "$$NOW",
1009
+ unit: "millisecond",
1010
+ amount: leaseMs
1011
+ } }
1012
+ } }], {
1013
+ returnDocument: "after",
1014
+ writeConcern: { w: "majority" }
1015
+ });
1016
+ } catch (error) {
1017
+ if (isDuplicateKey(error)) throw new MigrationLockUnavailableError(`Migration lock ${lockId} is held by another runner`);
1018
+ throw error;
1019
+ }
1020
+ if (!document || document.owner !== owner || document.runId !== runId) throw new MigrationLockUnavailableError(`Migration lock ${lockId} could not be acquired`);
1021
+ const fence = document.fence;
1022
+ const abortController = new AbortController();
1023
+ let state = "active";
1024
+ let heartbeatTimer;
1025
+ let heartbeatPromise = null;
1026
+ const lose = (reason, cause) => {
1027
+ const error = new MigrationLockLostError(`Migration lock ${lockId} was lost: ${reason}`);
1028
+ if (cause !== void 0) error.cause = cause;
1029
+ if (state === "active") {
1030
+ state = "lost";
1031
+ if (heartbeatTimer) clearTimeout(heartbeatTimer);
1032
+ abortController.abort(error);
1033
+ }
1034
+ return error;
1035
+ };
1036
+ const assertOwned = async () => {
1037
+ if (state === "lost") throw abortController.signal.reason;
1038
+ if (state !== "active") throw new MigrationLockLostError(`Migration lock ${lockId} is no longer active`);
1039
+ if (!await collection.findOne({
1040
+ _id: lockId,
1041
+ owner,
1042
+ runId,
1043
+ fence,
1044
+ $expr: { $gt: ["$expiresAt", "$$NOW"] }
1045
+ }, { projection: { _id: 1 } })) throw lose("ownership or lease expiry could not be confirmed");
1046
+ };
1047
+ const heartbeat = async () => {
1048
+ if (state !== "active") return;
1049
+ if ((await collection.updateOne({
1050
+ _id: lockId,
1051
+ owner,
1052
+ runId,
1053
+ fence,
1054
+ $expr: { $gt: ["$expiresAt", "$$NOW"] }
1055
+ }, [{ $set: {
1056
+ heartbeatAt: "$$NOW",
1057
+ expiresAt: { $dateAdd: {
1058
+ startDate: "$$NOW",
1059
+ unit: "millisecond",
1060
+ amount: leaseMs
1061
+ } }
1062
+ } }], { writeConcern: { w: "majority" } })).modifiedCount !== 1) throw lose("heartbeat was rejected");
1063
+ };
1064
+ const scheduleHeartbeat = () => {
1065
+ if (state !== "active") return;
1066
+ heartbeatTimer = setTimeout(() => {
1067
+ heartbeatPromise = heartbeat().catch((error) => {
1068
+ if (state === "active") lose("heartbeat failed", error);
1069
+ }).finally(() => {
1070
+ heartbeatPromise = null;
1071
+ scheduleHeartbeat();
1072
+ });
1073
+ }, heartbeatMs);
1074
+ heartbeatTimer.unref?.();
1075
+ };
1076
+ const release = async () => {
1077
+ if (state === "released") return;
1078
+ if (heartbeatTimer) clearTimeout(heartbeatTimer);
1079
+ if (heartbeatPromise) await heartbeatPromise.catch(() => void 0);
1080
+ if (state === "lost") return;
1081
+ state = "released";
1082
+ await collection.updateOne({
1083
+ _id: lockId,
1084
+ owner,
1085
+ runId,
1086
+ fence
1087
+ }, {
1088
+ $set: {
1089
+ expiresAt: /* @__PURE__ */ new Date(0),
1090
+ releasedAt: /* @__PURE__ */ new Date()
1091
+ },
1092
+ $unset: {
1093
+ owner: "",
1094
+ runId: ""
1095
+ }
1096
+ }, { writeConcern: { w: "majority" } });
1097
+ };
1098
+ scheduleHeartbeat();
1099
+ return {
1100
+ owner,
1101
+ runId,
1102
+ fence,
1103
+ signal: abortController.signal,
1104
+ assertOwned,
1105
+ release
1106
+ };
1107
+ };
1108
+ //#endregion
1109
+ //#region src/runner.ts
1110
+ var errorMessage = (error) => {
1111
+ return (error instanceof Error ? error.message : String(error)).slice(0, 8e3);
1112
+ };
1113
+ var combineSignals = (...signals) => {
1114
+ const active = signals.filter((signal) => Boolean(signal));
1115
+ if (active.length === 0) return new AbortController().signal;
1116
+ if (active.length === 1) return active[0];
1117
+ return AbortSignal.any(active);
1118
+ };
1119
+ var assertPlanCanRun = (plan) => {
1120
+ const retrying = plan.running.length > 0 || plan.failed.length > 0;
1121
+ const errors = [...plan.integrityErrors, ...retrying ? [] : plan.resourceDivergences.map((divergence) => divergence.message)];
1122
+ if (errors.length > 0) throw new MigrationIntegrityError(`${plan.database}: ${errors.join("; ")}`);
1123
+ };
1124
+ var recordForMigration = (migration, lock, attempt, release) => ({
1125
+ _id: migration.qualifiedId,
1126
+ checksum: migration.checksum,
1127
+ source: migration.source,
1128
+ sourcePosition: migration.sourcePosition,
1129
+ scope: migration.scope,
1130
+ mode: migration.mode,
1131
+ rollback: migration.rollback,
1132
+ status: "running",
1133
+ attempt,
1134
+ ...migration.resources?.before ? { resourcesBeforeHash: migration.resources.before } : {},
1135
+ ...migration.resources ? { resourcesAfterHash: migration.resources.after } : {},
1136
+ ...release ? { release } : {},
1137
+ runId: lock.runId,
1138
+ fence: lock.fence,
1139
+ startedAt: /* @__PURE__ */ new Date(),
1140
+ heartbeatAt: /* @__PURE__ */ new Date()
1141
+ });
1142
+ var startMigration = async (collection, migration, lock, release) => {
1143
+ await lock.assertOwned();
1144
+ const existing = await collection.findOne({ _id: migration.qualifiedId });
1145
+ if (existing?.status === "applied") return existing;
1146
+ if (existing && existing.checksum !== migration.checksum) throw new MigrationIntegrityError(`${migration.qualifiedId}: checksum differs`);
1147
+ const record = recordForMigration(migration, lock, (existing?.attempt ?? 0) + 1, release);
1148
+ if (!existing) {
1149
+ await collection.insertOne(record, { writeConcern: { w: "majority" } });
1150
+ return record;
1151
+ }
1152
+ const { _id: _recordId, ...recordUpdates } = record;
1153
+ const result = await collection.findOneAndUpdate({
1154
+ _id: migration.qualifiedId,
1155
+ checksum: migration.checksum,
1156
+ status: { $in: ["running", "failed"] }
1157
+ }, {
1158
+ $set: {
1159
+ ...recordUpdates,
1160
+ ...existing.checkpoint !== void 0 ? { checkpoint: existing.checkpoint } : {}
1161
+ },
1162
+ $unset: {
1163
+ error: "",
1164
+ appliedAt: "",
1165
+ durationMs: ""
1166
+ }
1167
+ }, {
1168
+ returnDocument: "after",
1169
+ writeConcern: { w: "majority" }
1170
+ });
1171
+ if (!result) throw new MigrationIntegrityError(`${migration.qualifiedId}: history state changed while starting`);
1172
+ return result;
1173
+ };
1174
+ var createCheckpoint = (collection, migration, record, lock) => {
1175
+ let value = record.checkpoint;
1176
+ const update = async (next, clear) => {
1177
+ await lock.assertOwned();
1178
+ if ((await collection.updateOne({
1179
+ _id: migration.qualifiedId,
1180
+ checksum: migration.checksum,
1181
+ status: "running",
1182
+ runId: lock.runId,
1183
+ fence: lock.fence
1184
+ }, clear ? {
1185
+ $unset: { checkpoint: "" },
1186
+ $set: { heartbeatAt: /* @__PURE__ */ new Date() }
1187
+ } : { $set: {
1188
+ checkpoint: next,
1189
+ heartbeatAt: /* @__PURE__ */ new Date()
1190
+ } }, { writeConcern: { w: "majority" } })).matchedCount !== 1) throw new MigrationLockLostError(`${migration.qualifiedId}: checkpoint ownership was lost`);
1191
+ value = next;
1192
+ };
1193
+ return {
1194
+ get value() {
1195
+ return value;
1196
+ },
1197
+ save: async (next) => update(next, false),
1198
+ clear: async () => update(void 0, true)
1199
+ };
1200
+ };
1201
+ var markFailed = async (collection, migration, lock, error) => {
1202
+ await collection.updateOne({
1203
+ _id: migration.qualifiedId,
1204
+ checksum: migration.checksum,
1205
+ status: "running",
1206
+ runId: lock.runId,
1207
+ fence: lock.fence
1208
+ }, { $set: {
1209
+ status: "failed",
1210
+ error: errorMessage(error),
1211
+ heartbeatAt: /* @__PURE__ */ new Date()
1212
+ } }, { writeConcern: { w: "majority" } });
1213
+ };
1214
+ var markApplied = async (collection, migration, record, lock) => {
1215
+ await lock.assertOwned();
1216
+ const appliedAt = /* @__PURE__ */ new Date();
1217
+ if ((await collection.updateOne({
1218
+ _id: migration.qualifiedId,
1219
+ checksum: migration.checksum,
1220
+ status: "running",
1221
+ runId: lock.runId,
1222
+ fence: lock.fence
1223
+ }, {
1224
+ $set: {
1225
+ status: "applied",
1226
+ appliedAt,
1227
+ heartbeatAt: appliedAt,
1228
+ durationMs: appliedAt.getTime() - record.startedAt.getTime()
1229
+ },
1230
+ $unset: { error: "" }
1231
+ }, { writeConcern: { w: "majority" } })).matchedCount !== 1) throw new MigrationLockLostError(`${migration.qualifiedId}: apply ownership was lost`);
1232
+ };
1233
+ var getExpectedTransitionSnapshot = (snapshots, checksum, scope) => {
1234
+ const snapshot = snapshots.get(checksum);
1235
+ if (!snapshot) throw new MigrationIntegrityError(`Missing resource snapshot ${checksum}`);
1236
+ return filterMongoResources(snapshot, scope);
1237
+ };
1238
+ var applyMigration = async (registry, target, migration, lock, signal, release) => {
1239
+ signal.throwIfAborted();
1240
+ const collection = target.db.collection(MIGRATIONS_COLLECTION);
1241
+ const record = await startMigration(collection, migration, lock, release);
1242
+ if (record.status === "applied") return;
1243
+ const checkpoint = createCheckpoint(collection, migration, record, lock);
1244
+ const source = registry.sources.find((item) => item.name === migration.source);
1245
+ const resourceTransition = migration.resources && source ? {
1246
+ ...migration.resources.before ? { before: getExpectedTransitionSnapshot(source.resourceSnapshots, migration.resources.before, target.scope) } : {},
1247
+ after: getExpectedTransitionSnapshot(source.resourceSnapshots, migration.resources.after, target.scope)
1248
+ } : void 0;
1249
+ try {
1250
+ await migration.up({
1251
+ db: target.db,
1252
+ ...target.tenantId ? { tenantId: target.tenantId } : {},
1253
+ checkpoint,
1254
+ signal,
1255
+ helpers: createMigrationHelpers(target.db, signal),
1256
+ ...resourceTransition ? { resources: resourceTransition } : {}
1257
+ });
1258
+ signal.throwIfAborted();
1259
+ if (migration.resources) {
1260
+ const hypothetical = (await readMigrationHistory(target.db)).map((item) => item._id === migration.qualifiedId ? {
1261
+ ...item,
1262
+ status: "applied"
1263
+ } : item);
1264
+ const expected = getExpectedResources(registry, target.scope, hypothetical);
1265
+ const divergences = await inspectMongoResources(target.db, expected, {
1266
+ signal,
1267
+ requireSearchReady: true
1268
+ });
1269
+ if (divergences.length > 0) throw new MigrationIntegrityError(divergences.map((divergence) => divergence.message).join("; "));
1270
+ }
1271
+ await markApplied(collection, migration, record, lock);
1272
+ } catch (error) {
1273
+ await markFailed(collection, migration, lock, error).catch(() => void 0);
1274
+ throw error;
1275
+ }
1276
+ };
1277
+ var sourcePredecessorsApplied = (registry, migration, applied) => registry.sources.find((source) => source.name === migration.source)?.migrations.filter((candidate) => candidate.scope === migration.scope && candidate.sourcePosition < migration.sourcePosition).every((candidate) => applied.has(candidate.qualifiedId)) ?? false;
1278
+ var runTarget = async (registry, target, lock, signal, options, earlierScopeDependencyApplied) => {
1279
+ const plan = await planMigrationTarget(registry, target, { signal });
1280
+ assertPlanCanRun(plan);
1281
+ const applied = new Set(plan.applied);
1282
+ const migrationsToRun = registry.migrations.filter((migration) => migration.scope === target.scope && !applied.has(migration.qualifiedId));
1283
+ for (const migration of migrationsToRun) {
1284
+ if (migration.mode === "offline" && !options.allowOffline) continue;
1285
+ if (!sourcePredecessorsApplied(registry, migration, applied)) continue;
1286
+ let dependenciesReady = true;
1287
+ for (const dependencyId of migration.dependsOn) {
1288
+ const dependency = registry.migrationsById.get(dependencyId);
1289
+ if (!dependency) throw new MigrationIntegrityError(`Unknown dependency ${dependencyId}`);
1290
+ if (!(dependency.scope === target.scope ? applied.has(dependencyId) : await earlierScopeDependencyApplied(dependency))) {
1291
+ dependenciesReady = false;
1292
+ break;
1293
+ }
1294
+ }
1295
+ if (!dependenciesReady) continue;
1296
+ await applyMigration(registry, target, migration, lock, signal, options.release);
1297
+ applied.add(migration.qualifiedId);
1298
+ }
1299
+ return applied;
1300
+ };
1301
+ var runWithConcurrency = async (values, concurrency, action) => {
1302
+ let nextIndex = 0;
1303
+ let failed = false;
1304
+ let failure;
1305
+ const workers = Array.from({ length: Math.min(concurrency, values.length) }, async () => {
1306
+ while (nextIndex < values.length && !failed) {
1307
+ const index = nextIndex;
1308
+ nextIndex += 1;
1309
+ try {
1310
+ await action(values[index]);
1311
+ } catch (error) {
1312
+ if (!failed) failure = error;
1313
+ failed = true;
1314
+ }
1315
+ }
1316
+ });
1317
+ await Promise.all(workers);
1318
+ if (failed) throw failure;
1319
+ };
1320
+ var runMigrations = async (registry, provider, options = {}) => {
1321
+ const concurrency = options.concurrency ?? 4;
1322
+ if (!Number.isInteger(concurrency) || concurrency < 1) throw new Error("Migration concurrency must be a positive integer");
1323
+ const lock = await acquireMigrationLock(await provider.global(), {
1324
+ lockId: options.lockId,
1325
+ leaseMs: options.leaseMs,
1326
+ heartbeatMs: options.heartbeatMs
1327
+ });
1328
+ const signal = combineSignals(options.signal, lock.signal);
1329
+ try {
1330
+ const targets = await collectMigrationTargets(provider, {
1331
+ tenantId: options.tenantId,
1332
+ signal
1333
+ });
1334
+ const globalTarget = targets.find((target) => target.scope === "global");
1335
+ if (!globalTarget) throw new Error("Migration provider did not return a global database");
1336
+ let globalApplied = /* @__PURE__ */ new Set();
1337
+ if (options.initializeTenant) {
1338
+ const globalPlan = await planMigrationTarget(registry, globalTarget, { signal });
1339
+ assertPlanCanRun(globalPlan);
1340
+ if (globalPlan.pending.length > 0 || globalPlan.running.length > 0 || globalPlan.failed.length > 0) throw new MigrationIntegrityError("Global migrations must be current before initializing a tenant");
1341
+ globalApplied = new Set(globalPlan.applied);
1342
+ } else globalApplied = await runTarget(registry, globalTarget, lock, signal, options, async () => false);
1343
+ const tenantApplied = /* @__PURE__ */ new Map();
1344
+ await runWithConcurrency(targets.filter((target) => target.scope === "tenant"), concurrency, async (target) => {
1345
+ const applied = await runTarget(registry, target, lock, signal, options, async (dependency) => dependency.scope === "global" && globalApplied.has(dependency.qualifiedId));
1346
+ if (target.tenantId) tenantApplied.set(target.tenantId, applied);
1347
+ });
1348
+ await runWithConcurrency(targets.filter((target) => target.scope === "filesystem"), concurrency, async (target) => {
1349
+ const appliedForTenant = target.tenantId ? tenantApplied.get(target.tenantId) : void 0;
1350
+ await runTarget(registry, target, lock, signal, options, async (dependency) => {
1351
+ if (dependency.scope === "global") return globalApplied.has(dependency.qualifiedId);
1352
+ if (dependency.scope === "tenant") return appliedForTenant?.has(dependency.qualifiedId) ?? false;
1353
+ return false;
1354
+ });
1355
+ });
1356
+ return await planMigrations(registry, provider, {
1357
+ tenantId: options.tenantId,
1358
+ signal
1359
+ });
1360
+ } finally {
1361
+ await lock.release();
1362
+ }
1363
+ };
1364
+ var initializeTenantMigrations = async (registry, provider, tenantId, options = {}) => await runMigrations(registry, provider, {
1365
+ ...options,
1366
+ tenantId,
1367
+ allowOffline: true,
1368
+ initializeTenant: true
1369
+ });
1370
+ //#endregion
1371
+ //#region src/assertCurrent.ts
1372
+ var assertMigrationsCurrent = async (registry, provider, options = {}) => {
1373
+ const plan = await planMigrations(registry, provider, options);
1374
+ const errors = [];
1375
+ for (const database of plan.databases) {
1376
+ if (database.pending.length > 0) errors.push(`${database.database}: ${database.pending.length} migration(s) pending`);
1377
+ if (database.running.length > 0) errors.push(`${database.database}: migration running`);
1378
+ if (database.failed.length > 0) errors.push(`${database.database}: migration failed`);
1379
+ errors.push(...database.integrityErrors.map((error) => `${database.database}: ${error}`));
1380
+ errors.push(...database.resourceDivergences.map((divergence) => `${database.database}: ${divergence.message}`));
1381
+ }
1382
+ if (errors.length > 0) throw new MigrationIntegrityError(errors.join("; "));
1383
+ return plan;
1384
+ };
1385
+ //#endregion
1386
+ //#region src/integrity.ts
1387
+ var relativeImportPattern = /^(?:\.\.?\/)/;
1388
+ var migrationCallPattern = /defineMigration(?:<[^>]+>)?\s*\(\s*\{/;
1389
+ var incompleteCollectionMigrationPattern = /Implement the collection option migration for/;
1390
+ var typeOnlyImportPattern = /^import\s+type\b/;
1391
+ initSync();
1392
+ var resolveLocalImport = (importer, specifier) => {
1393
+ const base = resolve(dirname(importer), specifier);
1394
+ const resolved = (extname(base) ? [base] : [
1395
+ `${base}.ts`,
1396
+ `${base}.tsx`,
1397
+ `${base}.js`,
1398
+ `${base}.mjs`,
1399
+ resolve(base, "index.ts"),
1400
+ resolve(base, "index.tsx"),
1401
+ resolve(base, "index.js")
1402
+ ]).find((candidate) => existsSync(candidate));
1403
+ if (!resolved) throw new Error(`Cannot resolve migration import ${specifier} from ${importer}`);
1404
+ return resolved;
1405
+ };
1406
+ var collectIntegrityFiles = (entry, rootDir, files, visiting) => {
1407
+ const filePath = resolve(entry);
1408
+ if (visiting.has(filePath) || files.has(filePath)) return;
1409
+ if (!filePath.startsWith(`${rootDir}/`) && filePath !== rootDir) throw new Error(`Migration dependency escapes integrity root: ${filePath}`);
1410
+ visiting.add(filePath);
1411
+ const source = readFileSync(filePath, "utf8");
1412
+ const [imports] = parse(source, filePath);
1413
+ for (const imported of imports) {
1414
+ if (imported.t === ImportType.Dynamic || imported.t === ImportType.DynamicSourcePhase || imported.t === ImportType.DynamicDeferPhase) throw new Error(`Dynamic imports are not allowed in migrations: ${filePath}`);
1415
+ if (imported.t === ImportType.ImportMeta) continue;
1416
+ const declaration = source.slice(imported.ss, imported.se);
1417
+ if (typeOnlyImportPattern.test(declaration)) continue;
1418
+ const specifier = imported.n;
1419
+ if (!specifier) throw new Error(`Cannot resolve migration import in ${filePath}`);
1420
+ if (specifier === "@rpcbase/migrations") continue;
1421
+ if (!relativeImportPattern.test(specifier)) throw new Error(`Mutable runtime import "${specifier}" is not allowed in migration ${filePath}`);
1422
+ collectIntegrityFiles(resolveLocalImport(filePath, specifier), rootDir, files, visiting);
1423
+ }
1424
+ visiting.delete(filePath);
1425
+ files.set(filePath, source);
1426
+ };
1427
+ var computeMigrationIntegrity = (entry, options = {}) => {
1428
+ const entryPath = resolve(entry);
1429
+ const rootDir = resolve(options.rootDir ?? dirname(entryPath));
1430
+ const files = /* @__PURE__ */ new Map();
1431
+ collectIntegrityFiles(entryPath, rootDir, files, /* @__PURE__ */ new Set());
1432
+ return canonicalChecksum([...files.entries()].map(([filePath, source]) => ({
1433
+ path: filePath.slice(rootDir.length),
1434
+ checksum: sha256(source)
1435
+ })).sort((left, right) => left.path.localeCompare(right.path)));
1436
+ };
1437
+ var createMigrationIntegrityPlugin = (options = {}) => ({
1438
+ name: "rpcbase-migration-integrity",
1439
+ enforce: "pre",
1440
+ transform(code, id) {
1441
+ const cleanId = id.split("?", 1)[0];
1442
+ if (!isAbsolute(cleanId) || !migrationCallPattern.test(code)) return null;
1443
+ if (incompleteCollectionMigrationPattern.test(code)) throw new Error(`Incomplete collection option migration: ${cleanId}`);
1444
+ const integrity = computeMigrationIntegrity(cleanId, { rootDir: resolve(options.rootDir ?? dirname(cleanId)) });
1445
+ return {
1446
+ code: code.replace(migrationCallPattern, (match) => `${match}\n __rpcbaseIntegrity: ${JSON.stringify(integrity)},`),
1447
+ map: null
1448
+ };
1449
+ }
1450
+ });
1451
+ //#endregion
1452
+ //#region src/testHarness.ts
1453
+ var testAppNamePattern = /^rbmtest-[a-f0-9]{24}$/;
1454
+ var testTenantIdPattern = /^[a-z0-9][a-z0-9_-]{0,15}$/;
1455
+ var createTestAppName = () => `rbmtest-${randomUUID().replaceAll("-", "").slice(0, 24)}`;
1456
+ var assertTestAppName = (appName) => {
1457
+ if (!testAppNamePattern.test(appName)) throw new Error(`Refusing to use unsafe migrations test app name: ${appName}`);
1458
+ };
1459
+ var databaseNames = (appName, tenantId) => [
1460
+ `${appName}-global-db`,
1461
+ `${appName}-${tenantId}-db`,
1462
+ `${appName}-${tenantId}-filesystem-db`
1463
+ ];
1464
+ var testMigrationRegistry = async (options) => {
1465
+ const appName = options.appName ?? createTestAppName();
1466
+ const recoveryAppName = createTestAppName();
1467
+ const tenantId = options.tenantId ?? "tenant";
1468
+ assertTestAppName(appName);
1469
+ assertTestAppName(recoveryAppName);
1470
+ if (!testTenantIdPattern.test(tenantId)) throw new Error(`Refusing to use unsafe migrations test tenant id: ${tenantId}`);
1471
+ const databases = [...databaseNames(appName, tenantId), ...databaseNames(recoveryAppName, tenantId)];
1472
+ try {
1473
+ const provider = createMigrationDatabaseProvider({
1474
+ client: options.client,
1475
+ appName,
1476
+ filesystemRequired: () => true
1477
+ });
1478
+ await (await provider.global()).collection("rbtenants").insertOne({
1479
+ tenantId,
1480
+ provisioningStatus: "active"
1481
+ });
1482
+ await runMigrations(options.registry, provider, {
1483
+ allowOffline: true,
1484
+ concurrency: 1,
1485
+ signal: options.signal
1486
+ });
1487
+ await assertMigrationsCurrent(options.registry, provider, { signal: options.signal });
1488
+ const recoveryRegistry = compileMigrationRegistry([defineMigrationSource({
1489
+ name: "rbmtest",
1490
+ migrations: [defineMigration({
1491
+ id: "20000101000000-checkpoint-recovery",
1492
+ scope: "global",
1493
+ mode: "online",
1494
+ async up({ checkpoint }) {
1495
+ if (checkpoint.value === 1) return;
1496
+ await checkpoint.save(1);
1497
+ throw new Error("simulated migration interruption");
1498
+ }
1499
+ })]
1500
+ })]);
1501
+ const recoveryProvider = createMigrationDatabaseProvider({
1502
+ client: options.client,
1503
+ appName: recoveryAppName
1504
+ });
1505
+ await runMigrations(recoveryRegistry, recoveryProvider, { signal: options.signal }).then(() => {
1506
+ throw new Error("Checkpoint recovery probe did not interrupt");
1507
+ }, (error) => {
1508
+ if (!(error instanceof Error) || error.message !== "simulated migration interruption") throw error;
1509
+ });
1510
+ await runMigrations(recoveryRegistry, recoveryProvider, { signal: options.signal });
1511
+ await assertMigrationsCurrent(recoveryRegistry, recoveryProvider, { signal: options.signal });
1512
+ const history = await (await recoveryProvider.global()).collection(MIGRATIONS_COLLECTION).findOne({ _id: "rbmtest:20000101000000-checkpoint-recovery" });
1513
+ if (!history || history.status !== "applied" || history.checkpoint !== 1 || history.attempt !== 2) throw new Error("Checkpoint recovery probe did not resume from its saved checkpoint");
1514
+ return {
1515
+ appName,
1516
+ tenantId,
1517
+ checkpointRecoveryAttempt: history.attempt
1518
+ };
1519
+ } finally {
1520
+ await Promise.all(databases.map(async (databaseName) => {
1521
+ if (!databaseName.startsWith(`${appName}-`) && !databaseName.startsWith(`${recoveryAppName}-`)) throw new Error(`Refusing to clean unsafe migrations test database: ${databaseName}`);
1522
+ await options.client.db(databaseName).dropDatabase();
1523
+ }));
1524
+ }
1525
+ };
1526
+ var testSingleMigration = async (options) => {
1527
+ const appName = createTestAppName();
1528
+ assertTestAppName(appName);
1529
+ const tenantId = options.tenantId ?? "tenant";
1530
+ const scope = options.scope ?? options.migration.scope;
1531
+ if (scope !== "global" && !testTenantIdPattern.test(tenantId)) throw new Error(`Refusing to use unsafe migrations test tenant id: ${tenantId}`);
1532
+ const databaseName = scope === "global" ? `${appName}-global-db` : scope === "filesystem" ? `${appName}-${tenantId}-filesystem-db` : `${appName}-${tenantId}-db`;
1533
+ const db = options.client.db(databaseName);
1534
+ const controller = new AbortController();
1535
+ const signal = options.signal ? AbortSignal.any([options.signal, controller.signal]) : controller.signal;
1536
+ try {
1537
+ await options.prepare(db);
1538
+ let checkpointValue = options.checkpoint;
1539
+ const context = {
1540
+ db,
1541
+ ...scope === "global" ? {} : { tenantId },
1542
+ checkpoint: {
1543
+ get value() {
1544
+ return checkpointValue;
1545
+ },
1546
+ save: async (value) => {
1547
+ checkpointValue = value;
1548
+ },
1549
+ clear: async () => {
1550
+ checkpointValue = void 0;
1551
+ }
1552
+ },
1553
+ signal,
1554
+ helpers: createMigrationHelpers(db, signal)
1555
+ };
1556
+ await options.migration.up(context);
1557
+ await options.verify(db);
1558
+ } finally {
1559
+ controller.abort();
1560
+ await db.dropDatabase();
1561
+ }
1562
+ };
1563
+ //#endregion
1564
+ export { MIGRATIONS_COLLECTION, MIGRATION_LOCKS_COLLECTION, MigrationIntegrityError, MigrationLockLostError, MigrationLockUnavailableError, acquireMigrationLock, assertMigrationsCurrent, canonicalChecksum, canonicalStringify, collectMigrationTargets, compileMigrationRegistry, computeMigrationIntegrity, createMigrationDatabaseProvider, createMigrationHelpers, createMigrationIntegrityPlugin, defineMigration, defineMigrationSource, defineMongoResources, diffMongoResources, filterMongoResources, getExpectedResources, getMigrationDatabaseName, initializeTenantMigrations, inspectMongoResources, mergeMongoResources, planMigrationTarget, planMigrations, readMigrationHistory, runMigrations, sha256, testMigrationRegistry, testSingleMigration };
1565
+
1566
+ //# sourceMappingURL=index.js.map