@prisma-next/sql-contract 0.3.0-dev.14 → 0.3.0-dev.147
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-D6o_FjCJ.d.mts +171 -0
- package/dist/types-D6o_FjCJ.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 +22 -25
- package/src/exports/factories.ts +1 -11
- package/src/exports/types.ts +22 -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 +153 -30
- package/src/validate.ts +227 -0
- package/src/validators.ts +300 -50
- 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 -96
- 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 -68
- 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,23 +1,86 @@
|
|
|
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
|
-
|
|
13
|
-
StorageTable,
|
|
10
|
+
StorageTypeInstance,
|
|
14
11
|
UniqueConstraint,
|
|
15
12
|
} from './types';
|
|
16
13
|
|
|
17
|
-
|
|
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',
|
|
18
67
|
nativeType: 'string',
|
|
19
68
|
codecId: 'string',
|
|
20
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
|
+
});
|
|
79
|
+
|
|
80
|
+
const StorageTypeInstanceSchema = type.declare<StorageTypeInstance>().type({
|
|
81
|
+
codecId: 'string',
|
|
82
|
+
nativeType: 'string',
|
|
83
|
+
typeParams: 'Record<string, unknown>',
|
|
21
84
|
});
|
|
22
85
|
|
|
23
86
|
const PrimaryKeySchema = type.declare<PrimaryKey>().type({
|
|
@@ -30,23 +93,34 @@ const UniqueConstraintSchema = type.declare<UniqueConstraint>().type({
|
|
|
30
93
|
'name?': 'string',
|
|
31
94
|
});
|
|
32
95
|
|
|
33
|
-
const IndexSchema = type
|
|
96
|
+
export const IndexSchema = type({
|
|
34
97
|
columns: type.string.array().readonly(),
|
|
35
98
|
'name?': 'string',
|
|
99
|
+
'using?': 'string',
|
|
100
|
+
'config?': 'Record<string, unknown>',
|
|
36
101
|
});
|
|
37
102
|
|
|
38
|
-
const ForeignKeyReferencesSchema = type.declare<ForeignKeyReferences>().type({
|
|
103
|
+
export const ForeignKeyReferencesSchema = type.declare<ForeignKeyReferences>().type({
|
|
39
104
|
table: 'string',
|
|
40
105
|
columns: type.string.array().readonly(),
|
|
41
106
|
});
|
|
42
107
|
|
|
43
|
-
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({
|
|
44
113
|
columns: type.string.array().readonly(),
|
|
45
114
|
references: ForeignKeyReferencesSchema,
|
|
46
115
|
'name?': 'string',
|
|
116
|
+
'onDelete?': ReferentialActionSchema,
|
|
117
|
+
'onUpdate?': ReferentialActionSchema,
|
|
118
|
+
constraint: 'boolean',
|
|
119
|
+
index: 'boolean',
|
|
47
120
|
});
|
|
48
121
|
|
|
49
|
-
const StorageTableSchema = type
|
|
122
|
+
const StorageTableSchema = type({
|
|
123
|
+
'+': 'reject',
|
|
50
124
|
columns: type({ '[string]': StorageColumnSchema }),
|
|
51
125
|
'primaryKey?': PrimaryKeySchema,
|
|
52
126
|
uniques: UniqueConstraintSchema.array().readonly(),
|
|
@@ -54,38 +128,97 @@ const StorageTableSchema = type.declare<StorageTable>().type({
|
|
|
54
128
|
foreignKeys: ForeignKeySchema.array().readonly(),
|
|
55
129
|
});
|
|
56
130
|
|
|
57
|
-
const StorageSchema = type
|
|
131
|
+
const StorageSchema = type({
|
|
132
|
+
'+': 'reject',
|
|
133
|
+
storageHash: 'string',
|
|
58
134
|
tables: type({ '[string]': StorageTableSchema }),
|
|
135
|
+
'types?': type({ '[string]': StorageTypeInstanceSchema }),
|
|
59
136
|
});
|
|
60
137
|
|
|
61
|
-
|
|
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({
|
|
62
175
|
column: 'string',
|
|
176
|
+
'codecId?': 'string',
|
|
177
|
+
'nullable?': 'boolean',
|
|
63
178
|
});
|
|
64
179
|
|
|
65
|
-
const ModelStorageSchema = type
|
|
180
|
+
const ModelStorageSchema = type({
|
|
66
181
|
table: 'string',
|
|
182
|
+
fields: type({ '[string]': ModelStorageFieldSchema }),
|
|
67
183
|
});
|
|
68
184
|
|
|
69
|
-
const ModelSchema = type
|
|
185
|
+
const ModelSchema = type({
|
|
70
186
|
storage: ModelStorageSchema,
|
|
71
|
-
fields: type({ '[string]': ModelFieldSchema }),
|
|
72
|
-
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',
|
|
73
197
|
});
|
|
74
198
|
|
|
75
199
|
const SqlContractSchema = type({
|
|
76
|
-
'
|
|
200
|
+
'+': 'reject',
|
|
77
201
|
target: 'string',
|
|
78
202
|
targetFamily: "'sql'",
|
|
79
|
-
coreHash: 'string',
|
|
80
|
-
|
|
203
|
+
'coreHash?': 'string',
|
|
204
|
+
profileHash: 'string',
|
|
81
205
|
'capabilities?': 'Record<string, Record<string, boolean>>',
|
|
82
206
|
'extensionPacks?': 'Record<string, unknown>',
|
|
83
|
-
'meta?':
|
|
84
|
-
'
|
|
207
|
+
'meta?': ContractMetaSchema,
|
|
208
|
+
'roots?': 'Record<string, string>',
|
|
85
209
|
models: type({ '[string]': ModelSchema }),
|
|
210
|
+
'valueObjects?': 'Record<string, unknown>',
|
|
86
211
|
storage: StorageSchema,
|
|
212
|
+
'execution?': ExecutionSchema,
|
|
87
213
|
});
|
|
88
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
|
+
|
|
89
222
|
/**
|
|
90
223
|
* Validates the structural shape of SqlStorage using Arktype.
|
|
91
224
|
*
|
|
@@ -99,17 +232,10 @@ export function validateStorage(value: unknown): SqlStorage {
|
|
|
99
232
|
const messages = result.map((p: { message: string }) => p.message).join('; ');
|
|
100
233
|
throw new Error(`Storage validation failed: ${messages}`);
|
|
101
234
|
}
|
|
102
|
-
return result;
|
|
235
|
+
return result as SqlStorage;
|
|
103
236
|
}
|
|
104
237
|
|
|
105
|
-
|
|
106
|
-
* Validates the structural shape of ModelDefinition using Arktype.
|
|
107
|
-
*
|
|
108
|
-
* @param value - The model value to validate
|
|
109
|
-
* @returns The validated model if structure is valid
|
|
110
|
-
* @throws Error if the model structure is invalid
|
|
111
|
-
*/
|
|
112
|
-
export function validateModel(value: unknown): ModelDefinition {
|
|
238
|
+
export function validateModel(value: unknown): unknown {
|
|
113
239
|
const result = ModelSchema(value);
|
|
114
240
|
if (result instanceof type.errors) {
|
|
115
241
|
const messages = result.map((p: { message: string }) => p.message).join('; ');
|
|
@@ -119,37 +245,161 @@ export function validateModel(value: unknown): ModelDefinition {
|
|
|
119
245
|
}
|
|
120
246
|
|
|
121
247
|
/**
|
|
122
|
-
* Validates the structural shape of
|
|
123
|
-
*
|
|
124
|
-
* **Responsibility: Validation Only**
|
|
125
|
-
* This function validates that the contract has the correct structure and types.
|
|
126
|
-
* It does NOT normalize the contract - normalization must happen in the contract builder.
|
|
248
|
+
* Validates the structural shape of an SQL contract using Arktype.
|
|
127
249
|
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
* 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).
|
|
132
252
|
*
|
|
133
253
|
* @param value - The contract value to validate (typically from a JSON import)
|
|
134
254
|
* @returns The validated contract if structure is valid
|
|
135
|
-
* @throws
|
|
255
|
+
* @throws ContractValidationError if the contract structure is invalid
|
|
136
256
|
*/
|
|
137
|
-
export function validateSqlContract<T extends
|
|
138
|
-
|
|
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
|
+
|
|
139
265
|
const rawValue = value as { targetFamily?: string };
|
|
140
266
|
if (rawValue.targetFamily !== undefined && rawValue.targetFamily !== 'sql') {
|
|
141
|
-
throw new
|
|
267
|
+
throw new ContractValidationError(
|
|
268
|
+
`Unsupported target family: ${rawValue.targetFamily}`,
|
|
269
|
+
'structural',
|
|
270
|
+
);
|
|
142
271
|
}
|
|
143
272
|
|
|
144
273
|
const contractResult = SqlContractSchema(value);
|
|
145
274
|
|
|
146
275
|
if (contractResult instanceof type.errors) {
|
|
147
276
|
const messages = contractResult.map((p: { message: string }) => p.message).join('; ');
|
|
148
|
-
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
|
+
}
|
|
149
402
|
}
|
|
150
403
|
|
|
151
|
-
|
|
152
|
-
// TypeScript needs an assertion here due to exactOptionalPropertyTypes differences
|
|
153
|
-
// between Arktype's inferred type and the generic T, but runtime-wise they're compatible
|
|
154
|
-
return contractResult as T;
|
|
404
|
+
return errors;
|
|
155
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"}
|