@happyvertical/smrt-cli 0.37.2 → 0.37.4

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