Project Files
src / engine / schema.ts
import path from "node:path";
import fs from "node:fs/promises";
import type {
ColumnDef,
TableSchema,
ValidationResult,
ValidationErrorDetail,
} from "../types";
import { SchemaNotFoundError, FilesystemError } from "../errors";
export function schemaPath(
database: string,
table: string,
dataRoot: string,
): string {
return path.join(dataRoot, "databases", database, `${table}.schema.json`);
}
function now(): string {
return new Date().toISOString();
}
export function createSchema(
columns: ColumnDef[],
description?: string,
): TableSchema {
return {
columns,
createdAt: now(),
description,
version: 1,
};
}
export async function loadSchema(schemaFile: string): Promise<TableSchema> {
try {
const raw = await fs.readFile(schemaFile, "utf-8");
return JSON.parse(raw) as TableSchema;
} catch (cause) {
if ((cause as NodeJS.ErrnoException).code === "ENOENT") {
throw new SchemaNotFoundError(`Schema not found: ${schemaFile}`);
}
throw new FilesystemError(`Cannot read schema: ${schemaFile}`);
}
}
export async function saveSchema(
schemaFile: string,
schema: TableSchema,
): Promise<void> {
try {
await fs.writeFile(schemaFile, JSON.stringify(schema, null, 2), "utf-8");
} catch (cause) {
throw new FilesystemError(`Cannot write schema: ${schemaFile}`);
}
}
function coerceType(
value: unknown,
colType: string,
): { ok: boolean; expected: string; actual: string } {
const actual = typeof value;
if (value === null || value === undefined || value === "") {
return { ok: true, expected: colType, actual: String(actual) };
}
switch (colType) {
case "string":
return { ok: true, expected: "string", actual: "string" };
case "number": {
const n = Number(value);
return { ok: !isNaN(n), expected: "number", actual: actual };
}
case "boolean": {
if (typeof value === "boolean")
return { ok: true, expected: "boolean", actual: "boolean" };
const s = String(value).toLowerCase();
const isBool = ["true", "false", "1", "0", "yes", "no"].includes(s);
return { ok: isBool, expected: "boolean", actual: actual };
}
case "date": {
if (value instanceof Date)
return { ok: true, expected: "date", actual: "date" };
if (typeof value === "string") {
const d = new Date(value);
return { ok: !isNaN(d.getTime()), expected: "date", actual: "string" };
}
return { ok: false, expected: "date", actual: actual };
}
default:
return { ok: true, expected: colType, actual: actual };
}
}
export function validateRowAgainstSchema(
row: Record<string, unknown>,
schema: TableSchema,
): ValidationResult {
const errors: ValidationErrorDetail[] = [];
for (const col of schema.columns) {
const val = row[col.name];
if (col.required && (val === undefined || val === null || val === "")) {
errors.push({
column: col.name,
message: `Field "${col.name}" is required`,
code: "REQUIRED_FIELD",
expected: col.type,
actual: val,
});
continue;
}
if (val !== undefined && val !== null && val !== "") {
const check = coerceType(val, col.type);
if (!check.ok) {
errors.push({
column: col.name,
message: `Expected ${col.type} for "${col.name}", got ${check.actual}`,
code: "TYPE_MISMATCH",
expected: col.type,
actual: val,
});
}
}
}
return { valid: errors.length === 0, errors };
}
export function validateRowsAgainstSchema(
rows: Record<string, unknown>[],
schema: TableSchema,
): ValidationResult {
const allErrors: ValidationErrorDetail[] = [];
for (const row of rows) {
const result = validateRowAgainstSchema(row, schema);
allErrors.push(...result.errors);
}
return { valid: allErrors.length === 0, errors: allErrors };
}
export function inferSchemaFromRow(row: Record<string, unknown>): TableSchema {
const columns: ColumnDef[] = [];
for (const [key, value] of Object.entries(row)) {
let colType: ColumnDef["type"] = "string";
if (typeof value === "number") {
colType = "number";
} else if (typeof value === "boolean") {
colType = "boolean";
} else if (typeof value === "string") {
const lower = value.toLowerCase();
if (["true", "false", "1", "0", "yes", "no"].includes(lower)) {
colType = "boolean";
} else if (!isNaN(Date.parse(value)) && value.includes("-")) {
colType = "date";
}
}
columns.push({
name: key,
type: colType,
required: false,
});
}
return {
columns,
createdAt: now(),
version: 1,
};
}