mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-13 07:09:20 +00:00
249 lines
7.8 KiB
JavaScript
249 lines
7.8 KiB
JavaScript
/**
|
|
* Generates a plugin's migrations from its table definitions.
|
|
*
|
|
* Not drizzle-kit. Core's own SQLite schema is hand-written DDL and only the
|
|
* client-server engines use drizzle migrations, so a plugin on drizzle-kit
|
|
* would still need a hand-rolled SQLite path, three configs and three
|
|
* generated schema modules. This diffs the definitions against a committed
|
|
* snapshot and writes plain SQL for all three dialects, which is what the
|
|
* server's runner applies.
|
|
*
|
|
* Usage, from a plugin directory:
|
|
* termix-plugin migrations [name]
|
|
* termix-plugin migrations --check verifies nothing is undeclared
|
|
*
|
|
* The plugin exports its definitions from src/backend/tables.ts as `tables`.
|
|
*/
|
|
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import * as esbuild from "esbuild";
|
|
import { BACKEND_EXTERNALS } from "../lib/externals.mjs";
|
|
import { readManifest, resolveEntry } from "../lib/plugin-dir.mjs";
|
|
|
|
const DIALECTS = ["sqlite", "postgres", "mysql"];
|
|
|
|
const TABLE_ENTRIES = [
|
|
"src/backend/tables.ts",
|
|
"src/backend/tables.mjs",
|
|
"src/backend/tables.js",
|
|
];
|
|
|
|
/** Loads the plugin's table definitions by bundling them to a temp file. */
|
|
async function loadDefinitions(cwd) {
|
|
const entry = resolveEntry(cwd, TABLE_ENTRIES);
|
|
if (!entry) return null;
|
|
|
|
// Emitted inside the plugin rather than in the system temp dir: the SDK
|
|
// stays external to the bundle, so the output has to sit somewhere node can
|
|
// resolve @termix/plugin-sdk from.
|
|
const outdir = fs.mkdtempSync(path.join(cwd, ".termix-tables-"));
|
|
const outfile = path.join(outdir, "tables.mjs");
|
|
|
|
try {
|
|
await esbuild.build({
|
|
entryPoints: [entry],
|
|
outfile,
|
|
bundle: true,
|
|
format: "esm",
|
|
platform: "node",
|
|
target: "node22",
|
|
logLevel: "warning",
|
|
external: BACKEND_EXTERNALS,
|
|
});
|
|
|
|
const module = await import(`file://${outfile.split(path.sep).join("/")}`);
|
|
const tables = module.tables ?? module.default;
|
|
if (!Array.isArray(tables)) {
|
|
throw new Error(
|
|
`${path.relative(cwd, entry)} must export a "tables" array of defineTable() results`,
|
|
);
|
|
}
|
|
return tables;
|
|
} finally {
|
|
fs.rmSync(outdir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
function snapshotPath(cwd) {
|
|
return path.join(cwd, "migrations", "snapshot.json");
|
|
}
|
|
|
|
function readSnapshot(cwd) {
|
|
const file = snapshotPath(cwd);
|
|
if (!fs.existsSync(file)) return { version: 1, tables: {} };
|
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
}
|
|
|
|
/** What a definition looks like in the snapshot, so a diff can compare them. */
|
|
function snapshotTable(definition) {
|
|
return {
|
|
columns: definition.columns,
|
|
indexes: definition.indexes,
|
|
...(definition.adopts ? { adopts: definition.adopts } : {}),
|
|
};
|
|
}
|
|
|
|
function nextSequence(cwd) {
|
|
let highest = 0;
|
|
for (const dialect of DIALECTS) {
|
|
const dir = path.join(cwd, "migrations", dialect);
|
|
if (!fs.existsSync(dir)) continue;
|
|
for (const file of fs.readdirSync(dir)) {
|
|
const match = /^(\d{4})_/.exec(file);
|
|
if (match) highest = Math.max(highest, Number(match[1]));
|
|
}
|
|
}
|
|
return highest + 1;
|
|
}
|
|
|
|
/**
|
|
* What changed since the snapshot.
|
|
*
|
|
* New tables and new columns are generated. A changed or removed column is
|
|
* reported rather than guessed at: renames and type changes need a data
|
|
* decision this cannot make, so the author writes that migration by hand.
|
|
*/
|
|
function diff(previous, definitions) {
|
|
const created = [];
|
|
const added = [];
|
|
const manual = [];
|
|
|
|
for (const definition of definitions) {
|
|
const before = previous.tables[definition.name];
|
|
if (!before) {
|
|
created.push(definition);
|
|
continue;
|
|
}
|
|
|
|
for (const [property, column] of Object.entries(definition.columns)) {
|
|
const existing = before.columns[property];
|
|
if (!existing) {
|
|
added.push({ definition, property, column });
|
|
continue;
|
|
}
|
|
if (JSON.stringify(existing) !== JSON.stringify(column)) {
|
|
manual.push(`${definition.name}.${property} changed type or modifiers`);
|
|
}
|
|
}
|
|
|
|
for (const property of Object.keys(before.columns)) {
|
|
if (!definition.columns[property]) {
|
|
manual.push(`${definition.name}.${property} was removed`);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const name of Object.keys(previous.tables)) {
|
|
if (!definitions.some((definition) => definition.name === name)) {
|
|
manual.push(`table ${name} was removed`);
|
|
}
|
|
}
|
|
|
|
return { created, added, manual };
|
|
}
|
|
|
|
export async function migrations({ cwd, args }) {
|
|
const manifest = readManifest(cwd);
|
|
const pluginId = manifest.id ?? path.basename(cwd);
|
|
const check = args.includes("--check");
|
|
const name = args.find((arg) => !arg.startsWith("--")) ?? "update";
|
|
|
|
const definitions = await loadDefinitions(cwd);
|
|
if (definitions === null) {
|
|
console.log(`${pluginId}: no table definitions, nothing to generate`);
|
|
return;
|
|
}
|
|
|
|
const { LEGACY_TABLE_OWNERS } = await import(
|
|
new URL("../../dist/db.js", import.meta.url).href
|
|
);
|
|
for (const definition of definitions) {
|
|
if (
|
|
definition.adopts &&
|
|
LEGACY_TABLE_OWNERS[definition.adopts] !== pluginId
|
|
) {
|
|
throw new Error(
|
|
`${pluginId}: table ${definition.name} adopts "${definition.adopts}", which belongs to the "${LEGACY_TABLE_OWNERS[definition.adopts]}" plugin`,
|
|
);
|
|
}
|
|
}
|
|
|
|
const { createTableSql, adoptTableSql, addColumnSql } = await loadEmitter();
|
|
|
|
const previous = readSnapshot(cwd);
|
|
const { created, added, manual } = diff(previous, definitions);
|
|
|
|
if (manual.length > 0) {
|
|
for (const problem of manual) console.error(` ${problem}`);
|
|
throw new Error(
|
|
`${pluginId}: these changes need a hand-written migration, because a rename or a type change is a data decision.`,
|
|
);
|
|
}
|
|
|
|
if (created.length === 0 && added.length === 0) {
|
|
console.log(`${pluginId}: migrations are up to date`);
|
|
return;
|
|
}
|
|
|
|
if (check) {
|
|
throw new Error(
|
|
`${pluginId}: table definitions have changed but no migration was generated. Run: termix-plugin migrations`,
|
|
);
|
|
}
|
|
|
|
const sequence = String(nextSequence(cwd)).padStart(4, "0");
|
|
const file = `${sequence}_${name.replace(/[^a-z0-9_]/gi, "_").toLowerCase()}.sql`;
|
|
|
|
for (const dialect of DIALECTS) {
|
|
const statements = [];
|
|
for (const definition of created) {
|
|
// An adopted table renames the legacy one into place, creating it first
|
|
// on a fresh install, so existing rows are kept rather than copied.
|
|
statements.push(
|
|
...(definition.adopts
|
|
? adoptTableSql(dialect, pluginId, definition)
|
|
: createTableSql(dialect, pluginId, definition)),
|
|
);
|
|
}
|
|
for (const entry of added) {
|
|
statements.push(
|
|
addColumnSql(dialect, pluginId, entry.definition, entry.property),
|
|
);
|
|
}
|
|
|
|
const dir = path.join(cwd, "migrations", dialect);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(dir, file),
|
|
`-- ${pluginId} ${sequence}: ${name}\n-- Generated by termix-plugin migrations. Review before committing.\n\n${statements.join("\n")}\n`,
|
|
);
|
|
}
|
|
|
|
const snapshot = { version: 1, tables: {} };
|
|
for (const definition of definitions) {
|
|
snapshot.tables[definition.name] = snapshotTable(definition);
|
|
}
|
|
fs.writeFileSync(snapshotPath(cwd), `${JSON.stringify(snapshot, null, 2)}\n`);
|
|
|
|
console.log(
|
|
`${pluginId}: wrote ${DIALECTS.length} dialect files for ${sequence}_${name}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The DDL emitter, shared with the server.
|
|
*
|
|
* One mapping, two consumers: the SQL written here is the SQL the server's
|
|
* migration runner applies, so a second copy could drift into producing a
|
|
* migration the runner cannot reproduce.
|
|
*/
|
|
async function loadEmitter() {
|
|
const entry = new URL("../../dist/ddl.js", import.meta.url);
|
|
if (!fs.existsSync(fileURLToPath(entry))) {
|
|
throw new Error("The plugin SDK is not built yet. Run: npm run build:sdk");
|
|
}
|
|
return import(entry.href);
|
|
}
|