@prisma-next/family-sql 0.3.0-dev.10 → 0.3.0-dev.113

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