mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-08-30 02:41:34 +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", " ");
|
||||
}
|
||||
Reference in New Issue
Block a user