@prisma-next/sql-contract-ts 0.3.0-dev.1 → 0.3.0-dev.100

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.
@@ -0,0 +1,543 @@
1
+ import type {
2
+ ModelDefinition,
3
+ ModelField,
4
+ ModelStorage,
5
+ PrimaryKey,
6
+ SqlContract,
7
+ SqlMappings,
8
+ SqlStorage,
9
+ StorageTypeInstance,
10
+ UniqueConstraint,
11
+ } from '@prisma-next/sql-contract/types';
12
+ import { decodeContractDefaults } from '@prisma-next/sql-contract/validate';
13
+ import {
14
+ ColumnDefaultSchema,
15
+ ForeignKeySchema,
16
+ IndexSchema,
17
+ } from '@prisma-next/sql-contract/validators';
18
+ import { type } from 'arktype';
19
+ import type { O } from 'ts-toolbelt';
20
+
21
+ /**
22
+ * Structural validation schema for SqlContract using Arktype.
23
+ * This validates the shape and types of the contract structure.
24
+ */
25
+
26
+ const StorageColumnSchema = type({
27
+ nativeType: 'string',
28
+ codecId: 'string',
29
+ nullable: 'boolean',
30
+ 'typeParams?': 'Record<string, unknown>',
31
+ 'typeRef?': 'string',
32
+ 'default?': ColumnDefaultSchema,
33
+ });
34
+
35
+ const StorageTypeInstanceSchema = type.declare<StorageTypeInstance>().type({
36
+ codecId: 'string',
37
+ nativeType: 'string',
38
+ typeParams: 'Record<string, unknown>',
39
+ });
40
+
41
+ const PrimaryKeySchema = type.declare<PrimaryKey>().type({
42
+ columns: type.string.array().readonly(),
43
+ 'name?': 'string',
44
+ });
45
+
46
+ const UniqueConstraintSchema = type.declare<UniqueConstraint>().type({
47
+ columns: type.string.array().readonly(),
48
+ 'name?': 'string',
49
+ });
50
+
51
+ const StorageTableSchema = type({
52
+ columns: type({ '[string]': StorageColumnSchema }),
53
+ 'primaryKey?': PrimaryKeySchema,
54
+ uniques: UniqueConstraintSchema.array().readonly(),
55
+ indexes: IndexSchema.array().readonly(),
56
+ foreignKeys: ForeignKeySchema.array().readonly(),
57
+ });
58
+
59
+ const StorageSchema = type({
60
+ tables: type({ '[string]': StorageTableSchema }),
61
+ 'types?': type({ '[string]': StorageTypeInstanceSchema }),
62
+ });
63
+
64
+ const ModelFieldSchema = type.declare<ModelField>().type({
65
+ column: 'string',
66
+ });
67
+
68
+ const ModelStorageSchema = type.declare<ModelStorage>().type({
69
+ table: 'string',
70
+ });
71
+
72
+ const ModelSchema = type.declare<ModelDefinition>().type({
73
+ storage: ModelStorageSchema,
74
+ fields: type({ '[string]': ModelFieldSchema }),
75
+ relations: type({ '[string]': 'unknown' }),
76
+ });
77
+
78
+ const GeneratorIdSchema = type('string').narrow((value, ctx) => {
79
+ return /^[A-Za-z0-9][A-Za-z0-9_-]*$/.test(value) ? true : ctx.mustBe('a flat generator id');
80
+ });
81
+
82
+ const ExecutionMutationDefaultValueSchema = type({
83
+ kind: "'generator'",
84
+ id: GeneratorIdSchema,
85
+ 'params?': 'Record<string, unknown>',
86
+ });
87
+
88
+ const ExecutionMutationDefaultSchema = type({
89
+ ref: {
90
+ table: 'string',
91
+ column: 'string',
92
+ },
93
+ 'onCreate?': ExecutionMutationDefaultValueSchema,
94
+ 'onUpdate?': ExecutionMutationDefaultValueSchema,
95
+ });
96
+
97
+ const ExecutionSchema = type({
98
+ mutations: {
99
+ defaults: ExecutionMutationDefaultSchema.array().readonly(),
100
+ },
101
+ });
102
+
103
+ /**
104
+ * Complete SqlContract schema for structural validation.
105
+ * This validates the entire contract structure at once.
106
+ */
107
+ const SqlContractSchema = type({
108
+ 'schemaVersion?': "'1'",
109
+ target: 'string',
110
+ targetFamily: "'sql'",
111
+ storageHash: 'string',
112
+ 'executionHash?': 'string',
113
+ 'profileHash?': 'string',
114
+ 'capabilities?': 'Record<string, Record<string, boolean>>',
115
+ 'extensionPacks?': 'Record<string, unknown>',
116
+ 'meta?': 'Record<string, unknown>',
117
+ 'sources?': 'Record<string, unknown>',
118
+ models: type({ '[string]': ModelSchema }),
119
+ storage: StorageSchema,
120
+ 'execution?': ExecutionSchema,
121
+ });
122
+
123
+ /**
124
+ * Validates the structural shape of a SqlContract using Arktype.
125
+ *
126
+ * **Responsibility: Validation Only**
127
+ * This function validates that the contract has the correct structure and types.
128
+ * It does NOT normalize the contract - normalization must happen in the contract builder.
129
+ *
130
+ * The contract passed to this function must already be normalized (all required fields present).
131
+ * If normalization is needed, it should be done by the contract builder before calling this function.
132
+ *
133
+ * This ensures all required fields are present and have the correct types.
134
+ *
135
+ * @param value - The contract value to validate (typically from a JSON import)
136
+ * @returns The validated contract if structure is valid
137
+ * @throws Error if the contract structure is invalid
138
+ */
139
+ function validateContractStructure<T extends SqlContract<SqlStorage>>(
140
+ value: unknown,
141
+ ): O.Overwrite<T, { targetFamily: 'sql' }> {
142
+ // Check targetFamily first to provide a clear error message for unsupported target families
143
+ const rawValue = value as { targetFamily?: string };
144
+ if (rawValue.targetFamily !== undefined && rawValue.targetFamily !== 'sql') {
145
+ /* c8 ignore next */
146
+ throw new Error(`Unsupported target family: ${rawValue.targetFamily}`);
147
+ }
148
+
149
+ const contractResult = SqlContractSchema(value);
150
+
151
+ if (contractResult instanceof type.errors) {
152
+ const messages = contractResult.map((p: { message: string }) => p.message).join('; ');
153
+ throw new Error(`Contract structural validation failed: ${messages}`);
154
+ }
155
+
156
+ // After validation, contractResult matches the schema and preserves the input structure
157
+ // TypeScript needs an assertion here due to exactOptionalPropertyTypes differences
158
+ // between Arktype's inferred type and the generic T, but runtime-wise they're compatible
159
+ return contractResult as O.Overwrite<T, { targetFamily: 'sql' }>;
160
+ }
161
+
162
+ /**
163
+ * Computes mapping dictionaries from models and storage structures.
164
+ * Assumes valid input - validation happens separately in validateContractLogic().
165
+ *
166
+ * @param models - Models object from contract
167
+ * @param storage - Storage object from contract
168
+ * @param existingMappings - Existing mappings from contract input (optional)
169
+ * @returns Computed mappings dictionary
170
+ */
171
+ export function computeMappings(
172
+ models: Record<string, ModelDefinition>,
173
+ _storage: SqlStorage,
174
+ existingMappings?: Partial<SqlMappings>,
175
+ ): SqlMappings {
176
+ const modelToTable: Record<string, string> = {};
177
+ const tableToModel: Record<string, string> = {};
178
+ const fieldToColumn: Record<string, Record<string, string>> = {};
179
+ const columnToField: Record<string, Record<string, string>> = {};
180
+
181
+ for (const [modelName, model] of Object.entries(models)) {
182
+ const tableName = model.storage.table;
183
+ modelToTable[modelName] = tableName;
184
+ tableToModel[tableName] = modelName;
185
+
186
+ const modelFieldToColumn: Record<string, string> = {};
187
+ for (const [fieldName, field] of Object.entries(model.fields)) {
188
+ const columnName = field.column;
189
+ modelFieldToColumn[fieldName] = columnName;
190
+
191
+ if (!columnToField[tableName]) {
192
+ columnToField[tableName] = {};
193
+ }
194
+ columnToField[tableName][columnName] = fieldName;
195
+ }
196
+ fieldToColumn[modelName] = modelFieldToColumn;
197
+ }
198
+
199
+ return {
200
+ modelToTable: existingMappings?.modelToTable ?? modelToTable,
201
+ tableToModel: existingMappings?.tableToModel ?? tableToModel,
202
+ fieldToColumn: existingMappings?.fieldToColumn ?? fieldToColumn,
203
+ columnToField: existingMappings?.columnToField ?? columnToField,
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Validates logical consistency of a **structurally validated** SqlContract.
209
+ * This checks that references (e.g., foreign keys, primary keys, uniques) point to storage objects that already exist.
210
+ * Structural validation is expected to have already completed before this helper runs.
211
+ *
212
+ * Rule: keep this focused on structural consistency only; capability/feature
213
+ * gating (e.g., defaults.*) belongs in migration/runtime verification, not here.
214
+ *
215
+ * @param structurallyValidatedContract - The contract whose structure has already been validated
216
+ * @throws Error if logical validation fails
217
+ */
218
+ function validateContractLogic(structurallyValidatedContract: SqlContract<SqlStorage>): void {
219
+ const { storage, models } = structurallyValidatedContract;
220
+ const tableNames = new Set(Object.keys(storage.tables));
221
+
222
+ // Validate storage.types if present
223
+ if (storage.types) {
224
+ for (const [typeName, typeInstance] of Object.entries(storage.types)) {
225
+ // Validate typeParams is not an array (arrays are objects in JS but not valid here)
226
+ if (Array.isArray(typeInstance.typeParams)) {
227
+ throw new Error(
228
+ `Type instance "${typeName}" has invalid typeParams: must be a plain object, not an array`,
229
+ );
230
+ }
231
+ }
232
+ }
233
+
234
+ // Validate columns in all tables
235
+ for (const [tableName, table] of Object.entries(storage.tables)) {
236
+ for (const [columnName, column] of Object.entries(table.columns)) {
237
+ // Validate typeParams and typeRef are mutually exclusive
238
+ if (column.typeParams !== undefined && column.typeRef !== undefined) {
239
+ throw new Error(
240
+ `Column "${columnName}" in table "${tableName}" has both typeParams and typeRef; these are mutually exclusive`,
241
+ );
242
+ }
243
+
244
+ // Validate typeParams is not an array (arrays are objects in JS but not valid here)
245
+ if (column.typeParams !== undefined && Array.isArray(column.typeParams)) {
246
+ throw new Error(
247
+ `Column "${columnName}" in table "${tableName}" has invalid typeParams: must be a plain object, not an array`,
248
+ );
249
+ }
250
+
251
+ // Validate NOT NULL columns do not have literal null defaults
252
+ if (!column.nullable && column.default?.kind === 'literal' && column.default.value === null) {
253
+ throw new Error(
254
+ `Table "${tableName}" column "${columnName}" is NOT NULL but has a literal null default`,
255
+ );
256
+ }
257
+
258
+ // Validate typeRef points to an existing storage.types key and matches codecId/nativeType
259
+ if (column.typeRef !== undefined) {
260
+ const referencedType = storage.types?.[column.typeRef];
261
+ if (!referencedType) {
262
+ throw new Error(
263
+ `Column "${columnName}" in table "${tableName}" references non-existent type instance "${column.typeRef}" (not found in storage.types)`,
264
+ );
265
+ }
266
+
267
+ if (column.codecId !== referencedType.codecId) {
268
+ throw new Error(
269
+ `Column "${columnName}" in table "${tableName}" has codecId "${column.codecId}" but references type instance "${column.typeRef}" with codecId "${referencedType.codecId}"`,
270
+ );
271
+ }
272
+
273
+ if (column.nativeType !== referencedType.nativeType) {
274
+ throw new Error(
275
+ `Column "${columnName}" in table "${tableName}" has nativeType "${column.nativeType}" but references type instance "${column.typeRef}" with nativeType "${referencedType.nativeType}"`,
276
+ );
277
+ }
278
+ }
279
+ }
280
+ }
281
+
282
+ // Validate models
283
+ for (const [modelName, modelUnknown] of Object.entries(models)) {
284
+ const model = modelUnknown as ModelDefinition;
285
+ // Validate model has storage.table
286
+ if (!model.storage?.table) {
287
+ /* c8 ignore next */
288
+ throw new Error(`Model "${modelName}" is missing storage.table`);
289
+ }
290
+
291
+ const tableName = model.storage.table;
292
+
293
+ // Validate model's table exists in storage
294
+ if (!tableNames.has(tableName)) {
295
+ /* c8 ignore next */
296
+ throw new Error(`Model "${modelName}" references non-existent table "${tableName}"`);
297
+ }
298
+
299
+ const table = storage.tables[tableName];
300
+ if (!table) {
301
+ /* c8 ignore next */
302
+ throw new Error(`Model "${modelName}" references non-existent table "${tableName}"`);
303
+ }
304
+
305
+ // Validate model's table has a primary key
306
+ if (!table.primaryKey) {
307
+ /* c8 ignore next */
308
+ throw new Error(`Model "${modelName}" table "${tableName}" is missing a primary key`);
309
+ }
310
+
311
+ const columnNames = new Set(Object.keys(table.columns));
312
+
313
+ // Validate model fields
314
+ if (!model.fields) {
315
+ /* c8 ignore next */
316
+ throw new Error(`Model "${modelName}" is missing fields`);
317
+ }
318
+
319
+ for (const [fieldName, fieldUnknown] of Object.entries(model.fields)) {
320
+ const field = fieldUnknown as { column: string };
321
+ // Validate field has column property
322
+ if (!field.column) {
323
+ /* c8 ignore next */
324
+ throw new Error(`Model "${modelName}" field "${fieldName}" is missing column property`);
325
+ }
326
+
327
+ // Validate field's column exists in the model's backing table
328
+ if (!columnNames.has(field.column)) {
329
+ /* c8 ignore next */
330
+ throw new Error(
331
+ `Model "${modelName}" field "${fieldName}" references non-existent column "${field.column}" in table "${tableName}"`,
332
+ );
333
+ }
334
+ }
335
+
336
+ // Validate model relations have corresponding foreign keys
337
+ if (model.relations) {
338
+ for (const [relationName, relation] of Object.entries(model.relations)) {
339
+ // For now, we'll do basic validation. Full FK validation can be added later
340
+ // This would require checking that the relation's on.parentCols/childCols match FKs
341
+ if (
342
+ typeof relation === 'object' &&
343
+ relation !== null &&
344
+ 'on' in relation &&
345
+ 'to' in relation
346
+ ) {
347
+ const on = relation.on as { parentCols?: string[]; childCols?: string[] };
348
+ const cardinality = (relation as { cardinality?: string }).cardinality;
349
+ if (on.parentCols && on.childCols) {
350
+ // For 1:N relations, the foreign key is on the child table
351
+ // For N:1 relations, the foreign key is on the parent table (this table)
352
+ // For now, we'll skip validation for 1:N relations as the FK is on the child table
353
+ // and we'll validate it when we process the child model
354
+ if (cardinality === '1:N') {
355
+ // Foreign key is on the child table, skip validation here
356
+ // It will be validated when we process the child model
357
+ continue;
358
+ }
359
+
360
+ // For N:1 relations, check that there's a foreign key matching this relation
361
+ const hasMatchingFk = table.foreignKeys?.some((fk) => {
362
+ return (
363
+ fk.columns.length === on.childCols?.length &&
364
+ fk.columns.every((col, i) => col === on.childCols?.[i]) &&
365
+ fk.references.table &&
366
+ fk.references.columns.length === on.parentCols?.length &&
367
+ fk.references.columns.every((col, i) => col === on.parentCols?.[i])
368
+ );
369
+ });
370
+
371
+ if (!hasMatchingFk) {
372
+ /* c8 ignore next */
373
+ throw new Error(
374
+ `Model "${modelName}" relation "${relationName}" does not have a corresponding foreign key in table "${tableName}"`,
375
+ );
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+ }
382
+
383
+ for (const [tableName, table] of Object.entries(storage.tables)) {
384
+ const columnNames = new Set(Object.keys(table.columns));
385
+
386
+ // Validate primaryKey references existing columns
387
+ if (table.primaryKey) {
388
+ for (const colName of table.primaryKey.columns) {
389
+ if (!columnNames.has(colName)) {
390
+ /* c8 ignore next */
391
+ throw new Error(
392
+ `Table "${tableName}" primaryKey references non-existent column "${colName}"`,
393
+ );
394
+ }
395
+ }
396
+ }
397
+
398
+ // Validate unique constraints reference existing columns
399
+ for (const unique of table.uniques) {
400
+ for (const colName of unique.columns) {
401
+ if (!columnNames.has(colName)) {
402
+ /* c8 ignore next */
403
+ throw new Error(
404
+ `Table "${tableName}" unique constraint references non-existent column "${colName}"`,
405
+ );
406
+ }
407
+ }
408
+ }
409
+
410
+ // Validate indexes reference existing columns
411
+ for (const index of table.indexes) {
412
+ for (const colName of index.columns) {
413
+ if (!columnNames.has(colName)) {
414
+ /* c8 ignore next */
415
+ throw new Error(`Table "${tableName}" index references non-existent column "${colName}"`);
416
+ }
417
+ }
418
+ }
419
+
420
+ // Validate foreignKeys reference existing tables and columns
421
+ for (const fk of table.foreignKeys) {
422
+ // Validate FK columns exist in the referencing table
423
+ for (const colName of fk.columns) {
424
+ if (!columnNames.has(colName)) {
425
+ /* c8 ignore next */
426
+ throw new Error(
427
+ `Table "${tableName}" foreignKey references non-existent column "${colName}"`,
428
+ );
429
+ }
430
+ }
431
+
432
+ // Validate referenced table exists
433
+ if (!tableNames.has(fk.references.table)) {
434
+ /* c8 ignore next */
435
+ throw new Error(
436
+ `Table "${tableName}" foreignKey references non-existent table "${fk.references.table}"`,
437
+ );
438
+ }
439
+
440
+ // Validate referenced columns exist in the referenced table
441
+ const referencedTable = storage.tables[fk.references.table];
442
+ if (!referencedTable) {
443
+ /* c8 ignore next */
444
+ throw new Error(
445
+ `Table "${tableName}" foreignKey references non-existent table "${fk.references.table}"`,
446
+ );
447
+ }
448
+ const referencedColumnNames = new Set(Object.keys(referencedTable.columns));
449
+
450
+ for (const colName of fk.references.columns) {
451
+ if (!referencedColumnNames.has(colName)) {
452
+ /* c8 ignore next */
453
+ throw new Error(
454
+ `Table "${tableName}" foreignKey references non-existent column "${colName}" in table "${fk.references.table}"`,
455
+ );
456
+ }
457
+ }
458
+
459
+ if (fk.columns.length !== fk.references.columns.length) {
460
+ /* c8 ignore next */
461
+ throw new Error(
462
+ `Table "${tableName}" foreignKey column count (${fk.columns.length}) does not match referenced column count (${fk.references.columns.length})`,
463
+ );
464
+ }
465
+ }
466
+ }
467
+ }
468
+
469
+ import { normalizeContract } from '@prisma-next/sql-contract/validate';
470
+ export { normalizeContract };
471
+
472
+ /**
473
+ * Validates that a JSON import conforms to the SqlContract structure
474
+ * and returns a fully typed SqlContract.
475
+ *
476
+ * This function is specifically for validating JSON imports (e.g., from contract.json).
477
+ * Contracts created via the builder API (defineContract) are already valid and should
478
+ * not be passed to this function - use them directly without validation.
479
+ *
480
+ * Performs both structural validation (using Arktype) and logical validation
481
+ * (ensuring all references are valid).
482
+ *
483
+ *
484
+ * The type parameter `TContract` must be a fully-typed contract type (e.g., from `contract.d.ts`),
485
+ * NOT a generic `SqlContract<SqlStorage>`.
486
+ *
487
+ * **Correct:**
488
+ * ```typescript
489
+ * import type { Contract } from './contract.d';
490
+ * const contract = validateContract<Contract>(contractJson);
491
+ * ```
492
+ *
493
+ * **Incorrect:**
494
+ * ```typescript
495
+ * import type { SqlContract, SqlStorage } from '@prisma-next/sql-contract/types';
496
+ * const contract = validateContract<SqlContract<SqlStorage>>(contractJson);
497
+ * // ❌ Types will be inferred as 'unknown' - this won't work!
498
+ * ```
499
+ *
500
+ * The type parameter provides the specific table structure, column types, and model definitions.
501
+ * This function validates the runtime structure matches the type, but does not infer types
502
+ * from JSON (as JSON imports lose literal type information).
503
+ *
504
+ * @param value - The contract value to validate (must be from a JSON import, not a builder)
505
+ * @returns A validated contract matching the TContract type
506
+ * @throws Error if the contract structure or logic is invalid
507
+ */
508
+ export function validateContract<TContract extends SqlContract<SqlStorage>>(
509
+ value: unknown,
510
+ ): TContract {
511
+ // Normalize contract first (add defaults for missing fields)
512
+ const normalized = normalizeContract(value);
513
+
514
+ const structurallyValid = validateContractStructure<SqlContract<SqlStorage>>(normalized);
515
+
516
+ const contractForValidation = structurallyValid as SqlContract<SqlStorage>;
517
+
518
+ // Validate contract logic (contracts must already have fully qualified type IDs)
519
+ validateContractLogic(contractForValidation);
520
+
521
+ // Extract existing mappings (optional - will be computed if missing)
522
+ const existingMappings = (contractForValidation as { mappings?: Partial<SqlMappings> }).mappings;
523
+
524
+ // Compute mappings from models and storage
525
+ const mappings = computeMappings(
526
+ contractForValidation.models as Record<string, ModelDefinition>,
527
+ contractForValidation.storage,
528
+ existingMappings,
529
+ );
530
+
531
+ // Add default values for optional metadata fields if missing
532
+ const contractWithMappings = {
533
+ ...structurallyValid,
534
+ models: contractForValidation.models,
535
+ relations: contractForValidation.relations,
536
+ storage: contractForValidation.storage,
537
+ mappings,
538
+ };
539
+
540
+ // Type assertion: The caller provides the strict type via TContract.
541
+ // We validate the structure matches, but the precise types come from contract.d.ts
542
+ return decodeContractDefaults(contractWithMappings) as TContract;
543
+ }
@@ -0,0 +1,2 @@
1
+ export type { ContractConfig } from '@prisma-next/config/config-types';
2
+ export { typescriptContract } from '../config-types';
@@ -0,0 +1,2 @@
1
+ export type { ColumnBuilder } from '../contract-builder';
2
+ export { defineContract } from '../contract-builder';