make the repository layer engine-agnostic (#1127)

DatabaseContext handed every repository a raw better-sqlite3 handle alongside
drizzle, and three of them used it for retention queries built on datetime('now',
?) — a SQLite-only function. That handle is the one thing standing between the
repository layer and a second engine.

Drop it. The two time-based prunes compute their cutoff in JS against the
CURRENT_TIMESTAMP text format, which every engine writes the same way and which
compares correctly as a string; the health-history prune becomes a select of the
rows to keep followed by a NOT IN delete. All three turn async, so their two
callers await them.

Name the dialect rather than repeating a string literal, so adding an engine is
one edit instead of a search.

Tests built their schema through context.sqlite?.exec(). Optional chaining meant
removing the field type-checked cleanly and then silently created no tables, so
the fixture now owns exec() and a raw handle for direct assertions — schema setup
belongs to the test harness, not to the interface repositories consume.

No behaviour change, and no Postgres yet: this only removes the coupling that
would have to be undone first.
This commit is contained in:
ZacharyZcR
2026-07-28 16:59:10 +08:00
committed by GitHub
parent cdb030dff1
commit 64a80f411a
50 changed files with 205 additions and 88 deletions
@@ -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 { import {
alertFirings, alertFirings,
alertRuleChannels, alertRuleChannels,
@@ -7,6 +7,7 @@ import {
notificationChannels, notificationChannels,
} from "../db/schema.js"; } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
type AlertRuleRecord = typeof alertRules.$inferSelect; type AlertRuleRecord = typeof alertRules.$inferSelect;
type NotificationChannelRecord = typeof notificationChannels.$inferSelect; type NotificationChannelRecord = typeof notificationChannels.$inferSelect;
@@ -411,12 +412,15 @@ export class AlertRepository {
await this.afterWrite(); await this.afterWrite();
} }
pruneFiringsOlderThan(userId: string, days: number): void { async pruneFiringsOlderThan(userId: string, days: number): Promise<void> {
this.context.sqlite await this.context.drizzle
?.prepare( .delete(alertFirings)
"DELETE FROM alert_firings WHERE user_id = ? AND fired_at < datetime('now', ?)", .where(
) and(
.run(userId, `-${days} days`); eq(alertFirings.userId, userId),
lt(alertFirings.firedAt, sqlTimestampDaysAgo(days)),
),
);
} }
async deleteByUserId(userId: string): Promise<{ async deleteByUserId(userId: string): Promise<{
@@ -1,9 +1,22 @@
import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
import type { Database as BetterSqliteDatabase } from "better-sqlite3";
import type * as schema from "../db/schema.js"; 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 { export interface DatabaseContext {
dialect: "sqlite"; dialect: DatabaseDialect;
drizzle: BetterSQLite3Database<typeof schema>; drizzle: BetterSQLite3Database<typeof schema>;
sqlite?: BetterSqliteDatabase;
} }
+6 -1
View File
@@ -49,7 +49,6 @@ export function createCurrentRepositoryContext(): DatabaseContext {
return { return {
dialect: "sqlite", dialect: "sqlite",
drizzle: getDb(), drizzle: getDb(),
sqlite: getSqlite(),
}; };
} }
@@ -59,6 +58,12 @@ export function createCurrentRepositoryWriteHook(
return () => DatabaseSaveTrigger.forceSave(reason); 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() { export function getCurrentRepositorySqlite() {
return getSqlite(); return getSqlite();
} }
@@ -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 { hostHealthChecks, hostHealthHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.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(); await this.afterWrite();
return results.length; return results.length;
} }
@@ -141,21 +141,37 @@ export class HostHealthRepository {
}; };
} }
private pruneHistory(userId: string, hostId: number, keep: number): void { /** Keeps the newest `keep` rows for the host and drops the rest. */
this.context.sqlite private async pruneHistory(
?.prepare( userId: string,
`DELETE FROM host_health_history hostId: number,
WHERE id IN ( keep: number,
SELECT id FROM host_health_history ): Promise<void> {
WHERE user_id = ? AND host_id = ? const scope = and(
AND id NOT IN ( eq(hostHealthHistory.userId, userId),
SELECT id FROM host_health_history eq(hostHealthHistory.hostId, hostId),
WHERE user_id = ? AND host_id = ? );
ORDER BY ts DESC LIMIT ?
) const retained = await this.context.drizzle
)`, .select({ id: hostHealthHistory.id })
) .from(hostHealthHistory)
.run(userId, hostId, userId, hostId, keep); .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<void> { private async afterWrite(): Promise<void> {
@@ -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 { hostMetricsHistory } from "../db/schema.js";
import type { DatabaseContext } from "./database-context.js"; import type { DatabaseContext } from "./database-context.js";
import { sqlTimestampDaysAgo } from "./sql-timestamp.js";
export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect; export type HostMetricsHistoryRecord = typeof hostMetricsHistory.$inferSelect;
@@ -32,12 +33,15 @@ export class HostMetricsHistoryRepository {
await this.afterWrite(); await this.afterWrite();
} }
pruneOlderThan(hostId: number, retentionDays: number): void { async pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
this.context.sqlite await this.context.drizzle
?.prepare( .delete(hostMetricsHistory)
"DELETE FROM host_metrics_history WHERE host_id = ? AND ts < datetime('now', ?)", .where(
) and(
.run(hostId, `-${retentionDays} days`); eq(hostMetricsHistory.hostId, hostId),
lt(hostMetricsHistory.ts, sqlTimestampDaysAgo(retentionDays)),
),
);
} }
async listRange( async listRange(
@@ -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", " ");
}
+1 -1
View File
@@ -225,7 +225,7 @@ export class AlertEngine {
severity: context.severity, severity: context.severity,
}); });
repository.pruneFiringsOlderThan(rule.userId, 30); await repository.pruneFiringsOlderThan(rule.userId, 30);
} catch (err) { } catch (err) {
statsLogger.warn("Failed to write alert firing", { statsLogger.warn("Failed to write alert firing", {
operation: "alert_firing_insert_error", operation: "alert_firing_insert_error",
+1 -1
View File
@@ -616,7 +616,7 @@ class PollingManager {
}); });
const retentionDays = this.getRetentionDays(); const retentionDays = this.getRetentionDays();
repository.pruneOlderThan(hostId, retentionDays); await repository.pruneOlderThan(hostId, retentionDays);
} catch (err) { } catch (err) {
statsLogger.warn("Failed to write metrics history", { statsLogger.warn("Failed to write metrics history", {
operation: "insert_metrics_history", operation: "insert_metrics_history",
@@ -17,7 +17,7 @@ describe("AlertRepository", () => {
): Promise<AlertRepository> { ): Promise<AlertRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -229,7 +229,7 @@ describe("AlertRepository", () => {
expect(unacknowledged.total).toBe(0); expect(unacknowledged.total).toBe(0);
await repo.acknowledgeAllFirings("user-1"); 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 () => { it("loads enabled rules and notification channels for the alert engine", async () => {
@@ -17,7 +17,7 @@ describe("ApiKeyRepository", () => {
}> { }> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("AuditLogRepository", () => {
): Promise<AuditLogRepository> { ): Promise<AuditLogRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("C2sTunnelPresetRepository", () => {
): Promise<C2sTunnelPresetRepository> { ): Promise<C2sTunnelPresetRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("CommandHistoryRepository", () => {
): Promise<CommandHistoryRepository> { ): Promise<CommandHistoryRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("DashboardServiceLinkRepository", () => {
): Promise<DashboardServiceLinkRepository> { ): Promise<DashboardServiceLinkRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("DismissedAlertRepository", () => {
): Promise<DismissedAlertRepository> { ): Promise<DismissedAlertRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("FileManagerBookmarkRepository", () => {
): Promise<FileManagerBookmarkRepository> { ): Promise<FileManagerBookmarkRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("HomepageItemRepository", () => {
): Promise<HomepageItemRepository> { ): Promise<HomepageItemRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("HomepageLayoutRepository", () => {
): Promise<HomepageLayoutRepository> { ): Promise<HomepageLayoutRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -27,7 +27,7 @@ describe("HostRepository and CredentialRepository", () => {
}> { }> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -197,7 +197,7 @@ describe("HostRepository and CredentialRepository", () => {
return { return {
credentials: new CredentialRepository(context, onCredentialWrite), credentials: new CredentialRepository(context, onCredentialWrite),
hosts: new HostRepository(context, onHostWrite), hosts: new HostRepository(context, onHostWrite),
sqlite: context.sqlite!, sqlite: adapter.raw,
}; };
} }
@@ -22,7 +22,7 @@ describe("HostFolderRepository", () => {
}> { }> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -170,7 +170,7 @@ describe("HostFolderRepository", () => {
return { return {
repository: new HostFolderRepository(context, onWrite), repository: new HostFolderRepository(context, onWrite),
sqlite: context.sqlite!, sqlite: adapter.raw,
}; };
} }
@@ -17,7 +17,7 @@ describe("HostHealthRepository", () => {
): Promise<HostHealthRepository> { ): Promise<HostHealthRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("HostMetricsHistoryRepository", () => {
): Promise<HostMetricsHistoryRepository> { ): Promise<HostMetricsHistoryRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE hosts ( CREATE TABLE hosts (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -78,7 +78,7 @@ describe("HostMetricsHistoryRepository", () => {
it("prunes old history for a host only", async () => { it("prunes old history for a host only", async () => {
const repo = await createRepository(); const repo = await createRepository();
repo.pruneOlderThan(1, 1); await repo.pruneOlderThan(1, 1);
const rows = await repo.listRange( const rows = await repo.listRange(
1, 1,
@@ -17,7 +17,7 @@ describe("HostMetricsPreferenceRepository", () => {
): Promise<HostMetricsPreferenceRepository> { ): Promise<HostMetricsPreferenceRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -27,7 +27,7 @@ describe("HostResolutionRepository", () => {
): Promise<HostResolutionRepository> { ): Promise<HostResolutionRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("NetworkTopologyRepository", () => {
): Promise<NetworkTopologyRepository> { ): Promise<NetworkTopologyRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("OpenTabRepository", () => {
): Promise<OpenTabRepository> { ): Promise<OpenTabRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("OpksshTokenRepository", () => {
): Promise<OpksshTokenRepository> { ): Promise<OpksshTokenRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -18,7 +18,7 @@ describe("RbacAccessRepository", () => {
): Promise<RbacAccessRepository> { ): Promise<RbacAccessRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -22,7 +22,7 @@ describe("RecentActivityRepository", () => {
}> { }> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -59,7 +59,7 @@ describe("RecentActivityRepository", () => {
return { return {
repository: new RecentActivityRepository(context, onWrite), repository: new RecentActivityRepository(context, onWrite),
sqlite: context.sqlite!, sqlite: adapter.raw,
}; };
} }
@@ -17,7 +17,7 @@ describe("RoleRepository", () => {
): Promise<RoleRepository> { ): Promise<RoleRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("SessionRecordingRepository", () => {
): Promise<SessionRecordingRepository> { ): Promise<SessionRecordingRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("SessionShareRepository", () => {
): Promise<SessionShareRepository> { ): Promise<SessionShareRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -15,7 +15,7 @@ describe("SettingsRepository", () => {
async function createRepository(): Promise<SettingsRepository> { async function createRepository(): Promise<SettingsRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE settings ( CREATE TABLE settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL value TEXT NOT NULL
@@ -22,7 +22,7 @@ describe("SharedHostSecretsRepository", () => {
}> { }> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite!.exec(` adapter.exec(`
CREATE TABLE host_access ( CREATE TABLE host_access (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL, host_id INTEGER NOT NULL,
@@ -84,7 +84,7 @@ describe("SharedHostSecretsRepository", () => {
return { return {
repository: new SharedHostSecretsRepository(context, onWrite), repository: new SharedHostSecretsRepository(context, onWrite),
sqlite: context.sqlite!, sqlite: adapter.raw,
}; };
} }
@@ -20,7 +20,7 @@ describe("SnippetRepository", () => {
}> { }> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE snippets ( CREATE TABLE snippets (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
@@ -63,7 +63,7 @@ describe("SnippetRepository", () => {
return { return {
repository: new SnippetRepository(context, onWrite), repository: new SnippetRepository(context, onWrite),
sqlite: context.sqlite!, sqlite: adapter.raw,
}; };
} }
@@ -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);
});
});
@@ -17,7 +17,7 @@ describe("SshCredentialUsageRepository", () => {
): Promise<SshCredentialUsageRepository> { ): Promise<SshCredentialUsageRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -19,8 +19,8 @@ describe("SsoProviderRepository", () => {
): Promise<SsoProviderRepository> { ): Promise<SsoProviderRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
sqlite = context.sqlite; sqlite = adapter.raw;
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("SyncTombstoneRepository", () => {
): Promise<SyncTombstoneRepository> { ): Promise<SyncTombstoneRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -102,7 +102,7 @@ describe("SyncTombstoneRepository", () => {
const adapterLocal = new TestSqliteDatabase(); const adapterLocal = new TestSqliteDatabase();
adapter = adapterLocal; adapter = adapterLocal;
const context = await adapterLocal.connect(); const context = await adapterLocal.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -23,7 +23,7 @@ describe("TermixIdentityCaRepository", () => {
}> { }> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -63,7 +63,7 @@ describe("TermixIdentityCaRepository", () => {
return { return {
repo: new TermixIdentityCaRepository(context, onWrite), repo: new TermixIdentityCaRepository(context, onWrite),
sqlite: context.sqlite!, sqlite: adapter.raw,
onWrite, onWrite,
}; };
} }
@@ -18,7 +18,7 @@ describe("TermixIdentityRepository", () => {
}> { }> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -15,12 +15,31 @@ export class TestSqliteDatabase {
this.context = { this.context = {
dialect: "sqlite", dialect: "sqlite",
drizzle: drizzle(this.sqlite, { schema }), drizzle: drizzle(this.sqlite, { schema }),
sqlite: this.sqlite,
}; };
return this.context; 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<void> { async close(): Promise<void> {
if (this.sqlite) { if (this.sqlite) {
this.sqlite.close(); this.sqlite.close();
@@ -17,7 +17,7 @@ describe("TmuxSessionTagRepository", () => {
): Promise<TmuxSessionTagRepository> { ): Promise<TmuxSessionTagRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("TransferRecentRepository", () => {
): Promise<TransferRecentRepository> { ): Promise<TransferRecentRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("TrustedDeviceRepository", () => {
}> { }> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -15,7 +15,7 @@ describe("UserDataExportRepository", () => {
async function createRepository(): Promise<UserDataExportRepository> { async function createRepository(): Promise<UserDataExportRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("UserPreferenceRepository", () => {
): Promise<UserPreferenceRepository> { ): Promise<UserPreferenceRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -35,7 +35,7 @@ describe("UserRepository and SessionRepository", () => {
}> { }> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("VaultProfileRepository", () => {
): Promise<VaultProfileRepository> { ): Promise<VaultProfileRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,
@@ -17,7 +17,7 @@ describe("VaultTokenRepository", () => {
): Promise<VaultTokenRepository> { ): Promise<VaultTokenRepository> {
adapter = new TestSqliteDatabase(); adapter = new TestSqliteDatabase();
const context = await adapter.connect(); const context = await adapter.connect();
context.sqlite?.exec(` adapter.exec(`
CREATE TABLE users ( CREATE TABLE users (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
username TEXT NOT NULL, username TEXT NOT NULL,