diff --git a/src/commands/export.ts b/src/commands/export.ts index fd861f2..1eee33e 100644 --- a/src/commands/export.ts +++ b/src/commands/export.ts @@ -3,10 +3,14 @@ import path from "path"; import fs from "fs"; import chalk from "chalk"; import grabDBDir from "../utils/grab-db-dir"; -import { AppData } from "../data/app-data"; import { dumpDatabase } from "../utils/mariadb-dump-restore"; import { writeExportArchive } from "../utils/export-archive"; import trimExports from "../utils/trim-exports"; +import { + resolveDataFile, + supportedDataFileNames, +} from "../utils/resolve-and-load-data-file"; +import { AppData } from "../data/app-data"; function defaultExportFileName( dbName: string, @@ -18,7 +22,7 @@ function defaultExportFileName( export default function () { return new Command("export") .description( - "Export database SQL dump + schema.ts into a portable archive", + "Export database SQL dump + schema into a portable archive", ) .option( "-o, --output ", @@ -39,10 +43,19 @@ export default function () { fs.mkdirSync(export_dir, { recursive: true }); } - const schemaPath = path.join(db_dir, AppData.DbSchemaFileName); - if (!fs.existsSync(schemaPath)) { + const schemaResolved = + (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( - chalk.red(`Schema file not found: ${schemaPath}`), + chalk.red( + `Schema file not found in \`${db_dir}\` (${supportedDataFileNames(AppData.DbSchemaFileName)})`, + ), ); process.exit(1); } @@ -67,10 +80,14 @@ export default function () { try { const sql = await dumpDatabase(config); - const schemaTs = fs.readFileSync(schemaPath, "utf-8"); + const schema = fs.readFileSync(schemaResolved.path, "utf-8"); await writeExportArchive({ - contents: { sql, schemaTs }, + contents: { + sql, + schema, + schemaFileName: schemaResolved.basename, + }, outPath, }); @@ -83,7 +100,7 @@ export default function () { ); console.log( chalk.dim( - `Contains: dump.sql + ${AppData.DbSchemaFileName}`, + `Contains: dump.sql + ${schemaResolved.basename}`, ), ); process.exit(0); diff --git a/src/commands/import.ts b/src/commands/import.ts index 8f056c9..4a35cd5 100644 --- a/src/commands/import.ts +++ b/src/commands/import.ts @@ -6,13 +6,14 @@ import { select } from "@inquirer/prompts"; import grabDBDir from "../utils/grab-db-dir"; import grabSortedExports from "../utils/grab-sorted-exports"; import grabBackupData from "../utils/grab-backup-data"; -import { AppData } from "../data/app-data"; import { restoreDatabase } from "../utils/mariadb-dump-restore"; import { isArchivePath, isSqlPath, readExportArchive, } 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 { const { backup_date } = grabBackupData({ backup_name: name }); @@ -25,7 +26,7 @@ function formatChoice(name: string, index: number): string { export default function () { return new Command("import") .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( "[file]", @@ -33,11 +34,11 @@ export default function () { ) .option( "--sql-only", - "When importing an archive, restore SQL only (skip writing schema.ts)", + "When importing an archive, restore SQL only (skip writing schema)", ) .option( "--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) => { console.log(`Importing database ...`); @@ -113,7 +114,8 @@ export default function () { process.exit(1); } - const { sql, schemaTs } = await readExportArchive(filePath); + const { sql, schema, schemaFileName } = + await readExportArchive(filePath); if (!opts.schemaOnly) { await restoreDatabase(config, sql); @@ -121,14 +123,27 @@ export default function () { } if (!opts.sqlOnly) { - const schemaPath = path.join( + const existing = resolveDataFile( db_dir, AppData.DbSchemaFileName, ); - fs.writeFileSync(schemaPath, schemaTs, "utf-8"); - console.log( - chalk.green(`Schema written → ${schemaPath}`), - ); + 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( + chalk.dim( + `Removed previous schema file: ${existing.basename}`, + ), + ); + } + + fs.writeFileSync(schemaPath, schema, "utf-8"); + console.log(chalk.green(`Schema written → ${schemaPath}`)); } console.log( diff --git a/src/commands/typedef.ts b/src/commands/typedef.ts index dc09c98..ed105eb 100644 --- a/src/commands/typedef.ts +++ b/src/commands/typedef.ts @@ -20,7 +20,7 @@ export default function () { if (!config.typedef_file_path) { 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); } diff --git a/src/data/app-data.ts b/src/data/app-data.ts index c9e7eac..c8aad8e 100644 --- a/src/data/app-data.ts +++ b/src/data/app-data.ts @@ -1,11 +1,13 @@ export const AppData = { - ConfigFileName: "bun-mariadb.config.ts", + ConfigFileName: "bun-mariadb.config", MaxBackups: 10, MaxExports: 10, DefaultBackupDirName: ".backups", DefaultExportDirName: ".exports", 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, InitRetryIntervalMilliseconds: 5000, } as const; diff --git a/src/functions/init.ts b/src/functions/init.ts index c710da6..de97c99 100644 --- a/src/functions/init.ts +++ b/src/functions/init.ts @@ -8,6 +8,10 @@ import { RequiredENVs, } from "../types"; import setMariaDBClient from "./set-mariadb-client"; +import { + resolveAndLoadDataFile, + supportedDataFileNames, +} from "../utils/resolve-and-load-data-file"; /** * # Declare Global Variables @@ -16,6 +20,8 @@ declare global { var CONFIG: BunMariaDBConfig; var DB_SCHEMA: BUN_MARIADB_DatabaseSchemaType; var MARIADB_CLIENT: Bun.SQL; + var CONFIG_FILE_PATH: string; + var SCHEMA_FILE_PATH: string; } export default function init(): void { @@ -23,21 +29,23 @@ export default function init(): void { const { ROOT_DIR } = grabDirNames(); const { ConfigFileName } = AppData; - const ConfigFilePath = path.join(ROOT_DIR, ConfigFileName); + const loadedConfig = resolveAndLoadDataFile( + ROOT_DIR, + ConfigFileName, + ); - if (!fs.existsSync(ConfigFilePath)) { + if (!loadedConfig) { 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); } - const ConfigImport = require(ConfigFilePath); - const Config = ConfigImport["default"] as BunMariaDBConfig; + const Config = loadedConfig.data; - if (!Config) { + if (!Config || typeof Config !== "object") { 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); } @@ -63,7 +71,7 @@ export default function init(): void { if (!Config.db_dir) { 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); } @@ -73,19 +81,27 @@ export default function init(): void { fs.mkdirSync(db_dir, { recursive: true }); } - const DBSchemaFilePath = path.join(db_dir, AppData["DbSchemaFileName"]); + const loadedSchema = + resolveAndLoadDataFile( + db_dir, + AppData.DbSchemaFileName, + ); - if (!fs.existsSync(DBSchemaFilePath)) { + if (!loadedSchema) { 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); } - const DbSchemaImport = require(DBSchemaFilePath); - const DbSchema = DbSchemaImport[ - "default" - ] as BUN_MARIADB_DatabaseSchemaType; + const DbSchema = loadedSchema.data; + + if (!DbSchema || typeof DbSchema !== "object") { + 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 = Config.db_backup_dir || AppData["DefaultBackupDirName"]; @@ -95,16 +111,15 @@ export default function init(): void { fs.mkdirSync(BackupDir, { recursive: true }); } - const ExportDir = path.resolve( - db_dir, - AppData["DefaultExportDirName"], - ); + const ExportDir = path.resolve(db_dir, AppData["DefaultExportDirName"]); if (!fs.existsSync(ExportDir)) { fs.mkdirSync(ExportDir, { recursive: true }); } global.CONFIG = Config; global.DB_SCHEMA = DbSchema; + global.CONFIG_FILE_PATH = loadedConfig.path; + global.SCHEMA_FILE_PATH = loadedSchema.path; if (!global.CONFIG) { console.error(`Couldn't grab global Config.`); diff --git a/src/lib/schema/sync-indexes.ts b/src/lib/schema/sync-indexes.ts index be1b350..c1be727 100644 --- a/src/lib/schema/sync-indexes.ts +++ b/src/lib/schema/sync-indexes.ts @@ -68,6 +68,7 @@ export default async function syncIndexes({ protectedIndexNames.add(field.fieldName); } } + for (const constraint of table.uniqueConstraints || []) { if (constraint.constraintName) { protectedIndexNames.add(constraint.constraintName); @@ -94,7 +95,9 @@ export default async function syncIndexes({ continue; } - const schemaIndex = table.indexes?.find((i) => i.indexName === indexName); + const schemaIndex = table.indexes?.find( + (i) => i.indexName === indexName, + ); if (!schemaIndex) { console.log(`Dropping index: ${indexName}`); diff --git a/src/utils/export-archive.ts b/src/utils/export-archive.ts index e5f4c9f..57a48d9 100644 --- a/src/utils/export-archive.ts +++ b/src/utils/export-archive.ts @@ -2,6 +2,7 @@ import fs from "fs"; import path from "path"; import { AppData } from "../data/app-data"; import grabDirNames from "../data/grab-dir-names"; +import { isSchemaFileName } from "./resolve-and-load-data-file"; export const ExportArchiveMembers = { SqlFileName: "dump.sql", @@ -10,7 +11,9 @@ export const ExportArchiveMembers = { export type ExportArchiveContents = { 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; @@ -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({ contents, @@ -42,7 +45,7 @@ export async function writeExportArchive({ const members = { [ExportArchiveMembers.SqlFileName]: contents.sql, - [ExportArchiveMembers.SchemaFileName]: contents.schemaTs, + [contents.schemaFileName]: contents.schema, }; const gzip = lower.endsWith(".gz") || lower.endsWith(".tgz"); @@ -73,12 +76,7 @@ export async function readExportArchive( (await readArchiveMember(files, ExportArchiveMembers.SqlFileName)) ?? (await readFirstMatching(files, (name) => name.endsWith(".sql"))); - const schemaTs = - (await readArchiveMember( - files, - ExportArchiveMembers.SchemaFileName, - )) ?? - (await readFirstMatching(files, (name) => name.endsWith("schema.ts"))); + const schemaEntry = await readFirstSchemaMember(files); if (!sql) { throw new Error( @@ -86,20 +84,23 @@ export async function readExportArchive( ); } - if (!schemaTs) { + if (!schemaEntry) { 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( files: Map, name: string, ): Promise { - // Exact match, or basename match for nested paths for (const [entry, file] of files) { if (entry === name || path.basename(entry) === name) { return await file.text(); @@ -120,6 +121,21 @@ async function readFirstMatching( return null; } +async function readFirstSchemaMember( + files: Map, +): 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({ contents, outPath, @@ -137,12 +153,9 @@ async function writeZipArchive({ try { const sqlPath = path.join(tempDir, ExportArchiveMembers.SqlFileName); - const schemaPath = path.join( - tempDir, - ExportArchiveMembers.SchemaFileName, - ); + const schemaPath = path.join(tempDir, contents.schemaFileName); 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 proc = Bun.spawn( @@ -152,7 +165,7 @@ async function writeZipArchive({ "-j", absOut, ExportArchiveMembers.SqlFileName, - ExportArchiveMembers.SchemaFileName, + contents.schemaFileName, ], { cwd: tempDir, @@ -208,25 +221,24 @@ async function readZipArchive( const sql = findFileContents(tempDir, (name) => name === ExportArchiveMembers.SqlFileName || name.endsWith(".sql"), ); - const schemaTs = findFileContents( - tempDir, - (name) => - name === ExportArchiveMembers.SchemaFileName || - name.endsWith("schema.ts"), - ); + const schemaHit = findSchemaFile(tempDir); if (!sql) { throw new Error( `Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`, ); } - if (!schemaTs) { + if (!schemaHit) { 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 { fs.rmSync(tempDir, { recursive: true, force: true }); } @@ -250,3 +262,27 @@ function findFileContents( } 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; +} diff --git a/src/utils/resolve-and-load-data-file.ts b/src/utils/resolve-and-load-data-file.ts new file mode 100644 index 0000000..6c558bf --- /dev/null +++ b/src/utils/resolve-and-load-data-file.ts @@ -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 = 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(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( + dir: string, + baseName: string, +): LoadedDataFile | null { + const resolved = resolveDataFile(dir, baseName); + if (!resolved) { + return null; + } + + return { + ...resolved, + data: loadDataFile(resolved), + }; +}