Port bun-mariadb conventions: init globals, db handler, split schema sync

- init() loads config, schema and the SQLite client once into globals
- all CRUD queries go through lib/db-handler.ts (optional per-call config)
- split db-schema-manager.ts into lib/schema/* with FK-aware table ordering
- add bun:test tests for schema ordering, FK constraints and schema sync
- config/schema files can be .ts/.js/.json/.yaml/.yml; db_schema_file_name
  is now optional (defaults to schema.* in db_dir)
- add export/import commands (database snapshot + schema archives)
- package exports map, type re-exports, hardened publish.sh
- validate table name in delete

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-14 02:37:35 +01:00
co-authored by Claude Opus 5.5
parent 66f73c4228
commit 87da436f81
54 changed files with 2385 additions and 857 deletions
+132
View File
@@ -0,0 +1,132 @@
import fs from "fs";
import path from "path";
import { AppData } from "../data/app-data";
export type DataFileFormat = "ts" | "js" | "json" | "yaml";
export type ResolvedDataFile = {
path: string;
basename: string;
extension: (typeof AppData.SupportedDataFileExtensions)[number];
format: DataFileFormat;
};
export type LoadedDataFile<T> = ResolvedDataFile & {
data: T;
};
function extensionToFormat(
extension: (typeof AppData.SupportedDataFileExtensions)[number],
): DataFileFormat {
switch (extension) {
case ".ts":
return "ts";
case ".js":
return "js";
case ".json":
return "json";
case ".yaml":
case ".yml":
return "yaml";
}
}
/**
* Resolve a basename (no extension) to an existing file among supported formats.
* Priority: .ts > .js > .json > .yaml > .yml
* Errors if multiple matches exist.
*/
export function resolveDataFile(
dir: string,
baseName: string,
): ResolvedDataFile | null {
const matches: ResolvedDataFile[] = [];
for (const extension of AppData.SupportedDataFileExtensions) {
const filePath = path.join(dir, `${baseName}${extension}`);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
matches.push({
path: filePath,
basename: `${baseName}${extension}`,
extension,
format: extensionToFormat(extension),
});
}
}
if (matches.length === 0) {
return null;
}
if (matches.length > 1) {
const found = matches.map((m) => m.basename).join(", ");
throw new Error(
`Multiple \`${baseName}\` files found (${found}). Keep only one of: ${AppData.SupportedDataFileExtensions.join(", ")}`,
);
}
return matches[0]!;
}
export function supportedDataFileNames(baseName: string): string {
return AppData.SupportedDataFileExtensions.map(
(ext) => `\`${baseName}${ext}\``,
).join(", ");
}
export function isSchemaFileName(name: string): boolean {
const base = path.basename(name);
if (base === AppData.DbSchemaFileName) {
return true;
}
return AppData.SupportedDataFileExtensions.some(
(ext) => base === `${AppData.DbSchemaFileName}${ext}`,
);
}
/**
* Load a data file as a plain object.
* - .ts / .js: require() and use default export (or module itself)
* - .json: JSON.parse
* - .yaml / .yml: Bun.YAML.parse
*/
export function loadDataFile<T>(resolved: ResolvedDataFile): T {
if (resolved.format === "ts" || resolved.format === "js") {
const imported = require(resolved.path);
const data =
imported && typeof imported === "object" && "default" in imported
? imported.default
: imported;
if (data == null) {
throw new Error(
`No default export from \`${resolved.path}\`. Please export a default module.`,
);
}
return data as T;
}
const text = fs.readFileSync(resolved.path, "utf-8");
if (resolved.format === "json") {
return JSON.parse(text) as T;
}
return Bun.YAML.parse(text) as T;
}
export function resolveAndLoadDataFile<T>(
dir: string,
baseName: string,
): LoadedDataFile<T> | null {
const resolved = resolveDataFile(dir, baseName);
if (!resolved) {
return null;
}
return {
...resolved,
data: loadDataFile<T>(resolved),
};
}