@prisma-next/family-sql 0.14.0-dev.49 → 0.14.0-dev.50
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/{control-adapter-DHYFuOBy.d.mts → control-adapter-BVOj4gXI.d.mts} +34 -5
- package/dist/control-adapter-BVOj4gXI.d.mts.map +1 -0
- package/dist/control-adapter.d.mts +1 -1
- package/dist/control.d.mts +65 -50
- package/dist/control.d.mts.map +1 -1
- package/dist/control.mjs +308 -56
- package/dist/control.mjs.map +1 -1
- package/dist/diff.d.mts +141 -30
- package/dist/diff.d.mts.map +1 -1
- package/dist/diff.mjs +155 -1384
- package/dist/diff.mjs.map +1 -1
- package/dist/ir.d.mts +5 -5
- package/dist/ir.d.mts.map +1 -1
- package/dist/ir.mjs +2 -2
- package/dist/ir.mjs.map +1 -1
- package/dist/migration.d.mts +1 -1
- package/dist/schema-differ-DnoopSXm.d.mts +45 -0
- package/dist/schema-differ-DnoopSXm.d.mts.map +1 -0
- package/dist/schema-verify-W3r631Jh.mjs +226 -0
- package/dist/schema-verify-W3r631Jh.mjs.map +1 -0
- package/dist/{sql-contract-serializer-DwUSJ4PO.mjs → sql-contract-serializer-C75cfMSS.mjs} +2 -2
- package/dist/{sql-contract-serializer-DwUSJ4PO.mjs.map → sql-contract-serializer-C75cfMSS.mjs.map} +1 -1
- package/dist/{types-DBxrwN1d.d.mts → types-BW7pzb2j.d.mts} +24 -15
- package/dist/types-BW7pzb2j.d.mts.map +1 -0
- package/package.json +21 -21
- package/src/core/control-instance.ts +99 -80
- package/src/core/control-target-descriptor.ts +29 -14
- package/src/core/diff/diff-tree-normalization.ts +211 -0
- package/src/core/diff/schema-verify.ts +324 -0
- package/src/core/diff/sql-schema-diff.ts +40 -1466
- package/src/core/diff/verifier-disposition.ts +14 -42
- package/src/core/ir/sql-schema-verifier-base.ts +5 -5
- package/src/core/migrations/contract-to-schema-ir.ts +63 -20
- package/src/core/migrations/native-type-expander.ts +28 -0
- package/src/core/migrations/schema-differ.ts +30 -29
- package/src/core/migrations/types.ts +23 -14
- package/src/exports/control.ts +6 -0
- package/src/exports/diff.ts +28 -8
- package/dist/contract-to-schema-ir-S-evq8E6.mjs +0 -264
- package/dist/contract-to-schema-ir-S-evq8E6.mjs.map +0 -1
- package/dist/control-adapter-DHYFuOBy.d.mts.map +0 -1
- package/dist/sql-schema-diff-6z36dZt6.d.mts +0 -105
- package/dist/sql-schema-diff-6z36dZt6.d.mts.map +0 -1
- package/dist/types-DBxrwN1d.d.mts.map +0 -1
- package/src/core/diff/control-verify-emit.ts +0 -46
- package/src/core/diff/verify-helpers.ts +0 -832
|
@@ -1,71 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
2
|
+
* Shared relational-diff types and semantic-satisfaction predicates. The
|
|
3
|
+
* coordinate-based issue diff this module used to house (`collectSqlSchemaIssues`
|
|
4
|
+
* / `collectSqlSchemaIssuesPerNamespace`) retired once the migration planner
|
|
5
|
+
* took `plan(start, end)` over the one differ (`buildPostgresPlanDiff` /
|
|
6
|
+
* `buildSqlitePlanDiff`); what remains here is consumed by the surviving
|
|
7
|
+
* verify verdict (`schema-verify.ts`) and the control adapters.
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
import type { ColumnDefault
|
|
10
|
-
import {
|
|
11
|
-
import type { TargetBoundComponentDescriptor } from '@prisma-next/framework-components/components';
|
|
12
|
-
import type {
|
|
13
|
-
OperationContext,
|
|
14
|
-
SchemaIssue,
|
|
15
|
-
SchemaVerificationNode,
|
|
16
|
-
VerificationStatus,
|
|
17
|
-
VerifyDatabaseSchemaResult,
|
|
18
|
-
} from '@prisma-next/framework-components/control';
|
|
19
|
-
|
|
20
|
-
import {
|
|
21
|
-
isStorageTypeInstance,
|
|
22
|
-
type SqlStorage,
|
|
23
|
-
type StorageColumn,
|
|
24
|
-
StorageTable,
|
|
25
|
-
type StorageTypeInstance,
|
|
26
|
-
} from '@prisma-next/sql-contract/types';
|
|
27
|
-
import type { SqlSchemaIR, SqlSchemaIRNode } from '@prisma-next/sql-schema-ir/types';
|
|
28
|
-
import { canonicalStringify } from '@prisma-next/utils/canonical-stringify';
|
|
29
|
-
import { blindCast } from '@prisma-next/utils/casts';
|
|
30
|
-
import { ifDefined } from '@prisma-next/utils/defined';
|
|
31
|
-
import { extractCodecControlHooks } from '../assembly';
|
|
32
|
-
import { resolveValueSetValues } from '../migrations/contract-to-schema-ir';
|
|
33
|
-
import type { CodecControlHooks } from '../migrations/types';
|
|
34
|
-
import { emitIssueAndNodeUnderControlPolicy } from './control-verify-emit';
|
|
35
|
-
import { verifierDisposition } from './verifier-disposition';
|
|
36
|
-
import {
|
|
37
|
-
arraysEqual,
|
|
38
|
-
computeCounts,
|
|
39
|
-
verifyCheckConstraints,
|
|
40
|
-
verifyForeignKeys,
|
|
41
|
-
verifyIndexes,
|
|
42
|
-
verifyPrimaryKey,
|
|
43
|
-
verifyUniqueConstraints,
|
|
44
|
-
} from './verify-helpers';
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Returns the per-schema namespace nodes of an introspected schema node, for
|
|
48
|
-
* the relational verify to consume one at a time. Structure-agnostic — imports
|
|
49
|
-
* no target node class. A root exposing a `namespaces` record (Postgres) yields
|
|
50
|
-
* its namespace nodes (never merged, so same-named tables in different schemas
|
|
51
|
-
* cannot collide); a flat schema (SQLite) is its own single namespace and
|
|
52
|
-
* yields itself. Handles spread-flattened input (own-enumerable fields survive).
|
|
53
|
-
*/
|
|
54
|
-
function namespaceSchemaNodes(schema: SqlSchemaIRNode): readonly SqlSchemaIR[] {
|
|
55
|
-
const obj = blindCast<
|
|
56
|
-
{ readonly namespaces?: Readonly<Record<string, SqlSchemaIR>> },
|
|
57
|
-
'structural read of an own-enumerable namespaces record; survives the projectSchemaToSpace spread'
|
|
58
|
-
>(schema);
|
|
59
|
-
if (obj.namespaces !== undefined) {
|
|
60
|
-
return Object.values(obj.namespaces);
|
|
61
|
-
}
|
|
62
|
-
return [
|
|
63
|
-
blindCast<
|
|
64
|
-
SqlSchemaIR,
|
|
65
|
-
'a flat schema node (no namespaces) is its own single namespace, exposing the per-schema { tables } shape'
|
|
66
|
-
>(schema),
|
|
67
|
-
];
|
|
68
|
-
}
|
|
10
|
+
import type { ColumnDefault } from '@prisma-next/contract/types';
|
|
11
|
+
import type { SqlIndexIR, SqlUniqueIR } from '@prisma-next/sql-schema-ir/types';
|
|
69
12
|
|
|
70
13
|
/**
|
|
71
14
|
* Function type for normalizing raw database default expressions into ColumnDefault.
|
|
@@ -84,1420 +27,51 @@ export type DefaultNormalizer = (
|
|
|
84
27
|
export type NativeTypeNormalizer = (nativeType: string) => string;
|
|
85
28
|
|
|
86
29
|
/**
|
|
87
|
-
*
|
|
30
|
+
* Compares two arrays of strings for equality (order-sensitive).
|
|
88
31
|
*/
|
|
89
|
-
export
|
|
90
|
-
|
|
91
|
-
readonly contract: Contract<SqlStorage>;
|
|
92
|
-
/** The schema IR from introspection (or another source) */
|
|
93
|
-
readonly schema: SqlSchemaIR;
|
|
94
|
-
/** Whether to run in strict mode (detects extra tables/columns) */
|
|
95
|
-
readonly strict: boolean;
|
|
96
|
-
/** Optional operation context for metadata */
|
|
97
|
-
readonly context?: OperationContext;
|
|
98
|
-
/** Type metadata registry for codec consistency warnings */
|
|
99
|
-
readonly typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
|
|
100
|
-
/**
|
|
101
|
-
* Active framework components participating in this composition.
|
|
102
|
-
* All components must have matching familyId ('sql') and targetId.
|
|
103
|
-
*/
|
|
104
|
-
readonly frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>;
|
|
105
|
-
/**
|
|
106
|
-
* Optional target-specific normalizer for raw database default expressions.
|
|
107
|
-
* When provided, schema defaults (raw strings) are normalized before comparison
|
|
108
|
-
* with contract defaults (ColumnDefault objects).
|
|
109
|
-
*/
|
|
110
|
-
readonly normalizeDefault?: DefaultNormalizer;
|
|
111
|
-
/**
|
|
112
|
-
* Optional target-specific normalizer for schema native type names.
|
|
113
|
-
* When provided, schema native types are normalized before comparison
|
|
114
|
-
* with contract native types (e.g., Postgres 'varchar' → 'character varying').
|
|
115
|
-
*/
|
|
116
|
-
readonly normalizeNativeType?: NativeTypeNormalizer;
|
|
117
|
-
/**
|
|
118
|
-
* When set, only the contract tables in these namespace ids are checked
|
|
119
|
-
* against `schema` (the matching actual namespace node). The full contract is
|
|
120
|
-
* still consulted for cross-namespace value-set / control-policy resolution.
|
|
121
|
-
* Used by the multi-schema verify, which pairs each contract namespace to its
|
|
122
|
-
* own actual node. Absent ⇒ all contract namespaces are checked against the
|
|
123
|
-
* single (flat) `schema` — the single-schema / SQLite path.
|
|
124
|
-
*/
|
|
125
|
-
readonly restrictToNamespaceIds?: ReadonlySet<string>;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Verifies that a SqlSchemaIR matches a Contract.
|
|
130
|
-
*
|
|
131
|
-
* This is a pure function that does NOT perform any database I/O.
|
|
132
|
-
* It takes an already-introspected schema IR and compares it against
|
|
133
|
-
* the contract requirements.
|
|
134
|
-
*
|
|
135
|
-
* @param options - Verification options
|
|
136
|
-
* @returns VerifyDatabaseSchemaResult with verification tree and issues
|
|
137
|
-
*/
|
|
138
|
-
export function verifySqlSchema(options: VerifySqlSchemaOptions): VerifyDatabaseSchemaResult {
|
|
139
|
-
const {
|
|
140
|
-
contract,
|
|
141
|
-
schema,
|
|
142
|
-
strict,
|
|
143
|
-
context,
|
|
144
|
-
typeMetadataRegistry,
|
|
145
|
-
normalizeDefault,
|
|
146
|
-
normalizeNativeType,
|
|
147
|
-
} = options;
|
|
148
|
-
const startTime = Date.now();
|
|
149
|
-
|
|
150
|
-
// Extract codec control hooks once at entry point for reuse
|
|
151
|
-
const codecHooks = extractCodecControlHooks(options.frameworkComponents);
|
|
152
|
-
|
|
153
|
-
const { contractStorageHash, contractProfileHash, contractTarget } =
|
|
154
|
-
extractContractMetadata(contract);
|
|
155
|
-
const storageTypes: Readonly<Record<string, StorageTypeInstance>> = contract.storage.types ?? {};
|
|
156
|
-
const { issues, rootChildren } = verifySchemaTables({
|
|
157
|
-
contract,
|
|
158
|
-
schema,
|
|
159
|
-
strict,
|
|
160
|
-
typeMetadataRegistry,
|
|
161
|
-
codecHooks,
|
|
162
|
-
storageTypes,
|
|
163
|
-
...ifDefined('normalizeDefault', normalizeDefault),
|
|
164
|
-
...ifDefined('normalizeNativeType', normalizeNativeType),
|
|
165
|
-
...ifDefined('restrictToNamespaceIds', options.restrictToNamespaceIds),
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
validateFrameworkComponentsForExtensions(contract, options.frameworkComponents);
|
|
169
|
-
|
|
170
|
-
const typeNodes: SchemaVerificationNode[] = [];
|
|
171
|
-
// Storage-type findings dispatch through the same control policy as tables
|
|
172
|
-
// and columns: each issue's disposition (fail / warn / suppress) is resolved
|
|
173
|
-
// from the type's effective control so an `external`/`observed` enum no longer
|
|
174
|
-
// hard-fails on value drift. `suppress` drops the issue entirely; the node
|
|
175
|
-
// status is the worst surviving disposition.
|
|
176
|
-
const pushTypeNode = (
|
|
177
|
-
typeName: string,
|
|
178
|
-
contractPath: string,
|
|
179
|
-
typeIssues: readonly SchemaIssue[],
|
|
180
|
-
controlPolicy: ControlPolicy,
|
|
181
|
-
): void => {
|
|
182
|
-
let status: VerificationStatus = 'pass';
|
|
183
|
-
let code = '';
|
|
184
|
-
let emitted = 0;
|
|
185
|
-
for (const issue of typeIssues) {
|
|
186
|
-
const disposition = verifierDisposition(controlPolicy, issue.kind);
|
|
187
|
-
if (disposition === 'suppress') continue;
|
|
188
|
-
issues.push(issue);
|
|
189
|
-
emitted += 1;
|
|
190
|
-
if (code === '') code = issue.kind;
|
|
191
|
-
if (disposition === 'fail') {
|
|
192
|
-
status = 'fail';
|
|
193
|
-
} else if (disposition === 'warn' && status !== 'fail') {
|
|
194
|
-
status = 'warn';
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
typeNodes.push({
|
|
198
|
-
status,
|
|
199
|
-
kind: 'storageType',
|
|
200
|
-
name: `type ${typeName}`,
|
|
201
|
-
contractPath,
|
|
202
|
-
code: status === 'pass' ? '' : code,
|
|
203
|
-
message: emitted > 0 ? `${emitted} issue${emitted === 1 ? '' : 's'}` : '',
|
|
204
|
-
expected: undefined,
|
|
205
|
-
actual: undefined,
|
|
206
|
-
children: [],
|
|
207
|
-
});
|
|
208
|
-
};
|
|
209
|
-
|
|
210
|
-
// Top-level `storage.types`: codec-typed entries via codec hooks.
|
|
211
|
-
for (const [typeName, typeInstance] of Object.entries(contract.storage.types ?? {})) {
|
|
212
|
-
if (isStorageTypeInstance(typeInstance)) {
|
|
213
|
-
const hook = codecHooks.get(typeInstance.codecId);
|
|
214
|
-
pushTypeNode(
|
|
215
|
-
typeName,
|
|
216
|
-
`storage.types.${typeName}`,
|
|
217
|
-
hook?.verifyType ? hook.verifyType({ typeName, typeInstance, schema }) : [],
|
|
218
|
-
effectiveControlPolicy(undefined, contract.defaultControlPolicy),
|
|
219
|
-
);
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
if (typeNodes.length > 0) {
|
|
224
|
-
const typesStatus: VerificationStatus = typeNodes.some((n) => n.status === 'fail')
|
|
225
|
-
? 'fail'
|
|
226
|
-
: typeNodes.some((n) => n.status === 'warn')
|
|
227
|
-
? 'warn'
|
|
228
|
-
: 'pass';
|
|
229
|
-
rootChildren.push({
|
|
230
|
-
status: typesStatus,
|
|
231
|
-
kind: 'storageTypes',
|
|
232
|
-
name: 'types',
|
|
233
|
-
contractPath: 'storage.types',
|
|
234
|
-
code: typesStatus === 'fail' ? 'type_mismatch' : '',
|
|
235
|
-
message: '',
|
|
236
|
-
expected: undefined,
|
|
237
|
-
actual: undefined,
|
|
238
|
-
children: typeNodes,
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const root = buildRootNode(rootChildren);
|
|
243
|
-
|
|
244
|
-
// Compute counts
|
|
245
|
-
const counts = computeCounts(root);
|
|
246
|
-
|
|
247
|
-
// Set ok flag
|
|
248
|
-
const ok = counts.fail === 0;
|
|
249
|
-
|
|
250
|
-
// Set code
|
|
251
|
-
const code = ok ? undefined : 'PN-SCHEMA-0001';
|
|
252
|
-
|
|
253
|
-
// Set summary
|
|
254
|
-
const summary = ok
|
|
255
|
-
? 'Database schema satisfies contract'
|
|
256
|
-
: `Database schema does not satisfy contract (${counts.fail} failure${counts.fail === 1 ? '' : 's'})`;
|
|
257
|
-
|
|
258
|
-
const totalTime = Date.now() - startTime;
|
|
259
|
-
|
|
260
|
-
return {
|
|
261
|
-
ok,
|
|
262
|
-
...ifDefined('code', code),
|
|
263
|
-
summary,
|
|
264
|
-
contract: {
|
|
265
|
-
storageHash: contractStorageHash,
|
|
266
|
-
...ifDefined('profileHash', contractProfileHash),
|
|
267
|
-
},
|
|
268
|
-
target: {
|
|
269
|
-
expected: contractTarget,
|
|
270
|
-
actual: contractTarget,
|
|
271
|
-
},
|
|
272
|
-
schema: {
|
|
273
|
-
issues,
|
|
274
|
-
schemaDiffIssues: [],
|
|
275
|
-
root,
|
|
276
|
-
counts,
|
|
277
|
-
},
|
|
278
|
-
meta: {
|
|
279
|
-
strict,
|
|
280
|
-
...ifDefined('contractPath', context?.contractPath),
|
|
281
|
-
...ifDefined('configPath', context?.configPath),
|
|
282
|
-
},
|
|
283
|
-
timings: {
|
|
284
|
-
total: totalTime,
|
|
285
|
-
},
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function extractContractMetadata(contract: Contract<SqlStorage>): {
|
|
290
|
-
contractStorageHash: SqlStorage['storageHash'];
|
|
291
|
-
contractProfileHash?: Contract<SqlStorage>['profileHash'] | undefined;
|
|
292
|
-
contractTarget: Contract<SqlStorage>['target'];
|
|
293
|
-
} {
|
|
294
|
-
return {
|
|
295
|
-
contractStorageHash: contract.storage.storageHash,
|
|
296
|
-
contractProfileHash:
|
|
297
|
-
'profileHash' in contract && typeof contract.profileHash === 'string'
|
|
298
|
-
? contract.profileHash
|
|
299
|
-
: undefined,
|
|
300
|
-
contractTarget: contract.target,
|
|
301
|
-
};
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
function verifySchemaTables(options: {
|
|
305
|
-
contract: Contract<SqlStorage>;
|
|
306
|
-
schema: SqlSchemaIR;
|
|
307
|
-
strict: boolean;
|
|
308
|
-
typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
|
|
309
|
-
codecHooks: Map<string, CodecControlHooks>;
|
|
310
|
-
storageTypes: Readonly<Record<string, StorageTypeInstance>>;
|
|
311
|
-
normalizeDefault?: DefaultNormalizer;
|
|
312
|
-
normalizeNativeType?: NativeTypeNormalizer;
|
|
313
|
-
restrictToNamespaceIds?: ReadonlySet<string>;
|
|
314
|
-
}): { issues: SchemaIssue[]; rootChildren: SchemaVerificationNode[] } {
|
|
315
|
-
const {
|
|
316
|
-
contract,
|
|
317
|
-
schema,
|
|
318
|
-
strict,
|
|
319
|
-
typeMetadataRegistry,
|
|
320
|
-
codecHooks,
|
|
321
|
-
storageTypes,
|
|
322
|
-
normalizeDefault,
|
|
323
|
-
normalizeNativeType,
|
|
324
|
-
restrictToNamespaceIds,
|
|
325
|
-
} = options;
|
|
326
|
-
const contractDefaultControl = contract.defaultControlPolicy;
|
|
327
|
-
const issues: SchemaIssue[] = [];
|
|
328
|
-
const rootChildren: SchemaVerificationNode[] = [];
|
|
329
|
-
const schemaTables = schema.tables;
|
|
330
|
-
const namespaceIds = Object.keys(contract.storage.namespaces).sort((a, b) =>
|
|
331
|
-
a < b ? -1 : a > b ? 1 : 0,
|
|
332
|
-
);
|
|
333
|
-
|
|
334
|
-
for (const namespaceId of namespaceIds) {
|
|
335
|
-
// When the caller pairs each contract namespace to its own actual node, it
|
|
336
|
-
// restricts the table check to that namespace; the full contract is still
|
|
337
|
-
// consulted for value-set / control-policy resolution.
|
|
338
|
-
if (restrictToNamespaceIds !== undefined && !restrictToNamespaceIds.has(namespaceId)) continue;
|
|
339
|
-
const ns = contract.storage.namespaces[namespaceId];
|
|
340
|
-
if (!ns) continue;
|
|
341
|
-
for (const [tableName, contractTableRaw] of Object.entries(ns.entries.table ?? {})) {
|
|
342
|
-
StorageTable.assert(
|
|
343
|
-
contractTableRaw,
|
|
344
|
-
`storage.namespaces.${namespaceId}.entries.table.${tableName}`,
|
|
345
|
-
);
|
|
346
|
-
const contractTable = contractTableRaw;
|
|
347
|
-
const tableControlPolicy = effectiveControlPolicy(
|
|
348
|
-
contractTable.control,
|
|
349
|
-
contractDefaultControl,
|
|
350
|
-
);
|
|
351
|
-
const schemaTable = schemaTables[tableName];
|
|
352
|
-
const tablePath = `storage.namespaces.${namespaceId}.entries.table.${tableName}`;
|
|
353
|
-
|
|
354
|
-
if (!schemaTable) {
|
|
355
|
-
const issue: SchemaIssue = {
|
|
356
|
-
kind: 'missing_table',
|
|
357
|
-
reason: 'not-found',
|
|
358
|
-
table: tableName,
|
|
359
|
-
namespaceId,
|
|
360
|
-
message: `Table "${tableName}" is missing from database`,
|
|
361
|
-
};
|
|
362
|
-
emitIssueAndNodeUnderControlPolicy(
|
|
363
|
-
tableControlPolicy,
|
|
364
|
-
issue,
|
|
365
|
-
{
|
|
366
|
-
status: 'fail',
|
|
367
|
-
kind: 'table',
|
|
368
|
-
name: `table ${tableName}`,
|
|
369
|
-
contractPath: tablePath,
|
|
370
|
-
code: 'missing_table',
|
|
371
|
-
message: `Table "${tableName}" is missing`,
|
|
372
|
-
expected: undefined,
|
|
373
|
-
actual: undefined,
|
|
374
|
-
children: [],
|
|
375
|
-
},
|
|
376
|
-
issues,
|
|
377
|
-
rootChildren,
|
|
378
|
-
);
|
|
379
|
-
continue;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const tableChildren = verifyTableChildren({
|
|
383
|
-
contractTable,
|
|
384
|
-
schemaTable,
|
|
385
|
-
tableName,
|
|
386
|
-
namespaceId,
|
|
387
|
-
tablePath,
|
|
388
|
-
tableControlPolicy,
|
|
389
|
-
issues,
|
|
390
|
-
strict,
|
|
391
|
-
typeMetadataRegistry,
|
|
392
|
-
codecHooks,
|
|
393
|
-
storageTypes,
|
|
394
|
-
contractStorage: contract.storage,
|
|
395
|
-
...ifDefined('normalizeDefault', normalizeDefault),
|
|
396
|
-
...ifDefined('normalizeNativeType', normalizeNativeType),
|
|
397
|
-
});
|
|
398
|
-
rootChildren.push(buildTableNode(tableName, tablePath, tableChildren));
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
if (strict) {
|
|
403
|
-
for (const tableName of Object.keys(schemaTables)) {
|
|
404
|
-
const claimed = namespaceIds.some(
|
|
405
|
-
(namespaceId) =>
|
|
406
|
-
contract.storage.namespaces[namespaceId]?.entries.table?.[tableName] !== undefined,
|
|
407
|
-
);
|
|
408
|
-
if (!claimed) {
|
|
409
|
-
const extraTableControlPolicy = effectiveControlPolicy(undefined, contractDefaultControl);
|
|
410
|
-
const issue: SchemaIssue = {
|
|
411
|
-
kind: 'extra_table',
|
|
412
|
-
reason: 'not-expected',
|
|
413
|
-
table: tableName,
|
|
414
|
-
message: `Extra table "${tableName}" found in database (not in contract)`,
|
|
415
|
-
};
|
|
416
|
-
emitIssueAndNodeUnderControlPolicy(
|
|
417
|
-
extraTableControlPolicy,
|
|
418
|
-
issue,
|
|
419
|
-
{
|
|
420
|
-
status: 'fail',
|
|
421
|
-
kind: 'table',
|
|
422
|
-
name: `table ${tableName}`,
|
|
423
|
-
contractPath: `storage.namespaces.*.entries.table.${tableName}`,
|
|
424
|
-
code: 'extra_table',
|
|
425
|
-
message: `Extra table "${tableName}" found`,
|
|
426
|
-
expected: undefined,
|
|
427
|
-
actual: undefined,
|
|
428
|
-
children: [],
|
|
429
|
-
reason: 'not-expected',
|
|
430
|
-
},
|
|
431
|
-
issues,
|
|
432
|
-
rootChildren,
|
|
433
|
-
);
|
|
434
|
-
}
|
|
435
|
-
}
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
return { issues, rootChildren };
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function verifyTableChildren(options: {
|
|
442
|
-
contractTable: StorageTable;
|
|
443
|
-
schemaTable: SqlSchemaIR['tables'][string];
|
|
444
|
-
tableName: string;
|
|
445
|
-
namespaceId: string;
|
|
446
|
-
tablePath: string;
|
|
447
|
-
tableControlPolicy: ControlPolicy;
|
|
448
|
-
issues: SchemaIssue[];
|
|
449
|
-
strict: boolean;
|
|
450
|
-
typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
|
|
451
|
-
codecHooks: Map<string, CodecControlHooks>;
|
|
452
|
-
storageTypes: Readonly<Record<string, StorageTypeInstance>>;
|
|
453
|
-
normalizeDefault?: DefaultNormalizer;
|
|
454
|
-
normalizeNativeType?: NativeTypeNormalizer;
|
|
455
|
-
contractStorage: SqlStorage;
|
|
456
|
-
}): SchemaVerificationNode[] {
|
|
457
|
-
const {
|
|
458
|
-
contractTable,
|
|
459
|
-
schemaTable,
|
|
460
|
-
tableName,
|
|
461
|
-
namespaceId,
|
|
462
|
-
tablePath,
|
|
463
|
-
tableControlPolicy,
|
|
464
|
-
issues,
|
|
465
|
-
strict,
|
|
466
|
-
typeMetadataRegistry,
|
|
467
|
-
codecHooks,
|
|
468
|
-
storageTypes,
|
|
469
|
-
normalizeDefault,
|
|
470
|
-
normalizeNativeType,
|
|
471
|
-
contractStorage,
|
|
472
|
-
} = options;
|
|
473
|
-
const tableChildren: SchemaVerificationNode[] = [];
|
|
474
|
-
const columnNodes = collectContractColumnNodes({
|
|
475
|
-
contractTable,
|
|
476
|
-
schemaTable,
|
|
477
|
-
tableName,
|
|
478
|
-
namespaceId,
|
|
479
|
-
tablePath,
|
|
480
|
-
tableControlPolicy,
|
|
481
|
-
issues,
|
|
482
|
-
strict,
|
|
483
|
-
typeMetadataRegistry,
|
|
484
|
-
codecHooks,
|
|
485
|
-
storageTypes,
|
|
486
|
-
...ifDefined('normalizeDefault', normalizeDefault),
|
|
487
|
-
...ifDefined('normalizeNativeType', normalizeNativeType),
|
|
488
|
-
});
|
|
489
|
-
if (columnNodes.length > 0) {
|
|
490
|
-
tableChildren.push(buildColumnsNode(tablePath, columnNodes));
|
|
491
|
-
}
|
|
492
|
-
if (strict) {
|
|
493
|
-
appendExtraColumnNodes({
|
|
494
|
-
contractTable,
|
|
495
|
-
schemaTable,
|
|
496
|
-
tableName,
|
|
497
|
-
namespaceId,
|
|
498
|
-
tablePath,
|
|
499
|
-
tableControlPolicy,
|
|
500
|
-
issues,
|
|
501
|
-
columnNodes,
|
|
502
|
-
});
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
if (contractTable.primaryKey) {
|
|
506
|
-
const pkStatus = verifyPrimaryKey(
|
|
507
|
-
contractTable.primaryKey,
|
|
508
|
-
schemaTable.primaryKey,
|
|
509
|
-
tableName,
|
|
510
|
-
namespaceId,
|
|
511
|
-
tableControlPolicy,
|
|
512
|
-
issues,
|
|
513
|
-
);
|
|
514
|
-
if (pkStatus === 'fail') {
|
|
515
|
-
tableChildren.push({
|
|
516
|
-
status: 'fail',
|
|
517
|
-
kind: 'primaryKey',
|
|
518
|
-
name: `primary key: ${contractTable.primaryKey.columns.join(', ')}`,
|
|
519
|
-
contractPath: `${tablePath}.primaryKey`,
|
|
520
|
-
code: 'primary_key_mismatch',
|
|
521
|
-
message: 'Primary key mismatch',
|
|
522
|
-
expected: contractTable.primaryKey,
|
|
523
|
-
actual: schemaTable.primaryKey,
|
|
524
|
-
children: [],
|
|
525
|
-
});
|
|
526
|
-
} else if (pkStatus === 'warn') {
|
|
527
|
-
tableChildren.push({
|
|
528
|
-
status: 'warn',
|
|
529
|
-
kind: 'primaryKey',
|
|
530
|
-
name: `primary key: ${contractTable.primaryKey.columns.join(', ')}`,
|
|
531
|
-
contractPath: `${tablePath}.primaryKey`,
|
|
532
|
-
code: 'primary_key_mismatch',
|
|
533
|
-
message: 'Primary key mismatch',
|
|
534
|
-
expected: contractTable.primaryKey,
|
|
535
|
-
actual: schemaTable.primaryKey,
|
|
536
|
-
children: [],
|
|
537
|
-
});
|
|
538
|
-
} else {
|
|
539
|
-
tableChildren.push({
|
|
540
|
-
status: 'pass',
|
|
541
|
-
kind: 'primaryKey',
|
|
542
|
-
name: `primary key: ${contractTable.primaryKey.columns.join(', ')}`,
|
|
543
|
-
contractPath: `${tablePath}.primaryKey`,
|
|
544
|
-
code: '',
|
|
545
|
-
message: '',
|
|
546
|
-
expected: undefined,
|
|
547
|
-
actual: undefined,
|
|
548
|
-
children: [],
|
|
549
|
-
});
|
|
550
|
-
}
|
|
551
|
-
} else if (schemaTable.primaryKey && strict) {
|
|
552
|
-
const issue: SchemaIssue = {
|
|
553
|
-
kind: 'extra_primary_key',
|
|
554
|
-
reason: 'not-expected',
|
|
555
|
-
table: tableName,
|
|
556
|
-
namespaceId,
|
|
557
|
-
message: 'Extra primary key found in database (not in contract)',
|
|
558
|
-
};
|
|
559
|
-
emitIssueAndNodeUnderControlPolicy(
|
|
560
|
-
tableControlPolicy,
|
|
561
|
-
issue,
|
|
562
|
-
{
|
|
563
|
-
status: 'fail',
|
|
564
|
-
kind: 'primaryKey',
|
|
565
|
-
name: `primary key: ${schemaTable.primaryKey.columns.join(', ')}`,
|
|
566
|
-
contractPath: `${tablePath}.primaryKey`,
|
|
567
|
-
code: 'extra_primary_key',
|
|
568
|
-
message: 'Extra primary key found',
|
|
569
|
-
expected: undefined,
|
|
570
|
-
actual: schemaTable.primaryKey,
|
|
571
|
-
children: [],
|
|
572
|
-
},
|
|
573
|
-
issues,
|
|
574
|
-
tableChildren,
|
|
575
|
-
);
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
// Verify FK constraints only for FKs with constraint: true.
|
|
579
|
-
// Always call when strict mode is on so extra-FK detection runs even if
|
|
580
|
-
// the contract has no FKs for this table.
|
|
581
|
-
const constraintFks = contractTable.foreignKeys.filter((fk) => fk.constraint === true);
|
|
582
|
-
if (constraintFks.length > 0 || strict) {
|
|
583
|
-
const fkStatuses = verifyForeignKeys(
|
|
584
|
-
constraintFks,
|
|
585
|
-
schemaTable.foreignKeys,
|
|
586
|
-
tableName,
|
|
587
|
-
namespaceId,
|
|
588
|
-
tablePath,
|
|
589
|
-
tableControlPolicy,
|
|
590
|
-
issues,
|
|
591
|
-
strict,
|
|
592
|
-
);
|
|
593
|
-
tableChildren.push(...fkStatuses);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
const uniqueStatuses = verifyUniqueConstraints(
|
|
597
|
-
contractTable.uniques,
|
|
598
|
-
schemaTable.uniques,
|
|
599
|
-
schemaTable.indexes,
|
|
600
|
-
tableName,
|
|
601
|
-
namespaceId,
|
|
602
|
-
tablePath,
|
|
603
|
-
tableControlPolicy,
|
|
604
|
-
issues,
|
|
605
|
-
strict,
|
|
606
|
-
);
|
|
607
|
-
tableChildren.push(...uniqueStatuses);
|
|
608
|
-
|
|
609
|
-
// Combine user-declared indexes with FK-backing indexes (from FKs with index: true)
|
|
610
|
-
// so the verifier treats FK-backing indexes as expected, not "extra".
|
|
611
|
-
// Deduplicate: skip FK-backing indexes already covered by a user-declared index.
|
|
612
|
-
const fkBackingIndexes = contractTable.foreignKeys
|
|
613
|
-
.filter(
|
|
614
|
-
(fk) =>
|
|
615
|
-
fk.index === true &&
|
|
616
|
-
!contractTable.indexes.some((idx) => arraysEqual(idx.columns, fk.source.columns)),
|
|
617
|
-
)
|
|
618
|
-
.map((fk) => ({ columns: fk.source.columns }));
|
|
619
|
-
const allExpectedIndexes = [...contractTable.indexes, ...fkBackingIndexes];
|
|
620
|
-
|
|
621
|
-
const indexStatuses = verifyIndexes(
|
|
622
|
-
allExpectedIndexes,
|
|
623
|
-
schemaTable.indexes,
|
|
624
|
-
schemaTable.uniques,
|
|
625
|
-
tableName,
|
|
626
|
-
namespaceId,
|
|
627
|
-
tablePath,
|
|
628
|
-
tableControlPolicy,
|
|
629
|
-
issues,
|
|
630
|
-
strict,
|
|
631
|
-
);
|
|
632
|
-
tableChildren.push(...indexStatuses);
|
|
633
|
-
|
|
634
|
-
// Verify check constraints when the contract declares checks for this table OR
|
|
635
|
-
// when strict mode is on (so extra live checks on zero-check tables are detected).
|
|
636
|
-
// schemaTable.checks carries the introspected live checks (parsed value sets).
|
|
637
|
-
const contractCheckIRs = (contractTable.checks ?? []).map((c) => ({
|
|
638
|
-
name: c.name,
|
|
639
|
-
column: c.column,
|
|
640
|
-
permittedValues: resolveValueSetValues(c.valueSet, contractStorage, `check "${c.name}"`),
|
|
641
|
-
}));
|
|
642
|
-
if (strict || contractCheckIRs.length > 0) {
|
|
643
|
-
const checkStatuses = verifyCheckConstraints(
|
|
644
|
-
contractCheckIRs,
|
|
645
|
-
schemaTable.checks ?? [],
|
|
646
|
-
tableName,
|
|
647
|
-
namespaceId,
|
|
648
|
-
tablePath,
|
|
649
|
-
tableControlPolicy,
|
|
650
|
-
issues,
|
|
651
|
-
strict,
|
|
652
|
-
);
|
|
653
|
-
tableChildren.push(...checkStatuses);
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
return tableChildren;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function collectContractColumnNodes(options: {
|
|
660
|
-
contractTable: StorageTable;
|
|
661
|
-
schemaTable: SqlSchemaIR['tables'][string];
|
|
662
|
-
tableName: string;
|
|
663
|
-
namespaceId: string;
|
|
664
|
-
tablePath: string;
|
|
665
|
-
tableControlPolicy: ControlPolicy;
|
|
666
|
-
issues: SchemaIssue[];
|
|
667
|
-
strict: boolean;
|
|
668
|
-
typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
|
|
669
|
-
codecHooks: Map<string, CodecControlHooks>;
|
|
670
|
-
storageTypes: Readonly<Record<string, StorageTypeInstance>>;
|
|
671
|
-
normalizeDefault?: DefaultNormalizer;
|
|
672
|
-
normalizeNativeType?: NativeTypeNormalizer;
|
|
673
|
-
}): SchemaVerificationNode[] {
|
|
674
|
-
const {
|
|
675
|
-
contractTable,
|
|
676
|
-
schemaTable,
|
|
677
|
-
tableName,
|
|
678
|
-
namespaceId,
|
|
679
|
-
tablePath,
|
|
680
|
-
tableControlPolicy,
|
|
681
|
-
issues,
|
|
682
|
-
strict,
|
|
683
|
-
typeMetadataRegistry,
|
|
684
|
-
codecHooks,
|
|
685
|
-
storageTypes,
|
|
686
|
-
normalizeDefault,
|
|
687
|
-
normalizeNativeType,
|
|
688
|
-
} = options;
|
|
689
|
-
const columnNodes: SchemaVerificationNode[] = [];
|
|
690
|
-
|
|
691
|
-
for (const [columnName, contractColumn] of Object.entries(contractTable.columns)) {
|
|
692
|
-
const schemaColumn = schemaTable.columns[columnName];
|
|
693
|
-
const columnPath = `${tablePath}.columns.${columnName}`;
|
|
694
|
-
|
|
695
|
-
if (!schemaColumn) {
|
|
696
|
-
const issue: SchemaIssue = {
|
|
697
|
-
kind: 'missing_column',
|
|
698
|
-
reason: 'not-found',
|
|
699
|
-
table: tableName,
|
|
700
|
-
namespaceId,
|
|
701
|
-
column: columnName,
|
|
702
|
-
message: `Column "${tableName}"."${columnName}" is missing from database`,
|
|
703
|
-
};
|
|
704
|
-
emitIssueAndNodeUnderControlPolicy(
|
|
705
|
-
tableControlPolicy,
|
|
706
|
-
issue,
|
|
707
|
-
{
|
|
708
|
-
status: 'fail',
|
|
709
|
-
kind: 'column',
|
|
710
|
-
name: `${columnName}: missing`,
|
|
711
|
-
contractPath: columnPath,
|
|
712
|
-
code: 'missing_column',
|
|
713
|
-
message: `Column "${columnName}" is missing`,
|
|
714
|
-
expected: undefined,
|
|
715
|
-
actual: undefined,
|
|
716
|
-
children: [],
|
|
717
|
-
},
|
|
718
|
-
issues,
|
|
719
|
-
columnNodes,
|
|
720
|
-
);
|
|
721
|
-
continue;
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
columnNodes.push(
|
|
725
|
-
verifyColumn({
|
|
726
|
-
tableName,
|
|
727
|
-
namespaceId,
|
|
728
|
-
columnName,
|
|
729
|
-
contractColumn,
|
|
730
|
-
schemaColumn,
|
|
731
|
-
columnPath,
|
|
732
|
-
tableControlPolicy,
|
|
733
|
-
issues,
|
|
734
|
-
strict,
|
|
735
|
-
typeMetadataRegistry,
|
|
736
|
-
codecHooks,
|
|
737
|
-
storageTypes,
|
|
738
|
-
...ifDefined('normalizeDefault', normalizeDefault),
|
|
739
|
-
...ifDefined('normalizeNativeType', normalizeNativeType),
|
|
740
|
-
}),
|
|
741
|
-
);
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
return columnNodes;
|
|
745
|
-
}
|
|
746
|
-
|
|
747
|
-
function appendExtraColumnNodes(options: {
|
|
748
|
-
contractTable: StorageTable;
|
|
749
|
-
schemaTable: SqlSchemaIR['tables'][string];
|
|
750
|
-
tableName: string;
|
|
751
|
-
namespaceId: string;
|
|
752
|
-
tablePath: string;
|
|
753
|
-
tableControlPolicy: ControlPolicy;
|
|
754
|
-
issues: SchemaIssue[];
|
|
755
|
-
columnNodes: SchemaVerificationNode[];
|
|
756
|
-
}): void {
|
|
757
|
-
const {
|
|
758
|
-
contractTable,
|
|
759
|
-
schemaTable,
|
|
760
|
-
tableName,
|
|
761
|
-
namespaceId,
|
|
762
|
-
tablePath,
|
|
763
|
-
tableControlPolicy,
|
|
764
|
-
issues,
|
|
765
|
-
columnNodes,
|
|
766
|
-
} = options;
|
|
767
|
-
for (const [columnName, { nativeType }] of Object.entries(schemaTable.columns)) {
|
|
768
|
-
if (!contractTable.columns[columnName]) {
|
|
769
|
-
const issue: SchemaIssue = {
|
|
770
|
-
kind: 'extra_column',
|
|
771
|
-
reason: 'not-expected',
|
|
772
|
-
table: tableName,
|
|
773
|
-
namespaceId,
|
|
774
|
-
column: columnName,
|
|
775
|
-
message: `Extra column "${tableName}"."${columnName}" found in database (not in contract)`,
|
|
776
|
-
};
|
|
777
|
-
emitIssueAndNodeUnderControlPolicy(
|
|
778
|
-
tableControlPolicy,
|
|
779
|
-
issue,
|
|
780
|
-
{
|
|
781
|
-
status: 'fail',
|
|
782
|
-
kind: 'column',
|
|
783
|
-
name: `${columnName}: extra`,
|
|
784
|
-
contractPath: `${tablePath}.columns.${columnName}`,
|
|
785
|
-
code: 'extra_column',
|
|
786
|
-
message: `Extra column "${columnName}" found`,
|
|
787
|
-
expected: undefined,
|
|
788
|
-
actual: nativeType,
|
|
789
|
-
children: [],
|
|
790
|
-
},
|
|
791
|
-
issues,
|
|
792
|
-
columnNodes,
|
|
793
|
-
);
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
}
|
|
797
|
-
|
|
798
|
-
function verifyColumn(options: {
|
|
799
|
-
tableName: string;
|
|
800
|
-
namespaceId: string;
|
|
801
|
-
columnName: string;
|
|
802
|
-
contractColumn: StorageTable['columns'][string];
|
|
803
|
-
schemaColumn: SqlSchemaIR['tables'][string]['columns'][string];
|
|
804
|
-
columnPath: string;
|
|
805
|
-
tableControlPolicy: ControlPolicy;
|
|
806
|
-
issues: SchemaIssue[];
|
|
807
|
-
strict: boolean;
|
|
808
|
-
typeMetadataRegistry: ReadonlyMap<string, { nativeType?: string }>;
|
|
809
|
-
codecHooks: Map<string, CodecControlHooks>;
|
|
810
|
-
storageTypes: Readonly<Record<string, StorageTypeInstance>>;
|
|
811
|
-
normalizeDefault?: DefaultNormalizer;
|
|
812
|
-
normalizeNativeType?: NativeTypeNormalizer;
|
|
813
|
-
}): SchemaVerificationNode {
|
|
814
|
-
const {
|
|
815
|
-
tableName,
|
|
816
|
-
namespaceId,
|
|
817
|
-
columnName,
|
|
818
|
-
contractColumn,
|
|
819
|
-
schemaColumn,
|
|
820
|
-
columnPath,
|
|
821
|
-
tableControlPolicy,
|
|
822
|
-
issues,
|
|
823
|
-
strict,
|
|
824
|
-
codecHooks,
|
|
825
|
-
storageTypes,
|
|
826
|
-
normalizeDefault,
|
|
827
|
-
normalizeNativeType,
|
|
828
|
-
} = options;
|
|
829
|
-
const columnChildren: SchemaVerificationNode[] = [];
|
|
830
|
-
let columnStatus: VerificationStatus = 'pass';
|
|
831
|
-
|
|
832
|
-
const resolvedContractColumn = resolveContractColumnTypeMetadata(contractColumn, storageTypes, {
|
|
833
|
-
tableName,
|
|
834
|
-
columnName,
|
|
835
|
-
});
|
|
836
|
-
const contractNativeType = renderExpectedNativeType(contractColumn, storageTypes, codecHooks, {
|
|
837
|
-
tableName,
|
|
838
|
-
columnName,
|
|
839
|
-
});
|
|
840
|
-
const schemaBaseNativeType =
|
|
841
|
-
normalizeNativeType?.(schemaColumn.nativeType) ?? schemaColumn.nativeType;
|
|
842
|
-
const schemaNativeType = schemaColumn.many ? `${schemaBaseNativeType}[]` : schemaBaseNativeType;
|
|
843
|
-
|
|
844
|
-
const typesMatch = contractNativeType === schemaNativeType;
|
|
845
|
-
|
|
846
|
-
if (!typesMatch) {
|
|
847
|
-
const issue: SchemaIssue = {
|
|
848
|
-
kind: 'type_mismatch',
|
|
849
|
-
reason: 'not-equal',
|
|
850
|
-
table: tableName,
|
|
851
|
-
namespaceId,
|
|
852
|
-
column: columnName,
|
|
853
|
-
expected: contractNativeType,
|
|
854
|
-
actual: schemaNativeType,
|
|
855
|
-
message: `Column "${tableName}"."${columnName}" has type mismatch: expected "${contractNativeType}", got "${schemaNativeType}"`,
|
|
856
|
-
};
|
|
857
|
-
const disposition = verifierDisposition(tableControlPolicy, issue.kind);
|
|
858
|
-
if (disposition !== 'suppress') {
|
|
859
|
-
issues.push(issue);
|
|
860
|
-
columnChildren.push({
|
|
861
|
-
status: disposition,
|
|
862
|
-
kind: 'type',
|
|
863
|
-
name: 'type',
|
|
864
|
-
contractPath: `${columnPath}.nativeType`,
|
|
865
|
-
code: 'type_mismatch',
|
|
866
|
-
message: `Type mismatch: expected ${contractNativeType}, got ${schemaNativeType}`,
|
|
867
|
-
expected: contractNativeType,
|
|
868
|
-
actual: schemaNativeType,
|
|
869
|
-
children: [],
|
|
870
|
-
});
|
|
871
|
-
columnStatus = disposition;
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
if (resolvedContractColumn.codecId) {
|
|
876
|
-
const typeMetadata = options.typeMetadataRegistry.get(resolvedContractColumn.codecId);
|
|
877
|
-
if (!typeMetadata) {
|
|
878
|
-
columnChildren.push({
|
|
879
|
-
status: 'warn',
|
|
880
|
-
kind: 'type',
|
|
881
|
-
name: 'type_metadata_missing',
|
|
882
|
-
contractPath: `${columnPath}.codecId`,
|
|
883
|
-
code: 'type_metadata_missing',
|
|
884
|
-
message: `codecId "${resolvedContractColumn.codecId}" not found in type metadata registry`,
|
|
885
|
-
expected: resolvedContractColumn.codecId,
|
|
886
|
-
actual: undefined,
|
|
887
|
-
children: [],
|
|
888
|
-
});
|
|
889
|
-
} else if (
|
|
890
|
-
typeMetadata.nativeType &&
|
|
891
|
-
typeMetadata.nativeType !== resolvedContractColumn.nativeType
|
|
892
|
-
) {
|
|
893
|
-
columnChildren.push({
|
|
894
|
-
status: 'warn',
|
|
895
|
-
kind: 'type',
|
|
896
|
-
name: 'type_consistency',
|
|
897
|
-
contractPath: `${columnPath}.codecId`,
|
|
898
|
-
code: 'type_consistency_warning',
|
|
899
|
-
message: `codecId "${resolvedContractColumn.codecId}" maps to nativeType "${typeMetadata.nativeType}" in registry, but contract has "${resolvedContractColumn.nativeType}"`,
|
|
900
|
-
expected: typeMetadata.nativeType,
|
|
901
|
-
actual: resolvedContractColumn.nativeType,
|
|
902
|
-
children: [],
|
|
903
|
-
});
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
|
|
907
|
-
if (contractColumn.nullable !== schemaColumn.nullable) {
|
|
908
|
-
const issue: SchemaIssue = {
|
|
909
|
-
kind: 'nullability_mismatch',
|
|
910
|
-
reason: 'not-equal',
|
|
911
|
-
table: tableName,
|
|
912
|
-
namespaceId,
|
|
913
|
-
column: columnName,
|
|
914
|
-
expected: String(contractColumn.nullable),
|
|
915
|
-
actual: String(schemaColumn.nullable),
|
|
916
|
-
message: `Column "${tableName}"."${columnName}" has nullability mismatch: expected ${contractColumn.nullable ? 'nullable' : 'not null'}, got ${schemaColumn.nullable ? 'nullable' : 'not null'}`,
|
|
917
|
-
};
|
|
918
|
-
const disposition = verifierDisposition(tableControlPolicy, issue.kind);
|
|
919
|
-
if (disposition !== 'suppress') {
|
|
920
|
-
issues.push(issue);
|
|
921
|
-
columnChildren.push({
|
|
922
|
-
status: disposition,
|
|
923
|
-
kind: 'nullability',
|
|
924
|
-
name: 'nullability',
|
|
925
|
-
contractPath: `${columnPath}.nullable`,
|
|
926
|
-
code: 'nullability_mismatch',
|
|
927
|
-
message: `Nullability mismatch: expected ${contractColumn.nullable ? 'nullable' : 'not null'}, got ${schemaColumn.nullable ? 'nullable' : 'not null'}`,
|
|
928
|
-
expected: contractColumn.nullable,
|
|
929
|
-
actual: schemaColumn.nullable,
|
|
930
|
-
children: [],
|
|
931
|
-
});
|
|
932
|
-
columnStatus = disposition;
|
|
933
|
-
}
|
|
934
|
-
}
|
|
935
|
-
|
|
936
|
-
if (contractColumn.default) {
|
|
937
|
-
if (!schemaColumn.default) {
|
|
938
|
-
const defaultDescription = describeColumnDefault(contractColumn.default);
|
|
939
|
-
const issue: SchemaIssue = {
|
|
940
|
-
kind: 'default_missing',
|
|
941
|
-
reason: 'not-found',
|
|
942
|
-
table: tableName,
|
|
943
|
-
namespaceId,
|
|
944
|
-
column: columnName,
|
|
945
|
-
expected: defaultDescription,
|
|
946
|
-
message: `Column "${tableName}"."${columnName}" should have default ${defaultDescription} but database has no default`,
|
|
947
|
-
};
|
|
948
|
-
const disposition = verifierDisposition(tableControlPolicy, issue.kind);
|
|
949
|
-
if (disposition !== 'suppress') {
|
|
950
|
-
issues.push(issue);
|
|
951
|
-
columnChildren.push({
|
|
952
|
-
status: disposition,
|
|
953
|
-
kind: 'default',
|
|
954
|
-
name: 'default',
|
|
955
|
-
contractPath: `${columnPath}.default`,
|
|
956
|
-
code: 'default_missing',
|
|
957
|
-
message: `Default missing: expected ${defaultDescription}`,
|
|
958
|
-
expected: defaultDescription,
|
|
959
|
-
actual: undefined,
|
|
960
|
-
children: [],
|
|
961
|
-
});
|
|
962
|
-
columnStatus = disposition;
|
|
963
|
-
}
|
|
964
|
-
} else if (
|
|
965
|
-
!columnDefaultsEqual(
|
|
966
|
-
contractColumn.default,
|
|
967
|
-
schemaColumn.default,
|
|
968
|
-
normalizeDefault,
|
|
969
|
-
schemaNativeType,
|
|
970
|
-
)
|
|
971
|
-
) {
|
|
972
|
-
const expectedDescription = describeColumnDefault(contractColumn.default);
|
|
973
|
-
const actualDescription = schemaColumn.default;
|
|
974
|
-
const issue: SchemaIssue = {
|
|
975
|
-
kind: 'default_mismatch',
|
|
976
|
-
reason: 'not-equal',
|
|
977
|
-
table: tableName,
|
|
978
|
-
namespaceId,
|
|
979
|
-
column: columnName,
|
|
980
|
-
expected: expectedDescription,
|
|
981
|
-
actual: actualDescription,
|
|
982
|
-
message: `Column "${tableName}"."${columnName}" has default mismatch: expected ${expectedDescription}, got ${actualDescription}`,
|
|
983
|
-
};
|
|
984
|
-
const disposition = verifierDisposition(tableControlPolicy, issue.kind);
|
|
985
|
-
if (disposition !== 'suppress') {
|
|
986
|
-
issues.push(issue);
|
|
987
|
-
columnChildren.push({
|
|
988
|
-
status: disposition,
|
|
989
|
-
kind: 'default',
|
|
990
|
-
name: 'default',
|
|
991
|
-
contractPath: `${columnPath}.default`,
|
|
992
|
-
code: 'default_mismatch',
|
|
993
|
-
message: `Default mismatch: expected ${expectedDescription}, got ${actualDescription}`,
|
|
994
|
-
expected: expectedDescription,
|
|
995
|
-
actual: actualDescription,
|
|
996
|
-
children: [],
|
|
997
|
-
});
|
|
998
|
-
columnStatus = disposition;
|
|
999
|
-
}
|
|
1000
|
-
}
|
|
1001
|
-
} else if (strict && schemaColumn.default) {
|
|
1002
|
-
const issue: SchemaIssue = {
|
|
1003
|
-
kind: 'extra_default',
|
|
1004
|
-
reason: 'not-expected',
|
|
1005
|
-
table: tableName,
|
|
1006
|
-
namespaceId,
|
|
1007
|
-
column: columnName,
|
|
1008
|
-
actual: schemaColumn.default,
|
|
1009
|
-
message: `Column "${tableName}"."${columnName}" has default ${schemaColumn.default} in database but contract specifies no default`,
|
|
1010
|
-
};
|
|
1011
|
-
const disposition = verifierDisposition(tableControlPolicy, issue.kind);
|
|
1012
|
-
if (disposition !== 'suppress') {
|
|
1013
|
-
issues.push(issue);
|
|
1014
|
-
columnChildren.push({
|
|
1015
|
-
status: disposition,
|
|
1016
|
-
kind: 'default',
|
|
1017
|
-
name: 'default',
|
|
1018
|
-
contractPath: `${columnPath}.default`,
|
|
1019
|
-
code: 'extra_default',
|
|
1020
|
-
message: `Extra default: ${schemaColumn.default}`,
|
|
1021
|
-
expected: undefined,
|
|
1022
|
-
actual: schemaColumn.default,
|
|
1023
|
-
children: [],
|
|
1024
|
-
});
|
|
1025
|
-
columnStatus = disposition;
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
// Single-pass aggregation for better performance
|
|
1030
|
-
const aggregated = aggregateChildState(columnChildren, columnStatus);
|
|
1031
|
-
const nullableText = contractColumn.nullable ? 'nullable' : 'not nullable';
|
|
1032
|
-
const columnTypeDisplay = resolvedContractColumn.codecId
|
|
1033
|
-
? `${contractNativeType} (${resolvedContractColumn.codecId})`
|
|
1034
|
-
: contractNativeType;
|
|
1035
|
-
const columnMessage = aggregated.failureMessages.join('; ');
|
|
1036
|
-
|
|
1037
|
-
return {
|
|
1038
|
-
status: aggregated.status,
|
|
1039
|
-
kind: 'column',
|
|
1040
|
-
name: `${columnName}: ${columnTypeDisplay} (${nullableText})`,
|
|
1041
|
-
contractPath: columnPath,
|
|
1042
|
-
code: aggregated.firstCode,
|
|
1043
|
-
message: columnMessage,
|
|
1044
|
-
expected: undefined,
|
|
1045
|
-
actual: undefined,
|
|
1046
|
-
children: columnChildren,
|
|
1047
|
-
};
|
|
1048
|
-
}
|
|
1049
|
-
|
|
1050
|
-
function buildColumnsNode(
|
|
1051
|
-
tablePath: string,
|
|
1052
|
-
columnNodes: SchemaVerificationNode[],
|
|
1053
|
-
): SchemaVerificationNode {
|
|
1054
|
-
return {
|
|
1055
|
-
status: aggregateChildState(columnNodes, 'pass').status,
|
|
1056
|
-
kind: 'columns',
|
|
1057
|
-
name: 'columns',
|
|
1058
|
-
contractPath: `${tablePath}.columns`,
|
|
1059
|
-
code: '',
|
|
1060
|
-
message: '',
|
|
1061
|
-
expected: undefined,
|
|
1062
|
-
actual: undefined,
|
|
1063
|
-
children: columnNodes,
|
|
1064
|
-
};
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
function buildTableNode(
|
|
1068
|
-
tableName: string,
|
|
1069
|
-
tablePath: string,
|
|
1070
|
-
tableChildren: SchemaVerificationNode[],
|
|
1071
|
-
): SchemaVerificationNode {
|
|
1072
|
-
const tableStatus = aggregateChildState(tableChildren, 'pass').status;
|
|
1073
|
-
const tableFailureMessages = tableChildren
|
|
1074
|
-
.filter((child) => child.status === 'fail' && child.message)
|
|
1075
|
-
.map((child) => child.message)
|
|
1076
|
-
.filter((msg): msg is string => typeof msg === 'string' && msg.length > 0);
|
|
1077
|
-
const tableMessage =
|
|
1078
|
-
tableStatus === 'fail' && tableFailureMessages.length > 0
|
|
1079
|
-
? `${tableFailureMessages.length} issue${tableFailureMessages.length === 1 ? '' : 's'}`
|
|
1080
|
-
: '';
|
|
1081
|
-
const tableCode =
|
|
1082
|
-
tableStatus === 'fail' && tableChildren.length > 0 && tableChildren[0]
|
|
1083
|
-
? tableChildren[0].code
|
|
1084
|
-
: '';
|
|
1085
|
-
|
|
1086
|
-
return {
|
|
1087
|
-
status: tableStatus,
|
|
1088
|
-
kind: 'table',
|
|
1089
|
-
name: `table ${tableName}`,
|
|
1090
|
-
contractPath: tablePath,
|
|
1091
|
-
code: tableCode,
|
|
1092
|
-
message: tableMessage,
|
|
1093
|
-
expected: undefined,
|
|
1094
|
-
actual: undefined,
|
|
1095
|
-
children: tableChildren,
|
|
1096
|
-
};
|
|
1097
|
-
}
|
|
1098
|
-
|
|
1099
|
-
function buildRootNode(rootChildren: SchemaVerificationNode[]): SchemaVerificationNode {
|
|
1100
|
-
return {
|
|
1101
|
-
status: aggregateChildState(rootChildren, 'pass').status,
|
|
1102
|
-
kind: 'contract',
|
|
1103
|
-
name: 'contract',
|
|
1104
|
-
contractPath: '',
|
|
1105
|
-
code: '',
|
|
1106
|
-
message: '',
|
|
1107
|
-
expected: undefined,
|
|
1108
|
-
actual: undefined,
|
|
1109
|
-
children: rootChildren,
|
|
1110
|
-
};
|
|
1111
|
-
}
|
|
1112
|
-
|
|
1113
|
-
/**
|
|
1114
|
-
* Aggregated state from child nodes, computed in a single pass.
|
|
1115
|
-
*/
|
|
1116
|
-
interface AggregatedChildState {
|
|
1117
|
-
readonly status: VerificationStatus;
|
|
1118
|
-
readonly failureMessages: readonly string[];
|
|
1119
|
-
readonly firstCode: string;
|
|
1120
|
-
}
|
|
1121
|
-
|
|
1122
|
-
/**
|
|
1123
|
-
* Aggregates status, failure messages, and code from children in a single pass.
|
|
1124
|
-
* This is more efficient than calling separate functions that each iterate the array.
|
|
1125
|
-
*/
|
|
1126
|
-
function aggregateChildState(
|
|
1127
|
-
children: SchemaVerificationNode[],
|
|
1128
|
-
fallback: VerificationStatus,
|
|
1129
|
-
): AggregatedChildState {
|
|
1130
|
-
let status: VerificationStatus = fallback;
|
|
1131
|
-
const failureMessages: string[] = [];
|
|
1132
|
-
let firstCode = '';
|
|
1133
|
-
|
|
1134
|
-
for (const child of children) {
|
|
1135
|
-
if (child.status === 'fail') {
|
|
1136
|
-
status = 'fail';
|
|
1137
|
-
if (!firstCode) {
|
|
1138
|
-
firstCode = child.code;
|
|
1139
|
-
}
|
|
1140
|
-
if (child.message && typeof child.message === 'string' && child.message.length > 0) {
|
|
1141
|
-
failureMessages.push(child.message);
|
|
1142
|
-
}
|
|
1143
|
-
} else if (child.status === 'warn' && status !== 'fail') {
|
|
1144
|
-
status = 'warn';
|
|
1145
|
-
if (!firstCode) {
|
|
1146
|
-
firstCode = child.code;
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
}
|
|
1150
|
-
|
|
1151
|
-
return { status, failureMessages, firstCode };
|
|
1152
|
-
}
|
|
1153
|
-
|
|
1154
|
-
function validateFrameworkComponentsForExtensions(
|
|
1155
|
-
contract: Contract<SqlStorage>,
|
|
1156
|
-
frameworkComponents: ReadonlyArray<TargetBoundComponentDescriptor<'sql', string>>,
|
|
1157
|
-
): void {
|
|
1158
|
-
const contractExtensionPacks = contract.extensionPacks ?? {};
|
|
1159
|
-
for (const extensionNamespace of Object.keys(contractExtensionPacks)) {
|
|
1160
|
-
const hasComponent = frameworkComponents.some(
|
|
1161
|
-
(component) =>
|
|
1162
|
-
component.id === extensionNamespace &&
|
|
1163
|
-
(component.kind === 'extension' ||
|
|
1164
|
-
component.kind === 'adapter' ||
|
|
1165
|
-
component.kind === 'target'),
|
|
1166
|
-
);
|
|
1167
|
-
if (!hasComponent) {
|
|
1168
|
-
throw new Error(
|
|
1169
|
-
`Extension pack '${extensionNamespace}' is declared in the contract but not found in framework components. ` +
|
|
1170
|
-
'This indicates a configuration mismatch - the contract was emitted with this extension pack, ' +
|
|
1171
|
-
'but it is not provided in the current configuration.',
|
|
1172
|
-
);
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
|
|
1177
|
-
/**
|
|
1178
|
-
* Renders the expected native type for a contract column, expanding parameterized types
|
|
1179
|
-
* using codec control hooks when available.
|
|
1180
|
-
*
|
|
1181
|
-
* This function delegates to the `expandNativeType` hook if the codec provides one,
|
|
1182
|
-
* ensuring that the SQL family layer remains dialect-agnostic while allowing
|
|
1183
|
-
* target-specific adapters (like Postgres) to provide their own expansion logic.
|
|
1184
|
-
*/
|
|
1185
|
-
function renderExpectedNativeType(
|
|
1186
|
-
contractColumn: StorageColumn,
|
|
1187
|
-
storageTypes: Readonly<Record<string, StorageTypeInstance>>,
|
|
1188
|
-
codecHooks: Map<string, CodecControlHooks>,
|
|
1189
|
-
context?: {
|
|
1190
|
-
readonly tableName: string;
|
|
1191
|
-
readonly columnName: string;
|
|
1192
|
-
},
|
|
1193
|
-
): string {
|
|
1194
|
-
const { codecId, nativeType, typeParams } = resolveContractColumnTypeMetadata(
|
|
1195
|
-
contractColumn,
|
|
1196
|
-
storageTypes,
|
|
1197
|
-
context,
|
|
1198
|
-
);
|
|
1199
|
-
|
|
1200
|
-
let baseType: string;
|
|
1201
|
-
|
|
1202
|
-
// If no typeParams or codecId, return the base native type
|
|
1203
|
-
if (!typeParams || !codecId) {
|
|
1204
|
-
baseType = nativeType;
|
|
1205
|
-
} else {
|
|
1206
|
-
// Try to use the codec's expandNativeType hook if available
|
|
1207
|
-
const hooks = codecHooks.get(codecId);
|
|
1208
|
-
baseType = hooks?.expandNativeType
|
|
1209
|
-
? hooks.expandNativeType({ nativeType, codecId, typeParams })
|
|
1210
|
-
: nativeType;
|
|
1211
|
-
}
|
|
1212
|
-
|
|
1213
|
-
return contractColumn.many ? `${baseType}[]` : baseType;
|
|
1214
|
-
}
|
|
1215
|
-
|
|
1216
|
-
function resolveContractColumnTypeMetadata(
|
|
1217
|
-
contractColumn: StorageColumn,
|
|
1218
|
-
storageTypes: Readonly<Record<string, StorageTypeInstance>>,
|
|
1219
|
-
context?: {
|
|
1220
|
-
readonly tableName: string;
|
|
1221
|
-
readonly columnName: string;
|
|
1222
|
-
},
|
|
1223
|
-
): Pick<StorageColumn, 'codecId' | 'nativeType' | 'typeParams'> {
|
|
1224
|
-
if (!contractColumn.typeRef) {
|
|
1225
|
-
return contractColumn;
|
|
1226
|
-
}
|
|
1227
|
-
|
|
1228
|
-
const referencedType = storageTypes[contractColumn.typeRef];
|
|
1229
|
-
if (!referencedType) {
|
|
1230
|
-
const columnLabel = context
|
|
1231
|
-
? `Column "${context.tableName}"."${context.columnName}"`
|
|
1232
|
-
: 'Column';
|
|
1233
|
-
throw new Error(
|
|
1234
|
-
`${columnLabel} references storage type "${contractColumn.typeRef}" but it is not defined in storage.types.`,
|
|
1235
|
-
);
|
|
1236
|
-
}
|
|
1237
|
-
|
|
1238
|
-
if (isStorageTypeInstance(referencedType)) {
|
|
1239
|
-
return {
|
|
1240
|
-
codecId: referencedType.codecId,
|
|
1241
|
-
nativeType: referencedType.nativeType,
|
|
1242
|
-
typeParams: referencedType.typeParams,
|
|
1243
|
-
};
|
|
1244
|
-
}
|
|
1245
|
-
throw new Error(
|
|
1246
|
-
`Storage type "${contractColumn.typeRef}" has an unknown kind; expected a codec-typed StorageTypeInstance.`,
|
|
1247
|
-
);
|
|
1248
|
-
}
|
|
1249
|
-
|
|
1250
|
-
/**
|
|
1251
|
-
* Describes a column default for display purposes.
|
|
1252
|
-
*/
|
|
1253
|
-
function describeColumnDefault(columnDefault: ColumnDefault): string {
|
|
1254
|
-
switch (columnDefault.kind) {
|
|
1255
|
-
case 'literal':
|
|
1256
|
-
return `literal(${formatLiteralValue(columnDefault.value)})`;
|
|
1257
|
-
case 'function':
|
|
1258
|
-
return columnDefault.expression;
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
/**
|
|
1263
|
-
* Compares a contract ColumnDefault against a schema raw default string for semantic equality.
|
|
1264
|
-
*
|
|
1265
|
-
* When a normalizer is provided, the raw schema default is first normalized to a ColumnDefault
|
|
1266
|
-
* before comparison. Without a normalizer, falls back to direct string comparison against
|
|
1267
|
-
* the contract expression.
|
|
1268
|
-
*
|
|
1269
|
-
* @param contractDefault - The expected default from the contract (normalized ColumnDefault)
|
|
1270
|
-
* @param schemaDefault - The raw default expression from the database (string)
|
|
1271
|
-
* @param normalizer - Optional target-specific normalizer to convert raw defaults
|
|
1272
|
-
* @param nativeType - The column's native type, passed to normalizer for context
|
|
1273
|
-
*/
|
|
1274
|
-
function columnDefaultsEqual(
|
|
1275
|
-
contractDefault: ColumnDefault,
|
|
1276
|
-
schemaDefault: string,
|
|
1277
|
-
normalizer?: DefaultNormalizer,
|
|
1278
|
-
nativeType?: string,
|
|
1279
|
-
): boolean {
|
|
1280
|
-
// If no normalizer provided, fall back to direct string comparison
|
|
1281
|
-
if (!normalizer) {
|
|
1282
|
-
if (contractDefault.kind === 'function') {
|
|
1283
|
-
return contractDefault.expression === schemaDefault;
|
|
1284
|
-
}
|
|
1285
|
-
const normalizedValue = normalizeLiteralValue(contractDefault.value, nativeType);
|
|
1286
|
-
if (typeof normalizedValue === 'string') {
|
|
1287
|
-
return normalizedValue === schemaDefault || `'${normalizedValue}'` === schemaDefault;
|
|
1288
|
-
}
|
|
1289
|
-
return String(normalizedValue) === schemaDefault;
|
|
1290
|
-
}
|
|
1291
|
-
|
|
1292
|
-
// Normalize the raw schema default using target-specific logic
|
|
1293
|
-
const normalizedSchema = normalizer(schemaDefault, nativeType ?? '');
|
|
1294
|
-
if (!normalizedSchema) {
|
|
1295
|
-
// Normalizer couldn't parse the expression - treat as mismatch
|
|
32
|
+
export function arraysEqual(a: readonly string[], b: readonly string[]): boolean {
|
|
33
|
+
if (a.length !== b.length) {
|
|
1296
34
|
return false;
|
|
1297
35
|
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
if (contractDefault.kind !== normalizedSchema.kind) {
|
|
1301
|
-
return false;
|
|
1302
|
-
}
|
|
1303
|
-
if (contractDefault.kind === 'literal' && normalizedSchema.kind === 'literal') {
|
|
1304
|
-
const contractValue = normalizeLiteralValue(contractDefault.value, nativeType);
|
|
1305
|
-
const schemaValue = normalizeLiteralValue(normalizedSchema.value, nativeType);
|
|
1306
|
-
return literalValuesEqual(contractValue, schemaValue);
|
|
1307
|
-
}
|
|
1308
|
-
if (contractDefault.kind === 'function' && normalizedSchema.kind === 'function') {
|
|
1309
|
-
// Normalize function expressions for comparison (case-insensitive, whitespace-tolerant)
|
|
1310
|
-
const normalizeExpr = (expr: string) => expr.toLowerCase().replace(/\s+/g, '');
|
|
1311
|
-
return normalizeExpr(contractDefault.expression) === normalizeExpr(normalizedSchema.expression);
|
|
1312
|
-
}
|
|
1313
|
-
return false;
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
function isTemporalNativeType(nativeType?: string): boolean {
|
|
1317
|
-
if (!nativeType) return false;
|
|
1318
|
-
const normalized = nativeType.toLowerCase();
|
|
1319
|
-
return normalized.includes('timestamp') || normalized === 'date';
|
|
1320
|
-
}
|
|
1321
|
-
|
|
1322
|
-
function normalizeLiteralValue(value: unknown, nativeType?: string): unknown {
|
|
1323
|
-
if (value instanceof Date) {
|
|
1324
|
-
return value.toISOString();
|
|
1325
|
-
}
|
|
1326
|
-
if (typeof value === 'string' && isTemporalNativeType(nativeType)) {
|
|
1327
|
-
const parsed = new Date(value);
|
|
1328
|
-
if (!Number.isNaN(parsed.getTime())) {
|
|
1329
|
-
return parsed.toISOString();
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1332
|
-
return value;
|
|
1333
|
-
}
|
|
1334
|
-
|
|
1335
|
-
function literalValuesEqual(a: unknown, b: unknown): boolean {
|
|
1336
|
-
if (a === b) return true;
|
|
1337
|
-
if (typeof a === 'object' && a !== null && typeof b === 'object' && b !== null) {
|
|
1338
|
-
return canonicalStringify(a) === canonicalStringify(b);
|
|
1339
|
-
}
|
|
1340
|
-
if (typeof a === 'object' && a !== null && typeof b === 'string') {
|
|
1341
|
-
try {
|
|
1342
|
-
return canonicalStringify(a) === canonicalStringify(JSON.parse(b));
|
|
1343
|
-
} catch {
|
|
1344
|
-
return false;
|
|
1345
|
-
}
|
|
1346
|
-
}
|
|
1347
|
-
if (typeof a === 'string' && typeof b === 'object' && b !== null) {
|
|
1348
|
-
try {
|
|
1349
|
-
return canonicalStringify(JSON.parse(a)) === canonicalStringify(b);
|
|
1350
|
-
} catch {
|
|
36
|
+
for (let i = 0; i < a.length; i++) {
|
|
37
|
+
if (a[i] !== b[i]) {
|
|
1351
38
|
return false;
|
|
1352
39
|
}
|
|
1353
40
|
}
|
|
1354
|
-
return
|
|
1355
|
-
}
|
|
1356
|
-
|
|
1357
|
-
function formatLiteralValue(value: unknown): string {
|
|
1358
|
-
if (value instanceof Date) {
|
|
1359
|
-
return value.toISOString();
|
|
1360
|
-
}
|
|
1361
|
-
if (typeof value === 'string') {
|
|
1362
|
-
return value;
|
|
1363
|
-
}
|
|
1364
|
-
return JSON.stringify(value);
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
/**
|
|
1368
|
-
* Reads a namespace node's DDL schema name. Namespaced target nodes (Postgres
|
|
1369
|
-
* `PostgresNamespaceSchemaNode`) carry `schemaName`; a flat schema (SQLite) has
|
|
1370
|
-
* none and pairs by position as the sole namespace.
|
|
1371
|
-
*/
|
|
1372
|
-
function namespaceSchemaName(node: SqlSchemaIR): string | undefined {
|
|
1373
|
-
return blindCast<
|
|
1374
|
-
{ readonly schemaName?: string },
|
|
1375
|
-
'reading the optional namespace schemaName off a per-schema node'
|
|
1376
|
-
>(node).schemaName;
|
|
1377
|
-
}
|
|
1378
|
-
|
|
1379
|
-
/**
|
|
1380
|
-
* Returns a shallow copy of `contract` exposing only the one named namespace —
|
|
1381
|
-
* used solely to resolve that namespace's live DDL schema via the target's
|
|
1382
|
-
* expected-tree projection (never for verification, so global value-sets it may
|
|
1383
|
-
* reference are not consulted).
|
|
1384
|
-
*/
|
|
1385
|
-
function scopeContractToNamespace(
|
|
1386
|
-
contract: Contract<SqlStorage>,
|
|
1387
|
-
namespaceId: string,
|
|
1388
|
-
): Contract<SqlStorage> {
|
|
1389
|
-
const namespace = contract.storage.namespaces[namespaceId];
|
|
1390
|
-
const scopedNamespaces = namespace === undefined ? {} : { [namespaceId]: namespace };
|
|
1391
|
-
return blindCast<
|
|
1392
|
-
Contract<SqlStorage>,
|
|
1393
|
-
'narrowing storage.namespaces to one entry; the rest of the contract is preserved'
|
|
1394
|
-
>({
|
|
1395
|
-
...contract,
|
|
1396
|
-
storage: blindCast<
|
|
1397
|
-
SqlStorage,
|
|
1398
|
-
'shallow storage copy with a single-namespace map; other storage fields are preserved'
|
|
1399
|
-
>({
|
|
1400
|
-
...contract.storage,
|
|
1401
|
-
namespaces: scopedNamespaces,
|
|
1402
|
-
}),
|
|
1403
|
-
});
|
|
41
|
+
return true;
|
|
1404
42
|
}
|
|
1405
43
|
|
|
1406
44
|
/**
|
|
1407
|
-
*
|
|
1408
|
-
*
|
|
1409
|
-
*
|
|
1410
|
-
*
|
|
45
|
+
* Checks if a unique constraint requirement is satisfied by the given columns:
|
|
46
|
+
* a unique constraint with the same columns, or a unique index with the same
|
|
47
|
+
* columns (semantic satisfaction). Used by the planners to keep the
|
|
48
|
+
* "stronger satisfies weaker" behavior consistent across the control plane.
|
|
1411
49
|
*/
|
|
1412
|
-
function
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
issues: [...a.schema.issues, ...b.schema.issues],
|
|
1423
|
-
schemaDiffIssues: [...a.schema.schemaDiffIssues, ...b.schema.schemaDiffIssues],
|
|
1424
|
-
counts: {
|
|
1425
|
-
pass: a.schema.counts.pass + b.schema.counts.pass,
|
|
1426
|
-
warn: a.schema.counts.warn + b.schema.counts.warn,
|
|
1427
|
-
fail: a.schema.counts.fail + b.schema.counts.fail,
|
|
1428
|
-
totalNodes: a.schema.counts.totalNodes + b.schema.counts.totalNodes,
|
|
1429
|
-
},
|
|
1430
|
-
},
|
|
1431
|
-
};
|
|
50
|
+
export function isUniqueConstraintSatisfied(
|
|
51
|
+
uniques: readonly SqlUniqueIR[],
|
|
52
|
+
indexes: readonly SqlIndexIR[],
|
|
53
|
+
columns: readonly string[],
|
|
54
|
+
): boolean {
|
|
55
|
+
const hasConstraint = uniques.some((unique) => arraysEqual(unique.columns, columns));
|
|
56
|
+
if (hasConstraint) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
return indexes.some((index) => index.unique && arraysEqual(index.columns, columns));
|
|
1432
60
|
}
|
|
1433
61
|
|
|
1434
62
|
/**
|
|
1435
|
-
*
|
|
1436
|
-
*
|
|
1437
|
-
*
|
|
1438
|
-
* Each contract namespace is paired to the introspected namespace node holding
|
|
1439
|
-
* the same DDL schema, then `verifySqlSchema` checks that namespace's tables
|
|
1440
|
-
* against the matching actual node (a contract table under `auth` is only ever
|
|
1441
|
-
* looked up in the `auth` actual node, so a multi-schema database no longer
|
|
1442
|
-
* reports tables in other schemas as missing). The full contract is passed every
|
|
1443
|
-
* time — `restrictToNamespaceIds` scopes only which tables are checked, so
|
|
1444
|
-
* cross-namespace value-set / control-policy resolution is unaffected.
|
|
1445
|
-
*
|
|
1446
|
-
* The DDL schema of each contract namespace is read from a single-namespace
|
|
1447
|
-
* expected projection (`buildExpectedSchema`), which both callers already build
|
|
1448
|
-
* the same way. Empty contract namespaces verify nothing and are skipped.
|
|
1449
|
-
* Single-schema (one namespace) and SQLite's flat schema are one pairing —
|
|
1450
|
-
* byte-identical to the prior per-node verify.
|
|
63
|
+
* Checks if an index requirement is satisfied by the given columns: any index
|
|
64
|
+
* (unique or non-unique) with the same columns, or a unique constraint with
|
|
65
|
+
* the same columns (stronger satisfies weaker).
|
|
1451
66
|
*/
|
|
1452
|
-
export function
|
|
1453
|
-
readonly
|
|
1454
|
-
readonly
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
readonly normalizeNativeType?: NativeTypeNormalizer;
|
|
1461
|
-
}): VerifyDatabaseSchemaResult {
|
|
1462
|
-
const baseOptions = {
|
|
1463
|
-
contract: options.contract,
|
|
1464
|
-
strict: options.strict,
|
|
1465
|
-
typeMetadataRegistry: options.typeMetadataRegistry,
|
|
1466
|
-
frameworkComponents: options.frameworkComponents,
|
|
1467
|
-
...ifDefined('normalizeDefault', options.normalizeDefault),
|
|
1468
|
-
...ifDefined('normalizeNativeType', options.normalizeNativeType),
|
|
1469
|
-
};
|
|
1470
|
-
|
|
1471
|
-
const actualNodes = namespaceSchemaNodes(options.actualSchema);
|
|
1472
|
-
const actualByName = new Map<string, SqlSchemaIR>();
|
|
1473
|
-
for (const node of actualNodes) {
|
|
1474
|
-
const name = namespaceSchemaName(node);
|
|
1475
|
-
if (name !== undefined) actualByName.set(name, node);
|
|
1476
|
-
}
|
|
1477
|
-
// A flat actual schema (SQLite) has no named namespaces — it is the sole node.
|
|
1478
|
-
const soleFlatActual = actualByName.size === 0 ? actualNodes[0] : undefined;
|
|
1479
|
-
const emptyNamespace: SqlSchemaIR = { tables: {} };
|
|
1480
|
-
|
|
1481
|
-
let combined: VerifyDatabaseSchemaResult | undefined;
|
|
1482
|
-
for (const namespaceId of Object.keys(options.contract.storage.namespaces)) {
|
|
1483
|
-
const namespace = options.contract.storage.namespaces[namespaceId];
|
|
1484
|
-
if (!namespace || Object.keys(namespace.entries.table ?? {}).length === 0) continue;
|
|
1485
|
-
|
|
1486
|
-
const ddlSchema = namespaceSchemaNodes(
|
|
1487
|
-
options.buildExpectedSchema(scopeContractToNamespace(options.contract, namespaceId)),
|
|
1488
|
-
)
|
|
1489
|
-
.map(namespaceSchemaName)
|
|
1490
|
-
.find((name) => name !== undefined);
|
|
1491
|
-
const actualNode =
|
|
1492
|
-
(ddlSchema !== undefined ? actualByName.get(ddlSchema) : soleFlatActual) ?? emptyNamespace;
|
|
1493
|
-
|
|
1494
|
-
const result = verifySqlSchema({
|
|
1495
|
-
...baseOptions,
|
|
1496
|
-
schema: actualNode,
|
|
1497
|
-
restrictToNamespaceIds: new Set([namespaceId]),
|
|
1498
|
-
});
|
|
1499
|
-
combined = combined === undefined ? result : mergeVerifyResults(combined, result);
|
|
67
|
+
export function isIndexSatisfied(
|
|
68
|
+
indexes: readonly SqlIndexIR[],
|
|
69
|
+
uniques: readonly SqlUniqueIR[],
|
|
70
|
+
columns: readonly string[],
|
|
71
|
+
): boolean {
|
|
72
|
+
const hasMatchingIndex = indexes.some((index) => arraysEqual(index.columns, columns));
|
|
73
|
+
if (hasMatchingIndex) {
|
|
74
|
+
return true;
|
|
1500
75
|
}
|
|
1501
|
-
|
|
1502
|
-
return combined ?? verifySqlSchema({ ...baseOptions, schema: emptyNamespace });
|
|
76
|
+
return uniques.some((unique) => arraysEqual(unique.columns, columns));
|
|
1503
77
|
}
|