Files
Termix/packages/plugin-sdk/src/db.ts
T
a89ccc6b07 v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
2026-10-20 16:03:29 -05:00

293 lines
9.9 KiB
TypeScript

/**
* 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"
| "real"
| "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, LONGTEXT on MySQL. 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" });
/** Floating point. A plain number on every engine. Never indexable. */
export const real = () => builder({ type: "real" });
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[];
/** A legacy core table this definition takes over by rename. See adoptLegacyTable. */
readonly adopts?: string;
}
/** 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",
recent_activity: "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",
dismissed_alerts: "alerts",
};
/**
* Marks a definition as the new home of a legacy core table.
*
* The migration the CLI writes for it renames the legacy table into the
* plugin's namespace, and creates it first when there is none (a fresh
* install), so both paths end with the same table. Keep the legacy column and
* index names in the definition: the rename carries the old indexes across,
* and reusing their names is what stops the migration from creating a second
* copy of each.
*/
export function adoptLegacyTable(
legacyName: string,
definition: PluginTableDefinition,
): PluginTableDefinition {
if (!Object.prototype.hasOwnProperty.call(LEGACY_TABLE_OWNERS, legacyName)) {
throw new Error(
`"${legacyName}" is not a legacy core table a plugin may adopt. See LEGACY_TABLE_OWNERS.`,
);
}
return { ...definition, adopts: legacyName };
}