@prisma-next/target-mongo 0.5.0-dev.4 → 0.5.0-dev.41
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/README.md +2 -0
- package/dist/control.d.mts +40 -19
- package/dist/control.d.mts.map +1 -1
- package/dist/control.mjs +100 -103
- package/dist/control.mjs.map +1 -1
- package/dist/descriptor-meta-D9_5quQi.mjs +14 -0
- package/dist/descriptor-meta-D9_5quQi.mjs.map +1 -0
- package/dist/{migration-factories-gwi81C8u.mjs → migration-factories-CoNYWrd1.mjs} +3 -1
- package/dist/migration-factories-CoNYWrd1.mjs.map +1 -0
- package/dist/migration.d.mts +7 -1
- package/dist/migration.d.mts.map +1 -1
- package/dist/migration.mjs +1 -1
- package/dist/{op-factory-call-BjNAcPSF.d.mts → op-factory-call--nK5dk8n.d.mts} +1 -1
- package/dist/{op-factory-call-BjNAcPSF.d.mts.map → op-factory-call--nK5dk8n.d.mts.map} +1 -1
- package/dist/pack.mjs +1 -11
- package/dist/pack.mjs.map +1 -1
- package/dist/runtime.d.mts +20 -0
- package/dist/runtime.d.mts.map +1 -0
- package/dist/runtime.mjs +28 -0
- package/dist/runtime.mjs.map +1 -0
- package/dist/schema-verify.d.mts +22 -0
- package/dist/schema-verify.d.mts.map +1 -0
- package/dist/schema-verify.mjs +3 -0
- package/dist/verify-mongo-schema-Daa7BMJY.mjs +582 -0
- package/dist/verify-mongo-schema-Daa7BMJY.mjs.map +1 -0
- package/package.json +20 -14
- package/src/core/marker-ledger.ts +90 -20
- package/src/core/migration-factories.ts +8 -0
- package/src/core/mongo-ops-serializer.ts +0 -8
- package/src/core/mongo-planner.ts +8 -2
- package/src/core/mongo-runner.ts +105 -70
- package/src/core/planner-produced-migration.ts +0 -1
- package/src/core/render-typescript.ts +3 -7
- package/src/core/schema-diff.ts +402 -0
- package/src/core/schema-verify/canonicalize-introspection.ts +389 -0
- package/src/core/schema-verify/verify-mongo-schema.ts +60 -0
- package/src/exports/runtime.ts +38 -0
- package/src/exports/schema-verify.ts +2 -0
- package/dist/migration-factories-gwi81C8u.mjs.map +0 -1
package/src/core/mongo-runner.ts
CHANGED
|
@@ -8,7 +8,9 @@ import type {
|
|
|
8
8
|
MigrationRunnerExecutionChecks,
|
|
9
9
|
MigrationRunnerFailure,
|
|
10
10
|
MigrationRunnerResult,
|
|
11
|
+
OperationContext,
|
|
11
12
|
} from '@prisma-next/framework-components/control';
|
|
13
|
+
import type { MongoContract } from '@prisma-next/mongo-contract';
|
|
12
14
|
import type { MongoAdapter, MongoDriver } from '@prisma-next/mongo-lowering';
|
|
13
15
|
import type {
|
|
14
16
|
AnyMongoMigrationOperation,
|
|
@@ -19,31 +21,28 @@ import type {
|
|
|
19
21
|
MongoMigrationCheck,
|
|
20
22
|
MongoMigrationPlanOperation,
|
|
21
23
|
} from '@prisma-next/mongo-query-ast/control';
|
|
24
|
+
import type { MongoSchemaIR } from '@prisma-next/mongo-schema-ir';
|
|
22
25
|
import { notOk, ok } from '@prisma-next/utils/result';
|
|
23
|
-
|
|
24
|
-
const READ_ONLY_CHECK_COMMAND_KINDS: ReadonlySet<string> = new Set(['aggregate', 'rawAggregate']);
|
|
25
|
-
|
|
26
|
-
function hasProfileHash(value: unknown): value is { readonly profileHash: string } {
|
|
27
|
-
return (
|
|
28
|
-
typeof value === 'object' &&
|
|
29
|
-
value !== null &&
|
|
30
|
-
Object.hasOwn(value, 'profileHash') &&
|
|
31
|
-
typeof (value as { profileHash: unknown }).profileHash === 'string'
|
|
32
|
-
);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
26
|
import { FilterEvaluator } from './filter-evaluator';
|
|
36
27
|
import { deserializeMongoOps } from './mongo-ops-serializer';
|
|
28
|
+
import { verifyMongoSchema } from './schema-verify/verify-mongo-schema';
|
|
29
|
+
|
|
30
|
+
const READ_ONLY_CHECK_COMMAND_KINDS: ReadonlySet<string> = new Set(['aggregate', 'rawAggregate']);
|
|
37
31
|
|
|
38
32
|
export interface MarkerOperations {
|
|
39
33
|
readMarker(): Promise<ContractMarkerRecord | null>;
|
|
40
34
|
initMarker(destination: {
|
|
41
35
|
readonly storageHash: string;
|
|
42
36
|
readonly profileHash: string;
|
|
37
|
+
readonly invariants?: readonly string[];
|
|
43
38
|
}): Promise<void>;
|
|
44
39
|
updateMarker(
|
|
45
40
|
expectedFrom: string,
|
|
46
|
-
destination: {
|
|
41
|
+
destination: {
|
|
42
|
+
readonly storageHash: string;
|
|
43
|
+
readonly profileHash: string;
|
|
44
|
+
readonly invariants?: readonly string[];
|
|
45
|
+
},
|
|
47
46
|
): Promise<boolean>;
|
|
48
47
|
writeLedgerEntry(entry: {
|
|
49
48
|
readonly edgeId: string;
|
|
@@ -58,6 +57,21 @@ export interface MongoRunnerDependencies {
|
|
|
58
57
|
readonly adapter: MongoAdapter;
|
|
59
58
|
readonly driver: MongoDriver;
|
|
60
59
|
readonly markerOps: MarkerOperations;
|
|
60
|
+
readonly introspectSchema: () => Promise<MongoSchemaIR>;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface MongoMigrationRunnerExecuteOptions {
|
|
64
|
+
readonly plan: MigrationPlan;
|
|
65
|
+
readonly destinationContract: MongoContract;
|
|
66
|
+
readonly policy: MigrationOperationPolicy;
|
|
67
|
+
readonly callbacks?: {
|
|
68
|
+
onOperationStart?(op: MigrationPlanOperation): void;
|
|
69
|
+
onOperationComplete?(op: MigrationPlanOperation): void;
|
|
70
|
+
};
|
|
71
|
+
readonly executionChecks?: MigrationRunnerExecutionChecks;
|
|
72
|
+
readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'mongo', 'mongo'>>;
|
|
73
|
+
readonly strictVerification?: boolean;
|
|
74
|
+
readonly context?: OperationContext;
|
|
61
75
|
}
|
|
62
76
|
|
|
63
77
|
function runnerFailure(
|
|
@@ -75,17 +89,7 @@ function runnerFailure(
|
|
|
75
89
|
export class MongoMigrationRunner {
|
|
76
90
|
constructor(private readonly deps: MongoRunnerDependencies) {}
|
|
77
91
|
|
|
78
|
-
async execute(options: {
|
|
79
|
-
readonly plan: MigrationPlan;
|
|
80
|
-
readonly destinationContract: unknown;
|
|
81
|
-
readonly policy: MigrationOperationPolicy;
|
|
82
|
-
readonly callbacks?: {
|
|
83
|
-
onOperationStart?(op: MigrationPlanOperation): void;
|
|
84
|
-
onOperationComplete?(op: MigrationPlanOperation): void;
|
|
85
|
-
};
|
|
86
|
-
readonly executionChecks?: MigrationRunnerExecutionChecks;
|
|
87
|
-
readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'mongo', 'mongo'>>;
|
|
88
|
-
}): Promise<MigrationRunnerResult> {
|
|
92
|
+
async execute(options: MongoMigrationRunnerExecuteOptions): Promise<MigrationRunnerResult> {
|
|
89
93
|
const { commandExecutor, inspectionExecutor, adapter, driver, markerOps } = this.deps;
|
|
90
94
|
const operations = deserializeMongoOps(options.plan.operations as readonly unknown[]);
|
|
91
95
|
|
|
@@ -176,49 +180,84 @@ export class MongoMigrationRunner {
|
|
|
176
180
|
}
|
|
177
181
|
|
|
178
182
|
const destination = options.plan.destination;
|
|
179
|
-
const profileHash =
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
183
|
+
const profileHash = options.destinationContract.profileHash ?? destination.storageHash;
|
|
184
|
+
|
|
185
|
+
const incomingInvariants = options.plan.providedInvariants ?? [];
|
|
186
|
+
const existingInvariantSet = new Set(existingMarker?.invariants ?? []);
|
|
187
|
+
const incomingIsSubsetOfExisting = incomingInvariants.every((id) =>
|
|
188
|
+
existingInvariantSet.has(id),
|
|
189
|
+
);
|
|
190
|
+
const markerAlreadyAtDestination =
|
|
191
|
+
existingMarker !== null &&
|
|
192
|
+
existingMarker.storageHash === destination.storageHash &&
|
|
193
|
+
existingMarker.profileHash === profileHash;
|
|
194
|
+
|
|
195
|
+
// Skip marker/ledger writes (and schema verification) only when the apply
|
|
196
|
+
// is a true no-op: no operations executed, marker already at destination,
|
|
197
|
+
// and every incoming invariant is already in the stored set.
|
|
198
|
+
//
|
|
199
|
+
// Divergence from the SQL runners (postgres/sqlite): those runners gate
|
|
200
|
+
// the no-op skip on `isSelfEdge` (origin === destination) only, so a
|
|
201
|
+
// non-self-edge `db update` that introspects-as-no-op still writes a
|
|
202
|
+
// ledger entry. Mongo skips even those because the runner has no
|
|
203
|
+
// structural distinction between self-edge and re-apply — invariant-
|
|
204
|
+
// aware routing here does not yet differentiate between the two
|
|
205
|
+
// ledger semantics. If the SQL audit-trail behavior should hold for
|
|
206
|
+
// Mongo too, gate this `isNoOp` on a self-edge check (or, conversely,
|
|
207
|
+
// align the SQL runners to skip non-self-edge no-ops uniformly).
|
|
208
|
+
const isNoOp =
|
|
209
|
+
operationsExecuted === 0 && markerAlreadyAtDestination && incomingIsSubsetOfExisting;
|
|
210
|
+
|
|
211
|
+
if (!isNoOp) {
|
|
212
|
+
const liveSchema = await this.deps.introspectSchema();
|
|
213
|
+
const verifyResult = verifyMongoSchema({
|
|
214
|
+
contract: options.destinationContract,
|
|
215
|
+
schema: liveSchema,
|
|
216
|
+
strict: options.strictVerification ?? true,
|
|
217
|
+
frameworkComponents: options.frameworkComponents,
|
|
218
|
+
...(options.context ? { context: options.context } : {}),
|
|
195
219
|
});
|
|
196
|
-
if (!
|
|
197
|
-
return runnerFailure(
|
|
198
|
-
'
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
220
|
+
if (!verifyResult.ok) {
|
|
221
|
+
return runnerFailure('SCHEMA_VERIFY_FAILED', verifyResult.summary, {
|
|
222
|
+
why: 'The resulting database schema does not satisfy the destination contract.',
|
|
223
|
+
meta: { issues: verifyResult.schema.issues },
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (existingMarker) {
|
|
228
|
+
const updated = await markerOps.updateMarker(existingMarker.storageHash, {
|
|
229
|
+
storageHash: destination.storageHash,
|
|
230
|
+
profileHash,
|
|
231
|
+
invariants: incomingInvariants,
|
|
232
|
+
});
|
|
233
|
+
if (!updated) {
|
|
234
|
+
return runnerFailure(
|
|
235
|
+
'MARKER_CAS_FAILURE',
|
|
236
|
+
'Marker was modified by another process during migration execution.',
|
|
237
|
+
{
|
|
238
|
+
meta: {
|
|
239
|
+
expectedStorageHash: existingMarker.storageHash,
|
|
240
|
+
destinationStorageHash: destination.storageHash,
|
|
241
|
+
},
|
|
204
242
|
},
|
|
205
|
-
|
|
206
|
-
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
await markerOps.initMarker({
|
|
247
|
+
storageHash: destination.storageHash,
|
|
248
|
+
profileHash,
|
|
249
|
+
invariants: incomingInvariants,
|
|
250
|
+
});
|
|
207
251
|
}
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
252
|
+
|
|
253
|
+
const originHash = existingMarker?.storageHash ?? '';
|
|
254
|
+
await markerOps.writeLedgerEntry({
|
|
255
|
+
edgeId: `${originHash}->${destination.storageHash}`,
|
|
256
|
+
from: originHash,
|
|
257
|
+
to: destination.storageHash,
|
|
212
258
|
});
|
|
213
259
|
}
|
|
214
260
|
|
|
215
|
-
const originHash = existingMarker?.storageHash ?? '';
|
|
216
|
-
await markerOps.writeLedgerEntry({
|
|
217
|
-
edgeId: `${originHash}->${destination.storageHash}`,
|
|
218
|
-
from: originHash,
|
|
219
|
-
to: destination.storageHash,
|
|
220
|
-
});
|
|
221
|
-
|
|
222
261
|
return ok({ operationsPlanned: operations.length, operationsExecuted });
|
|
223
262
|
}
|
|
224
263
|
|
|
@@ -259,7 +298,7 @@ export class MongoMigrationRunner {
|
|
|
259
298
|
}
|
|
260
299
|
|
|
261
300
|
for (const plan of op.run) {
|
|
262
|
-
const wireCommand = adapter.lower(plan);
|
|
301
|
+
const wireCommand = await adapter.lower(plan, {});
|
|
263
302
|
for await (const _ of driver.execute(wireCommand)) {
|
|
264
303
|
/* consume */
|
|
265
304
|
}
|
|
@@ -307,7 +346,7 @@ export class MongoMigrationRunner {
|
|
|
307
346
|
},
|
|
308
347
|
);
|
|
309
348
|
}
|
|
310
|
-
const wireCommand = adapter.lower(check.source);
|
|
349
|
+
const wireCommand = await adapter.lower(check.source, {});
|
|
311
350
|
let matchFound = false;
|
|
312
351
|
for await (const row of driver.execute<Record<string, unknown>>(wireCommand)) {
|
|
313
352
|
if (filterEvaluator.evaluate(check.filter, row)) {
|
|
@@ -375,13 +414,9 @@ export class MongoMigrationRunner {
|
|
|
375
414
|
): MigrationRunnerResult | undefined {
|
|
376
415
|
const origin = plan.origin ?? null;
|
|
377
416
|
if (!origin) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
'Database already has a contract marker but the plan has no origin. This would silently overwrite the existing marker.',
|
|
382
|
-
{ meta: { markerStorageHash: marker.storageHash } },
|
|
383
|
-
);
|
|
384
|
-
}
|
|
417
|
+
// No origin assertion on the plan — the caller has done its own
|
|
418
|
+
// correctness check (typically `db update` via live-schema
|
|
419
|
+
// introspection) and does not rely on marker continuity.
|
|
385
420
|
return undefined;
|
|
386
421
|
}
|
|
387
422
|
|
|
@@ -3,9 +3,8 @@ import { type ImportRequirement, jsonToTsSource, renderImports } from '@prisma-n
|
|
|
3
3
|
import type { OpFactoryCall } from './op-factory-call';
|
|
4
4
|
|
|
5
5
|
export interface RenderMigrationMeta {
|
|
6
|
-
readonly from: string;
|
|
6
|
+
readonly from: string | null;
|
|
7
7
|
readonly to: string;
|
|
8
|
-
readonly kind?: string;
|
|
9
8
|
readonly labels?: readonly string[];
|
|
10
9
|
}
|
|
11
10
|
|
|
@@ -36,8 +35,8 @@ const BASE_IMPORTS: readonly ImportRequirement[] = [
|
|
|
36
35
|
* `Migration` (i.e. `MongoMigration`) from `@prisma-next/family-mongo`, and
|
|
37
36
|
* implements the abstract `operations` and `describe` members. `meta` is
|
|
38
37
|
* always rendered — `describe()` is part of the `Migration` contract, so
|
|
39
|
-
* even an empty stub must satisfy it; callers pass
|
|
40
|
-
* migration-new scaffold.
|
|
38
|
+
* even an empty stub must satisfy it; callers pass `from: null` for a
|
|
39
|
+
* baseline `migration-new` scaffold (and a real `to` hash either way).
|
|
41
40
|
*
|
|
42
41
|
* The walk is polymorphic: each call node contributes its own
|
|
43
42
|
* `renderTypeScript()` expression and declares its own
|
|
@@ -89,9 +88,6 @@ function buildDescribeMethod(meta: RenderMigrationMeta): string {
|
|
|
89
88
|
lines.push(' return {');
|
|
90
89
|
lines.push(` from: ${JSON.stringify(meta.from)},`);
|
|
91
90
|
lines.push(` to: ${JSON.stringify(meta.to)},`);
|
|
92
|
-
if (meta.kind) {
|
|
93
|
-
lines.push(` kind: ${JSON.stringify(meta.kind)},`);
|
|
94
|
-
}
|
|
95
91
|
if (meta.labels && meta.labels.length > 0) {
|
|
96
92
|
lines.push(` labels: ${jsonToTsSource(meta.labels)},`);
|
|
97
93
|
}
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SchemaIssue,
|
|
3
|
+
SchemaVerificationNode,
|
|
4
|
+
} from '@prisma-next/framework-components/control';
|
|
5
|
+
import type {
|
|
6
|
+
MongoSchemaCollection,
|
|
7
|
+
MongoSchemaIndex,
|
|
8
|
+
MongoSchemaIR,
|
|
9
|
+
} from '@prisma-next/mongo-schema-ir';
|
|
10
|
+
import { canonicalize, deepEqual } from '@prisma-next/mongo-schema-ir';
|
|
11
|
+
|
|
12
|
+
export function diffMongoSchemas(
|
|
13
|
+
live: MongoSchemaIR,
|
|
14
|
+
expected: MongoSchemaIR,
|
|
15
|
+
strict: boolean,
|
|
16
|
+
): {
|
|
17
|
+
root: SchemaVerificationNode;
|
|
18
|
+
issues: SchemaIssue[];
|
|
19
|
+
counts: { pass: number; warn: number; fail: number; totalNodes: number };
|
|
20
|
+
} {
|
|
21
|
+
const issues: SchemaIssue[] = [];
|
|
22
|
+
const collectionChildren: SchemaVerificationNode[] = [];
|
|
23
|
+
let pass = 0;
|
|
24
|
+
let warn = 0;
|
|
25
|
+
let fail = 0;
|
|
26
|
+
|
|
27
|
+
const allNames = new Set([...live.collectionNames, ...expected.collectionNames]);
|
|
28
|
+
|
|
29
|
+
for (const name of [...allNames].sort()) {
|
|
30
|
+
const liveColl = live.collection(name);
|
|
31
|
+
const expectedColl = expected.collection(name);
|
|
32
|
+
|
|
33
|
+
if (!liveColl && expectedColl) {
|
|
34
|
+
issues.push({
|
|
35
|
+
kind: 'missing_table',
|
|
36
|
+
table: name,
|
|
37
|
+
message: `Collection "${name}" is missing from the database`,
|
|
38
|
+
});
|
|
39
|
+
collectionChildren.push({
|
|
40
|
+
status: 'fail',
|
|
41
|
+
kind: 'collection',
|
|
42
|
+
name,
|
|
43
|
+
contractPath: `storage.collections.${name}`,
|
|
44
|
+
code: 'MISSING_COLLECTION',
|
|
45
|
+
message: `Collection "${name}" is missing`,
|
|
46
|
+
expected: name,
|
|
47
|
+
actual: null,
|
|
48
|
+
children: [],
|
|
49
|
+
});
|
|
50
|
+
fail++;
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (liveColl && !expectedColl) {
|
|
55
|
+
const status = strict ? 'fail' : 'warn';
|
|
56
|
+
issues.push({
|
|
57
|
+
kind: 'extra_table',
|
|
58
|
+
table: name,
|
|
59
|
+
message: `Extra collection "${name}" exists in the database but not in the contract`,
|
|
60
|
+
});
|
|
61
|
+
collectionChildren.push({
|
|
62
|
+
status,
|
|
63
|
+
kind: 'collection',
|
|
64
|
+
name,
|
|
65
|
+
contractPath: `storage.collections.${name}`,
|
|
66
|
+
code: 'EXTRA_COLLECTION',
|
|
67
|
+
message: `Extra collection "${name}" found`,
|
|
68
|
+
expected: null,
|
|
69
|
+
actual: name,
|
|
70
|
+
children: [],
|
|
71
|
+
});
|
|
72
|
+
if (status === 'fail') fail++;
|
|
73
|
+
else warn++;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const lc = liveColl as MongoSchemaCollection;
|
|
78
|
+
const ec = expectedColl as MongoSchemaCollection;
|
|
79
|
+
const indexChildren = diffIndexes(name, lc, ec, strict, issues);
|
|
80
|
+
const validatorChildren = diffValidator(name, lc, ec, strict, issues);
|
|
81
|
+
const optionsChildren = diffOptions(name, lc, ec, strict, issues);
|
|
82
|
+
const children = [...indexChildren, ...validatorChildren, ...optionsChildren];
|
|
83
|
+
|
|
84
|
+
const worstStatus = children.reduce<'pass' | 'warn' | 'fail'>(
|
|
85
|
+
(s, c) => (c.status === 'fail' ? 'fail' : c.status === 'warn' && s !== 'fail' ? 'warn' : s),
|
|
86
|
+
'pass',
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
for (const c of children) {
|
|
90
|
+
if (c.status === 'pass') pass++;
|
|
91
|
+
else if (c.status === 'warn') warn++;
|
|
92
|
+
else fail++;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (children.length === 0) {
|
|
96
|
+
pass++;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
collectionChildren.push({
|
|
100
|
+
status: worstStatus,
|
|
101
|
+
kind: 'collection',
|
|
102
|
+
name,
|
|
103
|
+
contractPath: `storage.collections.${name}`,
|
|
104
|
+
code: worstStatus === 'pass' ? 'MATCH' : 'DRIFT',
|
|
105
|
+
message:
|
|
106
|
+
worstStatus === 'pass' ? `Collection "${name}" matches` : `Collection "${name}" has drift`,
|
|
107
|
+
expected: name,
|
|
108
|
+
actual: name,
|
|
109
|
+
children,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const rootStatus = fail > 0 ? 'fail' : warn > 0 ? 'warn' : 'pass';
|
|
114
|
+
const totalNodes = pass + warn + fail + collectionChildren.length;
|
|
115
|
+
|
|
116
|
+
const root: SchemaVerificationNode = {
|
|
117
|
+
status: rootStatus,
|
|
118
|
+
kind: 'root',
|
|
119
|
+
name: 'mongo-schema',
|
|
120
|
+
contractPath: 'storage',
|
|
121
|
+
code: rootStatus === 'pass' ? 'MATCH' : 'DRIFT',
|
|
122
|
+
message: rootStatus === 'pass' ? 'Schema matches' : 'Schema has drift',
|
|
123
|
+
expected: null,
|
|
124
|
+
actual: null,
|
|
125
|
+
children: collectionChildren,
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
return { root, issues, counts: { pass, warn, fail, totalNodes } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function buildIndexLookupKey(index: MongoSchemaIndex): string {
|
|
132
|
+
const keys = index.keys.map((k) => `${k.field}:${k.direction}`).join(',');
|
|
133
|
+
const opts = [
|
|
134
|
+
index.unique ? 'unique' : '',
|
|
135
|
+
index.sparse ? 'sparse' : '',
|
|
136
|
+
index.expireAfterSeconds != null ? `ttl:${index.expireAfterSeconds}` : '',
|
|
137
|
+
index.partialFilterExpression ? `pfe:${canonicalize(index.partialFilterExpression)}` : '',
|
|
138
|
+
index.wildcardProjection ? `wp:${canonicalize(index.wildcardProjection)}` : '',
|
|
139
|
+
index.collation ? `col:${canonicalize(index.collation)}` : '',
|
|
140
|
+
index.weights ? `wt:${canonicalize(index.weights)}` : '',
|
|
141
|
+
index.default_language ? `dl:${index.default_language}` : '',
|
|
142
|
+
index.language_override ? `lo:${index.language_override}` : '',
|
|
143
|
+
]
|
|
144
|
+
.filter(Boolean)
|
|
145
|
+
.join(';');
|
|
146
|
+
return opts ? `${keys}|${opts}` : keys;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function formatIndexName(index: MongoSchemaIndex): string {
|
|
150
|
+
return index.keys.map((k) => `${k.field}:${k.direction}`).join(', ');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function diffIndexes(
|
|
154
|
+
collName: string,
|
|
155
|
+
live: MongoSchemaCollection,
|
|
156
|
+
expected: MongoSchemaCollection,
|
|
157
|
+
strict: boolean,
|
|
158
|
+
issues: SchemaIssue[],
|
|
159
|
+
): SchemaVerificationNode[] {
|
|
160
|
+
const nodes: SchemaVerificationNode[] = [];
|
|
161
|
+
const liveLookup = new Map<string, MongoSchemaIndex>();
|
|
162
|
+
for (const idx of live.indexes) liveLookup.set(buildIndexLookupKey(idx), idx);
|
|
163
|
+
|
|
164
|
+
const expectedLookup = new Map<string, MongoSchemaIndex>();
|
|
165
|
+
for (const idx of expected.indexes) expectedLookup.set(buildIndexLookupKey(idx), idx);
|
|
166
|
+
|
|
167
|
+
for (const [key, idx] of expectedLookup) {
|
|
168
|
+
if (liveLookup.has(key)) {
|
|
169
|
+
nodes.push({
|
|
170
|
+
status: 'pass',
|
|
171
|
+
kind: 'index',
|
|
172
|
+
name: formatIndexName(idx),
|
|
173
|
+
contractPath: `storage.collections.${collName}.indexes`,
|
|
174
|
+
code: 'MATCH',
|
|
175
|
+
message: `Index ${formatIndexName(idx)} matches`,
|
|
176
|
+
expected: key,
|
|
177
|
+
actual: key,
|
|
178
|
+
children: [],
|
|
179
|
+
});
|
|
180
|
+
} else {
|
|
181
|
+
issues.push({
|
|
182
|
+
kind: 'index_mismatch',
|
|
183
|
+
table: collName,
|
|
184
|
+
indexOrConstraint: formatIndexName(idx),
|
|
185
|
+
message: `Index ${formatIndexName(idx)} missing on collection "${collName}"`,
|
|
186
|
+
});
|
|
187
|
+
nodes.push({
|
|
188
|
+
status: 'fail',
|
|
189
|
+
kind: 'index',
|
|
190
|
+
name: formatIndexName(idx),
|
|
191
|
+
contractPath: `storage.collections.${collName}.indexes`,
|
|
192
|
+
code: 'MISSING_INDEX',
|
|
193
|
+
message: `Index ${formatIndexName(idx)} missing`,
|
|
194
|
+
expected: key,
|
|
195
|
+
actual: null,
|
|
196
|
+
children: [],
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
for (const [key, idx] of liveLookup) {
|
|
202
|
+
if (!expectedLookup.has(key)) {
|
|
203
|
+
const status = strict ? 'fail' : 'warn';
|
|
204
|
+
issues.push({
|
|
205
|
+
kind: 'extra_index',
|
|
206
|
+
table: collName,
|
|
207
|
+
indexOrConstraint: formatIndexName(idx),
|
|
208
|
+
message: `Extra index ${formatIndexName(idx)} on collection "${collName}"`,
|
|
209
|
+
});
|
|
210
|
+
nodes.push({
|
|
211
|
+
status,
|
|
212
|
+
kind: 'index',
|
|
213
|
+
name: formatIndexName(idx),
|
|
214
|
+
contractPath: `storage.collections.${collName}.indexes`,
|
|
215
|
+
code: 'EXTRA_INDEX',
|
|
216
|
+
message: `Extra index ${formatIndexName(idx)}`,
|
|
217
|
+
expected: null,
|
|
218
|
+
actual: key,
|
|
219
|
+
children: [],
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return nodes;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function diffValidator(
|
|
228
|
+
collName: string,
|
|
229
|
+
live: MongoSchemaCollection,
|
|
230
|
+
expected: MongoSchemaCollection,
|
|
231
|
+
strict: boolean,
|
|
232
|
+
issues: SchemaIssue[],
|
|
233
|
+
): SchemaVerificationNode[] {
|
|
234
|
+
if (!live.validator && !expected.validator) return [];
|
|
235
|
+
|
|
236
|
+
if (expected.validator && !live.validator) {
|
|
237
|
+
issues.push({
|
|
238
|
+
kind: 'type_missing',
|
|
239
|
+
table: collName,
|
|
240
|
+
message: `Validator missing on collection "${collName}"`,
|
|
241
|
+
});
|
|
242
|
+
return [
|
|
243
|
+
{
|
|
244
|
+
status: 'fail',
|
|
245
|
+
kind: 'validator',
|
|
246
|
+
name: 'validator',
|
|
247
|
+
contractPath: `storage.collections.${collName}.validator`,
|
|
248
|
+
code: 'MISSING_VALIDATOR',
|
|
249
|
+
message: 'Validator missing',
|
|
250
|
+
expected: canonicalize(expected.validator.jsonSchema),
|
|
251
|
+
actual: null,
|
|
252
|
+
children: [],
|
|
253
|
+
},
|
|
254
|
+
];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (!expected.validator && live.validator) {
|
|
258
|
+
const status = strict ? 'fail' : 'warn';
|
|
259
|
+
issues.push({
|
|
260
|
+
kind: 'extra_validator',
|
|
261
|
+
table: collName,
|
|
262
|
+
message: `Extra validator on collection "${collName}"`,
|
|
263
|
+
});
|
|
264
|
+
return [
|
|
265
|
+
{
|
|
266
|
+
status,
|
|
267
|
+
kind: 'validator',
|
|
268
|
+
name: 'validator',
|
|
269
|
+
contractPath: `storage.collections.${collName}.validator`,
|
|
270
|
+
code: 'EXTRA_VALIDATOR',
|
|
271
|
+
message: 'Extra validator found',
|
|
272
|
+
expected: null,
|
|
273
|
+
actual: canonicalize(live.validator.jsonSchema),
|
|
274
|
+
children: [],
|
|
275
|
+
},
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const liveVal = live.validator as NonNullable<typeof live.validator>;
|
|
280
|
+
const expectedVal = expected.validator as NonNullable<typeof expected.validator>;
|
|
281
|
+
const liveSchema = canonicalize(liveVal.jsonSchema);
|
|
282
|
+
const expectedSchema = canonicalize(expectedVal.jsonSchema);
|
|
283
|
+
|
|
284
|
+
if (
|
|
285
|
+
liveSchema !== expectedSchema ||
|
|
286
|
+
liveVal.validationLevel !== expectedVal.validationLevel ||
|
|
287
|
+
liveVal.validationAction !== expectedVal.validationAction
|
|
288
|
+
) {
|
|
289
|
+
issues.push({
|
|
290
|
+
kind: 'type_mismatch',
|
|
291
|
+
table: collName,
|
|
292
|
+
expected: expectedSchema,
|
|
293
|
+
actual: liveSchema,
|
|
294
|
+
message: `Validator mismatch on collection "${collName}"`,
|
|
295
|
+
});
|
|
296
|
+
return [
|
|
297
|
+
{
|
|
298
|
+
status: 'fail',
|
|
299
|
+
kind: 'validator',
|
|
300
|
+
name: 'validator',
|
|
301
|
+
contractPath: `storage.collections.${collName}.validator`,
|
|
302
|
+
code: 'VALIDATOR_MISMATCH',
|
|
303
|
+
message: 'Validator mismatch',
|
|
304
|
+
expected: {
|
|
305
|
+
jsonSchema: expectedVal.jsonSchema,
|
|
306
|
+
validationLevel: expectedVal.validationLevel,
|
|
307
|
+
validationAction: expectedVal.validationAction,
|
|
308
|
+
},
|
|
309
|
+
actual: {
|
|
310
|
+
jsonSchema: liveVal.jsonSchema,
|
|
311
|
+
validationLevel: liveVal.validationLevel,
|
|
312
|
+
validationAction: liveVal.validationAction,
|
|
313
|
+
},
|
|
314
|
+
children: [],
|
|
315
|
+
},
|
|
316
|
+
];
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
return [
|
|
320
|
+
{
|
|
321
|
+
status: 'pass',
|
|
322
|
+
kind: 'validator',
|
|
323
|
+
name: 'validator',
|
|
324
|
+
contractPath: `storage.collections.${collName}.validator`,
|
|
325
|
+
code: 'MATCH',
|
|
326
|
+
message: 'Validator matches',
|
|
327
|
+
expected: expectedSchema,
|
|
328
|
+
actual: liveSchema,
|
|
329
|
+
children: [],
|
|
330
|
+
},
|
|
331
|
+
];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function diffOptions(
|
|
335
|
+
collName: string,
|
|
336
|
+
live: MongoSchemaCollection,
|
|
337
|
+
expected: MongoSchemaCollection,
|
|
338
|
+
strict: boolean,
|
|
339
|
+
issues: SchemaIssue[],
|
|
340
|
+
): SchemaVerificationNode[] {
|
|
341
|
+
if (!live.options && !expected.options) return [];
|
|
342
|
+
|
|
343
|
+
if (!expected.options && live.options) {
|
|
344
|
+
const status = strict ? 'fail' : 'warn';
|
|
345
|
+
issues.push({
|
|
346
|
+
kind: 'type_mismatch',
|
|
347
|
+
table: collName,
|
|
348
|
+
actual: canonicalize(live.options),
|
|
349
|
+
message: `Extra collection options on "${collName}"`,
|
|
350
|
+
});
|
|
351
|
+
return [
|
|
352
|
+
{
|
|
353
|
+
status,
|
|
354
|
+
kind: 'options',
|
|
355
|
+
name: 'options',
|
|
356
|
+
contractPath: `storage.collections.${collName}.options`,
|
|
357
|
+
code: 'EXTRA_OPTIONS',
|
|
358
|
+
message: 'Extra collection options found',
|
|
359
|
+
expected: null,
|
|
360
|
+
actual: live.options,
|
|
361
|
+
children: [],
|
|
362
|
+
},
|
|
363
|
+
];
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
if (deepEqual(live.options, expected.options)) {
|
|
367
|
+
return [
|
|
368
|
+
{
|
|
369
|
+
status: 'pass',
|
|
370
|
+
kind: 'options',
|
|
371
|
+
name: 'options',
|
|
372
|
+
contractPath: `storage.collections.${collName}.options`,
|
|
373
|
+
code: 'MATCH',
|
|
374
|
+
message: 'Collection options match',
|
|
375
|
+
expected: canonicalize(expected.options),
|
|
376
|
+
actual: canonicalize(live.options),
|
|
377
|
+
children: [],
|
|
378
|
+
},
|
|
379
|
+
];
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
issues.push({
|
|
383
|
+
kind: 'type_mismatch',
|
|
384
|
+
table: collName,
|
|
385
|
+
expected: canonicalize(expected.options),
|
|
386
|
+
actual: canonicalize(live.options),
|
|
387
|
+
message: `Collection options mismatch on "${collName}"`,
|
|
388
|
+
});
|
|
389
|
+
return [
|
|
390
|
+
{
|
|
391
|
+
status: 'fail',
|
|
392
|
+
kind: 'options',
|
|
393
|
+
name: 'options',
|
|
394
|
+
contractPath: `storage.collections.${collName}.options`,
|
|
395
|
+
code: 'OPTIONS_MISMATCH',
|
|
396
|
+
message: 'Collection options mismatch',
|
|
397
|
+
expected: expected.options,
|
|
398
|
+
actual: live.options,
|
|
399
|
+
children: [],
|
|
400
|
+
},
|
|
401
|
+
];
|
|
402
|
+
}
|