@prisma-next/sql-contract 0.3.0-pr.99.5 → 0.3.0
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/LICENSE +201 -0
- package/README.md +80 -11
- package/dist/factories.d.mts +29 -0
- package/dist/factories.d.mts.map +1 -0
- package/dist/factories.mjs +64 -0
- package/dist/factories.mjs.map +1 -0
- package/dist/pack-types.d.mts +13 -0
- package/dist/pack-types.d.mts.map +1 -0
- package/dist/pack-types.mjs +1 -0
- package/dist/types-BYQMEXGG.d.mts +176 -0
- package/dist/types-BYQMEXGG.d.mts.map +1 -0
- package/dist/types-DRR5stkj.mjs +13 -0
- package/dist/types-DRR5stkj.mjs.map +1 -0
- package/dist/types.d.mts +2 -0
- package/dist/types.mjs +3 -0
- package/dist/validate.d.mts +9 -0
- package/dist/validate.d.mts.map +1 -0
- package/dist/validate.mjs +107 -0
- package/dist/validate.mjs.map +1 -0
- package/dist/validators-BjZ6lOS1.mjs +281 -0
- package/dist/validators-BjZ6lOS1.mjs.map +1 -0
- package/dist/validators.d.mts +61 -0
- package/dist/validators.d.mts.map +1 -0
- package/dist/validators.mjs +3 -0
- package/package.json +18 -21
- package/src/exports/factories.ts +1 -11
- package/src/exports/types.ts +23 -6
- package/src/exports/validate.ts +1 -0
- package/src/exports/validators.ts +1 -1
- package/src/factories.ts +29 -57
- package/src/index.ts +1 -0
- package/src/types.ts +126 -31
- package/src/validate.ts +227 -0
- package/src/validators.ts +296 -64
- package/dist/exports/factories.d.ts +0 -2
- package/dist/exports/factories.d.ts.map +0 -1
- package/dist/exports/factories.js +0 -83
- package/dist/exports/factories.js.map +0 -1
- package/dist/exports/pack-types.d.ts +0 -2
- package/dist/exports/pack-types.d.ts.map +0 -1
- package/dist/exports/pack-types.js +0 -1
- package/dist/exports/pack-types.js.map +0 -1
- package/dist/exports/types.d.ts +0 -2
- package/dist/exports/types.d.ts.map +0 -1
- package/dist/exports/types.js +0 -1
- package/dist/exports/types.js.map +0 -1
- package/dist/exports/validators.d.ts +0 -2
- package/dist/exports/validators.d.ts.map +0 -1
- package/dist/exports/validators.js +0 -109
- package/dist/exports/validators.js.map +0 -1
- package/dist/factories.d.ts +0 -38
- package/dist/factories.d.ts.map +0 -1
- package/dist/index.d.ts +0 -4
- package/dist/index.d.ts.map +0 -1
- package/dist/pack-types.d.ts +0 -10
- package/dist/pack-types.d.ts.map +0 -1
- package/dist/types.d.ts +0 -106
- package/dist/types.d.ts.map +0 -1
- package/dist/validators.d.ts +0 -35
- package/dist/validators.d.ts.map +0 -1
package/src/validate.ts
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ColumnDefaultLiteralInputValue,
|
|
3
|
+
Contract,
|
|
4
|
+
ContractField,
|
|
5
|
+
ContractModel,
|
|
6
|
+
JsonValue,
|
|
7
|
+
} from '@prisma-next/contract/types';
|
|
8
|
+
import {
|
|
9
|
+
ContractValidationError,
|
|
10
|
+
validateContract as frameworkValidateContract,
|
|
11
|
+
} from '@prisma-next/contract/validate-contract';
|
|
12
|
+
import type { CodecLookup } from '@prisma-next/framework-components/codec';
|
|
13
|
+
import type { SqlModelStorage, SqlStorage, StorageColumn, StorageTable } from './types';
|
|
14
|
+
import { validateSqlContract, validateStorageSemantics } from './validators';
|
|
15
|
+
|
|
16
|
+
type SqlValidationContract = Contract<SqlStorage, Record<string, ContractModel<SqlModelStorage>>>;
|
|
17
|
+
|
|
18
|
+
function validateModelStorageReferences(contract: SqlValidationContract): void {
|
|
19
|
+
for (const [modelName, model] of Object.entries(contract.models)) {
|
|
20
|
+
const storageTable = model.storage.table;
|
|
21
|
+
|
|
22
|
+
const table = contract.storage.tables[storageTable] as
|
|
23
|
+
| (typeof contract.storage.tables)[string]
|
|
24
|
+
| undefined;
|
|
25
|
+
if (!table) {
|
|
26
|
+
throw new ContractValidationError(
|
|
27
|
+
`Model "${modelName}" references non-existent table "${storageTable}"`,
|
|
28
|
+
'storage',
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const columnNames = new Set(Object.keys(table.columns));
|
|
33
|
+
for (const [fieldName, field] of Object.entries(model.storage.fields)) {
|
|
34
|
+
if (!columnNames.has(field.column)) {
|
|
35
|
+
throw new ContractValidationError(
|
|
36
|
+
`Model "${modelName}" field "${fieldName}" references non-existent column "${field.column}" in table "${storageTable}"`,
|
|
37
|
+
'storage',
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const JSON_NATIVE_TYPES = new Set(['json', 'jsonb']);
|
|
43
|
+
for (const [fieldName, domainField] of Object.entries(model.fields)) {
|
|
44
|
+
const f = domainField as ContractField;
|
|
45
|
+
if (f.type?.kind !== 'valueObject') continue;
|
|
46
|
+
const storageField = model.storage.fields[fieldName];
|
|
47
|
+
if (!storageField) continue;
|
|
48
|
+
const column = table.columns[storageField.column];
|
|
49
|
+
if (!column) continue;
|
|
50
|
+
if (!JSON_NATIVE_TYPES.has(column.nativeType)) {
|
|
51
|
+
throw new ContractValidationError(
|
|
52
|
+
`Model "${modelName}" field "${fieldName}" is a value object but storage column "${storageField.column}" has nativeType "${column.nativeType}" (expected json or jsonb)`,
|
|
53
|
+
'storage',
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function validateContractLogic(contract: Contract<SqlStorage>): void {
|
|
61
|
+
const tableNames = new Set(Object.keys(contract.storage.tables));
|
|
62
|
+
|
|
63
|
+
for (const [tableName, table] of Object.entries(contract.storage.tables)) {
|
|
64
|
+
const columnNames = new Set(Object.keys(table.columns));
|
|
65
|
+
|
|
66
|
+
if (table.primaryKey) {
|
|
67
|
+
for (const colName of table.primaryKey.columns) {
|
|
68
|
+
if (!columnNames.has(colName)) {
|
|
69
|
+
throw new ContractValidationError(
|
|
70
|
+
`Table "${tableName}" primaryKey references non-existent column "${colName}"`,
|
|
71
|
+
'storage',
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const unique of table.uniques) {
|
|
78
|
+
for (const colName of unique.columns) {
|
|
79
|
+
if (!columnNames.has(colName)) {
|
|
80
|
+
throw new ContractValidationError(
|
|
81
|
+
`Table "${tableName}" unique constraint references non-existent column "${colName}"`,
|
|
82
|
+
'storage',
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
for (const index of table.indexes) {
|
|
89
|
+
for (const colName of index.columns) {
|
|
90
|
+
if (!columnNames.has(colName)) {
|
|
91
|
+
throw new ContractValidationError(
|
|
92
|
+
`Table "${tableName}" index references non-existent column "${colName}"`,
|
|
93
|
+
'storage',
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const [colName, column] of Object.entries(table.columns)) {
|
|
100
|
+
if (!column.nullable && column.default?.kind === 'literal' && column.default.value === null) {
|
|
101
|
+
throw new ContractValidationError(
|
|
102
|
+
`Table "${tableName}" column "${colName}" is NOT NULL but has a literal null default`,
|
|
103
|
+
'storage',
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const fk of table.foreignKeys) {
|
|
109
|
+
for (const colName of fk.columns) {
|
|
110
|
+
if (!columnNames.has(colName)) {
|
|
111
|
+
throw new ContractValidationError(
|
|
112
|
+
`Table "${tableName}" foreignKey references non-existent column "${colName}"`,
|
|
113
|
+
'storage',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (!tableNames.has(fk.references.table)) {
|
|
119
|
+
throw new ContractValidationError(
|
|
120
|
+
`Table "${tableName}" foreignKey references non-existent table "${fk.references.table}"`,
|
|
121
|
+
'storage',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const referencedTable = contract.storage.tables[fk.references.table];
|
|
126
|
+
if (!referencedTable) continue;
|
|
127
|
+
const referencedColumnNames = new Set(Object.keys(referencedTable.columns));
|
|
128
|
+
for (const colName of fk.references.columns) {
|
|
129
|
+
if (!referencedColumnNames.has(colName)) {
|
|
130
|
+
throw new ContractValidationError(
|
|
131
|
+
`Table "${tableName}" foreignKey references non-existent column "${colName}" in table "${fk.references.table}"`,
|
|
132
|
+
'storage',
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (fk.columns.length !== fk.references.columns.length) {
|
|
138
|
+
throw new ContractValidationError(
|
|
139
|
+
`Table "${tableName}" foreignKey column count (${fk.columns.length}) does not match referenced column count (${fk.references.columns.length})`,
|
|
140
|
+
'storage',
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function validateSqlStorage(contract: Contract): void {
|
|
148
|
+
const sqlContract = validateSqlContract<SqlValidationContract>(contract);
|
|
149
|
+
validateContractLogic(sqlContract);
|
|
150
|
+
validateModelStorageReferences(sqlContract);
|
|
151
|
+
const semanticErrors = validateStorageSemantics(sqlContract.storage);
|
|
152
|
+
if (semanticErrors.length > 0) {
|
|
153
|
+
throw new ContractValidationError(
|
|
154
|
+
`Contract semantic validation failed: ${semanticErrors.join('; ')}`,
|
|
155
|
+
'storage',
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function decodeContractDefaults<T extends Contract<SqlStorage>>(
|
|
161
|
+
contract: T,
|
|
162
|
+
codecLookup: CodecLookup,
|
|
163
|
+
): T {
|
|
164
|
+
const tables = contract.storage.tables;
|
|
165
|
+
let tablesChanged = false;
|
|
166
|
+
const decodedTables: Record<string, StorageTable> = {};
|
|
167
|
+
|
|
168
|
+
for (const [tableName, table] of Object.entries(tables)) {
|
|
169
|
+
let columnsChanged = false;
|
|
170
|
+
const decodedColumns: Record<string, StorageColumn> = {};
|
|
171
|
+
|
|
172
|
+
for (const [columnName, column] of Object.entries(table.columns)) {
|
|
173
|
+
if (column.default?.kind === 'literal') {
|
|
174
|
+
const codec = codecLookup.get(column.codecId);
|
|
175
|
+
if (codec) {
|
|
176
|
+
const decodedValue = codec.decodeJson(
|
|
177
|
+
column.default.value as JsonValue,
|
|
178
|
+
) as ColumnDefaultLiteralInputValue;
|
|
179
|
+
if (decodedValue !== column.default.value) {
|
|
180
|
+
columnsChanged = true;
|
|
181
|
+
decodedColumns[columnName] = {
|
|
182
|
+
...column,
|
|
183
|
+
default: { kind: 'literal', value: decodedValue },
|
|
184
|
+
};
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
decodedColumns[columnName] = column;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
if (columnsChanged) {
|
|
193
|
+
tablesChanged = true;
|
|
194
|
+
decodedTables[tableName] = { ...table, columns: decodedColumns };
|
|
195
|
+
} else {
|
|
196
|
+
decodedTables[tableName] = table;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!tablesChanged) {
|
|
201
|
+
return contract;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
...contract,
|
|
206
|
+
storage: {
|
|
207
|
+
...contract.storage,
|
|
208
|
+
tables: decodedTables,
|
|
209
|
+
},
|
|
210
|
+
} as T;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function validateContract<TContract extends Contract<SqlStorage>>(
|
|
214
|
+
value: unknown,
|
|
215
|
+
codecLookup: CodecLookup,
|
|
216
|
+
): TContract {
|
|
217
|
+
const validated = frameworkValidateContract<TContract>(value, validateSqlStorage);
|
|
218
|
+
try {
|
|
219
|
+
return decodeContractDefaults(validated, codecLookup);
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error instanceof ContractValidationError) throw error;
|
|
222
|
+
throw new ContractValidationError(
|
|
223
|
+
error instanceof Error ? error.message : String(error),
|
|
224
|
+
'storage',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/validators.ts
CHANGED
|
@@ -1,35 +1,81 @@
|
|
|
1
|
+
import type { Contract } from '@prisma-next/contract/types';
|
|
2
|
+
import { ContractValidationError } from '@prisma-next/contract/validate-contract';
|
|
1
3
|
import { type } from 'arktype';
|
|
2
4
|
import type {
|
|
3
5
|
ForeignKey,
|
|
4
6
|
ForeignKeyReferences,
|
|
5
|
-
Index,
|
|
6
|
-
ModelDefinition,
|
|
7
|
-
ModelField,
|
|
8
|
-
ModelStorage,
|
|
9
7
|
PrimaryKey,
|
|
10
|
-
|
|
8
|
+
ReferentialAction,
|
|
11
9
|
SqlStorage,
|
|
12
|
-
StorageColumn,
|
|
13
|
-
StorageTable,
|
|
14
10
|
StorageTypeInstance,
|
|
15
11
|
UniqueConstraint,
|
|
16
12
|
} from './types';
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
14
|
+
type ColumnDefaultLiteral = {
|
|
15
|
+
readonly kind: 'literal';
|
|
16
|
+
readonly value: string | number | boolean | Record<string, unknown> | unknown[] | null;
|
|
17
|
+
};
|
|
18
|
+
type ColumnDefaultFunction = { readonly kind: 'function'; readonly expression: string };
|
|
19
|
+
const literalKindSchema = type("'literal'");
|
|
20
|
+
const functionKindSchema = type("'function'");
|
|
21
|
+
const generatorKindSchema = type("'generator'");
|
|
22
|
+
const generatorIdSchema = type('string').narrow((value, ctx) => {
|
|
23
|
+
return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value) ? true : ctx.mustBe('a flat generator id');
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
export const ColumnDefaultLiteralSchema = type.declare<ColumnDefaultLiteral>().type({
|
|
27
|
+
kind: literalKindSchema,
|
|
28
|
+
value: 'string | number | boolean | null | unknown[] | Record<string, unknown>',
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
export const ColumnDefaultFunctionSchema = type.declare<ColumnDefaultFunction>().type({
|
|
32
|
+
kind: functionKindSchema,
|
|
33
|
+
expression: 'string',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const ColumnDefaultSchema = ColumnDefaultLiteralSchema.or(ColumnDefaultFunctionSchema);
|
|
37
|
+
|
|
38
|
+
const ExecutionMutationDefaultValueSchema = type({
|
|
39
|
+
'+': 'reject',
|
|
40
|
+
kind: generatorKindSchema,
|
|
41
|
+
id: generatorIdSchema,
|
|
42
|
+
'params?': 'Record<string, unknown>',
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const ExecutionMutationDefaultSchema = type({
|
|
46
|
+
'+': 'reject',
|
|
47
|
+
ref: {
|
|
48
|
+
'+': 'reject',
|
|
49
|
+
table: 'string',
|
|
50
|
+
column: 'string',
|
|
51
|
+
},
|
|
52
|
+
'onCreate?': ExecutionMutationDefaultValueSchema,
|
|
53
|
+
'onUpdate?': ExecutionMutationDefaultValueSchema,
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const ExecutionSchema = type({
|
|
57
|
+
'+': 'reject',
|
|
58
|
+
executionHash: 'string',
|
|
59
|
+
mutations: {
|
|
60
|
+
'+': 'reject',
|
|
61
|
+
defaults: ExecutionMutationDefaultSchema.array().readonly(),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const StorageColumnSchema = type({
|
|
66
|
+
'+': 'reject',
|
|
67
|
+
nativeType: 'string',
|
|
68
|
+
codecId: 'string',
|
|
69
|
+
nullable: 'boolean',
|
|
70
|
+
'typeParams?': 'Record<string, unknown>',
|
|
71
|
+
'typeRef?': 'string',
|
|
72
|
+
'default?': ColumnDefaultSchema,
|
|
73
|
+
}).narrow((col, ctx) => {
|
|
74
|
+
if (col.typeParams !== undefined && col.typeRef !== undefined) {
|
|
75
|
+
return ctx.mustBe('a column with either typeParams or typeRef, not both');
|
|
76
|
+
}
|
|
77
|
+
return true;
|
|
78
|
+
});
|
|
33
79
|
|
|
34
80
|
const StorageTypeInstanceSchema = type.declare<StorageTypeInstance>().type({
|
|
35
81
|
codecId: 'string',
|
|
@@ -47,23 +93,34 @@ const UniqueConstraintSchema = type.declare<UniqueConstraint>().type({
|
|
|
47
93
|
'name?': 'string',
|
|
48
94
|
});
|
|
49
95
|
|
|
50
|
-
const IndexSchema = type
|
|
96
|
+
export const IndexSchema = type({
|
|
51
97
|
columns: type.string.array().readonly(),
|
|
52
98
|
'name?': 'string',
|
|
99
|
+
'using?': 'string',
|
|
100
|
+
'config?': 'Record<string, unknown>',
|
|
53
101
|
});
|
|
54
102
|
|
|
55
|
-
const ForeignKeyReferencesSchema = type.declare<ForeignKeyReferences>().type({
|
|
103
|
+
export const ForeignKeyReferencesSchema = type.declare<ForeignKeyReferences>().type({
|
|
56
104
|
table: 'string',
|
|
57
105
|
columns: type.string.array().readonly(),
|
|
58
106
|
});
|
|
59
107
|
|
|
60
|
-
const
|
|
108
|
+
export const ReferentialActionSchema = type
|
|
109
|
+
.declare<ReferentialAction>()
|
|
110
|
+
.type("'noAction' | 'restrict' | 'cascade' | 'setNull' | 'setDefault'");
|
|
111
|
+
|
|
112
|
+
export const ForeignKeySchema = type.declare<ForeignKey>().type({
|
|
61
113
|
columns: type.string.array().readonly(),
|
|
62
114
|
references: ForeignKeyReferencesSchema,
|
|
63
115
|
'name?': 'string',
|
|
116
|
+
'onDelete?': ReferentialActionSchema,
|
|
117
|
+
'onUpdate?': ReferentialActionSchema,
|
|
118
|
+
constraint: 'boolean',
|
|
119
|
+
index: 'boolean',
|
|
64
120
|
});
|
|
65
121
|
|
|
66
|
-
const StorageTableSchema = type
|
|
122
|
+
const StorageTableSchema = type({
|
|
123
|
+
'+': 'reject',
|
|
67
124
|
columns: type({ '[string]': StorageColumnSchema }),
|
|
68
125
|
'primaryKey?': PrimaryKeySchema,
|
|
69
126
|
uniques: UniqueConstraintSchema.array().readonly(),
|
|
@@ -71,39 +128,97 @@ const StorageTableSchema = type.declare<StorageTable>().type({
|
|
|
71
128
|
foreignKeys: ForeignKeySchema.array().readonly(),
|
|
72
129
|
});
|
|
73
130
|
|
|
74
|
-
const StorageSchema = type
|
|
131
|
+
const StorageSchema = type({
|
|
132
|
+
'+': 'reject',
|
|
133
|
+
storageHash: 'string',
|
|
75
134
|
tables: type({ '[string]': StorageTableSchema }),
|
|
76
135
|
'types?': type({ '[string]': StorageTypeInstanceSchema }),
|
|
77
136
|
});
|
|
78
137
|
|
|
79
|
-
|
|
138
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
139
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function isContractFieldType(value: unknown): boolean {
|
|
143
|
+
if (!isPlainRecord(value)) return false;
|
|
144
|
+
const kind = value['kind'];
|
|
145
|
+
if (kind === 'scalar') {
|
|
146
|
+
if (typeof value['codecId'] !== 'string') return false;
|
|
147
|
+
const typeParams = value['typeParams'];
|
|
148
|
+
if (typeParams !== undefined && !isPlainRecord(typeParams)) return false;
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
if (kind === 'valueObject') {
|
|
152
|
+
return typeof value['name'] === 'string';
|
|
153
|
+
}
|
|
154
|
+
if (kind === 'union') {
|
|
155
|
+
const members = value['members'];
|
|
156
|
+
if (!Array.isArray(members)) return false;
|
|
157
|
+
return members.every((m) => isContractFieldType(m));
|
|
158
|
+
}
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ContractFieldTypeSchema = type('unknown').narrow((value, ctx) =>
|
|
163
|
+
isContractFieldType(value) ? true : ctx.mustBe('scalar, valueObject, or union field type'),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const ModelFieldSchema = type({
|
|
167
|
+
'+': 'reject',
|
|
168
|
+
nullable: 'boolean',
|
|
169
|
+
type: ContractFieldTypeSchema,
|
|
170
|
+
'many?': 'true',
|
|
171
|
+
'dict?': 'true',
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
const ModelStorageFieldSchema = type({
|
|
80
175
|
column: 'string',
|
|
176
|
+
'codecId?': 'string',
|
|
177
|
+
'nullable?': 'boolean',
|
|
81
178
|
});
|
|
82
179
|
|
|
83
|
-
const ModelStorageSchema = type
|
|
180
|
+
const ModelStorageSchema = type({
|
|
84
181
|
table: 'string',
|
|
182
|
+
fields: type({ '[string]': ModelStorageFieldSchema }),
|
|
85
183
|
});
|
|
86
184
|
|
|
87
|
-
const ModelSchema = type
|
|
185
|
+
const ModelSchema = type({
|
|
88
186
|
storage: ModelStorageSchema,
|
|
89
|
-
fields: type({ '[string]': ModelFieldSchema }),
|
|
90
|
-
relations: type({ '[string]': 'unknown' }),
|
|
187
|
+
'fields?': type({ '[string]': ModelFieldSchema }),
|
|
188
|
+
'relations?': type({ '[string]': 'unknown' }),
|
|
189
|
+
'discriminator?': 'unknown',
|
|
190
|
+
'variants?': 'unknown',
|
|
191
|
+
'base?': 'string',
|
|
192
|
+
'owner?': 'string',
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const ContractMetaSchema = type({
|
|
196
|
+
'[string]': 'unknown',
|
|
91
197
|
});
|
|
92
198
|
|
|
93
199
|
const SqlContractSchema = type({
|
|
94
|
-
'
|
|
200
|
+
'+': 'reject',
|
|
95
201
|
target: 'string',
|
|
96
202
|
targetFamily: "'sql'",
|
|
97
|
-
coreHash: 'string',
|
|
98
|
-
|
|
203
|
+
'coreHash?': 'string',
|
|
204
|
+
profileHash: 'string',
|
|
99
205
|
'capabilities?': 'Record<string, Record<string, boolean>>',
|
|
100
206
|
'extensionPacks?': 'Record<string, unknown>',
|
|
101
|
-
'meta?':
|
|
102
|
-
'
|
|
207
|
+
'meta?': ContractMetaSchema,
|
|
208
|
+
'roots?': 'Record<string, string>',
|
|
103
209
|
models: type({ '[string]': ModelSchema }),
|
|
210
|
+
'valueObjects?': 'Record<string, unknown>',
|
|
104
211
|
storage: StorageSchema,
|
|
212
|
+
'execution?': ExecutionSchema,
|
|
105
213
|
});
|
|
106
214
|
|
|
215
|
+
// NOTE: StorageColumnSchema, StorageTableSchema, and StorageSchema use bare type()
|
|
216
|
+
// instead of type.declare<T>().type() because the ColumnDefault union's value field
|
|
217
|
+
// includes bigint | Date (runtime-only types after decoding) which cannot be expressed
|
|
218
|
+
// in Arktype's JSON validation DSL. The `as SqlStorage` cast in validateStorage() bridges
|
|
219
|
+
// the gap between the JSON-safe Arktype output and the runtime TypeScript type.
|
|
220
|
+
// See decodeContractDefaults() in validate.ts for the decoding step.
|
|
221
|
+
|
|
107
222
|
/**
|
|
108
223
|
* Validates the structural shape of SqlStorage using Arktype.
|
|
109
224
|
*
|
|
@@ -117,17 +232,10 @@ export function validateStorage(value: unknown): SqlStorage {
|
|
|
117
232
|
const messages = result.map((p: { message: string }) => p.message).join('; ');
|
|
118
233
|
throw new Error(`Storage validation failed: ${messages}`);
|
|
119
234
|
}
|
|
120
|
-
return result;
|
|
235
|
+
return result as SqlStorage;
|
|
121
236
|
}
|
|
122
237
|
|
|
123
|
-
|
|
124
|
-
* Validates the structural shape of ModelDefinition using Arktype.
|
|
125
|
-
*
|
|
126
|
-
* @param value - The model value to validate
|
|
127
|
-
* @returns The validated model if structure is valid
|
|
128
|
-
* @throws Error if the model structure is invalid
|
|
129
|
-
*/
|
|
130
|
-
export function validateModel(value: unknown): ModelDefinition {
|
|
238
|
+
export function validateModel(value: unknown): unknown {
|
|
131
239
|
const result = ModelSchema(value);
|
|
132
240
|
if (result instanceof type.errors) {
|
|
133
241
|
const messages = result.map((p: { message: string }) => p.message).join('; ');
|
|
@@ -137,37 +245,161 @@ export function validateModel(value: unknown): ModelDefinition {
|
|
|
137
245
|
}
|
|
138
246
|
|
|
139
247
|
/**
|
|
140
|
-
* Validates the structural shape of
|
|
248
|
+
* Validates the structural shape of an SQL contract using Arktype.
|
|
141
249
|
*
|
|
142
|
-
*
|
|
143
|
-
*
|
|
144
|
-
* It does NOT normalize the contract - normalization must happen in the contract builder.
|
|
145
|
-
*
|
|
146
|
-
* The contract passed to this function must already be normalized (all required fields present).
|
|
147
|
-
* If normalization is needed, it should be done by the contract builder before calling this function.
|
|
148
|
-
*
|
|
149
|
-
* This ensures all required fields are present and have the correct types.
|
|
250
|
+
* Ensures all required fields are present and have the correct types,
|
|
251
|
+
* including SQL-specific storage structure (tables, columns, constraints).
|
|
150
252
|
*
|
|
151
253
|
* @param value - The contract value to validate (typically from a JSON import)
|
|
152
254
|
* @returns The validated contract if structure is valid
|
|
153
|
-
* @throws
|
|
255
|
+
* @throws ContractValidationError if the contract structure is invalid
|
|
154
256
|
*/
|
|
155
|
-
export function validateSqlContract<T extends
|
|
156
|
-
|
|
257
|
+
export function validateSqlContract<T extends Contract<SqlStorage>>(value: unknown): T {
|
|
258
|
+
if (typeof value !== 'object' || value === null) {
|
|
259
|
+
throw new ContractValidationError(
|
|
260
|
+
'Contract structural validation failed: value must be an object',
|
|
261
|
+
'structural',
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
157
265
|
const rawValue = value as { targetFamily?: string };
|
|
158
266
|
if (rawValue.targetFamily !== undefined && rawValue.targetFamily !== 'sql') {
|
|
159
|
-
throw new
|
|
267
|
+
throw new ContractValidationError(
|
|
268
|
+
`Unsupported target family: ${rawValue.targetFamily}`,
|
|
269
|
+
'structural',
|
|
270
|
+
);
|
|
160
271
|
}
|
|
161
272
|
|
|
162
273
|
const contractResult = SqlContractSchema(value);
|
|
163
274
|
|
|
164
275
|
if (contractResult instanceof type.errors) {
|
|
165
276
|
const messages = contractResult.map((p: { message: string }) => p.message).join('; ');
|
|
166
|
-
throw new
|
|
277
|
+
throw new ContractValidationError(
|
|
278
|
+
`Contract structural validation failed: ${messages}`,
|
|
279
|
+
'structural',
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Arktype's inferred output type differs from T due to exactOptionalPropertyTypes
|
|
284
|
+
// and branded hash types — the runtime value is structurally compatible after validation
|
|
285
|
+
return contractResult as unknown as T;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Validates semantic constraints on SqlStorage that cannot be expressed in Arktype schemas.
|
|
290
|
+
*
|
|
291
|
+
* Returns an array of human-readable error strings. Empty array = valid.
|
|
292
|
+
*
|
|
293
|
+
* Currently checks:
|
|
294
|
+
* - duplicate named primary key / unique / index / foreign key objects within a table
|
|
295
|
+
* - duplicate unique, index, or foreign key declarations within a table
|
|
296
|
+
* - `setNull` referential action on a non-nullable FK column (would fail at runtime)
|
|
297
|
+
* - `setDefault` referential action on a non-nullable FK column without a DEFAULT (would fail at runtime)
|
|
298
|
+
*/
|
|
299
|
+
export function validateStorageSemantics(storage: SqlStorage): string[] {
|
|
300
|
+
const errors: string[] = [];
|
|
301
|
+
|
|
302
|
+
for (const [tableName, table] of Object.entries(storage.tables)) {
|
|
303
|
+
const namedObjects = new Map<string, string[]>();
|
|
304
|
+
const registerNamedObject = (kind: string, name: string | undefined) => {
|
|
305
|
+
if (!name) return;
|
|
306
|
+
namedObjects.set(name, [...(namedObjects.get(name) ?? []), kind]);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
registerNamedObject('primary key', table.primaryKey?.name);
|
|
310
|
+
for (const unique of table.uniques) {
|
|
311
|
+
registerNamedObject('unique constraint', unique.name);
|
|
312
|
+
}
|
|
313
|
+
for (const index of table.indexes) {
|
|
314
|
+
registerNamedObject('index', index.name);
|
|
315
|
+
}
|
|
316
|
+
for (const fk of table.foreignKeys) {
|
|
317
|
+
registerNamedObject('foreign key', fk.name);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
for (const [name, kinds] of namedObjects) {
|
|
321
|
+
if (kinds.length > 1) {
|
|
322
|
+
errors.push(
|
|
323
|
+
`Table "${tableName}": named object "${name}" is declared multiple times (${kinds.join(', ')})`,
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const seenUniqueDefinitions = new Set<string>();
|
|
329
|
+
for (const unique of table.uniques) {
|
|
330
|
+
const signature = JSON.stringify({ columns: unique.columns });
|
|
331
|
+
if (seenUniqueDefinitions.has(signature)) {
|
|
332
|
+
errors.push(
|
|
333
|
+
`Table "${tableName}": duplicate unique constraint definition on columns [${unique.columns.join(', ')}]`,
|
|
334
|
+
);
|
|
335
|
+
continue;
|
|
336
|
+
}
|
|
337
|
+
seenUniqueDefinitions.add(signature);
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const seenIndexDefinitions = new Set<string>();
|
|
341
|
+
for (const index of table.indexes) {
|
|
342
|
+
const signature = JSON.stringify({
|
|
343
|
+
columns: index.columns,
|
|
344
|
+
using: index.using ?? null,
|
|
345
|
+
config: index.config ?? null,
|
|
346
|
+
});
|
|
347
|
+
if (seenIndexDefinitions.has(signature)) {
|
|
348
|
+
errors.push(
|
|
349
|
+
`Table "${tableName}": duplicate index definition on columns [${index.columns.join(', ')}]`,
|
|
350
|
+
);
|
|
351
|
+
continue;
|
|
352
|
+
}
|
|
353
|
+
seenIndexDefinitions.add(signature);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const seenForeignKeyDefinitions = new Set<string>();
|
|
357
|
+
for (const fk of table.foreignKeys) {
|
|
358
|
+
const signature = JSON.stringify({
|
|
359
|
+
columns: fk.columns,
|
|
360
|
+
references: fk.references,
|
|
361
|
+
onDelete: fk.onDelete ?? null,
|
|
362
|
+
onUpdate: fk.onUpdate ?? null,
|
|
363
|
+
constraint: fk.constraint,
|
|
364
|
+
index: fk.index,
|
|
365
|
+
});
|
|
366
|
+
if (seenForeignKeyDefinitions.has(signature)) {
|
|
367
|
+
errors.push(
|
|
368
|
+
`Table "${tableName}": duplicate foreign key definition on columns [${fk.columns.join(', ')}]`,
|
|
369
|
+
);
|
|
370
|
+
continue;
|
|
371
|
+
}
|
|
372
|
+
seenForeignKeyDefinitions.add(signature);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
for (const fk of table.foreignKeys) {
|
|
376
|
+
for (const colName of fk.columns) {
|
|
377
|
+
const column = table.columns[colName];
|
|
378
|
+
if (!column) continue;
|
|
379
|
+
|
|
380
|
+
if (fk.onDelete === 'setNull' && !column.nullable) {
|
|
381
|
+
errors.push(
|
|
382
|
+
`Table "${tableName}": onDelete setNull on foreign key column "${colName}" which is NOT NULL`,
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
if (fk.onUpdate === 'setNull' && !column.nullable) {
|
|
386
|
+
errors.push(
|
|
387
|
+
`Table "${tableName}": onUpdate setNull on foreign key column "${colName}" which is NOT NULL`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
if (fk.onDelete === 'setDefault' && !column.nullable && column.default === undefined) {
|
|
391
|
+
errors.push(
|
|
392
|
+
`Table "${tableName}": onDelete setDefault on foreign key column "${colName}" which is NOT NULL and has no DEFAULT`,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
if (fk.onUpdate === 'setDefault' && !column.nullable && column.default === undefined) {
|
|
396
|
+
errors.push(
|
|
397
|
+
`Table "${tableName}": onUpdate setDefault on foreign key column "${colName}" which is NOT NULL and has no DEFAULT`,
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
}
|
|
167
402
|
}
|
|
168
403
|
|
|
169
|
-
|
|
170
|
-
// TypeScript needs an assertion here due to exactOptionalPropertyTypes differences
|
|
171
|
-
// between Arktype's inferred type and the generic T, but runtime-wise they're compatible
|
|
172
|
-
return contractResult as T;
|
|
404
|
+
return errors;
|
|
173
405
|
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"factories.d.ts","sourceRoot":"","sources":["../../src/exports/factories.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,GAAG,EACH,QAAQ,EACR,EAAE,EACF,KAAK,EACL,KAAK,EACL,EAAE,EACF,OAAO,EACP,KAAK,EACL,MAAM,GACP,MAAM,cAAc,CAAC"}
|