@happyvertical/smrt-cli 0.30.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,912 @@
1
+ import { existsSync } from "node:fs";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import { basename, resolve } from "node:path";
4
+ import { getPackageConfig } from "@happyvertical/smrt-config";
5
+ import { ObjectRegistry } from "@happyvertical/smrt-core";
6
+ import glob from "fast-glob";
7
+ const ValidationCodes = {
8
+ // Structure issues
9
+ INVALID_JSON: "INVALID_JSON",
10
+ NOT_ARRAY: "NOT_ARRAY",
11
+ EMPTY_OBJECT: "EMPTY_OBJECT",
12
+ // ID/Slug issues
13
+ MISSING_ID: "MISSING_ID",
14
+ DUPLICATE_ID: "DUPLICATE_ID",
15
+ INVALID_ID_FORMAT: "INVALID_ID_FORMAT",
16
+ MISSING_SLUG: "MISSING_SLUG",
17
+ DUPLICATE_SLUG_CONTEXT: "DUPLICATE_SLUG_CONTEXT",
18
+ // Type issues
19
+ INVALID_TYPE: "INVALID_TYPE",
20
+ INVALID_DATE: "INVALID_DATE",
21
+ INVALID_JSON_FIELD: "INVALID_JSON_FIELD",
22
+ INVALID_BOOLEAN: "INVALID_BOOLEAN",
23
+ INVALID_NUMBER: "INVALID_NUMBER",
24
+ INVALID_INTEGER: "INVALID_INTEGER",
25
+ // STI issues
26
+ MISSING_META_TYPE: "MISSING_META_TYPE",
27
+ UNKNOWN_META_TYPE: "UNKNOWN_META_TYPE",
28
+ INVALID_META_DATA: "INVALID_META_DATA",
29
+ // Required field issues
30
+ MISSING_REQUIRED_FIELD: "MISSING_REQUIRED_FIELD",
31
+ // Constraint issues
32
+ VALUE_OUT_OF_RANGE: "VALUE_OUT_OF_RANGE",
33
+ STRING_TOO_LONG: "STRING_TOO_LONG",
34
+ STRING_TOO_SHORT: "STRING_TOO_SHORT",
35
+ PATTERN_MISMATCH: "PATTERN_MISMATCH",
36
+ // Relationship issues (full validation only)
37
+ INVALID_FOREIGN_KEY: "INVALID_FOREIGN_KEY",
38
+ MISSING_FK_TABLE: "MISSING_FK_TABLE",
39
+ ORPHANED_RECORD: "ORPHANED_RECORD",
40
+ // Schema mismatch
41
+ UNKNOWN_FIELD: "UNKNOWN_FIELD",
42
+ MISSING_TABLE_FILE: "MISSING_TABLE_FILE",
43
+ UNKNOWN_TABLE: "UNKNOWN_TABLE"
44
+ };
45
+ function toSnakeCase(str) {
46
+ return str.replace(/([A-Z])/g, "_$1").toLowerCase().replace(/^_/, "");
47
+ }
48
+ async function resolveDataPath(explicitPath) {
49
+ if (explicitPath) {
50
+ const resolved = resolve(process.cwd(), explicitPath);
51
+ if (!existsSync(resolved)) {
52
+ return null;
53
+ }
54
+ return resolved;
55
+ }
56
+ try {
57
+ const { DEFAULT_CLI_CONFIG: defaultConfig } = await import("./config-C8pQD-tk.js");
58
+ const config = getPackageConfig(
59
+ "cli",
60
+ defaultConfig
61
+ );
62
+ if (config.database?.url) {
63
+ const dbUrl = config.database.url;
64
+ if (!dbUrl.includes(":memory:") && !dbUrl.endsWith(".db") && !dbUrl.endsWith(".sqlite")) {
65
+ const configPath = resolve(process.cwd(), dbUrl);
66
+ if (existsSync(configPath)) {
67
+ const hasJsonFiles = await glob("*.json", { cwd: configPath });
68
+ if (hasJsonFiles.length > 0) {
69
+ return configPath;
70
+ }
71
+ }
72
+ }
73
+ }
74
+ } catch {
75
+ }
76
+ const commonPaths = ["./data", "./db", "./.data", "./json-db"];
77
+ for (const path of commonPaths) {
78
+ const resolved = resolve(process.cwd(), path);
79
+ if (existsSync(resolved)) {
80
+ const hasJsonFiles = await glob("*.json", { cwd: resolved });
81
+ if (hasJsonFiles.length > 0) {
82
+ return resolved;
83
+ }
84
+ }
85
+ }
86
+ return null;
87
+ }
88
+ async function discoverJsonFiles(dataPath) {
89
+ return glob("*.json", {
90
+ cwd: dataPath,
91
+ absolute: true,
92
+ ignore: ["*.schema.json", "package.json", "tsconfig.json", "manifest.json"]
93
+ });
94
+ }
95
+ function inferTableName(fileName) {
96
+ return fileName.replace(/\.json$/, "").toLowerCase();
97
+ }
98
+ const IRREGULAR_PLURALS = {
99
+ people: "person",
100
+ children: "child",
101
+ men: "man",
102
+ women: "woman",
103
+ mice: "mouse",
104
+ geese: "goose",
105
+ teeth: "tooth",
106
+ feet: "foot",
107
+ data: "datum",
108
+ media: "medium",
109
+ criteria: "criterion",
110
+ phenomena: "phenomenon",
111
+ analyses: "analysis",
112
+ indices: "index",
113
+ matrices: "matrix",
114
+ vertices: "vertex"
115
+ };
116
+ function toSingular(tableName) {
117
+ const lower = tableName.toLowerCase();
118
+ if (IRREGULAR_PLURALS[lower]) {
119
+ return IRREGULAR_PLURALS[lower];
120
+ }
121
+ return tableName.replace(/s$/, "");
122
+ }
123
+ function findObjectTypeForTable(tableName) {
124
+ const allClasses = ObjectRegistry.getAllClasses();
125
+ for (const [_key, metadata] of allClasses) {
126
+ const simpleName = metadata.name || _key;
127
+ const registeredTableName = ObjectRegistry.getTableName(simpleName);
128
+ if (registeredTableName === tableName) {
129
+ return simpleName;
130
+ }
131
+ }
132
+ const singular = toSingular(tableName);
133
+ for (const [_key, metadata] of allClasses) {
134
+ const simpleName = metadata.name || _key;
135
+ if (simpleName.toLowerCase() === singular) {
136
+ return simpleName;
137
+ }
138
+ }
139
+ return null;
140
+ }
141
+ class JsonDatabaseValidator {
142
+ dataPath;
143
+ quickMode;
144
+ verbose;
145
+ loadedData = /* @__PURE__ */ new Map();
146
+ constructor(options) {
147
+ this.dataPath = options.dataPath;
148
+ this.quickMode = options.quickMode;
149
+ this.verbose = options.verbose;
150
+ }
151
+ /**
152
+ * Run validation on all discovered JSON files
153
+ */
154
+ async validate(jsonFiles) {
155
+ const results = [];
156
+ const fixableIssues = [];
157
+ await this.loadAllFiles(jsonFiles);
158
+ for (const filePath of jsonFiles) {
159
+ const result = await this.validateFile(filePath);
160
+ results.push(result);
161
+ for (const issue of result.issues) {
162
+ if (issue.fixable) {
163
+ fixableIssues.push(issue);
164
+ }
165
+ }
166
+ }
167
+ if (!this.quickMode) {
168
+ await this.validateForeignKeys(results);
169
+ }
170
+ return { objectResults: results, fixableIssues };
171
+ }
172
+ /**
173
+ * Load all JSON files into memory for cross-file validation (FK checks).
174
+ *
175
+ * @remarks
176
+ * Files that fail to load are silently skipped here but will produce
177
+ * validation errors when validateFile() is called. In verbose mode,
178
+ * load failures are logged for debugging.
179
+ */
180
+ async loadAllFiles(jsonFiles) {
181
+ for (const filePath of jsonFiles) {
182
+ const fileName = basename(filePath, ".json");
183
+ const tableName = inferTableName(fileName);
184
+ try {
185
+ const content = await readFile(filePath, "utf-8");
186
+ const data = JSON.parse(content);
187
+ if (Array.isArray(data)) {
188
+ this.loadedData.set(tableName, data);
189
+ }
190
+ } catch (error) {
191
+ if (this.verbose) {
192
+ const errorMessage = error instanceof Error ? error.message : String(error);
193
+ console.error(
194
+ ` [verbose] Failed to pre-load ${filePath}: ${errorMessage}`
195
+ );
196
+ }
197
+ }
198
+ }
199
+ }
200
+ /**
201
+ * Validate a single JSON file
202
+ */
203
+ async validateFile(filePath) {
204
+ const issues = [];
205
+ const fileName = basename(filePath, ".json");
206
+ const tableName = inferTableName(fileName);
207
+ const objectType = findObjectTypeForTable(tableName);
208
+ const fields = objectType ? ObjectRegistry.getFields(objectType) : /* @__PURE__ */ new Map();
209
+ let records;
210
+ try {
211
+ const content = await readFile(filePath, "utf-8");
212
+ records = JSON.parse(content);
213
+ } catch (error) {
214
+ issues.push({
215
+ severity: "error",
216
+ code: ValidationCodes.INVALID_JSON,
217
+ message: `Invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
218
+ file: filePath
219
+ });
220
+ return this.createResult(objectType, tableName, filePath, 0, issues, 0);
221
+ }
222
+ if (!Array.isArray(records)) {
223
+ issues.push({
224
+ severity: "error",
225
+ code: ValidationCodes.NOT_ARRAY,
226
+ message: "Root element must be an array",
227
+ file: filePath
228
+ });
229
+ return this.createResult(objectType, tableName, filePath, 0, issues, 0);
230
+ }
231
+ if (!objectType) {
232
+ issues.push({
233
+ severity: "warning",
234
+ code: ValidationCodes.UNKNOWN_TABLE,
235
+ message: `No SMRT object found for table '${tableName}' - skipping field validation`,
236
+ file: filePath
237
+ });
238
+ }
239
+ const seenIds = /* @__PURE__ */ new Set();
240
+ const seenSlugContext = /* @__PURE__ */ new Set();
241
+ let validCount = 0;
242
+ for (let i = 0; i < records.length; i++) {
243
+ const record = records[i];
244
+ if (typeof record !== "object" || record === null) {
245
+ issues.push({
246
+ severity: "error",
247
+ code: ValidationCodes.EMPTY_OBJECT,
248
+ message: `Record at index ${i} is not an object`,
249
+ file: filePath,
250
+ objectId: `[index:${i}]`
251
+ });
252
+ continue;
253
+ }
254
+ const recordIssues = this.validateRecord(
255
+ record,
256
+ fields,
257
+ objectType,
258
+ filePath,
259
+ i,
260
+ seenIds,
261
+ seenSlugContext
262
+ );
263
+ if (recordIssues.length === 0) {
264
+ validCount++;
265
+ }
266
+ issues.push(...recordIssues);
267
+ }
268
+ return this.createResult(
269
+ objectType,
270
+ tableName,
271
+ filePath,
272
+ records.length,
273
+ issues,
274
+ validCount
275
+ );
276
+ }
277
+ /**
278
+ * Validate a single record
279
+ */
280
+ validateRecord(record, fields, objectType, filePath, index, seenIds, seenSlugContext) {
281
+ const issues = [];
282
+ const objectId = record.id || `[index:${index}]`;
283
+ if (!record.id) {
284
+ issues.push({
285
+ severity: "error",
286
+ code: ValidationCodes.MISSING_ID,
287
+ message: `Record at index ${index} missing required 'id' field`,
288
+ file: filePath,
289
+ objectId: `[index:${index}]`
290
+ });
291
+ } else if (typeof record.id !== "string") {
292
+ issues.push({
293
+ severity: "error",
294
+ code: ValidationCodes.INVALID_ID_FORMAT,
295
+ message: `Record ID must be a string, got ${typeof record.id}`,
296
+ file: filePath,
297
+ objectId: String(record.id)
298
+ });
299
+ } else if (seenIds.has(record.id)) {
300
+ issues.push({
301
+ severity: "error",
302
+ code: ValidationCodes.DUPLICATE_ID,
303
+ message: `Duplicate ID: ${record.id}`,
304
+ file: filePath,
305
+ objectId: record.id
306
+ });
307
+ } else {
308
+ seenIds.add(record.id);
309
+ }
310
+ if (record.slug) {
311
+ const context = record.context || "";
312
+ const slugKey = `${record.slug}:${context}`;
313
+ if (seenSlugContext.has(slugKey)) {
314
+ issues.push({
315
+ severity: "error",
316
+ code: ValidationCodes.DUPLICATE_SLUG_CONTEXT,
317
+ message: `Duplicate slug+context: '${record.slug}' in context '${context}'`,
318
+ file: filePath,
319
+ objectId
320
+ });
321
+ } else {
322
+ seenSlugContext.add(slugKey);
323
+ }
324
+ }
325
+ if (objectType) {
326
+ const tableStrategy = ObjectRegistry.getTableStrategy(objectType);
327
+ if (tableStrategy === "sti") {
328
+ issues.push(
329
+ ...this.validateSTIFields(record, objectType, filePath, objectId)
330
+ );
331
+ }
332
+ }
333
+ for (const [fieldName, fieldDef] of fields) {
334
+ issues.push(
335
+ ...this.validateField(
336
+ record,
337
+ fieldName,
338
+ fieldDef,
339
+ filePath,
340
+ objectId
341
+ )
342
+ );
343
+ }
344
+ return issues;
345
+ }
346
+ /**
347
+ * Validate STI-specific fields
348
+ */
349
+ validateSTIFields(record, objectType, filePath, objectId) {
350
+ const issues = [];
351
+ if (!record._meta_type) {
352
+ issues.push({
353
+ severity: "error",
354
+ code: ValidationCodes.MISSING_META_TYPE,
355
+ message: "STI record missing _meta_type discriminator",
356
+ file: filePath,
357
+ objectId,
358
+ objectType,
359
+ field: "_meta_type"
360
+ });
361
+ } else if (typeof record._meta_type !== "string") {
362
+ issues.push({
363
+ severity: "error",
364
+ code: ValidationCodes.INVALID_TYPE,
365
+ message: `_meta_type must be a string, got ${typeof record._meta_type}`,
366
+ file: filePath,
367
+ objectId,
368
+ objectType,
369
+ field: "_meta_type"
370
+ });
371
+ } else {
372
+ if (!ObjectRegistry.getClass(record._meta_type)) {
373
+ issues.push({
374
+ severity: "warning",
375
+ code: ValidationCodes.UNKNOWN_META_TYPE,
376
+ message: `Unknown _meta_type: '${record._meta_type}' (may be from unloaded package)`,
377
+ file: filePath,
378
+ objectId,
379
+ objectType,
380
+ field: "_meta_type",
381
+ actual: record._meta_type
382
+ });
383
+ }
384
+ }
385
+ if (record._meta_data !== void 0 && record._meta_data !== null) {
386
+ if (typeof record._meta_data !== "object" || Array.isArray(record._meta_data)) {
387
+ issues.push({
388
+ severity: "error",
389
+ code: ValidationCodes.INVALID_META_DATA,
390
+ message: "_meta_data must be an object",
391
+ file: filePath,
392
+ objectId,
393
+ objectType,
394
+ field: "_meta_data",
395
+ expected: "object",
396
+ actual: typeof record._meta_data
397
+ });
398
+ }
399
+ }
400
+ return issues;
401
+ }
402
+ /**
403
+ * Validate a single field against its definition.
404
+ *
405
+ * @remarks
406
+ * Null handling varies by field type:
407
+ * - Required fields: null triggers MISSING_REQUIRED_FIELD error
408
+ * - JSON type: null is valid (it's a valid JSON value)
409
+ * - Other types: null is treated as "no value" and skips validation
410
+ */
411
+ validateField(record, fieldName, fieldDef, filePath, objectId) {
412
+ const issues = [];
413
+ const snakeCaseFieldName = toSnakeCase(fieldName);
414
+ const value = record[snakeCaseFieldName];
415
+ const fieldType = fieldDef.type;
416
+ if (fieldDef.required && (value === void 0 || value === null)) {
417
+ issues.push({
418
+ severity: "error",
419
+ code: ValidationCodes.MISSING_REQUIRED_FIELD,
420
+ message: `Missing required field: ${fieldName}`,
421
+ file: filePath,
422
+ objectId,
423
+ field: fieldName,
424
+ fixable: fieldDef.default !== void 0
425
+ });
426
+ return issues;
427
+ }
428
+ if (value === void 0) {
429
+ return issues;
430
+ }
431
+ if (value === null && fieldType !== "json") {
432
+ return issues;
433
+ }
434
+ const typeIssue = this.validateFieldType(
435
+ value,
436
+ fieldType,
437
+ fieldName,
438
+ objectId,
439
+ filePath
440
+ );
441
+ if (typeIssue) {
442
+ issues.push(typeIssue);
443
+ }
444
+ if (value !== null) {
445
+ issues.push(
446
+ ...this.validateConstraints(
447
+ value,
448
+ fieldDef,
449
+ fieldName,
450
+ objectId,
451
+ filePath
452
+ )
453
+ );
454
+ }
455
+ return issues;
456
+ }
457
+ /**
458
+ * Validate field type
459
+ */
460
+ validateFieldType(value, expectedType, fieldName, objectId, filePath) {
461
+ switch (expectedType) {
462
+ case "text":
463
+ if (typeof value !== "string") {
464
+ return {
465
+ severity: "error",
466
+ code: ValidationCodes.INVALID_TYPE,
467
+ message: `Field '${fieldName}' expected string, got ${typeof value}`,
468
+ file: filePath,
469
+ objectId,
470
+ field: fieldName,
471
+ expected: "string",
472
+ actual: typeof value
473
+ };
474
+ }
475
+ break;
476
+ case "integer":
477
+ if (typeof value !== "number" || !Number.isInteger(value)) {
478
+ return {
479
+ severity: "error",
480
+ code: ValidationCodes.INVALID_INTEGER,
481
+ message: `Field '${fieldName}' expected integer, got ${typeof value}${typeof value === "number" ? " (decimal)" : ""}`,
482
+ file: filePath,
483
+ objectId,
484
+ field: fieldName,
485
+ expected: "integer",
486
+ actual: value
487
+ };
488
+ }
489
+ break;
490
+ case "decimal":
491
+ if (typeof value !== "number") {
492
+ return {
493
+ severity: "error",
494
+ code: ValidationCodes.INVALID_NUMBER,
495
+ message: `Field '${fieldName}' expected number, got ${typeof value}`,
496
+ file: filePath,
497
+ objectId,
498
+ field: fieldName,
499
+ expected: "number",
500
+ actual: typeof value
501
+ };
502
+ }
503
+ break;
504
+ case "boolean":
505
+ if (typeof value !== "boolean") {
506
+ return {
507
+ severity: "error",
508
+ code: ValidationCodes.INVALID_BOOLEAN,
509
+ message: `Field '${fieldName}' expected boolean, got ${typeof value}`,
510
+ file: filePath,
511
+ objectId,
512
+ field: fieldName,
513
+ expected: "boolean",
514
+ actual: typeof value
515
+ };
516
+ }
517
+ break;
518
+ case "datetime": {
519
+ const dateValue = typeof value === "string" ? new Date(value) : value;
520
+ if (!(dateValue instanceof Date) || Number.isNaN(dateValue.getTime())) {
521
+ return {
522
+ severity: "error",
523
+ code: ValidationCodes.INVALID_DATE,
524
+ message: `Field '${fieldName}' contains invalid date: ${String(value)}`,
525
+ file: filePath,
526
+ objectId,
527
+ field: fieldName,
528
+ expected: "ISO 8601 date string",
529
+ actual: value
530
+ };
531
+ }
532
+ break;
533
+ }
534
+ case "json":
535
+ if (value !== null && typeof value !== "object") {
536
+ return {
537
+ severity: "error",
538
+ code: ValidationCodes.INVALID_JSON_FIELD,
539
+ message: `Field '${fieldName}' expected JSON value (object, array, or null), got ${typeof value}`,
540
+ file: filePath,
541
+ objectId,
542
+ field: fieldName,
543
+ expected: "object, array, or null",
544
+ actual: typeof value
545
+ };
546
+ }
547
+ break;
548
+ case "foreignKey":
549
+ if (typeof value !== "string") {
550
+ return {
551
+ severity: "error",
552
+ code: ValidationCodes.INVALID_TYPE,
553
+ message: `Field '${fieldName}' (foreign key) expected string, got ${typeof value}`,
554
+ file: filePath,
555
+ objectId,
556
+ field: fieldName,
557
+ expected: "string",
558
+ actual: typeof value
559
+ };
560
+ }
561
+ break;
562
+ }
563
+ return null;
564
+ }
565
+ /**
566
+ * Validate field constraints
567
+ */
568
+ validateConstraints(value, fieldDef, fieldName, objectId, filePath) {
569
+ const issues = [];
570
+ if (typeof value === "number") {
571
+ if (fieldDef.min !== void 0 && value < fieldDef.min) {
572
+ issues.push({
573
+ severity: "error",
574
+ code: ValidationCodes.VALUE_OUT_OF_RANGE,
575
+ message: `Field '${fieldName}' value ${value} is below minimum ${fieldDef.min}`,
576
+ file: filePath,
577
+ objectId,
578
+ field: fieldName,
579
+ expected: `>= ${fieldDef.min}`,
580
+ actual: value
581
+ });
582
+ }
583
+ if (fieldDef.max !== void 0 && value > fieldDef.max) {
584
+ issues.push({
585
+ severity: "error",
586
+ code: ValidationCodes.VALUE_OUT_OF_RANGE,
587
+ message: `Field '${fieldName}' value ${value} is above maximum ${fieldDef.max}`,
588
+ file: filePath,
589
+ objectId,
590
+ field: fieldName,
591
+ expected: `<= ${fieldDef.max}`,
592
+ actual: value
593
+ });
594
+ }
595
+ }
596
+ if (typeof value === "string") {
597
+ if (fieldDef.minLength !== void 0 && value.length < fieldDef.minLength) {
598
+ issues.push({
599
+ severity: "error",
600
+ code: ValidationCodes.STRING_TOO_SHORT,
601
+ message: `Field '${fieldName}' length ${value.length} is below minimum ${fieldDef.minLength}`,
602
+ file: filePath,
603
+ objectId,
604
+ field: fieldName,
605
+ expected: `length >= ${fieldDef.minLength}`,
606
+ actual: value.length
607
+ });
608
+ }
609
+ if (fieldDef.maxLength !== void 0 && value.length > fieldDef.maxLength) {
610
+ issues.push({
611
+ severity: "error",
612
+ code: ValidationCodes.STRING_TOO_LONG,
613
+ message: `Field '${fieldName}' length ${value.length} is above maximum ${fieldDef.maxLength}`,
614
+ file: filePath,
615
+ objectId,
616
+ field: fieldName,
617
+ expected: `length <= ${fieldDef.maxLength}`,
618
+ actual: value.length
619
+ });
620
+ }
621
+ if (fieldDef.pattern) {
622
+ const regex = new RegExp(fieldDef.pattern);
623
+ if (!regex.test(value)) {
624
+ issues.push({
625
+ severity: "error",
626
+ code: ValidationCodes.PATTERN_MISMATCH,
627
+ message: `Field '${fieldName}' does not match required pattern`,
628
+ file: filePath,
629
+ objectId,
630
+ field: fieldName,
631
+ expected: fieldDef.pattern,
632
+ actual: value
633
+ });
634
+ }
635
+ }
636
+ }
637
+ return issues;
638
+ }
639
+ /**
640
+ * Validate foreign key references across files (full mode only).
641
+ *
642
+ * @remarks
643
+ * Uses Set-based lookup for target IDs (O(1) per lookup instead of O(n)),
644
+ * optimizing validation from O(n*m*k) to O(n*k + m) where:
645
+ * - n = number of records with FK fields
646
+ * - m = number of target records
647
+ * - k = number of FK fields per record
648
+ *
649
+ * Note: This method mutates the results to add FK validation issues.
650
+ * The validCount/invalidCount are updated to reflect records that
651
+ * failed FK validation (a record is counted as invalid if it has
652
+ * any FK errors).
653
+ */
654
+ async validateForeignKeys(results) {
655
+ const tableIdSets = /* @__PURE__ */ new Map();
656
+ for (const [tableName, records] of this.loadedData) {
657
+ const idSet = /* @__PURE__ */ new Set();
658
+ for (const record of records) {
659
+ const id = record.id;
660
+ if (typeof id === "string") {
661
+ idSet.add(id);
662
+ }
663
+ }
664
+ tableIdSets.set(tableName, idSet);
665
+ }
666
+ for (const result of results) {
667
+ if (!result.objectType) continue;
668
+ const fields = ObjectRegistry.getFields(result.objectType);
669
+ const fkFields = Array.from(fields.entries()).filter(
670
+ ([, def]) => def.type === "foreignKey"
671
+ );
672
+ if (fkFields.length === 0) continue;
673
+ const records = this.loadedData.get(result.tableName) || [];
674
+ const recordsWithFKErrors = /* @__PURE__ */ new Set();
675
+ for (const record of records) {
676
+ const rec = record;
677
+ const recordId = rec.id || `[unknown]`;
678
+ for (const [fieldName, fieldDef] of fkFields) {
679
+ const snakeCaseFieldName = toSnakeCase(fieldName);
680
+ const fkValue = rec[snakeCaseFieldName];
681
+ if (!fkValue) continue;
682
+ const def = fieldDef;
683
+ const targetClass = def.related;
684
+ if (!targetClass) continue;
685
+ const targetTable = ObjectRegistry.getTableName(targetClass);
686
+ if (!targetTable) {
687
+ result.issues.push({
688
+ severity: "warning",
689
+ code: ValidationCodes.MISSING_FK_TABLE,
690
+ message: `FK target class '${targetClass}' not registered`,
691
+ file: result.file,
692
+ objectId: recordId,
693
+ field: fieldName
694
+ });
695
+ continue;
696
+ }
697
+ const targetIdSet = tableIdSets.get(targetTable);
698
+ if (!targetIdSet) {
699
+ result.issues.push({
700
+ severity: "error",
701
+ code: ValidationCodes.MISSING_FK_TABLE,
702
+ message: `FK reference to missing table file: ${targetTable}.json`,
703
+ file: result.file,
704
+ objectId: recordId,
705
+ field: fieldName
706
+ });
707
+ recordsWithFKErrors.add(recordId);
708
+ continue;
709
+ }
710
+ if (!targetIdSet.has(fkValue)) {
711
+ result.issues.push({
712
+ severity: "error",
713
+ code: ValidationCodes.INVALID_FOREIGN_KEY,
714
+ message: `FK reference to non-existent record: ${fkValue} in ${targetTable}`,
715
+ file: result.file,
716
+ objectId: recordId,
717
+ field: fieldName,
718
+ actual: fkValue
719
+ });
720
+ recordsWithFKErrors.add(recordId);
721
+ }
722
+ }
723
+ }
724
+ const newInvalidCount = recordsWithFKErrors.size;
725
+ if (newInvalidCount > 0) {
726
+ const previouslyValid = result.validCount;
727
+ const newlyInvalid = Math.min(newInvalidCount, previouslyValid);
728
+ result.validCount -= newlyInvalid;
729
+ result.invalidCount += newlyInvalid;
730
+ }
731
+ }
732
+ }
733
+ /**
734
+ * Apply fixes to fixable issues.
735
+ *
736
+ * Currently supports auto-fixing:
737
+ * - MISSING_REQUIRED_FIELD: Applies default value from field definition
738
+ *
739
+ * @remarks
740
+ * Records are matched by ID. Records identified by index only (objectId = '[index:N]')
741
+ * require the ID to be present and are matched by array position as a fallback.
742
+ */
743
+ async applyFixes(fixableIssues) {
744
+ const fixesByFile = /* @__PURE__ */ new Map();
745
+ for (const issue of fixableIssues) {
746
+ if (!issue.file) continue;
747
+ const existing = fixesByFile.get(issue.file) || [];
748
+ existing.push(issue);
749
+ fixesByFile.set(issue.file, existing);
750
+ }
751
+ let fixedCount = 0;
752
+ for (const [filePath, issues] of fixesByFile) {
753
+ try {
754
+ const content = await readFile(filePath, "utf-8");
755
+ const records = JSON.parse(content);
756
+ for (const issue of issues) {
757
+ if (issue.code === ValidationCodes.MISSING_REQUIRED_FIELD && issue.field) {
758
+ let record;
759
+ if (issue.objectId?.startsWith("[index:")) {
760
+ const indexMatch = issue.objectId.match(/\[index:(\d+)\]/);
761
+ if (indexMatch) {
762
+ const index = parseInt(indexMatch[1], 10);
763
+ record = records[index];
764
+ }
765
+ } else {
766
+ record = records.find((r) => r.id === issue.objectId);
767
+ }
768
+ if (record && issue.objectType) {
769
+ const fields = ObjectRegistry.getFields(issue.objectType);
770
+ const fieldDef = fields.get(issue.field);
771
+ if (fieldDef?.default !== void 0) {
772
+ const snakeCaseField = toSnakeCase(issue.field);
773
+ record[snakeCaseField] = fieldDef.default;
774
+ fixedCount++;
775
+ }
776
+ }
777
+ }
778
+ }
779
+ await writeFile(filePath, `${JSON.stringify(records, null, 2)}
780
+ `);
781
+ } catch (error) {
782
+ if (this.verbose) {
783
+ const errorMessage = error instanceof Error ? error.message : String(error);
784
+ console.error(
785
+ ` [verbose] Failed to apply fixes to ${filePath}: ${errorMessage}`
786
+ );
787
+ }
788
+ }
789
+ }
790
+ return fixedCount;
791
+ }
792
+ /**
793
+ * Generate validation summary
794
+ */
795
+ generateSummary(results, duration, manifestPath = null) {
796
+ let totalRecords = 0;
797
+ let validRecords = 0;
798
+ let invalidRecords = 0;
799
+ let errors = 0;
800
+ let warnings = 0;
801
+ let info = 0;
802
+ for (const result of results.objectResults) {
803
+ totalRecords += result.recordCount;
804
+ validRecords += result.validCount;
805
+ invalidRecords += result.invalidCount;
806
+ for (const issue of result.issues) {
807
+ if (issue.severity === "error") errors++;
808
+ else if (issue.severity === "warning") warnings++;
809
+ else info++;
810
+ }
811
+ }
812
+ return {
813
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
814
+ dataPath: this.dataPath,
815
+ manifestPath,
816
+ duration,
817
+ totalFiles: results.objectResults.length,
818
+ totalRecords,
819
+ validRecords,
820
+ invalidRecords,
821
+ issues: { errors, warnings, info },
822
+ objectResults: results.objectResults
823
+ };
824
+ }
825
+ /**
826
+ * Create an ObjectValidationResult
827
+ */
828
+ createResult(objectType, tableName, file, recordCount, issues, validCount) {
829
+ return {
830
+ objectType,
831
+ tableName,
832
+ file,
833
+ recordCount,
834
+ validCount,
835
+ invalidCount: recordCount - validCount,
836
+ issues
837
+ };
838
+ }
839
+ }
840
+ function displayValidationResults(summary, verbose) {
841
+ console.log("\nšŸ” SMRT Database Validation Report\n");
842
+ console.log(` Data path: ${summary.dataPath}`);
843
+ if (summary.manifestPath) {
844
+ console.log(` Manifest: ${summary.manifestPath}`);
845
+ }
846
+ console.log(` Duration: ${summary.duration}ms
847
+ `);
848
+ console.log("━".repeat(60));
849
+ console.log("\nšŸ“Š Summary\n");
850
+ console.log(` Files validated: ${summary.totalFiles}`);
851
+ console.log(` Total records: ${summary.totalRecords}`);
852
+ console.log(` Valid records: ${summary.validRecords}`);
853
+ console.log(` Invalid records: ${summary.invalidRecords}`);
854
+ console.log();
855
+ console.log(` āŒ Errors: ${summary.issues.errors}`);
856
+ console.log(` āš ļø Warnings: ${summary.issues.warnings}`);
857
+ console.log(` ā„¹ļø Info: ${summary.issues.info}`);
858
+ console.log();
859
+ if (summary.issues.errors === 0 && summary.issues.warnings === 0) {
860
+ console.log("āœ… All validations passed!\n");
861
+ return;
862
+ }
863
+ console.log("━".repeat(60));
864
+ console.log("\nšŸ”§ Issues by Object Type\n");
865
+ for (const result of summary.objectResults) {
866
+ if (result.issues.length === 0) continue;
867
+ console.log(` ${result.objectType || result.tableName}`);
868
+ console.log(` File: ${result.file}`);
869
+ console.log(
870
+ ` Records: ${result.recordCount} (${result.validCount} valid, ${result.invalidCount} invalid)`
871
+ );
872
+ const errors = result.issues.filter((i) => i.severity === "error");
873
+ const warnings = result.issues.filter((i) => i.severity === "warning");
874
+ if (errors.length > 0) {
875
+ console.log(`
876
+ āŒ Errors (${errors.length}):`);
877
+ const displayErrors = verbose ? errors : errors.slice(0, 5);
878
+ for (const issue of displayErrors) {
879
+ console.log(` [${issue.code}] ${issue.message}`);
880
+ if (issue.objectId && issue.objectId !== "[index:0]") {
881
+ console.log(` Record: ${issue.objectId}`);
882
+ }
883
+ }
884
+ if (!verbose && errors.length > 5) {
885
+ console.log(` ... and ${errors.length - 5} more errors`);
886
+ }
887
+ }
888
+ if (warnings.length > 0) {
889
+ console.log(`
890
+ āš ļø Warnings (${warnings.length}):`);
891
+ const displayWarnings = verbose ? warnings : warnings.slice(0, 3);
892
+ for (const issue of displayWarnings) {
893
+ console.log(` [${issue.code}] ${issue.message}`);
894
+ }
895
+ if (!verbose && warnings.length > 3) {
896
+ console.log(` ... and ${warnings.length - 3} more warnings`);
897
+ }
898
+ }
899
+ console.log();
900
+ }
901
+ console.log("━".repeat(60));
902
+ console.log("\nšŸ’” Next steps:\n");
903
+ console.log(" - Run with --verbose for detailed issue information");
904
+ console.log(" - Run with --fix to auto-correct fixable issues");
905
+ console.log(" - Run with --json for CI-friendly output\n");
906
+ }
907
+ export {
908
+ JsonDatabaseValidator,
909
+ discoverJsonFiles,
910
+ displayValidationResults,
911
+ resolveDataPath
912
+ };