@prisma-next/family-sql 0.3.0-dev.7 → 0.3.0-dev.71

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 (102) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +15 -6
  3. package/dist/assembly-BVS641kd.mjs +106 -0
  4. package/dist/assembly-BVS641kd.mjs.map +1 -0
  5. package/dist/control-adapter.d.mts +60 -0
  6. package/dist/control-adapter.d.mts.map +1 -0
  7. package/dist/control-adapter.mjs +1 -0
  8. package/dist/control-instance-62RsSxEp.d.mts +291 -0
  9. package/dist/control-instance-62RsSxEp.d.mts.map +1 -0
  10. package/dist/control.d.mts +106 -0
  11. package/dist/control.d.mts.map +1 -0
  12. package/dist/control.mjs +640 -0
  13. package/dist/control.mjs.map +1 -0
  14. package/dist/runtime.d.mts +27 -0
  15. package/dist/runtime.d.mts.map +1 -0
  16. package/dist/runtime.mjs +38 -0
  17. package/dist/runtime.mjs.map +1 -0
  18. package/dist/schema-verify.d.mts +48 -0
  19. package/dist/schema-verify.d.mts.map +1 -0
  20. package/dist/schema-verify.mjs +4 -0
  21. package/dist/test-utils.d.mts +2 -0
  22. package/dist/test-utils.mjs +3 -0
  23. package/dist/verify-BfMETJcM.mjs +108 -0
  24. package/dist/verify-BfMETJcM.mjs.map +1 -0
  25. package/dist/verify-sql-schema-CpAVEi8A.mjs +1058 -0
  26. package/dist/verify-sql-schema-CpAVEi8A.mjs.map +1 -0
  27. package/dist/verify-sql-schema-DhHnkpPa.d.mts +67 -0
  28. package/dist/verify-sql-schema-DhHnkpPa.d.mts.map +1 -0
  29. package/dist/verify.d.mts +31 -0
  30. package/dist/verify.d.mts.map +1 -0
  31. package/dist/verify.mjs +3 -0
  32. package/package.json +36 -47
  33. package/src/core/assembly.ts +158 -59
  34. package/src/core/control-adapter.ts +15 -0
  35. package/src/core/control-descriptor.ts +37 -0
  36. package/src/core/{instance.ts → control-instance.ts} +108 -241
  37. package/src/core/migrations/contract-to-schema-ir.ts +181 -0
  38. package/src/core/migrations/types.ts +63 -165
  39. package/src/core/runtime-descriptor.ts +19 -41
  40. package/src/core/runtime-instance.ts +11 -133
  41. package/src/core/schema-verify/verify-helpers.ts +187 -97
  42. package/src/core/schema-verify/verify-sql-schema.ts +910 -392
  43. package/src/core/verify.ts +4 -13
  44. package/src/exports/control.ts +15 -6
  45. package/src/exports/runtime.ts +2 -6
  46. package/src/exports/schema-verify.ts +10 -2
  47. package/src/exports/test-utils.ts +0 -1
  48. package/dist/chunk-6K3RPBDP.js +0 -580
  49. package/dist/chunk-6K3RPBDP.js.map +0 -1
  50. package/dist/chunk-BHEGVBY7.js +0 -772
  51. package/dist/chunk-BHEGVBY7.js.map +0 -1
  52. package/dist/chunk-SU7LN2UH.js +0 -96
  53. package/dist/chunk-SU7LN2UH.js.map +0 -1
  54. package/dist/core/assembly.d.ts +0 -25
  55. package/dist/core/assembly.d.ts.map +0 -1
  56. package/dist/core/control-adapter.d.ts +0 -42
  57. package/dist/core/control-adapter.d.ts.map +0 -1
  58. package/dist/core/descriptor.d.ts +0 -31
  59. package/dist/core/descriptor.d.ts.map +0 -1
  60. package/dist/core/instance.d.ts +0 -142
  61. package/dist/core/instance.d.ts.map +0 -1
  62. package/dist/core/migrations/plan-helpers.d.ts +0 -20
  63. package/dist/core/migrations/plan-helpers.d.ts.map +0 -1
  64. package/dist/core/migrations/policies.d.ts +0 -6
  65. package/dist/core/migrations/policies.d.ts.map +0 -1
  66. package/dist/core/migrations/types.d.ts +0 -280
  67. package/dist/core/migrations/types.d.ts.map +0 -1
  68. package/dist/core/runtime-descriptor.d.ts +0 -19
  69. package/dist/core/runtime-descriptor.d.ts.map +0 -1
  70. package/dist/core/runtime-instance.d.ts +0 -54
  71. package/dist/core/runtime-instance.d.ts.map +0 -1
  72. package/dist/core/schema-verify/verify-helpers.d.ts +0 -50
  73. package/dist/core/schema-verify/verify-helpers.d.ts.map +0 -1
  74. package/dist/core/schema-verify/verify-sql-schema.d.ts +0 -45
  75. package/dist/core/schema-verify/verify-sql-schema.d.ts.map +0 -1
  76. package/dist/core/verify.d.ts +0 -39
  77. package/dist/core/verify.d.ts.map +0 -1
  78. package/dist/exports/control-adapter.d.ts +0 -2
  79. package/dist/exports/control-adapter.d.ts.map +0 -1
  80. package/dist/exports/control-adapter.js +0 -1
  81. package/dist/exports/control-adapter.js.map +0 -1
  82. package/dist/exports/control.d.ts +0 -13
  83. package/dist/exports/control.d.ts.map +0 -1
  84. package/dist/exports/control.js +0 -149
  85. package/dist/exports/control.js.map +0 -1
  86. package/dist/exports/runtime.d.ts +0 -8
  87. package/dist/exports/runtime.d.ts.map +0 -1
  88. package/dist/exports/runtime.js +0 -64
  89. package/dist/exports/runtime.js.map +0 -1
  90. package/dist/exports/schema-verify.d.ts +0 -11
  91. package/dist/exports/schema-verify.d.ts.map +0 -1
  92. package/dist/exports/schema-verify.js +0 -11
  93. package/dist/exports/schema-verify.js.map +0 -1
  94. package/dist/exports/test-utils.d.ts +0 -7
  95. package/dist/exports/test-utils.d.ts.map +0 -1
  96. package/dist/exports/test-utils.js +0 -17
  97. package/dist/exports/test-utils.js.map +0 -1
  98. package/dist/exports/verify.d.ts +0 -2
  99. package/dist/exports/verify.d.ts.map +0 -1
  100. package/dist/exports/verify.js +0 -11
  101. package/dist/exports/verify.js.map +0 -1
  102. package/src/core/descriptor.ts +0 -37
