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

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 (58) hide show
  1. package/dist/{authoring-type-constructors-F4JpCJl7.mjs → authoring-type-constructors-D4lQ-qpj.mjs} +1 -1
  2. package/dist/{authoring-type-constructors-F4JpCJl7.mjs.map → authoring-type-constructors-D4lQ-qpj.mjs.map} +1 -1
  3. package/dist/control-adapter-CgIL9Vtx.d.mts +182 -0
  4. package/dist/control-adapter-CgIL9Vtx.d.mts.map +1 -0
  5. package/dist/control-adapter.d.mts +2 -109
  6. package/dist/control.d.mts +132 -4
  7. package/dist/control.d.mts.map +1 -1
  8. package/dist/control.mjs +277 -215
  9. package/dist/control.mjs.map +1 -1
  10. package/dist/ir.d.mts +4 -5
  11. package/dist/ir.d.mts.map +1 -1
  12. package/dist/ir.mjs +1 -1
  13. package/dist/migration.d.mts +1 -1
  14. package/dist/migration.d.mts.map +1 -1
  15. package/dist/pack.mjs +1 -1
  16. package/dist/runtime.d.mts +4 -2
  17. package/dist/runtime.d.mts.map +1 -1
  18. package/dist/runtime.mjs +4 -2
  19. package/dist/runtime.mjs.map +1 -1
  20. package/dist/schema-verify.d.mts +2 -1
  21. package/dist/schema-verify.d.mts.map +1 -1
  22. package/dist/schema-verify.mjs +1 -1
  23. package/dist/{sql-contract-serializer-8axtK4lg.mjs → sql-contract-serializer-CY7qnms7.mjs} +18 -36
  24. package/dist/sql-contract-serializer-CY7qnms7.mjs.map +1 -0
  25. package/dist/{timestamp-now-generator-r7BP5n3l.mjs → timestamp-now-generator-CloimujU.mjs} +2 -1
  26. package/dist/{timestamp-now-generator-r7BP5n3l.mjs.map → timestamp-now-generator-CloimujU.mjs.map} +1 -1
  27. package/dist/{types-CeeCStqw.d.mts → types-CbwQCzXY.d.mts} +70 -16
  28. package/dist/types-CbwQCzXY.d.mts.map +1 -0
  29. package/dist/{verify-Crewz6hG.mjs → verify-C-G0obRm.mjs} +1 -1
  30. package/dist/{verify-Crewz6hG.mjs.map → verify-C-G0obRm.mjs.map} +1 -1
  31. package/dist/{verify-sql-schema-CN7pPoTC.d.mts → verify-sql-schema-DcMaT5Zj.d.mts} +1 -1
  32. package/dist/{verify-sql-schema-CN7pPoTC.d.mts.map → verify-sql-schema-DcMaT5Zj.d.mts.map} +1 -1
  33. package/dist/{verify-sql-schema-CYLsGCFO.mjs → verify-sql-schema-DlAgBiT_.mjs} +756 -319
  34. package/dist/verify-sql-schema-DlAgBiT_.mjs.map +1 -0
  35. package/dist/verify.mjs +1 -1
  36. package/package.json +23 -23
  37. package/src/core/control-adapter.ts +116 -7
  38. package/src/core/control-instance.ts +269 -66
  39. package/src/core/default-namespace.ts +9 -0
  40. package/src/core/ir/sql-contract-serializer-base.ts +72 -56
  41. package/src/core/migrations/contract-to-schema-ir.ts +75 -9
  42. package/src/core/migrations/control-policy.ts +322 -0
  43. package/src/core/migrations/field-event-planner.ts +2 -2
  44. package/src/core/migrations/plan-helpers.ts +16 -0
  45. package/src/core/migrations/types.ts +17 -7
  46. package/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts +8 -6
  47. package/src/core/schema-verify/control-verify-emit.ts +46 -0
  48. package/src/core/schema-verify/verifier-disposition.ts +58 -0
  49. package/src/core/schema-verify/verify-helpers.ts +310 -111
  50. package/src/core/schema-verify/verify-sql-schema.ts +309 -178
  51. package/src/core/timestamp-now-generator.ts +1 -0
  52. package/src/exports/control-adapter.ts +5 -1
  53. package/src/exports/control.ts +7 -0
  54. package/src/exports/runtime.ts +7 -0
  55. package/dist/control-adapter.d.mts.map +0 -1
  56. package/dist/sql-contract-serializer-8axtK4lg.mjs.map +0 -1
  57. package/dist/types-CeeCStqw.d.mts.map +0 -1
  58. package/dist/verify-sql-schema-CYLsGCFO.mjs.map +0 -1
