@prisma-next/family-sql 0.12.0-dev.2 → 0.12.0-dev.21

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 (37) hide show
  1. package/dist/control-adapter-8tV9WgWK.d.mts +134 -0
  2. package/dist/control-adapter-8tV9WgWK.d.mts.map +1 -0
  3. package/dist/control-adapter.d.mts +2 -109
  4. package/dist/control.d.mts +31 -3
  5. package/dist/control.d.mts.map +1 -1
  6. package/dist/control.mjs +62 -14
  7. package/dist/control.mjs.map +1 -1
  8. package/dist/migration.d.mts +1 -1
  9. package/dist/runtime.d.mts +3 -1
  10. package/dist/runtime.d.mts.map +1 -1
  11. package/dist/runtime.mjs +3 -1
  12. package/dist/runtime.mjs.map +1 -1
  13. package/dist/schema-verify.d.mts +2 -1
  14. package/dist/schema-verify.d.mts.map +1 -1
  15. package/dist/schema-verify.mjs +1 -1
  16. package/dist/{types-CeeCStqw.d.mts → types-BQiqw6kR.d.mts} +14 -5
  17. package/dist/types-BQiqw6kR.d.mts.map +1 -0
  18. package/dist/{verify-sql-schema-CN7pPoTC.d.mts → verify-sql-schema-Cj3c2t5Z.d.mts} +8 -2
  19. package/dist/verify-sql-schema-Cj3c2t5Z.d.mts.map +1 -0
  20. package/dist/{verify-sql-schema-CYLsGCFO.mjs → verify-sql-schema-fljAiO-C.mjs} +403 -310
  21. package/dist/verify-sql-schema-fljAiO-C.mjs.map +1 -0
  22. package/package.json +21 -21
  23. package/src/core/control-adapter.ts +44 -3
  24. package/src/core/control-instance.ts +40 -41
  25. package/src/core/default-namespace.ts +9 -0
  26. package/src/core/migrations/control-policy.ts +89 -0
  27. package/src/core/migrations/types.ts +8 -1
  28. package/src/core/schema-verify/control-verify-emit.ts +46 -0
  29. package/src/core/schema-verify/verifier-disposition.ts +53 -0
  30. package/src/core/schema-verify/verify-helpers.ts +151 -110
  31. package/src/core/schema-verify/verify-sql-schema.ts +291 -155
  32. package/src/exports/control.ts +2 -0
  33. package/src/exports/runtime.ts +7 -0
  34. package/dist/control-adapter.d.mts.map +0 -1
  35. package/dist/types-CeeCStqw.d.mts.map +0 -1
  36. package/dist/verify-sql-schema-CN7pPoTC.d.mts.map +0 -1
  37. package/dist/verify-sql-schema-CYLsGCFO.mjs.map +0 -1
