@prisma-next/family-sql 0.3.0-dev.4 → 0.3.0-dev.40

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