feat: give plugins their own tables, migrations and sync entities

This commit is contained in:
LukeGus committed 2026-09-22 15:49:54 -05:00
1 parent 5796186a1c
commit c496a093ca
47 files changed
+32595 -290

No files matched your search

@@ -30,6 +30,9 @@ vi.mock("../../database/repositories/factory.js", () => ({
delete: async (pluginId: string, key: string) =>
kvStore.delete(`${pluginId}:${key}`),
listKeys: async () => [...kvStore.keys()],
countKeys: async (pluginId: string) =>
[...kvStore.keys()].filter((key) => key.startsWith(`${pluginId}:`))
.length,
}),
}));
+234
View File
@@ -0,0 +1,234 @@
/**
* ctx.db and the kv caps.
*
* db:own was in the catalog from A1 but nothing checked it, which ARCHITECTURE
* calls the anti-pattern outright. These assert that it is checked where the
* privileged thing happens, and audited either way.
*/
import { beforeEach, describe, expect, it, vi } from "vitest";
const grants = new Map<string, string[]>();
const auditEntries: Array<Record<string, unknown>> = [];
const kvStore = new Map<string, string>();
vi.mock("../../database/repositories/factory.js", () => ({
createCurrentPluginPermissionGrantRepository: () => ({
listByPlugin: async (pluginId: string) =>
(grants.get(pluginId) ?? []).map((capability) => ({
pluginId,
capability,
})),
}),
createCurrentPluginStorageRepository: () => ({
get: async (pluginId: string, key: string) =>
kvStore.get(`${pluginId}:${key}`) ?? null,
set: async (pluginId: string, key: string, value: string) => {
kvStore.set(`${pluginId}:${key}`, value);
},
delete: async (pluginId: string, key: string) =>
kvStore.delete(`${pluginId}:${key}`),
listKeys: async (pluginId: string) =>
[...kvStore.keys()]
.filter((key) => key.startsWith(`${pluginId}:`))
.map((key) => key.slice(pluginId.length + 1)),
countKeys: async (pluginId: string) =>
[...kvStore.keys()].filter((key) => key.startsWith(`${pluginId}:`))
.length,
}),
}));
vi.mock("../../utils/audit-logger.js", () => ({
logAudit: async (entry: Record<string, unknown>) => {
auditEntries.push(entry);
},
}));
vi.mock("../../database/db/index.js", () => ({
getDb: () => ({ marker: "db-handle" }),
}));
vi.mock("../../database/db/schema.js", () => ({
users: { marker: "users" },
hosts: { marker: "hosts" },
}));
import { createPluginContext, createPluginHandle } from "../../plugins/ctx.js";
import { invalidatePluginPermissionCache } from "../../plugins/permissions.js";
import { resetPluginData } from "../../plugins/data.js";
import {
resetSyncRegistry,
listEntityTypes,
} from "../../plugins/sync-registry.js";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
function manifestFor(pluginId: string, capabilities: string[]): PluginManifest {
return {
id: pluginId,
name: pluginId,
version: "1.0.0",
description: "",
author: { name: "test" },
license: "MIT",
category: "Productivity",
engine: { termix: ">=2.9.0", api: "1" },
capabilities,
} as PluginManifest;
}
function contextFor(pluginId: string, capabilities: string[]) {
const manifest = manifestFor(pluginId, capabilities);
const handle = createPluginHandle(pluginId, { activate: () => {} });
return { ctx: createPluginContext(manifest, handle), handle };
}
beforeEach(() => {
grants.clear();
auditEntries.length = 0;
kvStore.clear();
resetPluginData();
resetSyncRegistry();
invalidatePluginPermissionCache();
delete process.env.PLUGIN_MAX_KV_KEYS;
});
describe("ctx.db capability", () => {
it("refuses define without db:own", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(
ctx.db.define({ name: "thing", columns: {}, indexes: [] }),
).rejects.toThrow(/db:own/);
});
it("refuses define when granted but not declared", async () => {
// A grant for something the manifest never declared is ignored, so
// widening a plugin's reach always needs a manifest the user can see.
grants.set("demo", ["db:own"]);
const { ctx } = contextFor("demo", []);
await expect(
ctx.db.define({ name: "thing", columns: {}, indexes: [] }),
).rejects.toThrow(/db:own/);
});
it("refuses the client handle without db:own", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(ctx.db.client()).rejects.toThrow(/db:own/);
});
it("allows define when declared and granted", async () => {
grants.set("demo", ["db:own"]);
const { ctx } = contextFor("demo", ["db:own"]);
const table = await ctx.db.define({
name: "thing",
columns: { id: { type: "id", primaryKey: true, notNull: true } },
indexes: [],
});
expect(table).toBeTruthy();
});
it("audits a refused call as well as an allowed one", async () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
await expect(ctx.db.client()).rejects.toThrow();
const entry = auditEntries.find((row) => row.action === "plugin_db_client");
expect(entry).toMatchObject({
success: false,
username: "plugin:demo",
resourceId: "demo",
});
});
it("hands back read-only refs to the core tables a plugin may join", async () => {
grants.set("demo", ["db:own"]);
const { ctx } = contextFor("demo", ["db:own"]);
const refs = (await ctx.db.refs()) as Record<string, unknown>;
expect(Object.keys(refs).sort()).toEqual(["hosts", "users"]);
});
});
describe("ctx.kv limits", () => {
it("refuses a key over the length cap", async () => {
grants.set("demo", ["kv:own"]);
const { ctx } = contextFor("demo", ["kv:own"]);
await expect(ctx.kv.set("k".repeat(129), 1)).rejects.toThrow(
/at most 128 characters/,
);
});
it("refuses a value over the size cap", async () => {
grants.set("demo", ["kv:own"]);
const { ctx } = contextFor("demo", ["kv:own"]);
await expect(ctx.kv.set("big", "x".repeat(300_000))).rejects.toThrow(
/byte limit/,
);
});
it("refuses a new key past the key-count cap", async () => {
process.env.PLUGIN_MAX_KV_KEYS = "2";
grants.set("demo", ["kv:own"]);
const { ctx } = contextFor("demo", ["kv:own"]);
await ctx.kv.set("a", 1);
await ctx.kv.set("b", 2);
await expect(ctx.kv.set("c", 3)).rejects.toThrow(/key limit/);
});
it("still allows overwriting an existing key when full", async () => {
// Only a new key grows the table, so a full store is not read-only.
process.env.PLUGIN_MAX_KV_KEYS = "2";
grants.set("demo", ["kv:own"]);
const { ctx } = contextFor("demo", ["kv:own"]);
await ctx.kv.set("a", 1);
await ctx.kv.set("b", 2);
await expect(ctx.kv.set("a", 99)).resolves.toBeUndefined();
expect(await ctx.kv.get("a")).toBe(99);
});
it("counts each plugin's keys separately", async () => {
process.env.PLUGIN_MAX_KV_KEYS = "1";
grants.set("one", ["kv:own"]);
grants.set("two", ["kv:own"]);
await contextFor("one", ["kv:own"]).ctx.kv.set("a", 1);
await expect(
contextFor("two", ["kv:own"]).ctx.kv.set("a", 1),
).resolves.toBeUndefined();
});
});
describe("ctx.sync", () => {
it("registers an entity a plugin owns", () => {
grants.set("demo", []);
const { ctx } = contextFor("demo", []);
ctx.sync.registerEntity({ type: "demoThings", table: {}, order: 200 });
expect(listEntityTypes()).toContain("demoThings");
});
it("drops the registration when the plugin is disposed", async () => {
grants.set("demo", []);
const { ctx, handle } = contextFor("demo", []);
ctx.sync.registerEntity({ type: "demoThings", table: {} });
await handle.bag.disposeAll();
expect(listEntityTypes()).not.toContain("demoThings");
});
});
@@ -0,0 +1,345 @@
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
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(),
},
}));
import {
applyPluginMigrations,
assertOwnedTables,
adoptLegacyTableSql,
checksum,
PluginMigrationChecksumError,
readMigrations,
splitStatements,
LEGACY_TABLE_OWNERS,
type MigrationRunner,
type PluginMigration,
} from "../../plugins/migrations.js";
let root: string;
function writeMigration(
pluginDir: string,
dialect: string,
file: string,
body: string,
): void {
const dir = path.join(pluginDir, "migrations", dialect);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, file), body);
}
function createRunner(overrides: Partial<MigrationRunner> = {}) {
const executed: string[][] = [];
const recorded: PluginMigration[] = [];
const applied: { migrationId: string; checksum: string }[] = [];
const runner: MigrationRunner = {
dialect: "sqlite",
listApplied: async () => applied,
execute: async (statements) => {
executed.push(statements);
},
record: async (_pluginId, migration) => {
recorded.push(migration);
applied.push({
migrationId: migration.id,
checksum: migration.checksum,
});
},
...overrides,
};
return { runner, executed, recorded, applied };
}
beforeEach(() => {
root = fs.mkdtempSync(path.join(os.tmpdir(), "termix-migrations-"));
});
afterEach(() => {
fs.rmSync(root, { recursive: true, force: true });
});
describe("readMigrations", () => {
it("reads files in sequence order regardless of directory order", () => {
writeMigration(root, "sqlite", "0002_second.sql", "SELECT 2;");
writeMigration(root, "sqlite", "0010_tenth.sql", "SELECT 10;");
writeMigration(root, "sqlite", "0001_first.sql", "SELECT 1;");
const migrations = readMigrations(root, "sqlite");
expect(migrations.map((m) => m.id)).toEqual([
"0001_first",
"0002_second",
"0010_tenth",
]);
});
it("returns nothing when the plugin ships no migrations", () => {
expect(readMigrations(root, "sqlite")).toEqual([]);
});
it("rejects a .sql file that is not named NNNN_name.sql", () => {
writeMigration(root, "sqlite", "init.sql", "SELECT 1;");
expect(() => readMigrations(root, "sqlite")).toThrow(/NNNN_name\.sql/);
});
it("is insensitive to line endings, so a checkout does not look edited", () => {
expect(checksum("CREATE TABLE a;\nSELECT 1;\n")).toBe(
checksum("CREATE TABLE a;\r\nSELECT 1;\r\n"),
);
});
});
describe("splitStatements", () => {
it("splits on semicolons", () => {
expect(splitStatements("SELECT 1; SELECT 2;")).toEqual([
"SELECT 1",
"SELECT 2",
]);
});
it("keeps a semicolon inside a string literal", () => {
const statements = splitStatements(
"INSERT INTO t VALUES ('a;b'); SELECT 1;",
);
expect(statements).toEqual(["INSERT INTO t VALUES ('a;b')", "SELECT 1"]);
});
it("keeps an escaped quote inside a string literal", () => {
const statements = splitStatements("INSERT INTO t VALUES ('it''s; fine');");
expect(statements).toEqual(["INSERT INTO t VALUES ('it''s; fine')"]);
});
it("keeps a semicolon inside a line comment", () => {
const statements = splitStatements("-- a; comment\nSELECT 1;");
expect(statements).toHaveLength(1);
expect(statements[0]).toContain("SELECT 1");
});
it("keeps a semicolon inside a block comment", () => {
const statements = splitStatements("/* a; b */ SELECT 1;");
expect(statements).toHaveLength(1);
});
it("ignores a trailing statement that is only whitespace", () => {
expect(splitStatements("SELECT 1;\n\n \n")).toEqual(["SELECT 1"]);
});
});
describe("assertOwnedTables", () => {
it("allows a table in the plugin's own namespace", () => {
expect(() =>
assertOwnedTables("my-plugin", [
"CREATE TABLE IF NOT EXISTS p_my_plugin_thing (id integer)",
]),
).not.toThrow();
});
it("rejects a table outside the plugin's namespace", () => {
expect(() =>
assertOwnedTables("my-plugin", ["CREATE TABLE users (id integer)"]),
).toThrow(/may only create or alter tables prefixed "p_my_plugin_"/);
});
it("rejects altering a core table", () => {
expect(() =>
assertOwnedTables("my-plugin", [
"ALTER TABLE ssh_data ADD COLUMN x text",
]),
).toThrow(/p_my_plugin_/);
});
it("allows the one plugin that may adopt a legacy table", () => {
expect(() =>
assertOwnedTables("fleets", [
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets"',
]),
).not.toThrow();
});
it("names the owner when another plugin tries to adopt its table", () => {
expect(() =>
assertOwnedTables("workspaces", [
'ALTER TABLE "fleets" RENAME TO "p_workspaces_fleets"',
]),
).toThrow(/belongs to the "fleets" plugin/);
});
it("maps every legacy table to exactly one owning plugin", () => {
// A table owned by two plugins would let either one adopt it and lose the
// other's data.
const owners = Object.entries(LEGACY_TABLE_OWNERS);
expect(new Set(owners.map(([table]) => table)).size).toBe(owners.length);
});
});
describe("applyPluginMigrations", () => {
it("applies pending migrations in order and records each one", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (id integer);",
);
writeMigration(
root,
"sqlite",
"0002_more.sql",
"CREATE TABLE p_demo_b (id integer);",
);
const { runner, recorded } = createRunner();
const ids = await applyPluginMigrations("demo", root, runner);
expect(ids).toEqual(["0001_init", "0002_more"]);
expect(recorded.map((m) => m.id)).toEqual(["0001_init", "0002_more"]);
});
it("skips a migration that has already been applied", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (id integer);",
);
const body = fs.readFileSync(
path.join(root, "migrations", "sqlite", "0001_init.sql"),
"utf8",
);
const { runner, executed } = createRunner({
listApplied: async () => [
{ migrationId: "0001_init", checksum: checksum(body) },
],
});
const ids = await applyPluginMigrations("demo", root, runner);
expect(ids).toEqual([]);
expect(executed).toEqual([]);
});
it("refuses to run when an applied migration was edited", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (id integer);",
);
const { runner } = createRunner({
listApplied: async () => [
{ migrationId: "0001_init", checksum: "stale-checksum" },
],
});
await expect(applyPluginMigrations("demo", root, runner)).rejects.toThrow(
PluginMigrationChecksumError,
);
});
it("does not record a migration whose statements failed", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (id integer);",
);
const { runner, recorded } = createRunner({
execute: async () => {
throw new Error("syntax error");
},
});
await expect(applyPluginMigrations("demo", root, runner)).rejects.toThrow(
"syntax error",
);
expect(recorded).toEqual([]);
});
it("refuses a migration that reaches outside the plugin's namespace", async () => {
writeMigration(root, "sqlite", "0001_init.sql", "DROP TABLE users;");
const { runner, executed } = createRunner();
await expect(applyPluginMigrations("demo", root, runner)).rejects.toThrow(
/p_demo_/,
);
expect(executed).toEqual([]);
});
it("runs inside a transaction when the dialect offers one", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (id integer);",
);
const order: string[] = [];
const { runner } = createRunner({
transaction: async (fn) => {
order.push("begin");
await fn();
order.push("commit");
},
execute: async () => {
order.push("execute");
},
});
await applyPluginMigrations("demo", root, runner);
expect(order).toEqual(["begin", "execute", "commit"]);
});
it("reads the directory for the dialect it was given", async () => {
writeMigration(
root,
"sqlite",
"0001_init.sql",
"CREATE TABLE p_demo_a (x);",
);
writeMigration(
root,
"mysql",
"0001_init.sql",
"CREATE TABLE p_demo_a (x int);",
);
const { runner, executed } = createRunner({ dialect: "mysql" });
await applyPluginMigrations("demo", root, runner);
expect(executed[0][0]).toContain("int");
});
});
describe("adoptLegacyTableSql", () => {
it("uses ALTER TABLE RENAME on sqlite and postgres", () => {
expect(adoptLegacyTableSql("sqlite", "fleets", "p_fleets_fleets")).toBe(
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";',
);
expect(adoptLegacyTableSql("postgres", "fleets", "p_fleets_fleets")).toBe(
'ALTER TABLE "fleets" RENAME TO "p_fleets_fleets";',
);
});
it("uses RENAME TABLE and backticks on mysql", () => {
expect(adoptLegacyTableSql("mysql", "fleets", "p_fleets_fleets")).toBe(
"RENAME TABLE `fleets` TO `p_fleets_fleets`;",
);
});
});
@@ -0,0 +1,382 @@
/**
* 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/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());
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()),
).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");
});
});
@@ -0,0 +1,162 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
CORE_OWNER,
getEntity,
hasEntity,
listEntities,
listEntityTypes,
registerEntity,
resetSyncRegistry,
unregisterByOwner,
} from "../../plugins/sync-registry.js";
import { registerCoreSyncEntities } from "../../database/routes/sync-entities.js";
import { SYNCED_ENTITY_TYPES } from "../../../../electron/remote-sync-entities.cjs";
beforeEach(() => {
resetSyncRegistry();
});
afterEach(() => {
resetSyncRegistry();
});
describe("sync registry", () => {
it("orders entities by their declared order, then by name", () => {
registerEntity("core", { type: "b", table: {}, order: 20 });
registerEntity("core", { type: "a", table: {}, order: 10 });
registerEntity("core", { type: "c", table: {}, order: 10 });
expect(listEntityTypes()).toEqual(["a", "c", "b"]);
});
it("defaults the user column and the optional lists", () => {
registerEntity("core", { type: "thing", table: {} });
expect(getEntity("thing")).toMatchObject({
userColumn: "userId",
readOnlyFields: [],
encryptedFields: [],
});
});
it("refuses an entity another owner already registered", () => {
registerEntity("core", { type: "hosts", table: {} });
expect(() =>
registerEntity("evil-plugin", { type: "hosts", table: {} }),
).toThrow(/already registered by "core"/);
});
it("lets the same owner re-register, which is what reactivation does", () => {
registerEntity("demo", { type: "thing", table: {}, order: 1 });
registerEntity("demo", { type: "thing", table: {}, order: 2 });
expect(getEntity("thing")?.order).toBe(2);
});
it("removes an entity when its registration is disposed", () => {
const dispose = registerEntity("demo", { type: "thing", table: {} });
expect(hasEntity("thing")).toBe(true);
dispose();
expect(hasEntity("thing")).toBe(false);
});
it("does not let a stale disposer remove a newer registration", () => {
// A plugin that crashed and restarted must not revoke the registration
// its own restart installed.
const stale = registerEntity("demo", { type: "thing", table: {} });
resetSyncRegistry();
registerEntity("other", { type: "thing", table: {} });
stale();
expect(getEntity("thing")?.owner).toBe("other");
});
it("removes everything an owner registered", () => {
registerEntity("demo", { type: "a", table: {} });
registerEntity("demo", { type: "b", table: {} });
registerEntity("core", { type: "c", table: {} });
unregisterByOwner("demo");
expect(listEntityTypes()).toEqual(["c"]);
});
it("needs a type", () => {
expect(() => registerEntity("demo", { type: "", table: {} })).toThrow(
/needs a type/,
);
});
});
describe("core sync entities", () => {
beforeEach(() => {
registerCoreSyncEntities();
});
it("registers every entity the Electron client knows about", () => {
// Nothing asserted this before, which is how networkTopology came to be
// missing from the list in sync.test.ts.
expect([...listEntityTypes()].sort()).toEqual(
[...SYNCED_ENTITY_TYPES].sort(),
);
});
it("keeps the dependency order the frozen Electron array encodes", () => {
expect(listEntityTypes()).toEqual([...SYNCED_ENTITY_TYPES]);
});
it("owns all of them as core", () => {
for (const entity of listEntities()) {
expect(entity.owner).toBe(CORE_OWNER);
}
});
it("sorts every reference target before the entity that points at it", () => {
const order = new Map(
listEntities().map((entity) => [entity.type, entity.order]),
);
for (const entity of listEntities()) {
for (const reference of entity.references ?? []) {
// A self-reference is ordered within the entity by orderSyncRows.
if (reference.entityType === entity.type) continue;
expect(
order.get(reference.entityType),
`${entity.type} -> ${reference.entityType}`,
).toBeLessThan(entity.order);
}
}
});
it("marks the two singletons and nothing else", () => {
const singletons = listEntities()
.filter((entity) => entity.singleton)
.map((entity) => entity.type);
expect(singletons.sort()).toEqual(["networkTopology", "userPreferences"]);
});
it("keeps the read-only fields that must survive a sync payload", () => {
expect(getEntity("hosts")?.readOnlyFields).toEqual(["connectionOrigin"]);
expect(getEntity("userPreferences")?.readOnlyFields).toEqual([
"storageMode",
]);
});
it("gives networkTopology the custom serializer its JSON blob needs", () => {
const topology = getEntity("networkTopology");
expect(topology?.serialize).toBeTypeOf("function");
expect(topology?.deserialize).toBeTypeOf("function");
});
it("is idempotent, because several modules prime it", () => {
const before = listEntityTypes();
registerCoreSyncEntities();
expect(listEntityTypes()).toEqual(before);
});
});
+255
View File
@@ -0,0 +1,255 @@
import { describe, expect, it } from "vitest";
import {
defineTable,
id,
varchar,
text,
json,
boolean,
timestamp,
integer,
bigint,
encryptedText,
refUser,
refHost,
prefixedTableName,
tablePrefix,
} from "@termix/plugin-sdk/db";
import {
createTableSql,
dropTableSql,
addColumnSql,
keyedColumns,
buildTable,
} from "../../plugins/table-builder.js";
/**
* The three-dialect DDL is what verify:dialect would catch against a real
* server, and that needs a live Postgres and MySQL. These snapshots are what
* covers it in a run that has neither.
*/
const definition = defineTable(
"note",
{
id: id(),
userId: refUser(),
hostId: refHost(),
title: varchar(200).notNull(),
body: text(),
meta: json(),
secret: encryptedText(),
pinned: boolean().default(false),
position: integer().default(0),
size: bigint(),
createdAt: timestamp().notNull().defaultNow(),
},
{
uniques: [{ name: "idx_note_user_title", columns: ["userId", "title"] }],
indexes: [{ name: "idx_note_host", columns: ["hostId"] }],
},
);
describe("table naming", () => {
it("prefixes with the plugin id, dashes becoming underscores", () => {
expect(tablePrefix("network-topology")).toBe("p_network_topology_");
expect(prefixedTableName("network-topology", "graph")).toBe(
"p_network_topology_graph",
);
});
});
describe("defineTable validation", () => {
it("refuses an index on an unbounded text column", () => {
expect(() =>
defineTable(
"bad",
{ id: id(), body: text() },
{ indexes: [{ name: "idx_bad", columns: ["body"] }] },
),
).toThrow(/MySQL cannot index an unbounded column/);
});
it("refuses an index on a json column", () => {
expect(() =>
defineTable(
"bad",
{ id: id(), meta: json() },
{ indexes: [{ name: "idx_bad", columns: ["meta"] }] },
),
).toThrow(/MySQL cannot index an unbounded column/);
});
it("refuses an index naming a column the table does not declare", () => {
expect(() =>
defineTable(
"bad",
{ id: id() },
{ indexes: [{ name: "idx_bad", columns: ["nope"] }] },
),
).toThrow(/which table "bad" does not declare/);
});
it("refuses a table name that is not lower snake_case", () => {
expect(() => defineTable("Notes", { id: id() })).toThrow(
/lower snake_case/,
);
});
it("refuses a table with no columns", () => {
expect(() => defineTable("empty", {})).toThrow(/declares no columns/);
});
it("treats uniques as indexes that happen to be unique", () => {
const table = defineTable(
"thing",
{ id: id(), name: varchar(50) },
{ uniques: [{ name: "idx_thing_name", columns: ["name"] }] },
);
expect(table.indexes[0]).toMatchObject({
name: "idx_thing_name",
unique: true,
});
});
});
describe("keyedColumns", () => {
it("counts primary keys, uniques, refs and indexed columns", () => {
const keyed = keyedColumns(definition);
expect(keyed.has("userId")).toBe(true);
expect(keyed.has("hostId")).toBe(true);
expect(keyed.has("title")).toBe(true);
// Not keyed, so it stays TEXT and is not narrowed to varchar(255).
expect(keyed.has("body")).toBe(false);
expect(keyed.has("meta")).toBe(false);
});
});
describe("createTableSql", () => {
it("emits sqlite DDL", () => {
expect(createTableSql("sqlite", "demo", definition).join("\n"))
.toMatchInlineSnapshot(`
"CREATE TABLE IF NOT EXISTS "p_demo_note" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"host_id" integer NOT NULL,
"title" text NOT NULL,
"body" text,
"meta" text,
"secret" text,
"pinned" integer DEFAULT 0,
"position" integer DEFAULT 0,
"size" integer,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_note_user_title" ON "p_demo_note" ("user_id", "title");
CREATE INDEX IF NOT EXISTS "idx_note_host" ON "p_demo_note" ("host_id");"
`);
});
it("emits postgres DDL with serial and native booleans", () => {
expect(createTableSql("postgres", "demo", definition).join("\n"))
.toMatchInlineSnapshot(`
"CREATE TABLE IF NOT EXISTS "p_demo_note" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"host_id" integer NOT NULL,
"title" varchar(200) NOT NULL,
"body" text,
"meta" text,
"secret" text,
"pinned" boolean DEFAULT false,
"position" integer DEFAULT 0,
"size" bigint,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_note_user_title" ON "p_demo_note" ("user_id", "title");
CREATE INDEX IF NOT EXISTS "idx_note_host" ON "p_demo_note" ("host_id");"
`);
});
it("emits mysql DDL with backticks and a parenthesised timestamp default", () => {
expect(createTableSql("mysql", "demo", definition).join("\n"))
.toMatchInlineSnapshot(`
"CREATE TABLE IF NOT EXISTS \`p_demo_note\` (
\`id\` int AUTO_INCREMENT PRIMARY KEY,
\`user_id\` varchar(255) NOT NULL,
\`host_id\` int NOT NULL,
\`title\` varchar(200) NOT NULL,
\`body\` text,
\`meta\` text,
\`secret\` text,
\`pinned\` boolean DEFAULT false,
\`position\` int DEFAULT 0,
\`size\` bigint,
\`created_at\` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (\`user_id\`) REFERENCES \`users\` (\`id\`) ON DELETE CASCADE,
FOREIGN KEY (\`host_id\`) REFERENCES \`ssh_data\` (\`id\`) ON DELETE CASCADE
);
CREATE UNIQUE INDEX \`idx_note_user_title\` ON \`p_demo_note\` (\`user_id\`, \`title\`);
CREATE INDEX \`idx_note_host\` ON \`p_demo_note\` (\`host_id\`);"
`);
});
it("never leaves an indexed column as TEXT off sqlite", () => {
// MySQL 8 rejects a TEXT column in a key specification. MariaDB accepts
// it, which is why this was missed in core until a real MySQL saw it.
for (const dialect of ["postgres", "mysql"] as const) {
const sql = createTableSql(dialect, "demo", definition).join("\n");
const keyed = keyedColumns(definition);
for (const property of keyed) {
const column = property.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
const line = sql
.split("\n")
.find(
(row) => row.includes(`${column}\``) || row.includes(`"${column}"`),
);
expect(line, `${dialect}.${column}`).not.toMatch(/\s(text|json)[,\s]/);
}
}
});
it("omits IF NOT EXISTS on a mysql index, which does not support it", () => {
const mysql = createTableSql("mysql", "demo", definition);
const indexes = mysql.filter((statement) => statement.includes("INDEX"));
expect(indexes).not.toHaveLength(0);
for (const statement of indexes) {
expect(statement).not.toContain("IF NOT EXISTS");
}
});
});
describe("addColumnSql", () => {
it("emits an ALTER per dialect", () => {
expect(addColumnSql("sqlite", "demo", definition, "body")).toBe(
'ALTER TABLE "p_demo_note" ADD COLUMN "body" text;',
);
expect(addColumnSql("mysql", "demo", definition, "title")).toBe(
"ALTER TABLE `p_demo_note` ADD COLUMN `title` varchar(200) NOT NULL;",
);
});
});
describe("dropTableSql", () => {
it("drops the prefixed table", () => {
expect(dropTableSql("postgres", "demo", definition)).toBe(
'DROP TABLE IF EXISTS "p_demo_note";',
);
});
});
describe("buildTable", () => {
it("builds a queryable drizzle table carrying the prefixed name", async () => {
const { getTableName, getTableColumns } = await import("drizzle-orm");
const table = buildTable("demo", definition) as never;
expect(getTableName(table)).toBe("p_demo_note");
expect(Object.keys(getTableColumns(table))).toContain("userId");
});
});