@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
@@ -6,12 +6,14 @@
6
6
  * by migration planners and other tools that need to compare schema states.
7
7
  */
8
8
 
9
- import type { ColumnDefault, Contract } from '@prisma-next/contract/types';
9
+ import type { ColumnDefault, Contract, ControlPolicy } from '@prisma-next/contract/types';
10
+ import { effectiveControlPolicy } from '@prisma-next/contract/types';
10
11
  import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
11
12
  import type {
12
13
  OperationContext,
13
14
  SchemaIssue,
14
15
  SchemaVerificationNode,
16
+ VerificationStatus,
15
17
  VerifyDatabaseSchemaResult,
16
18
  } from '@prisma-next/framework-components/control';
17
19
  import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir';
@@ -29,6 +31,8 @@ import { canonicalStringify } from '@prisma-next/utils/canonical-stringify';
29
31
  import { ifDefined } from '@prisma-next/utils/defined';
30
32
  import { extractCodecControlHooks } from '../assembly';
31
33
  import type { CodecControlHooks } from '../migrations/types';
34
+ import { emitIssueAndNodeUnderControlPolicy } from './control-verify-emit';
35
+ import { verifierDisposition } from './verifier-disposition';
32
36
  import {
33
37
  arraysEqual,
34
38
  computeCounts,
@@ -38,6 +42,8 @@ import {
38
42
  verifyUniqueConstraints,
39
43
  } from './verify-helpers';
40
44
 
45
+ export type ColumnsCompatible = (declared: string, live: string) => boolean;
46
+
41
47
  /**
42
48
  * Function type for normalizing raw database default expressions into ColumnDefault.
43
49
  * Target-specific implementations handle database dialect differences.
@@ -101,6 +107,11 @@ export interface VerifySqlSchemaOptions {
101
107
  enumType: PostgresEnumStorageEntry,
102
108
  namespaceId: string,
103
109
  ) => readonly string[] | null;
110
+ /**
111
+ * Target-supplied compatible-shape relation for `external` column type checks.
112
+ * Defaults to exact string equality when omitted.
113
+ */
114
+ readonly columnsCompatible?: ColumnsCompatible;
104
115
  }
105
116
 