@@ -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,58 @@
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
+ case 'check_mismatch':
37
+ return 'valueDrift';
38
+ case 'type_mismatch':
39
+ case 'nullability_mismatch':
40
+ case 'primary_key_mismatch':
41
+ case 'foreign_key_mismatch':
42
+ case 'unique_constraint_mismatch':
43
+ case 'index_mismatch':
44
+ case 'default_mismatch':
45
+ return 'declaredIncompatible';
46
+ case 'check_missing':
47
+ return 'declaredMissing';
48
+ case 'check_removed':
49
+ return 'extraAuxiliary';
50
+ }
51
+ }
52
+
53
+ export function verifierDisposition(
54
+ controlPolicy: ControlPolicy,
55
+ issueKind: SchemaIssue['kind'],
56
+ ): VerifierOutcome {
57
+ return dispositionForCategory(controlPolicy, classifySqlVerifierIssueKind(issueKind));
58
+ }
@@ -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,
@@ -14,7 +15,16 @@ import type {
14
15
  PrimaryKey,
15
16
  UniqueConstraint,
16
17
  } from '@prisma-next/sql-contract/types';
17
- import type { SqlForeignKeyIR, SqlIndexIR, SqlUniqueIR } from '@prisma-next/sql-schema-ir/types';
18
+ import type {
19
+ SqlCheckConstraintIR,
20
+ SqlForeignKeyIR,
21
+ SqlIndexIR,
22
+ SqlUniqueIR,
23
+ } from '@prisma-next/sql-schema-ir/types';
24
+ import {
25
+ emitIssueAndNodeUnderControlPolicy,
26
+ emitIssueUnderControlPolicy,
27
+ } from './control-verify-emit';
18
28
 
