Add local MariaDB copy and Drizzle connection for the legacy cleo database
- docker-compose: mariadb:10.4 service on port 3307 (utf8mb4), seeded from a mysqldump of the office server so development never writes to production - drizzle-amigo.config.ts + `pnpm db:amigo:pull` (introspection only) - scripts/fix-amigo-schema.ts repairs drizzle-kit's MariaDB output: quoted defaults, dropped primary keys, missing column-builder imports - db/amigo: pulled schema and `amigoDb` connection - README: step-by-step local MariaDB setup (dump, import, cleo_next grants) - CLAUDE.md: MyISAM, concurrent PHP writes, zero dates, other apps' tables Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>main
parent
a44fb24136
commit
21bce758cb
@ -0,0 +1,7 @@
|
||||
import { drizzle } from "drizzle-orm/mysql2";
|
||||
import * as schema from "./schema";
|
||||
|
||||
export const amigoDb = drizzle(process.env.AMIGO_DATABASE_URL!, {
|
||||
schema,
|
||||
mode: "default",
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,16 @@
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "drizzle-kit";
|
||||
|
||||
// Introspection only: never run generate/migrate/push with this config.
|
||||
// The PHP app still owns the schema; cleo_next has no DDL rights anyway.
|
||||
export default defineConfig({
|
||||
dialect: "mysql",
|
||||
schema: "./db/amigo/schema.ts",
|
||||
out: "./db/amigo",
|
||||
dbCredentials: {
|
||||
url: process.env.AMIGO_DATABASE_URL!,
|
||||
},
|
||||
introspect: {
|
||||
casing: "preserve",
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,133 @@
|
||||
import "dotenv/config";
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import mysql, { type RowDataPacket } from "mysql2/promise";
|
||||
|
||||
// drizzle-kit 0.31 introspects MariaDB as if it were MySQL, which breaks three things:
|
||||
// - MariaDB returns COLUMN_DEFAULT quoted ('abc', and 'NULL' for no default), so defaults come
|
||||
// out as .default('NULL') or broken literals like '''Z'''.
|
||||
// - drizzle-kit reads the primary key rows as row["TABLE_NAME"], but MariaDB returns lowercase
|
||||
// keys, so every primary key is dropped.
|
||||
// - The import line misses some column builders (bigint, tinyint).
|
||||
// This script rewrites db/amigo/schema.ts from information_schema after each pull.
|
||||
const SCHEMA_PATH = "db/amigo/schema.ts";
|
||||
|
||||
// `.default(...)` at the end of a column line, optionally followed by `.notNull()`/`.primaryKey()`.
|
||||
const DEFAULT_CALL =
|
||||
/\.default\(.*?\)(?=(?:\.notNull\(\))?(?:\.primaryKey\(\))?,$)/;
|
||||
|
||||
type ColumnRow = RowDataPacket & {
|
||||
tableName: string;
|
||||
columnName: string;
|
||||
columnDefault: string | null;
|
||||
isPrimary: number;
|
||||
};
|
||||
|
||||
function fixDefault(line: string, rawDefault: string | null): string {
|
||||
if (rawDefault === null || rawDefault === "NULL") {
|
||||
return line.replace(DEFAULT_CALL, "");
|
||||
}
|
||||
if (rawDefault.startsWith("'") && rawDefault.endsWith("'")) {
|
||||
const value = rawDefault.slice(1, -1).replaceAll("''", "'");
|
||||
return line.replace(DEFAULT_CALL, `.default(${JSON.stringify(value)})`);
|
||||
}
|
||||
// Numbers and expressions like current_timestamp() are already generated correctly.
|
||||
return line;
|
||||
}
|
||||
|
||||
function addPrimaryKey(line: string): string {
|
||||
if (line.includes(".primaryKey()")) return line;
|
||||
return line.replace(/,$/, ".primaryKey(),");
|
||||
}
|
||||
|
||||
// Rebuilds the import lines from the builders the schema actually calls.
|
||||
function rebuildImports(schema: string): string {
|
||||
const used = new Set(["mysqlTable"]);
|
||||
for (const match of schema.matchAll(/^\t\w+: (\w+)\(/gm)) used.add(match[1]);
|
||||
for (const match of schema.matchAll(/^\t(index|uniqueIndex|primaryKey)\(/gm))
|
||||
used.add(match[1]);
|
||||
|
||||
const body = schema.replace(/^import .*\n/gm, "");
|
||||
const imports = [
|
||||
`import { ${[...used].join(", ")} } from "drizzle-orm/mysql-core";`,
|
||||
];
|
||||
if (body.includes("sql`")) imports.push('import { sql } from "drizzle-orm";');
|
||||
return `${imports.join("\n")}\n${body}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const url = process.env.AMIGO_DATABASE_URL;
|
||||
if (!url) throw new Error("AMIGO_DATABASE_URL is not set in .env");
|
||||
|
||||
const connection = await mysql.createConnection(url);
|
||||
const [rows] = await connection.query<ColumnRow[]>(
|
||||
`SELECT c.table_name AS tableName,
|
||||
c.column_name AS columnName,
|
||||
c.column_default AS columnDefault,
|
||||
c.column_key = 'PRI' AS isPrimary
|
||||
FROM information_schema.columns c
|
||||
WHERE c.table_schema = DATABASE()
|
||||
ORDER BY c.table_name, c.ordinal_position`,
|
||||
);
|
||||
await connection.end();
|
||||
|
||||
const tables = new Map<string, ColumnRow[]>();
|
||||
for (const row of rows) {
|
||||
const columns = tables.get(row.tableName) ?? [];
|
||||
columns.push(row);
|
||||
tables.set(row.tableName, columns);
|
||||
}
|
||||
|
||||
let schema = await readFile(SCHEMA_PATH, "utf8");
|
||||
|
||||
for (const [tableName, columns] of tables) {
|
||||
const start = schema.indexOf(`mysqlTable("${tableName}", {`);
|
||||
if (start === -1)
|
||||
throw new Error(`Table ${tableName} not found in ${SCHEMA_PATH}`);
|
||||
const next = schema.indexOf("\nexport const ", start);
|
||||
const end = next === -1 ? schema.length : next;
|
||||
let block = schema.slice(start, end);
|
||||
|
||||
const primaryColumns = columns
|
||||
.filter((c) => c.isPrimary)
|
||||
.map((c) => c.columnName);
|
||||
|
||||
for (const column of columns) {
|
||||
const linePattern = new RegExp(`^\\t${column.columnName}: .*,$`, "m");
|
||||
const line = block.match(linePattern)?.[0];
|
||||
if (!line)
|
||||
throw new Error(`Column ${tableName}.${column.columnName} not found`);
|
||||
|
||||
let fixed = fixDefault(line, column.columnDefault);
|
||||
if (primaryColumns.length === 1 && column.isPrimary)
|
||||
fixed = addPrimaryKey(fixed);
|
||||
block = block.replace(line, () => fixed);
|
||||
}
|
||||
|
||||
if (primaryColumns.length > 1 && !block.includes("primaryKey({")) {
|
||||
if (!block.includes("(table) => [\n")) {
|
||||
throw new Error(
|
||||
`Composite key on ${tableName} needs a (table) => [...] block`,
|
||||
);
|
||||
}
|
||||
const columnsList = primaryColumns
|
||||
.map((name) => `table.${name}`)
|
||||
.join(", ");
|
||||
block = block.replace(
|
||||
"(table) => [\n",
|
||||
`(table) => [\n\tprimaryKey({ columns: [${columnsList}] }),\n`,
|
||||
);
|
||||
}
|
||||
|
||||
schema = schema.slice(0, start) + block + schema.slice(end);
|
||||
}
|
||||
|
||||
await writeFile(SCHEMA_PATH, rebuildImports(schema));
|
||||
console.log(
|
||||
`Fixed defaults and primary keys for ${tables.size} tables in ${SCHEMA_PATH}`,
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
Loading…
Reference in New Issue