@@ -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/core-execution-plane/types';
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
  }
@@ -33,9 +33,71 @@ export function arraysEqual(a: readonly string[], b: readonly string[]): boolean
33
33
  return true;
34
34
  }
35
35
 
36
+ // ============================================================================
37
+ // Semantic Satisfaction Predicates
38
+ // ============================================================================
39
+ // These predicates implement the "stronger satisfies weaker" logic for storage
40
+ // objects. They are used by both verification and migration planning to ensure
41
+ // consistent behavior across the control plane.
42
+
43
+ /**
44
+ * Checks if a unique constraint requirement is satisfied by the given columns.
45
+ *
46
+ * Semantic satisfaction: a unique constraint requirement can be satisfied by:
47
+ * - A unique constraint with the same columns, OR
48
+ * - A unique index with the same columns
49
+ *
50
+ * @param uniques - The unique constraints in the schema table
51
+ * @param indexes - The indexes in the schema table
52
+ * @param columns - The columns required by the unique constraint
53
+ * @returns true if the requirement is satisfied
54
+ */
55
+ export function isUniqueConstraintSatisfied(
56
+ uniques: readonly SqlUniqueIR[],
57
+ indexes: readonly SqlIndexIR[],
58
+ columns: readonly string[],
59
+ ): boolean {
60
+ // Check for matching unique constraint
61
+ const hasConstraint = uniques.some((unique) => arraysEqual(unique.columns, columns));
62
+ if (hasConstraint) {
63
+ return true;
64
+ }
65
+ // Check for matching unique index (semantic satisfaction)
66
+ return indexes.some((index) => index.unique && arraysEqual(index.columns, columns));
67
+ }
68
+
69
+ /**
70
+ * Checks if an index requirement is satisfied by the given columns.
71
+ *
72
+ * Semantic satisfaction: a non-unique index requirement can be satisfied by:
73
+ * - Any index (unique or non-unique) with the same columns, OR
74
+ * - A unique constraint with the same columns (stronger satisfies weaker)
75
+ *
76
+ * @param indexes - The indexes in the schema table
77
+ * @param uniques - The unique constraints in the schema table
78
+ * @param columns - The columns required by the index
79
+ * @returns true if the requirement is satisfied
80
+ */
81
+ export function isIndexSatisfied(
82
+ indexes: readonly SqlIndexIR[],
83
+ uniques: readonly SqlUniqueIR[],
84
+ columns: readonly string[],
85
+ ): boolean {
86
+ // Check for any matching index (unique or non-unique)
87
+ const hasMatchingIndex = indexes.some((index) => arraysEqual(index.columns, columns));
88
+ if (hasMatchingIndex) {
89
+ return true;
90
+ }
91
+ // Check for matching unique constraint (semantic satisfaction)
92
+ return uniques.some((unique) => arraysEqual(unique.columns, columns));
93
+ }
94
+
36
95
  /**
37
96
  * Verifies primary key matches between contract and schema.
38
97
  * Returns 'pass' or 'fail'.
98
+ *
99
+ * Uses semantic satisfaction: identity is based on (table + kind + columns).
100
+ * Name differences are ignored by default (names are for DDL/diagnostics, not identity).
39
101
  */
