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 {
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;
}
+6 -1
View File
@@ -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", " ");
}