@prisma-next/family-sql 0.3.0-dev.2 → 0.3.0-dev.21
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.
- package/dist/{exports/chunk-6P44BVZ4.js → chunk-F27CR6XZ.js} +12 -3
- package/dist/chunk-F27CR6XZ.js.map +1 -0
- package/dist/{exports/chunk-C3GKWCKA.js → chunk-SU7LN2UH.js} +1 -1
- package/dist/chunk-SU7LN2UH.js.map +1 -0
- package/dist/{exports/chunk-F252JMEU.js → chunk-XH2Y5NTD.js} +59 -116
- package/dist/chunk-XH2Y5NTD.js.map +1 -0
- package/dist/core/assembly.d.ts +25 -0
- package/dist/core/assembly.d.ts.map +1 -0
- package/dist/core/control-adapter.d.ts +42 -0
- package/dist/core/control-adapter.d.ts.map +1 -0
- package/dist/core/descriptor.d.ts +24 -0
- package/dist/core/descriptor.d.ts.map +1 -0
- package/dist/{exports/instance-DiZi2k_2.d.ts → core/instance.d.ts} +28 -15
- package/dist/core/instance.d.ts.map +1 -0
- package/dist/core/migrations/plan-helpers.d.ts +20 -0
- package/dist/core/migrations/plan-helpers.d.ts.map +1 -0
- package/dist/core/migrations/policies.d.ts +6 -0
- package/dist/core/migrations/policies.d.ts.map +1 -0
- package/dist/{exports/types-Bh7ftf0Q.d.ts → core/migrations/types.d.ts} +41 -36
- package/dist/core/migrations/types.d.ts.map +1 -0
- package/dist/core/runtime-descriptor.d.ts +19 -0
- package/dist/core/runtime-descriptor.d.ts.map +1 -0
- package/dist/core/runtime-instance.d.ts +54 -0
- package/dist/core/runtime-instance.d.ts.map +1 -0
- package/dist/core/schema-verify/verify-helpers.d.ts +96 -0
- package/dist/core/schema-verify/verify-helpers.d.ts.map +1 -0
- package/dist/core/schema-verify/verify-sql-schema.d.ts +45 -0
- package/dist/core/schema-verify/verify-sql-schema.d.ts.map +1 -0
- package/dist/core/verify.d.ts +39 -0
- package/dist/core/verify.d.ts.map +1 -0
- package/dist/exports/control-adapter.d.ts +2 -44
- package/dist/exports/control-adapter.d.ts.map +1 -0
- package/dist/exports/control.d.ts +8 -160
- package/dist/exports/control.d.ts.map +1 -0
- package/dist/exports/control.js +7 -7
- package/dist/exports/control.js.map +1 -1
- package/dist/exports/runtime.d.ts +3 -61
- package/dist/exports/runtime.d.ts.map +1 -0
- package/dist/exports/schema-verify.d.ts +8 -72
- package/dist/exports/schema-verify.d.ts.map +1 -0
- package/dist/exports/schema-verify.js +5 -1
- package/dist/exports/test-utils.d.ts +5 -31
- package/dist/exports/test-utils.d.ts.map +1 -0
- package/dist/exports/test-utils.js +3 -3
- package/dist/exports/verify.d.ts +2 -28
- package/dist/exports/verify.d.ts.map +1 -0
- package/dist/exports/verify.js +1 -1
- package/package.json +24 -25
- package/src/core/assembly.ts +117 -0
- package/src/core/control-adapter.ts +52 -0
- package/src/core/descriptor.ts +33 -0
- package/src/core/instance.ts +903 -0
- package/src/core/migrations/plan-helpers.ts +164 -0
- package/src/core/migrations/policies.ts +8 -0
- package/src/core/migrations/types.ts +380 -0
- package/src/core/runtime-descriptor.ts +45 -0
- package/src/core/runtime-instance.ts +144 -0
- package/src/core/schema-verify/verify-helpers.ts +532 -0
- package/src/core/schema-verify/verify-sql-schema.ts +588 -0
- package/src/core/verify.ts +177 -0
- package/src/exports/control-adapter.ts +1 -0
- package/src/exports/control.ts +56 -0
- package/src/exports/runtime.ts +7 -0
- package/src/exports/schema-verify.ts +16 -0
- package/src/exports/test-utils.ts +11 -0
- package/src/exports/verify.ts +1 -0
- package/dist/exports/chunk-6P44BVZ4.js.map +0 -1
- package/dist/exports/chunk-C3GKWCKA.js.map +0 -1
- package/dist/exports/chunk-F252JMEU.js.map +0 -1
|
@@ -0,0 +1,144 @@
|
|
|
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';
|
|
25
|
+
|
|
26
|
+
/**
|
|
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.
|
|
42
|
+
*/
|
|
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
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Creates a SQL runtime family instance from runtime descriptors.
|
|
75
|
+
*
|
|
76
|
+
* Routes the same framework composition as control-plane:
|
|
77
|
+
* family, target, adapter, driver, extensionPacks (all as descriptors with IDs).
|
|
78
|
+
*/
|
|
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
|
+
|
|
98
|
+
return {
|
|
99
|
+
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
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure verification helper functions for SQL schema verification.
|
|
3
|
+
* These functions verify schema IR against contract requirements.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { SchemaIssue, SchemaVerificationNode } from '@prisma-next/core-control-plane/types';
|
|
7
|
+
import type {
|
|
8
|
+
ForeignKey,
|
|
9
|
+
Index,
|
|
10
|
+
PrimaryKey,
|
|
11
|
+
UniqueConstraint,
|
|
12
|
+
} from '@prisma-next/sql-contract/types';
|
|
13
|
+
import type {
|
|
14
|
+
SqlForeignKeyIR,
|
|
15
|
+
SqlIndexIR,
|
|
16
|
+
SqlSchemaIR,
|
|
17
|
+
SqlUniqueIR,
|
|
18
|
+
} from '@prisma-next/sql-schema-ir/types';
|
|
19
|
+
import type { ComponentDatabaseDependency } from '../migrations/types';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compares two arrays of strings for equality (order-sensitive).
|
|
23
|
+
*/
|
|
24
|
+
export function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
25
|
+
if (a.length !== b.length) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
for (let i = 0; i < a.length; i++) {
|
|
29
|
+
if (a[i] !== b[i]) {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
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
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Verifies primary key matches between contract and schema.
|
|
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).
|
|
101
|
+
*/
|
|
102
|
+
export function verifyPrimaryKey(
|
|
103
|
+
contractPK: PrimaryKey,
|
|
104
|
+
schemaPK: PrimaryKey | undefined,
|
|
105
|
+
tableName: string,
|
|
106
|
+
issues: SchemaIssue[],
|
|
107
|
+
): 'pass' | 'fail' {
|
|
108
|
+
if (!schemaPK) {
|
|
109
|
+
issues.push({
|
|
110
|
+
kind: 'primary_key_mismatch',
|
|
111
|
+
table: tableName,
|
|
112
|
+
expected: contractPK.columns.join(', '),
|
|
113
|
+
message: `Table "${tableName}" is missing primary key`,
|
|
114
|
+
});
|
|
115
|
+
return 'fail';
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!arraysEqual(contractPK.columns, schemaPK.columns)) {
|
|
119
|
+
issues.push({
|
|
120
|
+
kind: 'primary_key_mismatch',
|
|
121
|
+
table: tableName,
|
|
122
|
+
expected: contractPK.columns.join(', '),
|
|
123
|
+
actual: schemaPK.columns.join(', '),
|
|
124
|
+
message: `Table "${tableName}" has primary key mismatch: expected columns [${contractPK.columns.join(', ')}], got [${schemaPK.columns.join(', ')}]`,
|
|
125
|
+
});
|
|
126
|
+
return 'fail';
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Name differences are ignored for semantic satisfaction.
|
|
130
|
+
// Names are persisted for deterministic DDL and diagnostics but are not identity.
|
|
131
|
+
|
|
132
|
+
return 'pass';
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Verifies foreign keys match between contract and schema.
|
|
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).
|
|
141
|
+
*/
|
|
142
|
+
export function verifyForeignKeys(
|
|
143
|
+
contractFKs: readonly ForeignKey[],
|
|
144
|
+
schemaFKs: readonly SqlForeignKeyIR[],
|
|
145
|
+
tableName: string,
|
|
146
|
+
tablePath: string,
|
|
147
|
+
issues: SchemaIssue[],
|
|
148
|
+
strict: boolean,
|
|
149
|
+
): SchemaVerificationNode[] {
|
|
150
|
+
const nodes: SchemaVerificationNode[] = [];
|
|
151
|
+
|
|
152
|
+
// Check each contract FK exists in schema
|
|
153
|
+
for (const contractFK of contractFKs) {
|
|
154
|
+
const fkPath = `${tablePath}.foreignKeys[${contractFK.columns.join(',')}]`;
|
|
155
|
+
const matchingFK = schemaFKs.find((fk) => {
|
|
156
|
+
return (
|
|
157
|
+
arraysEqual(fk.columns, contractFK.columns) &&
|
|
158
|
+
fk.referencedTable === contractFK.references.table &&
|
|
159
|
+
arraysEqual(fk.referencedColumns, contractFK.references.columns)
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (!matchingFK) {
|
|
164
|
+
issues.push({
|
|
165
|
+
kind: 'foreign_key_mismatch',
|
|
166
|
+
table: tableName,
|
|
167
|
+
expected: `${contractFK.columns.join(', ')} -> ${contractFK.references.table}(${contractFK.references.columns.join(', ')})`,
|
|
168
|
+
message: `Table "${tableName}" is missing foreign key: ${contractFK.columns.join(', ')} -> ${contractFK.references.table}(${contractFK.references.columns.join(', ')})`,
|
|
169
|
+
});
|
|
170
|
+
nodes.push({
|
|
171
|
+
status: 'fail',
|
|
172
|
+
kind: 'foreignKey',
|
|
173
|
+
name: `foreignKey(${contractFK.columns.join(', ')})`,
|
|
174
|
+
contractPath: fkPath,
|
|
175
|
+
code: 'foreign_key_mismatch',
|
|
176
|
+
message: 'Foreign key missing',
|
|
177
|
+
expected: contractFK,
|
|
178
|
+
actual: undefined,
|
|
179
|
+
children: [],
|
|
180
|
+
});
|
|
181
|
+
} else {
|
|
182
|
+
// Name differences are ignored for semantic satisfaction.
|
|
183
|
+
// Names are persisted for deterministic DDL and diagnostics but are not identity.
|
|
184
|
+
nodes.push({
|
|
185
|
+
status: 'pass',
|
|
186
|
+
kind: 'foreignKey',
|
|
187
|
+
name: `foreignKey(${contractFK.columns.join(', ')})`,
|
|
188
|
+
contractPath: fkPath,
|
|
189
|
+
code: '',
|
|
190
|
+
message: '',
|
|
191
|
+
expected: undefined,
|
|
192
|
+
actual: undefined,
|
|
193
|
+
children: [],
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Check for extra FKs in strict mode
|
|
199
|
+
if (strict) {
|
|
200
|
+
for (const schemaFK of schemaFKs) {
|
|
201
|
+
const matchingFK = contractFKs.find((fk) => {
|
|
202
|
+
return (
|
|
203
|
+
arraysEqual(fk.columns, schemaFK.columns) &&
|
|
204
|
+
fk.references.table === schemaFK.referencedTable &&
|
|
205
|
+
arraysEqual(fk.references.columns, schemaFK.referencedColumns)
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
if (!matchingFK) {
|
|
210
|
+
issues.push({
|
|
211
|
+
kind: 'extra_foreign_key',
|
|
212
|
+
table: tableName,
|
|
213
|
+
message: `Extra foreign key found in database (not in contract): ${schemaFK.columns.join(', ')} -> ${schemaFK.referencedTable}(${schemaFK.referencedColumns.join(', ')})`,
|
|
214
|
+
});
|
|
215
|
+
nodes.push({
|
|
216
|
+
status: 'fail',
|
|
217
|
+
kind: 'foreignKey',
|
|
218
|
+
name: `foreignKey(${schemaFK.columns.join(', ')})`,
|
|
219
|
+
contractPath: `${tablePath}.foreignKeys[${schemaFK.columns.join(',')}]`,
|
|
220
|
+
code: 'extra_foreign_key',
|
|
221
|
+
message: 'Extra foreign key found',
|
|
222
|
+
expected: undefined,
|
|
223
|
+
actual: schemaFK,
|
|
224
|
+
children: [],
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
return nodes;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Verifies unique constraints match between contract and schema.
|
|
235
|
+
* Returns verification nodes for the tree.
|
|
236
|
+
*
|
|
237
|
+
* Uses semantic satisfaction: identity is based on (table + kind + columns).
|
|
238
|
+
* A unique constraint requirement can be satisfied by either:
|
|
239
|
+
* - A unique constraint with the same columns, or
|
|
240
|
+
* - A unique index with the same columns
|
|
241
|
+
*
|
|
242
|
+
* Name differences are ignored by default (names are for DDL/diagnostics, not identity).
|
|
243
|
+
*/
|
|
244
|
+
export function verifyUniqueConstraints(
|
|
245
|
+
contractUniques: readonly UniqueConstraint[],
|
|
246
|
+
schemaUniques: readonly SqlUniqueIR[],
|
|
247
|
+
schemaIndexes: readonly SqlIndexIR[],
|
|
248
|
+
tableName: string,
|
|
249
|
+
tablePath: string,
|
|
250
|
+
issues: SchemaIssue[],
|
|
251
|
+
strict: boolean,
|
|
252
|
+
): SchemaVerificationNode[] {
|
|
253
|
+
const nodes: SchemaVerificationNode[] = [];
|
|
254
|
+
|
|
255
|
+
// Check each contract unique exists in schema
|
|
256
|
+
for (const contractUnique of contractUniques) {
|
|
257
|
+
const uniquePath = `${tablePath}.uniques[${contractUnique.columns.join(',')}]`;
|
|
258
|
+
|
|
259
|
+
// First check for a matching unique constraint
|
|
260
|
+
const matchingUnique = schemaUniques.find((u) =>
|
|
261
|
+
arraysEqual(u.columns, contractUnique.columns),
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
// If no matching constraint, check for a unique index with the same columns
|
|
265
|
+
const matchingUniqueIndex =
|
|
266
|
+
!matchingUnique &&
|
|
267
|
+
schemaIndexes.find((idx) => idx.unique && arraysEqual(idx.columns, contractUnique.columns));
|
|
268
|
+
|
|
269
|
+
if (!matchingUnique && !matchingUniqueIndex) {
|
|
270
|
+
issues.push({
|
|
271
|
+
kind: 'unique_constraint_mismatch',
|
|
272
|
+
table: tableName,
|
|
273
|
+
expected: contractUnique.columns.join(', '),
|
|
274
|
+
message: `Table "${tableName}" is missing unique constraint: ${contractUnique.columns.join(', ')}`,
|
|
275
|
+
});
|
|
276
|
+
nodes.push({
|
|
277
|
+
status: 'fail',
|
|
278
|
+
kind: 'unique',
|
|
279
|
+
name: `unique(${contractUnique.columns.join(', ')})`,
|
|
280
|
+
contractPath: uniquePath,
|
|
281
|
+
code: 'unique_constraint_mismatch',
|
|
282
|
+
message: 'Unique constraint missing',
|
|
283
|
+
expected: contractUnique,
|
|
284
|
+
actual: undefined,
|
|
285
|
+
children: [],
|
|
286
|
+
});
|
|
287
|
+
} else {
|
|
288
|
+
// Name differences are ignored for semantic satisfaction.
|
|
289
|
+
// Names are persisted for deterministic DDL and diagnostics but are not identity.
|
|
290
|
+
nodes.push({
|
|
291
|
+
status: 'pass',
|
|
292
|
+
kind: 'unique',
|
|
293
|
+
name: `unique(${contractUnique.columns.join(', ')})`,
|
|
294
|
+
contractPath: uniquePath,
|
|
295
|
+
code: '',
|
|
296
|
+
message: '',
|
|
297
|
+
expected: undefined,
|
|
298
|
+
actual: undefined,
|
|
299
|
+
children: [],
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Check for extra uniques in strict mode
|
|
305
|
+
if (strict) {
|
|
306
|
+
for (const schemaUnique of schemaUniques) {
|
|
307
|
+
const matchingUnique = contractUniques.find((u) =>
|
|
308
|
+
arraysEqual(u.columns, schemaUnique.columns),
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
if (!matchingUnique) {
|
|
312
|
+
issues.push({
|
|
313
|
+
kind: 'extra_unique_constraint',
|
|
314
|
+
table: tableName,
|
|
315
|
+
message: `Extra unique constraint found in database (not in contract): ${schemaUnique.columns.join(', ')}`,
|
|
316
|
+
});
|
|
317
|
+
nodes.push({
|
|
318
|
+
status: 'fail',
|
|
319
|
+
kind: 'unique',
|
|
320
|
+
name: `unique(${schemaUnique.columns.join(', ')})`,
|
|
321
|
+
contractPath: `${tablePath}.uniques[${schemaUnique.columns.join(',')}]`,
|
|
322
|
+
code: 'extra_unique_constraint',
|
|
323
|
+
message: 'Extra unique constraint found',
|
|
324
|
+
expected: undefined,
|
|
325
|
+
actual: schemaUnique,
|
|
326
|
+
children: [],
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return nodes;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Verifies indexes match between contract and schema.
|
|
337
|
+
* Returns verification nodes for the tree.
|
|
338
|
+
*
|
|
339
|
+
* Uses semantic satisfaction: identity is based on (table + kind + columns).
|
|
340
|
+
* A non-unique index requirement can be satisfied by either:
|
|
341
|
+
* - A non-unique index with the same columns, or
|
|
342
|
+
* - A unique index with the same columns (stronger satisfies weaker)
|
|
343
|
+
*
|
|
344
|
+
* Name differences are ignored by default (names are for DDL/diagnostics, not identity).
|
|
345
|
+
*/
|
|
346
|
+
export function verifyIndexes(
|
|
347
|
+
contractIndexes: readonly Index[],
|
|
348
|
+
schemaIndexes: readonly SqlIndexIR[],
|
|
349
|
+
schemaUniques: readonly SqlUniqueIR[],
|
|
350
|
+
tableName: string,
|
|
351
|
+
tablePath: string,
|
|
352
|
+
issues: SchemaIssue[],
|
|
353
|
+
strict: boolean,
|
|
354
|
+
): SchemaVerificationNode[] {
|
|
355
|
+
const nodes: SchemaVerificationNode[] = [];
|
|
356
|
+
|
|
357
|
+
// Check each contract index exists in schema
|
|
358
|
+
for (const contractIndex of contractIndexes) {
|
|
359
|
+
const indexPath = `${tablePath}.indexes[${contractIndex.columns.join(',')}]`;
|
|
360
|
+
|
|
361
|
+
// Check for any matching index (unique or non-unique)
|
|
362
|
+
// A unique index can satisfy a non-unique index requirement (stronger satisfies weaker)
|
|
363
|
+
const matchingIndex = schemaIndexes.find((idx) =>
|
|
364
|
+
arraysEqual(idx.columns, contractIndex.columns),
|
|
365
|
+
);
|
|
366
|
+
|
|
367
|
+
// Also check if a unique constraint satisfies the index requirement
|
|
368
|
+
const matchingUniqueConstraint =
|
|
369
|
+
!matchingIndex && schemaUniques.find((u) => arraysEqual(u.columns, contractIndex.columns));
|
|
370
|
+
|
|
371
|
+
if (!matchingIndex && !matchingUniqueConstraint) {
|
|
372
|
+
issues.push({
|
|
373
|
+
kind: 'index_mismatch',
|
|
374
|
+
table: tableName,
|
|
375
|
+
expected: contractIndex.columns.join(', '),
|
|
376
|
+
message: `Table "${tableName}" is missing index: ${contractIndex.columns.join(', ')}`,
|
|
377
|
+
});
|
|
378
|
+
nodes.push({
|
|
379
|
+
status: 'fail',
|
|
380
|
+
kind: 'index',
|
|
381
|
+
name: `index(${contractIndex.columns.join(', ')})`,
|
|
382
|
+
contractPath: indexPath,
|
|
383
|
+
code: 'index_mismatch',
|
|
384
|
+
message: 'Index missing',
|
|
385
|
+
expected: contractIndex,
|
|
386
|
+
actual: undefined,
|
|
387
|
+
children: [],
|
|
388
|
+
});
|
|
389
|
+
} else {
|
|
390
|
+
// Name differences are ignored for semantic satisfaction.
|
|
391
|
+
// Names are persisted for deterministic DDL and diagnostics but are not identity.
|
|
392
|
+
nodes.push({
|
|
393
|
+
status: 'pass',
|
|
394
|
+
kind: 'index',
|
|
395
|
+
name: `index(${contractIndex.columns.join(', ')})`,
|
|
396
|
+
contractPath: indexPath,
|
|
397
|
+
code: '',
|
|
398
|
+
message: '',
|
|
399
|
+
expected: undefined,
|
|
400
|
+
actual: undefined,
|
|
401
|
+
children: [],
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Check for extra indexes in strict mode
|
|
407
|
+
if (strict) {
|
|
408
|
+
for (const schemaIndex of schemaIndexes) {
|
|
409
|
+
// Skip unique indexes (they're handled as unique constraints)
|
|
410
|
+
if (schemaIndex.unique) {
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const matchingIndex = contractIndexes.find((idx) =>
|
|
415
|
+
arraysEqual(idx.columns, schemaIndex.columns),
|
|
416
|
+
);
|
|
417
|
+
|
|
418
|
+
if (!matchingIndex) {
|
|
419
|
+
issues.push({
|
|
420
|
+
kind: 'extra_index',
|
|
421
|
+
table: tableName,
|
|
422
|
+
message: `Extra index found in database (not in contract): ${schemaIndex.columns.join(', ')}`,
|
|
423
|
+
});
|
|
424
|
+
nodes.push({
|
|
425
|
+
status: 'fail',
|
|
426
|
+
kind: 'index',
|
|
427
|
+
name: `index(${schemaIndex.columns.join(', ')})`,
|
|
428
|
+
contractPath: `${tablePath}.indexes[${schemaIndex.columns.join(',')}]`,
|
|
429
|
+
code: 'extra_index',
|
|
430
|
+
message: 'Extra index found',
|
|
431
|
+
expected: undefined,
|
|
432
|
+
actual: schemaIndex,
|
|
433
|
+
children: [],
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
return nodes;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Verifies database dependencies are installed using component-owned verification hooks.
|
|
444
|
+
* Each dependency provides a pure verifyDatabaseDependencyInstalled function that checks
|
|
445
|
+
* whether the dependency is satisfied based on the in-memory schema IR (no DB I/O).
|
|
446
|
+
*
|
|
447
|
+
* Returns verification nodes for the tree.
|
|
448
|
+
*/
|
|
449
|
+
export function verifyDatabaseDependencies(
|
|
450
|
+
dependencies: ReadonlyArray<ComponentDatabaseDependency<unknown>>,
|
|
451
|
+
schema: SqlSchemaIR,
|
|
452
|
+
issues: SchemaIssue[],
|
|
453
|
+
): SchemaVerificationNode[] {
|
|
454
|
+
const nodes: SchemaVerificationNode[] = [];
|
|
455
|
+
|
|
456
|
+
for (const dependency of dependencies) {
|
|
457
|
+
const depIssues = dependency.verifyDatabaseDependencyInstalled(schema);
|
|
458
|
+
const depPath = `dependencies.${dependency.id}`;
|
|
459
|
+
|
|
460
|
+
if (depIssues.length > 0) {
|
|
461
|
+
// Dependency is not satisfied
|
|
462
|
+
issues.push(...depIssues);
|
|
463
|
+
const issuesMessage = depIssues.map((i) => i.message).join('; ');
|
|
464
|
+
const nodeMessage = issuesMessage ? `${dependency.id}: ${issuesMessage}` : dependency.id;
|
|
465
|
+
nodes.push({
|
|
466
|
+
status: 'fail',
|
|
467
|
+
kind: 'databaseDependency',
|
|
468
|
+
name: dependency.label,
|
|
469
|
+
contractPath: depPath,
|
|
470
|
+
code: 'dependency_missing',
|
|
471
|
+
message: nodeMessage,
|
|
472
|
+
expected: undefined,
|
|
473
|
+
actual: undefined,
|
|
474
|
+
children: [],
|
|
475
|
+
});
|
|
476
|
+
} else {
|
|
477
|
+
// Dependency is satisfied
|
|
478
|
+
nodes.push({
|
|
479
|
+
status: 'pass',
|
|
480
|
+
kind: 'databaseDependency',
|
|
481
|
+
name: dependency.label,
|
|
482
|
+
contractPath: depPath,
|
|
483
|
+
code: '',
|
|
484
|
+
message: '',
|
|
485
|
+
expected: undefined,
|
|
486
|
+
actual: undefined,
|
|
487
|
+
children: [],
|
|
488
|
+
});
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
return nodes;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Computes counts of pass/warn/fail nodes by traversing the tree.
|
|
497
|
+
*/
|
|
498
|
+
export function computeCounts(node: SchemaVerificationNode): {
|
|
499
|
+
pass: number;
|
|
500
|
+
warn: number;
|
|
501
|
+
fail: number;
|
|
502
|
+
totalNodes: number;
|
|
503
|
+
} {
|
|
504
|
+
let pass = 0;
|
|
505
|
+
let warn = 0;
|
|
506
|
+
let fail = 0;
|
|
507
|
+
|
|
508
|
+
function traverse(n: SchemaVerificationNode): void {
|
|
509
|
+
if (n.status === 'pass') {
|
|
510
|
+
pass++;
|
|
511
|
+
} else if (n.status === 'warn') {
|
|
512
|
+
warn++;
|
|
513
|
+
} else if (n.status === 'fail') {
|
|
514
|
+
fail++;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
if (n.children) {
|
|
518
|
+
for (const child of n.children) {
|
|
519
|
+
traverse(child);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
traverse(node);
|
|
525
|
+
|
|
526
|
+
return {
|
|
527
|
+
pass,
|
|
528
|
+
warn,
|
|
529
|
+
fail,
|
|
530
|
+
totalNodes: pass + warn + fail,
|
|
531
|
+
};
|
|
532
|
+
}
|