diff --git a/src/backend/database/repositories/alert-repository.ts b/src/backend/database/repositories/alert-repository.ts index 9b55ca36..b8740104 100644 --- a/src/backend/database/repositories/alert-repository.ts +++ b/src/backend/database/repositories/alert-repository.ts @@ -1,4 +1,4 @@ -import { and, count, desc, eq, inArray, isNull, or } from "drizzle-orm"; +import { and, count, desc, eq, inArray, isNull, lt, or } from "drizzle-orm"; import { alertFirings, alertRuleChannels, @@ -7,6 +7,7 @@ import { notificationChannels, } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { sqlTimestampDaysAgo } from "./sql-timestamp.js"; type AlertRuleRecord = typeof alertRules.$inferSelect; type NotificationChannelRecord = typeof notificationChannels.$inferSelect; @@ -411,12 +412,15 @@ export class AlertRepository { await this.afterWrite(); } - pruneFiringsOlderThan(userId: string, days: number): void { - this.context.sqlite - ?.prepare( - "DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', ?)", - ) - .run(userId, `-${days} days`); + async pruneFiringsOlderThan(userId: string, days: number): Promise { + await this.context.drizzle + .delete(alertFirings) + .where( + and( + eq(alertFirings.userId, userId), + lt(alertFirings.firedAt, sqlTimestampDaysAgo(days)), + ), + ); } async deleteByUserId(userId: string): Promise<{ diff --git a/src/backend/database/repositories/database-context.ts b/src/backend/database/repositories/database-context.ts index 7666cc41..4570897b 100644 --- a/src/backend/database/repositories/database-context.ts +++ b/src/backend/database/repositories/database-context.ts @@ -1,9 +1,22 @@ import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; -import type { Database as BetterSqliteDatabase } from "better-sqlite3"; import type * as schema from "../db/schema.js"; +/** + * Engines the repository layer can run against. SQLite is the only one wired up + * today; the alias exists so that adding another is a change in one place + * rather than a hunt for string literals. + */ +export type DatabaseDialect = "sqlite"; + +/** + * What a repository is allowed to touch. + * + * Deliberately drizzle-only: with no raw driver handle here, no repository can + * reach for engine-specific SQL. Retention queries that previously needed + * `datetime('now', ?)` compute their cutoff in JS instead — see + * ./sql-timestamp.ts. + */ export interface DatabaseContext { - dialect: "sqlite"; + dialect: DatabaseDialect; drizzle: BetterSQLite3Database; - sqlite?: BetterSqliteDatabase; } diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index 42dd08d8..4d25b665 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -49,7 +49,6 @@ export function createCurrentRepositoryContext(): DatabaseContext { return { dialect: "sqlite", drizzle: getDb(), - sqlite: getSqlite(), }; } @@ -59,6 +58,12 @@ export function createCurrentRepositoryWriteHook( return () => DatabaseSaveTrigger.forceSave(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(); } diff --git a/src/backend/database/repositories/host-health-repository.ts b/src/backend/database/repositories/host-health-repository.ts index 5aac9308..31ff776e 100644 --- a/src/backend/database/repositories/host-health-repository.ts +++ b/src/backend/database/repositories/host-health-repository.ts @@ -1,4 +1,4 @@ -import { and, desc, eq } from "drizzle-orm"; +import { and, desc, eq, notInArray } from "drizzle-orm"; import { hostHealthChecks, hostHealthHistory } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; @@ -94,7 +94,7 @@ export class HostHealthRepository { })), ); - this.pruneHistory(userId, hostId, keep); + await this.pruneHistory(userId, hostId, keep); await this.afterWrite(); return results.length; } @@ -141,21 +141,37 @@ export class HostHealthRepository { }; } - private pruneHistory(userId: string, hostId: number, keep: number): void { - this.context.sqlite - ?.prepare( - `DELETE FROM host_health_history - WHERE id IN ( - SELECT id FROM host_health_history - WHERE user_id = ? AND host_id = ? - AND id NOT IN ( - SELECT id FROM host_health_history - WHERE user_id = ? AND host_id = ? - ORDER BY ts DESC LIMIT ? - ) - )`, - ) - .run(userId, hostId, userId, hostId, keep); + /** Keeps the newest `keep` rows for the host and drops the rest. */ + private async pruneHistory( + userId: string, + hostId: number, + keep: number, + ): Promise { + const scope = and( + eq(hostHealthHistory.userId, userId), + eq(hostHealthHistory.hostId, hostId), + ); + + const retained = await this.context.drizzle + .select({ id: hostHealthHistory.id }) + .from(hostHealthHistory) + .where(scope) + .orderBy(desc(hostHealthHistory.ts)) + .limit(keep); + + // Nothing retained means nothing to keep back, so the scope alone is the + // delete condition. + await this.context.drizzle.delete(hostHealthHistory).where( + retained.length + ? and( + scope, + notInArray( + hostHealthHistory.id, + retained.map((row) => row.id), + ), + ) + : scope, + ); } private async afterWrite(): Promise { diff --git a/src/backend/database/repositories/host-metrics-history-repository.ts b/src/backend/database/repositories/host-metrics-history-repository.ts index cbad4e2f..62bdb0e8 100644 --- a/src/backend/database/repositories/host-metrics-history-repository.ts +++ b/src/backend/database/repositories/host-metrics-history-repository.ts @@ -1,6 +1,7 @@ -import { and, asc, eq, gte, lte } from "drizzle-orm"; +import { and, asc, eq, gte, lt, lte } from "drizzle-orm"; import { hostMetricsHistory } from "../db/schema.js"; import type { DatabaseContext } from "./database-context.js"; +import { sqlTimestampDaysAgo } from "./sql-timestamp.js"; export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect; @@ -32,12 +33,15 @@ export class HostMetricsHistoryRepository { await this.afterWrite(); } - pruneOlderThan(hostId: number, retentionDays: number): void { - this.context.sqlite - ?.prepare( - "DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)", - ) - .run(hostId, `-${retentionDays} days`); + async pruneOlderThan(hostId: number, retentionDays: number): Promise { + await this.context.drizzle + .delete(hostMetricsHistory) + .where( + and( + eq(hostMetricsHistory.hostId, hostId), + lt(hostMetricsHistory.ts, sqlTimestampDaysAgo(retentionDays)), + ), + ); } async listRange( diff --git a/src/backend/database/repositories/sql-timestamp.ts b/src/backend/database/repositories/sql-timestamp.ts new file mode 100644 index 00000000..3d41168e --- /dev/null +++ b/src/backend/database/repositories/sql-timestamp.ts @@ -0,0 +1,20 @@ +/** + * Timestamp columns are stored as text defaulting to `CURRENT_TIMESTAMP`, which + * every supported engine writes as `YYYY-MM-DD HH:MM:SS` in UTC. That format + * sorts lexicographically in time order, so retention cutoffs can be plain + * string comparisons. + * + * Computing the cutoff here rather than with `datetime('now', ?)` keeps the + * queries free of engine-specific date functions. + */ +export function sqlTimestampDaysAgo( + days: number, + now: Date = new Date(), +): string { + const cutoff = new Date(now.getTime() - days * 24 * 60 * 60 * 1000); + return formatSqlTimestamp(cutoff); +} + +export function formatSqlTimestamp(date: Date): string { + return date.toISOString().slice(0, 19).replace("T", " "); +} diff --git a/src/backend/hosts/metrics/alert-engine.ts b/src/backend/hosts/metrics/alert-engine.ts index deface56..eb963f5f 100644 --- a/src/backend/hosts/metrics/alert-engine.ts +++ b/src/backend/hosts/metrics/alert-engine.ts @@ -225,7 +225,7 @@ export class AlertEngine { severity: context.severity, }); - repository.pruneFiringsOlderThan(rule.userId, 30); + await repository.pruneFiringsOlderThan(rule.userId, 30); } catch (err) { statsLogger.warn("Failed to write alert firing", { operation: "alert_firing_insert_error", diff --git a/src/backend/hosts/metrics/index.ts b/src/backend/hosts/metrics/index.ts index 628d8fde..5084922e 100644 --- a/src/backend/hosts/metrics/index.ts +++ b/src/backend/hosts/metrics/index.ts @@ -616,7 +616,7 @@ class PollingManager { }); const retentionDays = this.getRetentionDays(); - repository.pruneOlderThan(hostId, retentionDays); + await repository.pruneOlderThan(hostId, retentionDays); } catch (err) { statsLogger.warn("Failed to write metrics history", { operation: "insert_metrics_history", diff --git a/src/backend/tests/database/repositories/alert-repository.test.ts b/src/backend/tests/database/repositories/alert-repository.test.ts index e5fefb81..97e651ae 100644 --- a/src/backend/tests/database/repositories/alert-repository.test.ts +++ b/src/backend/tests/database/repositories/alert-repository.test.ts @@ -17,7 +17,7 @@ describe("AlertRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, @@ -229,7 +229,7 @@ describe("AlertRepository", () => { expect(unacknowledged.total).toBe(0); await repo.acknowledgeAllFirings("user-1"); - repo.pruneFiringsOlderThan("user-1", 0); + await repo.pruneFiringsOlderThan("user-1", 0); }); it("loads enabled rules and notification channels for the alert engine", async () => { diff --git a/src/backend/tests/database/repositories/api-key-repository.test.ts b/src/backend/tests/database/repositories/api-key-repository.test.ts index 888460f5..f4482ae4 100644 --- a/src/backend/tests/database/repositories/api-key-repository.test.ts +++ b/src/backend/tests/database/repositories/api-key-repository.test.ts @@ -17,7 +17,7 @@ describe("ApiKeyRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/audit-log-repository.test.ts b/src/backend/tests/database/repositories/audit-log-repository.test.ts index 2f1fed7a..e113e89e 100644 --- a/src/backend/tests/database/repositories/audit-log-repository.test.ts +++ b/src/backend/tests/database/repositories/audit-log-repository.test.ts @@ -17,7 +17,7 @@ describe("AuditLogRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts b/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts index 0e3196d7..fecd283c 100644 --- a/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts +++ b/src/backend/tests/database/repositories/c2s-tunnel-preset-repository.test.ts @@ -17,7 +17,7 @@ describe("C2sTunnelPresetRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/command-history-repository.test.ts b/src/backend/tests/database/repositories/command-history-repository.test.ts index ea58d029..714ab2c7 100644 --- a/src/backend/tests/database/repositories/command-history-repository.test.ts +++ b/src/backend/tests/database/repositories/command-history-repository.test.ts @@ -17,7 +17,7 @@ describe("CommandHistoryRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts index a68bb268..6fdcb9b9 100644 --- a/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts +++ b/src/backend/tests/database/repositories/dashboard-service-link-repository.test.ts @@ -17,7 +17,7 @@ describe("DashboardServiceLinkRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts b/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts index 9cbff302..b519c1b6 100644 --- a/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts +++ b/src/backend/tests/database/repositories/dismissed-alert-repository.test.ts @@ -17,7 +17,7 @@ describe("DismissedAlertRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts b/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts index 5ae4405b..25bafec5 100644 --- a/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts +++ b/src/backend/tests/database/repositories/file-manager-bookmark-repository.test.ts @@ -17,7 +17,7 @@ describe("FileManagerBookmarkRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/homepage-item-repository.test.ts b/src/backend/tests/database/repositories/homepage-item-repository.test.ts index 3ea1525e..16e1869e 100644 --- a/src/backend/tests/database/repositories/homepage-item-repository.test.ts +++ b/src/backend/tests/database/repositories/homepage-item-repository.test.ts @@ -17,7 +17,7 @@ describe("HomepageItemRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/homepage-layout-repository.test.ts b/src/backend/tests/database/repositories/homepage-layout-repository.test.ts index 479ae067..e9210ab3 100644 --- a/src/backend/tests/database/repositories/homepage-layout-repository.test.ts +++ b/src/backend/tests/database/repositories/homepage-layout-repository.test.ts @@ -17,7 +17,7 @@ describe("HomepageLayoutRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/host-credential-repositories.test.ts b/src/backend/tests/database/repositories/host-credential-repositories.test.ts index e97dce23..6c969793 100644 --- a/src/backend/tests/database/repositories/host-credential-repositories.test.ts +++ b/src/backend/tests/database/repositories/host-credential-repositories.test.ts @@ -27,7 +27,7 @@ describe("HostRepository and CredentialRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, @@ -197,7 +197,7 @@ describe("HostRepository and CredentialRepository", () => { return { credentials: new CredentialRepository(context, onCredentialWrite), hosts: new HostRepository(context, onHostWrite), - sqlite: context.sqlite!, + sqlite: adapter.raw, }; } diff --git a/src/backend/tests/database/repositories/host-folder-repository.test.ts b/src/backend/tests/database/repositories/host-folder-repository.test.ts index 7b94bea4..edef9cd2 100644 --- a/src/backend/tests/database/repositories/host-folder-repository.test.ts +++ b/src/backend/tests/database/repositories/host-folder-repository.test.ts @@ -22,7 +22,7 @@ describe("HostFolderRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, @@ -170,7 +170,7 @@ describe("HostFolderRepository", () => { return { repository: new HostFolderRepository(context, onWrite), - sqlite: context.sqlite!, + sqlite: adapter.raw, }; } diff --git a/src/backend/tests/database/repositories/host-health-repository.test.ts b/src/backend/tests/database/repositories/host-health-repository.test.ts index 29ff31f6..a73fc54e 100644 --- a/src/backend/tests/database/repositories/host-health-repository.test.ts +++ b/src/backend/tests/database/repositories/host-health-repository.test.ts @@ -17,7 +17,7 @@ describe("HostHealthRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts b/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts index f20f5c42..28ea0e3e 100644 --- a/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts +++ b/src/backend/tests/database/repositories/host-metrics-history-repository.test.ts @@ -17,7 +17,7 @@ describe("HostMetricsHistoryRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE hosts ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, @@ -78,7 +78,7 @@ describe("HostMetricsHistoryRepository", () => { it("prunes old history for a host only", async () => { const repo = await createRepository(); - repo.pruneOlderThan(1, 1); + await repo.pruneOlderThan(1, 1); const rows = await repo.listRange( 1, diff --git a/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts b/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts index cc1ceeba..e76b1c0d 100644 --- a/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts +++ b/src/backend/tests/database/repositories/host-metrics-preference-repository.test.ts @@ -17,7 +17,7 @@ describe("HostMetricsPreferenceRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/host-resolution-repository.test.ts b/src/backend/tests/database/repositories/host-resolution-repository.test.ts index e59ab33a..4292789a 100644 --- a/src/backend/tests/database/repositories/host-resolution-repository.test.ts +++ b/src/backend/tests/database/repositories/host-resolution-repository.test.ts @@ -27,7 +27,7 @@ describe("HostResolutionRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/network-topology-repository.test.ts b/src/backend/tests/database/repositories/network-topology-repository.test.ts index 1c8ad72a..72820118 100644 --- a/src/backend/tests/database/repositories/network-topology-repository.test.ts +++ b/src/backend/tests/database/repositories/network-topology-repository.test.ts @@ -17,7 +17,7 @@ describe("NetworkTopologyRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/open-tab-repository.test.ts b/src/backend/tests/database/repositories/open-tab-repository.test.ts index 0d7f88c9..ab82f683 100644 --- a/src/backend/tests/database/repositories/open-tab-repository.test.ts +++ b/src/backend/tests/database/repositories/open-tab-repository.test.ts @@ -17,7 +17,7 @@ describe("OpenTabRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/opkssh-token-repository.test.ts b/src/backend/tests/database/repositories/opkssh-token-repository.test.ts index 4700e214..861ab109 100644 --- a/src/backend/tests/database/repositories/opkssh-token-repository.test.ts +++ b/src/backend/tests/database/repositories/opkssh-token-repository.test.ts @@ -17,7 +17,7 @@ describe("OpksshTokenRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/rbac-access-repository.test.ts b/src/backend/tests/database/repositories/rbac-access-repository.test.ts index 1cc8e509..c3e64579 100644 --- a/src/backend/tests/database/repositories/rbac-access-repository.test.ts +++ b/src/backend/tests/database/repositories/rbac-access-repository.test.ts @@ -18,7 +18,7 @@ describe("RbacAccessRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/recent-activity-repository.test.ts b/src/backend/tests/database/repositories/recent-activity-repository.test.ts index 52c0d405..245e5d8d 100644 --- a/src/backend/tests/database/repositories/recent-activity-repository.test.ts +++ b/src/backend/tests/database/repositories/recent-activity-repository.test.ts @@ -22,7 +22,7 @@ describe("RecentActivityRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, @@ -59,7 +59,7 @@ describe("RecentActivityRepository", () => { return { repository: new RecentActivityRepository(context, onWrite), - sqlite: context.sqlite!, + sqlite: adapter.raw, }; } diff --git a/src/backend/tests/database/repositories/role-repository.test.ts b/src/backend/tests/database/repositories/role-repository.test.ts index 585f9379..0852ca0b 100644 --- a/src/backend/tests/database/repositories/role-repository.test.ts +++ b/src/backend/tests/database/repositories/role-repository.test.ts @@ -17,7 +17,7 @@ describe("RoleRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/session-recording-repository.test.ts b/src/backend/tests/database/repositories/session-recording-repository.test.ts index 7d2437cf..186128a6 100644 --- a/src/backend/tests/database/repositories/session-recording-repository.test.ts +++ b/src/backend/tests/database/repositories/session-recording-repository.test.ts @@ -17,7 +17,7 @@ describe("SessionRecordingRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/session-share-repository.test.ts b/src/backend/tests/database/repositories/session-share-repository.test.ts index a7cc0318..f0a1254f 100644 --- a/src/backend/tests/database/repositories/session-share-repository.test.ts +++ b/src/backend/tests/database/repositories/session-share-repository.test.ts @@ -17,7 +17,7 @@ describe("SessionShareRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/settings-repository.test.ts b/src/backend/tests/database/repositories/settings-repository.test.ts index 94e1ca9a..f4520311 100644 --- a/src/backend/tests/database/repositories/settings-repository.test.ts +++ b/src/backend/tests/database/repositories/settings-repository.test.ts @@ -15,7 +15,7 @@ describe("SettingsRepository", () => { async function createRepository(): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE settings ( key TEXT PRIMARY KEY, value TEXT NOT NULL diff --git a/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts index 75acf718..8288ca90 100644 --- a/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts +++ b/src/backend/tests/database/repositories/shared-host-secrets-repository.test.ts @@ -22,7 +22,7 @@ describe("SharedHostSecretsRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite!.exec(` + adapter.exec(` CREATE TABLE host_access ( id INTEGER PRIMARY KEY AUTOINCREMENT, host_id INTEGER NOT NULL, @@ -84,7 +84,7 @@ describe("SharedHostSecretsRepository", () => { return { repository: new SharedHostSecretsRepository(context, onWrite), - sqlite: context.sqlite!, + sqlite: adapter.raw, }; } diff --git a/src/backend/tests/database/repositories/snippet-repository.test.ts b/src/backend/tests/database/repositories/snippet-repository.test.ts index f4d51377..25235600 100644 --- a/src/backend/tests/database/repositories/snippet-repository.test.ts +++ b/src/backend/tests/database/repositories/snippet-repository.test.ts @@ -20,7 +20,7 @@ describe("SnippetRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE snippets ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, @@ -63,7 +63,7 @@ describe("SnippetRepository", () => { return { repository: new SnippetRepository(context, onWrite), - sqlite: context.sqlite!, + sqlite: adapter.raw, }; } diff --git a/src/backend/tests/database/repositories/sql-timestamp.test.ts b/src/backend/tests/database/repositories/sql-timestamp.test.ts new file mode 100644 index 00000000..7707ad75 --- /dev/null +++ b/src/backend/tests/database/repositories/sql-timestamp.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { + formatSqlTimestamp, + sqlTimestampDaysAgo, +} from "../../../database/repositories/sql-timestamp.js"; + +describe("sql timestamps", () => { + it("matches the CURRENT_TIMESTAMP text format", () => { + expect(formatSqlTimestamp(new Date("2026-07-28T01:23:45.678Z"))).toBe( + "2026-07-28 01:23:45", + ); + }); + + it("subtracts whole days in UTC", () => { + const now = new Date("2026-07-28T01:23:45.000Z"); + + expect(sqlTimestampDaysAgo(7, now)).toBe("2026-07-21 01:23:45"); + expect(sqlTimestampDaysAgo(30, now)).toBe("2026-06-28 01:23:45"); + expect(sqlTimestampDaysAgo(0, now)).toBe("2026-07-28 01:23:45"); + }); + + it("crosses month and year boundaries", () => { + expect(sqlTimestampDaysAgo(1, new Date("2026-01-01T00:00:00.000Z"))).toBe( + "2025-12-31 00:00:00", + ); + }); + + it("stays lexicographically ordered, which is what the cutoff comparison relies on", () => { + const now = new Date("2026-07-28T01:23:45.000Z"); + const older = sqlTimestampDaysAgo(30, now); + const newer = sqlTimestampDaysAgo(7, now); + + expect(older < newer).toBe(true); + expect(newer < formatSqlTimestamp(now)).toBe(true); + }); +}); diff --git a/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts b/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts index 21b25aa0..d8308ce7 100644 --- a/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts +++ b/src/backend/tests/database/repositories/ssh-credential-usage-repository.test.ts @@ -17,7 +17,7 @@ describe("SshCredentialUsageRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/sso-provider-repository.test.ts b/src/backend/tests/database/repositories/sso-provider-repository.test.ts index ec0d7cd0..0ed50a19 100644 --- a/src/backend/tests/database/repositories/sso-provider-repository.test.ts +++ b/src/backend/tests/database/repositories/sso-provider-repository.test.ts @@ -19,8 +19,8 @@ describe("SsoProviderRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - sqlite = context.sqlite; - context.sqlite?.exec(` + sqlite = adapter.raw; + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts index cd85f2d9..152de5c8 100644 --- a/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts +++ b/src/backend/tests/database/repositories/sync-tombstone-repository.test.ts @@ -17,7 +17,7 @@ describe("SyncTombstoneRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, @@ -102,7 +102,7 @@ describe("SyncTombstoneRepository", () => { const adapterLocal = new TestSqliteDatabase(); adapter = adapterLocal; const context = await adapterLocal.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts b/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts index 26d2119d..ef157f36 100644 --- a/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts +++ b/src/backend/tests/database/repositories/termix-identity-ca-repository.test.ts @@ -23,7 +23,7 @@ describe("TermixIdentityCaRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, @@ -63,7 +63,7 @@ describe("TermixIdentityCaRepository", () => { return { repo: new TermixIdentityCaRepository(context, onWrite), - sqlite: context.sqlite!, + sqlite: adapter.raw, onWrite, }; } diff --git a/src/backend/tests/database/repositories/termix-identity-repository.test.ts b/src/backend/tests/database/repositories/termix-identity-repository.test.ts index b9c2ad21..12d8012d 100644 --- a/src/backend/tests/database/repositories/termix-identity-repository.test.ts +++ b/src/backend/tests/database/repositories/termix-identity-repository.test.ts @@ -18,7 +18,7 @@ describe("TermixIdentityRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/test-support.ts b/src/backend/tests/database/repositories/test-support.ts index 6aab29fd..3dab8a03 100644 --- a/src/backend/tests/database/repositories/test-support.ts +++ b/src/backend/tests/database/repositories/test-support.ts @@ -15,12 +15,31 @@ export class TestSqliteDatabase { this.context = { dialect: "sqlite", drizzle: drizzle(this.sqlite, { schema }), - sqlite: this.sqlite, }; return this.context; } + /** + * Schema setup for tests. Lives on the fixture rather than on + * DatabaseContext, which is drizzle-only so that no repository can reach for + * engine-specific SQL. + */ + /** Raw handle for assertions that read the database directly. Tests only. */ + get raw(): Database.Database { + if (!this.sqlite) { + throw new Error("connect() must be called before raw access"); + } + return this.sqlite; + } + + exec(sql: string): void { + if (!this.sqlite) { + throw new Error("connect() must be called before exec()"); + } + this.sqlite.exec(sql); + } + async close(): Promise { if (this.sqlite) { this.sqlite.close(); diff --git a/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts b/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts index a866b54d..6efa9cdc 100644 --- a/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts +++ b/src/backend/tests/database/repositories/tmux-session-tag-repository.test.ts @@ -17,7 +17,7 @@ describe("TmuxSessionTagRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/transfer-recent-repository.test.ts b/src/backend/tests/database/repositories/transfer-recent-repository.test.ts index 5ad97830..0e887af1 100644 --- a/src/backend/tests/database/repositories/transfer-recent-repository.test.ts +++ b/src/backend/tests/database/repositories/transfer-recent-repository.test.ts @@ -17,7 +17,7 @@ describe("TransferRecentRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/trusted-device-repository.test.ts b/src/backend/tests/database/repositories/trusted-device-repository.test.ts index 290bc10c..d7a22198 100644 --- a/src/backend/tests/database/repositories/trusted-device-repository.test.ts +++ b/src/backend/tests/database/repositories/trusted-device-repository.test.ts @@ -17,7 +17,7 @@ describe("TrustedDeviceRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/user-data-export-repository.test.ts b/src/backend/tests/database/repositories/user-data-export-repository.test.ts index 2a2796f3..2db650fe 100644 --- a/src/backend/tests/database/repositories/user-data-export-repository.test.ts +++ b/src/backend/tests/database/repositories/user-data-export-repository.test.ts @@ -15,7 +15,7 @@ describe("UserDataExportRepository", () => { async function createRepository(): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/user-preference-repository.test.ts b/src/backend/tests/database/repositories/user-preference-repository.test.ts index 307fa363..627ffae8 100644 --- a/src/backend/tests/database/repositories/user-preference-repository.test.ts +++ b/src/backend/tests/database/repositories/user-preference-repository.test.ts @@ -17,7 +17,7 @@ describe("UserPreferenceRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/user-session-repositories.test.ts b/src/backend/tests/database/repositories/user-session-repositories.test.ts index e26c508a..40daa580 100644 --- a/src/backend/tests/database/repositories/user-session-repositories.test.ts +++ b/src/backend/tests/database/repositories/user-session-repositories.test.ts @@ -35,7 +35,7 @@ describe("UserRepository and SessionRepository", () => { }> { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/vault-profile-repository.test.ts b/src/backend/tests/database/repositories/vault-profile-repository.test.ts index c0ade51e..e0cbdfbf 100644 --- a/src/backend/tests/database/repositories/vault-profile-repository.test.ts +++ b/src/backend/tests/database/repositories/vault-profile-repository.test.ts @@ -17,7 +17,7 @@ describe("VaultProfileRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL, diff --git a/src/backend/tests/database/repositories/vault-token-repository.test.ts b/src/backend/tests/database/repositories/vault-token-repository.test.ts index a67d104d..35b13020 100644 --- a/src/backend/tests/database/repositories/vault-token-repository.test.ts +++ b/src/backend/tests/database/repositories/vault-token-repository.test.ts @@ -17,7 +17,7 @@ describe("VaultTokenRepository", () => { ): Promise { adapter = new TestSqliteDatabase(); const context = await adapter.connect(); - context.sqlite?.exec(` + adapter.exec(` CREATE TABLE users ( id TEXT PRIMARY KEY, username TEXT NOT NULL,