Files
Termix/scripts/verify-dialects.mjs
T

189 lines
5.9 KiB
JavaScript

/**
* Runs the repository layer against a real Postgres or MySQL server.
*
* The unit tests only ever see SQLite, so the parts of this codebase that
* differ per engine — the RETURNING replacements, the read-then-write
* transactions, the value encoders — have no coverage there at all. This is
* what covers them, and it needs a live server, which is why it is a script
* rather than a test.
*
* Usage:
* npm run verify:dialect -- postgres://user:pass@host:5432/db
* npm run verify:dialect -- mysql://user:pass@host:3306/db
*
* Applies the migrations first, through the same runRemoteMigrations() the
* application uses at startup — so a broken migration fails here rather than in
* production. Writes real rows: point it at a scratch database.
*/
import { randomUUID } from "crypto";
const url = process.argv[2];
if (!url) {
console.error("usage: node scripts/verify-dialects.mjs <DATABASE_URL>");
process.exit(2);
}
const scheme = url.split("://", 1)[0].toLowerCase();
const dialect = scheme.startsWith("postgres")
? "postgres"
: scheme === "mysql" || scheme === "mariadb"
? "mysql"
: null;
if (!dialect) {
console.error(`unsupported URL scheme "${scheme}://"`);
process.exit(2);
}
const { drizzle } = await import(
dialect === "postgres" ? "drizzle-orm/node-postgres" : "drizzle-orm/mysql2"
);
// No schema option on purpose — see connect.ts.
const db = drizzle(url);
const context = { dialect, drizzle: db };
const { runRemoteMigrations } =
await import("../src/backend/database/db/migrate.js");
await runRemoteMigrations(dialect, db);
const { UserRepository } =
await import("../src/backend/database/repositories/user-repository.js");
const { HostRepository } =
await import("../src/backend/database/repositories/host-repository.js");
const { SettingsRepository } =
await import("../src/backend/database/repositories/settings-repository.js");
let failures = 0;
const check = (label, got, want) => {
const ok = JSON.stringify(got) === JSON.stringify(want);
if (!ok) failures++;
console.log(
` ${ok ? "ok " : "FAIL"} ${label}` +
(ok
? ""
: `\n got ${JSON.stringify(got)}, want ${JSON.stringify(want)}`),
);
};
console.log(`\nverifying ${dialect} at ${url.replace(/:[^:@]*@/, ":***@")}\n`);
const users = new UserRepository(context);
const userId = `verify-${randomUUID()}`;
// insertReturning: on MySQL this is an insert plus a read inside a transaction.
const created = await users.create({
id: userId,
username: "before",
passwordHash: "x",
isAdmin: true,
});
check("insert returns the stored row", created?.username, "before");
// The one non-identity value encoder in the schema. Booleans are integers in
// the sqlite definitions the repositories import, so this asserts that 1/0
// survives a round trip through a native boolean column.
check("boolean true survives the round trip", created?.isAdmin, true);
// updateReturning must report the state AFTER the write. Reading first would
// return the value the update replaced — silently, with no error.
const updated = await users.update(userId, { username: "after" });
check("update returns the new value", updated?.username, "after");
const hosts = new HostRepository(context);
const host = await hosts.create({
userId,
name: "verify",
ip: "127.0.0.1",
port: 22,
username: "root",
authType: "password",
enableTerminal: true,
});
check(
"autoincrement id came back",
typeof host?.id === "number" && host.id > 0,
true,
);
check(
"database-assigned createdAt came back",
typeof host?.createdAt === "string" && host.createdAt.length > 0,
true,
);
// deleteReturning must report the state BEFORE the write. Reading afterwards
// would find nothing at all.
const settings = new SettingsRepository(context);
const prefix = `verify-${randomUUID()}`;
await settings.set(`${prefix}-a`, "1");
await settings.set(`${prefix}-b`, "2");
check(
"delete reports the rows it removed",
await settings.deleteLike(`${prefix}-%`),
2,
);
check(
"and they are actually gone",
(await settings.listAll()).filter((row) => row.key.startsWith(prefix)).length,
0,
);
await hosts.deleteForUser(userId, host.id);
check("host really deleted", await hosts.findById(host.id), null);
// A plugin's own tables. The DDL is emitted from a table definition rather
// than written by hand, so this is what proves the emitter's output is valid
// on a real engine - the unit tests can only snapshot the string.
const { defineTable, id, varchar, text, refUser, timestamp } =
await import("../packages/plugin-sdk/dist/db.js");
const { createTableSql, dropTableSql } =
await import("../packages/plugin-sdk/dist/ddl.js");
const { sql: raw } = await import("drizzle-orm");
const pluginTable = defineTable(
"verify_note",
{
id: id(),
userId: refUser(),
title: varchar(200).notNull(),
body: text(),
createdAt: timestamp().notNull().defaultNow(),
},
{ indexes: [{ name: "idx_verify_note_user", columns: ["userId"] }] },
);
const pluginId = "verify";
try {
for (const statement of createTableSql(dialect, pluginId, pluginTable)) {
await db.execute(raw.raw(statement));
}
check("plugin table DDL is accepted", true, true);
await db.execute(
raw.raw(
`INSERT INTO p_verify_verify_note (user_id, title) VALUES ('${userId}', 'from verify')`,
),
);
const rows = await db.execute(
raw.raw("SELECT title FROM p_verify_verify_note"),
);
const list = Array.isArray(rows) ? rows : (rows?.rows ?? []);
check("plugin table round-trips a row", list.length > 0, true);
} catch (error) {
check(`plugin table DDL is accepted (${error.message})`, false, true);
} finally {
try {
await db.execute(raw.raw(dropTableSql(dialect, pluginId, pluginTable)));
} catch {
// Best effort: the scratch database is the caller's to clean.
}
}
console.log(
failures === 0
? `\n${dialect}: all checks passed\n`
: `\n${dialect}: ${failures} FAILED\n`,
);
process.exit(failures === 0 ? 0 : 1);