@prisma-next/family-sql 0.3.0-dev.15 → 0.3.0-dev.162

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 +38 -46
  41. package/src/core/assembly.ts +132 -92
  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 +206 -106
  55. package/src/core/schema-verify/verify-sql-schema.ts +964 -414
  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 +10 -2
  62. package/src/exports/test-utils.ts +3 -4
  63. package/dist/chunk-BHEGVBY7.js +0 -772
  64. package/dist/chunk-BHEGVBY7.js.map +0 -1
  65. package/dist/chunk-SQ2VWYDV.js +0 -589
  66. package/dist/chunk-SQ2VWYDV.js.map +0 -1
  67. package/dist/chunk-SU7LN2UH.js +0 -96
  68. package/dist/chunk-SU7LN2UH.js.map +0 -1
  69. package/dist/core/assembly.d.ts +0 -25
  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 -24
  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 -50
  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 -11
  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 -903
@@ -1,144 +1,22 @@
1
- import { assertRuntimeContractRequirementsSatisfied } from '@prisma-next/core-execution-plane/framework-components';
2
- import type {
3
- RuntimeAdapterDescriptor,
4
- RuntimeDriverDescriptor,
5
- RuntimeDriverInstance,
6
- RuntimeFamilyDescriptor,
7
- RuntimeFamilyInstance,
8
- RuntimeTargetDescriptor,
9
- } from '@prisma-next/core-execution-plane/types';
10
- import type { Log, Plugin, RuntimeVerifyOptions } from '@prisma-next/runtime-executor';
11
- import type { SqlContract, SqlStorage } from '@prisma-next/sql-contract/types';
12
- import type {
13
- Adapter,
14
- LoweredStatement,
15
- SelectAst,
16
- SqlDriver,
17
- } from '@prisma-next/sql-relational-core/ast';
18
- import type {
19
- Runtime,
20
- RuntimeOptions,
21
- SqlRuntimeAdapterInstance,
22
- SqlRuntimeExtensionDescriptor,
23
- } from '@prisma-next/sql-runtime';
24
- import { createRuntime, createRuntimeContext } from '@prisma-next/sql-runtime';
1
+ import type { RuntimeFamilyInstance } from '@prisma-next/framework-components/execution';
25
2
 
26
3
  /**
27
- * SQL runtime driver instance type.
28
- * Combines identity properties with SQL-specific behavior methods.
29
- */
30
- export type SqlRuntimeDriverInstance<TTargetId extends string = string> = RuntimeDriverInstance<
31
- 'sql',
32
- TTargetId
33
- > &
34
- SqlDriver;
35
-
36
- // Re-export SqlRuntimeAdapterInstance from sql-runtime for consumers
37
- export type { SqlRuntimeAdapterInstance } from '@prisma-next/sql-runtime';
38
-
39
- /**
40
- * SQL runtime family instance interface.
41
- * Extends base RuntimeFamilyInstance with SQL-specific runtime creation method.
4
+ * SQL execution-plane family instance interface.
5
+ *
6
+ * Note: this is currently named `SqlRuntimeFamilyInstance` because the execution plane
7
+ * framework types are still using the `Runtime*` naming (`RuntimeFamilyInstance`, etc.).
8
+ *
9
+ * This will be renamed to `SqlExecutionFamilyInstance` as part of `TML-1842`.
42
10
  */
43
- export interface SqlRuntimeFamilyInstance extends RuntimeFamilyInstance<'sql'> {
44
- /**
45
- * Creates a SQL runtime from contract, driver options, and verification settings.
46
- *
47
- * Extension packs are routed through composition (at instance creation time),
48
- * not through this method. This aligns with control-plane composition patterns.
49
- *
50
- * @param options - Runtime creation options
51
- * @param options.contract - SQL contract
52
- * @param options.driverOptions - Driver options (e.g., PostgresDriverOptions)
53
- * @param options.verify - Runtime verification options
54
- * @param options.plugins - Optional plugins
55
- * @param options.mode - Optional runtime mode
56
- * @param options.log - Optional log instance
57
- * @returns Runtime instance
58
- */
59
- createRuntime<TContract extends SqlContract<SqlStorage>>(options: {
60
- readonly contract: TContract;
61
- readonly driverOptions: unknown;
62
- readonly verify: RuntimeVerifyOptions;
63
- readonly plugins?: readonly Plugin<
64
- TContract,
65
- Adapter<SelectAst, SqlContract<SqlStorage>, LoweredStatement>,
66
- SqlDriver
67
- >[];
68
- readonly mode?: 'strict' | 'permissive';
69
- readonly log?: Log;
70
- }): Runtime;
71
- }
11
+ export interface SqlRuntimeFamilyInstance extends RuntimeFamilyInstance<'sql'> {}
72
12
 
