@prisma-next/family-sql 0.3.0-dev.34 → 0.3.0-dev.37

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 (98) hide show
  1. package/README.md +11 -6
  2. package/dist/assembly-BVS641kd.mjs +106 -0
  3. package/dist/assembly-BVS641kd.mjs.map +1 -0
  4. package/dist/control-adapter.d.mts +60 -0
  5. package/dist/control-adapter.d.mts.map +1 -0
  6. package/dist/control-adapter.mjs +1 -0
  7. package/dist/control-instance-Cvmn5zpn.d.mts +292 -0
  8. package/dist/control-instance-Cvmn5zpn.d.mts.map +1 -0
  9. package/dist/control.d.mts +64 -0
  10. package/dist/control.d.mts.map +1 -0
  11. package/dist/control.mjs +534 -0
  12. package/dist/control.mjs.map +1 -0
  13. package/dist/runtime.d.mts +27 -0
  14. package/dist/runtime.d.mts.map +1 -0
  15. package/dist/runtime.mjs +38 -0
  16. package/dist/runtime.mjs.map +1 -0
  17. package/dist/schema-verify.d.mts +48 -0
  18. package/dist/schema-verify.d.mts.map +1 -0
  19. package/dist/schema-verify.mjs +4 -0
  20. package/dist/test-utils.d.mts +2 -0
  21. package/dist/test-utils.mjs +3 -0
  22. package/dist/verify-BfMETJcM.mjs +108 -0
  23. package/dist/verify-BfMETJcM.mjs.map +1 -0
  24. package/dist/verify-sql-schema-B4T5MEuz.mjs +934 -0
  25. package/dist/verify-sql-schema-B4T5MEuz.mjs.map +1 -0
  26. package/dist/verify-sql-schema-CvQoGm2Q.d.mts +67 -0
  27. package/dist/verify-sql-schema-CvQoGm2Q.d.mts.map +1 -0
  28. package/dist/verify.d.mts +31 -0
  29. package/dist/verify.d.mts.map +1 -0
  30. package/dist/verify.mjs +3 -0
  31. package/package.json +33 -44
  32. package/src/core/assembly.ts +120 -96
  33. package/src/core/control-adapter.ts +15 -0
  34. package/src/core/{descriptor.ts → control-descriptor.ts} +15 -11
  35. package/src/core/{instance.ts → control-instance.ts} +72 -231
  36. package/src/core/migrations/types.ts +62 -163
  37. package/src/core/runtime-descriptor.ts +19 -41
  38. package/src/core/runtime-instance.ts +11 -133
  39. package/src/core/schema-verify/verify-sql-schema.ts +820 -397
  40. package/src/core/verify.ts +4 -13
  41. package/src/exports/control.ts +9 -6
  42. package/src/exports/runtime.ts +2 -6
  43. package/src/exports/schema-verify.ts +4 -1
  44. package/src/exports/test-utils.ts +0 -1
  45. package/dist/chunk-EHYNXF4K.js +0 -627
  46. package/dist/chunk-EHYNXF4K.js.map +0 -1
  47. package/dist/chunk-SU7LN2UH.js +0 -96
  48. package/dist/chunk-SU7LN2UH.js.map +0 -1
  49. package/dist/chunk-XH2Y5NTD.js +0 -715
  50. package/dist/chunk-XH2Y5NTD.js.map +0 -1
  51. package/dist/core/assembly.d.ts +0 -43
  52. package/dist/core/assembly.d.ts.map +0 -1
  53. package/dist/core/control-adapter.d.ts +0 -42
  54. package/dist/core/control-adapter.d.ts.map +0 -1
  55. package/dist/core/descriptor.d.ts +0 -28
  56. package/dist/core/descriptor.d.ts.map +0 -1
  57. package/dist/core/instance.d.ts +0 -140
  58. package/dist/core/instance.d.ts.map +0 -1
  59. package/dist/core/migrations/plan-helpers.d.ts +0 -20
  60. package/dist/core/migrations/plan-helpers.d.ts.map +0 -1
  61. package/dist/core/migrations/policies.d.ts +0 -6
  62. package/dist/core/migrations/policies.d.ts.map +0 -1
  63. package/dist/core/migrations/types.d.ts +0 -280
  64. package/dist/core/migrations/types.d.ts.map +0 -1
  65. package/dist/core/runtime-descriptor.d.ts +0 -19
  66. package/dist/core/runtime-descriptor.d.ts.map +0 -1
  67. package/dist/core/runtime-instance.d.ts +0 -54
  68. package/dist/core/runtime-instance.d.ts.map +0 -1
  69. package/dist/core/schema-verify/verify-helpers.d.ts +0 -96
  70. package/dist/core/schema-verify/verify-helpers.d.ts.map +0 -1
  71. package/dist/core/schema-verify/verify-sql-schema.d.ts +0 -45
  72. package/dist/core/schema-verify/verify-sql-schema.d.ts.map +0 -1
  73. package/dist/core/verify.d.ts +0 -39
  74. package/dist/core/verify.d.ts.map +0 -1
  75. package/dist/exports/control-adapter.d.ts +0 -2
  76. package/dist/exports/control-adapter.d.ts.map +0 -1
  77. package/dist/exports/control-adapter.js +0 -1
  78. package/dist/exports/control-adapter.js.map +0 -1
  79. package/dist/exports/control.d.ts +0 -13
  80. package/dist/exports/control.d.ts.map +0 -1
  81. package/dist/exports/control.js +0 -149
  82. package/dist/exports/control.js.map +0 -1
  83. package/dist/exports/runtime.d.ts +0 -8
  84. package/dist/exports/runtime.d.ts.map +0 -1
  85. package/dist/exports/runtime.js +0 -64
  86. package/dist/exports/runtime.js.map +0 -1
  87. package/dist/exports/schema-verify.d.ts +0 -11
  88. package/dist/exports/schema-verify.d.ts.map +0 -1
  89. package/dist/exports/schema-verify.js +0 -15
  90. package/dist/exports/schema-verify.js.map +0 -1
  91. package/dist/exports/test-utils.d.ts +0 -7
  92. package/dist/exports/test-utils.d.ts.map +0 -1
  93. package/dist/exports/test-utils.js +0 -17
  94. package/dist/exports/test-utils.js.map +0 -1
  95. package/dist/exports/verify.d.ts +0 -2
  96. package/dist/exports/verify.d.ts.map +0 -1
  97. package/dist/exports/verify.js +0 -11
  98. package/dist/exports/verify.js.map +0 -1
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import type { TargetBoundComponentDescriptor } from '@prisma-next/contract/framework-components';
10
+ import type { ColumnDefault } from '@prisma-next/contract/types';
10
11
  import type {
11
12
  OperationContext,
12
13
  SchemaIssue,
@@ -16,8 +17,10 @@ import type {
16
17
  import type { SqlContract, SqlStorage } from '@prisma-next/sql-contract/types';
17
18
  import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types';
18
19
  import { ifDefined } from '@prisma-next/utils/defined';
19
- import type { ComponentDatabaseDependency } from '../migrations/types';
20
+ import { extractCodecControlHooks } from '../assembly';
21
+ import type { CodecControlHooks, ComponentDatabaseDependency } from '../migrations/types';
20
22
  import {
23
+ arraysEqual,
21
24
  computeCounts,
22
25
  verifyDatabaseDependencies,
23
26
  verifyForeignKeys,
@@ -26,6 +29,22 @@ import {
26
29
  verifyUniqueConstraints,
27
30
  } from './verify-helpers';
28
31
 
32
+ /**
33
+ * Function type for normalizing raw database default expressions into ColumnDefault.
34
+ * Target-specific implementations handle database dialect differences.
35
+ */
36
+ export type DefaultNormalizer = (
37
+ rawDefault: string,
38
+ nativeType: string,
39
+ ) => ColumnDefault | undefined;
40
+
41
+ /**
42
+ * Function type for normalizing schema native types to canonical form for comparison.
43
+ * Target-specific implementations handle dialect-specific type name variations
44
+ * (e.g., Postgres 'varchar' → 'character varying', 'timestamptz' normalization).
45
+ */
46
+ export type NativeTypeNormalizer = (nativeType: string) => string;
47
+
29
48
  /**
30
49
  * Options for the pure schema verification function.
31
50
  */
@@ -45,6 +64,18 @@ export interface VerifySqlSchemaOptions {
45
64
  * All components must have matching familyId ('sql') and targetId.
46
65
  */
47
66
  readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
67
+ /**
68
+ * Optional target-specific normalizer for raw database default expressions.
69
+ * When provided, schema defaults (raw strings) are normalized before comparison
70
+ * with contract defaults (ColumnDefault objects).
71
+ */
72
+ readonly normalizeDefault?: DefaultNormalizer;
73
+ /**
74
+ * Optional target-specific normalizer for schema native type names.
75
+ * When provided, schema native types are normalized before comparison
76
+ * with contract native types (e.g., Postgres 'varchar' → 'character varying').
77
+ */
78
+ readonly normalizeNativeType?: NativeTypeNormalizer;
48
79
  }
49
80
 
50
81
  /**
@@ -58,22 +89,167 @@ export interface VerifySqlSchemaOptions {
58
89
  * @returns VerifyDatabaseSchemaResult with verification tree and issues
59
90
  */
60
91
  export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabaseSchemaResult {
61
- const { contract, schema, strict, context, typeMetadataRegistry } = options;
92
+ const {
93
+ contract,
94
+ schema,
95
+ strict,
96
+ context,
97
+ typeMetadataRegistry,
98
+ normalizeDefault,
99
+ normalizeNativeType,
100
+ } = options;
62
101
  const startTime = Date.now();
63
102
 
64
- // Extract contract hashes and target
65
- const contractCoreHash = contract.coreHash;
66
- const contractProfileHash =
67
- 'profileHash' in contract && typeof contract.profileHash === 'string'
68
- ? contract.profileHash
69
- : undefined;
70
- const contractTarget = contract.target;
103
+ // Extract codec control hooks once at entry point for reuse
104
+ const codecHooks = extractCodecControlHooks(options.frameworkComponents);
105
+
106
+ const { contractStorageHash, contractProfileHash, contractTarget } =
107
+ extractContractMetadata(contract);
108
+ const { issues, rootChildren } = verifySchemaTables({
109
+ contract,
110
+ schema,
111
+ strict,
112
+ typeMetadataRegistry,
113
+ codecHooks,
114
+ ...ifDefined('normalizeDefault', normalizeDefault),
115
+ ...ifDefined('normalizeNativeType', normalizeNativeType),
116
+ });
117
+
118
+ validateFrameworkComponentsForExtensions(contract, options.frameworkComponents);
119
+
120
+ // Verify storage type instances via codec control hooks (pure, deterministic)
121
+ const storageTypes = contract.storage.types ?? {};
122
+ const storageTypeEntries = Object.entries(storageTypes);
123
+ if (storageTypeEntries.length > 0) {
124
+ const typeNodes: SchemaVerificationNode[] = [];
125
+ for (const [typeName, typeInstance] of storageTypeEntries) {
126
+ const hook = codecHooks.get(typeInstance.codecId);
127
+ const typeIssues = hook?.verifyType
128
+ ? hook.verifyType({ typeName, typeInstance, schema })
129
+ : [];
130
+ if (typeIssues.length > 0) {
131
+ issues.push(...typeIssues);
132
+ }
133
+ const typeStatus = typeIssues.length > 0 ? 'fail' : 'pass';
134
+ const typeCode = typeIssues.length > 0 ? (typeIssues[0]?.kind ?? '') : '';
135
+ typeNodes.push({
136
+ status: typeStatus,
137
+ kind: 'storageType',
138
+ name: `type ${typeName}`,
139
+ contractPath: `storage.types.${typeName}`,
140
+ code: typeCode,
141
+ message:
142
+ typeIssues.length > 0
143
+ ? `${typeIssues.length} issue${typeIssues.length === 1 ? '' : 's'}`
144
+ : '',
145
+ expected: undefined,
146
+ actual: undefined,
147
+ children: [],
148
+ });
149
+ }
150
+ const typesStatus = typeNodes.some((n) => n.status === 'fail') ? 'fail' : 'pass';
151
+ rootChildren.push({
152
+ status: typesStatus,
153
+ kind: 'storageTypes',
154
+ name: 'types',
155
+ contractPath: 'storage.types',
156
+ code: typesStatus === 'fail' ? 'type_mismatch' : '',
157
+ message: '',
158
+ expected: undefined,
159
+ actual: undefined,
160
+ children: typeNodes,
161
+ });
162
+ }
163
+
164
+ const databaseDependencies = collectDependenciesFromFrameworkComponents(
165
+ options.frameworkComponents,
166
+ );
167
+ const dependencyStatuses = verifyDatabaseDependencies(databaseDependencies, schema, issues);
168
+ rootChildren.push(...dependencyStatuses);
169
+
170
+ const root = buildRootNode(rootChildren);
71
171
 
72
- // Compare contract vs schema IR
172
+ // Compute counts
173
+ const counts = computeCounts(root);
174
+
175
+ // Set ok flag
176
+ const ok = counts.fail === 0;
177
+
178
+ // Set code
179
+ const code = ok ? undefined : 'PN-SCHEMA-0001';
180
+
181
+ // Set summary
182
+ const summary = ok
183
+ ? 'Database schema satisfies contract'
184
+ : `Database schema does not satisfy contract (${counts.fail} failure${counts.fail === 1 ? '' : 's'})`;
185
+
186
+ const totalTime = Date.now() - startTime;
187
+
188
+ return {
189
+ ok,
190
+ ...ifDefined('code', code),
191
+ summary,
192
+ contract: {
193
+ storageHash: contractStorageHash,
194
+ ...ifDefined('profileHash', contractProfileHash),
195
+ },
196
+ target: {
197
+ expected: contractTarget,
198
+ actual: contractTarget,
199
+ },
200
+ schema: {
201
+ issues,
202
+ root,
203
+ counts,
204
+ },
205
+ meta: {
206
+ strict,
207
+ ...ifDefined('contractPath', context?.contractPath),
208
+ ...ifDefined('configPath', context?.configPath),
209
+ },
210
+ timings: {
211
+ total: totalTime,
212
+ },
213
+ };
214
+ }
215
+
216
+ type VerificationStatus = 'pass' | 'warn' | 'fail';
217
+
218
+ function extractContractMetadata(contract: SqlContract<SqlStorage>): {
219
+ contractStorageHash: SqlContract<SqlStorage>['storageHash'];
220
+ contractProfileHash?: SqlContract<SqlStorage>['profileHash'];
221
+ contractTarget: SqlContract<SqlStorage>['target'];
222
+ } {
223
+ return {
224
+ contractStorageHash: contract.storageHash,
225
+ contractProfileHash:
226
+ 'profileHash' in contract && typeof contract.profileHash === 'string'
227
+ ? contract.profileHash
228
+ : undefined,
229
+ contractTarget: contract.target,
230
+ };
231
+ }
232
+
233
+ function verifySchemaTables(options: {
234
+ contract: SqlContract<SqlStorage>;
235
+ schema: SqlSchemaIR;
236
+ strict: boolean;
237
+ typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
238
+ codecHooks: Map<string, CodecControlHooks>;
239
+ normalizeDefault?: DefaultNormalizer;
240
+ normalizeNativeType?: NativeTypeNormalizer;
241
+ }): { issues: SchemaIssue[]; rootChildren: SchemaVerificationNode[] } {
242
+ const {
243
+ contract,
244
+ schema,
245
+ strict,
246
+ typeMetadataRegistry,
247
+ codecHooks,
248
+ normalizeDefault,
249
+ normalizeNativeType,
250
+ } = options;
73
251
  const issues: SchemaIssue[] = [];
74
252
  const rootChildren: SchemaVerificationNode[] = [];
75
-
76
- // Compare tables
77
253
  const contractTables = contract.storage.tables;
78
254
  const schemaTables = schema.tables;
79
255
 
@@ -82,7 +258,6 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
82
258
  const tablePath = `storage.tables.${tableName}`;
83
259
 
84
260
  if (!schemaTable) {
85
- // Missing table
86
261
  issues.push({
87
262
  kind: 'missing_table',
88
263
  table: tableName,
@@ -102,366 +277,592 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
102
277
  continue;
103
278
  }
104
279
 
105
- // Table exists - compare columns, constraints, etc.
106
- const tableChildren: SchemaVerificationNode[] = [];
107
- const columnNodes: SchemaVerificationNode[] = [];
108
-
109
- // Compare columns
110
- for (const [columnName, contractColumn] of Object.entries(contractTable.columns)) {
111
- const schemaColumn = schemaTable.columns[columnName];
112
- const columnPath = `${tablePath}.columns.${columnName}`;
280
+ const tableChildren = verifyTableChildren({
281
+ contractTable,
282
+ schemaTable,
283
+ tableName,
284
+ tablePath,
285
+ issues,
286
+ strict,
287
+ typeMetadataRegistry,
288
+ codecHooks,
289
+ ...ifDefined('normalizeDefault', normalizeDefault),
290
+ ...ifDefined('normalizeNativeType', normalizeNativeType),
291
+ });
292
+ rootChildren.push(buildTableNode(tableName, tablePath, tableChildren));
293
+ }
113
294
 
114
- if (!schemaColumn) {
115
- // Missing column
295
+ if (strict) {
296
+ for (const tableName of Object.keys(schemaTables)) {
297
+ if (!contractTables[tableName]) {
116
298
  issues.push({
117
- kind: 'missing_column',
299
+ kind: 'extra_table',
118
300
  table: tableName,
119
- column: columnName,
120
- message: `Column "${tableName}"."${columnName}" is missing from database`,
301
+ message: `Extra table "${tableName}" found in database (not in contract)`,
121
302
  });
122
- columnNodes.push({
303
+ rootChildren.push({
123
304
  status: 'fail',
124
- kind: 'column',
125
- name: `${columnName}: missing`,
126
- contractPath: columnPath,
127
- code: 'missing_column',
128
- message: `Column "${columnName}" is missing`,
305
+ kind: 'table',
306
+ name: `table ${tableName}`,
307
+ contractPath: `storage.tables.${tableName}`,
308
+ code: 'extra_table',
309
+ message: `Extra table "${tableName}" found`,
129
310
  expected: undefined,
130
311
  actual: undefined,
131
312
  children: [],
132
313
  });
133
- continue;
134
314
  }
315
+ }
316
+ }
135
317
 
136
- // Column exists - compare type and nullability
137
- const columnChildren: SchemaVerificationNode[] = [];
138
- let columnStatus: 'pass' | 'warn' | 'fail' = 'pass';
318
+ return { issues, rootChildren };
319
+ }
139
320
 
140
- // Compare type using nativeType directly
141
- // Both contractColumn.nativeType and schemaColumn.nativeType are required by their types
142
- const contractNativeType = contractColumn.nativeType;
143
- const schemaNativeType = schemaColumn.nativeType;
321
+ function verifyTableChildren(options: {
322
+ contractTable: SqlContract<SqlStorage>['storage']['tables'][string];
323
+ schemaTable: SqlSchemaIR['tables'][string];
324
+ tableName: string;
325
+ tablePath: string;
326
+ issues: SchemaIssue[];
327
+ strict: boolean;
328
+ typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
329
+ codecHooks: Map<string, CodecControlHooks>;
330
+ normalizeDefault?: DefaultNormalizer;
331
+ normalizeNativeType?: NativeTypeNormalizer;
332
+ }): SchemaVerificationNode[] {
333
+ const {
334
+ contractTable,
335
+ schemaTable,
336
+ tableName,
337
+ tablePath,
338
+ issues,
339
+ strict,
340
+ typeMetadataRegistry,
341
+ codecHooks,
342
+ normalizeDefault,
343
+ normalizeNativeType,
344
+ } = options;
345
+ const tableChildren: SchemaVerificationNode[] = [];
346
+ const columnNodes = collectContractColumnNodes({
347
+ contractTable,
348
+ schemaTable,
349
+ tableName,
350
+ tablePath,
351
+ issues,
352
+ typeMetadataRegistry,
353
+ codecHooks,
354
+ ...ifDefined('normalizeDefault', normalizeDefault),
355
+ ...ifDefined('normalizeNativeType', normalizeNativeType),
356
+ });
357
+ if (columnNodes.length > 0) {
358
+ tableChildren.push(buildColumnsNode(tablePath, columnNodes));
359
+ }
360
+ if (strict) {
361
+ appendExtraColumnNodes({
362
+ contractTable,
363
+ schemaTable,
364
+ tableName,
365
+ tablePath,
366
+ issues,
367
+ columnNodes,
368
+ });
369
+ }
144
370
 
145
- if (contractNativeType !== schemaNativeType) {
146
- // Compare native types directly
147
- issues.push({
148
- kind: 'type_mismatch',
149
- table: tableName,
150
- column: columnName,
151
- expected: contractNativeType,
152
- actual: schemaNativeType,
153
- message: `Column "${tableName}"."${columnName}" has type mismatch: expected "${contractNativeType}", got "${schemaNativeType}"`,
154
- });
155
- columnChildren.push({
156
- status: 'fail',
157
- kind: 'type',
158
- name: 'type',
159
- contractPath: `${columnPath}.nativeType`,
160
- code: 'type_mismatch',
161
- message: `Type mismatch: expected ${contractNativeType}, got ${schemaNativeType}`,
162
- expected: contractNativeType,
163
- actual: schemaNativeType,
164
- children: [],
165
- });
166
- columnStatus = 'fail';
167
- }
371
+ if (contractTable.primaryKey) {
372
+ const pkStatus = verifyPrimaryKey(
373
+ contractTable.primaryKey,
374
+ schemaTable.primaryKey,
375
+ tableName,
376
+ issues,
377
+ );
378
+ if (pkStatus === 'fail') {
379
+ tableChildren.push({
380
+ status: 'fail',
381
+ kind: 'primaryKey',
382
+ name: `primary key: ${contractTable.primaryKey.columns.join(', ')}`,
383
+ contractPath: `${tablePath}.primaryKey`,
384
+ code: 'primary_key_mismatch',
385
+ message: 'Primary key mismatch',
386
+ expected: contractTable.primaryKey,
387
+ actual: schemaTable.primaryKey,
388
+ children: [],
389
+ });
390
+ } else {
391
+ tableChildren.push({
392
+ status: 'pass',
393
+ kind: 'primaryKey',
394
+ name: `primary key: ${contractTable.primaryKey.columns.join(', ')}`,
395
+ contractPath: `${tablePath}.primaryKey`,
396
+ code: '',
397
+ message: '',
398
+ expected: undefined,
399
+ actual: undefined,
400
+ children: [],
401
+ });
402
+ }
403
+ } else if (schemaTable.primaryKey && strict) {
404
+ issues.push({
405
+ kind: 'extra_primary_key',
406
+ table: tableName,
407
+ message: 'Extra primary key found in database (not in contract)',
408
+ });
409
+ tableChildren.push({
410
+ status: 'fail',
411
+ kind: 'primaryKey',
412
+ name: `primary key: ${schemaTable.primaryKey.columns.join(', ')}`,
413
+ contractPath: `${tablePath}.primaryKey`,
414
+ code: 'extra_primary_key',
415
+ message: 'Extra primary key found',
416
+ expected: undefined,
417
+ actual: schemaTable.primaryKey,
418
+ children: [],
419
+ });
420
+ }
168
421
 
169
- // Optionally validate that codecId (if present) and nativeType agree with registry
170
- if (contractColumn.codecId) {
171
- const typeMetadata = typeMetadataRegistry.get(contractColumn.codecId);
172
- if (!typeMetadata) {
173
- // Warning: codecId not found in registry
174
- columnChildren.push({
175
- status: 'warn',
176
- kind: 'type',
177
- name: 'type_metadata_missing',
178
- contractPath: `${columnPath}.codecId`,
179
- code: 'type_metadata_missing',
180
- message: `codecId "${contractColumn.codecId}" not found in type metadata registry`,
181
- expected: contractColumn.codecId,
182
- actual: undefined,
183
- children: [],
184
- });
185
- } else if (typeMetadata.nativeType && typeMetadata.nativeType !== contractNativeType) {
186
- // Warning: codecId and nativeType don't agree with registry
187
- columnChildren.push({
188
- status: 'warn',
189
- kind: 'type',
190
- name: 'type_consistency',
191
- contractPath: `${columnPath}.codecId`,
192
- code: 'type_consistency_warning',
193
- message: `codecId "${contractColumn.codecId}" maps to nativeType "${typeMetadata.nativeType}" in registry, but contract has "${contractNativeType}"`,
194
- expected: typeMetadata.nativeType,
195
- actual: contractNativeType,
196
- children: [],
197
- });
198
- }
199
- }
422
+ // Verify FK constraints only for FKs with constraint: true
423
+ const constraintFks = contractTable.foreignKeys.filter((fk) => fk.constraint === true);
424
+ if (constraintFks.length > 0) {
425
+ const fkStatuses = verifyForeignKeys(
426
+ constraintFks,
427
+ schemaTable.foreignKeys,
428
+ tableName,
429
+ tablePath,
430
+ issues,
431
+ strict,
432
+ );
433
+ tableChildren.push(...fkStatuses);
434
+ }
200
435
 
201
- // Compare nullability
202
- if (contractColumn.nullable !== schemaColumn.nullable) {
203
- issues.push({
204
- kind: 'nullability_mismatch',
205
- table: tableName,
206
- column: columnName,
207
- expected: String(contractColumn.nullable),
208
- actual: String(schemaColumn.nullable),
209
- message: `Column "${tableName}"."${columnName}" has nullability mismatch: expected ${contractColumn.nullable ? 'nullable' : 'not null'}, got ${schemaColumn.nullable ? 'nullable' : 'not null'}`,
210
- });
211
- columnChildren.push({
212
- status: 'fail',
213
- kind: 'nullability',
214
- name: 'nullability',
215
- contractPath: `${columnPath}.nullable`,
216
- code: 'nullability_mismatch',
217
- message: `Nullability mismatch: expected ${contractColumn.nullable ? 'nullable' : 'not null'}, got ${schemaColumn.nullable ? 'nullable' : 'not null'}`,
218
- expected: contractColumn.nullable,
219
- actual: schemaColumn.nullable,
220
- children: [],
221
- });
222
- columnStatus = 'fail';
223
- }
436
+ const uniqueStatuses = verifyUniqueConstraints(
437
+ contractTable.uniques,
438
+ schemaTable.uniques,
439
+ schemaTable.indexes,
440
+ tableName,
441
+ tablePath,
442
+ issues,
443
+ strict,
444
+ );
445
+ tableChildren.push(...uniqueStatuses);
446
+
447
+ // Combine user-declared indexes with FK-backing indexes (from FKs with index: true)
448
+ // so the verifier treats FK-backing indexes as expected, not "extra".
449
+ // Deduplicate: skip FK-backing indexes already covered by a user-declared index.
450
+ const fkBackingIndexes = contractTable.foreignKeys
451
+ .filter(
452
+ (fk) =>
453
+ fk.index === true &&
454
+ !contractTable.indexes.some((idx) => arraysEqual(idx.columns, fk.columns)),
455
+ )
456
+ .map((fk) => ({ columns: fk.columns }));
457
+ const allExpectedIndexes = [...contractTable.indexes, ...fkBackingIndexes];
458
+
459
+ const indexStatuses = verifyIndexes(
460
+ allExpectedIndexes,
461
+ schemaTable.indexes,
462
+ schemaTable.uniques,
463
+ tableName,
464
+ tablePath,
465
+ issues,
466
+ strict,
467
+ );
468
+ tableChildren.push(...indexStatuses);
469
+
470
+ return tableChildren;
471
+ }
224
472
 
225
- // Compute column status from children (fail > warn > pass)
226
- const computedColumnStatus = columnChildren.some((c) => c.status === 'fail')
227
- ? 'fail'
228
- : columnChildren.some((c) => c.status === 'warn')
229
- ? 'warn'
230
- : 'pass';
231
- // Use computed status if we have children, otherwise use the manually set status
232
- const finalColumnStatus = columnChildren.length > 0 ? computedColumnStatus : columnStatus;
233
-
234
- // Build column node
235
- const nullableText = contractColumn.nullable ? 'nullable' : 'not nullable';
236
- const columnTypeDisplay = contractColumn.codecId
237
- ? `${contractNativeType} (${contractColumn.codecId})`
238
- : contractNativeType;
239
- // Collect failure messages from children to create a summary message
240
- const failureMessages = columnChildren
241
- .filter((child) => child.status === 'fail' && child.message)
242
- .map((child) => child.message)
243
- .filter((msg): msg is string => typeof msg === 'string' && msg.length > 0);
244
- const columnMessage =
245
- finalColumnStatus === 'fail' && failureMessages.length > 0
246
- ? failureMessages.join('; ')
247
- : '';
248
- // Extract code from first child if status indicates an issue
249
- const columnCode =
250
- (finalColumnStatus === 'fail' || finalColumnStatus === 'warn') && columnChildren[0]
251
- ? columnChildren[0].code
252
- : '';
473
+ function collectContractColumnNodes(options: {
474
+ contractTable: SqlContract<SqlStorage>['storage']['tables'][string];
475
+ schemaTable: SqlSchemaIR['tables'][string];
476
+ tableName: string;
477
+ tablePath: string;
478
+ issues: SchemaIssue[];
479
+ typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
480
+ codecHooks: Map<string, CodecControlHooks>;
481
+ normalizeDefault?: DefaultNormalizer;
482
+ normalizeNativeType?: NativeTypeNormalizer;
483
+ }): SchemaVerificationNode[] {
484
+ const {
485
+ contractTable,
486
+ schemaTable,
487
+ tableName,
488
+ tablePath,
489
+ issues,
490
+ typeMetadataRegistry,
491
+ codecHooks,
492
+ normalizeDefault,
493
+ normalizeNativeType,
494
+ } = options;
495
+ const columnNodes: SchemaVerificationNode[] = [];
496
+
497
+ for (const [columnName, contractColumn] of Object.entries(contractTable.columns)) {
498
+ const schemaColumn = schemaTable.columns[columnName];
499
+ const columnPath = `${tablePath}.columns.${columnName}`;
500
+
501
+ if (!schemaColumn) {
502
+ issues.push({
503
+ kind: 'missing_column',
504
+ table: tableName,
505
+ column: columnName,
506
+ message: `Column "${tableName}"."${columnName}" is missing from database`,
507
+ });
253
508
  columnNodes.push({
254
- status: finalColumnStatus,
509
+ status: 'fail',
255
510
  kind: 'column',
256
- name: `${columnName}: ${columnTypeDisplay} (${nullableText})`,
511
+ name: `${columnName}: missing`,
257
512
  contractPath: columnPath,
258
- code: columnCode,
259
- message: columnMessage,
513
+ code: 'missing_column',
514
+ message: `Column "${columnName}" is missing`,
260
515
  expected: undefined,
261
516
  actual: undefined,
262
- children: columnChildren,
517
+ children: [],
263
518
  });
519
+ continue;
264
520
  }
265
521
 
266
- // Group columns under a "columns" header if we have any columns
267
- if (columnNodes.length > 0) {
268
- const columnsStatus = columnNodes.some((c) => c.status === 'fail')
269
- ? 'fail'
270
- : columnNodes.some((c) => c.status === 'warn')
271
- ? 'warn'
272
- : 'pass';
273
- tableChildren.push({
274
- status: columnsStatus,
275
- kind: 'columns',
276
- name: 'columns',
277
- contractPath: `${tablePath}.columns`,
278
- code: '',
279
- message: '',
522
+ columnNodes.push(
523
+ verifyColumn({
524
+ tableName,
525
+ columnName,
526
+ contractColumn,
527
+ schemaColumn,
528
+ columnPath,
529
+ issues,
530
+ typeMetadataRegistry,
531
+ codecHooks,
532
+ ...ifDefined('normalizeDefault', normalizeDefault),
533
+ ...ifDefined('normalizeNativeType', normalizeNativeType),
534
+ }),
535
+ );
536
+ }
537
+
538
+ return columnNodes;
539
+ }
540
+
541
+ function appendExtraColumnNodes(options: {
542
+ contractTable: SqlContract<SqlStorage>['storage']['tables'][string];
543
+ schemaTable: SqlSchemaIR['tables'][string];
544
+ tableName: string;
545
+ tablePath: string;
546
+ issues: SchemaIssue[];
547
+ columnNodes: SchemaVerificationNode[];
548
+ }): void {
549
+ const { contractTable, schemaTable, tableName, tablePath, issues, columnNodes } = options;
550
+ for (const [columnName, { nativeType }] of Object.entries(schemaTable.columns)) {
551
+ if (!contractTable.columns[columnName]) {
552
+ issues.push({
553
+ kind: 'extra_column',
554
+ table: tableName,
555
+ column: columnName,
556
+ message: `Extra column "${tableName}"."${columnName}" found in database (not in contract)`,
557
+ });
558
+ columnNodes.push({
559
+ status: 'fail',
560
+ kind: 'column',
561
+ name: `${columnName}: extra`,
562
+ contractPath: `${tablePath}.columns.${columnName}`,
563
+ code: 'extra_column',
564
+ message: `Extra column "${columnName}" found`,
280
565
  expected: undefined,
281
- actual: undefined,
282
- children: columnNodes,
566
+ actual: nativeType,
567
+ children: [],
283
568
  });
284
569
  }
570
+ }
571
+ }
285
572
 
286
- // Check for extra columns in strict mode
287
- if (strict) {
288
- for (const [columnName, { nativeType }] of Object.entries(schemaTable.columns)) {
289
- if (!contractTable.columns[columnName]) {
290
- issues.push({
291
- kind: 'extra_column',
292
- table: tableName,
293
- column: columnName,
294
- message: `Extra column "${tableName}"."${columnName}" found in database (not in contract)`,
295
- });
296
- columnNodes.push({
297
- status: 'fail',
298
- kind: 'column',
299
- name: `${columnName}: extra`,
300
- contractPath: `${tablePath}.columns.${columnName}`,
301
- code: 'extra_column',
302
- message: `Extra column "${columnName}" found`,
303
- expected: undefined,
304
- actual: nativeType,
305
- children: [],
306
- });
307
- }
308
- }
573
+ function verifyColumn(options: {
574
+ tableName: string;
575
+ columnName: string;
576
+ contractColumn: SqlContract<SqlStorage>['storage']['tables'][string]['columns'][string];
577
+ schemaColumn: SqlSchemaIR['tables'][string]['columns'][string];
578
+ columnPath: string;
579
+ issues: SchemaIssue[];
580
+ typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
581
+ codecHooks: Map<string, CodecControlHooks>;
582
+ normalizeDefault?: DefaultNormalizer;
583
+ normalizeNativeType?: NativeTypeNormalizer;
584
+ }): SchemaVerificationNode {
585
+ const {
586
+ tableName,
587
+ columnName,
588
+ contractColumn,
589
+ schemaColumn,
590
+ columnPath,
591
+ issues,
592
+ codecHooks,
593
+ normalizeDefault,
594
+ normalizeNativeType,
595
+ } = options;
596
+ const columnChildren: SchemaVerificationNode[] = [];
597
+ let columnStatus: VerificationStatus = 'pass';
598
+
599
+ const contractNativeType = renderExpectedNativeType(contractColumn, codecHooks);
600
+ const schemaNativeType =
601
+ normalizeNativeType?.(schemaColumn.nativeType) ?? schemaColumn.nativeType;
602
+
603
+ if (contractNativeType !== schemaNativeType) {
604
+ issues.push({
605
+ kind: 'type_mismatch',
606
+ table: tableName,
607
+ column: columnName,
608
+ expected: contractNativeType,
609
+ actual: schemaNativeType,
610
+ message: `Column "${tableName}"."${columnName}" has type mismatch: expected "${contractNativeType}", got "${schemaNativeType}"`,
611
+ });
612
+ columnChildren.push({
613
+ status: 'fail',
614
+ kind: 'type',
615
+ name: 'type',
616
+ contractPath: `${columnPath}.nativeType`,
617
+ code: 'type_mismatch',
618
+ message: `Type mismatch: expected ${contractNativeType}, got ${schemaNativeType}`,
619
+ expected: contractNativeType,
620
+ actual: schemaNativeType,
621
+ children: [],
622
+ });
623
+ columnStatus = 'fail';
624
+ }
625
+
626
+ if (contractColumn.codecId) {
627
+ const typeMetadata = options.typeMetadataRegistry.get(contractColumn.codecId);
628
+ if (!typeMetadata) {
629
+ columnChildren.push({
630
+ status: 'warn',
631
+ kind: 'type',
632
+ name: 'type_metadata_missing',
633
+ contractPath: `${columnPath}.codecId`,
634
+ code: 'type_metadata_missing',
635
+ message: `codecId "${contractColumn.codecId}" not found in type metadata registry`,
636
+ expected: contractColumn.codecId,
637
+ actual: undefined,
638
+ children: [],
639
+ });
640
+ } else if (typeMetadata.nativeType && typeMetadata.nativeType !== contractColumn.nativeType) {
641
+ columnChildren.push({
642
+ status: 'warn',
643
+ kind: 'type',
644
+ name: 'type_consistency',
645
+ contractPath: `${columnPath}.codecId`,
646
+ code: 'type_consistency_warning',
647
+ message: `codecId "${contractColumn.codecId}" maps to nativeType "${typeMetadata.nativeType}" in registry, but contract has "${contractColumn.nativeType}"`,
648
+ expected: typeMetadata.nativeType,
649
+ actual: contractColumn.nativeType,
650
+ children: [],
651
+ });
309
652
  }
653
+ }
310
654
 
311
- // Compare primary key
312
- if (contractTable.primaryKey) {
313
- const pkStatus = verifyPrimaryKey(
314
- contractTable.primaryKey,
315
- schemaTable.primaryKey,
316
- tableName,
317
- issues,
318
- );
319
- if (pkStatus === 'fail') {
320
- tableChildren.push({
321
- status: 'fail',
322
- kind: 'primaryKey',
323
- name: `primary key: ${contractTable.primaryKey.columns.join(', ')}`,
324
- contractPath: `${tablePath}.primaryKey`,
325
- code: 'primary_key_mismatch',
326
- message: 'Primary key mismatch',
327
- expected: contractTable.primaryKey,
328
- actual: schemaTable.primaryKey,
329
- children: [],
330
- });
331
- } else {
332
- tableChildren.push({
333
- status: 'pass',
334
- kind: 'primaryKey',
335
- name: `primary key: ${contractTable.primaryKey.columns.join(', ')}`,
336
- contractPath: `${tablePath}.primaryKey`,
337
- code: '',
338
- message: '',
339
- expected: undefined,
340
- actual: undefined,
341
- children: [],
342
- });
343
- }
344
- } else if (schemaTable.primaryKey && strict) {
345
- // Extra primary key in strict mode
655
+ if (contractColumn.nullable !== schemaColumn.nullable) {
656
+ issues.push({
657
+ kind: 'nullability_mismatch',
658
+ table: tableName,
659
+ column: columnName,
660
+ expected: String(contractColumn.nullable),
661
+ actual: String(schemaColumn.nullable),
662
+ message: `Column "${tableName}"."${columnName}" has nullability mismatch: expected ${contractColumn.nullable ? 'nullable' : 'not null'}, got ${schemaColumn.nullable ? 'nullable' : 'not null'}`,
663
+ });
664
+ columnChildren.push({
665
+ status: 'fail',
666
+ kind: 'nullability',
667
+ name: 'nullability',
668
+ contractPath: `${columnPath}.nullable`,
669
+ code: 'nullability_mismatch',
670
+ message: `Nullability mismatch: expected ${contractColumn.nullable ? 'nullable' : 'not null'}, got ${schemaColumn.nullable ? 'nullable' : 'not null'}`,
671
+ expected: contractColumn.nullable,
672
+ actual: schemaColumn.nullable,
673
+ children: [],
674
+ });
675
+ columnStatus = 'fail';
676
+ }
677
+
678
+ if (contractColumn.default) {
679
+ if (!schemaColumn.default) {
680
+ const defaultDescription = describeColumnDefault(contractColumn.default);
346
681
  issues.push({
347
- kind: 'extra_primary_key',
682
+ kind: 'default_missing',
348
683
  table: tableName,
349
- message: 'Extra primary key found in database (not in contract)',
684
+ column: columnName,
685
+ expected: defaultDescription,
686
+ message: `Column "${tableName}"."${columnName}" should have default ${defaultDescription} but database has no default`,
350
687
  });
351
- tableChildren.push({
688
+ columnChildren.push({
352
689
  status: 'fail',
353
- kind: 'primaryKey',
354
- name: `primary key: ${schemaTable.primaryKey.columns.join(', ')}`,
355
- contractPath: `${tablePath}.primaryKey`,
356
- code: 'extra_primary_key',
357
- message: 'Extra primary key found',
358
- expected: undefined,
359
- actual: schemaTable.primaryKey,
690
+ kind: 'default',
691
+ name: 'default',
692
+ contractPath: `${columnPath}.default`,
693
+ code: 'default_missing',
694
+ message: `Default missing: expected ${defaultDescription}`,
695
+ expected: defaultDescription,
696
+ actual: undefined,
697
+ children: [],
698
+ });
699
+ columnStatus = 'fail';
700
+ } else if (
701
+ !columnDefaultsEqual(
702
+ contractColumn.default,
703
+ schemaColumn.default,
704
+ normalizeDefault,
705
+ schemaNativeType,
706
+ )
707
+ ) {
708
+ const expectedDescription = describeColumnDefault(contractColumn.default);
709
+ // schemaColumn.default is now a raw string, describe it as-is
710
+ const actualDescription = schemaColumn.default;
711
+ issues.push({
712
+ kind: 'default_mismatch',
713
+ table: tableName,
714
+ column: columnName,
715
+ expected: expectedDescription,
716
+ actual: actualDescription,
717
+ message: `Column "${tableName}"."${columnName}" has default mismatch: expected ${expectedDescription}, got ${actualDescription}`,
718
+ });
719
+ columnChildren.push({
720
+ status: 'fail',
721
+ kind: 'default',
722
+ name: 'default',
723
+ contractPath: `${columnPath}.default`,
724
+ code: 'default_mismatch',
725
+ message: `Default mismatch: expected ${expectedDescription}, got ${actualDescription}`,
726
+ expected: expectedDescription,
727
+ actual: actualDescription,
360
728
  children: [],
361
729
  });
730
+ columnStatus = 'fail';
362
731
  }
732
+ }
363
733
 
364
- // Compare foreign keys
365
- const fkStatuses = verifyForeignKeys(
366
- contractTable.foreignKeys,
367
- schemaTable.foreignKeys,
368
- tableName,
369
- tablePath,
370
- issues,
371
- strict,
372
- );
373
- tableChildren.push(...fkStatuses);
734
+ // Single-pass aggregation for better performance
735
+ const aggregated = aggregateChildState(columnChildren, columnStatus);
736
+ const nullableText = contractColumn.nullable ? 'nullable' : 'not nullable';
737
+ const columnTypeDisplay = contractColumn.codecId
738
+ ? `${contractNativeType} (${contractColumn.codecId})`
739
+ : contractNativeType;
740
+ const columnMessage = aggregated.failureMessages.join('; ');
374
741
 
375
- // Compare unique constraints
376
- // Pass schemaIndexes so unique indexes can satisfy unique constraint requirements
377
- const uniqueStatuses = verifyUniqueConstraints(
378
- contractTable.uniques,
379
- schemaTable.uniques,
380
- schemaTable.indexes,
381
- tableName,
382
- tablePath,
383
- issues,
384
- strict,
385
- );
386
- tableChildren.push(...uniqueStatuses);
387
-
388
- // Compare indexes
389
- // Pass schemaUniques so unique constraints can satisfy index requirements
390
- const indexStatuses = verifyIndexes(
391
- contractTable.indexes,
392
- schemaTable.indexes,
393
- schemaTable.uniques,
394
- tableName,
395
- tablePath,
396
- issues,
397
- strict,
398
- );
399
- tableChildren.push(...indexStatuses);
400
-
401
- // Build table node
402
- const tableStatus = tableChildren.some((c) => c.status === 'fail')
403
- ? 'fail'
404
- : tableChildren.some((c) => c.status === 'warn')
405
- ? 'warn'
406
- : 'pass';
407
- // Collect failure messages from children to create a summary message
408
- const tableFailureMessages = tableChildren
409
- .filter((child) => child.status === 'fail' && child.message)
410
- .map((child) => child.message)
411
- .filter((msg): msg is string => typeof msg === 'string' && msg.length > 0);
412
- const tableMessage =
413
- tableStatus === 'fail' && tableFailureMessages.length > 0
414
- ? `${tableFailureMessages.length} issue${tableFailureMessages.length === 1 ? '' : 's'}`
415
- : '';
416
- const tableCode =
417
- tableStatus === 'fail' && tableChildren.length > 0 && tableChildren[0]
418
- ? tableChildren[0].code
419
- : '';
420
- rootChildren.push({
421
- status: tableStatus,
422
- kind: 'table',
423
- name: `table ${tableName}`,
424
- contractPath: tablePath,
425
- code: tableCode,
426
- message: tableMessage,
427
- expected: undefined,
428
- actual: undefined,
429
- children: tableChildren,
430
- });
431
- }
742
+ return {
743
+ status: aggregated.status,
744
+ kind: 'column',
745
+ name: `${columnName}: ${columnTypeDisplay} (${nullableText})`,
746
+ contractPath: columnPath,
747
+ code: aggregated.firstCode,
748
+ message: columnMessage,
749
+ expected: undefined,
750
+ actual: undefined,
751
+ children: columnChildren,
752
+ };
753
+ }
432
754
 
433
- // Check for extra tables in strict mode
434
- if (strict) {
435
- for (const tableName of Object.keys(schemaTables)) {
436
- if (!contractTables[tableName]) {
437
- issues.push({
438
- kind: 'extra_table',
439
- table: tableName,
440
- message: `Extra table "${tableName}" found in database (not in contract)`,
441
- });
442
- rootChildren.push({
443
- status: 'fail',
444
- kind: 'table',
445
- name: `table ${tableName}`,
446
- contractPath: `storage.tables.${tableName}`,
447
- code: 'extra_table',
448
- message: `Extra table "${tableName}" found`,
449
- expected: undefined,
450
- actual: undefined,
451
- children: [],
452
- });
755
+ function buildColumnsNode(
756
+ tablePath: string,
757
+ columnNodes: SchemaVerificationNode[],
758
+ ): SchemaVerificationNode {
759
+ return {
760
+ status: aggregateChildState(columnNodes, 'pass').status,
761
+ kind: 'columns',
762
+ name: 'columns',
763
+ contractPath: `${tablePath}.columns`,
764
+ code: '',
765
+ message: '',
766
+ expected: undefined,
767
+ actual: undefined,
768
+ children: columnNodes,
769
+ };
770
+ }
771
+
772
+ function buildTableNode(
773
+ tableName: string,
774
+ tablePath: string,
775
+ tableChildren: SchemaVerificationNode[],
776
+ ): SchemaVerificationNode {
777
+ const tableStatus = aggregateChildState(tableChildren, 'pass').status;
778
+ const tableFailureMessages = tableChildren
779
+ .filter((child) => child.status === 'fail' && child.message)
780
+ .map((child) => child.message)
781
+ .filter((msg): msg is string => typeof msg === 'string' && msg.length > 0);
782
+ const tableMessage =
783
+ tableStatus === 'fail' && tableFailureMessages.length > 0
784
+ ? `${tableFailureMessages.length} issue${tableFailureMessages.length === 1 ? '' : 's'}`
785
+ : '';
786
+ const tableCode =
787
+ tableStatus === 'fail' && tableChildren.length > 0 && tableChildren[0]
788
+ ? tableChildren[0].code
789
+ : '';
790
+
791
+ return {
792
+ status: tableStatus,
793
+ kind: 'table',
794
+ name: `table ${tableName}`,
795
+ contractPath: tablePath,
796
+ code: tableCode,
797
+ message: tableMessage,
798
+ expected: undefined,
799
+ actual: undefined,
800
+ children: tableChildren,
801
+ };
802
+ }
803
+
804
+ function buildRootNode(rootChildren: SchemaVerificationNode[]): SchemaVerificationNode {
805
+ return {
806
+ status: aggregateChildState(rootChildren, 'pass').status,
807
+ kind: 'contract',
808
+ name: 'contract',
809
+ contractPath: '',
810
+ code: '',
811
+ message: '',
812
+ expected: undefined,
813
+ actual: undefined,
814
+ children: rootChildren,
815
+ };
816
+ }
817
+
818
+ /**
819
+ * Aggregated state from child nodes, computed in a single pass.
820
+ */
821
+ interface AggregatedChildState {
822
+ readonly status: VerificationStatus;
823
+ readonly failureMessages: readonly string[];
824
+ readonly firstCode: string;
825
+ }
826
+
827
+ /**
828
+ * Aggregates status, failure messages, and code from children in a single pass.
829
+ * This is more efficient than calling separate functions that each iterate the array.
830
+ */
831
+ function aggregateChildState(
832
+ children: SchemaVerificationNode[],
833
+ fallback: VerificationStatus,
834
+ ): AggregatedChildState {
835
+ let status: VerificationStatus = fallback;
836
+ const failureMessages: string[] = [];
837
+ let firstCode = '';
838
+
839
+ for (const child of children) {
840
+ if (child.status === 'fail') {
841
+ status = 'fail';
842
+ if (!firstCode) {
843
+ firstCode = child.code;
844
+ }
845
+ if (child.message && typeof child.message === 'string' && child.message.length > 0) {
846
+ failureMessages.push(child.message);
847
+ }
848
+ } else if (child.status === 'warn' && status !== 'fail') {
849
+ status = 'warn';
850
+ if (!firstCode) {
851
+ firstCode = child.code;
453
852
  }
454
853
  }
455
854
  }
456
855
 
457
- // Validate that all extension packs declared in the contract are present in frameworkComponents
458
- // This is a configuration integrity check - if the contract was emitted with an extension,
459
- // that extension must be provided in the current configuration.
460
- // Note: contract.extensionPacks includes adapter.id and target.id (from extractExtensionIds),
461
- // so we check for matches as extension, adapter, or target components.
856
+ return { status, failureMessages, firstCode };
857
+ }
858
+
859
+ function validateFrameworkComponentsForExtensions(
860
+ contract: SqlContract<SqlStorage>,
861
+ frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>,
862
+ ): void {
462
863
  const contractExtensionPacks = contract.extensionPacks ?? {};
463
864
  for (const extensionNamespace of Object.keys(contractExtensionPacks)) {
464
- const hasComponent = options.frameworkComponents.some(
865
+ const hasComponent = frameworkComponents.some(
465
866
  (component) =>
466
867
  component.id === extensionNamespace &&
467
868
  (component.kind === 'extension' ||
@@ -476,76 +877,6 @@ export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabase
476
877
  );
477
878
  }
478
879
  }
479
-
480
- // Compare component-owned database dependencies (pure, deterministic)
481
- // Per ADR 154: We do NOT infer dependencies from contract extension packs.
482
- // Dependencies are only collected from frameworkComponents provided by the CLI.
483
- const databaseDependencies = collectDependenciesFromFrameworkComponents(
484
- options.frameworkComponents,
485
- );
486
- const dependencyStatuses = verifyDatabaseDependencies(databaseDependencies, schema, issues);
487
- rootChildren.push(...dependencyStatuses);
488
-
489
- // Build root node
490
- const rootStatus = rootChildren.some((c) => c.status === 'fail')
491
- ? 'fail'
492
- : rootChildren.some((c) => c.status === 'warn')
493
- ? 'warn'
494
- : 'pass';
495
- const root: SchemaVerificationNode = {
496
- status: rootStatus,
497
- kind: 'contract',
498
- name: 'contract',
499
- contractPath: '',
500
- code: '',
501
- message: '',
502
- expected: undefined,
503
- actual: undefined,
504
- children: rootChildren,
505
- };
506
-
507
- // Compute counts
508
- const counts = computeCounts(root);
509
-
510
- // Set ok flag
511
- const ok = counts.fail === 0;
512
-
513
- // Set code
514
- const code = ok ? undefined : 'PN-SCHEMA-0001';
515
-
516
- // Set summary
517
- const summary = ok
518
- ? 'Database schema satisfies contract'
519
- : `Database schema does not satisfy contract (${counts.fail} failure${counts.fail === 1 ? '' : 's'})`;
520
-
521
- const totalTime = Date.now() - startTime;
522
-
523
- return {
524
- ok,
525
- ...ifDefined('code', code),
526
- summary,
527
- contract: {
528
- coreHash: contractCoreHash,
529
- ...ifDefined('profileHash', contractProfileHash),
530
- },
531
- target: {
532
- expected: contractTarget,
533
- actual: contractTarget,
534
- },
535
- schema: {
536
- issues,
537
- root,
538
- counts,
539
- },
540
- meta: {
541
- strict,
542
- ...ifDefined('contractPath', context?.contractPath),
543
- ...ifDefined('configPath', context?.configPath),
544
- },
545
- timings: {
546
- total: totalTime,
547
- },
548
- };
549
880
  }
550
881
 
551
882
  /**
@@ -586,3 +917,95 @@ function collectDependenciesFromFrameworkComponents<T extends string>(
586
917
  }
587
918
  return dependencies;
588
919
  }
920
+
921
+ /**
922
+ * Renders the expected native type for a contract column, expanding parameterized types
923
+ * using codec control hooks when available.
924
+ *
925
+ * This function delegates to the `expandNativeType` hook if the codec provides one,
926
+ * ensuring that the SQL family layer remains dialect-agnostic while allowing
927
+ * target-specific adapters (like Postgres) to provide their own expansion logic.
928
+ */
929
+ function renderExpectedNativeType(
930
+ contractColumn: SqlContract<SqlStorage>['storage']['tables'][string]['columns'][string],
931
+ codecHooks: Map<string, CodecControlHooks>,
932
+ ): string {
933
+ const { codecId, nativeType, typeParams } = contractColumn;
934
+
935
+ // If no typeParams or codecId, return the base native type
936
+ if (!typeParams || !codecId) {
937
+ return nativeType;
938
+ }
939
+
940
+ // Try to use the codec's expandNativeType hook if available
941
+ const hooks = codecHooks.get(codecId);
942
+ if (hooks?.expandNativeType) {
943
+ return hooks.expandNativeType({ nativeType, codecId, typeParams });
944
+ }
945
+
946
+ // Fallback: return base native type if no hook is available
947
+ return nativeType;
948
+ }
949
+
950
+ /**
951
+ * Describes a column default for display purposes.
952
+ */
953
+ function describeColumnDefault(columnDefault: ColumnDefault): string {
954
+ switch (columnDefault.kind) {
955
+ case 'literal':
956
+ return `literal(${columnDefault.expression})`;
957
+ case 'function':
958
+ return columnDefault.expression;
959
+ }
960
+ }
961
+
962
+ /**
963
+ * Compares a contract ColumnDefault against a schema raw default string for semantic equality.
964
+ *
965
+ * When a normalizer is provided, the raw schema default is first normalized to a ColumnDefault
966
+ * before comparison. Without a normalizer, falls back to direct string comparison against
967
+ * the contract expression.
968
+ *
969
+ * @param contractDefault - The expected default from the contract (normalized ColumnDefault)
970
+ * @param schemaDefault - The raw default expression from the database (string)
971
+ * @param normalizer - Optional target-specific normalizer to convert raw defaults
972
+ * @param nativeType - The column's native type, passed to normalizer for context
973
+ */
974
+ function columnDefaultsEqual(
975
+ contractDefault: ColumnDefault,
976
+ schemaDefault: string,
977
+ normalizer?: DefaultNormalizer,
978
+ nativeType?: string,
979
+ ): boolean {
980
+ // If no normalizer provided, fall back to direct string comparison
981
+ if (!normalizer) {
982
+ return contractDefault.expression === schemaDefault;
983
+ }
984
+
985
+ // Normalize the raw schema default using target-specific logic
986
+ const normalizedSchema = normalizer(schemaDefault, nativeType ?? '');
987
+ if (!normalizedSchema) {
988
+ // Normalizer couldn't parse the expression - treat as mismatch
989
+ return false;
990
+ }
991
+
992
+ // Compare normalized defaults
993
+ if (contractDefault.kind !== normalizedSchema.kind) {
994
+ return false;
995
+ }
996
+ if (contractDefault.kind === 'literal' && normalizedSchema.kind === 'literal') {
997
+ // Normalize both sides: the contract expression may also contain a type cast
998
+ // (e.g. 'atRisk'::"BillingState") that the normalizer strips, so run the
999
+ // normalizer on the contract expression too for a fair comparison.
1000
+ const normalizedContract = normalizer(contractDefault.expression, nativeType ?? '');
1001
+ const contractExpr = (normalizedContract?.expression ?? contractDefault.expression).trim();
1002
+ const schemaExpr = normalizedSchema.expression.trim();
1003
+ return contractExpr === schemaExpr;
1004
+ }
1005
+ if (contractDefault.kind === 'function' && normalizedSchema.kind === 'function') {
1006
+ // Normalize function expressions for comparison (case-insensitive, whitespace-tolerant)
1007
+ const normalizeExpr = (expr: string) => expr.toLowerCase().replace(/\s+/g, '');
1008
+ return normalizeExpr(contractDefault.expression) === normalizeExpr(normalizedSchema.expression);
1009
+ }
1010
+ return false;
1011
+ }