mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
387 lines
11 KiB
TypeScript
387 lines
11 KiB
TypeScript
/**
|
|
* Plugin-owned data against a real engine.
|
|
*
|
|
* The DDL snapshots in table-ddl.test.ts prove what the emitter writes; this
|
|
* proves an engine accepts it, that adoption moves real rows, and that
|
|
* removing a plugin's data removes exactly its own.
|
|
*/
|
|
|
|
import Database from "better-sqlite3";
|
|
import { drizzle } from "drizzle-orm/better-sqlite3";
|
|
import { sql } from "drizzle-orm";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
defineTable,
|
|
id,
|
|
refUser,
|
|
text,
|
|
timestamp,
|
|
varchar,
|
|
} from "@termix-ssh/plugin-sdk/db";
|
|
import { createTableSql, dropTableSql } from "../../plugins/table-builder.js";
|
|
import {
|
|
adoptLegacyTableSql,
|
|
applyPluginMigrations,
|
|
checksum,
|
|
PluginMigrationChecksumError,
|
|
tableExistsSql,
|
|
type MigrationRunner,
|
|
} from "../../plugins/migrations.js";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
|
|
vi.mock("../../utils/logger.js", () => ({
|
|
pluginLogger: {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
success: vi.fn(),
|
|
},
|
|
}));
|
|
|
|
let sqlite: Database.Database;
|
|
let db: ReturnType<typeof drizzle>;
|
|
let root: string;
|
|
|
|
const notes = defineTable(
|
|
"note",
|
|
{
|
|
id: id(),
|
|
userId: refUser(),
|
|
title: varchar(200).notNull(),
|
|
body: text(),
|
|
createdAt: timestamp().notNull().defaultNow(),
|
|
},
|
|
{ indexes: [{ name: "idx_note_user", columns: ["userId"] }] },
|
|
);
|
|
|
|
/** The ledger, kept in the same database so a rollback would take it too. */
|
|
function createRunner(): MigrationRunner {
|
|
return {
|
|
dialect: "sqlite",
|
|
listApplied: async (pluginId) =>
|
|
sqlite
|
|
.prepare(
|
|
"SELECT migration_id as migrationId, checksum FROM plugin_migrations WHERE plugin_id = ?",
|
|
)
|
|
.all(pluginId) as { migrationId: string; checksum: string }[],
|
|
execute: async (statements) => {
|
|
for (const statement of statements) sqlite.exec(statement);
|
|
},
|
|
record: async (pluginId, migration) => {
|
|
sqlite
|
|
.prepare(
|
|
"INSERT INTO plugin_migrations (plugin_id, migration_id, checksum, applied_at) VALUES (?, ?, ?, ?)",
|
|
)
|
|
.run(
|
|
pluginId,
|
|
migration.id,
|
|
migration.checksum,
|
|
new Date().toISOString(),
|
|
);
|
|
},
|
|
};
|
|
}
|
|
|
|
function writeMigration(dialect: string, file: string, body: string): void {
|
|
const dir = path.join(root, "migrations", dialect);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, file), body);
|
|
}
|
|
|
|
function tableExists(name: string): boolean {
|
|
return (
|
|
(
|
|
sqlite
|
|
.prepare(
|
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?",
|
|
)
|
|
.all(name) as unknown[]
|
|
).length > 0
|
|
);
|
|
}
|
|
|
|
beforeEach(() => {
|
|
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-plugin-data-"));
|
|
sqlite = new Database(":memory:");
|
|
sqlite.exec("PRAGMA foreign_keys = ON");
|
|
sqlite.exec(`
|
|
CREATE TABLE users (id TEXT PRIMARY KEY, username TEXT NOT NULL);
|
|
CREATE TABLE ssh_data (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT);
|
|
CREATE TABLE plugin_migrations (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
plugin_id TEXT NOT NULL,
|
|
migration_id TEXT NOT NULL,
|
|
checksum TEXT NOT NULL,
|
|
applied_at TEXT NOT NULL,
|
|
UNIQUE (plugin_id, migration_id)
|
|
);
|
|
`);
|
|
sqlite
|
|
.prepare("INSERT INTO users (id, username) VALUES (?, ?)")
|
|
.run("u1", "ada");
|
|
db = drizzle(sqlite);
|
|
});
|
|
|
|
afterEach(() => {
|
|
sqlite.close();
|
|
fs.rmSync(root, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("emitted DDL against a real engine", () => {
|
|
it("creates the table and its indexes", () => {
|
|
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
|
sqlite.exec(statement);
|
|
}
|
|
|
|
expect(tableExists("p_demo_note")).toBe(true);
|
|
});
|
|
|
|
it("enforces the foreign key to users", () => {
|
|
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
|
sqlite.exec(statement);
|
|
}
|
|
|
|
expect(() =>
|
|
sqlite
|
|
.prepare("INSERT INTO p_demo_note (user_id, title) VALUES (?, ?)")
|
|
.run("nobody", "orphan"),
|
|
).toThrow(/FOREIGN KEY/);
|
|
});
|
|
|
|
it("cascades a delete from the referenced user", () => {
|
|
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
|
sqlite.exec(statement);
|
|
}
|
|
sqlite
|
|
.prepare("INSERT INTO p_demo_note (user_id, title) VALUES (?, ?)")
|
|
.run("u1", "mine");
|
|
|
|
sqlite.prepare("DELETE FROM users WHERE id = ?").run("u1");
|
|
|
|
const rows = sqlite.prepare("SELECT * FROM p_demo_note").all();
|
|
expect(rows).toHaveLength(0);
|
|
});
|
|
|
|
it("fills the timestamp default the emitter wrote", () => {
|
|
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
|
sqlite.exec(statement);
|
|
}
|
|
sqlite
|
|
.prepare("INSERT INTO p_demo_note (user_id, title) VALUES (?, ?)")
|
|
.run("u1", "mine");
|
|
|
|
const [row] = sqlite
|
|
.prepare("SELECT created_at FROM p_demo_note")
|
|
.all() as {
|
|
created_at: string;
|
|
}[];
|
|
expect(row.created_at).toBeTruthy();
|
|
});
|
|
|
|
it("answers the table-exists probe for the active dialect", async () => {
|
|
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
|
sqlite.exec(statement);
|
|
}
|
|
|
|
const found = await db.all(tableExistsSql("sqlite", "p_demo_note"));
|
|
const missing = await db.all(tableExistsSql("sqlite", "p_demo_nothing"));
|
|
|
|
expect(found).toHaveLength(1);
|
|
expect(missing).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("applying migrations for real", () => {
|
|
it("creates the tables and records the ledger", async () => {
|
|
writeMigration(
|
|
"sqlite",
|
|
"0001_init.sql",
|
|
createTableSql("sqlite", "demo", notes).join("\n"),
|
|
);
|
|
|
|
const applied = await applyPluginMigrations("demo", root, createRunner());
|
|
|
|
expect(applied).toEqual(["0001_init"]);
|
|
expect(tableExists("p_demo_note")).toBe(true);
|
|
expect(
|
|
sqlite.prepare("SELECT * FROM plugin_migrations").all(),
|
|
).toHaveLength(1);
|
|
});
|
|
|
|
it("is a no-op the second time, so a restart does not re-run DDL", async () => {
|
|
writeMigration(
|
|
"sqlite",
|
|
"0001_init.sql",
|
|
createTableSql("sqlite", "demo", notes).join("\n"),
|
|
);
|
|
await applyPluginMigrations("demo", root, createRunner());
|
|
|
|
const again = await applyPluginMigrations("demo", root, createRunner());
|
|
|
|
expect(again).toEqual([]);
|
|
});
|
|
|
|
it("blocks the plugin when an applied migration was edited", async () => {
|
|
writeMigration(
|
|
"sqlite",
|
|
"0001_init.sql",
|
|
createTableSql("sqlite", "demo", notes).join("\n"),
|
|
);
|
|
await applyPluginMigrations("demo", root, createRunner());
|
|
|
|
writeMigration(
|
|
"sqlite",
|
|
"0001_init.sql",
|
|
`${createTableSql("sqlite", "demo", notes).join("\n")}\n-- edited`,
|
|
);
|
|
|
|
await expect(
|
|
applyPluginMigrations("demo", root, createRunner()),
|
|
).rejects.toThrow(PluginMigrationChecksumError);
|
|
});
|
|
|
|
it("leaves one plugin's failure without touching another's tables", async () => {
|
|
writeMigration(
|
|
"sqlite",
|
|
"0001_init.sql",
|
|
createTableSql("sqlite", "good", notes).join("\n"),
|
|
);
|
|
await applyPluginMigrations("good", root, createRunner());
|
|
|
|
const brokenRoot = fs.mkdtempSync(path.join(os.tmpdir(), "termix-broken-"));
|
|
fs.mkdirSync(path.join(brokenRoot, "migrations", "sqlite"), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(brokenRoot, "migrations", "sqlite", "0001_init.sql"),
|
|
"CREATE TABLE p_broken_x (id integer); THIS IS NOT SQL;",
|
|
);
|
|
|
|
await expect(
|
|
applyPluginMigrations("broken", brokenRoot, createRunner()),
|
|
).rejects.toThrow();
|
|
|
|
// The good plugin is untouched, and core is still usable.
|
|
expect(tableExists("p_good_note")).toBe(true);
|
|
expect(sqlite.prepare("SELECT * FROM users").all()).toHaveLength(1);
|
|
|
|
fs.rmSync(brokenRoot, { recursive: true, force: true });
|
|
});
|
|
});
|
|
|
|
describe("adopting a legacy core table", () => {
|
|
it("renames the table and keeps every row", async () => {
|
|
sqlite.exec(
|
|
"CREATE TABLE fleets (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT)",
|
|
);
|
|
sqlite.prepare("INSERT INTO fleets (name) VALUES (?)").run("production");
|
|
sqlite.prepare("INSERT INTO fleets (name) VALUES (?)").run("staging");
|
|
|
|
writeMigration(
|
|
"sqlite",
|
|
"0001_adopt.sql",
|
|
adoptLegacyTableSql("sqlite", "fleets", "p_fleets_fleets"),
|
|
);
|
|
await applyPluginMigrations("fleets", root, createRunner(), {
|
|
bundled: true,
|
|
});
|
|
|
|
expect(tableExists("fleets")).toBe(false);
|
|
const rows = sqlite
|
|
.prepare("SELECT name FROM p_fleets_fleets ORDER BY id")
|
|
.all() as { name: string }[];
|
|
expect(rows.map((row) => row.name)).toEqual(["production", "staging"]);
|
|
});
|
|
|
|
it("creates the table fresh when there is no legacy one", async () => {
|
|
const fresh = defineTable("fleets", { id: id(), name: varchar(100) });
|
|
writeMigration(
|
|
"sqlite",
|
|
"0001_adopt.sql",
|
|
createTableSql("sqlite", "fleets", fresh).join("\n"),
|
|
);
|
|
|
|
await applyPluginMigrations("fleets", root, createRunner());
|
|
|
|
expect(tableExists("p_fleets_fleets")).toBe(true);
|
|
expect(sqlite.prepare("SELECT * FROM p_fleets_fleets").all()).toHaveLength(
|
|
0,
|
|
);
|
|
});
|
|
|
|
it("refuses a plugin adopting a table that belongs to another", async () => {
|
|
sqlite.exec("CREATE TABLE fleets (id INTEGER PRIMARY KEY)");
|
|
writeMigration(
|
|
"sqlite",
|
|
"0001_steal.sql",
|
|
adoptLegacyTableSql("sqlite", "fleets", "p_workspaces_fleets"),
|
|
);
|
|
|
|
await expect(
|
|
applyPluginMigrations("workspaces", root, createRunner(), {
|
|
bundled: true,
|
|
}),
|
|
).rejects.toThrow(/belongs to the "fleets" plugin/);
|
|
|
|
// Refused before execution, so the legacy table is still there.
|
|
expect(tableExists("fleets")).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("removing a plugin's data", () => {
|
|
it("drops the plugin's tables and leaves everything else", async () => {
|
|
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
|
sqlite.exec(statement);
|
|
}
|
|
const other = defineTable("thing", { id: id(), name: varchar(50) });
|
|
for (const statement of createTableSql("sqlite", "other", other)) {
|
|
sqlite.exec(statement);
|
|
}
|
|
|
|
sqlite.exec(dropTableSql("sqlite", "demo", notes));
|
|
|
|
expect(tableExists("p_demo_note")).toBe(false);
|
|
expect(tableExists("p_other_thing")).toBe(true);
|
|
expect(tableExists("users")).toBe(true);
|
|
});
|
|
|
|
it("clears only the removed plugin's ledger rows", () => {
|
|
for (const pluginId of ["demo", "other"]) {
|
|
sqlite
|
|
.prepare(
|
|
"INSERT INTO plugin_migrations (plugin_id, migration_id, checksum, applied_at) VALUES (?, ?, ?, ?)",
|
|
)
|
|
.run(pluginId, "0001_init", checksum("x"), new Date().toISOString());
|
|
}
|
|
|
|
sqlite
|
|
.prepare("DELETE FROM plugin_migrations WHERE plugin_id = ?")
|
|
.run("demo");
|
|
|
|
const remaining = sqlite
|
|
.prepare("SELECT plugin_id FROM plugin_migrations")
|
|
.all() as { plugin_id: string }[];
|
|
expect(remaining.map((row) => row.plugin_id)).toEqual(["other"]);
|
|
});
|
|
});
|
|
|
|
describe("querying through the built table", () => {
|
|
it("reads back a row written with raw SQL", async () => {
|
|
for (const statement of createTableSql("sqlite", "demo", notes)) {
|
|
sqlite.exec(statement);
|
|
}
|
|
sqlite
|
|
.prepare("INSERT INTO p_demo_note (user_id, title) VALUES (?, ?)")
|
|
.run("u1", "written");
|
|
|
|
const rows = (await db.all(sql.raw("SELECT title FROM p_demo_note"))) as {
|
|
title: string;
|
|
}[];
|
|
|
|
expect(rows[0].title).toBe("written");
|
|
});
|
|
});
|