@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/assertCurrent.d.ts +3 -0
- package/dist/assertCurrent.d.ts.map +1 -0
- package/dist/canonical.d.ts +4 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/databaseProvider.d.ts +11 -0
- package/dist/databaseProvider.d.ts.map +1 -0
- package/dist/diffResources.d.ts +3 -0
- package/dist/diffResources.d.ts.map +1 -0
- package/dist/errors.d.ts +13 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/helpers.d.ts +8 -0
- package/dist/helpers.d.ts.map +1 -0
- package/dist/history.d.ts +5 -0
- package/dist/history.d.ts.map +1 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1566 -0
- package/dist/index.js.map +1 -0
- package/dist/indexDefinition.d.ts +7 -0
- package/dist/indexDefinition.d.ts.map +1 -0
- package/dist/inspectResources.d.ts +8 -0
- package/dist/inspectResources.d.ts.map +1 -0
- package/dist/integrity.d.ts +16 -0
- package/dist/integrity.d.ts.map +1 -0
- package/dist/lock.d.ts +19 -0
- package/dist/lock.d.ts.map +1 -0
- package/dist/planner.d.ts +6 -0
- package/dist/planner.d.ts.map +1 -0
- package/dist/registry.d.ts +5 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/resources.d.ts +5 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/runner.d.ts +4 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/testHarness.d.ts +27 -0
- package/dist/testHarness.d.ts.map +1 -0
- package/dist/types.d.ts +248 -0
- package/dist/types.d.ts.map +1 -0
- package/package.json +83 -0
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
|