106
117
  /**
@@ -123,6 +134,7 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
123
134
  normalizeDefault,
124
135
  normalizeNativeType,
125
136
  resolveExistingEnumValues,
137
+ columnsCompatible,
126
138
  } = options;
127
139
  const startTime = Date.now();
128
140
 
@@ -159,6 +171,7 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
159
171
  storageTypes,
160
172
  ...ifDefined('normalizeDefault', normalizeDefault),
161
173
  ...ifDefined('normalizeNativeType', normalizeNativeType),
174
+ ...ifDefined('columnsCompatible', columnsCompatible),
162
175
  });
163
176
 
164
177
  validateFrameworkComponentsForExtensions(contract, options.frameworkComponents);
@@ -171,24 +184,39 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
171
184
  // namespace coordinate — a bare-name aggregation would collapse them
172
185
  // (last-write-wins) and verify only one.
173
186
  const typeNodes: SchemaVerificationNode[] = [];
187
+ // Storage-type findings dispatch through the same control policy as tables
188
+ // and columns: each issue's disposition (fail / warn / suppress) is resolved
189
+ // from the type's effective control so an `external`/`observed` enum no longer
190
+ // hard-fails on value drift. `suppress` drops the issue entirely; the node
191
+ // status is the worst surviving disposition.
174
192
  const pushTypeNode = (
175
193
  typeName: string,
176
194
  contractPath: string,
177
195
  typeIssues: readonly SchemaIssue[],
196
+ controlPolicy: ControlPolicy,
178
197
  ): void => {
179
- if (typeIssues.length > 0) {
180
- issues.push(...typeIssues);
198
+ let status: VerificationStatus = 'pass';
199
+ let code = '';
200
+ let emitted = 0;
201
+ for (const issue of typeIssues) {
202
+ const disposition = verifierDisposition(controlPolicy, issue.kind);
203
+ if (disposition === 'suppress') continue;
204
+ issues.push(issue);
205
+ emitted += 1;
206
+ if (code === '') code = issue.kind;
207
+ if (disposition === 'fail') {
208
+ status = 'fail';
209
+ } else if (disposition === 'warn' && status !== 'fail') {
210
+ status = 'warn';
211
+ }
181
212
  }
182
213
  typeNodes.push({
183
- status: typeIssues.length > 0 ? 'fail' : 'pass',
214
+ status,
184
215
  kind: 'storageType',
185
216
  name: `type ${typeName}`,
186
217
  contractPath,
187
- code: typeIssues.length > 0 ? (typeIssues[0]?.kind ?? '') : '',
188
- message:
189
- typeIssues.length > 0
190
- ? `${typeIssues.length} issue${typeIssues.length === 1 ? '' : 's'}`
191
- : '',
218
+ code: status === 'pass' ? '' : code,
219
+ message: emitted > 0 ? `${emitted} issue${emitted === 1 ? '' : 's'}` : '',
192
220
  expected: undefined,
193
221
  actual: undefined,
194
222
  children: [],
@@ -209,6 +237,7 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
209
237
  resolveExistingEnumValues,
210
238
  namespaceId: UNBOUND_NAMESPACE_ID,
211
239
  }),
240
+ effectiveControlPolicy(typeInstance.control, contract.defaultControlPolicy),
212
241
  );
213
242
  } else if (isStorageTypeInstance(typeInstance)) {
214
243
  const hook = codecHooks.get(typeInstance.codecId);
@@ -216,6 +245,7 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
216
245
  typeName,
217
246
  `storage.types.${typeName}`,
218
247
  hook?.verifyType ? hook.verifyType({ typeName, typeInstance, schema }) : [],
248
+ effectiveControlPolicy(undefined, contract.defaultControlPolicy),
219
249
  );
220
250
  }
221
251
  }
@@ -238,12 +268,17 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
238
268
  resolveExistingEnumValues,
239
269
  namespaceId: nsId,
240
270
  }),
271
+ effectiveControlPolicy(entry.control, contract.defaultControlPolicy),
241
272
  );
242
273
  }
243
274
  }
244
275
 
245
276
  if (typeNodes.length > 0) {
246
- const typesStatus = typeNodes.some((n) => n.status === 'fail') ? 'fail' : 'pass';
277
+ const typesStatus: VerificationStatus = typeNodes.some((n) => n.status === 'fail')
278
+ ? 'fail'
279
+ : typeNodes.some((n) => n.status === 'warn')
280
+ ? 'warn'
281
+ : 'pass';
247
282
  rootChildren.push({
248
283
  status: typesStatus,
249
284
  kind: 'storageTypes',
@@ -303,8 +338,6 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
303
338
  };
304
339
  }
305
340
 
306
- type VerificationStatus = 'pass' | 'warn' | 'fail';
307
-
308
341
  /**
309
342
  * Native verification walk for `PostgresEnumStorageEntry` instances (no codec hook).
310
343
  *
@@ -386,6 +419,7 @@ function verifySchemaTables(options: {
386
419
  storageTypes: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>;
387
420
  normalizeDefault?: DefaultNormalizer;
388
421
  normalizeNativeType?: NativeTypeNormalizer;
422
+ columnsCompatible?: ColumnsCompatible;
389
423
  }): { issues: SchemaIssue[]; rootChildren: SchemaVerificationNode[] } {
390
424
  const {
391
425
  contract,
@@ -396,7 +430,9 @@ function verifySchemaTables(options: {
396
430
  storageTypes,
397
431
  normalizeDefault,
398
432
  normalizeNativeType,
433
+ columnsCompatible,
399
434
  } = options;
435
+ const contractDefaultControl = contract.defaultControlPolicy;
400
436
  const issues: SchemaIssue[] = [];
401
437
  const rootChildren: SchemaVerificationNode[] = [];
402
438
  const schemaTables = schema.tables;
@@ -414,27 +450,37 @@ function verifySchemaTables(options: {
414
450
  );
415
451
  }
416
452
  const contractTable = contractTableRaw;
453
+ const tableControlPolicy = effectiveControlPolicy(
454
+ contractTable.control,
455
+ contractDefaultControl,
456
+ );
417
457
  const schemaTable = schemaTables[tableName];
418
458
  const tablePath = `storage.namespaces.${namespaceId}.tables.${tableName}`;
419
459
 
420
460
  if (!schemaTable) {
421
- issues.push({
461
+ const issue: SchemaIssue = {
422
462
  kind: 'missing_table',
423
463
  table: tableName,
424
464
  namespaceId,
425
465
  message: `Table "${tableName}" is missing from database`,
426
- });
427
- rootChildren.push({
428
- status: 'fail',
429
- kind: 'table',
430
- name: `table ${tableName}`,
431
- contractPath: tablePath,
432
- code: 'missing_table',
433
- message: `Table "${tableName}" is missing`,
434
- expected: undefined,
435
- actual: undefined,
436
- children: [],
437
- });
466
+ };
467
+ emitIssueAndNodeUnderControlPolicy(
468
+ tableControlPolicy,
469
+ issue,
470
+ {
471
+ status: 'fail',
472
+ kind: 'table',
473
+ name: `table ${tableName}`,
474
+ contractPath: tablePath,
475
+ code: 'missing_table',
476
+ message: `Table "${tableName}" is missing`,
477
+ expected: undefined,
478
+ actual: undefined,
479
+ children: [],
480
+ },
481
+ issues,
482
+ rootChildren,
483
+ );
438
484
  continue;
439
485
  }
440
486
 
@@ -444,6 +490,7 @@ function verifySchemaTables(options: {
444
490
  tableName,
445
491
  namespaceId,
446
492
  tablePath,
493
+ tableControlPolicy,
447
494
  issues,
448
495
  strict,
449
496
  typeMetadataRegistry,
@@ -451,6 +498,7 @@ function verifySchemaTables(options: {
451
498
  storageTypes,
452
499
  ...ifDefined('normalizeDefault', normalizeDefault),
453
500
  ...ifDefined('normalizeNativeType', normalizeNativeType),
501
+ ...ifDefined('columnsCompatible', columnsCompatible),
454
502
  });
455
503
  rootChildren.push(buildTableNode(tableName, tablePath, tableChildren));
456
504
  }
@@ -462,26 +510,29 @@ function verifySchemaTables(options: {
462
510
  (namespaceId) => contract.storage.namespaces[namespaceId]?.tables[tableName] !== undefined,
463
511
  );
464
512
  if (!claimed) {
465
- // `namespaceId` is intentionally absent: an extra table exists in the
466
- // live database but is not claimed by any contract namespace, so there
467
- // is no contract coordinate to stamp here. Planners that consume this
468
- // issue must handle the unstamped case (drop / quarantine by name).
469
- issues.push({
513
+ const extraTableControlPolicy = effectiveControlPolicy(undefined, contractDefaultControl);
514
+ const issue: SchemaIssue = {
470
515
  kind: 'extra_table',
471
516
  table: tableName,
472
517
  message: `Extra table "${tableName}" found in database (not in contract)`,
473
- });
474
- rootChildren.push({
475
- status: 'fail',
476
- kind: 'table',
477
- name: `table ${tableName}`,
478
- contractPath: `storage.namespaces.*.tables.${tableName}`,
479
- code: 'extra_table',
480
- message: `Extra table "${tableName}" found`,
481
- expected: undefined,
482
- actual: undefined,
483
- children: [],
484
- });
518
+ };
519
+ emitIssueAndNodeUnderControlPolicy(
520
+ extraTableControlPolicy,
521
+ issue,
522
+ {
523
+ status: 'fail',
524
+ kind: 'table',
525
+ name: `table ${tableName}`,
526
+ contractPath: `storage.namespaces.*.tables.${tableName}`,
527
+ code: 'extra_table',
528
+ message: `Extra table "${tableName}" found`,
529
+ expected: undefined,
530
+ actual: undefined,
531
+ children: [],
532
+ },
533
+ issues,
534
+ rootChildren,
535
+ );
485
536
  }
486
537
  }
487
538
  }
@@ -495,6 +546,7 @@ function verifyTableChildren(options: {
495
546
  tableName: string;
496
547
  namespaceId: string;
497
548
  tablePath: string;
549
+ tableControlPolicy: ControlPolicy;
498
550
  issues: SchemaIssue[];
499
551
  strict: boolean;
500
552
  typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
@@ -502,6 +554,7 @@ function verifyTableChildren(options: {
502
554
  storageTypes: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>;
503
555
  normalizeDefault?: DefaultNormalizer;
504
556
  normalizeNativeType?: NativeTypeNormalizer;
557
+ columnsCompatible?: ColumnsCompatible;
505
558
  }): SchemaVerificationNode[] {
506
559
  const {
507
560
  contractTable,
@@ -509,6 +562,7 @@ function verifyTableChildren(options: {
509
562
  tableName,
510
563
  namespaceId,
511
564
  tablePath,
565
+ tableControlPolicy,
512
566
  issues,
513
567
  strict,
514
568
  typeMetadataRegistry,
@@ -516,6 +570,7 @@ function verifyTableChildren(options: {
516
570
  storageTypes,
517
571
  normalizeDefault,
518
572
  normalizeNativeType,
573
+ columnsCompatible,
519
574
  } = options;
520
575
  const tableChildren: SchemaVerificationNode[] = [];
521
576
  const columnNodes = collectContractColumnNodes({
@@ -524,6 +579,7 @@ function verifyTableChildren(options: {
524
579
  tableName,
525
580
  namespaceId,
526
581
  tablePath,
582
+ tableControlPolicy,
527
583
  issues,
528
584
  strict,
529
585
  typeMetadataRegistry,
@@ -531,6 +587,7 @@ function verifyTableChildren(options: {
531
587
  storageTypes,
532
588
  ...ifDefined('normalizeDefault', normalizeDefault),
533
589
  ...ifDefined('normalizeNativeType', normalizeNativeType),
590
+ ...ifDefined('columnsCompatible', columnsCompatible),
534
591
  });
535
592
  if (columnNodes.length > 0) {
536
593
  tableChildren.push(buildColumnsNode(tablePath, columnNodes));
@@ -542,6 +599,7 @@ function verifyTableChildren(options: {
542
599
  tableName,
543
600
  namespaceId,
544
601
  tablePath,
602
+ tableControlPolicy,
545
603
  issues,
546
604
  columnNodes,
547
605
  });
@@ -553,6 +611,7 @@ function verifyTableChildren(options: {
553
611
  schemaTable.primaryKey,
554
612
  tableName,
555
613
  namespaceId,
614
+ tableControlPolicy,
556
615
  issues,
557
616
  );
558
617
  if (pkStatus === 'fail') {
@@ -567,6 +626,18 @@ function verifyTableChildren(options: {
567
626
  actual: schemaTable.primaryKey,
568
627
  children: [],
569
628
  });
629
+ } else if (pkStatus === 'warn') {
630
+ tableChildren.push({
631
+ status: 'warn',
632
+ kind: 'primaryKey',
633
+ name: `primary key: ${contractTable.primaryKey.columns.join(', ')}`,
634
+ contractPath: `${tablePath}.primaryKey`,
635
+ code: 'primary_key_mismatch',
636
+ message: 'Primary key mismatch',
637
+ expected: contractTable.primaryKey,
638
+ actual: schemaTable.primaryKey,
639
+ children: [],
640
+ });
570
641
  } else {
571
642
  tableChildren.push({
572
643
  status: 'pass',
@@ -581,23 +652,29 @@ function verifyTableChildren(options: {
581
652
  });
582
653
  }
583
654
  } else if (schemaTable.primaryKey && strict) {
584
- issues.push({
655
+ const issue: SchemaIssue = {
585
656
  kind: 'extra_primary_key',
586
657
  table: tableName,
587
658
  namespaceId,
588
659
  message: 'Extra primary key found in database (not in contract)',
589
- });
590
- tableChildren.push({
591
- status: 'fail',
592
- kind: 'primaryKey',
593
- name: `primary key: ${schemaTable.primaryKey.columns.join(', ')}`,
594
- contractPath: `${tablePath}.primaryKey`,
595
- code: 'extra_primary_key',
596
- message: 'Extra primary key found',
597
- expected: undefined,
598
- actual: schemaTable.primaryKey,
599
- children: [],
600
- });
660
+ };
661
+ emitIssueAndNodeUnderControlPolicy(
662
+ tableControlPolicy,
663
+ issue,
664
+ {
665
+ status: 'fail',
666
+ kind: 'primaryKey',
667
+ name: `primary key: ${schemaTable.primaryKey.columns.join(', ')}`,
668
+ contractPath: `${tablePath}.primaryKey`,
669
+ code: 'extra_primary_key',
670
+ message: 'Extra primary key found',
671
+ expected: undefined,
672
+ actual: schemaTable.primaryKey,
673
+ children: [],
674
+ },
675
+ issues,
676
+ tableChildren,
677
+ );
601
678
  }
602
679
 
603
680
  // Verify FK constraints only for FKs with constraint: true.
@@ -611,6 +688,7 @@ function verifyTableChildren(options: {
611
688
  tableName,
612
689
  namespaceId,
613
690
  tablePath,
691
+ tableControlPolicy,
614
692
  issues,
615
693
  strict,
616
694
  );
@@ -624,6 +702,7 @@ function verifyTableChildren(options: {
624
702
  tableName,
625
703
  namespaceId,
626
704
  tablePath,
705
+ tableControlPolicy,
627
706
  issues,
628
707
  strict,
629
708
  );
@@ -648,6 +727,7 @@ function verifyTableChildren(options: {
648
727
  tableName,
649
728
  namespaceId,
650
729
  tablePath,
730
+ tableControlPolicy,
651
731
  issues,
652
732
  strict,
653
733
  );
@@ -662,6 +742,7 @@ function collectContractColumnNodes(options: {
662
742
  tableName: string;
663
743
  namespaceId: string;
664
744
  tablePath: string;
745
+ tableControlPolicy: ControlPolicy;
665
746
  issues: SchemaIssue[];
666
747
  strict: boolean;
667
748
  typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
@@ -669,6 +750,7 @@ function collectContractColumnNodes(options: {
669
750
  storageTypes: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>;
670
751
  normalizeDefault?: DefaultNormalizer;
671
752
  normalizeNativeType?: NativeTypeNormalizer;
753
+ columnsCompatible?: ColumnsCompatible;
672
754
  }): SchemaVerificationNode[] {
673
755
  const {
674
756
  contractTable,
@@ -676,6 +758,7 @@ function collectContractColumnNodes(options: {
676
758
  tableName,
677
759
  namespaceId,
678
760
  tablePath,
761
+ tableControlPolicy,
679
762
  issues,
680
763
  strict,
681
764
  typeMetadataRegistry,
@@ -683,6 +766,7 @@ function collectContractColumnNodes(options: {
683
766
  storageTypes,
684
767
  normalizeDefault,
685
768
  normalizeNativeType,
769
+ columnsCompatible,
686
770
  } = options;
687
771
  const columnNodes: SchemaVerificationNode[] = [];
688
772
 
@@ -691,24 +775,30 @@ function collectContractColumnNodes(options: {
691
775
  const columnPath = `${tablePath}.columns.${columnName}`;
692
776
 
693
777
  if (!schemaColumn) {
694
- issues.push({
778
+ const issue: SchemaIssue = {
695
779
  kind: 'missing_column',
696
780
  table: tableName,
697
781
  namespaceId,
698
782
  column: columnName,
699
783
  message: `Column "${tableName}"."${columnName}" is missing from database`,
700
- });
701
- columnNodes.push({
702
- status: 'fail',
703
- kind: 'column',
704
- name: `${columnName}: missing`,
705
- contractPath: columnPath,
706
- code: 'missing_column',
707
- message: `Column "${columnName}" is missing`,
708
- expected: undefined,
709
- actual: undefined,
710
- children: [],
711
- });
784
+ };
785
+ emitIssueAndNodeUnderControlPolicy(
786
+ tableControlPolicy,
787
+ issue,
788
+ {
789
+ status: 'fail',
790
+ kind: 'column',
791
+ name: `${columnName}: missing`,
792
+ contractPath: columnPath,
793
+ code: 'missing_column',
794
+ message: `Column "${columnName}" is missing`,
795
+ expected: undefined,
796
+ actual: undefined,
797
+ children: [],
798
+ },
799
+ issues,
800
+ columnNodes,
801
+ );
712
802
  continue;
713
803
  }
714
804
 
@@ -720,6 +810,7 @@ function collectContractColumnNodes(options: {
720
810
  contractColumn,
721
811
  schemaColumn,
722
812
  columnPath,
813
+ tableControlPolicy,
723
814
  issues,
724
815
  strict,
725
816
  typeMetadataRegistry,
@@ -727,6 +818,7 @@ function collectContractColumnNodes(options: {
727
818
  storageTypes,
728
819
  ...ifDefined('normalizeDefault', normalizeDefault),
729
820
  ...ifDefined('normalizeNativeType', normalizeNativeType),
821
+ ...ifDefined('columnsCompatible', columnsCompatible),
730
822
  }),
731
823
  );
732
824
  }
@@ -740,31 +832,46 @@ function appendExtraColumnNodes(options: {
740
832
  tableName: string;
741
833
  namespaceId: string;
742
834
  tablePath: string;
835
+ tableControlPolicy: ControlPolicy;
743
836
  issues: SchemaIssue[];
744
837
  columnNodes: SchemaVerificationNode[];
745
838
  }): void {
746
- const { contractTable, schemaTable, tableName, namespaceId, tablePath, issues, columnNodes } =
747
- options;
839
+ const {
840
+ contractTable,
841
+ schemaTable,
842
+ tableName,
843
+ namespaceId,
844
+ tablePath,
845
+ tableControlPolicy,
846
+ issues,
847
+ columnNodes,
848
+ } = options;
748
849
  for (const [columnName, { nativeType }] of Object.entries(schemaTable.columns)) {
749
850
  if (!contractTable.columns[columnName]) {
750
- issues.push({
851
+ const issue: SchemaIssue = {
751
852
  kind: 'extra_column',
752
853
  table: tableName,
753
854
  namespaceId,
754
855
  column: columnName,
755
856
  message: `Extra column "${tableName}"."${columnName}" found in database (not in contract)`,
756
- });
757
- columnNodes.push({
758
- status: 'fail',
759
- kind: 'column',
760
- name: `${columnName}: extra`,
761
- contractPath: `${tablePath}.columns.${columnName}`,
762
- code: 'extra_column',
763
- message: `Extra column "${columnName}" found`,
764
- expected: undefined,
765
- actual: nativeType,
766
- children: [],
767
- });
857
+ };
858
+ emitIssueAndNodeUnderControlPolicy(
859
+ tableControlPolicy,
860
+ issue,
861
+ {
862
+ status: 'fail',
863
+ kind: 'column',
864
+ name: `${columnName}: extra`,
865
+ contractPath: `${tablePath}.columns.${columnName}`,
866
+ code: 'extra_column',
867
+ message: `Extra column "${columnName}" found`,
868
+ expected: undefined,
869
+ actual: nativeType,
870
+ children: [],
871
+ },
872
+ issues,
873
+ columnNodes,
874
+ );
768
875
  }
769
876
  }
770
877
  }
@@ -776,6 +883,7 @@ function verifyColumn(options: {
776
883
  contractColumn: StorageTable['columns'][string];
777
884
  schemaColumn: SqlSchemaIR['tables'][string]['columns'][string];
778
885
  columnPath: string;
886
+ tableControlPolicy: ControlPolicy;
779
887
  issues: SchemaIssue[];
780
888
  strict: boolean;
781
889
  typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
@@ -783,6 +891,7 @@ function verifyColumn(options: {
783
891
  storageTypes: Readonly<Record<string, StorageTypeInstance | PostgresEnumStorageEntry>>;
784
892
  normalizeDefault?: DefaultNormalizer;
785
893
  normalizeNativeType?: NativeTypeNormalizer;
894
+ columnsCompatible?: ColumnsCompatible;
786
895
  }): SchemaVerificationNode {
787
896
  const {
788
897
  tableName,
@@ -791,12 +900,14 @@ function verifyColumn(options: {
791
900
  contractColumn,
792
901
  schemaColumn,
793
902
  columnPath,
903
+ tableControlPolicy,
794
904
  issues,
795
905
  strict,
796
906
  codecHooks,
797
907
  storageTypes,
798
908
  normalizeDefault,
799
909
  normalizeNativeType,
910
+ columnsCompatible,
800
911
  } = options;
801
912
  const columnChildren: SchemaVerificationNode[] = [];
802
913
  let columnStatus: VerificationStatus = 'pass';
@@ -812,8 +923,14 @@ function verifyColumn(options: {
812
923
  const schemaNativeType =
813
924
  normalizeNativeType?.(schemaColumn.nativeType) ?? schemaColumn.nativeType;
814
925
 
815
- if (contractNativeType !== schemaNativeType) {
816
- issues.push({
926
+ const typesMatch =
927
+ contractNativeType === schemaNativeType ||
928
+ (tableControlPolicy === 'external' &&
929
+ (columnsCompatible?.(contractNativeType, schemaNativeType) ??
930
+ contractNativeType === schemaNativeType));
931
+
932
+ if (!typesMatch) {
933
+ const issue: SchemaIssue = {
817
934
  kind: 'type_mismatch',
818
935
  table: tableName,
819
936
  namespaceId,
@@ -821,19 +938,23 @@ function verifyColumn(options: {
821
938
  expected: contractNativeType,
822
939
  actual: schemaNativeType,
823
940
  message: `Column "${tableName}"."${columnName}" has type mismatch: expected "${contractNativeType}", got "${schemaNativeType}"`,
824
- });
825
- columnChildren.push({
826
- status: 'fail',
827
- kind: 'type',
828
- name: 'type',
829
- contractPath: `${columnPath}.nativeType`,
830
- code: 'type_mismatch',
831
- message: `Type mismatch: expected ${contractNativeType}, got ${schemaNativeType}`,
832
- expected: contractNativeType,
833
- actual: schemaNativeType,
834
- children: [],
835
- });
836
- columnStatus = 'fail';
941
+ };
942
+ const disposition = verifierDisposition(tableControlPolicy, issue.kind);
943
+ if (disposition !== 'suppress') {
944
+ issues.push(issue);
945
+ columnChildren.push({
946
+ status: disposition,
947
+ kind: 'type',
948
+ name: 'type',
949
+ contractPath: `${columnPath}.nativeType`,
950
+ code: 'type_mismatch',
951
+ message: `Type mismatch: expected ${contractNativeType}, got ${schemaNativeType}`,
952
+ expected: contractNativeType,
953
+ actual: schemaNativeType,
954
+ children: [],
955
+ });
956
+ columnStatus = disposition;
957
+ }
837
958
  }
838
959
 
839
960
  if (resolvedContractColumn.codecId) {
@@ -869,7 +990,7 @@ function verifyColumn(options: {
869
990
  }
870
991
 
871
992
  if (contractColumn.nullable !== schemaColumn.nullable) {
872
- issues.push({
993
+ const issue: SchemaIssue = {
873
994
  kind: 'nullability_mismatch',
874
995
  table: tableName,
875
996
  namespaceId,
@@ -877,44 +998,52 @@ function verifyColumn(options: {
877
998
  expected: String(contractColumn.nullable),
878
999
  actual: String(schemaColumn.nullable),
879
1000
  message: `Column "${tableName}"."${columnName}" has nullability mismatch: expected ${contractColumn.nullable ? 'nullable' : 'not null'}, got ${schemaColumn.nullable ? 'nullable' : 'not null'}`,
880
- });
881
- columnChildren.push({
882
- status: 'fail',
883
- kind: 'nullability',
884
- name: 'nullability',
885
- contractPath: `${columnPath}.nullable`,
886
- code: 'nullability_mismatch',
887
- message: `Nullability mismatch: expected ${contractColumn.nullable ? 'nullable' : 'not null'}, got ${schemaColumn.nullable ? 'nullable' : 'not null'}`,
888
- expected: contractColumn.nullable,
889
- actual: schemaColumn.nullable,
890
- children: [],
891
- });
892
- columnStatus = 'fail';
1001
+ };
1002
+ const disposition = verifierDisposition(tableControlPolicy, issue.kind);
1003
+ if (disposition !== 'suppress') {
1004
+ issues.push(issue);
1005
+ columnChildren.push({
1006
+ status: disposition,
1007
+ kind: 'nullability',
1008
+ name: 'nullability',
1009
+ contractPath: `${columnPath}.nullable`,
1010
+ code: 'nullability_mismatch',
1011
+ message: `Nullability mismatch: expected ${contractColumn.nullable ? 'nullable' : 'not null'}, got ${schemaColumn.nullable ? 'nullable' : 'not null'}`,
1012
+ expected: contractColumn.nullable,
1013
+ actual: schemaColumn.nullable,
1014
+ children: [],
1015
+ });
1016
+ columnStatus = disposition;
1017
+ }
893
1018
  }
894
1019
 
895
1020
  if (contractColumn.default) {
896
1021
  if (!schemaColumn.default) {
897
1022
  const defaultDescription = describeColumnDefault(contractColumn.default);
898
- issues.push({
1023
+ const issue: SchemaIssue = {
899
1024
  kind: 'default_missing',
900
1025
  table: tableName,
901
1026
  namespaceId,
902
1027
  column: columnName,
903
1028
  expected: defaultDescription,
904
1029
  message: `Column "${tableName}"."${columnName}" should have default ${defaultDescription} but database has no default`,
905
- });
906
- columnChildren.push({
907
- status: 'fail',
908
- kind: 'default',
909
- name: 'default',
910
- contractPath: `${columnPath}.default`,
911
- code: 'default_missing',
912
- message: `Default missing: expected ${defaultDescription}`,
913
- expected: defaultDescription,
914
- actual: undefined,
915
- children: [],
916
- });
917
- columnStatus = 'fail';
1030
+ };
1031
+ const disposition = verifierDisposition(tableControlPolicy, issue.kind);
1032
+ if (disposition !== 'suppress') {
1033
+ issues.push(issue);
1034
+ columnChildren.push({
1035
+ status: disposition,
1036
+ kind: 'default',
1037
+ name: 'default',
1038
+ contractPath: `${columnPath}.default`,
1039
+ code: 'default_missing',
1040
+ message: `Default missing: expected ${defaultDescription}`,
1041
+ expected: defaultDescription,
1042
+ actual: undefined,
1043
+ children: [],
1044
+ });
1045
+ columnStatus = disposition;
1046
+ }
918
1047
  } else if (
919
1048
  !columnDefaultsEqual(
920
1049
  contractColumn.default,
@@ -924,9 +1053,8 @@ function verifyColumn(options: {
924
1053
  )
925
1054
  ) {
926
1055
  const expectedDescription = describeColumnDefault(contractColumn.default);
927
- // schemaColumn.default is now a raw string, describe it as-is
928
1056
  const actualDescription = schemaColumn.default;
929
- issues.push({
1057
+ const issue: SchemaIssue = {
930
1058
  kind: 'default_mismatch',
931
1059
  table: tableName,
932
1060
  namespaceId,
@@ -934,41 +1062,49 @@ function verifyColumn(options: {
934
1062
  expected: expectedDescription,
935
1063
  actual: actualDescription,
936
1064
  message: `Column "${tableName}"."${columnName}" has default mismatch: expected ${expectedDescription}, got ${actualDescription}`,
937
- });
938
- columnChildren.push({
939
- status: 'fail',
940
- kind: 'default',
941
- name: 'default',
942
- contractPath: `${columnPath}.default`,
943
- code: 'default_mismatch',
944
- message: `Default mismatch: expected ${expectedDescription}, got ${actualDescription}`,
945
- expected: expectedDescription,
946
- actual: actualDescription,
947
- children: [],
948
- });
949
- columnStatus = 'fail';
1065
+ };
1066
+ const disposition = verifierDisposition(tableControlPolicy, issue.kind);
1067
+ if (disposition !== 'suppress') {
1068
+ issues.push(issue);
1069
+ columnChildren.push({
1070
+ status: disposition,
1071
+ kind: 'default',
1072
+ name: 'default',
1073
+ contractPath: `${columnPath}.default`,
1074
+ code: 'default_mismatch',
1075
+ message: `Default mismatch: expected ${expectedDescription}, got ${actualDescription}`,
1076
+ expected: expectedDescription,
1077
+ actual: actualDescription,
1078
+ children: [],
1079
+ });
1080
+ columnStatus = disposition;
1081
+ }
950
1082
  }
951
1083
  } else if (strict && schemaColumn.default) {
952
- issues.push({
1084
+ const issue: SchemaIssue = {
953
1085
  kind: 'extra_default',
954
1086
  table: tableName,
955
1087
  namespaceId,
956
1088
  column: columnName,
957
1089
  actual: schemaColumn.default,
958
1090
  message: `Column "${tableName}"."${columnName}" has default ${schemaColumn.default} in database but contract specifies no default`,
959
- });
960
- columnChildren.push({
961
- status: 'fail',
962
- kind: 'default',
963
- name: 'default',
964
- contractPath: `${columnPath}.default`,
965
- code: 'extra_default',
966
- message: `Extra default: ${schemaColumn.default}`,
967
- expected: undefined,
968
- actual: schemaColumn.default,
969
- children: [],
970
- });
971
- columnStatus = 'fail';
1091
+ };
1092
+ const disposition = verifierDisposition(tableControlPolicy, issue.kind);
1093
+ if (disposition !== 'suppress') {
1094
+ issues.push(issue);
1095
+ columnChildren.push({
1096
+ status: disposition,
1097
+ kind: 'default',
1098
+ name: 'default',
1099
+ contractPath: `${columnPath}.default`,
1100
+ code: 'extra_default',
1101
+ message: `Extra default: ${schemaColumn.default}`,
1102
+ expected: undefined,
1103
+ actual: schemaColumn.default,
1104
+ children: [],
1105
+ });
1106
+ columnStatus = disposition;
1107
+ }
972
1108
  }
973
1109
 
974
1110
  // Single-pass aggregation for better performance