/** * 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 "); 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);