73
13
  /**
74
- * Creates a SQL runtime family instance from runtime descriptors.
14
+ * Creates a SQL execution-plane family instance.
75
15
  *
76
- * Routes the same framework composition as control-plane:
77
- * family, target, adapter, driver, extensionPacks (all as descriptors with IDs).
16
+ * This will be renamed to `createSqlExecutionFamilyInstance()` as part of `TML-1842`.
78
17
  */
79
- export function createSqlRuntimeFamilyInstance<TTargetId extends string>(options: {
80
- readonly family: RuntimeFamilyDescriptor<'sql'>;
81
- readonly target: RuntimeTargetDescriptor<'sql', TTargetId>;
82
- readonly adapter: RuntimeAdapterDescriptor<
83
- 'sql',
84
- TTargetId,
85
- SqlRuntimeAdapterInstance<TTargetId>
86
- >;
87
- readonly driver: RuntimeDriverDescriptor<'sql', TTargetId, SqlRuntimeDriverInstance<TTargetId>>;
88
- readonly extensionPacks?: readonly SqlRuntimeExtensionDescriptor<TTargetId>[];
89
- }): SqlRuntimeFamilyInstance {
90
- const {
91
- family: familyDescriptor,
92
- target: targetDescriptor,
93
- adapter: adapterDescriptor,
94
- driver: driverDescriptor,
95
- extensionPacks: extensionDescriptors = [],
96
- } = options;
97
-
18
+ export function createSqlRuntimeFamilyInstance(): SqlRuntimeFamilyInstance {
98
19
  return {
99
20
  familyId: 'sql' as const,
100
- createRuntime<TContract extends SqlContract<SqlStorage>>(runtimeOptions: {
101
- readonly contract: TContract;
102
- readonly driverOptions: unknown;
103
- readonly verify: RuntimeVerifyOptions;
104
- readonly plugins?: readonly Plugin<
105
- TContract,
106
- Adapter<SelectAst, SqlContract<SqlStorage>, LoweredStatement>,
107
- SqlDriver
108
- >[];
109
- readonly mode?: 'strict' | 'permissive';
110
- readonly log?: Log;
111
- }): Runtime {
112
- // Validate contract requirements against provided descriptors
113
- assertRuntimeContractRequirementsSatisfied({
114
- contract: runtimeOptions.contract,
115
- family: familyDescriptor,
116
- target: targetDescriptor,
117
- adapter: adapterDescriptor,
118
- extensionPacks: extensionDescriptors,
119
- });
120
-
121
- // Create driver instance
122
- const driverInstance = driverDescriptor.create(runtimeOptions.driverOptions);
123
-
124
- // Create context via descriptor-first API
125
- const context = createRuntimeContext<TContract, TTargetId>({
126
- contract: runtimeOptions.contract,
127
- target: targetDescriptor,
128
- adapter: adapterDescriptor,
129
- extensionPacks: extensionDescriptors,
130
- });
131
-
132
- const runtimeOptions_: RuntimeOptions<TContract> = {
133
- driver: driverInstance,
134
- verify: runtimeOptions.verify,
135
- context,
136
- ...(runtimeOptions.plugins ? { plugins: runtimeOptions.plugins } : {}),
137
- ...(runtimeOptions.mode ? { mode: runtimeOptions.mode } : {}),
138
- ...(runtimeOptions.log ? { log: runtimeOptions.log } : {}),
139
- };
140
-
141
- return createRuntime(runtimeOptions_);
142
- },
143
21
  };
144
22
  }
@@ -3,7 +3,10 @@
3
3
  * These functions verify schema IR against contract requirements.
4
4
  */
5
5
 
6
- import type { SchemaIssue, SchemaVerificationNode } from '@prisma-next/core-control-plane/types';
6
+ import type {
7
+ SchemaIssue,
8
+ SchemaVerificationNode,
9
+ } from '@prisma-next/framework-components/control';
7
10
  import type {
8
11
  ForeignKey,
9
12
  Index,
@@ -33,9 +36,71 @@ export function arraysEqual(a: readonly string[], b: readonly string[]): boolean
33
36
  return true;
34
37
  }
35
38
 
39
+ // ============================================================================
40
+ // Semantic Satisfaction Predicates
41
+ // ============================================================================
42
+ // These predicates implement the "stronger satisfies weaker" logic for storage
43
+ // objects. They are used by both verification and migration planning to ensure
44
+ // consistent behavior across the control plane.
45
+
46
+ /**
47
+ * Checks if a unique constraint requirement is satisfied by the given columns.
48
+ *
49
+ * Semantic satisfaction: a unique constraint requirement can be satisfied by:
50
+ * - A unique constraint with the same columns, OR
51
+ * - A unique index with the same columns
52
+ *
53
+ * @param uniques - The unique constraints in the schema table
54
+ * @param indexes - The indexes in the schema table
55
+ * @param columns - The columns required by the unique constraint
56
+ * @returns true if the requirement is satisfied
57
+ */
58
+ export function isUniqueConstraintSatisfied(
59
+ uniques: readonly SqlUniqueIR[],
60
+ indexes: readonly SqlIndexIR[],
61
+ columns: readonly string[],
62
+ ): boolean {
63
+ // Check for matching unique constraint
64
+ const hasConstraint = uniques.some((unique) => arraysEqual(unique.columns, columns));
65
+ if (hasConstraint) {
66
+ return true;
67
+ }
68
+ // Check for matching unique index (semantic satisfaction)
69
+ return indexes.some((index) => index.unique && arraysEqual(index.columns, columns));
70
+ }
71
+
72
+ /**
73
+ * Checks if an index requirement is satisfied by the given columns.
74
+ *
75
+ * Semantic satisfaction: a non-unique index requirement can be satisfied by:
76
+ * - Any index (unique or non-unique) with the same columns, OR
77
+ * - A unique constraint with the same columns (stronger satisfies weaker)
78
+ *
79
+ * @param indexes - The indexes in the schema table
80
+ * @param uniques - The unique constraints in the schema table
81
+ * @param columns - The columns required by the index
82
+ * @returns true if the requirement is satisfied
83
+ */
84
+ export function isIndexSatisfied(
85
+ indexes: readonly SqlIndexIR[],
86
+ uniques: readonly SqlUniqueIR[],
87
+ columns: readonly string[],
88
+ ): boolean {
89
+ // Check for any matching index (unique or non-unique)
90
+ const hasMatchingIndex = indexes.some((index) => arraysEqual(index.columns, columns));
91
+ if (hasMatchingIndex) {
92
+ return true;
93
+ }
94
+ // Check for matching unique constraint (semantic satisfaction)
95
+ return uniques.some((unique) => arraysEqual(unique.columns, columns));
96
+ }
97
+
36
98
  /**
37
99
  * Verifies primary key matches between contract and schema.
38
100
  * Returns 'pass' or 'fail'.
101
+ *
102
+ * Uses semantic satisfaction: identity is based on (table + kind + columns).
103
+ * Name differences are ignored by default (names are for DDL/diagnostics, not identity).
39
104
  */
40
105
  export function verifyPrimaryKey(
41
106
  contractPK: PrimaryKey,
@@ -64,18 +129,8 @@ export function verifyPrimaryKey(
64
129
  return 'fail';
65
130
  }
66
131
 
67
- // Compare name if both are modeled
68
- if (contractPK.name && schemaPK.name && contractPK.name !== schemaPK.name) {
69
- issues.push({
70
- kind: 'primary_key_mismatch',
71
- table: tableName,
72
- indexOrConstraint: contractPK.name,
73
- expected: contractPK.name,
74
- actual: schemaPK.name,
75
- message: `Table "${tableName}" has primary key name mismatch: expected "${contractPK.name}", got "${schemaPK.name}"`,
76
- });
77
- return 'fail';
78
- }
132
+ // Name differences are ignored for semantic satisfaction.
133
+ // Names are persisted for deterministic DDL and diagnostics but are not identity.
79
134
 
80
135
  return 'pass';
81
136
  }
@@ -83,6 +138,9 @@ export function verifyPrimaryKey(
83
138
  /**
84
139
  * Verifies foreign keys match between contract and schema.
85
140
  * Returns verification nodes for the tree.
141
+ *
142
+ * Uses semantic satisfaction: identity is based on (table + columns + referenced table + referenced columns).
143
+ * Name differences are ignored by default (names are for DDL/diagnostics, not identity).
86
144
  */
87
145
  export function verifyForeignKeys(
88
146
  contractFKs: readonly ForeignKey[],
@@ -124,15 +182,20 @@ export function verifyForeignKeys(
124
182
  children: [],
125
183
  });
126
184
  } else {
127
- // Compare name if both are modeled
128
- if (contractFK.name && matchingFK.name && contractFK.name !== matchingFK.name) {
185
+ const actionMismatches = getReferentialActionMismatches(contractFK, matchingFK);
186
+ if (actionMismatches.length > 0) {
187
+ const combinedMessage = actionMismatches.map((m) => m.message).join('; ');
188
+ const combinedExpected = actionMismatches.map((m) => m.expected).join(', ');
189
+ const combinedActual = actionMismatches.map((m) => m.actual).join(', ');
129
190
  issues.push({
130
191
  kind: 'foreign_key_mismatch',
131
192
  table: tableName,
132
- indexOrConstraint: contractFK.name,
133
- expected: contractFK.name,
134
- actual: matchingFK.name,
135
- message: `Table "${tableName}" has foreign key name mismatch: expected "${contractFK.name}", got "${matchingFK.name}"`,
193
+ // Set indexOrConstraint so the planner classifies this as a non-additive
194
+ // conflict (existing FK with wrong actions cannot be fixed additively).
195
+ indexOrConstraint: matchingFK.name ?? `fk(${contractFK.columns.join(',')})`,
196
+ expected: combinedExpected,
197
+ actual: combinedActual,
198
+ message: `Table "${tableName}" foreign key ${contractFK.columns.join(', ')} -> ${contractFK.references.table}: ${combinedMessage}`,
136
199
  });
137
200
  nodes.push({
138
201
  status: 'fail',
@@ -140,9 +203,9 @@ export function verifyForeignKeys(
140
203
  name: `foreignKey(${contractFK.columns.join(', ')})`,
141
204
  contractPath: fkPath,
142
205
  code: 'foreign_key_mismatch',
143
- message: 'Foreign key name mismatch',
144
- expected: contractFK.name,
145
- actual: matchingFK.name,
206
+ message: combinedMessage,
207
+ expected: contractFK,
208
+ actual: matchingFK,
146
209
  children: [],
147
210
  });
148
211
  } else {
@@ -176,6 +239,7 @@ export function verifyForeignKeys(
176
239
  issues.push({
177
240
  kind: 'extra_foreign_key',
178
241
  table: tableName,
242
+ indexOrConstraint: schemaFK.name ?? `fk(${schemaFK.columns.join(',')})`,
179
243
  message: `Extra foreign key found in database (not in contract): ${schemaFK.columns.join(', ')} -> ${schemaFK.referencedTable}(${schemaFK.referencedColumns.join(', ')})`,
180
244
  });
181
245
  nodes.push({
@@ -199,10 +263,18 @@ export function verifyForeignKeys(
199
263
  /**
200
264
  * Verifies unique constraints match between contract and schema.
201
265
  * Returns verification nodes for the tree.
266
+ *
267
+ * Uses semantic satisfaction: identity is based on (table + kind + columns).
268
+ * A unique constraint requirement can be satisfied by either:
269
+ * - A unique constraint with the same columns, or
270
+ * - A unique index with the same columns
271
+ *
272
+ * Name differences are ignored by default (names are for DDL/diagnostics, not identity).
202
273
  */
203
274
  export function verifyUniqueConstraints(
204
275
  contractUniques: readonly UniqueConstraint[],
205
276
  schemaUniques: readonly SqlUniqueIR[],
277
+ schemaIndexes: readonly SqlIndexIR[],
206
278
  tableName: string,
207
279
  tablePath: string,
208
280
  issues: SchemaIssue[],
@@ -213,11 +285,18 @@ export function verifyUniqueConstraints(
213
285
  // Check each contract unique exists in schema
214
286
  for (const contractUnique of contractUniques) {
215
287
  const uniquePath = `${tablePath}.uniques[${contractUnique.columns.join(',')}]`;
288
+
289
+ // First check for a matching unique constraint
216
290
  const matchingUnique = schemaUniques.find((u) =>
217
291
  arraysEqual(u.columns, contractUnique.columns),
218
292
  );
219
293
 
220
- if (!matchingUnique) {
294
+ // If no matching constraint, check for a unique index with the same columns
295
+ const matchingUniqueIndex =
296
+ !matchingUnique &&
297
+ schemaIndexes.find((idx) => idx.unique && arraysEqual(idx.columns, contractUnique.columns));
298
+
299
+ if (!matchingUnique && !matchingUniqueIndex) {
221
300
  issues.push({
222
301
  kind: 'unique_constraint_mismatch',
223
302
  table: tableName,
@@ -236,44 +315,19 @@ export function verifyUniqueConstraints(
236
315
  children: [],
237
316
  });
238
317
  } else {
239
- // Compare name if both are modeled
240
- if (
241
- contractUnique.name &&
242
- matchingUnique.name &&
243
- contractUnique.name !== matchingUnique.name
244
- ) {
245
- issues.push({
246
- kind: 'unique_constraint_mismatch',
247
- table: tableName,
248
- indexOrConstraint: contractUnique.name,
249
- expected: contractUnique.name,
250
- actual: matchingUnique.name,
251
- message: `Table "${tableName}" has unique constraint name mismatch: expected "${contractUnique.name}", got "${matchingUnique.name}"`,
252
- });
253
- nodes.push({
254
- status: 'fail',
255
- kind: 'unique',
256
- name: `unique(${contractUnique.columns.join(', ')})`,
257
- contractPath: uniquePath,
258
- code: 'unique_constraint_mismatch',
259
- message: 'Unique constraint name mismatch',
260
- expected: contractUnique.name,
261
- actual: matchingUnique.name,
262
- children: [],
263
- });
264
- } else {
265
- nodes.push({
266
- status: 'pass',
267
- kind: 'unique',
268
- name: `unique(${contractUnique.columns.join(', ')})`,
269
- contractPath: uniquePath,
270
- code: '',
271
- message: '',
272
- expected: undefined,
273
- actual: undefined,
274
- children: [],
275
- });
276
- }
318
+ // Name differences are ignored for semantic satisfaction.
319
+ // Names are persisted for deterministic DDL and diagnostics but are not identity.
320
+ nodes.push({
321
+ status: 'pass',
322
+ kind: 'unique',
323
+ name: `unique(${contractUnique.columns.join(', ')})`,
324
+ contractPath: uniquePath,
325
+ code: '',
326
+ message: '',
327
+ expected: undefined,
328
+ actual: undefined,
329
+ children: [],
330
+ });
277
331
  }
278
332
  }
279
333
 
@@ -288,6 +342,7 @@ export function verifyUniqueConstraints(
288
342
  issues.push({
289
343
  kind: 'extra_unique_constraint',
290
344
  table: tableName,
345
+ indexOrConstraint: schemaUnique.name ?? `unique(${schemaUnique.columns.join(',')})`,
291
346
  message: `Extra unique constraint found in database (not in contract): ${schemaUnique.columns.join(', ')}`,
292
347
  });
293
348
  nodes.push({
@@ -311,10 +366,18 @@ export function verifyUniqueConstraints(
311
366
  /**
312
367
  * Verifies indexes match between contract and schema.
313
368
  * Returns verification nodes for the tree.
369
+ *
370
+ * Uses semantic satisfaction: identity is based on (table + kind + columns).
371
+ * A non-unique index requirement can be satisfied by either:
372
+ * - A non-unique index with the same columns, or
373
+ * - A unique index with the same columns (stronger satisfies weaker)
374
+ *
375
+ * Name differences are ignored by default (names are for DDL/diagnostics, not identity).
314
376
  */
315
377
  export function verifyIndexes(
316
378
  contractIndexes: readonly Index[],
317
379
  schemaIndexes: readonly SqlIndexIR[],
380
+ schemaUniques: readonly SqlUniqueIR[],
318
381
  tableName: string,
319
382
  tablePath: string,
320
383
  issues: SchemaIssue[],
@@ -325,11 +388,18 @@ export function verifyIndexes(
325
388
  // Check each contract index exists in schema
326
389
  for (const contractIndex of contractIndexes) {
327
390
  const indexPath = `${tablePath}.indexes[${contractIndex.columns.join(',')}]`;
328
- const matchingIndex = schemaIndexes.find(
329
- (idx) => arraysEqual(idx.columns, contractIndex.columns) && idx.unique === false,
391
+
392
+ // Check for any matching index (unique or non-unique)
393
+ // A unique index can satisfy a non-unique index requirement (stronger satisfies weaker)
394
+ const matchingIndex = schemaIndexes.find((idx) =>
395
+ arraysEqual(idx.columns, contractIndex.columns),
330
396
  );
331
397
 
332
- if (!matchingIndex) {
398
+ // Also check if a unique constraint satisfies the index requirement
399
+ const matchingUniqueConstraint =
400
+ !matchingIndex && schemaUniques.find((u) => arraysEqual(u.columns, contractIndex.columns));
401
+
402
+ if (!matchingIndex && !matchingUniqueConstraint) {
333
403
  issues.push({
334
404
  kind: 'index_mismatch',
335
405
  table: tableName,
@@ -348,40 +418,19 @@ export function verifyIndexes(
348
418
  children: [],
349
419
  });
350
420
  } else {
351
- // Compare name if both are modeled
352
- if (contractIndex.name && matchingIndex.name && contractIndex.name !== matchingIndex.name) {
353
- issues.push({
354
- kind: 'index_mismatch',
355
- table: tableName,
356
- indexOrConstraint: contractIndex.name,
357
- expected: contractIndex.name,
358
- actual: matchingIndex.name,
359
- message: `Table "${tableName}" has index name mismatch: expected "${contractIndex.name}", got "${matchingIndex.name}"`,
360
- });
361
- nodes.push({
362
- status: 'fail',
363
- kind: 'index',
364
- name: `index(${contractIndex.columns.join(', ')})`,
365
- contractPath: indexPath,
366
- code: 'index_mismatch',
367
- message: 'Index name mismatch',
368
- expected: contractIndex.name,
369
- actual: matchingIndex.name,
370
- children: [],
371
- });
372
- } else {
373
- nodes.push({
374
- status: 'pass',
375
- kind: 'index',
376
- name: `index(${contractIndex.columns.join(', ')})`,
377
- contractPath: indexPath,
378
- code: '',
379
- message: '',
380
- expected: undefined,
381
- actual: undefined,
382
- children: [],
383
- });
384
- }
421
+ // Name differences are ignored for semantic satisfaction.
422
+ // Names are persisted for deterministic DDL and diagnostics but are not identity.
423
+ nodes.push({
424
+ status: 'pass',
425
+ kind: 'index',
426
+ name: `index(${contractIndex.columns.join(', ')})`,
427
+ contractPath: indexPath,
428
+ code: '',
429
+ message: '',
430
+ expected: undefined,
431
+ actual: undefined,
432
+ children: [],
433
+ });
385
434
  }
386
435
  }
387
436
 
@@ -401,6 +450,7 @@ export function verifyIndexes(
401
450
  issues.push({
402
451
  kind: 'extra_index',
403
452
  table: tableName,
453
+ indexOrConstraint: schemaIndex.name ?? `idx(${schemaIndex.columns.join(',')})`,
404
454
  message: `Extra index found in database (not in contract): ${schemaIndex.columns.join(', ')}`,
405
455
  });
406
456
  nodes.push({
@@ -423,8 +473,8 @@ export function verifyIndexes(
423
473
 
424
474
  /**
425
475
  * Verifies database dependencies are installed using component-owned verification hooks.
426
- * Each dependency provides a pure verifyDatabaseDependencyInstalled function that checks
427
- * whether the dependency is satisfied based on the in-memory schema IR (no DB I/O).
476
+ * Checks whether each dependency is satisfied by verifying its id is present in
477
+ * schema.dependencies (populated from introspection).
428
478
  *
429
479
  * Returns verification nodes for the tree.
430
480
  */
@@ -434,16 +484,20 @@ export function verifyDatabaseDependencies(
434
484
  issues: SchemaIssue[],
435
485
  ): SchemaVerificationNode[] {
436
486
  const nodes: SchemaVerificationNode[] = [];
487
+ const installedIds = new Set(schema.dependencies.map((d) => d.id));
437
488
 
438
489
  for (const dependency of dependencies) {
439
- const depIssues = dependency.verifyDatabaseDependencyInstalled(schema);
490
+ const isSatisfied = installedIds.has(dependency.id);
440
491
  const depPath = `dependencies.${dependency.id}`;
441
492
 
442
- if (depIssues.length > 0) {
443
- // Dependency is not satisfied
444
- issues.push(...depIssues);
445
- const issuesMessage = depIssues.map((i) => i.message).join('; ');
446
- const nodeMessage = issuesMessage ? `${dependency.id}: ${issuesMessage}` : dependency.id;
493
+ if (!isSatisfied) {
494
+ const depIssue: SchemaIssue = {
495
+ kind: 'dependency_missing',
496
+ dependencyId: dependency.id,
497
+ message: `Dependency "${dependency.id}" is missing from database`,
498
+ };
499
+ issues.push(depIssue);
500
+ const nodeMessage = depIssue.message;
447
501
  nodes.push({
448
502
  status: 'fail',
449
503
  kind: 'databaseDependency',
@@ -512,3 +566,49 @@ export function computeCounts(node: SchemaVerificationNode): {
512
566
  totalNodes: pass + warn + fail,
513
567
  };
514
568
  }
569
+
570
+ /**
571
+ * Compares referential actions between a contract FK and a schema FK.
572
+ * Only compares when the contract FK explicitly specifies onDelete or onUpdate.
573
+ * Returns all mismatches (both onDelete and onUpdate) so both are reported at once.
574
+ *
575
+ * Note: 'noAction' in the contract is semantically equivalent to undefined in the
576
+ * schema IR, because the introspection adapter omits 'NO ACTION' (the database default)
577
+ * to keep the IR sparse. We normalize both sides before comparing.
578
+ */
579
+ function getReferentialActionMismatches(
580
+ contractFK: ForeignKey,
581
+ schemaFK: SqlForeignKeyIR,
582
+ ): ReadonlyArray<{ expected: string; actual: string; message: string }> {
583
+ const mismatches: Array<{ expected: string; actual: string; message: string }> = [];
584
+
585
+ const contractOnDelete = normalizeReferentialAction(contractFK.onDelete);
586
+ const schemaOnDelete = normalizeReferentialAction(schemaFK.onDelete);
587
+ if (contractOnDelete !== undefined && contractOnDelete !== schemaOnDelete) {
588
+ mismatches.push({
589
+ expected: `onDelete: ${contractFK.onDelete}`,
590
+ actual: `onDelete: ${schemaFK.onDelete ?? 'noAction (default)'}`,
591
+ message: `onDelete mismatch: expected ${contractFK.onDelete}, got ${schemaFK.onDelete ?? 'noAction (default)'}`,
592
+ });
593
+ }
594
+
595
+ const contractOnUpdate = normalizeReferentialAction(contractFK.onUpdate);
596
+ const schemaOnUpdate = normalizeReferentialAction(schemaFK.onUpdate);
597
+ if (contractOnUpdate !== undefined && contractOnUpdate !== schemaOnUpdate) {
598
+ mismatches.push({
599
+ expected: `onUpdate: ${contractFK.onUpdate}`,
600
+ actual: `onUpdate: ${schemaFK.onUpdate ?? 'noAction (default)'}`,
601
+ message: `onUpdate mismatch: expected ${contractFK.onUpdate}, got ${schemaFK.onUpdate ?? 'noAction (default)'}`,
602
+ });
603
+ }
604
+
605
+ return mismatches;
606
+ }
607
+
608
+ /**
609
+ * Normalizes a referential action value for comparison.
610
+ * 'noAction' is the database default and equivalent to undefined (omitted) in the sparse IR.
611
+ */
612
+ function normalizeReferentialAction(action: string | undefined): string | undefined {
613
+ return action === 'noAction' ? undefined : action;
614
+ }