@@ -0,0 +1,89 @@
1
+ import {
2
+ type Contract,
3
+ type ControlPolicy,
4
+ effectiveControlPolicy,
5
+ } from '@prisma-next/contract/types';
6
+ import type { SqlStorage } from '@prisma-next/sql-contract/types';
7
+
8
+ /**
9
+ * The target object a control policy governs for a single planner call,
10
+ * resolved from the target's own IR. `undefined` means the call's target
11
+ * object could not be positively established — a fail-closed signal: any
12
+ * policy stricter than `managed` drops such a call rather than emitting it.
13
+ */
14
+ export interface ControlPolicySubject {
15
+ readonly namespaceId: string;
16
+ readonly explicitNodeControlPolicy?: ControlPolicy;
17
+ readonly table?: string;
18
+ readonly column?: string;
19
+ readonly typeName?: string;
20
+ /**
21
+ * Whether the call creates a whole, previously-absent top-level storage
22
+ * object (e.g. a table or an enum/type), as opposed to modifying an
23
+ * existing object. This is the only thing `tolerated` permits: it is a
24
+ * create-if-absent policy, so an op that touches an existing object — add
25
+ * column, add index/constraint, alter, drop — is never allowed under it.
26
+ */
27
+ readonly createsNewObject: boolean;
28
+ }
29
+
30
+ /**
31
+ * The control policy that governs a single call. The `external` default is an
32
+ * un-overridable namespace floor: when the contract default is `external`, no
33
+ * per-object `managed` override can escalate DDL above the floor, so the
34
+ * policy is forced to `external` regardless of the node's own declaration.
35
+ * Every other default defers to the node's effective control policy.
36
+ */
37
+ function controlPolicyForCall(
38
+ subject: ControlPolicySubject | undefined,
39
+ defaultControlPolicy: ControlPolicy | undefined,
40
+ ): ControlPolicy {
41
+ if (defaultControlPolicy === 'external') {
42
+ return 'external';
43
+ }
44
+ return effectiveControlPolicy(subject?.explicitNodeControlPolicy, defaultControlPolicy);
45
+ }
46
+
47
+ /**
48
+ * Whether a call is allowed to emit under a given control policy.
49
+ *
50
+ * - `managed` — full lifecycle, every op allowed.
51
+ * - `tolerated` — create-if-absent only: allowed iff the call creates a whole
52
+ * new top-level object (and its subject was positively resolved). Anything
53
+ * that modifies an existing object, and anything whose subject could not be
54
+ * resolved, is suppressed.
55
+ * - `external` / `observed` — no DDL at all.
56
+ */
57
+ function callAllowedUnderControlPolicy(
58
+ policy: ControlPolicy,
59
+ subject: ControlPolicySubject | undefined,
60
+ ): boolean {
61
+ switch (policy) {
62
+ case 'managed':
63
+ return true;
64
+ case 'tolerated':
65
+ return subject?.createsNewObject === true;
66
+ case 'external':
67
+ case 'observed':
68
+ return false;
69
+ }
70
+ }
71
+
72
+ export function filterCallsByControlPolicy<TCall>(options: {
73
+ readonly calls: readonly TCall[];
74
+ readonly contract: Contract<SqlStorage>;
75
+ readonly resolveControlPolicySubject: (call: TCall) => ControlPolicySubject | undefined;
76
+ }): readonly TCall[] {
77
+ const defaultControlPolicy = options.contract.defaultControlPolicy;
78
+ const kept: TCall[] = [];
79
+
80
+ for (const call of options.calls) {
81
+ const subject = options.resolveControlPolicySubject(call);
82
+ const policy = controlPolicyForCall(subject, defaultControlPolicy);
83
+ if (callAllowedUnderControlPolicy(policy, subject)) {
84
+ kept.push(call);
85
+ }
86
+ }
87
+
88
+ return Object.freeze(kept);
89
+ }
@@ -22,6 +22,7 @@ import type {
22
22
  SchemaIssue,
23
23
  SchemaVerifier,
24
24
  } from '@prisma-next/framework-components/control';
