@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.
Files changed (39) hide show
  1. package/README.md +2 -0
  2. package/dist/control.d.mts +40 -19
  3. package/dist/control.d.mts.map +1 -1
  4. package/dist/control.mjs +100 -103
  5. package/dist/control.mjs.map +1 -1
  6. package/dist/descriptor-meta-D9_5quQi.mjs +14 -0
  7. package/dist/descriptor-meta-D9_5quQi.mjs.map +1 -0
  8. package/dist/{migration-factories-gwi81C8u.mjs → migration-factories-CoNYWrd1.mjs} +3 -1
  9. package/dist/migration-factories-CoNYWrd1.mjs.map +1 -0
  10. package/dist/migration.d.mts +7 -1
  11. package/dist/migration.d.mts.map +1 -1
  12. package/dist/migration.mjs +1 -1
  13. package/dist/{op-factory-call-BjNAcPSF.d.mts → op-factory-call--nK5dk8n.d.mts} +1 -1
  14. package/dist/{op-factory-call-BjNAcPSF.d.mts.map → op-factory-call--nK5dk8n.d.mts.map} +1 -1
  15. package/dist/pack.mjs +1 -11
  16. package/dist/pack.mjs.map +1 -1
  17. package/dist/runtime.d.mts +20 -0
  18. package/dist/runtime.d.mts.map +1 -0
  19. package/dist/runtime.mjs +28 -0
  20. package/dist/runtime.mjs.map +1 -0
  21. package/dist/schema-verify.d.mts +22 -0
  22. package/dist/schema-verify.d.mts.map +1 -0
  23. package/dist/schema-verify.mjs +3 -0
  24. package/dist/verify-mongo-schema-Daa7BMJY.mjs +582 -0
  25. package/dist/verify-mongo-schema-Daa7BMJY.mjs.map +1 -0
  26. package/package.json +20 -14
  27. package/src/core/marker-ledger.ts +90 -20
  28. package/src/core/migration-factories.ts +8 -0
  29. package/src/core/mongo-ops-serializer.ts +0 -8
  30. package/src/core/mongo-planner.ts +8 -2
  31. package/src/core/mongo-runner.ts +105 -70
  32. package/src/core/planner-produced-migration.ts +0 -1
  33. package/src/core/render-typescript.ts +3 -7
  34. package/src/core/schema-diff.ts +402 -0
  35. package/src/core/schema-verify/canonicalize-introspection.ts +389 -0
  36. package/src/core/schema-verify/verify-mongo-schema.ts +60 -0
  37. package/src/exports/runtime.ts +38 -0
  38. package/src/exports/schema-verify.ts +2 -0
  39. package/dist/migration-factories-gwi81C8u.mjs.map +0 -1
@@ -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: { readonly storageHash: string; readonly profileHash: string },
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 = hasProfileHash(options.destinationContract)
180
- ? options.destinationContract.profileHash
181
- : destination.storageHash;
182
-
183
- if (
184
- operationsExecuted === 0 &&
185
- existingMarker?.storageHash === destination.storageHash &&
186
- existingMarker.profileHash === profileHash
187
- ) {
188
- return ok({ operationsPlanned: operations.length, operationsExecuted });
189
- }
190
-
191
- if (existingMarker) {
192
- const updated = await markerOps.updateMarker(existingMarker.storageHash, {
193
- storageHash: destination.storageHash,
194
- profileHash,
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 (!updated) {
197
- return runnerFailure(
198
- 'MARKER_CAS_FAILURE',
199
- 'Marker was modified by another process during migration execution.',
200
- {
201
- meta: {
202
- expectedStorageHash: existingMarker.storageHash,
203
- destinationStorageHash: destination.storageHash,
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
- } else {
209
- await markerOps.initMarker({
210
- storageHash: destination.storageHash,
211
- profileHash,
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
- if (marker) {
379
- return runnerFailure(
380
- 'MARKER_ORIGIN_MISMATCH',
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
 
@@ -47,7 +47,6 @@ export class PlannerProducedMongoMigration
47
47
  return renderCallsToTypeScript(this.calls, {
48
48
  from: this.meta.from,
49
49
  to: this.meta.to,
50
- ...ifDefined('kind', this.meta.kind),
51
50
  ...ifDefined('labels', this.meta.labels),
52
51
  });
53
52
  }
@@ -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 empty strings for a
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
+ }