Files
Termix/src/backend/database/repositories/factory.ts
T

439 lines
16 KiB
TypeScript

import { DatabaseSaveTrigger } from "../../utils/database-save-trigger.js";
import { getDb, getSqlite } from "../db/index.js";
import { needsExplicitPersist, resolveDatabaseDialect } from "../db/dialect.js";
import { primeSettingsCache, readCachedSetting } from "./settings-cache.js";
import type { DatabaseContext } from "./database-context.js";
import { ApiKeyRepository } from "./api-key-repository.js";
import { AuditLogRepository } from "./audit-log-repository.js";
import { CredentialRepository } from "./credential-repository.js";
import { HostFolderRepository } from "./host-folder-repository.js";
import { HostRepository } from "./host-repository.js";
import { HostResolutionRepository } from "./host-resolution-repository.js";
import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js";
import { CredentialSidebarPreferenceRepository } from "./credential-sidebar-preference-repository.js";
import { UiPreferenceRepository } from "./ui-preference-repository.js";
import { OpenTabRepository } from "./open-tab-repository.js";
import { PluginRepository } from "./plugin-repository.js";
import { PluginStorageRepository } from "./plugin-storage-repository.js";
import { PluginSettingsRepository } from "./plugin-settings-repository.js";
import { HostDefaultsRepository } from "./host-defaults-repository.js";
import { PluginMigrationRepository } from "./plugin-migration-repository.js";
import { PluginInstallCountRepository } from "./plugin-install-count-repository.js";
import { PluginPermissionGrantRepository } from "./plugin-permission-grant-repository.js";
import { UserAuthRepository } from "./user-auth-repository.js";
import { RbacAccessRepository } from "./rbac-access-repository.js";
import { RbacPermissionRepository } from "./rbac-permission-repository.js";
import { RoleRepository } from "./role-repository.js";
import { SessionRepository } from "./session-repository.js";
import { CredentialAccessRepository } from "./credential-access-repository.js";
import { SharedCredentialSecretsRepository } from "./shared-credential-secrets-repository.js";
import { FolderAccessRepository } from "./folder-access-repository.js";
import { SettingsRepository } from "./settings-repository.js";
import { HostProtocolAuthRepository } from "./host-protocol-auth-repository.js";
import { SharedHostAuthOverrideRepository } from "./shared-host-auth-override-repository.js";
import { SharedHostSecretsRepository } from "./shared-host-secrets-repository.js";
import { SshCredentialUsageRepository } from "./ssh-credential-usage-repository.js";
import { TrustedDeviceRepository } from "./trusted-device-repository.js";
import { UserDataExportRepository } from "./user-data-export-repository.js";
import { UserPreferenceRepository } from "./user-preference-repository.js";
import { UserRepository } from "./user-repository.js";
/**
* The context every repository runs against.
*
* The dialect has to be resolved, not assumed: it is what `returning.ts` reads
* to decide whether it can ask for RETURNING, and whether an upsert spells
* itself `onConflictDoUpdate` or `onDuplicateKeyUpdate`. Reporting "sqlite"
* while connected to MySQL makes the second of those a TypeError on the first
* write.
*
* Both cross-dialect harnesses build a DatabaseContext themselves, so neither
* exercises this function, see tests/database/repositories/factory-context.
*/
export function createCurrentRepositoryContext(): DatabaseContext {
return {
dialect: resolveDatabaseDialect(),
drizzle: getDb(),
};
}
/**
* Post-write hook handed to every repository.
*
* Only meaningful for SQLite, where the database lives in memory and has to be
* serialised back to its encrypted file. On Postgres and MySQL the write is
* already durable, so no hook is installed at all rather than one that does
* nothing, repositories call it as `this.onWrite?.()`.
*/
export function createCurrentRepositoryWriteHook(
reason: string,
): (() => Promise<void>) | undefined {
if (!needsExplicitPersist(resolveDatabaseDialect())) return undefined;
return () => DatabaseSaveTrigger.forceSave(reason);
}
/**
* Post-write hook for high-frequency, non-critical writes (telemetry
* inserts/cleanup, informational timestamp touches).
*
* Marks the in-memory database dirty and lets DatabaseSaveTrigger's existing
* debounce coalesce the actual serialize+encrypt, instead of forcing one on
* every single sample. Losing up to 30 seconds of these on a crash is
* acceptable; blocking the event loop that serves SSH traffic on every metric
* sample is not.
*/
export function createCurrentRepositoryLazyWriteHook(
reason: string,
): (() => Promise<void>) | undefined {
if (!needsExplicitPersist(resolveDatabaseDialect())) return undefined;
return () => DatabaseSaveTrigger.triggerSave(reason);
}
/**
* Raw driver handle for the few synchronous call sites that cannot await,
* getCurrentSettingValue below, and settings reads during startup. Repositories
* must not use this: they take a DatabaseContext, which is drizzle-only.
* Porting to another engine means giving these callers an async path first.
*/
export function getCurrentRepositorySqlite() {
return getSqlite();
}
/**
* Synchronous settings read.
*
* SQLite can be queried synchronously, so it is read directly and stays
* authoritative. Other engines have no synchronous query, so the value comes
* from the cache primed at startup and kept current by SettingsRepository.
*/
export function getCurrentSettingValue(key: string): string | null {
if (!needsExplicitPersist(resolveDatabaseDialect())) {
return readCachedSetting(key);
}
const row = getCurrentRepositorySqlite()
.prepare("SELECT value FROM settings WHERE key = ?")
.get(key) as { value?: string } | undefined;
return row?.value ?? null;
}
export function createCurrentApiKeyRepository(): ApiKeyRepository {
return new ApiKeyRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("api_key_repository_write"),
);
}
export function createCurrentAuditLogRepository(): AuditLogRepository {
return new AuditLogRepository(
createCurrentRepositoryContext(),
// Rows land on every audited call; the audited action saves on its own.
createCurrentRepositoryLazyWriteHook("audit_log_repository_write"),
);
}
export function createCurrentCredentialRepository(): CredentialRepository {
return new CredentialRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("credential_repository_write"),
);
}
export function createCurrentHostFolderRepository(): HostFolderRepository {
return new HostFolderRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_folder_repository_write"),
);
}
export function createCurrentHostRepository(): HostRepository {
return new HostRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_repository_write"),
);
}
export function createCurrentHostResolutionRepository(): HostResolutionRepository {
return new HostResolutionRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_resolution_repository_write"),
createCurrentRepositoryLazyWriteHook(
"host_resolution_repository_lazy_write",
),
);
}
export function createCurrentHostSidebarPreferenceRepository(): HostSidebarPreferenceRepository {
return new HostSidebarPreferenceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"host_sidebar_preference_repository_write",
),
);
}
export function createCurrentCredentialSidebarPreferenceRepository(): CredentialSidebarPreferenceRepository {
return new CredentialSidebarPreferenceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"credential_sidebar_preference_repository_write",
),
);
}
export function createCurrentUiPreferenceRepository(): UiPreferenceRepository {
return new UiPreferenceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("ui_preference_repository_write"),
);
}
export function createCurrentOpenTabRepository(): OpenTabRepository {
return new OpenTabRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("open_tab_repository_write"),
);
}
export function createCurrentPluginRepository(): PluginRepository {
return new PluginRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_repository_write"),
);
}
export function createCurrentUserAuthRepository(): UserAuthRepository {
return new UserAuthRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("user_auth_repository_write"),
);
}
export function createCurrentPluginPermissionGrantRepository(): PluginPermissionGrantRepository {
return new PluginPermissionGrantRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"plugin_permission_grant_repository_write",
),
);
}
export function createCurrentPluginStorageRepository(): PluginStorageRepository {
return new PluginStorageRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_storage_repository_write"),
);
}
export function createCurrentHostDefaultsRepository(): HostDefaultsRepository {
return new HostDefaultsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_defaults_repository_write"),
);
}
export function createCurrentPluginSettingsRepository(): PluginSettingsRepository {
return new PluginSettingsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_settings_repository_write"),
);
}
export function createCurrentPluginInstallCountRepository(): PluginInstallCountRepository {
return new PluginInstallCountRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_install_count_repository_write"),
);
}
export function createCurrentPluginMigrationRepository(): PluginMigrationRepository {
return new PluginMigrationRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("plugin_migration_repository_write"),
);
}
export function createCurrentRbacPermissionRepository(): RbacPermissionRepository {
return new RbacPermissionRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("rbac_permission_repository_write"),
);
}
export function createCurrentRbacAccessRepository(): RbacAccessRepository {
return new RbacAccessRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("rbac_access_repository_write"),
);
}
export function createCurrentRoleRepository(): RoleRepository {
return new RoleRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("role_repository_write"),
);
}
export function createCurrentSessionRepository(): SessionRepository {
return new SessionRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("session_repository_write"),
// Activity timestamps are informational; the periodic flush persists them.
needsExplicitPersist(resolveDatabaseDialect())
? () => DatabaseSaveTrigger.markDirty()
: undefined,
);
}
export function createCurrentSettingsRepository(): SettingsRepository {
return new SettingsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("settings_repository_write"),
);
}
export function createCurrentSharedHostSecretsRepository(): SharedHostSecretsRepository {
return new SharedHostSecretsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("shared_host_secrets_repository_write"),
);
}
export function createCurrentHostProtocolAuthRepository(): HostProtocolAuthRepository {
return new HostProtocolAuthRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("host_protocol_auth_repository_write"),
);
}
export function createCurrentSharedHostAuthOverrideRepository(): SharedHostAuthOverrideRepository {
return new SharedHostAuthOverrideRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"shared_host_auth_override_repository_write",
),
);
}
export function createCurrentSshCredentialUsageRepository(): SshCredentialUsageRepository {
return new SshCredentialUsageRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("ssh_credential_usage_repository_write"),
);
}
export function createCurrentTrustedDeviceRepository(): TrustedDeviceRepository {
return new TrustedDeviceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("trusted_device_repository_write"),
);
}
export function createCurrentUserDataExportRepository(): UserDataExportRepository {
return new UserDataExportRepository(createCurrentRepositoryContext());
}
export function createCurrentUserPreferenceRepository(): UserPreferenceRepository {
return new UserPreferenceRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("user_preference_repository_write"),
);
}
export function createCurrentUserRepository(): UserRepository {
return new UserRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("user_repository_write"),
);
}
export function createCurrentFolderAccessRepository(): FolderAccessRepository {
return new FolderAccessRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("folder_access_repository_write"),
);
}
export function createCurrentCredentialAccessRepository(): CredentialAccessRepository {
return new CredentialAccessRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook("credential_access_repository_write"),
);
}
export function createCurrentSharedCredentialSecretsRepository(): SharedCredentialSecretsRepository {
return new SharedCredentialSecretsRepository(
createCurrentRepositoryContext(),
createCurrentRepositoryWriteHook(
"shared_credential_secrets_repository_write",
),
);
}
/**
* Loads the settings cache. Must run during startup on engines without a
* synchronous read, before anything calls getCurrentSettingValue.
*/
export async function primeCurrentSettingsCache(): Promise<void> {
const rows = await createCurrentSettingsRepository().listAll();
primeSettingsCache(rows);
}
/**
* How often a replica re-reads the settings table.
*
* Override with SETTINGS_CACHE_REFRESH_SECONDS; 0 disables the refresh.
*/
const REFRESH_SECONDS_ENV = "SETTINGS_CACHE_REFRESH_SECONDS";
const DEFAULT_REFRESH_SECONDS = 30;
let refreshTimer: NodeJS.Timeout | null = null;
/**
* Keeps the settings cache from drifting on a multi-replica deployment.
*
* The cache is per-process and updated in the process that writes. That is
* enough for SQLite, where there is only ever one process. On Postgres and
* MySQL, which exist here precisely so more than one instance can share the
* data, a setting changed on one replica would otherwise never reach the
* others, because the synchronous read has no way to go back to the database.
*
* Periodic re-priming does not make the value immediately consistent. It bounds
* how long it can be wrong, which is the difference between a setting that
* takes effect on the next tick and one that takes effect at the next restart.
*/
export function startSettingsCacheRefresh(
env = process.env,
refresh: () => Promise<void> = primeCurrentSettingsCache,
): void {
if (refreshTimer) return;
const seconds = refreshIntervalSeconds(env);
if (seconds === null) return;
refreshTimer = setInterval(() => {
void refresh().catch(() => {
// A failed refresh leaves the previous values in place, which is the
// right outcome: a transient database blip should not blank the cache.
// Every caller reads a missing setting as "use the default", so an empty
// cache would silently revert configuration across the deployment.
});
}, seconds * 1000);
refreshTimer.unref();
}
/** The configured interval, or null when refreshing is switched off. */
export function refreshIntervalSeconds(env = process.env): number | null {
const seconds = Number(env[REFRESH_SECONDS_ENV] ?? DEFAULT_REFRESH_SECONDS);
return Number.isFinite(seconds) && seconds > 0 ? seconds : null;
}
/** Test seam. */
export function stopSettingsCacheRefresh(): void {
if (!refreshTimer) return;
clearInterval(refreshTimer);
refreshTimer = null;
}