40
102
  export function verifyPrimaryKey(
41
103
  contractPK: PrimaryKey,
@@ -64,18 +126,8 @@ export function verifyPrimaryKey(
64
126
  return 'fail';
65
127
  }
66
128
 
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
- }
129
+ // Name differences are ignored for semantic satisfaction.
130
+ // Names are persisted for deterministic DDL and diagnostics but are not identity.
79
131
 
80
132
  return 'pass';
81
133
  }
@@ -83,6 +135,9 @@ export function verifyPrimaryKey(
83
135
  /**
84
136
  * Verifies foreign keys match between contract and schema.
85
137
  * Returns verification nodes for the tree.
138
+ *
139
+ * Uses semantic satisfaction: identity is based on (table + columns + referenced table + referenced columns).
140
+ * Name differences are ignored by default (names are for DDL/diagnostics, not identity).
86
141
  */
87
142
  export function verifyForeignKeys(
88
143
  contractFKs: readonly ForeignKey[],
@@ -124,15 +179,20 @@ export function verifyForeignKeys(
124
179
  children: [],
125
180
  });
126
181
  } else {
127
- // Compare name if both are modeled
128
- if (contractFK.name && matchingFK.name && contractFK.name !== matchingFK.name) {
182
+ const actionMismatches = getReferentialActionMismatches(contractFK, matchingFK);
183
+ if (actionMismatches.length > 0) {
184
+ const combinedMessage = actionMismatches.map((m) => m.message).join('; ');
185
+ const combinedExpected = actionMismatches.map((m) => m.expected).join(', ');
186
+ const combinedActual = actionMismatches.map((m) => m.actual).join(', ');
129
187
  issues.push({
130
188
  kind: 'foreign_key_mismatch',
131
189
  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}"`,
190
+ // Set indexOrConstraint so the planner classifies this as a non-additive
191
+ // conflict (existing FK with wrong actions cannot be fixed additively).
192
+ indexOrConstraint: matchingFK.name ?? `fk(${contractFK.columns.join(',')})`,
193
+ expected: combinedExpected,
194
+ actual: combinedActual,
195
+ message: `Table "${tableName}" foreign key ${contractFK.columns.join(', ')} -> ${contractFK.references.table}: ${combinedMessage}`,
136
196
  });
137
197
  nodes.push({
138
198
  status: 'fail',
@@ -140,9 +200,9 @@ export function verifyForeignKeys(
140
200
  name: `foreignKey(${contractFK.columns.join(', ')})`,
141
201
  contractPath: fkPath,
142
202
  code: 'foreign_key_mismatch',
143
- message: 'Foreign key name mismatch',
144
- expected: contractFK.name,
145
- actual: matchingFK.name,
203
+ message: combinedMessage,
204
+ expected: contractFK,
205
+ actual: matchingFK,
146
206
  children: [],
147
207
  });
148
208
  } else {
@@ -199,10 +259,18 @@ export function verifyForeignKeys(
199
259
  /**
200
260
  * Verifies unique constraints match between contract and schema.
201
261
  * Returns verification nodes for the tree.
262
+ *
263
+ * Uses semantic satisfaction: identity is based on (table + kind + columns).
264
+ * A unique constraint requirement can be satisfied by either:
265
+ * - A unique constraint with the same columns, or
266
+ * - A unique index with the same columns
267
+ *
268
+ * Name differences are ignored by default (names are for DDL/diagnostics, not identity).
202
269
  */
203
270
  export function verifyUniqueConstraints(
204
271
  contractUniques: readonly UniqueConstraint[],
205
272
  schemaUniques: readonly SqlUniqueIR[],
273
+ schemaIndexes: readonly SqlIndexIR[],
206
274
  tableName: string,
207
275
  tablePath: string,
208
276
  issues: SchemaIssue[],
@@ -213,11 +281,18 @@ export function verifyUniqueConstraints(
213
281
  // Check each contract unique exists in schema
214
282
  for (const contractUnique of contractUniques) {
215
283
  const uniquePath = `${tablePath}.uniques[${contractUnique.columns.join(',')}]`;
284
+
285
+ // First check for a matching unique constraint
216
286
  const matchingUnique = schemaUniques.find((u) =>
217
287
  arraysEqual(u.columns, contractUnique.columns),
218
288
  );
219
289
 
220
- if (!matchingUnique) {
290
+ // If no matching constraint, check for a unique index with the same columns
291
+ const matchingUniqueIndex =
292
+ !matchingUnique &&
293
+ schemaIndexes.find((idx) => idx.unique && arraysEqual(idx.columns, contractUnique.columns));
294
+
295
+ if (!matchingUnique && !matchingUniqueIndex) {
221
296
  issues.push({
222
297
  kind: 'unique_constraint_mismatch',
223
298
  table: tableName,
@@ -236,44 +311,19 @@ export function verifyUniqueConstraints(
236
311
  children: [],
237
312
  });
238
313
  } 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
- }
314
+ // Name differences are ignored for semantic satisfaction.
315
+ // Names are persisted for deterministic DDL and diagnostics but are not identity.
316
+ nodes.push({
317
+ status: 'pass',
318
+ kind: 'unique',
319
+ name: `unique(${contractUnique.columns.join(', ')})`,
320
+ contractPath: uniquePath,
321
+ code: '',
322
+ message: '',
323
+ expected: undefined,
324
+ actual: undefined,
325
+ children: [],
326
+ });
277
327
  }
278
328
  }
279
329
 
@@ -311,10 +361,18 @@ export function verifyUniqueConstraints(
311
361
  /**
312
362
  * Verifies indexes match between contract and schema.
313
363
  * Returns verification nodes for the tree.
364
+ *
365
+ * Uses semantic satisfaction: identity is based on (table + kind + columns).
366
+ * A non-unique index requirement can be satisfied by either:
367
+ * - A non-unique index with the same columns, or
368
+ * - A unique index with the same columns (stronger satisfies weaker)
369
+ *
370
+ * Name differences are ignored by default (names are for DDL/diagnostics, not identity).
314
371
  */
315
372
  export function verifyIndexes(
316
373
  contractIndexes: readonly Index[],
317
374
  schemaIndexes: readonly SqlIndexIR[],
375
+ schemaUniques: readonly SqlUniqueIR[],
318
376
  tableName: string,
319
377
  tablePath: string,
320
378
  issues: SchemaIssue[],
@@ -325,11 +383,18 @@ export function verifyIndexes(
325
383
  // Check each contract index exists in schema
326
384
  for (const contractIndex of contractIndexes) {
327
385
  const indexPath = `${tablePath}.indexes[${contractIndex.columns.join(',')}]`;
328
- const matchingIndex = schemaIndexes.find(
329
- (idx) => arraysEqual(idx.columns, contractIndex.columns) && idx.unique === false,
386
+
387
+ // Check for any matching index (unique or non-unique)
388
+ // A unique index can satisfy a non-unique index requirement (stronger satisfies weaker)
389
+ const matchingIndex = schemaIndexes.find((idx) =>
390
+ arraysEqual(idx.columns, contractIndex.columns),
330
391
  );
331
392
 
332
- if (!matchingIndex) {
393
+ // Also check if a unique constraint satisfies the index requirement
394
+ const matchingUniqueConstraint =
395
+ !matchingIndex && schemaUniques.find((u) => arraysEqual(u.columns, contractIndex.columns));
396
+
397
+ if (!matchingIndex && !matchingUniqueConstraint) {
333
398
  issues.push({
334
399
  kind: 'index_mismatch',
335
400
  table: tableName,
@@ -348,40 +413,19 @@ export function verifyIndexes(
348
413
  children: [],
349
414
  });
350
415
  } 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
- }
416
+ // Name differences are ignored for semantic satisfaction.
417
+ // Names are persisted for deterministic DDL and diagnostics but are not identity.
418
+ nodes.push({
419
+ status: 'pass',
420
+ kind: 'index',
421
+ name: `index(${contractIndex.columns.join(', ')})`,
422
+ contractPath: indexPath,
423
+ code: '',
424
+ message: '',
425
+ expected: undefined,
426
+ actual: undefined,
427
+ children: [],
428
+ });
385
429
  }
386
430
  }
387
431
 
@@ -512,3 +556,49 @@ export function computeCounts(node: SchemaVerificationNode): {
512
556
  totalNodes: pass + warn + fail,
513
557
  };
514
558
  }
559
+
560
+ /**
561
+ * Compares referential actions between a contract FK and a schema FK.
562
+ * Only compares when the contract FK explicitly specifies onDelete or onUpdate.
563
+ * Returns all mismatches (both onDelete and onUpdate) so both are reported at once.
564
+ *
565
+ * Note: 'noAction' in the contract is semantically equivalent to undefined in the
566
+ * schema IR, because the introspection adapter omits 'NO ACTION' (the database default)
567
+ * to keep the IR sparse. We normalize both sides before comparing.
568
+ */
569
+ function getReferentialActionMismatches(
570
+ contractFK: ForeignKey,
571
+ schemaFK: SqlForeignKeyIR,
572
+ ): ReadonlyArray<{ expected: string; actual: string; message: string }> {
573
+ const mismatches: Array<{ expected: string; actual: string; message: string }> = [];
574
+
575
+ const contractOnDelete = normalizeReferentialAction(contractFK.onDelete);
576
+ const schemaOnDelete = normalizeReferentialAction(schemaFK.onDelete);
577
+ if (contractOnDelete !== undefined && contractOnDelete !== schemaOnDelete) {
578
+ mismatches.push({
579
+ expected: `onDelete: ${contractFK.onDelete}`,
580
+ actual: `onDelete: ${schemaFK.onDelete ?? 'noAction (default)'}`,
581
+ message: `onDelete mismatch: expected ${contractFK.onDelete}, got ${schemaFK.onDelete ?? 'noAction (default)'}`,
582
+ });
583
+ }
584
+
585
+ const contractOnUpdate = normalizeReferentialAction(contractFK.onUpdate);
586
+ const schemaOnUpdate = normalizeReferentialAction(schemaFK.onUpdate);
587
+ if (contractOnUpdate !== undefined && contractOnUpdate !== schemaOnUpdate) {
588
+ mismatches.push({
589
+ expected: `onUpdate: ${contractFK.onUpdate}`,
590
+ actual: `onUpdate: ${schemaFK.onUpdate ?? 'noAction (default)'}`,
591
+ message: `onUpdate mismatch: expected ${contractFK.onUpdate}, got ${schemaFK.onUpdate ?? 'noAction (default)'}`,
592
+ });
593
+ }
594
+
595
+ return mismatches;
596
+ }
597
+
598
+ /**
599
+ * Normalizes a referential action value for comparison.
600
+ * 'noAction' is the database default and equivalent to undefined (omitted) in the sparse IR.
601
+ */
602
+ function normalizeReferentialAction(action: string | undefined): string | undefined {
603
+ return action === 'noAction' ? undefined : action;
604
+ }