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

+64 -14
View File
@@ -27,6 +27,8 @@ dependencies. Entry points:
| Entry | Contents |
| --------------------------------- | ---------------------------------------------------------- |
| `@termix/plugin-sdk/backend` | `PluginContext`, `definePlugin()`, `PluginCapabilityError` |
| `@termix/plugin-sdk/db` | `defineTable()`, the column builders, the legacy-table map |
| `@termix/plugin-sdk/ddl` | The per-dialect DDL emitter, shared with the CLI |
| `@termix/plugin-sdk/frontend` | The `app` object types (stubs until A7) |
| `@termix/plugin-sdk/manifest` | Manifest types, validation, the JSON schema |
| `@termix/plugin-sdk/capabilities` | The capability catalog |
@@ -76,13 +78,50 @@ revokes one does not get it handed back on the next restart.
### 5. Data
Plugins own their tables through SDK table definitions and ship migrations for
sqlite, pg and mysql. Table names are prefixed `p_<id with - as _>_`. Legacy
core tables are adopted by rename in the plugin's first migration. `ctx.kv` is
for small key/value state. Core `schema.ts` ends up holding only core tables.
Plugins own their tables. A plugin declares them with `defineTable()` from
`@termix/plugin-sdk/db` and ships migrations for `sqlite`, `postgres` and
`mysql` under `migrations/<dialect>/NNNN_name.sql`. Table names are prefixed
`p_<id with - as _>_`, which `defineTable` adds. `ctx.kv` is for small
key/value state. Core `schema.ts` ends up holding only core tables.
**A3** delivers the table definitions and migration runner. A1 ships `ctx.kv`
only.
**One table object, three DDL emitters.** The sqlite-core definitions already
encode correctly on every engine at query time, which is why core's own 44
repositories use `schema.ts` on all three. Only DDL genuinely differs, and
that mapping lives in `@termix/plugin-sdk/ddl` so `termix-plugin migrations`
and the server's runner cannot drift into disagreeing.
**MySQL cannot index TEXT.** Any column carrying a key becomes
`varchar(255)`, and `defineTable` refuses an index on `text()` or `json()`
outright rather than silently truncating it.
**Migrations are applied at activation**, before `activate()` runs, so a
plugin's first query never meets a missing table. Applied migrations are
recorded in `plugin_migrations` with a checksum, which makes them immutable:
editing one that already ran blocks that plugin rather than leaving two
installs with quietly different schemas. A migration that throws marks its
plugin failed and core keeps booting.
**Adoption** is how a feature keeps its rows when it moves out of core. The
legacy table is renamed into the plugin's namespace
(`fleets` -> `p_fleets_fleets`) rather than copied, so nothing is duplicated
and no row is migrated one at a time. `LEGACY_TABLE_OWNERS` maps each legacy
table to the one plugin allowed to adopt it, enforced by the CLI at build time
and by the runner at activation, because a plugin installed from a tarball
never ran the CLI.
**Anything DDL-only in `db/index.ts` is SQLite-only.** `alert_rules`,
`alert_rule_channels` and `alert_firings` were created only there and never
declared in `schema.ts`, so they never existed on Postgres or MySQL at all.
Automations replaced them and A3 drops them. A core table needs all three
artifacts in lockstep: the `schema.ts` declaration, the `index.ts` DDL, and the
regenerated `schema.pg.ts`/`schema.mysql.ts` plus drizzle migrations.
**Sync entities are registered, not hardcoded.** `ctx.sync.registerEntity`
adds an entity to remote sync; core registers its own ten the same way in
`database/routes/sync-entities.ts`. The wire names are unchanged, because
existing tombstones and rows on the far side match on those strings. The
Electron client asks the server for the ordered list and falls back to its
frozen array for a server that predates the endpoint.
### 6. HTTP and WebSockets
@@ -175,7 +214,9 @@ plugins/<id>/
src/backend/index.ts exports activate(ctx) and deactivate()
src/frontend/index.tsx exports activate(app) and deactivate() (A7)
locales/en.json English strings; Crowdin translates the rest
migrations/{sqlite,pg,mysql}/ owned tables (A3)
migrations/{sqlite,postgres,mysql}/ owned tables, one .sql per dialect
migrations/snapshot.json diff basis for the generator
src/backend/tables.ts defineTable() definitions
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
README.md CHANGELOG.md
@@ -364,7 +405,8 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.disposables.add` | none | **A1** |
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db` | `db:own` | A3 |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.route` / `ctx.ws` | `network:serve` | A4 |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | A4 |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | A4 |
@@ -454,6 +496,13 @@ architecture did.
- **Protection from malicious code.** A plugin that wants to bypass the gate
can. Nothing here stops it.
`ctx.db` is a clear example. It hands back a handle scoped to the plugin's own
tables, and the `p_<id>_` prefix, the CLI check and the runner's check all say
a plugin may only touch its own. None of that is enforced by the engine: a
plugin holding that handle can write any table in the database, and `ctx.db.refs`
hands it `users` and `ssh_data` outright. The prefix, the capability, the audit
line, lint and review are the contract. The engine is not.
So the honest boundary is: capabilities stop accidental and casual overreach,
and make every privileged call auditable. What stops malicious code is
**signing, review and the kill list** - the same trust model as any other
@@ -473,12 +522,13 @@ until one is, this document does not pretend otherwise.
runs from the plugin's own directory, so a plugin that moves to its own repo
keeps working unchanged.
| Command | What it does |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `termix-plugin build` | esbuild bundles `src/backend/index.ts` to `dist/backend.js` (ESM, node22) and `src/frontend/index.tsx` to `dist/frontend.js` (ESM, browser), then copies `locales/` and `migrations/` into `dist/`. Imported CSS lands in `dist/frontend.css`. |
| `termix-plugin validate` | Runs the SDK's `parseManifest` and checks the files the manifest names exist. |
| `termix-plugin test` | Runs the plugin's vitest suite. |
| `termix-plugin pack` | Writes a `.tgz` of the manifest, `dist/`, locales, migrations and README. **D3** adds signing. |
| Command | What it does |
| -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `termix-plugin build` | esbuild bundles `src/backend/index.ts` to `dist/backend.js` (ESM, node22) and `src/frontend/index.tsx` to `dist/frontend.js` (ESM, browser), then copies `locales/` and `migrations/` into `dist/`. Imported CSS lands in `dist/frontend.css`. |
| `termix-plugin validate` | Runs the SDK's `parseManifest`, checks the files the manifest names exist, and checks that migrations only touch this plugin's tables and exist for every dialect. |
| `termix-plugin migrations` | Diffs `src/backend/tables.ts` against `migrations/snapshot.json` and writes one `.sql` per dialect. `--check` fails when a definition changed without a migration. A rename or a type change is refused rather than guessed at, because that is a data decision. |
| `termix-plugin test` | Runs the plugin's vitest suite. |
| `termix-plugin pack` | Writes a `.tgz` of the manifest, `dist/`, locales, migrations and README. **D3** adds signing. |
`npm run build:plugins` builds every plugin and stages the result in
`dist/plugins/<id>/`, which is what `getBundledPluginsDir()` resolves to and
@@ -0,0 +1,227 @@
/**
* 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,
};
}
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 { createTableSql, 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) {
statements.push(...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);
}
@@ -2,6 +2,7 @@ import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { readManifest } from "../lib/plugin-dir.mjs";
import { LEGACY_TABLE_OWNERS as LEGACY_TABLES } from "../../dist/db.js";
/**
* The manifest rules live in src/manifest.ts, which the server uses too, so
@@ -35,6 +36,8 @@ export async function validate({ cwd }) {
}
}
problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd)));
if (problems.length > 0) {
for (const problem of problems) console.error(` ${problem}`);
throw new Error(`${raw.id ?? path.basename(cwd)}: manifest is not valid.`);
@@ -42,3 +45,77 @@ export async function validate({ cwd }) {
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
}
const DIALECTS = ["sqlite", "postgres", "mysql"];
const CREATE_OR_ALTER =
/(?:CREATE\s+(?:TEMP\s+|TEMPORARY\s+)?TABLE(?:\s+IF\s+NOT\s+EXISTS)?|ALTER\s+TABLE|DROP\s+TABLE(?:\s+IF\s+EXISTS)?|TRUNCATE\s+TABLE)\s+([`"']?)([a-zA-Z0-9_]+)\1/gi;
/**
* Checks that a plugin's migrations only touch tables it owns.
*
* The server enforces this too, because a plugin installed from a tarball
* never ran this command. Here it is a build-time error with a file name
* attached, rather than a plugin that fails to activate later.
*/
function validateMigrations(cwd, pluginId) {
const problems = [];
const prefix = `p_${String(pluginId).replace(/-/g, "_")}_`;
const root = path.join(cwd, "migrations");
if (!fs.existsSync(root)) return problems;
const present = DIALECTS.filter((dialect) =>
fs.existsSync(path.join(root, dialect)),
);
const byDialect = new Map();
for (const dialect of present) {
const dir = path.join(root, dialect);
const files = fs
.readdirSync(dir)
.filter((file) => file.endsWith(".sql"))
.sort();
byDialect.set(dialect, files);
for (const file of files) {
if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(file)) {
problems.push(
`migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`,
);
}
const sql = fs.readFileSync(path.join(dir, file), "utf8");
CREATE_OR_ALTER.lastIndex = 0;
let match;
while ((match = CREATE_OR_ALTER.exec(sql)) !== null) {
const table = match[2];
if (table.startsWith(prefix)) continue;
if (LEGACY_TABLES[table] === pluginId) continue;
problems.push(
`migrations/${dialect}/${file} touches "${table}", which is not prefixed "${prefix}"`,
);
}
}
}
// A migration that exists for one engine and not another leaves that
// deployment without the table, which is how alert_rules ended up
// SQLite-only in core.
const [first, ...rest] = present;
for (const dialect of rest) {
const a = byDialect.get(first);
const b = byDialect.get(dialect);
for (const file of a) {
if (!b.includes(file)) {
problems.push(`migrations/${dialect}/${file} is missing`);
}
}
for (const file of b) {
if (!a.includes(file)) {
problems.push(`migrations/${first}/${file} is missing`);
}
}
}
return problems;
}
+3 -1
View File
@@ -10,8 +10,9 @@ import { build } from "./commands/build.mjs";
import { validate } from "./commands/validate.mjs";
import { test } from "./commands/test.mjs";
import { pack } from "./commands/pack.mjs";
import { migrations } from "./commands/migrations.mjs";
const COMMANDS = { build, validate, test, pack };
const COMMANDS = { build, validate, test, pack, migrations };
const [command, ...args] = process.argv.slice(2);
@@ -24,6 +25,7 @@ if (!command || command === "--help" || command === "-h") {
" validate Check manifest.json and the files it names",
" test Run the plugin's vitest suite",
" pack Write a .tgz of the built plugin",
" migrations Generate migrations from the plugin's table definitions",
].join("\n"),
);
process.exit(command ? 0 : 1);
+8
View File
@@ -12,6 +12,14 @@
"types": "./dist/backend.d.ts",
"default": "./dist/backend.js"
},
"./db": {
"types": "./dist/db.d.ts",
"default": "./dist/db.js"
},
"./ddl": {
"types": "./dist/ddl.d.ts",
"default": "./dist/ddl.js"
},
"./frontend": {
"types": "./dist/frontend.d.ts",
"default": "./dist/frontend.js"
+73
View File
@@ -11,6 +11,7 @@
*/
import type { PluginManifest } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
/**
* Thrown when a plugin calls a guarded member without the capability.
@@ -56,6 +57,75 @@ export interface PluginKeyValue {
list: () => Promise<string[]>;
}
/**
* The plugin's own tables.
*
* `define` registers a table definition and returns the Drizzle table object
* to query through `db`. Both require db:own.
*
* Scoped by name: every table carries the p_<id>_ prefix, and `refs` exposes
* users and ssh_data read-only so a plugin can join against them without being
* able to write them. In-process code could reach around all of this - see
* "What this protects, and what it does not" in ARCHITECTURE.md. The
* capability, the prefix, lint and review are the contract, not a sandbox.
*/
export interface PluginDatabase {
/** Registers a definition and returns its queryable table object. */
define: <T = unknown>(definition: PluginTableDefinition) => Promise<T>;
/** Drizzle handle, scoped to this plugin's tables. */
client: <T = unknown>() => Promise<T>;
/** Read-only references to the core tables a plugin may point at. */
refs: <T = unknown>() => Promise<T>;
}
/** A row's shape on the wire, before it is written to a table. */
export type SyncRow = Record<string, unknown>;
export interface SyncEntityReference {
/** Stored column holding a local numeric id, e.g. "credentialId". */
field: string;
/** Wire field holding the portable id, e.g. "credentialSyncId". */
syncField: string;
/** The entity the id points at. */
entityType: string;
}
export interface SyncEntityRegistration {
/** Stable wire name. Never change it: tombstones and remote rows match on it. */
type: string;
/** The table, as returned by ctx.db.define. */
table: unknown;
/** Column holding the owning user id. Defaults to "userId". */
userColumn?: string;
/** Fields DataCrypto translates between plaintext wire and encrypted row. */
encryptedFields?: readonly string[];
/** Local-id to sync-id translations applied on the way out and back. */
references?: readonly SyncEntityReference[];
/** Lower sorts first. Reference targets must sort before their referrers. */
order?: number;
/** Fields that must never be overwritten by an inbound payload. */
readOnlyFields?: readonly string[];
/** One row per user rather than many, keyed on the owner. */
singleton?: boolean;
/**
* Escape hatch for a row whose references are not plain columns, such as ids
* embedded in a JSON blob. Runs instead of `references`, not alongside it.
*/
serialize?: (
row: SyncRow,
resolveSyncId: (entityType: string, id: number) => Promise<string | null>,
) => Promise<SyncRow>;
deserialize?: (
row: SyncRow,
resolveId: (entityType: string, syncId: string) => Promise<number | null>,
) => Promise<SyncRow>;
}
/** Adds an entity to remote sync between a desktop backend and a server. */
export interface PluginSync {
registerEntity: (entity: SyncEntityRegistration) => void;
}
export interface PluginRegistry {
provide: <T>(key: string, value: T) => void;
consume: <T>(key: string) => T | undefined;
@@ -95,6 +165,8 @@ export interface PluginContext {
readonly log: PluginLogger;
readonly events: PluginEvents;
readonly kv: PluginKeyValue;
readonly db: PluginDatabase;
readonly sync: PluginSync;
readonly registry: PluginRegistry;
readonly services: PluginServices;
readonly secrets: PluginSecrets;
@@ -133,3 +205,4 @@ export function definePlugin(plugin: PluginModule): PluginModule {
}
export type { PluginManifest } from "./manifest.js";
export type { PluginTableDefinition } from "./db.js";
+263
View File
@@ -0,0 +1,263 @@
/**
* Table definitions a plugin owns.
*
* A definition is a plain descriptor, not a live Drizzle table. Core turns it
* into one at runtime (src/backend/plugins/table-builder.ts) and emits the DDL
* for whichever dialect the server is on, which keeps this entry a description
* of the contract rather than an implementation a plugin could reach around.
*
* Why one table object and three DDL emitters, rather than three table
* objects: the sqlite-core definitions already encode correctly for every
* engine at query time. text and integer encode as themselves; a boolean
* writes 1/0, which Postgres and MySQL both accept; real is a plain number.
* What genuinely differs between the engines is DDL. See the header of
* scripts/generate-dialect-schema.cjs, which this mapping is taken from.
*/
export type PluginColumnType =
| "id"
| "text"
| "varchar"
| "integer"
| "bigint"
| "boolean"
| "timestamp"
| "json"
| "encryptedText"
| "refUser"
| "refHost";
export interface PluginColumn {
readonly type: PluginColumnType;
/** varchar only. The ceiling MySQL can index. */
readonly length?: number;
readonly notNull?: boolean;
readonly primaryKey?: boolean;
readonly unique?: boolean;
readonly defaultValue?: string | number | boolean | null;
/** timestamp only: default CURRENT_TIMESTAMP. */
readonly defaultNow?: boolean;
}
/** The longest string MySQL will index, and so the width of every key column. */
export const KEY_LENGTH = 255;
interface Builder<T extends PluginColumn = PluginColumn> {
notNull: () => Builder<T>;
primaryKey: () => Builder<T>;
unique: () => Builder<T>;
default: (value: string | number | boolean | null) => Builder<T>;
defaultNow: () => Builder<T>;
readonly column: PluginColumn;
}
function builder(column: PluginColumn): Builder {
const next = (patch: Partial<PluginColumn>) =>
builder({ ...column, ...patch });
return {
column,
notNull: () => next({ notNull: true }),
primaryKey: () => next({ primaryKey: true, notNull: true }),
unique: () => next({ unique: true }),
default: (defaultValue) => next({ defaultValue }),
defaultNow: () => next({ defaultNow: true }),
};
}
/** Auto-incrementing surrogate key: integer on sqlite, serial, auto_increment. */
export const id = () =>
builder({ type: "id", primaryKey: true, notNull: true });
/** Unbounded string. Never indexable: MySQL cannot index TEXT. */
export const text = () => builder({ type: "text" });
/** Bounded string. Indexable on every engine. */
export const varchar = (length: number = KEY_LENGTH) =>
builder({ type: "varchar", length });
export const integer = () => builder({ type: "integer" });
export const bigint = () => builder({ type: "bigint" });
export const boolean = () => builder({ type: "boolean" });
/** Stored as text on every engine. See src/backend/database/repositories/sql-timestamp.ts. */
export const timestamp = () => builder({ type: "timestamp" });
/** JSON held as text. Never indexable, for the same reason as text(). */
export const json = () => builder({ type: "json" });
/** Text encrypted with the owning user's key before it is written. */
export const encryptedText = () => builder({ type: "encryptedText" });
/** Foreign key to users.id, cascading on delete. */
export const refUser = () =>
builder({ type: "refUser", length: KEY_LENGTH, notNull: true });
/** Foreign key to ssh_data.id, cascading on delete. */
export const refHost = () => builder({ type: "refHost", notNull: true });
export interface PluginTableIndex {
readonly name: string;
readonly columns: readonly string[];
readonly unique?: boolean;
}
export interface PluginTableOptions {
readonly indexes?: readonly PluginTableIndex[];
readonly uniques?: readonly PluginTableIndex[];
}
export interface PluginTableDefinition {
/** The unprefixed name as the plugin wrote it. */
readonly name: string;
readonly columns: Readonly<Record<string, PluginColumn>>;
readonly indexes: readonly PluginTableIndex[];
}
/** Columns that can never carry an index, because MySQL cannot index them. */
const UNINDEXABLE: ReadonlySet<PluginColumnType> = new Set([
"text",
"json",
"encryptedText",
]);
const NAME_PATTERN = /^[a-z][a-z0-9_]*$/;
type Columns = Record<string, Builder | PluginColumn>;
function unwrap(value: Builder | PluginColumn): PluginColumn {
return "column" in value ? value.column : value;
}
/**
* Declares a table this plugin owns.
*
* The name is unprefixed here; core prefixes it with p_<plugin id>_ when the
* definition is registered, so a plugin cannot name a table into another
* plugin's namespace or into core's.
*/
export function defineTable(
name: string,
columns: Columns,
options: PluginTableOptions = {},
): PluginTableDefinition {
if (!NAME_PATTERN.test(name)) {
throw new Error(
`Table name "${name}" must be lower snake_case and start with a letter`,
);
}
const resolved: Record<string, PluginColumn> = {};
for (const [key, value] of Object.entries(columns)) {
resolved[key] = unwrap(value);
}
if (Object.keys(resolved).length === 0) {
throw new Error(`Table "${name}" declares no columns`);
}
const indexes = [
...(options.uniques ?? []).map((entry) => ({ ...entry, unique: true })),
...(options.indexes ?? []),
];
// Checked here rather than at migration time so the error names the table
// and column while the author is still looking at the definition.
for (const index of indexes) {
if (!NAME_PATTERN.test(index.name)) {
throw new Error(
`Index name "${index.name}" must be lower snake_case and start with a letter`,
);
}
if (index.columns.length === 0) {
throw new Error(`Index "${index.name}" names no columns`);
}
for (const column of index.columns) {
const declared = resolved[column];
if (!declared) {
throw new Error(
`Index "${index.name}" names "${column}", which table "${name}" does not declare`,
);
}
if (UNINDEXABLE.has(declared.type)) {
throw new Error(
`Index "${index.name}" covers "${column}", a ${declared.type} column. ` +
`MySQL cannot index an unbounded column: use varchar(n) instead.`,
);
}
}
}
return { name, columns: resolved, indexes };
}
/** p_<id with - as _>_ - the prefix every table a plugin owns carries. */
export function tablePrefix(pluginId: string): string {
return `p_${pluginId.replace(/-/g, "_")}_`;
}
/** The physical table name for a definition owned by a plugin. */
export function prefixedTableName(pluginId: string, name: string): string {
return `${tablePrefix(pluginId)}${name}`;
}
/**
* Legacy core tables, and the one plugin allowed to adopt each.
*
* Adoption is how a feature keeps its rows when it moves out of core: the
* table is renamed into the plugin's namespace rather than copied. One owner
* per table, because two plugins adopting the same one would mean whichever
* activated second found it already gone.
*
* Shared with the CLI so a migration that reaches for someone else's table is
* caught at build time as well as at activation.
*/
export const LEGACY_TABLE_OWNERS: Readonly<Record<string, string>> = {
fleets: "fleets",
fleet_members: "fleets",
fleet_inventory: "fleets",
user_workspaces: "workspaces",
network_topology: "network-topology",
snippets: "snippets",
snippet_folders: "snippets",
snippet_access: "snippets",
c2s_tunnel_presets: "tunnels",
command_history: "ssh-terminal",
file_manager_recent: "file-manager",
file_manager_pinned: "file-manager",
file_manager_shortcuts: "file-manager",
transfer_recent: "file-manager",
tmux_session_tags: "tmux-monitor",
session_shares: "session-sharing",
session_share_participants: "session-sharing",
collab_rooms: "session-sharing",
collab_room_members: "session-sharing",
session_recordings: "session-recording",
host_metrics_preferences: "host-metrics",
host_health_checks: "host-metrics",
host_health_history: "host-metrics",
host_metrics_history: "host-metrics",
proxmox_stats_preferences: "proxmox",
proxmox_node_history: "proxmox",
automations: "automations",
automation_trigger_state: "automations",
automation_schedules: "automations",
automation_runs: "automations",
automation_run_steps: "automations",
automation_channels: "automations",
ai_providers: "ai",
ai_conversations: "ai",
ai_messages: "ai",
ai_proposals: "ai",
homepage_items: "homepage",
homepage_layouts: "homepage",
dashboard_service_links: "homepage",
secret_sources: "secret-sources",
opkssh_tokens: "opkssh",
vault_profiles: "vault",
vault_tokens: "vault",
termix_identities: "termix-identity",
termix_identity_keys: "termix-identity",
termix_identity_ca: "termix-identity",
webauthn_credentials: "webauthn",
sso_providers: "sso",
};
+220
View File
@@ -0,0 +1,220 @@
/**
* The DDL a table definition becomes, per dialect.
*
* Lives in the SDK because two things need the identical mapping: the CLI,
* which writes a plugin's migration files, and the server, which applies them.
* A second copy would drift and produce a migration the runner could not
* reproduce.
*
* The mapping itself is the one scripts/generate-dialect-schema.cjs applies to
* core's schema, kept deliberately in step with it.
*/
import { KEY_LENGTH, prefixedTableName } from "./db.js";
import type {
PluginColumn,
PluginTableDefinition,
PluginTableIndex,
} from "./db.js";
/** Which engine the DDL is for. Matches core's DatabaseDialect exactly. */
export type SqlDialect = "sqlite" | "postgres" | "mysql";
/** The core tables a plugin may point a foreign key at, and nothing else. */
export const REFERENCEABLE = {
refUser: { table: "users", column: "id" },
refHost: { table: "ssh_data", column: "id" },
} as const;
function camelToSnake(value: string): string {
return value.replace(/[A-Z]/g, (c) => `_${c.toLowerCase()}`);
}
/** The SQL column name for a declared property. */
export function columnName(property: string, column?: PluginColumn): string {
void column;
return camelToSnake(property);
}
function quote(dialect: SqlDialect, identifier: string): string {
if (dialect === "mysql") return "`" + identifier + "`";
return '"' + identifier + '"';
}
/**
* The physical type for a column on one dialect.
*
* Mirrors generate-dialect-schema.cjs: an autoincrement key is serial /
* auto_increment, a boolean is native off sqlite, and anything carrying a key
* has to be varchar because MySQL will not index TEXT.
*/
function columnType(
dialect: SqlDialect,
column: PluginColumn,
keyed: boolean,
): string {
switch (column.type) {
case "id":
if (dialect === "postgres") return "serial";
if (dialect === "mysql") return "int AUTO_INCREMENT";
return "integer";
case "integer":
return dialect === "mysql" ? "int" : "integer";
case "bigint":
return dialect === "sqlite" ? "integer" : "bigint";
case "boolean":
return dialect === "sqlite" ? "integer" : "boolean";
case "varchar":
return dialect === "sqlite"
? "text"
: `varchar(${column.length ?? KEY_LENGTH})`;
case "refUser":
return dialect === "sqlite" ? "text" : `varchar(${KEY_LENGTH})`;
case "refHost":
return dialect === "mysql" ? "int" : "integer";
default:
// A keyed string has to be indexable, so it narrows to varchar even
// though it was declared unbounded. defineTable refuses to index a
// text/json column, so in practice this only catches a primary key.
if (keyed && dialect !== "sqlite") return `varchar(${KEY_LENGTH})`;
return "text";
}
}
function defaultClause(dialect: SqlDialect, column: PluginColumn): string {
if (column.defaultNow) {
// MySQL rejects a bare DEFAULT CURRENT_TIMESTAMP on a text column; an
// expression default is written parenthesised. MariaDB takes either.
return dialect === "mysql"
? " DEFAULT (CURRENT_TIMESTAMP)"
: " DEFAULT CURRENT_TIMESTAMP";
}
if (column.defaultValue === undefined) return "";
const value = column.defaultValue;
if (value === null) return " DEFAULT NULL";
if (typeof value === "boolean") {
return dialect === "sqlite"
? ` DEFAULT ${value ? 1 : 0}`
: ` DEFAULT ${value ? "true" : "false"}`;
}
if (typeof value === "number") return ` DEFAULT ${value}`;
return ` DEFAULT '${String(value).replace(/'/g, "''")}'`;
}
/** Every column that carries a key, and so must be indexable on MySQL. */
export function keyedColumns(
definition: PluginTableDefinition,
): ReadonlySet<string> {
const keyed = new Set<string>();
for (const [property, column] of Object.entries(definition.columns)) {
if (column.primaryKey || column.unique) keyed.add(property);
if (column.type === "refUser" || column.type === "refHost") {
keyed.add(property);
}
}
for (const index of definition.indexes) {
for (const property of index.columns) keyed.add(property);
}
return keyed;
}
function indexStatement(
dialect: SqlDialect,
physical: string,
definition: PluginTableDefinition,
entry: PluginTableIndex,
): string {
const columns = entry.columns
.map((property) =>
quote(dialect, columnName(property, definition.columns[property])),
)
.join(", ");
const unique = entry.unique ? "UNIQUE " : "";
// MySQL has no CREATE INDEX IF NOT EXISTS. A plugin migration runs once,
// recorded in plugin_migrations, so the guard is the ledger, not the DDL.
const guard = dialect === "mysql" ? "" : "IF NOT EXISTS ";
return `CREATE ${unique}INDEX ${guard}${quote(dialect, entry.name)} ON ${quote(dialect, physical)} (${columns});`;
}
/** The CREATE TABLE and CREATE INDEX statements for one definition. */
export function createTableSql(
dialect: SqlDialect,
pluginId: string,
definition: PluginTableDefinition,
): string[] {
const physical = prefixedTableName(pluginId, definition.name);
const keyed = keyedColumns(definition);
const lines: string[] = [];
const constraints: string[] = [];
for (const [property, column] of Object.entries(definition.columns)) {
const name = columnName(property, column);
const type = columnType(dialect, column, keyed.has(property));
let line = ` ${quote(dialect, name)} ${type}`;
if (column.type === "id") {
line += " PRIMARY KEY";
if (dialect === "sqlite") line += " AUTOINCREMENT";
} else {
if (column.primaryKey) line += " PRIMARY KEY";
if (column.notNull) line += " NOT NULL";
if (column.unique) line += " UNIQUE";
line += defaultClause(dialect, column);
}
lines.push(line);
const reference =
column.type === "refUser"
? REFERENCEABLE.refUser
: column.type === "refHost"
? REFERENCEABLE.refHost
: null;
if (reference) {
constraints.push(
` FOREIGN KEY (${quote(dialect, name)}) REFERENCES ${quote(dialect, reference.table)} (${quote(dialect, reference.column)}) ON DELETE CASCADE`,
);
}
}
const body = [...lines, ...constraints].join(",\n");
const statements = [
`CREATE TABLE IF NOT EXISTS ${quote(dialect, physical)} (\n${body}\n);`,
];
for (const entry of definition.indexes) {
statements.push(indexStatement(dialect, physical, definition, entry));
}
return statements;
}
/** DROP statement for a definition, for removePluginData. */
/** DROP statement for a definition, for removePluginData. */
export function dropTableSql(
dialect: SqlDialect,
pluginId: string,
definition: PluginTableDefinition,
): string {
const physical = prefixedTableName(pluginId, definition.name);
return `DROP TABLE IF EXISTS ${quote(dialect, physical)};`;
}
/** The ALTER that adds one newly declared column. */
export function addColumnSql(
dialect: SqlDialect,
pluginId: string,
definition: PluginTableDefinition,
property: string,
): string {
const physical = prefixedTableName(pluginId, definition.name);
const column = definition.columns[property];
const keyed = keyedColumns(definition).has(property);
const name = columnName(property, column);
let line = `${quote(dialect, name)} ${columnType(dialect, column, keyed)}`;
if (column.notNull) line += " NOT NULL";
line += defaultClause(dialect, column);
return `ALTER TABLE ${quote(dialect, physical)} ADD COLUMN ${line};`;
}
+46 -1
View File
@@ -16,6 +16,8 @@ import type {
} from "./backend.js";
import type { TermixApp } from "./frontend.js";
import type { PluginManifest } from "./manifest.js";
import type { PluginTableDefinition } from "./db.js";
import type { SyncEntityRegistration } from "./backend.js";
export interface FakeContextOptions {
pluginId?: string;
@@ -32,6 +34,10 @@ export interface FakePluginContext {
emitted: Array<{ topic: string; payload: unknown }>;
/** Backing store behind ctx.kv. */
kv: Map<string, unknown>;
/** Table definitions registered through ctx.db.define, in order. */
tables: PluginTableDefinition[];
/** Sync entities registered through ctx.sync.registerEntity, in order. */
syncEntities: SyncEntityRegistration[];
}
function noopLogger(): PluginLogger {
@@ -55,6 +61,8 @@ export function createFakeContext(
const disposals: Array<() => void | Promise<void>> = [];
const emitted: Array<{ topic: string; payload: unknown }> = [];
const kv = new Map<string, unknown>();
const tables: PluginTableDefinition[] = [];
const syncEntities: SyncEntityRegistration[] = [];
const listeners = new Map<string, Set<(payload: unknown) => void>>();
let actor = options.actor;
@@ -105,6 +113,27 @@ export function createFakeContext(
list: async () => [...kv.keys()],
},
db: {
// No engine behind this one. A test that needs real SQL should drive the
// runtime rather than the double.
define: async (definition) => {
tables.push(definition);
return undefined as never;
},
client: async () => {
throw new Error("createFakeContext does not implement db.client");
},
refs: async () => {
throw new Error("createFakeContext does not implement db.refs");
},
},
sync: {
registerEntity: (entity) => {
syncEntities.push(entity);
},
},
registry: {
provide: () => {},
consume: () => undefined,
@@ -139,7 +168,7 @@ export function createFakeContext(
currentActor: () => actor,
};
return { ctx, disposals, emitted, kv };
return { ctx, disposals, emitted, kv, tables, syncEntities };
}
export interface MockContextOptions {
@@ -203,9 +232,25 @@ export function createMockCtx(
};
const guardedKv = ctx.kv;
const guardedDb = ctx.db;
const gatedCtx: PluginContext = {
...ctx,
db: {
define: async (definition) => {
require("db:own");
return guardedDb.define(definition);
},
client: async () => {
require("db:own");
return guardedDb.client();
},
refs: async () => {
require("db:own");
return guardedDb.refs();
},
},
kv: {
get: async (key) => {
require("kv:own");