@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.
- package/AGENTS.md +51 -0
- package/CLAUDE.md +1 -0
- package/LICENSE +7 -0
- package/README.md +202 -0
- package/bin/smrt.js +26 -0
- package/dist/config-C8pQD-tk.js +30 -0
- package/dist/index-psX--9zT.js +9181 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1809 -0
- package/dist/json-validator-zsI8zUQC.js +912 -0
- package/package.json +79 -0
- package/scripts/verify-package-types-exports.js +573 -0
|
@@ -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
|
+
};
|