25
+ import type { AggregateMigrationEdgeRef } from '@prisma-next/migration-tools/aggregate';
25
26
  import type {
26
27
  SqlStorage,
27
28
  StorageColumn,
@@ -31,6 +32,7 @@ import type {
31
32
  import type { SqlOperationDescriptors } from '@prisma-next/sql-operations';
32
33
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
33
34
  import type { Result } from '@prisma-next/utils/result';
35
+ import type { SqlControlAdapter } from '../control-adapter';
34
36
  import type { SqlControlFamilyInstance } from '../control-instance';
35
37
 
36
38
  export type AnyRecord = Readonly<Record<string, unknown>>;
@@ -174,7 +176,7 @@ export interface SqlControlExtensionDescriptor<TTargetId extends string>
174
176
  }
175
177
 
176
178
  export interface SqlControlAdapterDescriptor<TTargetId extends string>
177
- extends ControlAdapterDescriptor<'sql', TTargetId> {
179
+ extends ControlAdapterDescriptor<'sql', TTargetId, SqlControlAdapter<TTargetId>> {
178
180
  readonly queryOperations?: () => SqlOperationDescriptors;
179
181
  }
180
182
 
@@ -384,6 +386,11 @@ export interface SqlMigrationRunnerExecuteOptions<TTargetDetails> {
384
386
  * All components must have matching familyId ('sql') and targetId.
385
387
  */
386
388
  readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
389
+ /**
390
+ * Per-edge breakdown from graph-walk planning. When present, the runner
391
+ * writes one ledger row per edge instead of one collapsed row per apply.
392
+ */
393
+ readonly migrationEdges: readonly AggregateMigrationEdgeRef[];
387
394
  }
388
395
 
389
396
  export type SqlMigrationRunnerErrorCode =
@@ -0,0 +1,46 @@
1
+ import type { ControlPolicy } from '@prisma-next/contract/types';
2
+ import type {
3
+ SchemaIssue,
4
+ SchemaVerificationNode,
5
+ VerifierOutcome,
6
+ } from '@prisma-next/framework-components/control';
7
+ import { verifierDisposition } from './verifier-disposition';
8
+
9
+ /**
10
+ * Grades `issue` under `controlPolicy` and, unless suppressed, pushes both the
11
+ * issue and a status-stamped verification node. Returns the resolved outcome so
12
+ * the caller never re-grades the same issue.
13
+ */
14
+ export function emitIssueAndNodeUnderControlPolicy(
15
+ controlPolicy: ControlPolicy,
16
+ issue: SchemaIssue,
17
+ node: SchemaVerificationNode,
18
+ issues: SchemaIssue[],
19
+ nodes: SchemaVerificationNode[],
20
+ ): VerifierOutcome {
21
+ const disposition = verifierDisposition(controlPolicy, issue.kind);
22
+ if (disposition === 'suppress') {
23
+ return disposition;
24
+ }
25
+ issues.push(issue);
26
+ nodes.push({ ...node, status: disposition });
27
+ return disposition;
28
+ }
29
+
30
+ /**
31
+ * Grades `issue` under `controlPolicy` and, unless suppressed, pushes the issue
32
+ * (no verification node). Returns the resolved outcome so the caller maps it to
33
+ * a node status itself without re-grading.
34
+ */
35
+ export function emitIssueUnderControlPolicy(
36
+ controlPolicy: ControlPolicy,
37
+ issue: SchemaIssue,
38
+ issues: SchemaIssue[],
39
+ ): VerifierOutcome {
40
+ const disposition = verifierDisposition(controlPolicy, issue.kind);
41
+ if (disposition === 'suppress') {
42
+ return disposition;
43
+ }
44
+ issues.push(issue);
45
+ return disposition;
46
+ }
@@ -0,0 +1,53 @@
1
+ import type { ControlPolicy } from '@prisma-next/contract/types';
2
+ import type {
3
+ SchemaIssue,
4
+ VerifierIssueCategory,
5
+ VerifierOutcome,
6
+ } from '@prisma-next/framework-components/control';
7
+ import { dispositionForCategory } from '@prisma-next/framework-components/control';
8
+
9
+ /**
10
+ * Classifies the relational verifier issue kinds the SQL family emits (tables,
11
+ * columns, constraints, indexes, defaults, enum types) into the target-neutral
12
+ * categories the framework grades. The relational vocabulary lives here, in the
13
+ * SQL domain — the framework never switches over `extra_foreign_key` and friends.
14
+ */
15
+ export function classifySqlVerifierIssueKind(kind: SchemaIssue['kind']): VerifierIssueCategory {
16
+ switch (kind) {
17
+ case 'extra_column':
18
+ return 'extraNestedElement';
19
+ case 'extra_primary_key':
20
+ case 'extra_foreign_key':
21
+ case 'extra_unique_constraint':
22
+ case 'extra_index':
23
+ case 'extra_validator':
24
+ case 'extra_default':
25
+ return 'extraAuxiliary';
26
+ case 'extra_table':
27
+ return 'extraTopLevelObject';
28
+ case 'missing_schema':
29
+ case 'missing_table':
30
+ case 'missing_column':
31
+ case 'type_missing':
32
+ case 'default_missing':
33
+ return 'declaredMissing';
34
+ case 'type_values_mismatch':
35
+ case 'enum_values_changed':
36
+ return 'valueDrift';
37
+ case 'type_mismatch':
38
+ case 'nullability_mismatch':
39
+ case 'primary_key_mismatch':
40
+ case 'foreign_key_mismatch':
41
+ case 'unique_constraint_mismatch':
42
+ case 'index_mismatch':
43
+ case 'default_mismatch':
44
+ return 'declaredIncompatible';
45
+ }
46
+ }
47
+
48
+ export function verifierDisposition(
49
+ controlPolicy: ControlPolicy,
50
+ issueKind: SchemaIssue['kind'],
51
+ ): VerifierOutcome {
52
+ return dispositionForCategory(controlPolicy, classifySqlVerifierIssueKind(issueKind));
53
+ }
@@ -3,6 +3,7 @@
3
3
  * These functions verify schema IR against contract requirements.
4
4
  */
5
5
 
6
+ import type { ControlPolicy } from '@prisma-next/contract/types';
6
7
  import type {
7
8
  SchemaIssue,
8
9
  SchemaVerificationNode,
@@ -15,6 +16,10 @@ import type {
15
16
  UniqueConstraint,
16
17
  } from '@prisma-next/sql-contract/types';
17
18
  import type { SqlForeignKeyIR, SqlIndexIR, SqlUniqueIR } from '@prisma-next/sql-schema-ir/types';
19
+ import {
20
+ emitIssueAndNodeUnderControlPolicy,
21
+ emitIssueUnderControlPolicy,
22
+ } from './control-verify-emit';
18
23
 
19
24
  function indexOptionsLooselyEqual(
20
25
  a: Record<string, unknown> | undefined,
@@ -135,34 +140,34 @@ export function verifyPrimaryKey(
135
140
  schemaPK: PrimaryKey | undefined,
136
141
  tableName: string,
137
142
  namespaceId: string,
143
+ tableControlPolicy: ControlPolicy,
138
144
  issues: SchemaIssue[],
139
- ): 'pass' | 'fail' {
145
+ ): 'pass' | 'warn' | 'fail' {
140
146
  if (!schemaPK) {
141
- issues.push({
147
+ const issue: SchemaIssue = {
142
148
  kind: 'primary_key_mismatch',
143
149
  table: tableName,
144
150
  namespaceId,
145
151
  expected: contractPK.columns.join(', '),
146
152
  message: `Table "${tableName}" is missing primary key`,
147
- });
148
- return 'fail';
153
+ };
154
+ const outcome = emitIssueUnderControlPolicy(tableControlPolicy, issue, issues);
155
+ return outcome === 'suppress' ? 'pass' : outcome;
149
156
  }
150
157
 
151
158
  if (!arraysEqual(contractPK.columns, schemaPK.columns)) {
152
- issues.push({
159
+ const issue: SchemaIssue = {
153
160
  kind: 'primary_key_mismatch',
154
161
  table: tableName,
155
162
  namespaceId,
156
163
  expected: contractPK.columns.join(', '),
157
164
  actual: schemaPK.columns.join(', '),
158
165
  message: `Table "${tableName}" has primary key mismatch: expected columns [${contractPK.columns.join(', ')}], got [${schemaPK.columns.join(', ')}]`,
159
- });
160
- return 'fail';
166
+ };
167
+ const outcome = emitIssueUnderControlPolicy(tableControlPolicy, issue, issues);
168
+ return outcome === 'suppress' ? 'pass' : outcome;
161
169
  }
162
170
 
163
- // Name differences are ignored for semantic satisfaction.
164
- // Names are persisted for deterministic DDL and diagnostics but are not identity.
165
-
166
171
  return 'pass';
167
172
  }
168
173
 
@@ -179,6 +184,7 @@ export function verifyForeignKeys(
179
184
  tableName: string,
180
185
  namespaceId: string,
181
186
  tablePath: string,
187
+ tableControlPolicy: ControlPolicy,
182
188
  issues: SchemaIssue[],
183
189
  strict: boolean,
184
190
  ): SchemaVerificationNode[] {
@@ -207,52 +213,62 @@ export function verifyForeignKeys(
207
213
  });
208
214
 
209
215
  if (!matchingFK) {
210
- issues.push({
216
+ const issue: SchemaIssue = {
211
217
  kind: 'foreign_key_mismatch',
212
218
  table: tableName,
213
219
  namespaceId,
214
220
  expected: `${contractFK.source.columns.join(', ')} -> ${contractFK.target.tableName}(${contractFK.target.columns.join(', ')})`,
215
221
  message: `Table "${tableName}" is missing foreign key: ${contractFK.source.columns.join(', ')} -> ${contractFK.target.tableName}(${contractFK.target.columns.join(', ')})`,
216
- });
217
- nodes.push({
218
- status: 'fail',
219
- kind: 'foreignKey',
220
- name: `foreignKey(${contractFK.source.columns.join(', ')})`,
221
- contractPath: fkPath,
222
- code: 'foreign_key_mismatch',
223
- message: 'Foreign key missing',
224
- expected: contractFK,
225
- actual: undefined,
226
- children: [],
227
- });
222
+ };
223
+ emitIssueAndNodeUnderControlPolicy(
224
+ tableControlPolicy,
225
+ issue,
226
+ {
227
+ status: 'fail',
228
+ kind: 'foreignKey',
229
+ name: `foreignKey(${contractFK.source.columns.join(', ')})`,
230
+ contractPath: fkPath,
231
+ code: 'foreign_key_mismatch',
232
+ message: 'Foreign key missing',
233
+ expected: contractFK,
234
+ actual: undefined,
235
+ children: [],
236
+ },
237
+ issues,
238
+ nodes,
239
+ );
228
240
  } else {
229
241
  const actionMismatches = getReferentialActionMismatches(contractFK, matchingFK);
230
242
  if (actionMismatches.length > 0) {
231
243
  const combinedMessage = actionMismatches.map((m) => m.message).join('; ');
232
244
  const combinedExpected = actionMismatches.map((m) => m.expected).join(', ');
233
245
  const combinedActual = actionMismatches.map((m) => m.actual).join(', ');
234
- issues.push({
246
+ const issue: SchemaIssue = {
235
247
  kind: 'foreign_key_mismatch',
236
248
  table: tableName,
237
249
  namespaceId,
238
- // Set indexOrConstraint so the planner classifies this as a non-additive
239
- // conflict (existing FK with wrong actions cannot be fixed additively).
240
250
  indexOrConstraint: matchingFK.name ?? `fk(${contractFK.source.columns.join(',')})`,
241
251
  expected: combinedExpected,
242
252
  actual: combinedActual,
243
253
  message: `Table "${tableName}" foreign key ${contractFK.source.columns.join(', ')} -> ${contractFK.target.tableName}: ${combinedMessage}`,
244
- });
245
- nodes.push({
246
- status: 'fail',
247
- kind: 'foreignKey',
248
- name: `foreignKey(${contractFK.source.columns.join(', ')})`,
249
- contractPath: fkPath,
250
- code: 'foreign_key_mismatch',
251
- message: combinedMessage,
252
- expected: contractFK,
253
- actual: matchingFK,
254
- children: [],
255
- });
254
+ };
255
+ emitIssueAndNodeUnderControlPolicy(
256
+ tableControlPolicy,
257
+ issue,
258
+ {
259
+ status: 'fail',
260
+ kind: 'foreignKey',
261
+ name: `foreignKey(${contractFK.source.columns.join(', ')})`,
262
+ contractPath: fkPath,
263
+ code: 'foreign_key_mismatch',
264
+ message: combinedMessage,
265
+ expected: contractFK,
266
+ actual: matchingFK,
267
+ children: [],
268
+ },
269
+ issues,
270
+ nodes,
271
+ );
256
272
  } else {
257
273
  nodes.push({
258
274
  status: 'pass',
@@ -286,24 +302,30 @@ export function verifyForeignKeys(
286
302
  });
287
303
 
288
304
  if (!matchingFK) {
289
- issues.push({
305
+ const issue: SchemaIssue = {
290
306
  kind: 'extra_foreign_key',
291
307
  table: tableName,
292
308
  namespaceId,
293
309
  indexOrConstraint: schemaFK.name ?? `fk(${schemaFK.columns.join(',')})`,
294
310
  message: `Extra foreign key found in database (not in contract): ${schemaFK.columns.join(', ')} -> ${schemaFK.referencedTable}(${schemaFK.referencedColumns.join(', ')})`,
295
- });
296
- nodes.push({
297
- status: 'fail',
298
- kind: 'foreignKey',
299
- name: `foreignKey(${schemaFK.columns.join(', ')})`,
300
- contractPath: `${tablePath}.foreignKeys[${schemaFK.columns.join(',')}]`,
301
- code: 'extra_foreign_key',
302
- message: 'Extra foreign key found',
303
- expected: undefined,
304
- actual: schemaFK,
305
- children: [],
306
- });
311
+ };
312
+ emitIssueAndNodeUnderControlPolicy(
313
+ tableControlPolicy,
314
+ issue,
315
+ {
316
+ status: 'fail',
317
+ kind: 'foreignKey',
318
+ name: `foreignKey(${schemaFK.columns.join(', ')})`,
319
+ contractPath: `${tablePath}.foreignKeys[${schemaFK.columns.join(',')}]`,
320
+ code: 'extra_foreign_key',
321
+ message: 'Extra foreign key found',
322
+ expected: undefined,
323
+ actual: schemaFK,
324
+ children: [],
325
+ },
326
+ issues,
327
+ nodes,
328
+ );
307
329
  }
308
330
  }
309
331
  }
@@ -329,6 +351,7 @@ export function verifyUniqueConstraints(
329
351
  tableName: string,
330
352
  namespaceId: string,
331
353
  tablePath: string,
354
+ tableControlPolicy: ControlPolicy,
332
355
  issues: SchemaIssue[],
333
356
  strict: boolean,
334
357
  ): SchemaVerificationNode[] {
@@ -349,27 +372,31 @@ export function verifyUniqueConstraints(
349
372
  schemaIndexes.find((idx) => idx.unique && arraysEqual(idx.columns, contractUnique.columns));
350
373
 
351
374
  if (!matchingUnique && !matchingUniqueIndex) {
352
- issues.push({
375
+ const issue: SchemaIssue = {
353
376
  kind: 'unique_constraint_mismatch',
354
377
  table: tableName,
355
378
  namespaceId,
356
379
  expected: contractUnique.columns.join(', '),
357
380
  message: `Table "${tableName}" is missing unique constraint: ${contractUnique.columns.join(', ')}`,
358
- });
359
- nodes.push({
360
- status: 'fail',
361
- kind: 'unique',
362
- name: `unique(${contractUnique.columns.join(', ')})`,
363
- contractPath: uniquePath,
364
- code: 'unique_constraint_mismatch',
365
- message: 'Unique constraint missing',
366
- expected: contractUnique,
367
- actual: undefined,
368
- children: [],
369
- });
381
+ };
382
+ emitIssueAndNodeUnderControlPolicy(
383
+ tableControlPolicy,
384
+ issue,
385
+ {
386
+ status: 'fail',
387
+ kind: 'unique',
388
+ name: `unique(${contractUnique.columns.join(', ')})`,
389
+ contractPath: uniquePath,
390
+ code: 'unique_constraint_mismatch',
391
+ message: 'Unique constraint missing',
392
+ expected: contractUnique,
393
+ actual: undefined,
394
+ children: [],
395
+ },
396
+ issues,
397
+ nodes,
398
+ );
370
399
  } else {
371
- // Name differences are ignored for semantic satisfaction.
372
- // Names are persisted for deterministic DDL and diagnostics but are not identity.
373
400
  nodes.push({
374
401
  status: 'pass',
375
402
  kind: 'unique',
@@ -384,7 +411,6 @@ export function verifyUniqueConstraints(
384
411
  }
385
412
  }
386
413
 
387
- // Check for extra uniques in strict mode
388
414
  if (strict) {
389
415
  for (const schemaUnique of schemaUniques) {
390
416
  const matchingUnique = contractUniques.find((u) =>
@@ -392,24 +418,30 @@ export function verifyUniqueConstraints(
392
418
  );
393
419
 
394
420
  if (!matchingUnique) {
395
- issues.push({
421
+ const issue: SchemaIssue = {
396
422
  kind: 'extra_unique_constraint',
397
423
  table: tableName,
398
424
  namespaceId,
399
425
  indexOrConstraint: schemaUnique.name ?? `unique(${schemaUnique.columns.join(',')})`,
400
426
  message: `Extra unique constraint found in database (not in contract): ${schemaUnique.columns.join(', ')}`,
401
- });
402
- nodes.push({
403
- status: 'fail',
404
- kind: 'unique',
405
- name: `unique(${schemaUnique.columns.join(', ')})`,
406
- contractPath: `${tablePath}.uniques[${schemaUnique.columns.join(',')}]`,
407
- code: 'extra_unique_constraint',
408
- message: 'Extra unique constraint found',
409
- expected: undefined,
410
- actual: schemaUnique,
411
- children: [],
412
- });
427
+ };
428
+ emitIssueAndNodeUnderControlPolicy(
429
+ tableControlPolicy,
430
+ issue,
431
+ {
432
+ status: 'fail',
433
+ kind: 'unique',
434
+ name: `unique(${schemaUnique.columns.join(', ')})`,
435
+ contractPath: `${tablePath}.uniques[${schemaUnique.columns.join(',')}]`,
436
+ code: 'extra_unique_constraint',
437
+ message: 'Extra unique constraint found',
438
+ expected: undefined,
439
+ actual: schemaUnique,
440
+ children: [],
441
+ },
442
+ issues,
443
+ nodes,
444
+ );
413
445
  }
414
446
  }
415
447
  }
@@ -435,6 +467,7 @@ export function verifyIndexes(
435
467
  tableName: string,
436
468
  namespaceId: string,
437
469
  tablePath: string,
470
+ tableControlPolicy: ControlPolicy,
438
471
  issues: SchemaIssue[],
439
472
  strict: boolean,
440
473
  ): SchemaVerificationNode[] {
@@ -461,27 +494,31 @@ export function verifyIndexes(
461
494
  schemaUniques.find((u) => arraysEqual(u.columns, contractIndex.columns));
462
495
 
463
496
  if (!matchingIndex && !matchingUniqueConstraint) {
464
- issues.push({
497
+ const issue: SchemaIssue = {
465
498
  kind: 'index_mismatch',
466
499
  table: tableName,
467
500
  namespaceId,
468
501
  expected: contractIndex.columns.join(', '),
469
502
  message: `Table "${tableName}" is missing index: ${contractIndex.columns.join(', ')}`,
470
- });
471
- nodes.push({
472
- status: 'fail',
473
- kind: 'index',
474
- name: `index(${contractIndex.columns.join(', ')})`,
475
- contractPath: indexPath,
476
- code: 'index_mismatch',
477
- message: 'Index missing',
478
- expected: contractIndex,
479
- actual: undefined,
480
- children: [],
481
- });
503
+ };
504
+ emitIssueAndNodeUnderControlPolicy(
505
+ tableControlPolicy,
506
+ issue,
507
+ {
508
+ status: 'fail',
509
+ kind: 'index',
510
+ name: `index(${contractIndex.columns.join(', ')})`,
511
+ contractPath: indexPath,
512
+ code: 'index_mismatch',
513
+ message: 'Index missing',
514
+ expected: contractIndex,
515
+ actual: undefined,
516
+ children: [],
517
+ },
518
+ issues,
519
+ nodes,
520
+ );
482
521
  } else {
483
- // Name differences are ignored for semantic satisfaction.
484
- // Names are persisted for deterministic DDL and diagnostics but are not identity.
485
522
  nodes.push({
486
523
  status: 'pass',
487
524
  kind: 'index',
@@ -496,10 +533,8 @@ export function verifyIndexes(
496
533
  }
497
534
  }
498
535
 
499
- // Check for extra indexes in strict mode
500
536
  if (strict) {
501
537
  for (const schemaIndex of schemaIndexes) {
502
- // Skip unique indexes (they're handled as unique constraints)
503
538
  if (schemaIndex.unique) {
504
539
  continue;
505
540
  }
@@ -510,24 +545,30 @@ export function verifyIndexes(
510
545
  );
511
546
 
512
547
  if (!matchingIndex) {
513
- issues.push({
548
+ const issue: SchemaIssue = {
514
549
  kind: 'extra_index',
515
550
  table: tableName,
516
551
  namespaceId,
517
552
  indexOrConstraint: schemaIndex.name ?? `idx(${schemaIndex.columns.join(',')})`,
518
553
  message: `Extra index found in database (not in contract): ${schemaIndex.columns.join(', ')}`,
519
- });
520
- nodes.push({
521
- status: 'fail',
522
- kind: 'index',
523
- name: `index(${schemaIndex.columns.join(', ')})`,
524
- contractPath: `${tablePath}.indexes[${schemaIndex.columns.join(',')}]`,
525
- code: 'extra_index',
526
- message: 'Extra index found',
527
- expected: undefined,
528
- actual: schemaIndex,
529
- children: [],
530
- });
554
+ };
555
+ emitIssueAndNodeUnderControlPolicy(
556
+ tableControlPolicy,
557
+ issue,
558
+ {
559
+ status: 'fail',
560
+ kind: 'index',
561
+ name: `index(${schemaIndex.columns.join(', ')})`,
562
+ contractPath: `${tablePath}.indexes[${schemaIndex.columns.join(',')}]`,
563
+ code: 'extra_index',
564
+ message: 'Extra index found',
565
+ expected: undefined,
566
+ actual: schemaIndex,
567
+ children: [],
568
+ },
569
+ issues,
570
+ nodes,
571
+ );
531
572
  }
532
573
  }
533
574
  }