@prisma-next/family-sql 0.3.0-dev.12 → 0.3.0-dev.123

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +34 -7
  3. package/dist/assembly-Dzumaba1.mjs +159 -0
  4. package/dist/assembly-Dzumaba1.mjs.map +1 -0
  5. package/dist/control-adapter.d.mts +60 -0
  6. package/dist/control-adapter.d.mts.map +1 -0
  7. package/dist/control-adapter.mjs +1 -0
  8. package/dist/control-instance-BKuHINR7.d.mts +411 -0
  9. package/dist/control-instance-BKuHINR7.d.mts.map +1 -0
  10. package/dist/control.d.mts +128 -0
  11. package/dist/control.d.mts.map +1 -0
  12. package/dist/control.mjs +683 -0
  13. package/dist/control.mjs.map +1 -0
  14. package/dist/runtime.d.mts +27 -0
  15. package/dist/runtime.d.mts.map +1 -0
  16. package/dist/runtime.mjs +38 -0
  17. package/dist/runtime.mjs.map +1 -0
  18. package/dist/schema-verify.d.mts +48 -0
  19. package/dist/schema-verify.d.mts.map +1 -0
  20. package/dist/schema-verify.mjs +4 -0
  21. package/dist/test-utils.d.mts +2 -0
  22. package/dist/test-utils.mjs +3 -0
  23. package/dist/verify-BfMETJcM.mjs +108 -0
  24. package/dist/verify-BfMETJcM.mjs.map +1 -0
  25. package/dist/verify-sql-schema-C3Pit9o4.mjs +1085 -0
  26. package/dist/verify-sql-schema-C3Pit9o4.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 +35 -46
  33. package/src/core/assembly.ts +265 -59
  34. package/src/core/control-adapter.ts +15 -0
  35. package/src/core/{descriptor.ts → control-descriptor.ts} +15 -11
  36. package/src/core/{instance.ts → control-instance.ts} +106 -248
  37. package/src/core/migrations/contract-to-schema-ir.ts +265 -0
  38. package/src/core/migrations/types.ts +193 -168
  39. package/src/core/runtime-descriptor.ts +19 -41
  40. package/src/core/runtime-instance.ts +11 -133
  41. package/src/core/schema-verify/verify-helpers.ts +201 -105
  42. package/src/core/schema-verify/verify-sql-schema.ts +918 -413
  43. package/src/core/verify.ts +4 -13
  44. package/src/exports/control.ts +29 -6
  45. package/src/exports/runtime.ts +2 -6
  46. package/src/exports/schema-verify.ts +10 -2
  47. package/src/exports/test-utils.ts +1 -1
  48. package/dist/chunk-BHEGVBY7.js +0 -772
  49. package/dist/chunk-BHEGVBY7.js.map +0 -1
  50. package/dist/chunk-SQ2VWYDV.js +0 -589
  51. package/dist/chunk-SQ2VWYDV.js.map +0 -1
  52. package/dist/chunk-SU7LN2UH.js +0 -96
  53. package/dist/chunk-SU7LN2UH.js.map +0 -1
  54. package/dist/core/assembly.d.ts +0 -25
  55. package/dist/core/assembly.d.ts.map +0 -1
  56. package/dist/core/control-adapter.d.ts +0 -42
  57. package/dist/core/control-adapter.d.ts.map +0 -1
  58. package/dist/core/descriptor.d.ts +0 -24
  59. package/dist/core/descriptor.d.ts.map +0 -1
  60. package/dist/core/instance.d.ts +0 -140
  61. package/dist/core/instance.d.ts.map +0 -1
  62. package/dist/core/migrations/plan-helpers.d.ts +0 -20
  63. package/dist/core/migrations/plan-helpers.d.ts.map +0 -1
  64. package/dist/core/migrations/policies.d.ts +0 -6
  65. package/dist/core/migrations/policies.d.ts.map +0 -1
  66. package/dist/core/migrations/types.d.ts +0 -280
  67. package/dist/core/migrations/types.d.ts.map +0 -1
  68. package/dist/core/runtime-descriptor.d.ts +0 -19
  69. package/dist/core/runtime-descriptor.d.ts.map +0 -1
  70. package/dist/core/runtime-instance.d.ts +0 -54
  71. package/dist/core/runtime-instance.d.ts.map +0 -1
  72. package/dist/core/schema-verify/verify-helpers.d.ts +0 -50
  73. package/dist/core/schema-verify/verify-helpers.d.ts.map +0 -1
  74. package/dist/core/schema-verify/verify-sql-schema.d.ts +0 -45
  75. package/dist/core/schema-verify/verify-sql-schema.d.ts.map +0 -1
  76. package/dist/core/verify.d.ts +0 -39
  77. package/dist/core/verify.d.ts.map +0 -1
  78. package/dist/exports/control-adapter.d.ts +0 -2
  79. package/dist/exports/control-adapter.d.ts.map +0 -1
  80. package/dist/exports/control-adapter.js +0 -1
  81. package/dist/exports/control-adapter.js.map +0 -1
  82. package/dist/exports/control.d.ts +0 -13
  83. package/dist/exports/control.d.ts.map +0 -1
  84. package/dist/exports/control.js +0 -149
  85. package/dist/exports/control.js.map +0 -1
  86. package/dist/exports/runtime.d.ts +0 -8
  87. package/dist/exports/runtime.d.ts.map +0 -1
  88. package/dist/exports/runtime.js +0 -64
  89. package/dist/exports/runtime.js.map +0 -1
  90. package/dist/exports/schema-verify.d.ts +0 -11
  91. package/dist/exports/schema-verify.d.ts.map +0 -1
  92. package/dist/exports/schema-verify.js +0 -11
  93. package/dist/exports/schema-verify.js.map +0 -1
  94. package/dist/exports/test-utils.d.ts +0 -7
  95. package/dist/exports/test-utils.d.ts.map +0 -1
  96. package/dist/exports/test-utils.js +0 -17
  97. package/dist/exports/test-utils.js.map +0 -1
  98. package/dist/exports/verify.d.ts +0 -2
  99. package/dist/exports/verify.d.ts.map +0 -1
  100. package/dist/exports/verify.js +0 -11
  101. package/dist/exports/verify.js.map +0 -1
