@vibeorm/parser 1.0.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.
@@ -0,0 +1,625 @@
1
+ /**
2
+ * Schema IR Validator
3
+ *
4
+ * Performs semantic validation on the parsed Schema IR,
5
+ * catching issues that the structural TypeScript types cannot express:
6
+ * missing primary keys, dangling relation references, FK type mismatches, etc.
7
+ */
8
+
9
+ import type {
10
+ Schema,
11
+ Model,
12
+ Field,
13
+ ScalarField,
14
+ EnumField,
15
+ RelationField,
16
+ } from "./types.ts";
17
+ import { PRISMA_TO_TS, PRISMA_TO_PG } from "./types.ts";
18
+
19
+ // ─── Validation Result Types ──────────────────────────────────────
20
+
21
+ export type ValidationSeverity = "error" | "warning";
22
+
23
+ export type SchemaValidationError = {
24
+ severity: ValidationSeverity;
25
+ path: string;
26
+ code: string;
27
+ message: string;
28
+ };
29
+
30
+ export type ValidationResult =
31
+ | { valid: true; warnings: SchemaValidationError[] }
32
+ | { valid: false; errors: SchemaValidationError[]; warnings: SchemaValidationError[] };
33
+
34
+ const VALID_PRISMA_TYPES = new Set<string>([
35
+ "String", "Boolean", "Int", "BigInt", "Float", "Decimal", "DateTime", "Json", "Bytes",
36
+ ]);
37
+
38
+ // ─── Public API ───────────────────────────────────────────────────
39
+
40
+ export function validateSchema(params: { schema: Schema }): ValidationResult {
41
+ const { schema } = params;
42
+ const errors: SchemaValidationError[] = [];
43
+ const warnings: SchemaValidationError[] = [];
44
+
45
+ const modelNames = new Set(schema.models.map((m) => m.name));
46
+ const enumNames = new Set(schema.enums.map((e) => e.name));
47
+
48
+ // Build lookup maps
49
+ const modelMap = new Map<string, Model>();
50
+ for (const model of schema.models) {
51
+ modelMap.set(model.name, model);
52
+ }
53
+
54
+ // ── Schema-level checks ──
55
+ validateSchemaLevel({ schema, modelNames, enumNames, errors, warnings });
56
+
57
+ // ── Enum-level checks ──
58
+ for (const enumDef of schema.enums) {
59
+ validateEnum({ enumDef, path: `enums.${enumDef.name}`, errors, warnings });
60
+ }
61
+
62
+ // ── Model-level checks ──
63
+ for (const model of schema.models) {
64
+ validateModel({
65
+ model,
66
+ modelNames,
67
+ enumNames,
68
+ modelMap,
69
+ errors,
70
+ warnings,
71
+ });
72
+ }
73
+
74
+ if (errors.length > 0) {
75
+ return { valid: false, errors, warnings };
76
+ }
77
+ return { valid: true, warnings };
78
+ }
79
+
80
+ // ─── Schema-Level Validation ──────────────────────────────────────
81
+
82
+ function validateSchemaLevel(params: {
83
+ schema: Schema;
84
+ modelNames: Set<string>;
85
+ enumNames: Set<string>;
86
+ errors: SchemaValidationError[];
87
+ warnings: SchemaValidationError[];
88
+ }): void {
89
+ const { schema, modelNames, enumNames, errors } = params;
90
+
91
+ // Check for duplicate model names
92
+ const seenModels = new Set<string>();
93
+ for (const model of schema.models) {
94
+ if (seenModels.has(model.name)) {
95
+ errors.push({
96
+ severity: "error",
97
+ path: `models.${model.name}`,
98
+ code: "DUPLICATE_MODEL_NAME",
99
+ message: `Duplicate model name "${model.name}"`,
100
+ });
101
+ }
102
+ seenModels.add(model.name);
103
+ }
104
+
105
+ // Check for duplicate enum names
106
+ const seenEnums = new Set<string>();
107
+ for (const enumDef of schema.enums) {
108
+ if (seenEnums.has(enumDef.name)) {
109
+ errors.push({
110
+ severity: "error",
111
+ path: `enums.${enumDef.name}`,
112
+ code: "DUPLICATE_ENUM_NAME",
113
+ message: `Duplicate enum name "${enumDef.name}"`,
114
+ });
115
+ }
116
+ seenEnums.add(enumDef.name);
117
+ }
118
+
119
+ // Check for model/enum name collisions
120
+ for (const name of modelNames) {
121
+ if (enumNames.has(name)) {
122
+ errors.push({
123
+ severity: "error",
124
+ path: `schema`,
125
+ code: "MODEL_ENUM_NAME_COLLISION",
126
+ message: `Model and enum share the same name "${name}"`,
127
+ });
128
+ }
129
+ }
130
+ }
131
+
132
+ // ─── Enum Validation ──────────────────────────────────────────────
133
+
134
+ function validateEnum(params: {
135
+ enumDef: Schema["enums"][number];
136
+ path: string;
137
+ errors: SchemaValidationError[];
138
+ warnings: SchemaValidationError[];
139
+ }): void {
140
+ const { enumDef, path, errors } = params;
141
+
142
+ if (enumDef.values.length === 0) {
143
+ errors.push({
144
+ severity: "error",
145
+ path,
146
+ code: "EMPTY_ENUM",
147
+ message: `Enum "${enumDef.name}" has no values`,
148
+ });
149
+ }
150
+
151
+ // Check for duplicate enum value names
152
+ const seenValues = new Set<string>();
153
+ for (const value of enumDef.values) {
154
+ if (seenValues.has(value.name)) {
155
+ errors.push({
156
+ severity: "error",
157
+ path: `${path}.${value.name}`,
158
+ code: "DUPLICATE_ENUM_VALUE",
159
+ message: `Duplicate enum value "${value.name}" in enum "${enumDef.name}"`,
160
+ });
161
+ }
162
+ seenValues.add(value.name);
163
+ }
164
+ }
165
+
166
+ // ─── Model Validation ─────────────────────────────────────────────
167
+
168
+ function validateModel(params: {
169
+ model: Model;
170
+ modelNames: Set<string>;
171
+ enumNames: Set<string>;
172
+ modelMap: Map<string, Model>;
173
+ errors: SchemaValidationError[];
174
+ warnings: SchemaValidationError[];
175
+ }): void {
176
+ const { model, modelNames, enumNames, modelMap, errors, warnings } = params;
177
+ const modelPath = `models.${model.name}`;
178
+
179
+ // Build field lookup for this model
180
+ const fieldMap = new Map<string, Field>();
181
+ const scalarEnumFieldNames = new Set<string>();
182
+
183
+ // Check for duplicate field names
184
+ const seenFields = new Set<string>();
185
+ for (const field of model.fields) {
186
+ if (seenFields.has(field.name)) {
187
+ errors.push({
188
+ severity: "error",
189
+ path: `${modelPath}.fields.${field.name}`,
190
+ code: "DUPLICATE_FIELD_NAME",
191
+ message: `Duplicate field name "${field.name}" in model "${model.name}"`,
192
+ });
193
+ }
194
+ seenFields.add(field.name);
195
+ fieldMap.set(field.name, field);
196
+ if (field.kind === "scalar" || field.kind === "enum") {
197
+ scalarEnumFieldNames.add(field.name);
198
+ }
199
+ }
200
+
201
+ // Primary key validation
202
+ if (model.primaryKey.fields.length === 0) {
203
+ errors.push({
204
+ severity: "error",
205
+ path: `${modelPath}.primaryKey`,
206
+ code: "MISSING_PRIMARY_KEY",
207
+ message: `Model "${model.name}" has no primary key -- every model must have an @id or @@id`,
208
+ });
209
+ } else {
210
+ // isComposite consistency
211
+ const shouldBeComposite = model.primaryKey.fields.length > 1;
212
+ if (model.primaryKey.isComposite !== shouldBeComposite) {
213
+ warnings.push({
214
+ severity: "warning",
215
+ path: `${modelPath}.primaryKey`,
216
+ code: "INCONSISTENT_COMPOSITE_FLAG",
217
+ message: `Model "${model.name}" has isComposite=${model.primaryKey.isComposite} but ${model.primaryKey.fields.length} PK field(s)`,
218
+ });
219
+ }
220
+
221
+ // PK fields reference actual scalar/enum fields
222
+ for (const pkField of model.primaryKey.fields) {
223
+ if (!scalarEnumFieldNames.has(pkField)) {
224
+ errors.push({
225
+ severity: "error",
226
+ path: `${modelPath}.primaryKey`,
227
+ code: "PK_FIELD_NOT_FOUND",
228
+ message: `Primary key field "${pkField}" does not exist as a scalar/enum field on model "${model.name}"`,
229
+ });
230
+ }
231
+ }
232
+ }
233
+
234
+ // Unique constraint field references
235
+ for (const uc of model.uniqueConstraints) {
236
+ for (const ucField of uc.fields) {
237
+ if (!scalarEnumFieldNames.has(ucField)) {
238
+ errors.push({
239
+ severity: "error",
240
+ path: `${modelPath}.uniqueConstraints`,
241
+ code: "UNIQUE_FIELD_NOT_FOUND",
242
+ message: `Unique constraint references field "${ucField}" which does not exist on model "${model.name}"`,
243
+ });
244
+ }
245
+ }
246
+ }
247
+
248
+ // Index field references
249
+ for (const idx of model.indexes) {
250
+ for (const idxField of idx.fields) {
251
+ if (!scalarEnumFieldNames.has(idxField)) {
252
+ errors.push({
253
+ severity: "error",
254
+ path: `${modelPath}.indexes`,
255
+ code: "INDEX_FIELD_NOT_FOUND",
256
+ message: `Index references field "${idxField}" which does not exist on model "${model.name}"`,
257
+ });
258
+ }
259
+ }
260
+ }
261
+
262
+ // Per-field validation
263
+ for (const field of model.fields) {
264
+ const fieldPath = `${modelPath}.fields.${field.name}`;
265
+
266
+ switch (field.kind) {
267
+ case "scalar":
268
+ validateScalarField({
269
+ field,
270
+ model,
271
+ fieldPath,
272
+ errors,
273
+ warnings,
274
+ });
275
+ break;
276
+ case "enum":
277
+ validateEnumField({
278
+ field,
279
+ enumNames,
280
+ fieldPath,
281
+ errors,
282
+ });
283
+ break;
284
+ case "relation":
285
+ validateRelationField({
286
+ field,
287
+ model,
288
+ modelNames,
289
+ modelMap,
290
+ fieldPath,
291
+ errors,
292
+ warnings,
293
+ });
294
+ break;
295
+ }
296
+ }
297
+ }
298
+
299
+ // ─── Scalar Field Validation ──────────────────────────────────────
300
+
301
+ function validateScalarField(params: {
302
+ field: ScalarField;
303
+ model: Model;
304
+ fieldPath: string;
305
+ errors: SchemaValidationError[];
306
+ warnings: SchemaValidationError[];
307
+ }): void {
308
+ const { field, model, fieldPath, errors, warnings } = params;
309
+
310
+ // Valid Prisma type
311
+ if (!VALID_PRISMA_TYPES.has(field.prismaType)) {
312
+ errors.push({
313
+ severity: "error",
314
+ path: fieldPath,
315
+ code: "INVALID_PRISMA_TYPE",
316
+ message: `Field "${field.name}" has invalid Prisma type "${field.prismaType}"`,
317
+ });
318
+ }
319
+
320
+ // tsType consistency (only when no nativeType override)
321
+ if (!field.nativeType) {
322
+ const expectedTs = PRISMA_TO_TS[field.prismaType];
323
+ if (expectedTs && field.tsType !== expectedTs) {
324
+ warnings.push({
325
+ severity: "warning",
326
+ path: fieldPath,
327
+ code: "TS_TYPE_MISMATCH",
328
+ message: `Field "${field.name}" has tsType "${field.tsType}" but expected "${expectedTs}" for Prisma type "${field.prismaType}"`,
329
+ });
330
+ }
331
+
332
+ const expectedPg = PRISMA_TO_PG[field.prismaType];
333
+ if (expectedPg && field.pgType !== expectedPg) {
334
+ warnings.push({
335
+ severity: "warning",
336
+ path: fieldPath,
337
+ code: "PG_TYPE_MISMATCH",
338
+ message: `Field "${field.name}" has pgType "${field.pgType}" but expected "${expectedPg}" for Prisma type "${field.prismaType}"`,
339
+ });
340
+ }
341
+ }
342
+
343
+ // @default(autoincrement()) only on Int/BigInt
344
+ if (field.default?.kind === "autoincrement") {
345
+ if (field.prismaType !== "Int" && field.prismaType !== "BigInt") {
346
+ errors.push({
347
+ severity: "error",
348
+ path: fieldPath,
349
+ code: "AUTOINCREMENT_WRONG_TYPE",
350
+ message: `Field "${field.name}" uses @default(autoincrement()) but has type "${field.prismaType}" (must be Int or BigInt)`,
351
+ });
352
+ }
353
+ }
354
+
355
+ // @updatedAt only on DateTime
356
+ if (field.isUpdatedAt && field.prismaType !== "DateTime") {
357
+ errors.push({
358
+ severity: "error",
359
+ path: fieldPath,
360
+ code: "UPDATED_AT_WRONG_TYPE",
361
+ message: `Field "${field.name}" uses @updatedAt but has type "${field.prismaType}" (must be DateTime)`,
362
+ });
363
+ }
364
+
365
+ // isId consistency with primaryKey
366
+ if (field.isId && !model.primaryKey.fields.includes(field.name)) {
367
+ warnings.push({
368
+ severity: "warning",
369
+ path: fieldPath,
370
+ code: "ID_NOT_IN_PK",
371
+ message: `Field "${field.name}" has isId=true but is not listed in model "${model.name}" primaryKey.fields`,
372
+ });
373
+ }
374
+
375
+ // dbName must be non-empty
376
+ if (!field.dbName) {
377
+ errors.push({
378
+ severity: "error",
379
+ path: fieldPath,
380
+ code: "EMPTY_DB_NAME",
381
+ message: `Field "${field.name}" has an empty dbName`,
382
+ });
383
+ }
384
+ }
385
+
386
+ // ─── Enum Field Validation ────────────────────────────────────────
387
+
388
+ function validateEnumField(params: {
389
+ field: EnumField;
390
+ enumNames: Set<string>;
391
+ fieldPath: string;
392
+ errors: SchemaValidationError[];
393
+ }): void {
394
+ const { field, enumNames, fieldPath, errors } = params;
395
+
396
+ if (!enumNames.has(field.enumName)) {
397
+ errors.push({
398
+ severity: "error",
399
+ path: fieldPath,
400
+ code: "ENUM_NOT_FOUND",
401
+ message: `Field "${field.name}" references enum "${field.enumName}" which does not exist`,
402
+ });
403
+ }
404
+
405
+ if (!field.dbName) {
406
+ errors.push({
407
+ severity: "error",
408
+ path: fieldPath,
409
+ code: "EMPTY_DB_NAME",
410
+ message: `Field "${field.name}" has an empty dbName`,
411
+ });
412
+ }
413
+ }
414
+
415
+ // ─── Relation Field Validation ────────────────────────────────────
416
+
417
+ function validateRelationField(params: {
418
+ field: RelationField;
419
+ model: Model;
420
+ modelNames: Set<string>;
421
+ modelMap: Map<string, Model>;
422
+ fieldPath: string;
423
+ errors: SchemaValidationError[];
424
+ warnings: SchemaValidationError[];
425
+ }): void {
426
+ const { field, model, modelNames, modelMap, fieldPath, errors, warnings } = params;
427
+ const rel = field.relation;
428
+
429
+ // relatedModel exists
430
+ if (!modelNames.has(field.relatedModel)) {
431
+ errors.push({
432
+ severity: "error",
433
+ path: fieldPath,
434
+ code: "RELATED_MODEL_NOT_FOUND",
435
+ message: `Field "${field.name}" references model "${field.relatedModel}" which does not exist`,
436
+ });
437
+ return; // Can't do further relation checks without the related model
438
+ }
439
+
440
+ // relatedModel consistency (two places store the same value)
441
+ if (field.relatedModel !== rel.relatedModel) {
442
+ errors.push({
443
+ severity: "error",
444
+ path: fieldPath,
445
+ code: "RELATED_MODEL_MISMATCH",
446
+ message: `Field "${field.name}" has relatedModel="${field.relatedModel}" but relation.relatedModel="${rel.relatedModel}"`,
447
+ });
448
+ }
449
+
450
+ // isList / type consistency
451
+ if ((rel.type === "oneToMany" || rel.type === "manyToMany") && !field.isList) {
452
+ errors.push({
453
+ severity: "error",
454
+ path: fieldPath,
455
+ code: "LIST_TYPE_MISMATCH",
456
+ message: `Field "${field.name}" has relation type "${rel.type}" but isList=false (should be true)`,
457
+ });
458
+ }
459
+ if ((rel.type === "manyToOne" || rel.type === "oneToOne") && field.isList) {
460
+ errors.push({
461
+ severity: "error",
462
+ path: fieldPath,
463
+ code: "LIST_TYPE_MISMATCH",
464
+ message: `Field "${field.name}" has relation type "${rel.type}" but isList=true (should be false)`,
465
+ });
466
+ }
467
+
468
+ // isForeignKey / type consistency
469
+ if (rel.type === "manyToOne" && !rel.isForeignKey) {
470
+ errors.push({
471
+ severity: "error",
472
+ path: fieldPath,
473
+ code: "FK_TYPE_MISMATCH",
474
+ message: `Field "${field.name}" has relation type "manyToOne" but isForeignKey=false (should be true)`,
475
+ });
476
+ }
477
+ if (rel.type === "oneToMany" && rel.isForeignKey) {
478
+ errors.push({
479
+ severity: "error",
480
+ path: fieldPath,
481
+ code: "FK_TYPE_MISMATCH",
482
+ message: `Field "${field.name}" has relation type "oneToMany" but isForeignKey=true (should be false)`,
483
+ });
484
+ }
485
+ if (rel.type === "manyToMany" && rel.isForeignKey) {
486
+ errors.push({
487
+ severity: "error",
488
+ path: fieldPath,
489
+ code: "FK_TYPE_MISMATCH",
490
+ message: `Field "${field.name}" has relation type "manyToMany" but isForeignKey=true (should be false)`,
491
+ });
492
+ }
493
+
494
+ // FK fields/references validation
495
+ if (rel.isForeignKey) {
496
+ if (rel.fields.length === 0) {
497
+ errors.push({
498
+ severity: "error",
499
+ path: fieldPath,
500
+ code: "FK_FIELDS_EMPTY",
501
+ message: `Field "${field.name}" has isForeignKey=true but relation.fields is empty`,
502
+ });
503
+ }
504
+ if (rel.references.length === 0) {
505
+ errors.push({
506
+ severity: "error",
507
+ path: fieldPath,
508
+ code: "FK_REFERENCES_EMPTY",
509
+ message: `Field "${field.name}" has isForeignKey=true but relation.references is empty`,
510
+ });
511
+ }
512
+ if (rel.fields.length !== rel.references.length) {
513
+ errors.push({
514
+ severity: "error",
515
+ path: fieldPath,
516
+ code: "FK_FIELDS_REFERENCES_LENGTH_MISMATCH",
517
+ message: `Field "${field.name}" has ${rel.fields.length} FK field(s) but ${rel.references.length} reference(s)`,
518
+ });
519
+ }
520
+
521
+ // FK fields reference actual scalar/enum fields on THIS model
522
+ const thisScalarFields = new Set(
523
+ model.fields
524
+ .filter((f): f is ScalarField | EnumField => f.kind === "scalar" || f.kind === "enum")
525
+ .map((f) => f.name)
526
+ );
527
+
528
+ for (const fkField of rel.fields) {
529
+ if (!thisScalarFields.has(fkField)) {
530
+ errors.push({
531
+ severity: "error",
532
+ path: fieldPath,
533
+ code: "FK_FIELD_NOT_FOUND",
534
+ message: `Relation field "${field.name}" references FK field "${fkField}" which does not exist on model "${model.name}"`,
535
+ });
536
+ }
537
+ }
538
+
539
+ // References point to actual fields on the RELATED model
540
+ const relatedModel = modelMap.get(field.relatedModel);
541
+ if (relatedModel) {
542
+ const relatedScalarFields = new Set(
543
+ relatedModel.fields
544
+ .filter((f): f is ScalarField | EnumField => f.kind === "scalar" || f.kind === "enum")
545
+ .map((f) => f.name)
546
+ );
547
+
548
+ for (const refField of rel.references) {
549
+ if (!relatedScalarFields.has(refField)) {
550
+ errors.push({
551
+ severity: "error",
552
+ path: fieldPath,
553
+ code: "FK_REFERENCE_NOT_FOUND",
554
+ message: `Relation field "${field.name}" references field "${refField}" on model "${field.relatedModel}" which does not exist`,
555
+ });
556
+ }
557
+ }
558
+ }
559
+ } else {
560
+ // Non-FK side should have empty fields/references
561
+ if (rel.fields.length > 0) {
562
+ warnings.push({
563
+ severity: "warning",
564
+ path: fieldPath,
565
+ code: "NON_FK_HAS_FIELDS",
566
+ message: `Field "${field.name}" has isForeignKey=false but relation.fields is non-empty`,
567
+ });
568
+ }
569
+ if (rel.references.length > 0) {
570
+ warnings.push({
571
+ severity: "warning",
572
+ path: fieldPath,
573
+ code: "NON_FK_HAS_REFERENCES",
574
+ message: `Field "${field.name}" has isForeignKey=false but relation.references is non-empty`,
575
+ });
576
+ }
577
+ }
578
+
579
+ // Cross-model: check for inverse relation
580
+ const relatedModel = modelMap.get(field.relatedModel);
581
+ if (relatedModel) {
582
+ const inverseRelations = relatedModel.fields.filter(
583
+ (f): f is RelationField =>
584
+ f.kind === "relation" &&
585
+ f.relatedModel === model.name &&
586
+ (!rel.name || !f.relation.name || f.relation.name === rel.name)
587
+ );
588
+
589
+ if (inverseRelations.length === 0) {
590
+ warnings.push({
591
+ severity: "warning",
592
+ path: fieldPath,
593
+ code: "MISSING_INVERSE_RELATION",
594
+ message: `Field "${field.name}" on "${model.name}" has no inverse relation on "${field.relatedModel}"`,
595
+ });
596
+ }
597
+ }
598
+ }
599
+
600
+ // ─── Format Errors for CLI Output ─────────────────────────────────
601
+
602
+ export function formatValidationErrors(params: {
603
+ result: ValidationResult;
604
+ }): string {
605
+ const { result } = params;
606
+ const lines: string[] = [];
607
+
608
+ if (!result.valid) {
609
+ lines.push(`Schema validation failed with ${result.errors.length} error(s):`);
610
+ lines.push("");
611
+ for (const err of result.errors) {
612
+ lines.push(` [ERROR] ${err.path}: ${err.message}`);
613
+ }
614
+ }
615
+
616
+ if (result.warnings.length > 0) {
617
+ if (lines.length > 0) lines.push("");
618
+ lines.push(`${result.warnings.length} warning(s):`);
619
+ for (const warn of result.warnings) {
620
+ lines.push(` [WARN] ${warn.path}: ${warn.message}`);
621
+ }
622
+ }
623
+
624
+ return lines.join("\n");
625
+ }