mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-23 06:26:51 +00:00
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:
@@ -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<void> {
|
||||
await this.context.drizzle
|
||||
.delete(alertFirings)
|
||||
.where(
|
||||
and(
|
||||
eq(alertFirings.userId, userId),
|
||||
lt(alertFirings.firedAt, sqlTimestampDaysAgo(days)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async deleteByUserId(userId: string): Promise<{
|
||||
|
||||
@@ -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<typeof schema>;
|
||||
sqlite?: BetterSqliteDatabase;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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<void> {
|
||||
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<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 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<void> {
|
||||
await this.context.drizzle
|
||||
.delete(hostMetricsHistory)
|
||||
.where(
|
||||
and(
|
||||
eq(hostMetricsHistory.hostId, hostId),
|
||||
lt(hostMetricsHistory.ts, sqlTimestampDaysAgo(retentionDays)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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", " ");
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("AlertRepository", () => {
|
||||
): Promise<AlertRepository> {
|
||||
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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("AuditLogRepository", () => {
|
||||
): Promise<AuditLogRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("C2sTunnelPresetRepository", () => {
|
||||
): Promise<C2sTunnelPresetRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("CommandHistoryRepository", () => {
|
||||
): Promise<CommandHistoryRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("DashboardServiceLinkRepository", () => {
|
||||
): Promise<DashboardServiceLinkRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("DismissedAlertRepository", () => {
|
||||
): Promise<DismissedAlertRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("FileManagerBookmarkRepository", () => {
|
||||
): Promise<FileManagerBookmarkRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("HomepageItemRepository", () => {
|
||||
): Promise<HomepageItemRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("HomepageLayoutRepository", () => {
|
||||
): Promise<HomepageLayoutRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("HostHealthRepository", () => {
|
||||
): Promise<HostHealthRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("HostMetricsHistoryRepository", () => {
|
||||
): Promise<HostMetricsHistoryRepository> {
|
||||
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,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("HostMetricsPreferenceRepository", () => {
|
||||
): Promise<HostMetricsPreferenceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -27,7 +27,7 @@ describe("HostResolutionRepository", () => {
|
||||
): Promise<HostResolutionRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("NetworkTopologyRepository", () => {
|
||||
): Promise<NetworkTopologyRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("OpenTabRepository", () => {
|
||||
): Promise<OpenTabRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("OpksshTokenRepository", () => {
|
||||
): Promise<OpksshTokenRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -18,7 +18,7 @@ describe("RbacAccessRepository", () => {
|
||||
): Promise<RbacAccessRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("RoleRepository", () => {
|
||||
): Promise<RoleRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("SessionRecordingRepository", () => {
|
||||
): Promise<SessionRecordingRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("SessionShareRepository", () => {
|
||||
): Promise<SessionShareRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("SettingsRepository", () => {
|
||||
async function createRepository(): Promise<SettingsRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -19,8 +19,8 @@ describe("SsoProviderRepository", () => {
|
||||
): Promise<SsoProviderRepository> {
|
||||
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,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("SyncTombstoneRepository", () => {
|
||||
): Promise<SyncTombstoneRepository> {
|
||||
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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<void> {
|
||||
if (this.sqlite) {
|
||||
this.sqlite.close();
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("TmuxSessionTagRepository", () => {
|
||||
): Promise<TmuxSessionTagRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("TransferRecentRepository", () => {
|
||||
): Promise<TransferRecentRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("UserDataExportRepository", () => {
|
||||
async function createRepository(): Promise<UserDataExportRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("UserPreferenceRepository", () => {
|
||||
): Promise<UserPreferenceRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("VaultProfileRepository", () => {
|
||||
): Promise<VaultProfileRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
@@ -17,7 +17,7 @@ describe("VaultTokenRepository", () => {
|
||||
): Promise<VaultTokenRepository> {
|
||||
adapter = new TestSqliteDatabase();
|
||||
const context = await adapter.connect();
|
||||
context.sqlite?.exec(`
|
||||
adapter.exec(`
|
||||
CREATE TABLE users (
|
||||
id TEXT PRIMARY KEY,
|
||||
username TEXT NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user