@@ -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 {
@@ -176,6 +236,7 @@ export function verifyForeignKeys(
176
236
  issues.push({
177
237
  kind: 'extra_foreign_key',
178
238
  table: tableName,
239
+ indexOrConstraint: schemaFK.name ?? `fk(${schemaFK.columns.join(',')})`,
179
240
  message: `Extra foreign key found in database (not in contract): ${schemaFK.columns.join(', ')} -> ${schemaFK.referencedTable}(${schemaFK.referencedColumns.join(', ')})`,
180
241
  });
181
242
  nodes.push({
@@ -199,10 +260,18 @@ export function verifyForeignKeys(
199
260
  /**
200
261
  * Verifies unique constraints match between contract and schema.
201
262
  * Returns verification nodes for the tree.
263
+ *
264
+ * Uses semantic satisfaction: identity is based on (table + kind + columns).
265
+ * A unique constraint requirement can be satisfied by either:
266
+ * - A unique constraint with the same columns, or
267
+ * - A unique index with the same columns
268
+ *
269
+ * Name differences are ignored by default (names are for DDL/diagnostics, not identity).
202
270
  */
203
271
  export function verifyUniqueConstraints(
204
272
  contractUniques: readonly UniqueConstraint[],
205
273
  schemaUniques: readonly SqlUniqueIR[],
274
+ schemaIndexes: readonly SqlIndexIR[],
206
275
  tableName: string,
207
276
  tablePath: string,
208
277
  issues: SchemaIssue[],
@@ -213,11 +282,18 @@ export function verifyUniqueConstraints(
213
282
  // Check each contract unique exists in schema
214
283
  for (const contractUnique of contractUniques) {
215
284
  const uniquePath = `${tablePath}.uniques[${contractUnique.columns.join(',')}]`;
285
+
286
+ // First check for a matching unique constraint
216
287
  const matchingUnique = schemaUniques.find((u) =>
217
288
  arraysEqual(u.columns, contractUnique.columns),
218
289
  );
219
290
 
220
- if (!matchingUnique) {
291
+ // If no matching constraint, check for a unique index with the same columns
292
+ const matchingUniqueIndex =
293
+ !matchingUnique &&
294
+ schemaIndexes.find((idx) => idx.unique && arraysEqual(idx.columns, contractUnique.columns));
295
+
296
+ if (!matchingUnique && !matchingUniqueIndex) {
221
297
  issues.push({
222
298
  kind: 'unique_constraint_mismatch',
223
299
  table: tableName,
@@ -236,44 +312,19 @@ export function verifyUniqueConstraints(
236
312
  children: [],
237
313
  });
238
314
  } 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
- }
315
+ // Name differences are ignored for semantic satisfaction.
316
+ // Names are persisted for deterministic DDL and diagnostics but are not identity.
317
+ nodes.push({
318
+ status: 'pass',
319
+ kind: 'unique',
320
+ name: `unique(${contractUnique.columns.join(', ')})`,
321
+ contractPath: uniquePath,
322
+ code: '',
323
+ message: '',
324
+ expected: undefined,
325
+ actual: undefined,
326
+ children: [],
327
+ });
277
328
  }
278
329
  }
279
330
 
@@ -288,6 +339,7 @@ export function verifyUniqueConstraints(
288
339
  issues.push({
289
340
  kind: 'extra_unique_constraint',
290
341
  table: tableName,
342
+ indexOrConstraint: schemaUnique.name ?? `unique(${schemaUnique.columns.join(',')})`,
291
343
  message: `Extra unique constraint found in database (not in contract): ${schemaUnique.columns.join(', ')}`,
292
344
  });
293
345
  nodes.push({
@@ -311,10 +363,18 @@ export function verifyUniqueConstraints(
311
363
  /**
312
364
  * Verifies indexes match between contract and schema.
313
365
  * Returns verification nodes for the tree.
366
+ *
367
+ * Uses semantic satisfaction: identity is based on (table + kind + columns).
368
+ * A non-unique index requirement can be satisfied by either:
369
+ * - A non-unique index with the same columns, or
370
+ * - A unique index with the same columns (stronger satisfies weaker)
371
+ *
372
+ * Name differences are ignored by default (names are for DDL/diagnostics, not identity).
314
373
  */
315
374
  export function verifyIndexes(
316
375
  contractIndexes: readonly Index[],
317
376
  schemaIndexes: readonly SqlIndexIR[],
377
+ schemaUniques: readonly SqlUniqueIR[],
318
378
  tableName: string,
319
379
  tablePath: string,
320
380
  issues: SchemaIssue[],
@@ -325,11 +385,18 @@ export function verifyIndexes(
325
385
  // Check each contract index exists in schema
326
386
  for (const contractIndex of contractIndexes) {
327
387
  const indexPath = `${tablePath}.indexes[${contractIndex.columns.join(',')}]`;
328
- const matchingIndex = schemaIndexes.find(
329
- (idx) => arraysEqual(idx.columns, contractIndex.columns) && idx.unique === false,
388
+
389
+ // Check for any matching index (unique or non-unique)
390
+ // A unique index can satisfy a non-unique index requirement (stronger satisfies weaker)
391
+ const matchingIndex = schemaIndexes.find((idx) =>
392
+ arraysEqual(idx.columns, contractIndex.columns),
330
393
  );
331
394
 
332
- if (!matchingIndex) {
395
+ // Also check if a unique constraint satisfies the index requirement
396
+ const matchingUniqueConstraint =
397
+ !matchingIndex && schemaUniques.find((u) => arraysEqual(u.columns, contractIndex.columns));
398
+
399
+ if (!matchingIndex && !matchingUniqueConstraint) {
333
400
  issues.push({
334
401
  kind: 'index_mismatch',
335
402
  table: tableName,
@@ -348,40 +415,19 @@ export function verifyIndexes(
348
415
  children: [],
349
416
  });
350
417
  } 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
- }
418
+ // Name differences are ignored for semantic satisfaction.
419
+ // Names are persisted for deterministic DDL and diagnostics but are not identity.
420
+ nodes.push({
421
+ status: 'pass',
422
+ kind: 'index',
423
+ name: `index(${contractIndex.columns.join(', ')})`,
424
+ contractPath: indexPath,
425
+ code: '',
426
+ message: '',
427
+ expected: undefined,
428
+ actual: undefined,
429
+ children: [],
430
+ });
385
431
  }
386
432
  }
387
433
 
@@ -401,6 +447,7 @@ export function verifyIndexes(
401
447
  issues.push({
402
448
  kind: 'extra_index',
403
449
  table: tableName,
450
+ indexOrConstraint: schemaIndex.name ?? `idx(${schemaIndex.columns.join(',')})`,
404
451
  message: `Extra index found in database (not in contract): ${schemaIndex.columns.join(', ')}`,
405
452
  });
406
453
  nodes.push({
@@ -423,8 +470,8 @@ export function verifyIndexes(
423
470
 
424
471
  /**
425
472
  * 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).
473
+ * Checks whether each dependency is satisfied by verifying its id is present in
474
+ * schema.dependencies (populated from introspection).
428
475
  *
429
476
  * Returns verification nodes for the tree.
430
477
  */
@@ -434,16 +481,19 @@ export function verifyDatabaseDependencies(
434
481
  issues: SchemaIssue[],
435
482
  ): SchemaVerificationNode[] {
436
483
  const nodes: SchemaVerificationNode[] = [];
484
+ const installedIds = new Set(schema.dependencies.map((d) => d.id));
437
485
 
438
486
  for (const dependency of dependencies) {
439
- const depIssues = dependency.verifyDatabaseDependencyInstalled(schema);
487
+ const isSatisfied = installedIds.has(dependency.id);
440
488
  const depPath = `dependencies.${dependency.id}`;
441
489
 
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;
490
+ if (!isSatisfied) {
491
+ const depIssue: SchemaIssue = {
492
+ kind: 'dependency_missing',
493
+ message: `Dependency "${dependency.id}" is missing from database`,
494
+ };
495
+ issues.push(depIssue);
496
+ const nodeMessage = depIssue.message;
447
497
  nodes.push({
448
498
  status: 'fail',
449
499
  kind: 'databaseDependency',
@@ -512,3 +562,49 @@ export function computeCounts(node: SchemaVerificationNode): {
512
562
  totalNodes: pass + warn + fail,
513
563
  };
514
564
  }
565
+
566
+ /**
567
+ * Compares referential actions between a contract FK and a schema FK.
568
+ * Only compares when the contract FK explicitly specifies onDelete or onUpdate.
569
+ * Returns all mismatches (both onDelete and onUpdate) so both are reported at once.
570
+ *
571
+ * Note: 'noAction' in the contract is semantically equivalent to undefined in the
572
+ * schema IR, because the introspection adapter omits 'NO ACTION' (the database default)
573
+ * to keep the IR sparse. We normalize both sides before comparing.
574
+ */
575
+ function getReferentialActionMismatches(
576
+ contractFK: ForeignKey,
577
+ schemaFK: SqlForeignKeyIR,
578
+ ): ReadonlyArray<{ expected: string; actual: string; message: string }> {
579
+ const mismatches: Array<{ expected: string; actual: string; message: string }> = [];
580
+
581
+ const contractOnDelete = normalizeReferentialAction(contractFK.onDelete);
582
+ const schemaOnDelete = normalizeReferentialAction(schemaFK.onDelete);
583
+ if (contractOnDelete !== undefined && contractOnDelete !== schemaOnDelete) {
584
+ mismatches.push({
585
+ expected: `onDelete: ${contractFK.onDelete}`,
586
+ actual: `onDelete: ${schemaFK.onDelete ?? 'noAction (default)'}`,
587
+ message: `onDelete mismatch: expected ${contractFK.onDelete}, got ${schemaFK.onDelete ?? 'noAction (default)'}`,
588
+ });
589
+ }
590
+
591
+ const contractOnUpdate = normalizeReferentialAction(contractFK.onUpdate);
592
+ const schemaOnUpdate = normalizeReferentialAction(schemaFK.onUpdate);
593
+ if (contractOnUpdate !== undefined && contractOnUpdate !== schemaOnUpdate) {
594
+ mismatches.push({
595
+ expected: `onUpdate: ${contractFK.onUpdate}`,
596
+ actual: `onUpdate: ${schemaFK.onUpdate ?? 'noAction (default)'}`,
597
+ message: `onUpdate mismatch: expected ${contractFK.onUpdate}, got ${schemaFK.onUpdate ?? 'noAction (default)'}`,
598
+ });
599
+ }
600
+
601
+ return mismatches;
602
+ }
603
+
604
+ /**
605
+ * Normalizes a referential action value for comparison.
606
+ * 'noAction' is the database default and equivalent to undefined (omitted) in the sparse IR.
607
+ */
608
+ function normalizeReferentialAction(action: string | undefined): string | undefined {
609
+ return action === 'noAction' ? undefined : action;
610
+ }