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