Files
Termix/src/backend/database/db/performance-indexes.ts
T

270 lines
7.4 KiB
TypeScript

import { databaseLogger } from "../../utils/logger.js";
/**
* Indexes for the columns the app filters, joins and sorts on.
*
* Most tables here are scoped per user or per host and were previously reached
* with a full table scan on every read: the foreign keys existed, but SQLite
* does not index the child side of a foreign key on its own. That is fine for a
* home install with a handful of hosts and invisible in testing; on an install
* with thousands of hosts and a large audit trail it is the dominant cost of a
* request.
*
* Ordering within a composite matters: the equality column comes first and the
* range/sort column second, so the same index serves both the filter and the
* ORDER BY.
*/
export interface PerformanceIndex {
name: string;
table: string;
columns: string;
/** Enforces a constraint as well as serving reads, e.g. one row per key. */
unique?: boolean;
}
export const PERFORMANCE_INDEXES: PerformanceIndex[] = [
// Host list: the single hottest read in the app.
{ name: "idx_ssh_data_user_id", table: "ssh_data", columns: "user_id" },
{
name: "idx_ssh_data_parent_host",
table: "ssh_data",
columns: "parent_host_id",
},
{
name: "idx_ssh_data_credential",
table: "ssh_data",
columns: "credential_id",
},
// Sharing: resolved for every host list request and every permission check.
{ name: "idx_host_access_user_id", table: "host_access", columns: "user_id" },
{ name: "idx_host_access_role_id", table: "host_access", columns: "role_id" },
{ name: "idx_host_access_host_id", table: "host_access", columns: "host_id" },
{
name: "idx_host_access_expires_at",
table: "host_access",
columns: "expires_at",
},
// Audit log: grows without bound and is always read newest-first.
{
name: "idx_audit_logs_timestamp",
table: "audit_logs",
columns: "timestamp",
},
{
name: "idx_audit_logs_user_ts",
table: "audit_logs",
columns: "user_id, timestamp",
},
{
name: "idx_audit_logs_action_ts",
table: "audit_logs",
columns: "action, timestamp",
},
{
name: "idx_audit_logs_resource_ts",
table: "audit_logs",
columns: "resource_type, timestamp",
},
// Auth hot path: touched on every authenticated request.
{ name: "idx_sessions_user_id", table: "sessions", columns: "user_id" },
{ name: "idx_sessions_expires_at", table: "sessions", columns: "expires_at" },
{ name: "idx_user_roles_user_id", table: "user_roles", columns: "user_id" },
{ name: "idx_user_roles_role_id", table: "user_roles", columns: "role_id" },
{
name: "idx_trusted_devices_user_id",
table: "trusted_devices",
columns: "user_id",
},
{ name: "idx_api_keys_user_id", table: "api_keys", columns: "user_id" },
// Credentials and folders.
{
name: "idx_ssh_credentials_user_id",
table: "ssh_credentials",
columns: "user_id",
},
{ name: "idx_ssh_folders_user_id", table: "ssh_folders", columns: "user_id" },
{
name: "idx_ssh_credential_usage_credential",
table: "ssh_credential_usage",
columns: "credential_id",
},
{
name: "idx_ssh_credential_usage_user",
table: "ssh_credential_usage",
columns: "user_id",
},
// Per-user history and file manager surfaces.
{
name: "idx_recent_activity_user_ts",
table: "recent_activity",
columns: "user_id, timestamp",
},
// file_manager_recent, file_manager_pinned, file_manager_shortcuts,
// transfer_recent and command_history are owned by plugins now; their
// adopted table definitions carry these same indexes across the rename.
{
name: "idx_user_open_tabs_user_id",
table: "user_open_tabs",
columns: "user_id",
},
{
name: "idx_homepage_items_user_id",
table: "homepage_items",
columns: "user_id",
},
{
name: "idx_dismissed_alerts_user_id",
table: "dismissed_alerts",
columns: "user_id",
},
// Recordings and live session sharing.
{
name: "idx_session_recordings_user_started",
table: "session_recordings",
columns: "user_id, started_at",
},
{
name: "idx_session_recordings_host",
table: "session_recordings",
columns: "host_id",
},
// Automations.
{
name: "idx_automations_user",
table: "automations",
columns: "user_id, enabled",
},
{
name: "idx_automation_trigger_state_key",
table: "automation_trigger_state",
columns: "automation_id, state_key",
unique: true,
},
{
name: "idx_automation_schedules_automation",
table: "automation_schedules",
columns: "automation_id",
unique: true,
},
{
name: "idx_automation_schedules_due",
table: "automation_schedules",
columns: "next_due_at",
},
{
name: "idx_automation_runs_automation",
table: "automation_runs",
columns: "automation_id, started_at",
},
{
name: "idx_automation_runs_user",
table: "automation_runs",
columns: "user_id, started_at",
},
{
name: "idx_automation_run_steps_run",
table: "automation_run_steps",
columns: "run_id, step_index",
},
{
name: "idx_automation_channels_pair",
table: "automation_channels",
columns: "automation_id, channel_id",
unique: true,
},
// AI assistant.
{
name: "idx_ai_providers_user_label",
table: "ai_providers",
columns: "user_id, label",
unique: true,
},
{
name: "idx_ai_conversations_user",
table: "ai_conversations",
columns: "user_id, updated_at",
},
{
name: "idx_ai_messages_conversation",
table: "ai_messages",
columns: "conversation_id, created_at",
},
{
name: "idx_ai_proposals_user",
table: "ai_proposals",
columns: "user_id, status",
},
{
name: "idx_ai_proposals_conversation",
table: "ai_proposals",
columns: "conversation_id",
},
];
interface IndexableDatabase {
exec(sql: string): unknown;
}
export interface IndexCreationSummary {
created: number;
skipped: number;
failed: number;
}
/**
* Creates any missing index, leaving existing ones untouched.
*
* A failure here is logged and skipped rather than thrown: an index is a pure
* optimisation, and a table that a given install has not created yet (or a
* column an older schema is missing) must not stop the server from booting.
*/
export function createPerformanceIndexes(
db: IndexableDatabase,
indexes: PerformanceIndex[] = PERFORMANCE_INDEXES,
): IndexCreationSummary {
const summary: IndexCreationSummary = { created: 0, skipped: 0, failed: 0 };
const startedAt = Date.now();
for (const index of indexes) {
try {
db.exec(
`CREATE ${index.unique ? "UNIQUE " : ""}INDEX IF NOT EXISTS ${index.name} ON ${index.table}(${index.columns})`,
);
summary.created++;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// A missing table or column means this install does not have that
// feature's schema; nothing to index and nothing to warn loudly about.
if (/no such table|no such column/i.test(message)) {
summary.skipped++;
continue;
}
summary.failed++;
databaseLogger.warn(`Could not create index ${index.name}: ${message}`, {
operation: "performance_index_create",
index: index.name,
table: index.table,
});
}
}
databaseLogger.info(
`Performance indexes ready in ${Date.now() - startedAt}ms`,
{
operation: "performance_index_create",
...summary,
},
);
return summary;
}