Update schema and config file types. Allow js, ts, json, yaml, yml

This commit is contained in:
2026-07-30 07:05:47 +01:00
parent 3bbf00cdb0
commit 246c42a214
8 changed files with 289 additions and 69 deletions
+25 -8
View File
@@ -3,10 +3,14 @@ import path from "path";
import fs from "fs"; import fs from "fs";
import chalk from "chalk"; import chalk from "chalk";
import grabDBDir from "../utils/grab-db-dir"; import grabDBDir from "../utils/grab-db-dir";
import { AppData } from "../data/app-data";
import { dumpDatabase } from "../utils/mariadb-dump-restore"; import { dumpDatabase } from "../utils/mariadb-dump-restore";
import { writeExportArchive } from "../utils/export-archive"; import { writeExportArchive } from "../utils/export-archive";
import trimExports from "../utils/trim-exports"; import trimExports from "../utils/trim-exports";
import {
resolveDataFile,
supportedDataFileNames,
} from "../utils/resolve-and-load-data-file";
import { AppData } from "../data/app-data";
function defaultExportFileName( function defaultExportFileName(
dbName: string, dbName: string,
@@ -18,7 +22,7 @@ function defaultExportFileName(
export default function () { export default function () {
return new Command("export") return new Command("export")
.description( .description(
"Export database SQL dump + schema.ts into a portable archive", "Export database SQL dump + schema into a portable archive",
) )
.option( .option(
"-o, --output <path>", "-o, --output <path>",
@@ -39,10 +43,19 @@ export default function () {
fs.mkdirSync(export_dir, { recursive: true }); fs.mkdirSync(export_dir, { recursive: true });
} }
const schemaPath = path.join(db_dir, AppData.DbSchemaFileName); const schemaResolved =
if (!fs.existsSync(schemaPath)) { (global.SCHEMA_FILE_PATH &&
fs.existsSync(global.SCHEMA_FILE_PATH) && {
path: global.SCHEMA_FILE_PATH,
basename: path.basename(global.SCHEMA_FILE_PATH),
}) ||
resolveDataFile(db_dir, AppData.DbSchemaFileName);
if (!schemaResolved) {
console.error( console.error(
chalk.red(`Schema file not found: ${schemaPath}`), chalk.red(
`Schema file not found in \`${db_dir}\` (${supportedDataFileNames(AppData.DbSchemaFileName)})`,
),
); );
process.exit(1); process.exit(1);
} }
@@ -67,10 +80,14 @@ export default function () {
try { try {
const sql = await dumpDatabase(config); const sql = await dumpDatabase(config);
const schemaTs = fs.readFileSync(schemaPath, "utf-8"); const schema = fs.readFileSync(schemaResolved.path, "utf-8");
await writeExportArchive({ await writeExportArchive({
contents: { sql, schemaTs }, contents: {
sql,
schema,
schemaFileName: schemaResolved.basename,
},
outPath, outPath,
}); });
@@ -83,7 +100,7 @@ export default function () {
); );
console.log( console.log(
chalk.dim( chalk.dim(
`Contains: dump.sql + ${AppData.DbSchemaFileName}`, `Contains: dump.sql + ${schemaResolved.basename}`,
), ),
); );
process.exit(0); process.exit(0);
+23 -8
View File
@@ -6,13 +6,14 @@ import { select } from "@inquirer/prompts";
import grabDBDir from "../utils/grab-db-dir"; import grabDBDir from "../utils/grab-db-dir";
import grabSortedExports from "../utils/grab-sorted-exports"; import grabSortedExports from "../utils/grab-sorted-exports";
import grabBackupData from "../utils/grab-backup-data"; import grabBackupData from "../utils/grab-backup-data";
import { AppData } from "../data/app-data";
import { restoreDatabase } from "../utils/mariadb-dump-restore"; import { restoreDatabase } from "../utils/mariadb-dump-restore";
import { import {
isArchivePath, isArchivePath,
isSqlPath, isSqlPath,
readExportArchive, readExportArchive,
} from "../utils/export-archive"; } from "../utils/export-archive";
import { resolveDataFile } from "../utils/resolve-and-load-data-file";
import { AppData } from "../data/app-data";
function formatChoice(name: string, index: number): string { function formatChoice(name: string, index: number): string {
const { backup_date } = grabBackupData({ backup_name: name }); const { backup_date } = grabBackupData({ backup_name: name });
@@ -25,7 +26,7 @@ function formatChoice(name: string, index: number): string {
export default function () { export default function () {
return new Command("import") return new Command("import")
.description( .description(
"Import an SQL dump, or a full export archive (SQL + schema.ts)", "Import an SQL dump, or a full export archive (SQL + schema)",
) )
.argument( .argument(
"[file]", "[file]",
@@ -33,11 +34,11 @@ export default function () {
) )
.option( .option(
"--sql-only", "--sql-only",
"When importing an archive, restore SQL only (skip writing schema.ts)", "When importing an archive, restore SQL only (skip writing schema)",
) )
.option( .option(
"--schema-only", "--schema-only",
"When importing an archive, write schema.ts only (skip SQL restore)", "When importing an archive, write schema only (skip SQL restore)",
) )
.action(async (fileArg: string | undefined, opts) => { .action(async (fileArg: string | undefined, opts) => {
console.log(`Importing database ...`); console.log(`Importing database ...`);
@@ -113,7 +114,8 @@ export default function () {
process.exit(1); process.exit(1);
} }
const { sql, schemaTs } = await readExportArchive(filePath); const { sql, schema, schemaFileName } =
await readExportArchive(filePath);
if (!opts.schemaOnly) { if (!opts.schemaOnly) {
await restoreDatabase(config, sql); await restoreDatabase(config, sql);
@@ -121,16 +123,29 @@ export default function () {
} }
if (!opts.sqlOnly) { if (!opts.sqlOnly) {
const schemaPath = path.join( const existing = resolveDataFile(
db_dir, db_dir,
AppData.DbSchemaFileName, AppData.DbSchemaFileName,
); );
fs.writeFileSync(schemaPath, schemaTs, "utf-8"); const schemaPath = path.join(db_dir, schemaFileName);
// Remove a differently-named schema so only one format remains
if (
existing &&
path.resolve(existing.path) !== path.resolve(schemaPath)
) {
fs.unlinkSync(existing.path);
console.log( console.log(
chalk.green(`Schema written → ${schemaPath}`), chalk.dim(
`Removed previous schema file: ${existing.basename}`,
),
); );
} }
fs.writeFileSync(schemaPath, schema, "utf-8");
console.log(chalk.green(`Schema written → ${schemaPath}`));
}
console.log( console.log(
`${chalk.bold(chalk.green(`DB Import Success!`))}${filePath}`, `${chalk.bold(chalk.green(`DB Import Success!`))}${filePath}`,
); );
+1 -1
View File
@@ -20,7 +20,7 @@ export default function () {
if (!config.typedef_file_path) { if (!config.typedef_file_path) {
console.error( console.error(
`\`typedef_file_path\` is required in bun-mariadb.config.ts to generate types.`, `\`typedef_file_path\` is required in bun-mariadb.config to generate types.`,
); );
process.exit(1); process.exit(1);
} }
+4 -2
View File
@@ -1,11 +1,13 @@
export const AppData = { export const AppData = {
ConfigFileName: "bun-mariadb.config.ts", ConfigFileName: "bun-mariadb.config",
MaxBackups: 10, MaxBackups: 10,
MaxExports: 10, MaxExports: 10,
DefaultBackupDirName: ".backups", DefaultBackupDirName: ".backups",
DefaultExportDirName: ".exports", DefaultExportDirName: ".exports",
DbSchemaManagerTableName: "__db_schema_manager__", DbSchemaManagerTableName: "__db_schema_manager__",
DbSchemaFileName: "schema.ts", DbSchemaFileName: "schema",
/** Priority order when resolving config/schema files */
SupportedDataFileExtensions: [".ts", ".js", ".json", ".yaml", ".yml"] as const,
MaxInitRetries: 50, MaxInitRetries: 50,
InitRetryIntervalMilliseconds: 5000, InitRetryIntervalMilliseconds: 5000,
} as const; } as const;
+34 -19
View File
@@ -8,6 +8,10 @@ import {
RequiredENVs, RequiredENVs,
} from "../types"; } from "../types";
import setMariaDBClient from "./set-mariadb-client"; import setMariaDBClient from "./set-mariadb-client";
import {
resolveAndLoadDataFile,
supportedDataFileNames,
} from "../utils/resolve-and-load-data-file";
/** /**
* # Declare Global Variables * # Declare Global Variables
@@ -16,6 +20,8 @@ declare global {
var CONFIG: BunMariaDBConfig; var CONFIG: BunMariaDBConfig;
var DB_SCHEMA: BUN_MARIADB_DatabaseSchemaType; var DB_SCHEMA: BUN_MARIADB_DatabaseSchemaType;
var MARIADB_CLIENT: Bun.SQL; var MARIADB_CLIENT: Bun.SQL;
var CONFIG_FILE_PATH: string;
var SCHEMA_FILE_PATH: string;
} }
export default function init(): void { export default function init(): void {
@@ -23,21 +29,23 @@ export default function init(): void {
const { ROOT_DIR } = grabDirNames(); const { ROOT_DIR } = grabDirNames();
const { ConfigFileName } = AppData; const { ConfigFileName } = AppData;
const ConfigFilePath = path.join(ROOT_DIR, ConfigFileName); const loadedConfig = resolveAndLoadDataFile<BunMariaDBConfig>(
ROOT_DIR,
ConfigFileName,
);
if (!fs.existsSync(ConfigFilePath)) { if (!loadedConfig) {
console.error( console.error(
`Please create a \`${ConfigFileName}\` file at the root of your project.`, `Please create a ${supportedDataFileNames(ConfigFileName)} file at the root of your project.`,
); );
process.exit(1); process.exit(1);
} }
const ConfigImport = require(ConfigFilePath); const Config = loadedConfig.data;
const Config = ConfigImport["default"] as BunMariaDBConfig;
if (!Config) { if (!Config || typeof Config !== "object") {
console.error( console.error(
`No default export from \`${ConfigFilePath}\`. Please export a default module.`, `Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`,
); );
process.exit(1); process.exit(1);
} }
@@ -63,7 +71,7 @@ export default function init(): void {
if (!Config.db_dir) { if (!Config.db_dir) {
console.error( console.error(
`\`db_dir\` is required in your config. This directory holds all database related configuration. Also note that a \`${AppData["DbSchemaFileName"]}\` file is also required in this directory to define your database schema`, `\`db_dir\` is required in your config. This directory holds all database related configuration. Also note that a ${supportedDataFileNames(AppData.DbSchemaFileName)} file is also required in this directory to define your database schema`,
); );
process.exit(1); process.exit(1);
} }
@@ -73,19 +81,27 @@ export default function init(): void {
fs.mkdirSync(db_dir, { recursive: true }); fs.mkdirSync(db_dir, { recursive: true });
} }
const DBSchemaFilePath = path.join(db_dir, AppData["DbSchemaFileName"]); const loadedSchema =
resolveAndLoadDataFile<BUN_MARIADB_DatabaseSchemaType>(
db_dir,
AppData.DbSchemaFileName,
);
if (!fs.existsSync(DBSchemaFilePath)) { if (!loadedSchema) {
console.error( console.error(
`Please create a schema file at \`${DBSchemaFilePath}\`. Don't forget to export a default module from this file.`, `Please create a schema file (${supportedDataFileNames(AppData.DbSchemaFileName)}) in \`${db_dir}\`${loadedConfig.format === "ts" || loadedConfig.format === "js" ? ". Don't forget to export a default module from .ts/.js files." : "."}`,
); );
process.exit(1); process.exit(1);
} }
const DbSchemaImport = require(DBSchemaFilePath); const DbSchema = loadedSchema.data;
const DbSchema = DbSchemaImport[
"default" if (!DbSchema || typeof DbSchema !== "object") {
] as BUN_MARIADB_DatabaseSchemaType; console.error(
`Invalid schema in \`${loadedSchema.path}\`. Expected a schema object${loadedSchema.format === "ts" || loadedSchema.format === "js" ? " (export default)" : ""}.`,
);
process.exit(1);
}
const backup_dir = const backup_dir =
Config.db_backup_dir || AppData["DefaultBackupDirName"]; Config.db_backup_dir || AppData["DefaultBackupDirName"];
@@ -95,16 +111,15 @@ export default function init(): void {
fs.mkdirSync(BackupDir, { recursive: true }); fs.mkdirSync(BackupDir, { recursive: true });
} }
const ExportDir = path.resolve( const ExportDir = path.resolve(db_dir, AppData["DefaultExportDirName"]);
db_dir,
AppData["DefaultExportDirName"],
);
if (!fs.existsSync(ExportDir)) { if (!fs.existsSync(ExportDir)) {
fs.mkdirSync(ExportDir, { recursive: true }); fs.mkdirSync(ExportDir, { recursive: true });
} }
global.CONFIG = Config; global.CONFIG = Config;
global.DB_SCHEMA = DbSchema; global.DB_SCHEMA = DbSchema;
global.CONFIG_FILE_PATH = loadedConfig.path;
global.SCHEMA_FILE_PATH = loadedSchema.path;
if (!global.CONFIG) { if (!global.CONFIG) {
console.error(`Couldn't grab global Config.`); console.error(`Couldn't grab global Config.`);
+4 -1
View File
@@ -68,6 +68,7 @@ export default async function syncIndexes({
protectedIndexNames.add(field.fieldName); protectedIndexNames.add(field.fieldName);
} }
} }
for (const constraint of table.uniqueConstraints || []) { for (const constraint of table.uniqueConstraints || []) {
if (constraint.constraintName) { if (constraint.constraintName) {
protectedIndexNames.add(constraint.constraintName); protectedIndexNames.add(constraint.constraintName);
@@ -94,7 +95,9 @@ export default async function syncIndexes({
continue; continue;
} }
const schemaIndex = table.indexes?.find((i) => i.indexName === indexName); const schemaIndex = table.indexes?.find(
(i) => i.indexName === indexName,
);
if (!schemaIndex) { if (!schemaIndex) {
console.log(`Dropping index: ${indexName}`); console.log(`Dropping index: ${indexName}`);
+64 -28
View File
@@ -2,6 +2,7 @@ import fs from "fs";
import path from "path"; import path from "path";
import { AppData } from "../data/app-data"; import { AppData } from "../data/app-data";
import grabDirNames from "../data/grab-dir-names"; import grabDirNames from "../data/grab-dir-names";
import { isSchemaFileName } from "./resolve-and-load-data-file";
export const ExportArchiveMembers = { export const ExportArchiveMembers = {
SqlFileName: "dump.sql", SqlFileName: "dump.sql",
@@ -10,7 +11,9 @@ export const ExportArchiveMembers = {
export type ExportArchiveContents = { export type ExportArchiveContents = {
sql: string; sql: string;
schemaTs: string; schema: string;
/** Archive member basename, e.g. schema.ts / schema.json / schema.yaml */
schemaFileName: string;
}; };
const ARCHIVE_EXTENSIONS = [".tar.gz", ".tgz", ".tar", ".zip"] as const; const ARCHIVE_EXTENSIONS = [".tar.gz", ".tgz", ".tar", ".zip"] as const;
@@ -25,7 +28,7 @@ export function isSqlPath(filePath: string): boolean {
} }
/** /**
* Create a portable export archive (tar / tar.gz / zip) with dump.sql + schema.ts. * Create a portable export archive (tar / tar.gz / zip) with dump.sql + schema file.
*/ */
export async function writeExportArchive({ export async function writeExportArchive({
contents, contents,
@@ -42,7 +45,7 @@ export async function writeExportArchive({
const members = { const members = {
[ExportArchiveMembers.SqlFileName]: contents.sql, [ExportArchiveMembers.SqlFileName]: contents.sql,
[ExportArchiveMembers.SchemaFileName]: contents.schemaTs, [contents.schemaFileName]: contents.schema,
}; };
const gzip = lower.endsWith(".gz") || lower.endsWith(".tgz"); const gzip = lower.endsWith(".gz") || lower.endsWith(".tgz");
@@ -73,12 +76,7 @@ export async function readExportArchive(
(await readArchiveMember(files, ExportArchiveMembers.SqlFileName)) ?? (await readArchiveMember(files, ExportArchiveMembers.SqlFileName)) ??
(await readFirstMatching(files, (name) => name.endsWith(".sql"))); (await readFirstMatching(files, (name) => name.endsWith(".sql")));
const schemaTs = const schemaEntry = await readFirstSchemaMember(files);
(await readArchiveMember(
files,
ExportArchiveMembers.SchemaFileName,
)) ??
(await readFirstMatching(files, (name) => name.endsWith("schema.ts")));
if (!sql) { if (!sql) {
throw new Error( throw new Error(
@@ -86,20 +84,23 @@ export async function readExportArchive(
); );
} }
if (!schemaTs) { if (!schemaEntry) {
throw new Error( throw new Error(
`Archive is missing schema TypeScript (expected \`${ExportArchiveMembers.SchemaFileName}\`)`, `Archive is missing schema file (expected \`${AppData.DbSchemaFileName}.ts|json|yaml|yml\`)`,
); );
} }
return { sql, schemaTs }; return {
sql,
schema: schemaEntry.content,
schemaFileName: schemaEntry.fileName,
};
} }
async function readArchiveMember( async function readArchiveMember(
files: Map<string, File>, files: Map<string, File>,
name: string, name: string,
): Promise<string | null> { ): Promise<string | null> {
// Exact match, or basename match for nested paths
for (const [entry, file] of files) { for (const [entry, file] of files) {
if (entry === name || path.basename(entry) === name) { if (entry === name || path.basename(entry) === name) {
return await file.text(); return await file.text();
@@ -120,6 +121,21 @@ async function readFirstMatching(
return null; return null;
} }
async function readFirstSchemaMember(
files: Map<string, File>,
): Promise<{ content: string; fileName: string } | null> {
for (const [entry, file] of files) {
const base = path.basename(entry);
if (isSchemaFileName(base)) {
return {
content: await file.text(),
fileName: base === AppData.DbSchemaFileName ? "schema.ts" : base,
};
}
}
return null;
}
async function writeZipArchive({ async function writeZipArchive({
contents, contents,
outPath, outPath,
@@ -137,12 +153,9 @@ async function writeZipArchive({
try { try {
const sqlPath = path.join(tempDir, ExportArchiveMembers.SqlFileName); const sqlPath = path.join(tempDir, ExportArchiveMembers.SqlFileName);
const schemaPath = path.join( const schemaPath = path.join(tempDir, contents.schemaFileName);
tempDir,
ExportArchiveMembers.SchemaFileName,
);
fs.writeFileSync(sqlPath, contents.sql, "utf-8"); fs.writeFileSync(sqlPath, contents.sql, "utf-8");
fs.writeFileSync(schemaPath, contents.schemaTs, "utf-8"); fs.writeFileSync(schemaPath, contents.schema, "utf-8");
const absOut = path.resolve(outPath); const absOut = path.resolve(outPath);
const proc = Bun.spawn( const proc = Bun.spawn(
@@ -152,7 +165,7 @@ async function writeZipArchive({
"-j", "-j",
absOut, absOut,
ExportArchiveMembers.SqlFileName, ExportArchiveMembers.SqlFileName,
ExportArchiveMembers.SchemaFileName, contents.schemaFileName,
], ],
{ {
cwd: tempDir, cwd: tempDir,
@@ -208,25 +221,24 @@ async function readZipArchive(
const sql = findFileContents(tempDir, (name) => const sql = findFileContents(tempDir, (name) =>
name === ExportArchiveMembers.SqlFileName || name.endsWith(".sql"), name === ExportArchiveMembers.SqlFileName || name.endsWith(".sql"),
); );
const schemaTs = findFileContents( const schemaHit = findSchemaFile(tempDir);
tempDir,
(name) =>
name === ExportArchiveMembers.SchemaFileName ||
name.endsWith("schema.ts"),
);
if (!sql) { if (!sql) {
throw new Error( throw new Error(
`Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`, `Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`,
); );
} }
if (!schemaTs) { if (!schemaHit) {
throw new Error( throw new Error(
`Archive is missing schema TypeScript (expected \`${ExportArchiveMembers.SchemaFileName}\`)`, `Archive is missing schema file (expected \`${AppData.DbSchemaFileName}.ts|json|yaml|yml\`)`,
); );
} }
return { sql, schemaTs }; return {
sql,
schema: schemaHit.content,
schemaFileName: schemaHit.fileName,
};
} finally { } finally {
fs.rmSync(tempDir, { recursive: true, force: true }); fs.rmSync(tempDir, { recursive: true, force: true });
} }
@@ -250,3 +262,27 @@ function findFileContents(
} }
return null; return null;
} }
function findSchemaFile(
dir: string,
): { content: string; fileName: string } | null {
const stack = [dir];
while (stack.length) {
const current = stack.pop()!;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const full = path.join(current, entry.name);
if (entry.isDirectory()) {
stack.push(full);
} else if (isSchemaFileName(entry.name)) {
return {
content: fs.readFileSync(full, "utf-8"),
fileName:
entry.name === AppData.DbSchemaFileName
? "schema.ts"
: entry.name,
};
}
}
}
return null;
}
+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),
};
}