@prisma-next/family-sql 0.3.0-pr.99.6 → 0.3.0

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