19
29
  function indexOptionsLooselyEqual(
20
30
  a: Record<string, unknown> | undefined,
@@ -135,34 +145,34 @@ export function verifyPrimaryKey(
135
145
  schemaPK: PrimaryKey | undefined,
136
146
  tableName: string,
137
147
  namespaceId: string,
148
+ tableControlPolicy: ControlPolicy,
138
149
  issues: SchemaIssue[],
139
- ): 'pass' | 'fail' {
150
+ ): 'pass' | 'warn' | 'fail' {
140
151
  if (!schemaPK) {
141
- issues.push({
152
+ const issue: SchemaIssue = {
142
153
  kind: 'primary_key_mismatch',
143
154
  table: tableName,
144
155
  namespaceId,
145
156
  expected: contractPK.columns.join(', '),
146
157
  message: `Table "${tableName}" is missing primary key`,
147
- });
148
- return 'fail';
158
+ };
159
+ const outcome = emitIssueUnderControlPolicy(tableControlPolicy, issue, issues);
160
+ return outcome === 'suppress' ? 'pass' : outcome;
149
161
  }
150
162
 
151
163
  if (!arraysEqual(contractPK.columns, schemaPK.columns)) {
152
- issues.push({
164
+ const issue: SchemaIssue = {
153
165
  kind: 'primary_key_mismatch',
154
166
  table: tableName,
155
167
  namespaceId,
156
168
  expected: contractPK.columns.join(', '),
157
169
  actual: schemaPK.columns.join(', '),
158
170
  message: `Table "${tableName}" has primary key mismatch: expected columns [${contractPK.columns.join(', ')}], got [${schemaPK.columns.join(', ')}]`,
159
- });
160
- return 'fail';
171
+ };
172
+ const outcome = emitIssueUnderControlPolicy(tableControlPolicy, issue, issues);
173
+ return outcome === 'suppress' ? 'pass' : outcome;
161
174
  }
162
175
 
163
- // Name differences are ignored for semantic satisfaction.
164
- // Names are persisted for deterministic DDL and diagnostics but are not identity.
165
-
166
176
  return 'pass';
167
177
  }
168
178
 
@@ -179,6 +189,7 @@ export function verifyForeignKeys(
179
189
  tableName: string,
180
190
  namespaceId: string,
181
191
  tablePath: string,
192
+ tableControlPolicy: ControlPolicy,
182
193
  issues: SchemaIssue[],
183
194
  strict: boolean,
184
195
  ): SchemaVerificationNode[] {
@@ -207,52 +218,62 @@ export function verifyForeignKeys(
207
218
  });
208
219
 
209
220
  if (!matchingFK) {
210
- issues.push({
221
+ const issue: SchemaIssue = {
211
222
  kind: 'foreign_key_mismatch',
212
223
  table: tableName,
213
224
  namespaceId,
214
225
  expected: `${contractFK.source.columns.join(', ')} -> ${contractFK.target.tableName}(${contractFK.target.columns.join(', ')})`,
215
226
  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
- });
227
+ };
228
+ emitIssueAndNodeUnderControlPolicy(
229
+ tableControlPolicy,
230
+ issue,
231
+ {
232
+ status: 'fail',
233
+ kind: 'foreignKey',
234
+ name: `foreignKey(${contractFK.source.columns.join(', ')})`,
235
+ contractPath: fkPath,
236
+ code: 'foreign_key_mismatch',
237
+ message: 'Foreign key missing',
238
+ expected: contractFK,
239
+ actual: undefined,
240
+ children: [],
241
+ },
242
+ issues,
243
+ nodes,
244
+ );
228
245
  } else {
229
246
  const actionMismatches = getReferentialActionMismatches(contractFK, matchingFK);
230
247
  if (actionMismatches.length > 0) {
231
248
  const combinedMessage = actionMismatches.map((m) => m.message).join('; ');
232
249
  const combinedExpected = actionMismatches.map((m) => m.expected).join(', ');
233
250
  const combinedActual = actionMismatches.map((m) => m.actual).join(', ');
234
- issues.push({
251
+ const issue: SchemaIssue = {
235
252
  kind: 'foreign_key_mismatch',
236
253
  table: tableName,
237
254
  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
255
  indexOrConstraint: matchingFK.name ?? `fk(${contractFK.source.columns.join(',')})`,
241
256
  expected: combinedExpected,
242
257
  actual: combinedActual,
243
258
  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
- });
259
+ };
260
+ emitIssueAndNodeUnderControlPolicy(
261
+ tableControlPolicy,
262
+ issue,
263
+ {
264
+ status: 'fail',
265
+ kind: 'foreignKey',
266
+ name: `foreignKey(${contractFK.source.columns.join(', ')})`,
267
+ contractPath: fkPath,
268
+ code: 'foreign_key_mismatch',
269
+ message: combinedMessage,
270
+ expected: contractFK,
271
+ actual: matchingFK,
272
+ children: [],
273
+ },
274
+ issues,
275
+ nodes,
276
+ );
256
277
  } else {
257
278
  nodes.push({
258
279
  status: 'pass',
@@ -286,24 +307,30 @@ export function verifyForeignKeys(
286
307
  });
287
308
 
288
309
  if (!matchingFK) {
289
- issues.push({
310
+ const issue: SchemaIssue = {
290
311
  kind: 'extra_foreign_key',
291
312
  table: tableName,
292
313
  namespaceId,
293
314
  indexOrConstraint: schemaFK.name ?? `fk(${schemaFK.columns.join(',')})`,
294
315
  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
- });
316
+ };
317
+ emitIssueAndNodeUnderControlPolicy(
318
+ tableControlPolicy,
319
+ issue,
320
+ {
321
+ status: 'fail',
322
+ kind: 'foreignKey',
323
+ name: `foreignKey(${schemaFK.columns.join(', ')})`,
324
+ contractPath: `${tablePath}.foreignKeys[${schemaFK.columns.join(',')}]`,
325
+ code: 'extra_foreign_key',
326
+ message: 'Extra foreign key found',
327
+ expected: undefined,
328
+ actual: schemaFK,
329
+ children: [],
330
+ },
331
+ issues,
332
+ nodes,
333
+ );
307
334
  }
308
335
  }
309
336
  }
@@ -329,6 +356,7 @@ export function verifyUniqueConstraints(
329
356
  tableName: string,
330
357
  namespaceId: string,
331
358
  tablePath: string,
359
+ tableControlPolicy: ControlPolicy,
332
360
  issues: SchemaIssue[],
333
361
  strict: boolean,
334
362
  ): SchemaVerificationNode[] {
@@ -349,27 +377,31 @@ export function verifyUniqueConstraints(
349
377
  schemaIndexes.find((idx) => idx.unique && arraysEqual(idx.columns, contractUnique.columns));
350
378
 
351
379
  if (!matchingUnique && !matchingUniqueIndex) {
352
- issues.push({
380
+ const issue: SchemaIssue = {
353
381
  kind: 'unique_constraint_mismatch',
354
382
  table: tableName,
355
383
  namespaceId,
356
384
  expected: contractUnique.columns.join(', '),
357
385
  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
- });
386
+ };
387
+ emitIssueAndNodeUnderControlPolicy(
388
+ tableControlPolicy,
389
+ issue,
390
+ {
391
+ status: 'fail',
392
+ kind: 'unique',
393
+ name: `unique(${contractUnique.columns.join(', ')})`,
394
+ contractPath: uniquePath,
395
+ code: 'unique_constraint_mismatch',
396
+ message: 'Unique constraint missing',
397
+ expected: contractUnique,
398
+ actual: undefined,
399
+ children: [],
400
+ },
401
+ issues,
402
+ nodes,
403
+ );
370
404
  } else {
371
- // Name differences are ignored for semantic satisfaction.
372
- // Names are persisted for deterministic DDL and diagnostics but are not identity.
373
405
  nodes.push({
374
406
  status: 'pass',
375
407
  kind: 'unique',
@@ -384,7 +416,6 @@ export function verifyUniqueConstraints(
384
416
  }
385
417
  }
386
418
 
387
- // Check for extra uniques in strict mode
388
419
  if (strict) {
389
420
  for (const schemaUnique of schemaUniques) {
390
421
  const matchingUnique = contractUniques.find((u) =>
@@ -392,24 +423,30 @@ export function verifyUniqueConstraints(
392
423
  );
393
424
 
394
425
  if (!matchingUnique) {
395
- issues.push({
426
+ const issue: SchemaIssue = {
396
427
  kind: 'extra_unique_constraint',
397
428
  table: tableName,
398
429
  namespaceId,
399
430
  indexOrConstraint: schemaUnique.name ?? `unique(${schemaUnique.columns.join(',')})`,
400
431
  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
- });
432
+ };
433
+ emitIssueAndNodeUnderControlPolicy(
434
+ tableControlPolicy,
435
+ issue,
436
+ {
437
+ status: 'fail',
438
+ kind: 'unique',
439
+ name: `unique(${schemaUnique.columns.join(', ')})`,
440
+ contractPath: `${tablePath}.uniques[${schemaUnique.columns.join(',')}]`,
441
+ code: 'extra_unique_constraint',
442
+ message: 'Extra unique constraint found',
443
+ expected: undefined,
444
+ actual: schemaUnique,
445
+ children: [],
446
+ },
447
+ issues,
448
+ nodes,
449
+ );
413
450
  }
414
451
  }
415
452
  }
@@ -435,6 +472,7 @@ export function verifyIndexes(
435
472
  tableName: string,
436
473
  namespaceId: string,
437
474
  tablePath: string,
475
+ tableControlPolicy: ControlPolicy,
438
476
  issues: SchemaIssue[],
439
477
  strict: boolean,
440
478
  ): SchemaVerificationNode[] {
@@ -461,27 +499,31 @@ export function verifyIndexes(
461
499
  schemaUniques.find((u) => arraysEqual(u.columns, contractIndex.columns));
462
500
 
463
501
  if (!matchingIndex && !matchingUniqueConstraint) {
464
- issues.push({
502
+ const issue: SchemaIssue = {
465
503
  kind: 'index_mismatch',
466
504
  table: tableName,
467
505
  namespaceId,
468
506
  expected: contractIndex.columns.join(', '),
469
507
  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
- });
508
+ };
509
+ emitIssueAndNodeUnderControlPolicy(
510
+ tableControlPolicy,
511
+ issue,
512
+ {
513
+ status: 'fail',
514
+ kind: 'index',
515
+ name: `index(${contractIndex.columns.join(', ')})`,
516
+ contractPath: indexPath,
517
+ code: 'index_mismatch',
518
+ message: 'Index missing',
519
+ expected: contractIndex,
520
+ actual: undefined,
521
+ children: [],
522
+ },
523
+ issues,
524
+ nodes,
525
+ );
482
526
  } else {
483
- // Name differences are ignored for semantic satisfaction.
484
- // Names are persisted for deterministic DDL and diagnostics but are not identity.
485
527
  nodes.push({
486
528
  status: 'pass',
487
529
  kind: 'index',
@@ -496,10 +538,8 @@ export function verifyIndexes(
496
538
  }
497
539
  }
498
540
 
499
- // Check for extra indexes in strict mode
500
541
  if (strict) {
501
542
  for (const schemaIndex of schemaIndexes) {
502
- // Skip unique indexes (they're handled as unique constraints)
503
543
  if (schemaIndex.unique) {
504
544
  continue;
505
545
  }
@@ -510,24 +550,30 @@ export function verifyIndexes(
510
550
  );
511
551
 
512
552
  if (!matchingIndex) {
513
- issues.push({
553
+ const issue: SchemaIssue = {
514
554
  kind: 'extra_index',
515
555
  table: tableName,
516
556
  namespaceId,
517
557
  indexOrConstraint: schemaIndex.name ?? `idx(${schemaIndex.columns.join(',')})`,
518
558
  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
- });
559
+ };
560
+ emitIssueAndNodeUnderControlPolicy(
561
+ tableControlPolicy,
562
+ issue,
563
+ {
564
+ status: 'fail',
565
+ kind: 'index',
566
+ name: `index(${schemaIndex.columns.join(', ')})`,
567
+ contractPath: `${tablePath}.indexes[${schemaIndex.columns.join(',')}]`,
568
+ code: 'extra_index',
569
+ message: 'Extra index found',
570
+ expected: undefined,
571
+ actual: schemaIndex,
572
+ children: [],
573
+ },
574
+ issues,
575
+ nodes,
576
+ );
531
577
  }
532
578
  }
533
579
  }
@@ -619,3 +665,156 @@ function getReferentialActionMismatches(
619
665
  function normalizeReferentialAction(action: string | undefined): string | undefined {
620
666
  return action === 'noAction' ? undefined : action;
621
667
  }
668
+
669
+ /**
670
+ * Compares two value arrays as unordered sets.
671
+ * Returns true when both sides contain exactly the same values.
672
+ */
673
+ function valueSetsEqual(a: readonly string[], b: readonly string[]): boolean {
674
+ const aSet = new Set(a);
675
+ const bSet = new Set(b);
676
+ if (aSet.size !== bSet.size) return false;
677
+ return [...aSet].every((v) => bSet.has(v));
678
+ }
679
+
680
+ /**
681
+ * Verifies check constraints match between contract-projected checks and
682
+ * introspected live checks.
683
+ *
684
+ * Comparison is value-set-based, not SQL-string-based. Postgres rewrites
685
+ * `col IN ('a','b')` as `col = ANY (ARRAY['a','b'])` in
686
+ * `pg_get_constraintdef`, so comparing the extracted value sets (after
687
+ * the introspection adapter parses the predicate) avoids false mismatches
688
+ * from the `IN`-vs-`= ANY (ARRAY…)` rendering difference.
689
+ *
690
+ * Issues emitted:
691
+ * - `check_missing` — check expected by contract but absent from live DB
692
+ * - `check_removed` — check present in live DB but not in contract
693
+ * - `check_mismatch` — check present on both sides but permitted values differ
694
+ *
695
+ * `check_removed` is emitted only when `strict` is true so non-strict
696
+ * verification (the normal path) does not complain about extra constraints.
697
+ */
698
+ export function verifyCheckConstraints(
699
+ contractChecks: ReadonlyArray<{
700
+ readonly name: string;
701
+ readonly column: string;
702
+ readonly permittedValues: readonly string[];
703
+ }>,
704
+ schemaChecks: ReadonlyArray<SqlCheckConstraintIR>,
705
+ tableName: string,
706
+ namespaceId: string,
707
+ tablePath: string,
708
+ tableControlPolicy: ControlPolicy,
709
+ issues: SchemaIssue[],
710
+ strict: boolean,
711
+ ): SchemaVerificationNode[] {
712
+ const nodes: SchemaVerificationNode[] = [];
713
+
714
+ for (const contractCheck of contractChecks) {
715
+ const checkPath = `${tablePath}.checks[${contractCheck.name}]`;
716
+ const liveCheck = schemaChecks.find((c) => c.name === contractCheck.name);
717
+
718
+ if (!liveCheck) {
719
+ const issue: SchemaIssue = {
720
+ kind: 'check_missing',
721
+ table: tableName,
722
+ namespaceId,
723
+ indexOrConstraint: contractCheck.name,
724
+ expected: contractCheck.permittedValues.join(', '),
725
+ message: `Table "${tableName}" is missing check constraint "${contractCheck.name}" (column "${contractCheck.column}" IN (${contractCheck.permittedValues.join(', ')}))`,
726
+ };
727
+ emitIssueAndNodeUnderControlPolicy(
728
+ tableControlPolicy,
729
+ issue,
730
+ {
731
+ status: 'fail',
732
+ kind: 'checkConstraint',
733
+ name: `check(${contractCheck.name})`,
734
+ contractPath: checkPath,
735
+ code: 'check_missing',
736
+ message: `Check constraint "${contractCheck.name}" missing`,
737
+ expected: contractCheck,
738
+ actual: undefined,
739
+ children: [],
740
+ },
741
+ issues,
742
+ nodes,
743
+ );
744
+ } else if (!valueSetsEqual(contractCheck.permittedValues, liveCheck.permittedValues)) {
745
+ const issue: SchemaIssue = {
746
+ kind: 'check_mismatch',
747
+ table: tableName,
748
+ namespaceId,
749
+ indexOrConstraint: contractCheck.name,
750
+ expected: contractCheck.permittedValues.join(', '),
751
+ actual: liveCheck.permittedValues.join(', '),
752
+ message: `Table "${tableName}" check constraint "${contractCheck.name}" has different permitted values: expected [${contractCheck.permittedValues.join(', ')}], got [${liveCheck.permittedValues.join(', ')}]`,
753
+ };
754
+ emitIssueAndNodeUnderControlPolicy(
755
+ tableControlPolicy,
756
+ issue,
757
+ {
758
+ status: 'fail',
759
+ kind: 'checkConstraint',
760
+ name: `check(${contractCheck.name})`,
761
+ contractPath: checkPath,
762
+ code: 'check_mismatch',
763
+ message: `Check constraint "${contractCheck.name}" values mismatch`,
764
+ expected: contractCheck,
765
+ actual: liveCheck,
766
+ children: [],
767
+ },
768
+ issues,
769
+ nodes,
770
+ );
771
+ } else {
772
+ nodes.push({
773
+ status: 'pass',
774
+ kind: 'checkConstraint',
775
+ name: `check(${contractCheck.name})`,
776
+ contractPath: checkPath,
777
+ code: '',
778
+ message: '',
779
+ expected: undefined,
780
+ actual: undefined,
781
+ children: [],
782
+ });
783
+ }
784
+ }
785
+
786
+ if (strict) {
787
+ for (const liveCheck of schemaChecks) {
788
+ const matchingContract = contractChecks.find((c) => c.name === liveCheck.name);
789
+ if (!matchingContract) {
790
+ const issue: SchemaIssue = {
791
+ kind: 'check_removed',
792
+ table: tableName,
793
+ namespaceId,
794
+ indexOrConstraint: liveCheck.name,
795
+ actual: liveCheck.permittedValues.join(', '),
796
+ message: `Table "${tableName}" has extra check constraint "${liveCheck.name}" in database (not in contract)`,
797
+ };
798
+ emitIssueAndNodeUnderControlPolicy(
799
+ tableControlPolicy,
800
+ issue,
801
+ {
802
+ status: 'fail',
803
+ kind: 'checkConstraint',
804
+ name: `check(${liveCheck.name})`,
805
+ contractPath: `${tablePath}.checks[${liveCheck.name}]`,
806
+ code: 'check_removed',
807
+ message: `Extra check constraint "${liveCheck.name}" found`,
808
+ expected: undefined,
809
+ actual: liveCheck,
810
+ children: [],
811
+ },
812
+ issues,
813
+ nodes,
814
+ );
815
+ }
816
+ }
817
+ }
818
+
819
+ return nodes;
820
+ }