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