mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: give plugins their own tables, migrations and sync entities
This commit is contained in:
1 parent
5796186a1c
commit
c496a093ca
47 files changed
+32595
-290
No files matched your search
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
};
|
||||
@@ -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};`;
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user