Compare commits

..
18 Commits
83 changed files with 2741 additions and 806 deletions
+3 -1
View File
@@ -33,4 +33,6 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
.DS_Store .DS_Store
/test /test
.vscode .vscode
.dump .dump
/.bun-mariadb
+109 -73
View File
@@ -85,13 +85,13 @@ bun add github:moduletrace/bun-mariadb
Connection settings are read from the environment (not the config file): Connection settings are read from the environment (not the config file):
| Variable | Required | Description | | Variable | Required | Description |
| --------------------------------- | -------- | ------------------------------------ | | --------------------------------- | -------- | ---------------------------------- |
| `BUN_MARIADB_SERVER_HOST` | Yes | MariaDB host | | `BUN_MARIADB_SERVER_HOST` | Yes | MariaDB host |
| `BUN_MARIADB_SERVER_USERNAME` | Yes | Database user | | `BUN_MARIADB_SERVER_USERNAME` | Yes | Database user |
| `BUN_MARIADB_SERVER_PASSWORD` | Yes | Database password | | `BUN_MARIADB_SERVER_PASSWORD` | Yes | Database password |
| `BUN_MARIADB_SERVER_PORT` | No | Port (server default if omitted) | | `BUN_MARIADB_SERVER_PORT` | No | Port (server default if omitted) |
| `BUN_MARIADB_SERVER_SSL_KEY_PATH` | No | Optional SSL key path (legacy/env) | | `BUN_MARIADB_SERVER_SSL_KEY_PATH` | No | Optional SSL key path (legacy/env) |
Example `.env`: Example `.env`:
@@ -135,9 +135,22 @@ const schema: BUN_MARIADB_DatabaseSchemaType = {
{ {
tableName: "users", tableName: "users",
fields: [ fields: [
{ fieldName: "first_name", dataType: "VARCHAR", integerLength: 255 }, {
{ fieldName: "last_name", dataType: "VARCHAR", integerLength: 255 }, fieldName: "first_name",
{ fieldName: "email", dataType: "VARCHAR", integerLength: 255, unique: true }, dataType: "VARCHAR",
dataLength: 255,
},
{
fieldName: "last_name",
dataType: "VARCHAR",
dataLength: 255,
},
{
fieldName: "email",
dataType: "VARCHAR",
dataLength: 255,
unique: true,
},
{ fieldName: "bio", dataType: "LONGTEXT", html: true }, { fieldName: "bio", dataType: "LONGTEXT", html: true },
], ],
}, },
@@ -187,19 +200,19 @@ await BunMariaDB.delete({ table: "users", targetId: 1 });
The config file must be named `bun-mariadb.config.ts` and placed at the project root. The config file must be named `bun-mariadb.config.ts` and placed at the project root.
| Field | Type | Required | Description | | Field | Type | Required | Description |
| ------------------- | -------- | -------- | --------------------------------------------------------------------------- | | -------------------- | -------- | -------- | ----------------------------------------------------------------------------- |
| `db_name` | `string` | Yes | MariaDB database name | | `db_name` | `string` | Yes | MariaDB database name |
| `db_dir` | `string` | Yes | Directory for schema, types, and local artifacts (relative to project root) | | `db_dir` | `string` | Yes | Directory for schema, types, and local artifacts (relative to project root) |
| `db_backup_dir` | `string` | No | Backup directory name, relative to `db_dir` (default: `.backups`) | | `db_backup_dir` | `string` | No | Backup directory name, relative to `db_dir` (default: `.backups`) |
| `max_backups` | `number` | No | Max backup files to keep (default: `10`) | | `max_backups` | `number` | No | Max backup files to keep (default: `10`) |
| `max_exports` | `number` | No | Max export archives to keep (default: `10`) | | `max_exports` | `number` | No | Max export archives to keep (default: `10`) |
| `typedef_file_path` | `string` | No | Output path for generated TypeScript types (relative to project root) | | `typedef_file_path` | `string` | No | Output path for generated TypeScript types (relative to project root) |
| `db_config` | `object` | No | Extra options passed to Bun's `SQL` MariaDB adapter | | `db_config` | `object` | No | Extra options passed to Bun's `SQL` MariaDB adapter |
| `charset` | `string` | No | Database charset (default: `utf8mb4`) | | `charset` | `string` | No | Database charset (default: `utf8mb4`) |
| `connection_timeout`| `number` | No | Connection timeout in ms (default: `10000`) | | `connection_timeout` | `number` | No | Connection timeout in ms (default: `10000`) |
| `ssl_ca` | `string` | No | Path to SSL CA certificate (relative to project root) | | `ssl_ca` | `string` | No | Path to SSL CA certificate (relative to project root) |
| `html_sanitize` | `object` | No | Extra HTML sanitizer allowlists (see [HTML Sanitization](#html-sanitization)) | | `html_sanitize` | `object` | No | Extra HTML sanitizer allowlists (see [HTML Sanitization](#html-sanitization)) |
Schema file path is always: `{db_dir}/schema.ts`. Schema file path is always: `{db_dir}/schema.ts`.
@@ -226,9 +239,8 @@ interface BUN_MARIADB_TableSchemaType {
indexes?: BUN_MARIADB_IndexSchemaType[]; indexes?: BUN_MARIADB_IndexSchemaType[];
uniqueConstraints?: BUN_MARIADB_UniqueConstraintSchemaType[]; uniqueConstraints?: BUN_MARIADB_UniqueConstraintSchemaType[];
parentTableName?: string; // inherit / merge fields from another table parentTableName?: string; // inherit / merge fields from another table
tableNameOld?: string; // rename: old name triggers ALTER TABLE RENAME tableNameOld?: string; // rename: old name triggers ALTER TABLE RENAME
collation?: "utf8mb4_bin" | "utf8mb4_unicode_520_ci"; collation?: "utf8mb4_bin" | "utf8mb4_unicode_520_ci";
isVector?: boolean; // mark as vector-oriented table
} }
``` ```
@@ -238,12 +250,38 @@ interface BUN_MARIADB_TableSchemaType {
type BUN_MARIADB_FieldSchemaType = { type BUN_MARIADB_FieldSchemaType = {
fieldName?: string; fieldName?: string;
dataType: dataType:
| "CHAR" | "VARCHAR" | "TEXT" | "TINYTEXT" | "MEDIUMTEXT" | "LONGTEXT" | "CHAR"
| "TINYINT" | "SMALLINT" | "MEDIUMINT" | "INT" | "BIGINT" | "VARCHAR"
| "FLOAT" | "DOUBLE" | "DECIMAL" | "TEXT"
| "BINARY" | "VARBINARY" | "BLOB" | "TINYBLOB" | "MEDIUMBLOB" | "LONGBLOB" | "TINYTEXT"
| "DATE" | "TIME" | "DATETIME" | "TIMESTAMP" | "YEAR" | "MEDIUMTEXT"
| "BOOLEAN" | "UUID" | "JSON" | "INET6" | "ENUM" | "SET" | "VECTOR"; | "LONGTEXT"
| "TINYINT"
| "SMALLINT"
| "MEDIUMINT"
| "INT"
| "BIGINT"
| "FLOAT"
| "DOUBLE"
| "DECIMAL"
| "BINARY"
| "VARBINARY"
| "BLOB"
| "TINYBLOB"
| "MEDIUMBLOB"
| "LONGBLOB"
| "DATE"
| "TIME"
| "DATETIME"
| "TIMESTAMP"
| "YEAR"
| "BOOLEAN"
| "UUID"
| "JSON"
| "INET6"
| "ENUM"
| "SET"
| "VECTOR";
primaryKey?: boolean; primaryKey?: boolean;
autoIncrement?: boolean; autoIncrement?: boolean;
notNullValue?: boolean; notNullValue?: boolean;
@@ -253,11 +291,11 @@ type BUN_MARIADB_FieldSchemaType = {
onUpdate?: string; onUpdate?: string;
onUpdateLiteral?: string; onUpdateLiteral?: string;
foreignKey?: BUN_MARIADB_ForeignKeyType; foreignKey?: BUN_MARIADB_ForeignKeyType;
integerLength?: string | number; // e.g. VARCHAR length dataLength?: string | number; // e.g. VARCHAR length
decimals?: string | number; // DECIMAL scale decimals?: string | number; // DECIMAL scale
options?: (string | number)[]; // ENUM / SET values options?: (string | number)[]; // ENUM / SET values
isVector?: boolean; // native VECTOR column isVector?: boolean; // native VECTOR column
vectorSize?: number; // dimensions (default: 1536) vectorSize?: number; // dimensions (default: 1536)
// Content mode flags (editor metadata); only `html: true` affects runtime: // Content mode flags (editor metadata); only `html: true` affects runtime:
html?: boolean; html?: boolean;
markdown?: boolean; markdown?: boolean;
@@ -368,10 +406,10 @@ Requires `mariadb` or `mysql` on `PATH`.
bunx bun-mariadb export [options] bunx bun-mariadb export [options]
``` ```
| Option | Description | | Option | Description |
| ------------------- | --------------------------------------------------------------------------- | | ---------------- | -------------------------------------------------------------------------- |
| `-o`, `--output` | Output archive path (`.tar.gz` or `.zip`). Defaults to `{db_dir}/.exports` | | `-o`, `--output` | Output archive path (`.tar.gz` or `.zip`). Defaults to `{db_dir}/.exports` |
| `-f`, `--format` | When `--output` is omitted: `tar.gz` (default) or `zip` | | `-f`, `--format` | When `--output` is omitted: `tar.gz` (default) or `zip` |
Creates a portable archive containing: Creates a portable archive containing:
@@ -400,11 +438,11 @@ Requires `mariadb-dump` or `mysqldump` on `PATH`. Zip output also requires `zip`
bunx bun-mariadb import [file] [options] bunx bun-mariadb import [file] [options]
``` ```
| Option | Description | | Option | Description |
| ---------------- | --------------------------------------------------------------------------- | | --------------- | --------------------------------------------------------------------- |
| `[file]` | Path to a `.sql` dump or export archive (`.tar.gz` / `.tar` / `.zip`) | | `[file]` | Path to a `.sql` dump or export archive (`.tar.gz` / `.tar` / `.zip`) |
| `--sql-only` | Archive only: restore SQL, do not overwrite `schema.ts` | | `--sql-only` | Archive only: restore SQL, do not overwrite `schema.ts` |
| `--schema-only` | Archive only: write `schema.ts`, do not restore SQL | | `--schema-only` | Archive only: write `schema.ts`, do not restore SQL |
Behavior: Behavior:
@@ -641,26 +679,26 @@ type ServerQueryParam<T> = {
### Equality Operators ### Equality Operators
| Equality | SQL Equivalent | | Equality | SQL Equivalent |
| ----------------------- | ------------------------------------------------------ | | ----------------------- | ----------------------------- |
| `EQUAL` (default) | `=` | | `EQUAL` (default) | `=` |
| `NOT EQUAL` | `!=` | | `NOT EQUAL` | `!=` |
| `LIKE` | `LIKE '%value%'` | | `LIKE` | `LIKE '%value%'` |
| `LIKE_RAW` | `LIKE 'value'` | | `LIKE_RAW` | `LIKE 'value'` |
| `LIKE_LOWER` | `LOWER(field) LIKE '%value%'` | | `LIKE_LOWER` | `LOWER(field) LIKE '%value%'` |
| `NOT LIKE` | `NOT LIKE '%value%'` | | `NOT LIKE` | `NOT LIKE '%value%'` |
| `GREATER THAN` | `>` | | `GREATER THAN` | `>` |
| `GREATER THAN OR EQUAL` | `>=` | | `GREATER THAN OR EQUAL` | `>=` |
| `LESS THAN` | `<` | | `LESS THAN` | `<` |
| `LESS THAN OR EQUAL` | `<=` | | `LESS THAN OR EQUAL` | `<=` |
| `IN` | `IN (...)` | | `IN` | `IN (...)` |
| `NOT IN` | `NOT IN (...)` | | `NOT IN` | `NOT IN (...)` |
| `BETWEEN` | `BETWEEN a AND b` | | `BETWEEN` | `BETWEEN a AND b` |
| `IS NULL` | `IS NULL` | | `IS NULL` | `IS NULL` |
| `IS NOT NULL` | `IS NOT NULL` | | `IS NOT NULL` | `IS NOT NULL` |
| `REGEXP` | `REGEXP` | | `REGEXP` | `REGEXP` |
| `FULLTEXT` | full-text match | | `FULLTEXT` | full-text match |
| `MATCH` | vector / specialized match | | `MATCH` | vector / specialized match |
```ts ```ts
const res = await BunMariaDB.select({ const res = await BunMariaDB.select({
@@ -704,7 +742,6 @@ MariaDB native `VECTOR(n)` columns and `VECTOR INDEX` are supported (MariaDB 11.
```ts ```ts
{ {
tableName: "documents", tableName: "documents",
isVector: true,
fields: [ fields: [
{ {
fieldName: "embedding", fieldName: "embedding",
@@ -715,7 +752,7 @@ MariaDB native `VECTOR(n)` columns and `VECTOR INDEX` are supported (MariaDB 11.
{ {
fieldName: "title", fieldName: "title",
dataType: "VARCHAR", dataType: "VARCHAR",
integerLength: 255, dataLength: 255,
}, },
], ],
indexes: [ indexes: [
@@ -825,11 +862,11 @@ const res = await BunMariaDB.select<BUN_MARIADB_MY_APP_USERS>({
Every table automatically receives: Every table automatically receives:
| Field | Type | Description | | Field | Type | Description |
| ------------ | ------------------------------- | -------------------------------------- | | ------------ | -------------------------------------------- | -------------------------------------- |
| `id` | `BIGINT PRIMARY KEY AUTO_INCREMENT NOT NULL` | Unique row identifier | | `id` | `BIGINT PRIMARY KEY AUTO_INCREMENT NOT NULL` | Unique row identifier |
| `created_at` | `BIGINT` | Unix timestamp set on insert | | `created_at` | `BIGINT` | Unix timestamp set on insert |
| `updated_at` | `BIGINT` | Unix timestamp updated on every update | | `updated_at` | `BIGINT` | Unix timestamp updated on every update |
You do not need to declare these in your schema. You do not need to declare these in your schema.
@@ -865,7 +902,6 @@ bun-mariadb/
│ │ ├── create-db-schema.ts │ │ ├── create-db-schema.ts
│ │ ├── create-table.ts │ │ ├── create-table.ts
│ │ ├── update-table.ts │ │ ├── update-table.ts
│ │ ├── recreate-table.ts
│ │ ├── sync-indexes.ts │ │ ├── sync-indexes.ts
│ │ └── ... │ │ └── ...
│ ├── types/ │ ├── types/
+18 -8
View File
@@ -3,16 +3,17 @@ 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(dbName, format) { function defaultExportFileName(dbName, format) {
return `${dbName}-${Date.now()}.${format}`; return `${dbName}-${Date.now()}.${format}`;
} }
export default function () { export default function () {
return new Command("export") return new Command("export")
.description("Export database SQL dump + schema.ts into a portable archive") .description("Export database SQL dump + schema into a portable archive")
.option("-o, --output <path>", "Output archive path (.tar.gz or .zip). Defaults to db exports dir") .option("-o, --output <path>", "Output archive path (.tar.gz or .zip). Defaults to db exports dir")
.option("-f, --format <format>", "Archive format when --output is omitted: tar.gz | zip", "tar.gz") .option("-f, --format <format>", "Archive format when --output is omitted: tar.gz | zip", "tar.gz")
.action(async (opts) => { .action(async (opts) => {
@@ -22,9 +23,14 @@ export default function () {
if (!fs.existsSync(export_dir)) { if (!fs.existsSync(export_dir)) {
fs.mkdirSync(export_dir, { recursive: true }); fs.mkdirSync(export_dir, { recursive: true });
} }
const schemaPath = path.join(db_dir, AppData.DbSchemaFileName); const schemaResolved = (global.SCHEMA_FILE_PATH &&
if (!fs.existsSync(schemaPath)) { fs.existsSync(global.SCHEMA_FILE_PATH) && {
console.error(chalk.red(`Schema file not found: ${schemaPath}`)); 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 in \`${db_dir}\` (${supportedDataFileNames(AppData.DbSchemaFileName)})`));
process.exit(1); process.exit(1);
} }
const formatRaw = String(opts.format || "tar.gz").toLowerCase(); const formatRaw = String(opts.format || "tar.gz").toLowerCase();
@@ -42,16 +48,20 @@ 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,
}); });
if (path.dirname(outPath) === export_dir) { if (path.dirname(outPath) === export_dir) {
trimExports({ config }); trimExports({ config });
} }
console.log(`${chalk.bold(chalk.green(`DB Export Success!`))}${outPath}`); console.log(`${chalk.bold(chalk.green(`DB Export Success!`))}${outPath}`);
console.log(chalk.dim(`Contains: dump.sql + ${AppData.DbSchemaFileName}`)); console.log(chalk.dim(`Contains: dump.sql + ${schemaResolved.basename}`));
process.exit(0); process.exit(0);
} }
catch (error) { catch (error) {
+15 -7
View File
@@ -6,9 +6,10 @@ 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 { isArchivePath, isSqlPath, readExportArchive, } from "../utils/export-archive"; 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, index) { function formatChoice(name, index) {
const { backup_date } = grabBackupData({ backup_name: name }); const { backup_date } = grabBackupData({ backup_name: name });
const time = Number.isNaN(backup_date.getTime()) const time = Number.isNaN(backup_date.getTime())
@@ -18,10 +19,10 @@ function formatChoice(name, index) {
} }
export default function () { export default function () {
return new Command("import") return new Command("import")
.description("Import an SQL dump, or a full export archive (SQL + schema.ts)") .description("Import an SQL dump, or a full export archive (SQL + schema)")
.argument("[file]", "Path to .sql file or export archive (.tar.gz / .tar / .zip)") .argument("[file]", "Path to .sql file or export archive (.tar.gz / .tar / .zip)")
.option("--sql-only", "When importing an archive, restore SQL only (skip writing schema.ts)") .option("--sql-only", "When importing an archive, restore SQL only (skip writing schema)")
.option("--schema-only", "When importing an archive, write schema.ts only (skip SQL restore)") .option("--schema-only", "When importing an archive, write schema only (skip SQL restore)")
.action(async (fileArg, opts) => { .action(async (fileArg, opts) => {
console.log(`Importing database ...`); console.log(`Importing database ...`);
const config = global.CONFIG; const config = global.CONFIG;
@@ -65,14 +66,21 @@ export default function () {
console.error(chalk.red(`Cannot combine --sql-only and --schema-only.`)); console.error(chalk.red(`Cannot combine --sql-only and --schema-only.`));
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);
console.log(chalk.green(`SQL restored from archive`)); console.log(chalk.green(`SQL restored from archive`));
} }
if (!opts.sqlOnly) { if (!opts.sqlOnly) {
const schemaPath = path.join(db_dir, AppData.DbSchemaFileName); const existing = resolveDataFile(db_dir, 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(chalk.dim(`Removed previous schema file: ${existing.basename}`));
}
fs.writeFileSync(schemaPath, schema, "utf-8");
console.log(chalk.green(`Schema written → ${schemaPath}`)); console.log(chalk.green(`Schema written → ${schemaPath}`));
} }
console.log(`${chalk.bold(chalk.green(`DB Import Success!`))}${filePath}`); console.log(`${chalk.bold(chalk.green(`DB Import Success!`))}${filePath}`);
Vendored Regular → Executable
View File
+1 -1
View File
@@ -14,7 +14,7 @@ export default function () {
const { ROOT_DIR } = grabDirNames(); const { ROOT_DIR } = grabDirNames();
const finaldbSchema = appendDefaultFieldsToDbSchema({ dbSchema }); const finaldbSchema = appendDefaultFieldsToDbSchema({ dbSchema });
if (!config.typedef_file_path) { if (!config.typedef_file_path) {
console.error(`\`typedef_file_path\` is required in bun-mariadb.config.ts to generate types.`); console.error(`\`typedef_file_path\` is required in bun-mariadb.config to generate types.`);
process.exit(1); process.exit(1);
} }
const out_file = path.resolve(ROOT_DIR, config.typedef_file_path); const out_file = path.resolve(ROOT_DIR, config.typedef_file_path);
+4 -2
View File
@@ -1,11 +1,13 @@
export declare const AppData: { export declare const AppData: {
readonly ConfigFileName: "bun-mariadb.config.ts"; readonly ConfigFileName: "bun-mariadb.config";
readonly MaxBackups: 10; readonly MaxBackups: 10;
readonly MaxExports: 10; readonly MaxExports: 10;
readonly DefaultBackupDirName: ".backups"; readonly DefaultBackupDirName: ".backups";
readonly DefaultExportDirName: ".exports"; readonly DefaultExportDirName: ".exports";
readonly DbSchemaManagerTableName: "__db_schema_manager__"; readonly DbSchemaManagerTableName: "__db_schema_manager__";
readonly DbSchemaFileName: "schema.ts"; readonly DbSchemaFileName: "schema";
/** Priority order when resolving config/schema files */
readonly SupportedDataFileExtensions: readonly [".ts", ".js", ".json", ".yaml", ".yml"];
readonly MaxInitRetries: 50; readonly MaxInitRetries: 50;
readonly InitRetryIntervalMilliseconds: 5000; readonly InitRetryIntervalMilliseconds: 5000;
}; };
+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"],
MaxInitRetries: 50, MaxInitRetries: 50,
InitRetryIntervalMilliseconds: 5000, InitRetryIntervalMilliseconds: 5000,
}; };
+2
View File
@@ -6,5 +6,7 @@ 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;
+18 -13
View File
@@ -4,19 +4,19 @@ import { AppData } from "../data/app-data";
import grabDirNames from "../data/grab-dir-names"; import grabDirNames from "../data/grab-dir-names";
import { RequiredENVs, } from "../types"; import { RequiredENVs, } from "../types";
import setMariaDBClient from "./set-mariadb-client"; import setMariaDBClient from "./set-mariadb-client";
import { resolveAndLoadDataFile, supportedDataFileNames, } from "../utils/resolve-and-load-data-file";
export default function init() { export default function init() {
try { try {
const { ROOT_DIR } = grabDirNames(); const { ROOT_DIR } = grabDirNames();
const { ConfigFileName } = AppData; 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.`); console.error(`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"]; if (!Config || typeof Config !== "object") {
if (!Config) { console.error(`Invalid config in \`${loadedConfig.path}\`. Expected a config object${loadedConfig.format === "ts" || loadedConfig.format === "js" ? " (export default)" : ""}.`);
console.error(`No default export from \`${ConfigFilePath}\`. Please export a default module.`);
process.exit(1); process.exit(1);
} }
if (!Config.db_name) { if (!Config.db_name) {
@@ -34,20 +34,23 @@ export default function init() {
} }
}); });
if (!Config.db_dir) { 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`); console.error(`\`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);
} }
const db_dir = path.resolve(ROOT_DIR, Config.db_dir); const db_dir = path.resolve(ROOT_DIR, Config.db_dir);
if (!fs.existsSync(db_dir)) { if (!fs.existsSync(db_dir)) {
fs.mkdirSync(db_dir, { recursive: true }); 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.`); console.error(`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 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); process.exit(1);
} }
const DbSchemaImport = require(DBSchemaFilePath);
const DbSchema = DbSchemaImport["default"];
const backup_dir = Config.db_backup_dir || AppData["DefaultBackupDirName"]; const backup_dir = Config.db_backup_dir || AppData["DefaultBackupDirName"];
const BackupDir = path.resolve(db_dir, backup_dir); const BackupDir = path.resolve(db_dir, backup_dir);
if (!fs.existsSync(BackupDir)) { if (!fs.existsSync(BackupDir)) {
@@ -59,6 +62,8 @@ export default function init() {
} }
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.`);
process.exit(1); process.exit(1);
+2 -1
View File
@@ -17,4 +17,5 @@ declare const BunMariaDB: {
}; };
}; };
export default BunMariaDB; export default BunMariaDB;
export type { BunMariaDBConfig, BUN_MARIADB_DatabaseSchemaType, BUN_MARIADB_TableSchemaType, BUN_MARIADB_FieldSchemaType, BUN_MARIADB_IndexSchemaType, BUN_MARIADB_UniqueConstraintSchemaType, BUN_MARIADB_ForeignKeyType, DBResponseObject, ServerQueryParam, } from "./types"; export type * from "./types";
export { UsersOmitedFields, MariaDBCollations, MariaDBCharsets, TextFieldTypesArray, BUN_MARIADB_DATATYPES, MariaDBIndexTypes, ServerQueryOperators, ServerQueryEqualities, SQlComparisons, DataCrudRequestMethods, DataCrudRequestMethodsLowerCase, DsqlCrudActions, QueryFields, DockerComposeServices, IndexTypes, DefaultFields, RequiredENVs, } from "./types";
+1
View File
@@ -19,3 +19,4 @@ const BunMariaDB = {
}, },
}; };
export default BunMariaDB; export default BunMariaDB;
export { UsersOmitedFields, MariaDBCollations, MariaDBCharsets, TextFieldTypesArray, BUN_MARIADB_DATATYPES, MariaDBIndexTypes, ServerQueryOperators, ServerQueryEqualities, SQlComparisons, DataCrudRequestMethods, DataCrudRequestMethodsLowerCase, DsqlCrudActions, QueryFields, DockerComposeServices, IndexTypes, DefaultFields, RequiredENVs, } from "./types";
+1
View File
@@ -49,6 +49,7 @@ export default async function dbHandler({ query, values, config }) {
single_res: res_array?.[0], single_res: res_array?.[0],
insert_return, insert_return,
count, count,
db_res: res,
}; };
} }
catch (error) { catch (error) {
+3 -2
View File
@@ -12,7 +12,7 @@ export default async function DbDelete({ table, query, targetId, config, }) {
finalQuery = _.merge(finalQuery, { finalQuery = _.merge(finalQuery, {
query: { query: {
id: { id: {
value: String(targetId), value: Number(targetId),
}, },
}, },
}); });
@@ -41,8 +41,9 @@ export default async function DbDelete({ table, query, targetId, config, }) {
}); });
if (!res.success) { if (!res.success) {
return { return {
success: false,
msg: "Database delete failed", msg: "Database delete failed",
...res,
success: false,
debug: { debug: {
sqlObj, sqlObj,
}, },
+2 -1
View File
@@ -38,8 +38,9 @@ export default async function DbInsert({ table, data, update_on_duplicate, confi
}); });
if (!res.success) { if (!res.success) {
return { return {
success: false,
msg: "Database insert failed", msg: "Database insert failed",
...res,
success: false,
debug: { debug: {
sqlObj, sqlObj,
}, },
+3 -2
View File
@@ -12,7 +12,7 @@ export default async function DbSelect({ table, query, count, targetId, config,
finalQuery = _.merge(finalQuery, { finalQuery = _.merge(finalQuery, {
query: { query: {
id: { id: {
value: String(targetId), value: Number(targetId),
}, },
}, },
}); });
@@ -28,8 +28,9 @@ export default async function DbSelect({ table, query, count, targetId, config,
}); });
if (!res.success) { if (!res.success) {
return { return {
success: false,
msg: "Database select failed", msg: "Database select failed",
...res,
success: false,
debug: { debug: {
sqlObj, sqlObj,
sql: sqlObj.string, sql: sqlObj.string,
+6 -2
View File
@@ -9,8 +9,9 @@ export default async function DbSQL({ sql, values }) {
}); });
if (!res.success) { if (!res.success) {
return { return {
success: false,
msg: "Database query failed", msg: "Database query failed",
...res,
success: false,
debug: { debug: {
sqlObj: { sqlObj: {
sql: trimmedSql, sql: trimmedSql,
@@ -24,7 +25,10 @@ export default async function DbSQL({ sql, values }) {
const single_res = isSelect ? payload?.[0] : res.single_res; const single_res = isSelect ? payload?.[0] : res.single_res;
const singleRaw = res.single_res; const singleRaw = res.single_res;
return { return {
success: true, ...res,
success: isSelect
? Boolean(single_res) || Boolean(payload?.[0])
: true,
payload, payload,
single_res, single_res,
debug: { debug: {
+7 -22
View File
@@ -13,7 +13,7 @@ export default async function DbUpdate({ table, data, query, targetId, config, }
finalQuery = _.merge(finalQuery, { finalQuery = _.merge(finalQuery, {
query: { query: {
id: { id: {
value: String(targetId), value: Number(targetId),
}, },
}, },
}); });
@@ -60,41 +60,26 @@ export default async function DbUpdate({ table, data, query, targetId, config, }
values: values, values: values,
config, config,
}); });
if (res.error) {
return res;
}
sqlObj.string = sql; sqlObj.string = sql;
sqlObj.values = values; sqlObj.values = values;
let updated_sql = ``; let updated_sql = ``;
let updated_sql_values = []; let updated_sql_values = [];
updated_sql += `SELECT * FROM ${quoteIdentifier(table)} ${whereClause}`; updated_sql += `SELECT * FROM ${quoteIdentifier(table)} ${whereClause}`;
updated_sql_values = [...updated_sql_values, ...sqlQueryObj.values]; updated_sql_values = [...sqlQueryObj.values];
updated_sql += ` AND `;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (!key)
continue;
if (key == "updated_at")
continue;
const isLast = i == keys.length - 1;
updated_sql += ` ${quoteIdentifier(key)}=?`;
updated_sql_values.push(finalData[key] ?? null);
if (!isLast) {
updated_sql += ` AND `;
}
}
const updated_res = await dbHandler({ const updated_res = await dbHandler({
query: updated_sql, query: updated_sql,
values: updated_sql_values, values: updated_sql_values,
config, config,
}); });
const affected_rows = updated_res.payload?.length;
return { return {
...res, ...updated_res,
success: Boolean(affected_rows),
insert_return: {
affected_rows,
},
debug: { debug: {
sqlObj, sqlObj,
}, },
db_res: res.db_res,
}; };
} }
catch (error) { catch (error) {
+7 -1
View File
@@ -1,2 +1,8 @@
import type { BUN_MARIADB_FieldSchemaType } from "../../types"; import type { BUN_MARIADB_FieldSchemaType } from "../../types";
export default function buildColumnDefinition(field: BUN_MARIADB_FieldSchemaType): string; export type BuildColumnDefinitionOptions = {
/** UNIQUE is managed by syncUniqueConstraints on existing tables */
omitUnique?: boolean;
};
export default function buildColumnDefinition(field: BUN_MARIADB_FieldSchemaType, options?: BuildColumnDefinitionOptions): string;
/** Whether schema field requires NOT NULL */
export declare function fieldRequiresNotNull(field: BUN_MARIADB_FieldSchemaType): boolean;
+9 -2
View File
@@ -1,7 +1,7 @@
import isVectorField from "./is-vector-field"; import isVectorField from "./is-vector-field";
import mapDataType from "./map-data-types"; import mapDataType from "./map-data-types";
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
export default function buildColumnDefinition(field) { export default function buildColumnDefinition(field, options = {}) {
if (!field.fieldName) { if (!field.fieldName) {
throw new Error("Field name is required"); throw new Error("Field name is required");
} }
@@ -19,7 +19,10 @@ export default function buildColumnDefinition(field) {
} }
} }
// VECTOR columns cannot be UNIQUE in the usual sense // VECTOR columns cannot be UNIQUE in the usual sense
if (field.unique && !field.primaryKey && !isVectorField(field)) { if (!options.omitUnique &&
field.unique &&
!field.primaryKey &&
!isVectorField(field)) {
parts.push("UNIQUE"); parts.push("UNIQUE");
} }
if (field.defaultValue !== undefined) { if (field.defaultValue !== undefined) {
@@ -41,3 +44,7 @@ export default function buildColumnDefinition(field) {
} }
return parts.join(" "); return parts.join(" ");
} }
/** Whether schema field requires NOT NULL */
export function fieldRequiresNotNull(field) {
return Boolean(field.notNullValue || field.primaryKey || isVectorField(field));
}
+3 -1
View File
@@ -1,2 +1,4 @@
import type { BUN_MARIADB_FieldSchemaType } from "../../types"; import type { BUN_MARIADB_FieldSchemaType } from "../../types";
export default function buildForeignKeyConstraint(field: BUN_MARIADB_FieldSchemaType): string; export declare function defaultForeignKeyName(tableName: string, fieldName: string): string;
export declare function resolveForeignKeyName(field: BUN_MARIADB_FieldSchemaType, tableName: string): string;
export default function buildForeignKeyConstraint(field: BUN_MARIADB_FieldSchemaType, tableName: string): string;
+10 -5
View File
@@ -1,10 +1,15 @@
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
export default function buildForeignKeyConstraint(field) { export function defaultForeignKeyName(tableName, fieldName) {
return `fk_${tableName}_${fieldName}`;
}
export function resolveForeignKeyName(field, tableName) {
const fieldName = field.fieldName || "column";
return field.foreignKey?.foreignKeyName || defaultForeignKeyName(tableName, fieldName);
}
export default function buildForeignKeyConstraint(field, tableName) {
const fk = field.foreignKey; const fk = field.foreignKey;
const constraintName = fk.foreignKeyName const constraintName = resolveForeignKeyName(field, tableName);
? `CONSTRAINT ${MariaDBQuoteGen(fk.foreignKeyName)} ` let constraint = `CONSTRAINT ${MariaDBQuoteGen(constraintName)} FOREIGN KEY (${MariaDBQuoteGen(field.fieldName)}) REFERENCES ${MariaDBQuoteGen(fk.destinationTableName)}(${MariaDBQuoteGen(fk.destinationTableColumnName)})`;
: "";
let constraint = `${constraintName}FOREIGN KEY (${MariaDBQuoteGen(field.fieldName)}) REFERENCES ${MariaDBQuoteGen(fk.destinationTableName)}(${MariaDBQuoteGen(fk.destinationTableColumnName)})`;
if (fk.cascadeDelete) { if (fk.cascadeDelete) {
constraint += " ON DELETE CASCADE"; constraint += " ON DELETE CASCADE";
} }
+10 -10
View File
@@ -15,8 +15,8 @@ export default async function createTable({ table, config, }) {
if (field.primaryKey && field.fieldName) { if (field.primaryKey && field.fieldName) {
primaryKeys.push(field.fieldName); primaryKeys.push(field.fieldName);
} }
if (field.foreignKey && !table.isVector) { if (field.foreignKey) {
foreignKeys.push(buildForeignKeyConstraint(field)); foreignKeys.push(buildForeignKeyConstraint(field, table.tableName));
} }
} }
if (primaryKeys.length > 0) { if (primaryKeys.length > 0) {
@@ -25,15 +25,15 @@ export default async function createTable({ table, config, }) {
} }
if (table.uniqueConstraints) { if (table.uniqueConstraints) {
for (const constraint of table.uniqueConstraints) { for (const constraint of table.uniqueConstraints) {
if (constraint.constraintTableFields && const columns = (constraint.constraintTableFields || [])
constraint.constraintTableFields.length > 0) { .map((field) => field.value)
const fields = constraint.constraintTableFields .filter((value) => Boolean(value));
.map((field) => MariaDBQuoteGen(field.value)) if (columns.length === 0) {
.join(", "); continue;
const constraintName = constraint.constraintName ||
`unique_${fields.replace(/`/g, "")}`;
columnDefinitions.push(`CONSTRAINT ${MariaDBQuoteGen(constraintName)} UNIQUE (${fields})`);
} }
const fields = columns.map((col) => MariaDBQuoteGen(col)).join(", ");
const constraintName = constraint.constraintName || `unique_${columns.join("_")}`;
columnDefinitions.push(`CONSTRAINT ${MariaDBQuoteGen(constraintName)} UNIQUE (${fields})`);
} }
} }
const sql = `CREATE TABLE IF NOT EXISTS ${MariaDBQuoteGen(table.tableName)} (${[...columnDefinitions, ...foreignKeys].join(", ")})${buildTableOptions(table)}`; const sql = `CREATE TABLE IF NOT EXISTS ${MariaDBQuoteGen(table.tableName)} (${[...columnDefinitions, ...foreignKeys].join(", ")})${buildTableOptions(table)}`;
+13
View File
@@ -0,0 +1,13 @@
import type { BunMariaDBConfig } from "../../types";
export type ColumnInfoRow = {
name: string;
type: string;
comment?: string;
isNullable: boolean;
columnDefault: string | null;
extra: string;
};
export default function getTableColumnsGemini({ tableName, config, }: {
tableName: string;
config?: BunMariaDBConfig;
}): Promise<ColumnInfoRow[]>;
+45
View File
@@ -0,0 +1,45 @@
import { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition";
export default async function getTableColumnsGemini({ tableName, config, }) {
const schemaCond = schemaCondition(config);
const rows = await querySchemaRows({
query: `
SELECT
c.COLUMN_NAME,
c.COLUMN_TYPE,
c.COLUMN_COMMENT,
c.IS_NULLABLE,
c.COLUMN_DEFAULT,
c.EXTRA,
EXISTS (
SELECT 1
FROM information_schema.CHECK_CONSTRAINTS cc
WHERE cc.CONSTRAINT_SCHEMA = c.TABLE_SCHEMA
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.CHECK_CLAUSE LIKE CONCAT('%json_valid(\`', c.COLUMN_NAME, '\`)%')
) AS IS_JSON
FROM information_schema.COLUMNS c
WHERE ${schemaCond.where.replace(/\bTABLE_SCHEMA\b/g, "c.TABLE_SCHEMA")}
AND c.TABLE_NAME = ?
ORDER BY c.ORDINAL_POSITION
`,
values: [...schemaCond.values, tableName],
config,
});
return rows.map((row) => {
const isJson = Number(row.IS_JSON) === 1;
// Normalize longtext with a json_valid constraint to "json"
let resolvedType = row.COLUMN_TYPE;
if (isJson && row.COLUMN_TYPE.toLowerCase() === "longtext") {
resolvedType = "json";
}
return {
name: row.COLUMN_NAME,
type: resolvedType,
comment: row.COLUMN_COMMENT,
isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES",
columnDefault: row.COLUMN_DEFAULT,
extra: row.EXTRA || "",
};
});
}
+3
View File
@@ -3,6 +3,9 @@ export type ColumnInfoRow = {
name: string; name: string;
type: string; type: string;
comment?: string; comment?: string;
isNullable: boolean;
columnDefault: string | null;
extra: string;
}; };
export default function getTableColumns({ tableName, config, }: { export default function getTableColumns({ tableName, config, }: {
tableName: string; tableName: string;
+4 -1
View File
@@ -3,7 +3,7 @@ import schemaCondition from "./schema-condition";
export default async function getTableColumns({ tableName, config, }) { export default async function getTableColumns({ tableName, config, }) {
const schemaCond = schemaCondition(config); const schemaCond = schemaCondition(config);
const rows = await querySchemaRows({ const rows = await querySchemaRows({
query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`, query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`,
values: [...schemaCond.values, tableName], values: [...schemaCond.values, tableName],
config, config,
}); });
@@ -11,5 +11,8 @@ export default async function getTableColumns({ tableName, config, }) {
name: row.COLUMN_NAME, name: row.COLUMN_NAME,
type: row.COLUMN_TYPE, type: row.COLUMN_TYPE,
comment: row.COLUMN_COMMENT, comment: row.COLUMN_COMMENT,
isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES",
columnDefault: row.COLUMN_DEFAULT,
extra: row.EXTRA || "",
})); }));
} }
+20 -8
View File
@@ -3,14 +3,17 @@ import MariaDBQuoteGen from "./mariadb-quote-gen";
import resolveTable from "./resolve-table"; import resolveTable from "./resolve-table";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition"; import schemaCondition from "./schema-condition";
import { dropObsoleteForeignKeys, ensureForeignKeys, } from "./sync-foreign-keys";
import syncIndexes from "./sync-indexes"; import syncIndexes from "./sync-indexes";
import syncPrimaryKey from "./sync-primary-key";
import syncTableOptions from "./sync-table-options";
import syncUniqueConstraints from "./sync-unique-constraints";
import updateTable from "./update-table"; import updateTable from "./update-table";
import upsertDbManagerTable, { removeDbManagerTable, } from "./upsert-db-manager-table"; import upsertDbManagerTable, { removeDbManagerTable, } from "./upsert-db-manager-table";
export default async function handleDBSchemaTable({ db_schema, config, table, db_manager_table_name, existing_live_table, }) { export default async function handleDBSchemaTable({ db_schema, config, table, db_manager_table_name, existing_live_table, }) {
const resolvedTable = resolveTable(table, db_schema); const resolvedTable = resolveTable(table, db_schema);
let tableExistsTracked = Boolean(db_manager_table_name); let tableExistsTracked = Boolean(db_manager_table_name);
let tableExistsLive = Boolean(existing_live_table?.TABLE_NAME); let tableExistsLive = Boolean(existing_live_table?.TABLE_NAME);
let wasRenamed = false;
if (resolvedTable.tableNameOld && if (resolvedTable.tableNameOld &&
resolvedTable.tableNameOld !== resolvedTable.tableName) { resolvedTable.tableNameOld !== resolvedTable.tableName) {
// Only hit information_schema when a rename is declared // Only hit information_schema when a rename is declared
@@ -36,7 +39,6 @@ export default async function handleDBSchemaTable({ db_schema, config, table, db
}); });
tableExistsTracked = true; tableExistsTracked = true;
tableExistsLive = true; tableExistsLive = true;
wasRenamed = true;
} }
} }
if (!tableExistsTracked && !tableExistsLive) { if (!tableExistsTracked && !tableExistsLive) {
@@ -47,16 +49,26 @@ export default async function handleDBSchemaTable({ db_schema, config, table, db
}); });
} }
else { else {
if (!wasRenamed) { // Columns first (also drops FKs on removed/modified columns)
await updateTable({ await updateTable({
table: resolvedTable, table: resolvedTable,
config, config,
}); });
}
await upsertDbManagerTable({ await upsertDbManagerTable({
tableName: resolvedTable.tableName, tableName: resolvedTable.tableName,
config, config,
}); });
} }
// Order matters:
// 1. Table options (engine/collation)
// 2. Drop obsolete/changed FKs (unlocks index cleanup)
// 3. Primary key
// 4. Indexes / uniques (FK columns need supporting indexes)
// 5. Ensure foreign keys exist
await syncTableOptions({ table: resolvedTable, config });
await dropObsoleteForeignKeys({ table: resolvedTable, config });
await syncPrimaryKey({ table: resolvedTable, config });
await syncIndexes({ table: resolvedTable, config }); await syncIndexes({ table: resolvedTable, config });
await syncUniqueConstraints({ table: resolvedTable, config });
await ensureForeignKeys({ table: resolvedTable, config });
} }
+16 -17
View File
@@ -7,9 +7,9 @@ export default function mapDataType(field) {
} }
switch (dataType) { switch (dataType) {
case "CHAR": case "CHAR":
return `CHAR(${field.integerLength || 255})`; return `CHAR(${field.dataLength || 255})`;
case "VARCHAR": case "VARCHAR":
return `VARCHAR(${field.integerLength || 255})`; return `VARCHAR(${field.dataLength || 255})`;
case "TEXT": case "TEXT":
return "TEXT"; return "TEXT";
case "TINYTEXT": case "TINYTEXT":
@@ -19,36 +19,34 @@ export default function mapDataType(field) {
case "LONGTEXT": case "LONGTEXT":
return "LONGTEXT"; return "LONGTEXT";
case "TINYINT": case "TINYINT":
return field.integerLength return field.dataLength
? `TINYINT(${field.integerLength})` ? `TINYINT(${field.dataLength})`
: "TINYINT"; : "TINYINT";
case "SMALLINT": case "SMALLINT":
return field.integerLength return field.dataLength
? `SMALLINT(${field.integerLength})` ? `SMALLINT(${field.dataLength})`
: "SMALLINT"; : "SMALLINT";
case "MEDIUMINT": case "MEDIUMINT":
return field.integerLength return field.dataLength
? `MEDIUMINT(${field.integerLength})` ? `MEDIUMINT(${field.dataLength})`
: "MEDIUMINT"; : "MEDIUMINT";
case "INT": case "INT":
return field.integerLength ? `INT(${field.integerLength})` : "INT"; return field.dataLength ? `INT(${field.dataLength})` : "INT";
case "BIGINT": case "BIGINT":
return field.integerLength return field.dataLength ? `BIGINT(${field.dataLength})` : "BIGINT";
? `BIGINT(${field.integerLength})`
: "BIGINT";
case "FLOAT": case "FLOAT":
return "FLOAT"; return "FLOAT";
case "DOUBLE": case "DOUBLE":
return "DOUBLE"; return "DOUBLE";
case "DECIMAL": case "DECIMAL":
if (field.integerLength && field.decimals) { if (field.dataLength && field.decimals) {
return `DECIMAL(${field.integerLength}, ${field.decimals})`; return `DECIMAL(${field.dataLength}, ${field.decimals})`;
} }
return "DECIMAL(10,2)"; return "DECIMAL(10,2)";
case "BINARY": case "BINARY":
return `BINARY(${field.integerLength || 1})`; return `BINARY(${field.dataLength || 1})`;
case "VARBINARY": case "VARBINARY":
return `VARBINARY(${field.integerLength || 255})`; return `VARBINARY(${field.dataLength || 255})`;
case "BLOB": case "BLOB":
return "BLOB"; return "BLOB";
case "TINYBLOB": case "TINYBLOB":
@@ -68,11 +66,12 @@ export default function mapDataType(field) {
case "YEAR": case "YEAR":
return "YEAR"; return "YEAR";
case "UUID": case "UUID":
return "CHAR(36)"; // MariaDB does not have a native UUID type return "UUID";
case "JSON": case "JSON":
return "JSON"; return "JSON";
case "INET6": case "INET6":
return "INET6"; return "INET6";
case "BOOL":
case "BOOLEAN": case "BOOLEAN":
return "TINYINT(1)"; return "TINYINT(1)";
case "ENUM": { case "ENUM": {
-9
View File
@@ -1,9 +0,0 @@
import type { BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types";
/**
* Full table rebuild. For `isVector` tables this drops and recreates in place
* (preserving rows when possible). For regular tables it uses a temp-table swap.
*/
export default function recreateTable({ table, config, }: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void>;
-111
View File
@@ -1,111 +0,0 @@
import createTable from "./create-table";
import getTableColumns from "./get-table-columns";
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition";
async function checkIfTableExists({ tableName, config, }) {
const schemaCond = schemaCondition(config);
const rows = await querySchemaRows({
query: `SELECT 1 AS \`table_exists\` FROM information_schema.TABLES WHERE ${schemaCond.where} AND TABLE_NAME = ? LIMIT 1`,
values: [...schemaCond.values, tableName],
config,
});
return Boolean(rows[0]?.table_exists);
}
/**
* Full table rebuild. For `isVector` tables this drops and recreates in place
* (preserving rows when possible). For regular tables it uses a temp-table swap.
*/
export default async function recreateTable({ table, config, }) {
const doesTableExist = await checkIfTableExists({
tableName: table.tableName,
config,
});
if (!doesTableExist) {
await createTable({ table, config });
return;
}
/**
* Vector tables: drop + recreate + reinsert (MariaDB VECTOR INDEX / dim
* changes are not reliably alterable in place).
*/
if (table.isVector) {
console.log(`Recreating vector table: ${table.tableName}`);
const existingRows = await querySchemaRows({
query: `SELECT * FROM ${MariaDBQuoteGen(table.tableName)}`,
config,
});
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 0`, config });
try {
await runSchemaQuery({
query: `DROP TABLE IF EXISTS ${MariaDBQuoteGen(table.tableName)}`,
config,
});
await createTable({ table, config });
}
finally {
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 1`, config });
}
if (existingRows.length > 0) {
const schemaFieldNames = new Set((table.fields || [])
.map((f) => f.fieldName)
.filter((n) => Boolean(n)));
for (const row of existingRows) {
const columns = Object.keys(row).filter((c) => schemaFieldNames.has(c));
if (columns.length === 0)
continue;
const placeholders = columns.map(() => "?").join(", ");
const columnList = columns
.map((c) => MariaDBQuoteGen(c))
.join(", ");
await runSchemaQuery({
query: `INSERT INTO ${MariaDBQuoteGen(table.tableName)} (${columnList}) VALUES (${placeholders})`,
values: columns.map((c) => row[c] ?? null),
config,
});
}
}
return;
}
const tempTableName = `${table.tableName}_temp_${Date.now()}`;
const backupOldTableName = `${table.tableName}_old_${Date.now()}`;
const existingColumns = await getTableColumns({
tableName: table.tableName,
config,
});
const columnsToKeep = (table.fields || [])
.filter((field) => existingColumns.some((column) => column.name === field.fieldName))
.map((field) => field.fieldName)
.filter((fieldName) => Boolean(fieldName));
await createTable({
table: { ...table, tableName: tempTableName },
config,
});
if (columnsToKeep.length > 0) {
const columnList = columnsToKeep
.map((column) => MariaDBQuoteGen(column))
.join(", ");
await runSchemaQuery({
query: `INSERT INTO ${MariaDBQuoteGen(tempTableName)} (${columnList}) SELECT ${columnList} FROM ${MariaDBQuoteGen(table.tableName)}`,
config,
});
}
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 0`, config });
try {
await runSchemaQuery({
query: `RENAME TABLE ${MariaDBQuoteGen(table.tableName)} TO ${MariaDBQuoteGen(backupOldTableName)}`,
config,
});
await runSchemaQuery({
query: `RENAME TABLE ${MariaDBQuoteGen(tempTableName)} TO ${MariaDBQuoteGen(table.tableName)}`,
config,
});
await runSchemaQuery({
query: `DROP TABLE ${MariaDBQuoteGen(backupOldTableName)}`,
config,
});
}
finally {
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 1`, config });
}
}
-1
View File
@@ -23,7 +23,6 @@ export default function resolveTable(table, db_schema) {
tableName: table.tableName, tableName: table.tableName,
tableDescription: table.tableDescription || parentTable.tableDescription, tableDescription: table.tableDescription || parentTable.tableDescription,
collation: table.collation || parentTable.collation, collation: table.collation || parentTable.collation,
isVector: table.isVector !== undefined ? table.isVector : parentTable.isVector,
fields: Array.from(mergedFieldsMap.values()), fields: Array.from(mergedFieldsMap.values()),
indexes: _.uniqBy([...(parentTable.indexes || []), ...(table.indexes || [])], "indexName"), indexes: _.uniqBy([...(parentTable.indexes || []), ...(table.indexes || [])], "indexName"),
uniqueConstraints: [ uniqueConstraints: [
+52
View File
@@ -0,0 +1,52 @@
import type { BUN_MARIADB_FieldSchemaType, BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types";
export type DesiredForeignKey = {
name: string;
column: string;
refTable: string;
refColumn: string;
cascadeDelete: boolean;
cascadeUpdate: boolean;
field: BUN_MARIADB_FieldSchemaType;
};
export type LiveForeignKey = {
name: string;
column: string;
refTable: string;
refColumn: string;
deleteRule: string;
updateRule: string;
};
export declare function grabDesiredForeignKeys(table: BUN_MARIADB_TableSchemaType): DesiredForeignKey[];
export declare function grabLiveForeignKeys({ tableName, config, }: {
tableName: string;
config?: BunMariaDBConfig;
}): Promise<LiveForeignKey[]>;
export declare function dropForeignKey({ tableName, constraintName, config, }: {
tableName: string;
constraintName: string;
config?: BunMariaDBConfig;
}): Promise<void>;
export declare function dropForeignKeysOnColumns({ tableName, columns, config, }: {
tableName: string;
columns: string[];
config?: BunMariaDBConfig;
}): Promise<void>;
/**
* Drop foreign keys that are removed from schema or need recreation
* (name/cascade/target changed). Run before index cleanup.
*/
export declare function dropObsoleteForeignKeys({ table, config, }: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void>;
/**
* Ensure all desired foreign keys exist. Run after indexes/uniques.
*/
export declare function ensureForeignKeys({ table, config, }: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void>;
export default function syncForeignKeys({ table, config, }: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void>;
+180
View File
@@ -0,0 +1,180 @@
import buildForeignKeyConstraint, { resolveForeignKeyName, } from "./build-foreign-key-constraint";
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
function normalizeRule(rule) {
return String(rule || "RESTRICT").toUpperCase().replace(/_/g, " ");
}
function ruleIsCascade(rule) {
return normalizeRule(rule) === "CASCADE";
}
function fkIdentityKey(fk) {
return `${fk.column}\0${fk.refTable}\0${fk.refColumn}`;
}
function rulesMatch(live, desired) {
return (ruleIsCascade(live.deleteRule) === desired.cascadeDelete &&
ruleIsCascade(live.updateRule) === desired.cascadeUpdate);
}
export function grabDesiredForeignKeys(table) {
const desired = [];
for (const field of table.fields || []) {
const fk = field.foreignKey;
if (!field.fieldName ||
!fk?.destinationTableName ||
!fk.destinationTableColumnName) {
continue;
}
desired.push({
name: resolveForeignKeyName(field, table.tableName),
column: field.fieldName,
refTable: fk.destinationTableName,
refColumn: fk.destinationTableColumnName,
cascadeDelete: Boolean(fk.cascadeDelete),
cascadeUpdate: Boolean(fk.cascadeUpdate),
field,
});
}
return desired;
}
export async function grabLiveForeignKeys({ tableName, config, }) {
const databaseName = config?.db_name || global.CONFIG?.db_name;
const schemaWhere = databaseName
? "rc.CONSTRAINT_SCHEMA = ?"
: "rc.CONSTRAINT_SCHEMA = DATABASE()";
const schemaValues = databaseName ? [databaseName] : [];
const rows = await querySchemaRows({
query: `
SELECT
rc.CONSTRAINT_NAME,
kcu.COLUMN_NAME,
kcu.REFERENCED_TABLE_NAME,
kcu.REFERENCED_COLUMN_NAME,
rc.DELETE_RULE,
rc.UPDATE_RULE
FROM information_schema.REFERENTIAL_CONSTRAINTS rc
INNER JOIN information_schema.KEY_COLUMN_USAGE kcu
ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
AND rc.TABLE_NAME = kcu.TABLE_NAME
WHERE ${schemaWhere}
AND rc.TABLE_NAME = ?
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
ORDER BY rc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION
`,
values: [...schemaValues, tableName],
config,
});
// Single-column FKs only (schema model is one FK per field)
const byName = new Map();
for (const row of rows) {
if (byName.has(row.CONSTRAINT_NAME)) {
continue;
}
byName.set(row.CONSTRAINT_NAME, {
name: row.CONSTRAINT_NAME,
column: row.COLUMN_NAME,
refTable: row.REFERENCED_TABLE_NAME,
refColumn: row.REFERENCED_COLUMN_NAME,
deleteRule: row.DELETE_RULE,
updateRule: row.UPDATE_RULE,
});
}
return [...byName.values()];
}
export async function dropForeignKey({ tableName, constraintName, config, }) {
console.log(`Dropping foreign key: ${constraintName} on ${tableName}`);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} DROP FOREIGN KEY ${MariaDBQuoteGen(constraintName)}`,
config,
});
}
export async function dropForeignKeysOnColumns({ tableName, columns, config, }) {
if (columns.length === 0)
return;
const live = await grabLiveForeignKeys({ tableName, config });
const colSet = new Set(columns);
for (const fk of live) {
if (colSet.has(fk.column)) {
await dropForeignKey({
tableName,
constraintName: fk.name,
config,
});
}
}
}
/**
* Drop foreign keys that are removed from schema or need recreation
* (name/cascade/target changed). Run before index cleanup.
*/
export async function dropObsoleteForeignKeys({ table, config, }) {
const desired = grabDesiredForeignKeys(table);
const live = await grabLiveForeignKeys({
tableName: table.tableName,
config,
});
const keepNames = new Set();
const droppedNames = new Set();
const dropOnce = async (name) => {
if (droppedNames.has(name))
return;
droppedNames.add(name);
await dropForeignKey({
tableName: table.tableName,
constraintName: name,
config,
});
};
for (const want of desired) {
const byIdentity = live.find((existing) => !keepNames.has(existing.name) &&
!droppedNames.has(existing.name) &&
fkIdentityKey(existing) === fkIdentityKey(want));
if (byIdentity &&
rulesMatch(byIdentity, want) &&
byIdentity.name === want.name) {
keepNames.add(byIdentity.name);
continue;
}
// Changed relationship → drop so ensureForeignKeys can recreate
if (byIdentity) {
await dropOnce(byIdentity.name);
}
const byName = live.find((existing) => existing.name === want.name);
if (byName && byName !== byIdentity) {
await dropOnce(byName.name);
}
}
for (const existing of live) {
if (keepNames.has(existing.name) || droppedNames.has(existing.name)) {
continue;
}
// Not in schema anymore
await dropOnce(existing.name);
}
}
/**
* Ensure all desired foreign keys exist. Run after indexes/uniques.
*/
export async function ensureForeignKeys({ table, config, }) {
const desired = grabDesiredForeignKeys(table);
const live = await grabLiveForeignKeys({
tableName: table.tableName,
config,
});
for (const want of desired) {
const exists = live.some((existing) => fkIdentityKey(existing) === fkIdentityKey(want) &&
rulesMatch(existing, want) &&
existing.name === want.name);
if (exists) {
continue;
}
console.log(`Creating foreign key: ${want.name} (${want.column}${want.refTable}.${want.refColumn}) on ${table.tableName}`);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD ${buildForeignKeyConstraint(want.field, table.tableName)}`,
config,
});
}
}
export default async function syncForeignKeys({ table, config, }) {
await dropObsoleteForeignKeys({ table, config });
await ensureForeignKeys({ table, config });
}
+61 -39
View File
@@ -2,11 +2,10 @@ import isVectorField from "./is-vector-field";
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition"; import schemaCondition from "./schema-condition";
import { grabDesiredUniqueConstraints } from "./sync-unique-constraints";
function isVectorIndexDef(index, table) { function isVectorIndexDef(index, table) {
if (index.indexType === "VECTOR") if (index.indexType === "VECTOR")
return true; return true;
if (table.isVector)
return true;
const firstFieldName = index.indexTableFields?.[0]; const firstFieldName = index.indexTableFields?.[0];
if (!firstFieldName) if (!firstFieldName)
return false; return false;
@@ -20,6 +19,60 @@ function vectorDistanceMetric(index) {
return "euclidean"; return "euclidean";
return "euclidean"; return "euclidean";
} }
/** Normalize schema index type vs information_schema.INDEX_TYPE */
function indexTypesMatch(liveType, schemaIndex, table) {
const live = (liveType || "").toUpperCase();
const schemaType = schemaIndex.indexType?.toUpperCase();
if (isVectorIndexDef(schemaIndex, table)) {
// MariaDB may report VECTOR indexes as BTREE or VECTOR depending on version
return live === "VECTOR" || live === "BTREE" || live === "";
}
if (!schemaType || schemaType === "BTREE") {
// Default / unspecified → BTREE
return live === "BTREE" || live === "";
}
if (schemaType === "FULLTEXT") {
return live === "FULLTEXT";
}
if (schemaType === "SPATIAL") {
return live === "SPATIAL";
}
if (schemaType === "HASH") {
return live === "HASH";
}
return live === schemaType;
}
async function createIndex({ table, index, config, }) {
if (!index.indexName ||
!index.indexTableFields ||
index.indexTableFields.length === 0) {
return;
}
if (isVectorIndexDef(index, table)) {
console.log(`Creating Vector index: ${index.indexName}`);
const targetField = MariaDBQuoteGen(index.indexTableFields[0]);
const distanceMetric = vectorDistanceMetric(index);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD VECTOR INDEX ${MariaDBQuoteGen(index.indexName)} (${targetField}) M=8 DISTANCE=${distanceMetric}`,
config,
});
return;
}
console.log(`Creating standard index: ${index.indexName}`);
const fields = index.indexTableFields
.map((field) => MariaDBQuoteGen(field))
.join(", ");
const typeUpper = index.indexType?.toUpperCase();
const isSpecialType = typeUpper === "FULLTEXT" || typeUpper === "SPATIAL";
const indexPrefix = isSpecialType ? `${typeUpper} ` : "";
const indexSuffix = !isSpecialType && (typeUpper === "BTREE" || typeUpper === "HASH")
? ` USING ${typeUpper}`
: "";
await runSchemaQuery({
query: `CREATE ${indexPrefix}INDEX ${MariaDBQuoteGen(index.indexName)} ON ${MariaDBQuoteGen(table.tableName)} (${fields})${indexSuffix}`,
config,
});
}
export default async function syncIndexes({ table, config, }) { export default async function syncIndexes({ table, config, }) {
const schemaCond = schemaCondition(config); const schemaCond = schemaCondition(config);
const rows = await querySchemaRows({ const rows = await querySchemaRows({
@@ -37,16 +90,8 @@ export default async function syncIndexes({ table, config, }) {
config, config,
}); });
const protectedIndexNames = new Set(protectedConstraintRows.map((r) => r.CONSTRAINT_NAME)); const protectedIndexNames = new Set(protectedConstraintRows.map((r) => r.CONSTRAINT_NAME));
// Column-level UNIQUE creates an index often named after the column for (const constraint of grabDesiredUniqueConstraints(table)) {
for (const field of table.fields || []) { protectedIndexNames.add(constraint.name);
if (field.unique && field.fieldName) {
protectedIndexNames.add(field.fieldName);
}
}
for (const constraint of table.uniqueConstraints || []) {
if (constraint.constraintName) {
protectedIndexNames.add(constraint.constraintName);
}
} }
const existingIndexesMap = new Map(); const existingIndexesMap = new Map();
for (const row of rows) { for (const row of rows) {
@@ -70,6 +115,7 @@ export default async function syncIndexes({ table, config, }) {
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`,
config, config,
}); });
existingIndexesMap.delete(indexName);
} }
catch (err) { catch (err) {
if (String(err?.message || "").includes("needed in a foreign key constraint")) { if (String(err?.message || "").includes("needed in a foreign key constraint")) {
@@ -83,7 +129,8 @@ export default async function syncIndexes({ table, config, }) {
const schemaColumns = schemaIndex.indexTableFields || []; const schemaColumns = schemaIndex.indexTableFields || [];
const columnsMatch = details.columns.length === schemaColumns.length && const columnsMatch = details.columns.length === schemaColumns.length &&
details.columns.every((col, idx) => col === schemaColumns[idx]); details.columns.every((col, idx) => col === schemaColumns[idx]);
if (!columnsMatch) { const typeMatch = indexTypesMatch(details.type, schemaIndex, table);
if (!columnsMatch || !typeMatch) {
console.log(`Recreating changed index: ${indexName}`); console.log(`Recreating changed index: ${indexName}`);
await runSchemaQuery({ await runSchemaQuery({
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`,
@@ -100,32 +147,7 @@ export default async function syncIndexes({ table, config, }) {
continue; continue;
} }
if (!existingIndexesMap.has(index.indexName)) { if (!existingIndexesMap.has(index.indexName)) {
if (isVectorIndexDef(index, table)) { await createIndex({ table, index, config });
console.log(`Creating Vector index: ${index.indexName}`);
const targetField = MariaDBQuoteGen(index.indexTableFields[0]);
const distanceMetric = vectorDistanceMetric(index);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD VECTOR INDEX ${MariaDBQuoteGen(index.indexName)} (${targetField}) M=8 DISTANCE=${distanceMetric}`,
config,
});
}
else {
console.log(`Creating standard index: ${index.indexName}`);
const fields = index.indexTableFields
.map((field) => MariaDBQuoteGen(field))
.join(", ");
const typeUpper = index.indexType?.toUpperCase();
const isSpecialType = typeUpper === "FULLTEXT" || typeUpper === "SPATIAL";
const indexPrefix = isSpecialType ? `${typeUpper} ` : "";
const indexSuffix = !isSpecialType &&
(typeUpper === "BTREE" || typeUpper === "HASH")
? ` USING ${typeUpper}`
: "";
await runSchemaQuery({
query: `CREATE ${indexPrefix}INDEX ${MariaDBQuoteGen(index.indexName)} ON ${MariaDBQuoteGen(table.tableName)} (${fields})${indexSuffix}`,
config,
});
}
} }
} }
} }
+6
View File
@@ -0,0 +1,6 @@
import type { BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types";
export declare function grabDesiredPrimaryKeyColumns(table: BUN_MARIADB_TableSchemaType): string[];
export default function syncPrimaryKey({ table, config, }: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void>;
+52
View File
@@ -0,0 +1,52 @@
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition";
function columnsEqual(a, b) {
return a.length === b.length && a.every((col, i) => col === b[i]);
}
export function grabDesiredPrimaryKeyColumns(table) {
return (table.fields || [])
.filter((field) => field.primaryKey && field.fieldName)
.map((field) => field.fieldName);
}
async function grabLivePrimaryKeyColumns({ tableName, config, }) {
const schemaCond = schemaCondition(config);
const rows = await querySchemaRows({
query: `
SELECT COLUMN_NAME, ORDINAL_POSITION
FROM information_schema.KEY_COLUMN_USAGE
WHERE ${schemaCond.where}
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = 'PRIMARY'
ORDER BY ORDINAL_POSITION
`,
values: [...schemaCond.values, tableName],
config,
});
return rows.map((row) => row.COLUMN_NAME);
}
export default async function syncPrimaryKey({ table, config, }) {
const desired = grabDesiredPrimaryKeyColumns(table);
const live = await grabLivePrimaryKeyColumns({
tableName: table.tableName,
config,
});
if (columnsEqual(desired, live)) {
return;
}
if (live.length > 0) {
console.log(`Dropping primary key on ${table.tableName}`);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP PRIMARY KEY`,
config,
});
}
if (desired.length > 0) {
const cols = desired.map((col) => MariaDBQuoteGen(col)).join(", ");
console.log(`Creating primary key (${desired.join(", ")}) on ${table.tableName}`);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD PRIMARY KEY (${cols})`,
config,
});
}
}
+5
View File
@@ -0,0 +1,5 @@
import type { BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types";
export default function syncTableOptions({ table, config, }: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void>;
+33
View File
@@ -0,0 +1,33 @@
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition";
export default async function syncTableOptions({ table, config, }) {
const schemaCond = schemaCondition(config);
const rows = await querySchemaRows({
query: `SELECT ENGINE, TABLE_COLLATION FROM information_schema.TABLES WHERE ${schemaCond.where} AND TABLE_NAME = ? AND TABLE_TYPE = 'BASE TABLE'`,
values: [...schemaCond.values, table.tableName],
config,
});
const live = rows[0];
if (!live) {
return;
}
const alters = [];
if ((live.ENGINE || "").toUpperCase() !== "INNODB") {
alters.push("ENGINE=InnoDB");
}
if (table.collation) {
const liveCollation = (live.TABLE_COLLATION || "").toLowerCase();
if (liveCollation !== table.collation.toLowerCase()) {
alters.push(`CONVERT TO CHARACTER SET utf8mb4 COLLATE ${table.collation}`);
}
}
if (alters.length === 0) {
return;
}
console.log(`Updating table options on ${table.tableName}: ${alters.join(", ")}`);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ${alters.join(", ")}`,
config,
});
}
+11
View File
@@ -0,0 +1,11 @@
import type { BUN_MARIADB_TableSchemaType, BunMariaDBConfig } from "../../types";
type UniqueConstraintDesired = {
name: string;
columns: string[];
};
export declare function grabDesiredUniqueConstraints(table: BUN_MARIADB_TableSchemaType): UniqueConstraintDesired[];
export default function syncUniqueConstraints({ table, config, }: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void>;
export {};
+138
View File
@@ -0,0 +1,138 @@
import isVectorField from "./is-vector-field";
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
function columnsKey(columns) {
return columns.join("\0");
}
function columnsEqual(a, b) {
return a.length === b.length && a.every((col, i) => col === b[i]);
}
function defaultConstraintName(columns) {
return `unique_${columns.join("_")}`;
}
export function grabDesiredUniqueConstraints(table) {
const desired = [];
const seenColumnSets = new Set();
for (const constraint of table.uniqueConstraints || []) {
const columns = (constraint.constraintTableFields || [])
.map((field) => field.value)
.filter((value) => Boolean(value));
if (columns.length === 0) {
continue;
}
const key = columnsKey(columns);
if (seenColumnSets.has(key)) {
continue;
}
seenColumnSets.add(key);
desired.push({
name: constraint.constraintName || defaultConstraintName(columns),
columns,
});
}
for (const field of table.fields || []) {
if (!field.fieldName ||
!field.unique ||
field.primaryKey ||
isVectorField(field)) {
continue;
}
const columns = [field.fieldName];
const key = columnsKey(columns);
if (seenColumnSets.has(key)) {
continue;
}
seenColumnSets.add(key);
// Matches MariaDB's default name for column-level UNIQUE
desired.push({
name: field.fieldName,
columns,
});
}
return desired;
}
async function grabLiveUniqueConstraints({ tableName, config, }) {
const databaseName = config?.db_name || global.CONFIG?.db_name;
const schemaWhere = databaseName
? "tc.TABLE_SCHEMA = ?"
: "tc.TABLE_SCHEMA = DATABASE()";
const schemaValues = databaseName ? [databaseName] : [];
const rows = await querySchemaRows({
query: `
SELECT tc.CONSTRAINT_NAME, kcu.COLUMN_NAME, kcu.ORDINAL_POSITION
FROM information_schema.TABLE_CONSTRAINTS tc
INNER JOIN information_schema.KEY_COLUMN_USAGE kcu
ON tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
AND tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
AND tc.TABLE_NAME = kcu.TABLE_NAME
WHERE ${schemaWhere}
AND tc.TABLE_NAME = ?
AND tc.CONSTRAINT_TYPE = 'UNIQUE'
ORDER BY tc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION
`,
values: [...schemaValues, tableName],
config,
});
const map = new Map();
for (const row of rows) {
if (!map.has(row.CONSTRAINT_NAME)) {
map.set(row.CONSTRAINT_NAME, []);
}
map.get(row.CONSTRAINT_NAME).push(row.COLUMN_NAME);
}
return [...map.entries()].map(([name, columns]) => ({ name, columns }));
}
export default async function syncUniqueConstraints({ table, config, }) {
const desired = grabDesiredUniqueConstraints(table);
const live = await grabLiveUniqueConstraints({
tableName: table.tableName,
config,
});
const matchedLiveNames = new Set();
for (const want of desired) {
const byColumns = live.find((existing) => !matchedLiveNames.has(existing.name) &&
columnsEqual(existing.columns, want.columns));
if (byColumns) {
matchedLiveNames.add(byColumns.name);
continue;
}
const byName = live.find((existing) => existing.name === want.name);
if (byName && !matchedLiveNames.has(byName.name)) {
console.log(`Recreating changed unique constraint: ${want.name} on ${table.tableName}`);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP INDEX ${MariaDBQuoteGen(byName.name)}`,
config,
});
matchedLiveNames.add(byName.name);
}
const fields = want.columns.map((col) => MariaDBQuoteGen(col)).join(", ");
console.log(`Creating unique constraint: ${want.name} (${want.columns.join(", ")}) on ${table.tableName}`);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD CONSTRAINT ${MariaDBQuoteGen(want.name)} UNIQUE (${fields})`,
config,
});
}
for (const existing of live) {
if (matchedLiveNames.has(existing.name)) {
continue;
}
const stillDesired = desired.some((want) => columnsEqual(want.columns, existing.columns));
if (stillDesired) {
continue;
}
console.log(`Dropping unique constraint: ${existing.name} on ${table.tableName}`);
try {
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP INDEX ${MariaDBQuoteGen(existing.name)}`,
config,
});
}
catch (err) {
if (String(err?.message || "").includes("needed in a foreign key constraint")) {
console.warn(`Skipping drop of unique constraint ${existing.name}: required by a foreign key constraint`);
continue;
}
throw err;
}
}
}
+177 -38
View File
@@ -1,12 +1,13 @@
import buildColumnDefinition from "./build-column-definition"; import buildColumnDefinition, { fieldRequiresNotNull, } from "./build-column-definition";
import createTable from "./create-table"; import createTable from "./create-table";
import getTableColumns from "./get-table-columns"; import getTableColumns, {} from "./get-table-columns";
import getTableColumnsGemini from "./get-table-columns-gemnini";
import isVectorField from "./is-vector-field"; import isVectorField from "./is-vector-field";
import mapDataType from "./map-data-types"; import mapDataType from "./map-data-types";
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
import recreateTable from "./recreate-table";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition"; import schemaCondition from "./schema-condition";
import { dropForeignKeysOnColumns } from "./sync-foreign-keys";
/** /**
* Compare live COLUMN_TYPE with schema-mapped type. * Compare live COLUMN_TYPE with schema-mapped type.
* Live types often include display widths (e.g. bigint(20) vs BIGINT). * Live types often include display widths (e.g. bigint(20) vs BIGINT).
@@ -41,9 +42,87 @@ function vectorTypeDiverged(liveType, liveComment, field) {
} }
return true; return true;
} }
function normalizeDefault(value) {
if (value == null)
return "";
let v = String(value).trim();
// Strip surrounding quotes MariaDB may include
if ((v.startsWith("'") && v.endsWith("'")) ||
(v.startsWith('"') && v.endsWith('"'))) {
v = v.slice(1, -1);
}
return v.toLowerCase().replace(/\s+/g, " ");
}
function expectedDefault(field) {
if (field.defaultValue !== undefined) {
return String(field.defaultValue);
}
if (field.defaultValueLiteral) {
return field.defaultValueLiteral;
}
return null;
}
function expectedOnUpdate(field) {
if (field.onUpdate)
return field.onUpdate;
if (field.onUpdateLiteral)
return field.onUpdateLiteral;
return null;
}
function defaultsMatch(liveDefault, expected) {
if (liveDefault === expected)
return true;
if (expected.includes("current_timestamp") &&
liveDefault.includes("current_timestamp")) {
return true;
}
return false;
}
function columnAttributesDiverged(live, field) {
const wantsNotNull = fieldRequiresNotNull(field);
if (wantsNotNull && live.isNullable) {
return true;
}
if (!wantsNotNull && !live.isNullable && !field.primaryKey) {
return true;
}
const liveExtra = (live.extra || "").toLowerCase();
const wantsAi = Boolean(field.autoIncrement);
const hasAi = liveExtra.includes("auto_increment");
if (wantsAi !== hasAi) {
return true;
}
const wantsDefault = expectedDefault(field);
if (wantsDefault != null) {
const liveDefault = normalizeDefault(live.columnDefault);
const expected = normalizeDefault(wantsDefault);
if (!defaultsMatch(liveDefault, expected)) {
return true;
}
}
const wantsOnUpdate = expectedOnUpdate(field);
const hasOnUpdate = /on update/i.test(live.extra || "");
if (wantsOnUpdate && !hasOnUpdate) {
return true;
}
if (!wantsOnUpdate && hasOnUpdate) {
return true;
}
if (wantsOnUpdate && hasOnUpdate) {
const liveOnUpdate = normalizeDefault((live.extra.match(/on update\s+(.+)/i) || [])[1] || "");
const expectedOu = normalizeDefault(wantsOnUpdate);
if (liveOnUpdate &&
expectedOu &&
!defaultsMatch(liveOnUpdate, expectedOu)) {
return true;
}
}
return false;
}
async function addColumn({ tableName, field, config, }) { async function addColumn({ tableName, field, config, }) {
console.log(`Adding column: ${tableName}.${field.fieldName}`); console.log(`Adding column: ${tableName}.${field.fieldName}`);
const columnDef = buildColumnDefinition(field).trim(); // omitUnique — unique constraints are applied by syncUniqueConstraints
const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim();
await runSchemaQuery({ await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} ADD COLUMN IF NOT EXISTS ${columnDef}`, query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} ADD COLUMN IF NOT EXISTS ${columnDef}`,
config, config,
@@ -51,7 +130,7 @@ async function addColumn({ tableName, field, config, }) {
} }
async function modifyColumn({ tableName, field, config, }) { async function modifyColumn({ tableName, field, config, }) {
console.log(`Modifying column: ${tableName}.${field.fieldName}`); console.log(`Modifying column: ${tableName}.${field.fieldName}`);
const columnDef = buildColumnDefinition(field).trim(); const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim();
await runSchemaQuery({ await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} MODIFY COLUMN ${columnDef}`, query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} MODIFY COLUMN ${columnDef}`,
config, config,
@@ -64,8 +143,52 @@ async function dropColumn({ tableName, fieldName, config, }) {
config, config,
}); });
} }
/**
* Drop + re-add a column (values discarded). Used when VECTOR dimensions change —
* MODIFY cannot resize VECTOR, and a full table rebuild is unnecessary.
*/
async function recreateColumn({ tableName, field, config, }) {
if (!field.fieldName)
return;
console.log(`Recreating column: ${tableName}.${field.fieldName} (values will be cleared)`);
await dropForeignKeysOnColumns({
tableName,
columns: [field.fieldName],
config,
});
const schemaCond = schemaCondition(config);
const indexRows = await querySchemaRows({
query: `SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE ${schemaCond.where} AND TABLE_NAME = ? AND INDEX_NAME <> 'PRIMARY'`,
values: [...schemaCond.values, tableName],
config,
});
const indexesToDrop = new Set();
for (const row of indexRows) {
if (row.COLUMN_NAME === field.fieldName) {
indexesToDrop.add(row.INDEX_NAME);
}
}
for (const indexName of indexesToDrop) {
console.log(`Dropping index ${indexName} because column ${field.fieldName} is being recreated`);
try {
await runSchemaQuery({
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(tableName)}`,
config,
});
}
catch (err) {
if (String(err?.message || "").includes("needed in a foreign key constraint")) {
console.warn(`Skipping drop of index ${indexName}: required by a foreign key constraint`);
continue;
}
throw err;
}
}
await dropColumn({ tableName, fieldName: field.fieldName, config });
await addColumn({ tableName, field, config });
}
export default async function updateTable({ table, config, }) { export default async function updateTable({ table, config, }) {
const existingColumns = await getTableColumns({ const existingColumns = await getTableColumnsGemini({
tableName: table.tableName, tableName: table.tableName,
config, config,
}); });
@@ -73,43 +196,36 @@ export default async function updateTable({ table, config, }) {
await createTable({ table, config }); await createTable({ table, config });
return; return;
} }
const liveFieldsMap = new Map(existingColumns.map((col) => [ const liveFieldsMap = new Map(existingColumns.map((col) => [col.name, col]));
col.name,
{ type: col.type.toLowerCase(), comment: col.comment || "" },
]));
const codeFieldsMap = new Map((table.fields || []).map((f) => [f.fieldName, f])); const codeFieldsMap = new Map((table.fields || []).map((f) => [f.fieldName, f]));
const fieldsToAdd = []; const fieldsToAdd = [];
const fieldsToModify = []; const fieldsToModify = [];
const fieldsToRecreate = [];
const fieldsToDrop = []; const fieldsToDrop = [];
let needsVectorRecreate = false;
for (const field of table.fields || []) { for (const field of table.fields || []) {
if (!field.fieldName) if (!field.fieldName)
continue; continue;
const liveField = liveFieldsMap.get(field.fieldName); const liveField = liveFieldsMap.get(field.fieldName);
if (!liveField) { if (!liveField) {
// Adding a new vector column can require rebuild if VECTOR INDEX
// constraints conflict; still try surgical add first.
fieldsToAdd.push(field); fieldsToAdd.push(field);
} }
else { else {
let typeDiverged = !columnTypesMatch(liveField.type, mapDataType(field)); let mapped_data_type = mapDataType(field);
let typeDiverged = !columnTypesMatch(liveField.type, mapped_data_type);
if (isVectorField(field)) { if (isVectorField(field)) {
typeDiverged = vectorTypeDiverged(liveField.type, liveField.comment, field); typeDiverged = vectorTypeDiverged(liveField.type, liveField.comment || "", field);
if (typeDiverged) { if (typeDiverged) {
needsVectorRecreate = true; // VECTOR dimensions / storage cannot be MODIFYed — drop + re-add column
fieldsToRecreate.push(field);
continue;
} }
} }
if (typeDiverged) { const attrsDiverged = !typeDiverged && columnAttributesDiverged(liveField, field);
if (typeDiverged || attrsDiverged) {
fieldsToModify.push(field); fieldsToModify.push(field);
} }
} }
} }
// Vector dimension / storage type changes → full rebuild automatically
if (needsVectorRecreate) {
console.log(`Vector column change detected on \`${table.tableName}\`; recreating table`);
await recreateTable({ table, config });
return;
}
for (const col of existingColumns) { for (const col of existingColumns) {
if (!codeFieldsMap.has(col.name)) { if (!codeFieldsMap.has(col.name)) {
fieldsToDrop.push(col.name); fieldsToDrop.push(col.name);
@@ -117,11 +233,18 @@ export default async function updateTable({ table, config, }) {
} }
if (fieldsToAdd.length === 0 && if (fieldsToAdd.length === 0 &&
fieldsToModify.length === 0 && fieldsToModify.length === 0 &&
fieldsToRecreate.length === 0 &&
fieldsToDrop.length === 0) { fieldsToDrop.length === 0) {
return; return;
} }
console.log(`Surgically updating table structure from database layout: ${table.tableName}`); console.log(`Surgically updating table structure from database layout: ${table.tableName}`);
if (fieldsToDrop.length > 0) { if (fieldsToDrop.length > 0) {
// Drop FKs that reference columns being removed
await dropForeignKeysOnColumns({
tableName: table.tableName,
columns: fieldsToDrop,
config,
});
const schemaCond = schemaCondition(config); const schemaCond = schemaCondition(config);
const pkRows = await querySchemaRows({ const pkRows = await querySchemaRows({
query: `SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE ${schemaCond.where} AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'`, query: `SELECT COLUMN_NAME FROM information_schema.KEY_COLUMN_USAGE WHERE ${schemaCond.where} AND TABLE_NAME = ? AND CONSTRAINT_NAME = 'PRIMARY'`,
@@ -149,27 +272,43 @@ export default async function updateTable({ table, config, }) {
} }
for (const indexName of indexesToDrop) { for (const indexName of indexesToDrop) {
console.log(`Dropping index ${indexName} because it contains a dropped column`); console.log(`Dropping index ${indexName} because it contains a dropped column`);
await runSchemaQuery({ try {
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, await runSchemaQuery({
config, query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`,
}); config,
});
}
catch (err) {
if (String(err?.message || "").includes("needed in a foreign key constraint")) {
console.warn(`Skipping drop of index ${indexName}: required by a foreign key constraint`);
continue;
}
throw err;
}
} }
} }
// Drop FKs on columns being modified (MODIFY can fail with FK present)
if (fieldsToModify.length > 0) {
await dropForeignKeysOnColumns({
tableName: table.tableName,
columns: fieldsToModify
.map((f) => f.fieldName)
.filter((n) => Boolean(n)),
config,
});
}
for (const field of fieldsToAdd) { for (const field of fieldsToAdd) {
await addColumn({ tableName: table.tableName, field, config }); await addColumn({ tableName: table.tableName, field, config });
} }
for (const field of fieldsToModify) { for (const field of fieldsToModify) {
try { await modifyColumn({ tableName: table.tableName, field, config });
await modifyColumn({ tableName: table.tableName, field, config }); }
} for (const field of fieldsToRecreate) {
catch (err) { await recreateColumn({
if (isVectorField(field)) { tableName: table.tableName,
console.warn(`[Vector Resize] Re-aligning dimension spaces natively via safe migration schema rebuild.`); field,
await recreateTable({ table, config }); config,
return; });
}
throw err;
}
} }
for (const fieldName of fieldsToDrop) { for (const fieldName of fieldsToDrop) {
await dropColumn({ tableName: table.tableName, fieldName, config }); await dropColumn({ tableName: table.tableName, fieldName, config });
+6 -6
View File
@@ -74,10 +74,6 @@ export interface BUN_MARIADB_TableSchemaType {
*/ */
childTableDbId?: string | number; childTableDbId?: string | number;
collation?: (typeof MariaDBCollations)[number]; collation?: (typeof MariaDBCollations)[number];
/**
* If this is a vector-oriented table (native MariaDB VECTOR columns/indexes)
*/
isVector?: boolean;
} }
/** /**
* Reference object used to link a table to one of its child tables. * Reference object used to link a table to one of its child tables.
@@ -213,7 +209,10 @@ export type BUN_MARIADB_FieldSchemaType = {
onDelete?: string; onDelete?: string;
onDeleteLiteral?: string; onDeleteLiteral?: string;
cssFiles?: string[]; cssFiles?: string[];
integerLength?: string | number; /**
* Datatype length. Eg 255 for VARCHAR
*/
dataLength?: string | number;
decimals?: string | number; decimals?: string | number;
code?: boolean; code?: boolean;
options?: (string | number)[]; options?: (string | number)[];
@@ -830,7 +829,7 @@ export type ServerQueryParamsJoin<Table extends string = string, Field extends o
joinType: "INNER JOIN" | "JOIN" | "LEFT JOIN" | "RIGHT JOIN"; joinType: "INNER JOIN" | "JOIN" | "LEFT JOIN" | "RIGHT JOIN";
alias?: string; alias?: string;
tableName: Table; tableName: Table;
match?: ServerQueryParamsJoinMatchObject<Field> | ServerQueryParamsJoinMatchObject<Field>[]; match?: ServerQueryParamsJoinMatchObject<Field> | (ServerQueryParamsJoinMatchObject<Field> | undefined)[];
selectFields?: (keyof Field | SelectFieldObject<Field>)[]; selectFields?: (keyof Field | SelectFieldObject<Field>)[];
omitFields?: (keyof Field | { omitFields?: (keyof Field | {
field: keyof Field; field: keyof Field;
@@ -1411,6 +1410,7 @@ export type DBResponseObject<T extends {
msg?: string; msg?: string;
debug?: any; debug?: any;
count?: number; count?: number;
db_res?: any;
}; };
export type DBInsertReturn = { export type DBInsertReturn = {
count?: number; count?: number;
+5 -3
View File
@@ -1,15 +1,17 @@
export declare const ExportArchiveMembers: { export declare const ExportArchiveMembers: {
readonly SqlFileName: "dump.sql"; readonly SqlFileName: "dump.sql";
readonly SchemaFileName: "schema.ts"; readonly SchemaFileName: "schema";
}; };
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;
}; };
export declare function isArchivePath(filePath: string): boolean; export declare function isArchivePath(filePath: string): boolean;
export declare function isSqlPath(filePath: string): boolean; export declare 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 declare function writeExportArchive({ contents, outPath, }: { export declare function writeExportArchive({ contents, outPath, }: {
contents: ExportArchiveContents; contents: ExportArchiveContents;
+55 -16
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",
SchemaFileName: AppData.DbSchemaFileName, SchemaFileName: AppData.DbSchemaFileName,
@@ -15,7 +16,7 @@ export function isSqlPath(filePath) {
return filePath.toLowerCase().endsWith(".sql"); return filePath.toLowerCase().endsWith(".sql");
} }
/** /**
* 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, outPath, }) { export async function writeExportArchive({ contents, outPath, }) {
const lower = outPath.toLowerCase(); const lower = outPath.toLowerCase();
@@ -25,7 +26,7 @@ export async function writeExportArchive({ contents, outPath, }) {
} }
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");
if (gzip) { if (gzip) {
@@ -48,18 +49,20 @@ export async function readExportArchive(archivePath) {
const files = await archive.files(); const files = await archive.files();
const sql = (await readArchiveMember(files, ExportArchiveMembers.SqlFileName)) ?? const sql = (await readArchiveMember(files, ExportArchiveMembers.SqlFileName)) ??
(await readFirstMatching(files, (name) => name.endsWith(".sql"))); (await readFirstMatching(files, (name) => name.endsWith(".sql")));
const schemaTs = (await readArchiveMember(files, ExportArchiveMembers.SchemaFileName)) ?? const schemaEntry = await readFirstSchemaMember(files);
(await readFirstMatching(files, (name) => name.endsWith("schema.ts")));
if (!sql) { if (!sql) {
throw new Error(`Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`); throw new Error(`Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`);
} }
if (!schemaTs) { if (!schemaEntry) {
throw new Error(`Archive is missing schema TypeScript (expected \`${ExportArchiveMembers.SchemaFileName}\`)`); throw new Error(`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, name) { async function readArchiveMember(files, name) {
// 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();
@@ -75,15 +78,27 @@ async function readFirstMatching(files, predicate) {
} }
return null; return null;
} }
async function readFirstSchemaMember(files) {
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, }) { async function writeZipArchive({ contents, outPath, }) {
const { BUN_MARIADB_TEMP_DIR } = grabDirNames(); const { BUN_MARIADB_TEMP_DIR } = grabDirNames();
const tempDir = path.join(BUN_MARIADB_TEMP_DIR, `export-${Date.now()}-${Math.random().toString(36).slice(2)}`); const tempDir = path.join(BUN_MARIADB_TEMP_DIR, `export-${Date.now()}-${Math.random().toString(36).slice(2)}`);
fs.mkdirSync(tempDir, { recursive: true }); fs.mkdirSync(tempDir, { recursive: true });
try { try {
const sqlPath = path.join(tempDir, ExportArchiveMembers.SqlFileName); 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(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([
"zip", "zip",
@@ -91,7 +106,7 @@ async function writeZipArchive({ contents, outPath, }) {
"-j", "-j",
absOut, absOut,
ExportArchiveMembers.SqlFileName, ExportArchiveMembers.SqlFileName,
ExportArchiveMembers.SchemaFileName, contents.schemaFileName,
], { ], {
cwd: tempDir, cwd: tempDir,
stdout: "pipe", stdout: "pipe",
@@ -127,15 +142,18 @@ async function readZipArchive(archivePath) {
throw new Error(`unzip failed (exit ${exitCode}): ${stderr || "unknown error"}. Ensure \`unzip\` is installed.`); throw new Error(`unzip failed (exit ${exitCode}): ${stderr || "unknown error"}. Ensure \`unzip\` is installed.`);
} }
const sql = findFileContents(tempDir, (name) => name === ExportArchiveMembers.SqlFileName || name.endsWith(".sql")); const sql = findFileContents(tempDir, (name) => name === ExportArchiveMembers.SqlFileName || name.endsWith(".sql"));
const schemaTs = findFileContents(tempDir, (name) => name === ExportArchiveMembers.SchemaFileName || const schemaHit = findSchemaFile(tempDir);
name.endsWith("schema.ts"));
if (!sql) { if (!sql) {
throw new Error(`Archive is missing SQL dump (expected \`${ExportArchiveMembers.SqlFileName}\`)`); 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}\`)`); throw new Error(`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 });
@@ -157,3 +175,24 @@ function findFileContents(dir, predicate) {
} }
return null; return null;
} }
function findSchemaFile(dir) {
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;
}
+27
View File
@@ -0,0 +1,27 @@
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;
};
/**
* Resolve a basename (no extension) to an existing file among supported formats.
* Priority: .ts > .js > .json > .yaml > .yml
* Errors if multiple matches exist.
*/
export declare function resolveDataFile(dir: string, baseName: string): ResolvedDataFile | null;
export declare function supportedDataFileNames(baseName: string): string;
export declare function isSchemaFileName(name: string): boolean;
/**
* 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 declare function loadDataFile<T>(resolved: ResolvedDataFile): T;
export declare function resolveAndLoadDataFile<T>(dir: string, baseName: string): LoadedDataFile<T> | null;
+86
View File
@@ -0,0 +1,86 @@
import fs from "fs";
import path from "path";
import { AppData } from "../data/app-data";
function extensionToFormat(extension) {
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, baseName) {
const matches = [];
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) {
return AppData.SupportedDataFileExtensions.map((ext) => `\`${baseName}${ext}\``).join(", ");
}
export function isSchemaFileName(name) {
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) {
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;
}
const text = fs.readFileSync(resolved.path, "utf-8");
if (resolved.format === "json") {
return JSON.parse(text);
}
return Bun.YAML.parse(text);
}
export function resolveAndLoadDataFile(dir, baseName) {
const resolved = resolveDataFile(dir, baseName);
if (!resolved) {
return null;
}
return {
...resolved,
data: loadDataFile(resolved),
};
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { isUndefined } from "lodash"; import _, { isUndefined } from "lodash";
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str"; import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
import sqlGenGenJoinStr from "./sql-generator-gen-join-str"; import sqlGenGenJoinStr from "./sql-generator-gen-join-str";
import sqlGenGrabSelectFieldSQL from "./sql-generator-grab-select-field-sql"; import sqlGenGrabSelectFieldSQL from "./sql-generator-grab-select-field-sql";
@@ -162,6 +162,7 @@ export default function sqlGenGenQueryStr(params) {
if (Array.isArray(join.match)) { if (Array.isArray(join.match)) {
return ("(" + return ("(" +
join.match join.match
.filter((mtch) => !_.isUndefined(mtch))
.map((mtch) => { .map((mtch) => {
const { str, values } = sqlGenGenJoinStr({ const { str, values } = sqlGenGenJoinStr({
mtch, mtch,
+1 -1
View File
@@ -31,7 +31,7 @@ export default function sqlInsertGenerator({ tableName, data, dbFullName, }) {
: value : value
? value ? value
: null; : null;
if (!finalValue) { if (!finalValue && typeof value !== "number") {
queryValues.push(null); queryValues.push(null);
return "?"; return "?";
} }
+6 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@moduletrace/bun-mariadb", "name": "@moduletrace/bun-mariadb",
"version": "1.0.2", "version": "1.0.19",
"description": "Schema-driven MariaDB manager for Bun", "description": "Schema-driven MariaDB manager for Bun",
"author": "Benjamin Toby", "author": "Benjamin Toby",
"license": "MIT", "license": "MIT",
@@ -12,6 +12,11 @@
"types": "./dist/index.d.ts", "types": "./dist/index.d.ts",
"import": "./dist/index.js", "import": "./dist/index.js",
"default": "./dist/index.js" "default": "./dist/index.js"
},
"./types": {
"types": "./dist/types/index.d.ts",
"import": "./dist/types/index.js",
"default": "./dist/types/index.js"
} }
}, },
"bin": { "bin": {
+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);
+25 -10
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,14 +123,27 @@ 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);
console.log(
chalk.green(`Schema written → ${schemaPath}`), // 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( console.log(
+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.`);
+19 -10
View File
@@ -23,14 +23,23 @@ const BunMariaDB = {
export default BunMariaDB; export default BunMariaDB;
export type { export type * from "./types";
BunMariaDBConfig, export {
BUN_MARIADB_DatabaseSchemaType, UsersOmitedFields,
BUN_MARIADB_TableSchemaType, MariaDBCollations,
BUN_MARIADB_FieldSchemaType, MariaDBCharsets,
BUN_MARIADB_IndexSchemaType, TextFieldTypesArray,
BUN_MARIADB_UniqueConstraintSchemaType, BUN_MARIADB_DATATYPES,
BUN_MARIADB_ForeignKeyType, MariaDBIndexTypes,
DBResponseObject, ServerQueryOperators,
ServerQueryParam, ServerQueryEqualities,
SQlComparisons,
DataCrudRequestMethods,
DataCrudRequestMethodsLowerCase,
DsqlCrudActions,
QueryFields,
DockerComposeServices,
IndexTypes,
DefaultFields,
RequiredENVs,
} from "./types"; } from "./types";
+1
View File
@@ -75,6 +75,7 @@ export default async function dbHandler<
single_res: res_array?.[0], single_res: res_array?.[0],
insert_return, insert_return,
count, count,
db_res: res,
}; };
} catch (error: any) { } catch (error: any) {
return { return {
+3 -2
View File
@@ -41,7 +41,7 @@ export default async function DbDelete<
{ {
query: { query: {
id: { id: {
value: String(targetId), value: Number(targetId),
}, },
}, },
}, },
@@ -77,8 +77,9 @@ export default async function DbDelete<
if (!res.success) { if (!res.success) {
return { return {
success: false,
msg: "Database delete failed", msg: "Database delete failed",
...res,
success: false,
debug: { debug: {
sqlObj, sqlObj,
}, },
+2 -1
View File
@@ -71,8 +71,9 @@ export default async function DbInsert<
if (!res.success) { if (!res.success) {
return { return {
success: false,
msg: "Database insert failed", msg: "Database insert failed",
...res,
success: false,
debug: { debug: {
sqlObj, sqlObj,
}, },
+3 -2
View File
@@ -43,7 +43,7 @@ export default async function DbSelect<
{ {
query: { query: {
id: { id: {
value: String(targetId), value: Number(targetId),
}, },
}, },
}, },
@@ -63,8 +63,9 @@ export default async function DbSelect<
if (!res.success) { if (!res.success) {
return { return {
success: false,
msg: "Database select failed", msg: "Database select failed",
...res,
success: false,
debug: { debug: {
sqlObj, sqlObj,
sql: sqlObj.string, sql: sqlObj.string,
+6 -2
View File
@@ -20,8 +20,9 @@ export default async function DbSQL<
if (!res.success) { if (!res.success) {
return { return {
success: false,
msg: "Database query failed", msg: "Database query failed",
...res,
success: false,
debug: { debug: {
sqlObj: { sqlObj: {
sql: trimmedSql, sql: trimmedSql,
@@ -37,7 +38,10 @@ export default async function DbSQL<
const singleRaw = res.single_res as any; const singleRaw = res.single_res as any;
return { return {
success: true, ...res,
success: isSelect
? Boolean(single_res) || Boolean(payload?.[0])
: true,
payload, payload,
single_res, single_res,
debug: { debug: {
+8 -24
View File
@@ -45,7 +45,7 @@ export default async function DbUpdate<
{ {
query: { query: {
id: { id: {
value: String(targetId), value: Number(targetId),
}, },
}, },
}, },
@@ -106,6 +106,10 @@ export default async function DbUpdate<
config, config,
}); });
if (res.error) {
return res;
}
sqlObj.string = sql; sqlObj.string = sql;
sqlObj.values = values as any[]; sqlObj.values = values as any[];
@@ -113,22 +117,7 @@ export default async function DbUpdate<
let updated_sql_values: any[] = []; let updated_sql_values: any[] = [];
updated_sql += `SELECT * FROM ${quoteIdentifier(table)} ${whereClause}`; updated_sql += `SELECT * FROM ${quoteIdentifier(table)} ${whereClause}`;
updated_sql_values = [...updated_sql_values, ...sqlQueryObj.values]; updated_sql_values = [...sqlQueryObj.values];
updated_sql += ` AND `;
for (let i = 0; i < keys.length; i++) {
const key = keys[i];
if (!key) continue;
if (key == "updated_at") continue;
const isLast = i == keys.length - 1;
updated_sql += ` ${quoteIdentifier(key)}=?`;
updated_sql_values.push(finalData[key] ?? null);
if (!isLast) {
updated_sql += ` AND `;
}
}
const updated_res = await dbHandler({ const updated_res = await dbHandler({
query: updated_sql, query: updated_sql,
@@ -136,17 +125,12 @@ export default async function DbUpdate<
config, config,
}); });
const affected_rows = updated_res.payload?.length;
return { return {
...res, ...updated_res,
success: Boolean(affected_rows),
insert_return: {
affected_rows,
},
debug: { debug: {
sqlObj, sqlObj,
}, },
db_res: res.db_res,
}; };
} catch (error: any) { } catch (error: any) {
return { return {
+21 -1
View File
@@ -3,8 +3,14 @@ import isVectorField from "./is-vector-field";
import mapDataType from "./map-data-types"; import mapDataType from "./map-data-types";
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
export type BuildColumnDefinitionOptions = {
/** UNIQUE is managed by syncUniqueConstraints on existing tables */
omitUnique?: boolean;
};
export default function buildColumnDefinition( export default function buildColumnDefinition(
field: BUN_MARIADB_FieldSchemaType, field: BUN_MARIADB_FieldSchemaType,
options: BuildColumnDefinitionOptions = {},
): string { ): string {
if (!field.fieldName) { if (!field.fieldName) {
throw new Error("Field name is required"); throw new Error("Field name is required");
@@ -29,7 +35,12 @@ export default function buildColumnDefinition(
} }
// VECTOR columns cannot be UNIQUE in the usual sense // VECTOR columns cannot be UNIQUE in the usual sense
if (field.unique && !field.primaryKey && !isVectorField(field)) { if (
!options.omitUnique &&
field.unique &&
!field.primaryKey &&
!isVectorField(field)
) {
parts.push("UNIQUE"); parts.push("UNIQUE");
} }
@@ -51,3 +62,12 @@ export default function buildColumnDefinition(
return parts.join(" "); return parts.join(" ");
} }
/** Whether schema field requires NOT NULL */
export function fieldRequiresNotNull(
field: BUN_MARIADB_FieldSchemaType,
): boolean {
return Boolean(
field.notNullValue || field.primaryKey || isVectorField(field),
);
}
+18 -4
View File
@@ -1,15 +1,29 @@
import type { BUN_MARIADB_FieldSchemaType } from "../../types"; import type { BUN_MARIADB_FieldSchemaType } from "../../types";
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
export function defaultForeignKeyName(
tableName: string,
fieldName: string,
): string {
return `fk_${tableName}_${fieldName}`;
}
export function resolveForeignKeyName(
field: BUN_MARIADB_FieldSchemaType,
tableName: string,
): string {
const fieldName = field.fieldName || "column";
return field.foreignKey?.foreignKeyName || defaultForeignKeyName(tableName, fieldName);
}
export default function buildForeignKeyConstraint( export default function buildForeignKeyConstraint(
field: BUN_MARIADB_FieldSchemaType, field: BUN_MARIADB_FieldSchemaType,
tableName: string,
): string { ): string {
const fk = field.foreignKey!; const fk = field.foreignKey!;
const constraintName = fk.foreignKeyName const constraintName = resolveForeignKeyName(field, tableName);
? `CONSTRAINT ${MariaDBQuoteGen(fk.foreignKeyName)} `
: "";
let constraint = `${constraintName}FOREIGN KEY (${MariaDBQuoteGen(field.fieldName!)}) REFERENCES ${MariaDBQuoteGen(fk.destinationTableName!)}(${MariaDBQuoteGen(fk.destinationTableColumnName!)})`; let constraint = `CONSTRAINT ${MariaDBQuoteGen(constraintName)} FOREIGN KEY (${MariaDBQuoteGen(field.fieldName!)}) REFERENCES ${MariaDBQuoteGen(fk.destinationTableName!)}(${MariaDBQuoteGen(fk.destinationTableColumnName!)})`;
if (fk.cascadeDelete) { if (fk.cascadeDelete) {
constraint += " ON DELETE CASCADE"; constraint += " ON DELETE CASCADE";
+17 -15
View File
@@ -30,8 +30,10 @@ export default async function createTable({
primaryKeys.push(field.fieldName); primaryKeys.push(field.fieldName);
} }
if (field.foreignKey && !table.isVector) { if (field.foreignKey) {
foreignKeys.push(buildForeignKeyConstraint(field)); foreignKeys.push(
buildForeignKeyConstraint(field, table.tableName),
);
} }
} }
@@ -42,21 +44,21 @@ export default async function createTable({
if (table.uniqueConstraints) { if (table.uniqueConstraints) {
for (const constraint of table.uniqueConstraints) { for (const constraint of table.uniqueConstraints) {
if ( const columns = (constraint.constraintTableFields || [])
constraint.constraintTableFields && .map((field) => field.value)
constraint.constraintTableFields.length > 0 .filter((value): value is string => Boolean(value));
) {
const fields = constraint.constraintTableFields
.map((field) => MariaDBQuoteGen(field.value))
.join(", ");
const constraintName =
constraint.constraintName ||
`unique_${fields.replace(/`/g, "")}`;
columnDefinitions.push( if (columns.length === 0) {
`CONSTRAINT ${MariaDBQuoteGen(constraintName)} UNIQUE (${fields})`, continue;
);
} }
const fields = columns.map((col) => MariaDBQuoteGen(col)).join(", ");
const constraintName =
constraint.constraintName || `unique_${columns.join("_")}`;
columnDefinitions.push(
`CONSTRAINT ${MariaDBQuoteGen(constraintName)} UNIQUE (${fields})`,
);
} }
} }
@@ -0,0 +1,73 @@
import type { BunMariaDBConfig } from "../../types";
import { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition";
export type ColumnInfoRow = {
name: string;
type: string;
comment?: string;
isNullable: boolean;
columnDefault: string | null;
extra: string;
};
export default async function getTableColumnsGemini({
tableName,
config,
}: {
tableName: string;
config?: BunMariaDBConfig;
}): Promise<ColumnInfoRow[]> {
const schemaCond = schemaCondition(config);
const rows = await querySchemaRows<{
COLUMN_NAME: string;
COLUMN_TYPE: string;
COLUMN_COMMENT: string;
IS_NULLABLE: string;
COLUMN_DEFAULT: string | null;
EXTRA: string;
IS_JSON: number | boolean;
}>({
query: `
SELECT
c.COLUMN_NAME,
c.COLUMN_TYPE,
c.COLUMN_COMMENT,
c.IS_NULLABLE,
c.COLUMN_DEFAULT,
c.EXTRA,
EXISTS (
SELECT 1
FROM information_schema.CHECK_CONSTRAINTS cc
WHERE cc.CONSTRAINT_SCHEMA = c.TABLE_SCHEMA
AND cc.TABLE_NAME = c.TABLE_NAME
AND cc.CHECK_CLAUSE LIKE CONCAT('%json_valid(\`', c.COLUMN_NAME, '\`)%')
) AS IS_JSON
FROM information_schema.COLUMNS c
WHERE ${schemaCond.where.replace(/\bTABLE_SCHEMA\b/g, "c.TABLE_SCHEMA")}
AND c.TABLE_NAME = ?
ORDER BY c.ORDINAL_POSITION
`,
values: [...schemaCond.values, tableName],
config,
});
return rows.map((row) => {
const isJson = Number(row.IS_JSON) === 1;
// Normalize longtext with a json_valid constraint to "json"
let resolvedType = row.COLUMN_TYPE;
if (isJson && row.COLUMN_TYPE.toLowerCase() === "longtext") {
resolvedType = "json";
}
return {
name: row.COLUMN_NAME,
type: resolvedType,
comment: row.COLUMN_COMMENT,
isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES",
columnDefault: row.COLUMN_DEFAULT,
extra: row.EXTRA || "",
};
});
}
+10 -1
View File
@@ -6,6 +6,9 @@ export type ColumnInfoRow = {
name: string; name: string;
type: string; type: string;
comment?: string; comment?: string;
isNullable: boolean;
columnDefault: string | null;
extra: string;
}; };
export default async function getTableColumns({ export default async function getTableColumns({
@@ -20,8 +23,11 @@ export default async function getTableColumns({
COLUMN_NAME: string; COLUMN_NAME: string;
COLUMN_TYPE: string; COLUMN_TYPE: string;
COLUMN_COMMENT: string; COLUMN_COMMENT: string;
IS_NULLABLE: string;
COLUMN_DEFAULT: string | null;
EXTRA: string;
}>({ }>({
query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`, query: `SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT, IS_NULLABLE, COLUMN_DEFAULT, EXTRA FROM information_schema.COLUMNS WHERE ${schemaCond.where} AND TABLE_NAME = ? ORDER BY ORDINAL_POSITION`,
values: [...schemaCond.values, tableName], values: [...schemaCond.values, tableName],
config, config,
}); });
@@ -30,5 +36,8 @@ export default async function getTableColumns({
name: row.COLUMN_NAME, name: row.COLUMN_NAME,
type: row.COLUMN_TYPE, type: row.COLUMN_TYPE,
comment: row.COLUMN_COMMENT, comment: row.COLUMN_COMMENT,
isNullable: String(row.IS_NULLABLE).toUpperCase() === "YES",
columnDefault: row.COLUMN_DEFAULT,
extra: row.EXTRA || "",
})); }));
} }
+23 -8
View File
@@ -4,7 +4,14 @@ import MariaDBQuoteGen from "./mariadb-quote-gen";
import resolveTable from "./resolve-table"; import resolveTable from "./resolve-table";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition"; import schemaCondition from "./schema-condition";
import {
dropObsoleteForeignKeys,
ensureForeignKeys,
} from "./sync-foreign-keys";
import syncIndexes from "./sync-indexes"; import syncIndexes from "./sync-indexes";
import syncPrimaryKey from "./sync-primary-key";
import syncTableOptions from "./sync-table-options";
import syncUniqueConstraints from "./sync-unique-constraints";
import updateTable from "./update-table"; import updateTable from "./update-table";
import upsertDbManagerTable, { import upsertDbManagerTable, {
removeDbManagerTable, removeDbManagerTable,
@@ -21,7 +28,6 @@ export default async function handleDBSchemaTable({
let tableExistsTracked = Boolean(db_manager_table_name); let tableExistsTracked = Boolean(db_manager_table_name);
let tableExistsLive = Boolean(existing_live_table?.TABLE_NAME); let tableExistsLive = Boolean(existing_live_table?.TABLE_NAME);
let wasRenamed = false;
if ( if (
resolvedTable.tableNameOld && resolvedTable.tableNameOld &&
@@ -53,7 +59,6 @@ export default async function handleDBSchemaTable({
}); });
tableExistsTracked = true; tableExistsTracked = true;
tableExistsLive = true; tableExistsLive = true;
wasRenamed = true;
} }
} }
@@ -64,17 +69,27 @@ export default async function handleDBSchemaTable({
config, config,
}); });
} else { } else {
if (!wasRenamed) { // Columns first (also drops FKs on removed/modified columns)
await updateTable({ await updateTable({
table: resolvedTable, table: resolvedTable,
config, config,
}); });
}
await upsertDbManagerTable({ await upsertDbManagerTable({
tableName: resolvedTable.tableName, tableName: resolvedTable.tableName,
config, config,
}); });
} }
// Order matters:
// 1. Table options (engine/collation)
// 2. Drop obsolete/changed FKs (unlocks index cleanup)
// 3. Primary key
// 4. Indexes / uniques (FK columns need supporting indexes)
// 5. Ensure foreign keys exist
await syncTableOptions({ table: resolvedTable, config });
await dropObsoleteForeignKeys({ table: resolvedTable, config });
await syncPrimaryKey({ table: resolvedTable, config });
await syncIndexes({ table: resolvedTable, config }); await syncIndexes({ table: resolvedTable, config });
await syncUniqueConstraints({ table: resolvedTable, config });
await ensureForeignKeys({ table: resolvedTable, config });
} }
+3 -1
View File
@@ -91,7 +91,8 @@ export default async function handleDBSchemaTables(
schemaTableNames.includes(row.TABLE_NAME) || schemaTableNames.includes(row.TABLE_NAME) ||
!tablesToDrop.includes(row.TABLE_NAME) !tablesToDrop.includes(row.TABLE_NAME)
) { ) {
const list = referencedByRemaining.get(row.REFERENCED_TABLE_NAME) || []; const list =
referencedByRemaining.get(row.REFERENCED_TABLE_NAME) || [];
if (!list.includes(row.TABLE_NAME)) { if (!list.includes(row.TABLE_NAME)) {
list.push(row.TABLE_NAME); list.push(row.TABLE_NAME);
} }
@@ -118,6 +119,7 @@ export default async function handleDBSchemaTables(
} }
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 0`, config }); await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 0`, config });
try { try {
for (const tableName of safeToDrop) { for (const tableName of safeToDrop) {
console.log(`Dropping table: ${tableName}`); console.log(`Dropping table: ${tableName}`);
+16 -17
View File
@@ -13,9 +13,9 @@ export default function mapDataType(
switch (dataType) { switch (dataType) {
case "CHAR": case "CHAR":
return `CHAR(${field.integerLength || 255})`; return `CHAR(${field.dataLength || 255})`;
case "VARCHAR": case "VARCHAR":
return `VARCHAR(${field.integerLength || 255})`; return `VARCHAR(${field.dataLength || 255})`;
case "TEXT": case "TEXT":
return "TEXT"; return "TEXT";
case "TINYTEXT": case "TINYTEXT":
@@ -25,36 +25,34 @@ export default function mapDataType(
case "LONGTEXT": case "LONGTEXT":
return "LONGTEXT"; return "LONGTEXT";
case "TINYINT": case "TINYINT":
return field.integerLength return field.dataLength
? `TINYINT(${field.integerLength})` ? `TINYINT(${field.dataLength})`
: "TINYINT"; : "TINYINT";
case "SMALLINT": case "SMALLINT":
return field.integerLength return field.dataLength
? `SMALLINT(${field.integerLength})` ? `SMALLINT(${field.dataLength})`
: "SMALLINT"; : "SMALLINT";
case "MEDIUMINT": case "MEDIUMINT":
return field.integerLength return field.dataLength
? `MEDIUMINT(${field.integerLength})` ? `MEDIUMINT(${field.dataLength})`
: "MEDIUMINT"; : "MEDIUMINT";
case "INT": case "INT":
return field.integerLength ? `INT(${field.integerLength})` : "INT"; return field.dataLength ? `INT(${field.dataLength})` : "INT";
case "BIGINT": case "BIGINT":
return field.integerLength return field.dataLength ? `BIGINT(${field.dataLength})` : "BIGINT";
? `BIGINT(${field.integerLength})`
: "BIGINT";
case "FLOAT": case "FLOAT":
return "FLOAT"; return "FLOAT";
case "DOUBLE": case "DOUBLE":
return "DOUBLE"; return "DOUBLE";
case "DECIMAL": case "DECIMAL":
if (field.integerLength && field.decimals) { if (field.dataLength && field.decimals) {
return `DECIMAL(${field.integerLength}, ${field.decimals})`; return `DECIMAL(${field.dataLength}, ${field.decimals})`;
} }
return "DECIMAL(10,2)"; return "DECIMAL(10,2)";
case "BINARY": case "BINARY":
return `BINARY(${field.integerLength || 1})`; return `BINARY(${field.dataLength || 1})`;
case "VARBINARY": case "VARBINARY":
return `VARBINARY(${field.integerLength || 255})`; return `VARBINARY(${field.dataLength || 255})`;
case "BLOB": case "BLOB":
return "BLOB"; return "BLOB";
case "TINYBLOB": case "TINYBLOB":
@@ -74,11 +72,12 @@ export default function mapDataType(
case "YEAR": case "YEAR":
return "YEAR"; return "YEAR";
case "UUID": case "UUID":
return "CHAR(36)"; // MariaDB does not have a native UUID type return "UUID";
case "JSON": case "JSON":
return "JSON"; return "JSON";
case "INET6": case "INET6":
return "INET6"; return "INET6";
case "BOOL":
case "BOOLEAN": case "BOOLEAN":
return "TINYINT(1)"; return "TINYINT(1)";
case "ENUM": { case "ENUM": {
-148
View File
@@ -1,148 +0,0 @@
import type {
BUN_MARIADB_TableSchemaType,
BunMariaDBConfig,
} from "../../types";
import createTable from "./create-table";
import getTableColumns from "./get-table-columns";
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition";
async function checkIfTableExists({
tableName,
config,
}: {
tableName: string;
config?: BunMariaDBConfig;
}): Promise<boolean> {
const schemaCond = schemaCondition(config);
const rows = await querySchemaRows<{ table_exists: number }>({
query: `SELECT 1 AS \`table_exists\` FROM information_schema.TABLES WHERE ${schemaCond.where} AND TABLE_NAME = ? LIMIT 1`,
values: [...schemaCond.values, tableName],
config,
});
return Boolean(rows[0]?.table_exists);
}
/**
* Full table rebuild. For `isVector` tables this drops and recreates in place
* (preserving rows when possible). For regular tables it uses a temp-table swap.
*/
export default async function recreateTable({
table,
config,
}: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void> {
const doesTableExist = await checkIfTableExists({
tableName: table.tableName,
config,
});
if (!doesTableExist) {
await createTable({ table, config });
return;
}
/**
* Vector tables: drop + recreate + reinsert (MariaDB VECTOR INDEX / dim
* changes are not reliably alterable in place).
*/
if (table.isVector) {
console.log(`Recreating vector table: ${table.tableName}`);
const existingRows = await querySchemaRows<Record<string, any>>({
query: `SELECT * FROM ${MariaDBQuoteGen(table.tableName)}`,
config,
});
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 0`, config });
try {
await runSchemaQuery({
query: `DROP TABLE IF EXISTS ${MariaDBQuoteGen(table.tableName)}`,
config,
});
await createTable({ table, config });
} finally {
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 1`, config });
}
if (existingRows.length > 0) {
const schemaFieldNames = new Set(
(table.fields || [])
.map((f) => f.fieldName)
.filter((n): n is string => Boolean(n)),
);
for (const row of existingRows) {
const columns = Object.keys(row).filter((c) =>
schemaFieldNames.has(c),
);
if (columns.length === 0) continue;
const placeholders = columns.map(() => "?").join(", ");
const columnList = columns
.map((c) => MariaDBQuoteGen(c))
.join(", ");
await runSchemaQuery({
query: `INSERT INTO ${MariaDBQuoteGen(table.tableName)} (${columnList}) VALUES (${placeholders})`,
values: columns.map((c) => row[c] ?? null),
config,
});
}
}
return;
}
const tempTableName = `${table.tableName}_temp_${Date.now()}`;
const backupOldTableName = `${table.tableName}_old_${Date.now()}`;
const existingColumns = await getTableColumns({
tableName: table.tableName,
config,
});
const columnsToKeep = (table.fields || [])
.filter((field) =>
existingColumns.some((column) => column.name === field.fieldName),
)
.map((field) => field.fieldName)
.filter((fieldName): fieldName is string => Boolean(fieldName));
await createTable({
table: { ...table, tableName: tempTableName },
config,
});
if (columnsToKeep.length > 0) {
const columnList = columnsToKeep
.map((column) => MariaDBQuoteGen(column))
.join(", ");
await runSchemaQuery({
query: `INSERT INTO ${MariaDBQuoteGen(tempTableName)} (${columnList}) SELECT ${columnList} FROM ${MariaDBQuoteGen(table.tableName)}`,
config,
});
}
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 0`, config });
try {
await runSchemaQuery({
query: `RENAME TABLE ${MariaDBQuoteGen(table.tableName)} TO ${MariaDBQuoteGen(backupOldTableName)}`,
config,
});
await runSchemaQuery({
query: `RENAME TABLE ${MariaDBQuoteGen(tempTableName)} TO ${MariaDBQuoteGen(table.tableName)}`,
config,
});
await runSchemaQuery({
query: `DROP TABLE ${MariaDBQuoteGen(backupOldTableName)}`,
config,
});
} finally {
await runSchemaQuery({ query: `SET FOREIGN_KEY_CHECKS = 1`, config });
}
}
-2
View File
@@ -41,8 +41,6 @@ export default function resolveTable(
tableName: table.tableName, tableName: table.tableName,
tableDescription: table.tableDescription || parentTable.tableDescription, tableDescription: table.tableDescription || parentTable.tableDescription,
collation: table.collation || parentTable.collation, collation: table.collation || parentTable.collation,
isVector:
table.isVector !== undefined ? table.isVector : parentTable.isVector,
fields: Array.from(mergedFieldsMap.values()), fields: Array.from(mergedFieldsMap.values()),
indexes: _.uniqBy( indexes: _.uniqBy(
[...(parentTable.indexes || []), ...(table.indexes || [])], [...(parentTable.indexes || []), ...(table.indexes || [])],
+306
View File
@@ -0,0 +1,306 @@
import type {
BUN_MARIADB_FieldSchemaType,
BUN_MARIADB_TableSchemaType,
BunMariaDBConfig,
} from "../../types";
import buildForeignKeyConstraint, {
resolveForeignKeyName,
} from "./build-foreign-key-constraint";
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
export type DesiredForeignKey = {
name: string;
column: string;
refTable: string;
refColumn: string;
cascadeDelete: boolean;
cascadeUpdate: boolean;
field: BUN_MARIADB_FieldSchemaType;
};
export type LiveForeignKey = {
name: string;
column: string;
refTable: string;
refColumn: string;
deleteRule: string;
updateRule: string;
};
function normalizeRule(rule: string | undefined | null): string {
return String(rule || "RESTRICT").toUpperCase().replace(/_/g, " ");
}
function ruleIsCascade(rule: string): boolean {
return normalizeRule(rule) === "CASCADE";
}
function fkIdentityKey(fk: {
column: string;
refTable: string;
refColumn: string;
}): string {
return `${fk.column}\0${fk.refTable}\0${fk.refColumn}`;
}
function rulesMatch(
live: LiveForeignKey,
desired: DesiredForeignKey,
): boolean {
return (
ruleIsCascade(live.deleteRule) === desired.cascadeDelete &&
ruleIsCascade(live.updateRule) === desired.cascadeUpdate
);
}
export function grabDesiredForeignKeys(
table: BUN_MARIADB_TableSchemaType,
): DesiredForeignKey[] {
const desired: DesiredForeignKey[] = [];
for (const field of table.fields || []) {
const fk = field.foreignKey;
if (
!field.fieldName ||
!fk?.destinationTableName ||
!fk.destinationTableColumnName
) {
continue;
}
desired.push({
name: resolveForeignKeyName(field, table.tableName),
column: field.fieldName,
refTable: fk.destinationTableName,
refColumn: fk.destinationTableColumnName,
cascadeDelete: Boolean(fk.cascadeDelete),
cascadeUpdate: Boolean(fk.cascadeUpdate),
field,
});
}
return desired;
}
export async function grabLiveForeignKeys({
tableName,
config,
}: {
tableName: string;
config?: BunMariaDBConfig;
}): Promise<LiveForeignKey[]> {
const databaseName = config?.db_name || global.CONFIG?.db_name;
const schemaWhere = databaseName
? "rc.CONSTRAINT_SCHEMA = ?"
: "rc.CONSTRAINT_SCHEMA = DATABASE()";
const schemaValues = databaseName ? [databaseName] : [];
const rows = await querySchemaRows<{
CONSTRAINT_NAME: string;
COLUMN_NAME: string;
REFERENCED_TABLE_NAME: string;
REFERENCED_COLUMN_NAME: string;
DELETE_RULE: string;
UPDATE_RULE: string;
}>({
query: `
SELECT
rc.CONSTRAINT_NAME,
kcu.COLUMN_NAME,
kcu.REFERENCED_TABLE_NAME,
kcu.REFERENCED_COLUMN_NAME,
rc.DELETE_RULE,
rc.UPDATE_RULE
FROM information_schema.REFERENTIAL_CONSTRAINTS rc
INNER JOIN information_schema.KEY_COLUMN_USAGE kcu
ON rc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
AND rc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
AND rc.TABLE_NAME = kcu.TABLE_NAME
WHERE ${schemaWhere}
AND rc.TABLE_NAME = ?
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
ORDER BY rc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION
`,
values: [...schemaValues, tableName],
config,
});
// Single-column FKs only (schema model is one FK per field)
const byName = new Map<string, LiveForeignKey>();
for (const row of rows) {
if (byName.has(row.CONSTRAINT_NAME)) {
continue;
}
byName.set(row.CONSTRAINT_NAME, {
name: row.CONSTRAINT_NAME,
column: row.COLUMN_NAME,
refTable: row.REFERENCED_TABLE_NAME,
refColumn: row.REFERENCED_COLUMN_NAME,
deleteRule: row.DELETE_RULE,
updateRule: row.UPDATE_RULE,
});
}
return [...byName.values()];
}
export async function dropForeignKey({
tableName,
constraintName,
config,
}: {
tableName: string;
constraintName: string;
config?: BunMariaDBConfig;
}): Promise<void> {
console.log(
`Dropping foreign key: ${constraintName} on ${tableName}`,
);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} DROP FOREIGN KEY ${MariaDBQuoteGen(constraintName)}`,
config,
});
}
export async function dropForeignKeysOnColumns({
tableName,
columns,
config,
}: {
tableName: string;
columns: string[];
config?: BunMariaDBConfig;
}): Promise<void> {
if (columns.length === 0) return;
const live = await grabLiveForeignKeys({ tableName, config });
const colSet = new Set(columns);
for (const fk of live) {
if (colSet.has(fk.column)) {
await dropForeignKey({
tableName,
constraintName: fk.name,
config,
});
}
}
}
/**
* Drop foreign keys that are removed from schema or need recreation
* (name/cascade/target changed). Run before index cleanup.
*/
export async function dropObsoleteForeignKeys({
table,
config,
}: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void> {
const desired = grabDesiredForeignKeys(table);
const live = await grabLiveForeignKeys({
tableName: table.tableName,
config,
});
const keepNames = new Set<string>();
const droppedNames = new Set<string>();
const dropOnce = async (name: string) => {
if (droppedNames.has(name)) return;
droppedNames.add(name);
await dropForeignKey({
tableName: table.tableName,
constraintName: name,
config,
});
};
for (const want of desired) {
const byIdentity = live.find(
(existing) =>
!keepNames.has(existing.name) &&
!droppedNames.has(existing.name) &&
fkIdentityKey(existing) === fkIdentityKey(want),
);
if (
byIdentity &&
rulesMatch(byIdentity, want) &&
byIdentity.name === want.name
) {
keepNames.add(byIdentity.name);
continue;
}
// Changed relationship → drop so ensureForeignKeys can recreate
if (byIdentity) {
await dropOnce(byIdentity.name);
}
const byName = live.find((existing) => existing.name === want.name);
if (byName && byName !== byIdentity) {
await dropOnce(byName.name);
}
}
for (const existing of live) {
if (keepNames.has(existing.name) || droppedNames.has(existing.name)) {
continue;
}
// Not in schema anymore
await dropOnce(existing.name);
}
}
/**
* Ensure all desired foreign keys exist. Run after indexes/uniques.
*/
export async function ensureForeignKeys({
table,
config,
}: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void> {
const desired = grabDesiredForeignKeys(table);
const live = await grabLiveForeignKeys({
tableName: table.tableName,
config,
});
for (const want of desired) {
const exists = live.some(
(existing) =>
fkIdentityKey(existing) === fkIdentityKey(want) &&
rulesMatch(existing, want) &&
existing.name === want.name,
);
if (exists) {
continue;
}
console.log(
`Creating foreign key: ${want.name} (${want.column}${want.refTable}.${want.refColumn}) on ${table.tableName}`,
);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD ${buildForeignKeyConstraint(want.field, table.tableName)}`,
config,
});
}
}
export default async function syncForeignKeys({
table,
config,
}: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void> {
await dropObsoleteForeignKeys({ table, config });
await ensureForeignKeys({ table, config });
}
+91 -42
View File
@@ -7,13 +7,13 @@ import isVectorField from "./is-vector-field";
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition"; import schemaCondition from "./schema-condition";
import { grabDesiredUniqueConstraints } from "./sync-unique-constraints";
function isVectorIndexDef( function isVectorIndexDef(
index: BUN_MARIADB_IndexSchemaType, index: BUN_MARIADB_IndexSchemaType,
table: BUN_MARIADB_TableSchemaType, table: BUN_MARIADB_TableSchemaType,
): boolean { ): boolean {
if (index.indexType === "VECTOR") return true; if (index.indexType === "VECTOR") return true;
if (table.isVector) return true;
const firstFieldName = index.indexTableFields?.[0]; const firstFieldName = index.indexTableFields?.[0];
if (!firstFieldName) return false; if (!firstFieldName) return false;
@@ -28,6 +28,87 @@ function vectorDistanceMetric(index: BUN_MARIADB_IndexSchemaType): string {
return "euclidean"; return "euclidean";
} }
/** Normalize schema index type vs information_schema.INDEX_TYPE */
function indexTypesMatch(
liveType: string,
schemaIndex: BUN_MARIADB_IndexSchemaType,
table: BUN_MARIADB_TableSchemaType,
): boolean {
const live = (liveType || "").toUpperCase();
const schemaType = schemaIndex.indexType?.toUpperCase();
if (isVectorIndexDef(schemaIndex, table)) {
// MariaDB may report VECTOR indexes as BTREE or VECTOR depending on version
return live === "VECTOR" || live === "BTREE" || live === "";
}
if (!schemaType || schemaType === "BTREE") {
// Default / unspecified → BTREE
return live === "BTREE" || live === "";
}
if (schemaType === "FULLTEXT") {
return live === "FULLTEXT";
}
if (schemaType === "SPATIAL") {
return live === "SPATIAL";
}
if (schemaType === "HASH") {
return live === "HASH";
}
return live === schemaType;
}
async function createIndex({
table,
index,
config,
}: {
table: BUN_MARIADB_TableSchemaType;
index: BUN_MARIADB_IndexSchemaType;
config?: BunMariaDBConfig;
}): Promise<void> {
if (
!index.indexName ||
!index.indexTableFields ||
index.indexTableFields.length === 0
) {
return;
}
if (isVectorIndexDef(index, table)) {
console.log(`Creating Vector index: ${index.indexName}`);
const targetField = MariaDBQuoteGen(index.indexTableFields[0]!);
const distanceMetric = vectorDistanceMetric(index);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD VECTOR INDEX ${MariaDBQuoteGen(index.indexName)} (${targetField}) M=8 DISTANCE=${distanceMetric}`,
config,
});
return;
}
console.log(`Creating standard index: ${index.indexName}`);
const fields = index.indexTableFields
.map((field) => MariaDBQuoteGen(field))
.join(", ");
const typeUpper = index.indexType?.toUpperCase();
const isSpecialType = typeUpper === "FULLTEXT" || typeUpper === "SPATIAL";
const indexPrefix = isSpecialType ? `${typeUpper} ` : "";
const indexSuffix =
!isSpecialType && (typeUpper === "BTREE" || typeUpper === "HASH")
? ` USING ${typeUpper}`
: "";
await runSchemaQuery({
query: `CREATE ${indexPrefix}INDEX ${MariaDBQuoteGen(index.indexName)} ON ${MariaDBQuoteGen(table.tableName)} (${fields})${indexSuffix}`,
config,
});
}
export default async function syncIndexes({ export default async function syncIndexes({
table, table,
config, config,
@@ -62,16 +143,8 @@ export default async function syncIndexes({
protectedConstraintRows.map((r) => r.CONSTRAINT_NAME), protectedConstraintRows.map((r) => r.CONSTRAINT_NAME),
); );
// Column-level UNIQUE creates an index often named after the column for (const constraint of grabDesiredUniqueConstraints(table)) {
for (const field of table.fields || []) { protectedIndexNames.add(constraint.name);
if (field.unique && field.fieldName) {
protectedIndexNames.add(field.fieldName);
}
}
for (const constraint of table.uniqueConstraints || []) {
if (constraint.constraintName) {
protectedIndexNames.add(constraint.constraintName);
}
} }
const existingIndexesMap = new Map< const existingIndexesMap = new Map<
@@ -94,7 +167,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}`);
@@ -103,6 +178,7 @@ export default async function syncIndexes({
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`,
config, config,
}); });
existingIndexesMap.delete(indexName);
} catch (err: any) { } catch (err: any) {
if ( if (
String(err?.message || "").includes( String(err?.message || "").includes(
@@ -121,8 +197,9 @@ export default async function syncIndexes({
const columnsMatch = const columnsMatch =
details.columns.length === schemaColumns.length && details.columns.length === schemaColumns.length &&
details.columns.every((col, idx) => col === schemaColumns[idx]); details.columns.every((col, idx) => col === schemaColumns[idx]);
const typeMatch = indexTypesMatch(details.type, schemaIndex, table);
if (!columnsMatch) { if (!columnsMatch || !typeMatch) {
console.log(`Recreating changed index: ${indexName}`); console.log(`Recreating changed index: ${indexName}`);
await runSchemaQuery({ await runSchemaQuery({
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`,
@@ -143,35 +220,7 @@ export default async function syncIndexes({
} }
if (!existingIndexesMap.has(index.indexName)) { if (!existingIndexesMap.has(index.indexName)) {
if (isVectorIndexDef(index, table)) { await createIndex({ table, index, config });
console.log(`Creating Vector index: ${index.indexName}`);
const targetField = MariaDBQuoteGen(index.indexTableFields[0]!);
const distanceMetric = vectorDistanceMetric(index);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD VECTOR INDEX ${MariaDBQuoteGen(index.indexName)} (${targetField}) M=8 DISTANCE=${distanceMetric}`,
config,
});
} else {
console.log(`Creating standard index: ${index.indexName}`);
const fields = index.indexTableFields
.map((field) => MariaDBQuoteGen(field))
.join(", ");
const typeUpper = index.indexType?.toUpperCase();
const isSpecialType =
typeUpper === "FULLTEXT" || typeUpper === "SPATIAL";
const indexPrefix = isSpecialType ? `${typeUpper} ` : "";
const indexSuffix =
!isSpecialType &&
(typeUpper === "BTREE" || typeUpper === "HASH")
? ` USING ${typeUpper}`
: "";
await runSchemaQuery({
query: `CREATE ${indexPrefix}INDEX ${MariaDBQuoteGen(index.indexName)} ON ${MariaDBQuoteGen(table.tableName)} (${fields})${indexSuffix}`,
config,
});
}
} }
} }
} }
+83
View File
@@ -0,0 +1,83 @@
import type {
BUN_MARIADB_TableSchemaType,
BunMariaDBConfig,
} from "../../types";
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition";
function columnsEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((col, i) => col === b[i]);
}
export function grabDesiredPrimaryKeyColumns(
table: BUN_MARIADB_TableSchemaType,
): string[] {
return (table.fields || [])
.filter((field) => field.primaryKey && field.fieldName)
.map((field) => field.fieldName!);
}
async function grabLivePrimaryKeyColumns({
tableName,
config,
}: {
tableName: string;
config?: BunMariaDBConfig;
}): Promise<string[]> {
const schemaCond = schemaCondition(config);
const rows = await querySchemaRows<{
COLUMN_NAME: string;
ORDINAL_POSITION: number;
}>({
query: `
SELECT COLUMN_NAME, ORDINAL_POSITION
FROM information_schema.KEY_COLUMN_USAGE
WHERE ${schemaCond.where}
AND TABLE_NAME = ?
AND CONSTRAINT_NAME = 'PRIMARY'
ORDER BY ORDINAL_POSITION
`,
values: [...schemaCond.values, tableName],
config,
});
return rows.map((row) => row.COLUMN_NAME);
}
export default async function syncPrimaryKey({
table,
config,
}: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void> {
const desired = grabDesiredPrimaryKeyColumns(table);
const live = await grabLivePrimaryKeyColumns({
tableName: table.tableName,
config,
});
if (columnsEqual(desired, live)) {
return;
}
if (live.length > 0) {
console.log(`Dropping primary key on ${table.tableName}`);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP PRIMARY KEY`,
config,
});
}
if (desired.length > 0) {
const cols = desired.map((col) => MariaDBQuoteGen(col)).join(", ");
console.log(
`Creating primary key (${desired.join(", ")}) on ${table.tableName}`,
);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD PRIMARY KEY (${cols})`,
config,
});
}
}
+57
View File
@@ -0,0 +1,57 @@
import type {
BUN_MARIADB_TableSchemaType,
BunMariaDBConfig,
} from "../../types";
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition";
export default async function syncTableOptions({
table,
config,
}: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void> {
const schemaCond = schemaCondition(config);
const rows = await querySchemaRows<{
ENGINE: string;
TABLE_COLLATION: string;
}>({
query: `SELECT ENGINE, TABLE_COLLATION FROM information_schema.TABLES WHERE ${schemaCond.where} AND TABLE_NAME = ? AND TABLE_TYPE = 'BASE TABLE'`,
values: [...schemaCond.values, table.tableName],
config,
});
const live = rows[0];
if (!live) {
return;
}
const alters: string[] = [];
if ((live.ENGINE || "").toUpperCase() !== "INNODB") {
alters.push("ENGINE=InnoDB");
}
if (table.collation) {
const liveCollation = (live.TABLE_COLLATION || "").toLowerCase();
if (liveCollation !== table.collation.toLowerCase()) {
alters.push(
`CONVERT TO CHARACTER SET utf8mb4 COLLATE ${table.collation}`,
);
}
}
if (alters.length === 0) {
return;
}
console.log(
`Updating table options on ${table.tableName}: ${alters.join(", ")}`,
);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ${alters.join(", ")}`,
config,
});
}
+213
View File
@@ -0,0 +1,213 @@
import type {
BUN_MARIADB_TableSchemaType,
BunMariaDBConfig,
} from "../../types";
import isVectorField from "./is-vector-field";
import MariaDBQuoteGen from "./mariadb-quote-gen";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
type UniqueConstraintLive = {
name: string;
columns: string[];
};
type UniqueConstraintDesired = {
name: string;
columns: string[];
};
function columnsKey(columns: string[]): string {
return columns.join("\0");
}
function columnsEqual(a: string[], b: string[]): boolean {
return a.length === b.length && a.every((col, i) => col === b[i]);
}
function defaultConstraintName(columns: string[]): string {
return `unique_${columns.join("_")}`;
}
export function grabDesiredUniqueConstraints(
table: BUN_MARIADB_TableSchemaType,
): UniqueConstraintDesired[] {
const desired: UniqueConstraintDesired[] = [];
const seenColumnSets = new Set<string>();
for (const constraint of table.uniqueConstraints || []) {
const columns = (constraint.constraintTableFields || [])
.map((field) => field.value)
.filter((value): value is string => Boolean(value));
if (columns.length === 0) {
continue;
}
const key = columnsKey(columns);
if (seenColumnSets.has(key)) {
continue;
}
seenColumnSets.add(key);
desired.push({
name: constraint.constraintName || defaultConstraintName(columns),
columns,
});
}
for (const field of table.fields || []) {
if (
!field.fieldName ||
!field.unique ||
field.primaryKey ||
isVectorField(field)
) {
continue;
}
const columns = [field.fieldName];
const key = columnsKey(columns);
if (seenColumnSets.has(key)) {
continue;
}
seenColumnSets.add(key);
// Matches MariaDB's default name for column-level UNIQUE
desired.push({
name: field.fieldName,
columns,
});
}
return desired;
}
async function grabLiveUniqueConstraints({
tableName,
config,
}: {
tableName: string;
config?: BunMariaDBConfig;
}): Promise<UniqueConstraintLive[]> {
const databaseName = config?.db_name || global.CONFIG?.db_name;
const schemaWhere = databaseName
? "tc.TABLE_SCHEMA = ?"
: "tc.TABLE_SCHEMA = DATABASE()";
const schemaValues = databaseName ? [databaseName] : [];
const rows = await querySchemaRows<{
CONSTRAINT_NAME: string;
COLUMN_NAME: string;
ORDINAL_POSITION: number;
}>({
query: `
SELECT tc.CONSTRAINT_NAME, kcu.COLUMN_NAME, kcu.ORDINAL_POSITION
FROM information_schema.TABLE_CONSTRAINTS tc
INNER JOIN information_schema.KEY_COLUMN_USAGE kcu
ON tc.CONSTRAINT_SCHEMA = kcu.CONSTRAINT_SCHEMA
AND tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
AND tc.TABLE_NAME = kcu.TABLE_NAME
WHERE ${schemaWhere}
AND tc.TABLE_NAME = ?
AND tc.CONSTRAINT_TYPE = 'UNIQUE'
ORDER BY tc.CONSTRAINT_NAME, kcu.ORDINAL_POSITION
`,
values: [...schemaValues, tableName],
config,
});
const map = new Map<string, string[]>();
for (const row of rows) {
if (!map.has(row.CONSTRAINT_NAME)) {
map.set(row.CONSTRAINT_NAME, []);
}
map.get(row.CONSTRAINT_NAME)!.push(row.COLUMN_NAME);
}
return [...map.entries()].map(([name, columns]) => ({ name, columns }));
}
export default async function syncUniqueConstraints({
table,
config,
}: {
table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig;
}): Promise<void> {
const desired = grabDesiredUniqueConstraints(table);
const live = await grabLiveUniqueConstraints({
tableName: table.tableName,
config,
});
const matchedLiveNames = new Set<string>();
for (const want of desired) {
const byColumns = live.find(
(existing) =>
!matchedLiveNames.has(existing.name) &&
columnsEqual(existing.columns, want.columns),
);
if (byColumns) {
matchedLiveNames.add(byColumns.name);
continue;
}
const byName = live.find((existing) => existing.name === want.name);
if (byName && !matchedLiveNames.has(byName.name)) {
console.log(
`Recreating changed unique constraint: ${want.name} on ${table.tableName}`,
);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP INDEX ${MariaDBQuoteGen(byName.name)}`,
config,
});
matchedLiveNames.add(byName.name);
}
const fields = want.columns.map((col) => MariaDBQuoteGen(col)).join(", ");
console.log(
`Creating unique constraint: ${want.name} (${want.columns.join(", ")}) on ${table.tableName}`,
);
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} ADD CONSTRAINT ${MariaDBQuoteGen(want.name)} UNIQUE (${fields})`,
config,
});
}
for (const existing of live) {
if (matchedLiveNames.has(existing.name)) {
continue;
}
const stillDesired = desired.some((want) =>
columnsEqual(want.columns, existing.columns),
);
if (stillDesired) {
continue;
}
console.log(
`Dropping unique constraint: ${existing.name} on ${table.tableName}`,
);
try {
await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(table.tableName)} DROP INDEX ${MariaDBQuoteGen(existing.name)}`,
config,
});
} catch (err: any) {
if (
String(err?.message || "").includes(
"needed in a foreign key constraint",
)
) {
console.warn(
`Skipping drop of unique constraint ${existing.name}: required by a foreign key constraint`,
);
continue;
}
throw err;
}
}
}
+232 -42
View File
@@ -3,15 +3,18 @@ import type {
BUN_MARIADB_TableSchemaType, BUN_MARIADB_TableSchemaType,
BunMariaDBConfig, BunMariaDBConfig,
} from "../../types"; } from "../../types";
import buildColumnDefinition from "./build-column-definition"; import buildColumnDefinition, {
fieldRequiresNotNull,
} from "./build-column-definition";
import createTable from "./create-table"; import createTable from "./create-table";
import getTableColumns from "./get-table-columns"; import getTableColumns, { type ColumnInfoRow } from "./get-table-columns";
import getTableColumnsGemini from "./get-table-columns-gemnini";
import isVectorField from "./is-vector-field"; import isVectorField from "./is-vector-field";
import mapDataType from "./map-data-types"; import mapDataType from "./map-data-types";
import MariaDBQuoteGen from "./mariadb-quote-gen"; import MariaDBQuoteGen from "./mariadb-quote-gen";
import recreateTable from "./recreate-table";
import runSchemaQuery, { querySchemaRows } from "./run-schema-query"; import runSchemaQuery, { querySchemaRows } from "./run-schema-query";
import schemaCondition from "./schema-condition"; import schemaCondition from "./schema-condition";
import { dropForeignKeysOnColumns } from "./sync-foreign-keys";
/** /**
* Compare live COLUMN_TYPE with schema-mapped type. * Compare live COLUMN_TYPE with schema-mapped type.
@@ -54,6 +57,99 @@ function vectorTypeDiverged(
return true; return true;
} }
function normalizeDefault(value: string | null | undefined): string {
if (value == null) return "";
let v = String(value).trim();
// Strip surrounding quotes MariaDB may include
if (
(v.startsWith("'") && v.endsWith("'")) ||
(v.startsWith('"') && v.endsWith('"'))
) {
v = v.slice(1, -1);
}
return v.toLowerCase().replace(/\s+/g, " ");
}
function expectedDefault(field: BUN_MARIADB_FieldSchemaType): string | null {
if (field.defaultValue !== undefined) {
return String(field.defaultValue);
}
if (field.defaultValueLiteral) {
return field.defaultValueLiteral;
}
return null;
}
function expectedOnUpdate(field: BUN_MARIADB_FieldSchemaType): string | null {
if (field.onUpdate) return field.onUpdate;
if (field.onUpdateLiteral) return field.onUpdateLiteral;
return null;
}
function defaultsMatch(liveDefault: string, expected: string): boolean {
if (liveDefault === expected) return true;
if (
expected.includes("current_timestamp") &&
liveDefault.includes("current_timestamp")
) {
return true;
}
return false;
}
function columnAttributesDiverged(
live: ColumnInfoRow,
field: BUN_MARIADB_FieldSchemaType,
): boolean {
const wantsNotNull = fieldRequiresNotNull(field);
if (wantsNotNull && live.isNullable) {
return true;
}
if (!wantsNotNull && !live.isNullable && !field.primaryKey) {
return true;
}
const liveExtra = (live.extra || "").toLowerCase();
const wantsAi = Boolean(field.autoIncrement);
const hasAi = liveExtra.includes("auto_increment");
if (wantsAi !== hasAi) {
return true;
}
const wantsDefault = expectedDefault(field);
if (wantsDefault != null) {
const liveDefault = normalizeDefault(live.columnDefault);
const expected = normalizeDefault(wantsDefault);
if (!defaultsMatch(liveDefault, expected)) {
return true;
}
}
const wantsOnUpdate = expectedOnUpdate(field);
const hasOnUpdate = /on update/i.test(live.extra || "");
if (wantsOnUpdate && !hasOnUpdate) {
return true;
}
if (!wantsOnUpdate && hasOnUpdate) {
return true;
}
if (wantsOnUpdate && hasOnUpdate) {
const liveOnUpdate = normalizeDefault(
(live.extra.match(/on update\s+(.+)/i) || [])[1] || "",
);
const expectedOu = normalizeDefault(wantsOnUpdate);
if (
liveOnUpdate &&
expectedOu &&
!defaultsMatch(liveOnUpdate, expectedOu)
) {
return true;
}
}
return false;
}
async function addColumn({ async function addColumn({
tableName, tableName,
field, field,
@@ -64,7 +160,8 @@ async function addColumn({
config?: BunMariaDBConfig; config?: BunMariaDBConfig;
}): Promise<void> { }): Promise<void> {
console.log(`Adding column: ${tableName}.${field.fieldName}`); console.log(`Adding column: ${tableName}.${field.fieldName}`);
const columnDef = buildColumnDefinition(field).trim(); // omitUnique — unique constraints are applied by syncUniqueConstraints
const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim();
await runSchemaQuery({ await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} ADD COLUMN IF NOT EXISTS ${columnDef}`, query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} ADD COLUMN IF NOT EXISTS ${columnDef}`,
config, config,
@@ -81,7 +178,7 @@ async function modifyColumn({
config?: BunMariaDBConfig; config?: BunMariaDBConfig;
}): Promise<void> { }): Promise<void> {
console.log(`Modifying column: ${tableName}.${field.fieldName}`); console.log(`Modifying column: ${tableName}.${field.fieldName}`);
const columnDef = buildColumnDefinition(field).trim(); const columnDef = buildColumnDefinition(field, { omitUnique: true }).trim();
await runSchemaQuery({ await runSchemaQuery({
query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} MODIFY COLUMN ${columnDef}`, query: `ALTER TABLE ${MariaDBQuoteGen(tableName)} MODIFY COLUMN ${columnDef}`,
config, config,
@@ -104,6 +201,76 @@ async function dropColumn({
}); });
} }
/**
* Drop + re-add a column (values discarded). Used when VECTOR dimensions change —
* MODIFY cannot resize VECTOR, and a full table rebuild is unnecessary.
*/
async function recreateColumn({
tableName,
field,
config,
}: {
tableName: string;
field: BUN_MARIADB_FieldSchemaType;
config?: BunMariaDBConfig;
}): Promise<void> {
if (!field.fieldName) return;
console.log(
`Recreating column: ${tableName}.${field.fieldName} (values will be cleared)`,
);
await dropForeignKeysOnColumns({
tableName,
columns: [field.fieldName],
config,
});
const schemaCond = schemaCondition(config);
const indexRows = await querySchemaRows<{
INDEX_NAME: string;
COLUMN_NAME: string;
}>({
query: `SELECT INDEX_NAME, COLUMN_NAME FROM information_schema.STATISTICS WHERE ${schemaCond.where} AND TABLE_NAME = ? AND INDEX_NAME <> 'PRIMARY'`,
values: [...schemaCond.values, tableName],
config,
});
const indexesToDrop = new Set<string>();
for (const row of indexRows) {
if (row.COLUMN_NAME === field.fieldName) {
indexesToDrop.add(row.INDEX_NAME);
}
}
for (const indexName of indexesToDrop) {
console.log(
`Dropping index ${indexName} because column ${field.fieldName} is being recreated`,
);
try {
await runSchemaQuery({
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(tableName)}`,
config,
});
} catch (err: any) {
if (
String(err?.message || "").includes(
"needed in a foreign key constraint",
)
) {
console.warn(
`Skipping drop of index ${indexName}: required by a foreign key constraint`,
);
continue;
}
throw err;
}
}
await dropColumn({ tableName, fieldName: field.fieldName, config });
await addColumn({ tableName, field, config });
}
export default async function updateTable({ export default async function updateTable({
table, table,
config, config,
@@ -111,7 +278,7 @@ export default async function updateTable({
table: BUN_MARIADB_TableSchemaType; table: BUN_MARIADB_TableSchemaType;
config?: BunMariaDBConfig; config?: BunMariaDBConfig;
}): Promise<void> { }): Promise<void> {
const existingColumns = await getTableColumns({ const existingColumns = await getTableColumnsGemini({
tableName: table.tableName, tableName: table.tableName,
config, config,
}); });
@@ -122,10 +289,7 @@ export default async function updateTable({
} }
const liveFieldsMap = new Map( const liveFieldsMap = new Map(
existingColumns.map((col) => [ existingColumns.map((col) => [col.name, col]),
col.name,
{ type: col.type.toLowerCase(), comment: col.comment || "" },
]),
); );
const codeFieldsMap = new Map( const codeFieldsMap = new Map(
(table.fields || []).map((f) => [f.fieldName, f]), (table.fields || []).map((f) => [f.fieldName, f]),
@@ -133,8 +297,8 @@ export default async function updateTable({
const fieldsToAdd: BUN_MARIADB_FieldSchemaType[] = []; const fieldsToAdd: BUN_MARIADB_FieldSchemaType[] = [];
const fieldsToModify: BUN_MARIADB_FieldSchemaType[] = []; const fieldsToModify: BUN_MARIADB_FieldSchemaType[] = [];
const fieldsToRecreate: BUN_MARIADB_FieldSchemaType[] = [];
const fieldsToDrop: string[] = []; const fieldsToDrop: string[] = [];
let needsVectorRecreate = false;
for (const field of table.fields || []) { for (const field of table.fields || []) {
if (!field.fieldName) continue; if (!field.fieldName) continue;
@@ -142,41 +306,37 @@ export default async function updateTable({
const liveField = liveFieldsMap.get(field.fieldName); const liveField = liveFieldsMap.get(field.fieldName);
if (!liveField) { if (!liveField) {
// Adding a new vector column can require rebuild if VECTOR INDEX
// constraints conflict; still try surgical add first.
fieldsToAdd.push(field); fieldsToAdd.push(field);
} else { } else {
let mapped_data_type = mapDataType(field);
let typeDiverged = !columnTypesMatch( let typeDiverged = !columnTypesMatch(
liveField.type, liveField.type,
mapDataType(field), mapped_data_type,
); );
if (isVectorField(field)) { if (isVectorField(field)) {
typeDiverged = vectorTypeDiverged( typeDiverged = vectorTypeDiverged(
liveField.type, liveField.type,
liveField.comment, liveField.comment || "",
field, field,
); );
if (typeDiverged) { if (typeDiverged) {
needsVectorRecreate = true; // VECTOR dimensions / storage cannot be MODIFYed — drop + re-add column
fieldsToRecreate.push(field);
continue;
} }
} }
if (typeDiverged) { const attrsDiverged =
!typeDiverged && columnAttributesDiverged(liveField, field);
if (typeDiverged || attrsDiverged) {
fieldsToModify.push(field); fieldsToModify.push(field);
} }
} }
} }
// Vector dimension / storage type changes → full rebuild automatically
if (needsVectorRecreate) {
console.log(
`Vector column change detected on \`${table.tableName}\`; recreating table`,
);
await recreateTable({ table, config });
return;
}
for (const col of existingColumns) { for (const col of existingColumns) {
if (!codeFieldsMap.has(col.name)) { if (!codeFieldsMap.has(col.name)) {
fieldsToDrop.push(col.name); fieldsToDrop.push(col.name);
@@ -186,6 +346,7 @@ export default async function updateTable({
if ( if (
fieldsToAdd.length === 0 && fieldsToAdd.length === 0 &&
fieldsToModify.length === 0 && fieldsToModify.length === 0 &&
fieldsToRecreate.length === 0 &&
fieldsToDrop.length === 0 fieldsToDrop.length === 0
) { ) {
return; return;
@@ -196,6 +357,13 @@ export default async function updateTable({
); );
if (fieldsToDrop.length > 0) { if (fieldsToDrop.length > 0) {
// Drop FKs that reference columns being removed
await dropForeignKeysOnColumns({
tableName: table.tableName,
columns: fieldsToDrop,
config,
});
const schemaCond = schemaCondition(config); const schemaCond = schemaCondition(config);
const pkRows = await querySchemaRows<{ COLUMN_NAME: string }>({ const pkRows = await querySchemaRows<{ COLUMN_NAME: string }>({
@@ -235,30 +403,52 @@ export default async function updateTable({
console.log( console.log(
`Dropping index ${indexName} because it contains a dropped column`, `Dropping index ${indexName} because it contains a dropped column`,
); );
await runSchemaQuery({ try {
query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`, await runSchemaQuery({
config, query: `DROP INDEX ${MariaDBQuoteGen(indexName)} ON ${MariaDBQuoteGen(table.tableName)}`,
}); config,
});
} catch (err: any) {
if (
String(err?.message || "").includes(
"needed in a foreign key constraint",
)
) {
console.warn(
`Skipping drop of index ${indexName}: required by a foreign key constraint`,
);
continue;
}
throw err;
}
} }
} }
// Drop FKs on columns being modified (MODIFY can fail with FK present)
if (fieldsToModify.length > 0) {
await dropForeignKeysOnColumns({
tableName: table.tableName,
columns: fieldsToModify
.map((f) => f.fieldName)
.filter((n): n is string => Boolean(n)),
config,
});
}
for (const field of fieldsToAdd) { for (const field of fieldsToAdd) {
await addColumn({ tableName: table.tableName, field, config }); await addColumn({ tableName: table.tableName, field, config });
} }
for (const field of fieldsToModify) { for (const field of fieldsToModify) {
try { await modifyColumn({ tableName: table.tableName, field, config });
await modifyColumn({ tableName: table.tableName, field, config }); }
} catch (err: any) {
if (isVectorField(field)) { for (const field of fieldsToRecreate) {
console.warn( await recreateColumn({
`[Vector Resize] Re-aligning dimension spaces natively via safe migration schema rebuild.`, tableName: table.tableName,
); field,
await recreateTable({ table, config }); config,
return; });
}
throw err;
}
} }
for (const fieldName of fieldsToDrop) { for (const fieldName of fieldsToDrop) {
+6 -6
View File
@@ -96,10 +96,6 @@ export interface BUN_MARIADB_TableSchemaType {
*/ */
childTableDbId?: string | number; childTableDbId?: string | number;
collation?: (typeof MariaDBCollations)[number]; collation?: (typeof MariaDBCollations)[number];
/**
* If this is a vector-oriented table (native MariaDB VECTOR columns/indexes)
*/
isVector?: boolean;
} }
/** /**
@@ -202,7 +198,10 @@ export type BUN_MARIADB_FieldSchemaType = {
onDelete?: string; onDelete?: string;
onDeleteLiteral?: string; onDeleteLiteral?: string;
cssFiles?: string[]; cssFiles?: string[];
integerLength?: string | number; /**
* Datatype length. Eg 255 for VARCHAR
*/
dataLength?: string | number;
decimals?: string | number; decimals?: string | number;
code?: boolean; code?: boolean;
options?: (string | number)[]; options?: (string | number)[];
@@ -916,7 +915,7 @@ export type ServerQueryParamsJoin<
tableName: Table; tableName: Table;
match?: match?:
| ServerQueryParamsJoinMatchObject<Field> | ServerQueryParamsJoinMatchObject<Field>
| ServerQueryParamsJoinMatchObject<Field>[]; | (ServerQueryParamsJoinMatchObject<Field> | undefined)[];
selectFields?: (keyof Field | SelectFieldObject<Field>)[]; selectFields?: (keyof Field | SelectFieldObject<Field>)[];
omitFields?: ( omitFields?: (
| keyof Field | keyof Field
@@ -1619,6 +1618,7 @@ export type DBResponseObject<
msg?: string; msg?: string;
debug?: any; debug?: any;
count?: number; count?: number;
db_res?: any;
}; };
export type DBInsertReturn = { export type DBInsertReturn = {
+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),
};
}
+2 -1
View File
@@ -1,4 +1,4 @@
import { isUndefined } from "lodash"; import _, { isUndefined } from "lodash";
import type { ServerQueryParam, TableSelectFieldsObject } from "../types"; import type { ServerQueryParam, TableSelectFieldsObject } from "../types";
import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str"; import sqlGenGrabConcatStr from "./sql-generator-grab-concat-str";
import sqlGenGenJoinStr from "./sql-generator-gen-join-str"; import sqlGenGenJoinStr from "./sql-generator-gen-join-str";
@@ -195,6 +195,7 @@ export default function sqlGenGenQueryStr<
return ( return (
"(" + "(" +
join.match join.match
.filter((mtch) => !_.isUndefined(mtch))
.map((mtch) => { .map((mtch) => {
const { str, values } = const { str, values } =
sqlGenGenJoinStr({ sqlGenGenJoinStr({
+1 -1
View File
@@ -50,7 +50,7 @@ export default function sqlInsertGenerator({
? value ? value
: null; : null;
if (!finalValue) { if (!finalValue && typeof value !== "number") {
queryValues.push(null); queryValues.push(null);
return "?"; return "?";
} }