dimensionlab-website/apps/web/src/lib/server/db/dashboard-store.ts

261 lines
7.1 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { desc, eq } from "drizzle-orm";
import {
type DashboardDocument,
type DashboardValidationFailure,
} from "$lib/model";
import {
type DashboardDatabaseConnection,
openDashboardDatabase,
} from "./connection";
import {
dashboardDocuments,
dashboardRevisions,
type DashboardRevisionOperation,
} from "./schema";
import { migrateDashboardDocumentForPersistence } from "./model-migrations";
const DEFAULT_DASHBOARD_ID = "primary";
const DEFAULT_ACTOR = "system";
export interface DashboardRevision {
id: string;
dashboardId: string;
schemaVersion: string;
document: DashboardDocument;
actor: string;
message: string | null;
operation: DashboardRevisionOperation;
sourceRevisionId: string | null;
createdAt: Date;
}
export interface ActiveDashboard {
dashboardId: string;
currentRevisionId: string;
document: DashboardDocument;
revision: DashboardRevision;
createdAt: Date;
updatedAt: Date;
}
export interface DashboardWriteMetadata {
actor?: string;
message?: string;
}
export interface DashboardStoreOptions {
databaseUrl?: string;
dashboardId?: string;
}
export interface DashboardStore {
getActiveDashboard(): ActiveDashboard | null;
getRevision(revisionId: string): DashboardRevision | null;
listRevisions(limit?: number): DashboardRevision[];
seedDashboardIfEmpty(
document: DashboardDocument,
metadata?: DashboardWriteMetadata,
): DashboardRevision;
commitDashboard(
document: DashboardDocument,
metadata?: DashboardWriteMetadata,
): DashboardRevision;
rollbackToRevision(
revisionId: string,
metadata?: DashboardWriteMetadata,
): DashboardRevision;
close(): void;
}
export class DashboardPersistenceValidationError extends Error {
readonly failure: DashboardValidationFailure;
constructor(failure: DashboardValidationFailure) {
super(`Invalid dashboard document: ${failure.errors.join("; ")}`);
this.name = "DashboardPersistenceValidationError";
this.failure = failure;
}
}
export class DashboardRevisionNotFoundError extends Error {
constructor(revisionId: string) {
super(`Dashboard revision not found: ${revisionId}`);
this.name = "DashboardRevisionNotFoundError";
}
}
export function createDashboardStore(
options: DashboardStoreOptions = {},
): DashboardStore {
const connection = openDashboardDatabase(options.databaseUrl);
return new SqliteDashboardStore(connection, options.dashboardId || DEFAULT_DASHBOARD_ID);
}
class SqliteDashboardStore implements DashboardStore {
constructor(
private readonly connection: DashboardDatabaseConnection,
private readonly dashboardId: string,
) {}
getActiveDashboard(): ActiveDashboard | null {
const dashboard = this.connection.db
.select()
.from(dashboardDocuments)
.where(eq(dashboardDocuments.id, this.dashboardId))
.get();
if (!dashboard?.currentRevisionId) return null;
const revision = this.getRevision(dashboard.currentRevisionId);
if (!revision) return null;
return {
dashboardId: dashboard.id,
currentRevisionId: dashboard.currentRevisionId,
document: revision.document,
revision,
createdAt: dashboard.createdAt,
updatedAt: dashboard.updatedAt,
};
}
getRevision(revisionId: string): DashboardRevision | null {
const row = this.connection.db
.select()
.from(dashboardRevisions)
.where(eq(dashboardRevisions.id, revisionId))
.get();
return row ? toRevision(row) : null;
}
listRevisions(limit = 50): DashboardRevision[] {
return this.connection.db
.select()
.from(dashboardRevisions)
.where(eq(dashboardRevisions.dashboardId, this.dashboardId))
.orderBy(desc(dashboardRevisions.createdAt))
.limit(limit)
.all()
.map(toRevision);
}
seedDashboardIfEmpty(
document: DashboardDocument,
metadata: DashboardWriteMetadata = {},
): DashboardRevision {
const active = this.getActiveDashboard();
if (active) return active.revision;
return this.writeRevision(document, "seed", metadata);
}
commitDashboard(
document: DashboardDocument,
metadata: DashboardWriteMetadata = {},
): DashboardRevision {
return this.writeRevision(document, "commit", metadata);
}
rollbackToRevision(
revisionId: string,
metadata: DashboardWriteMetadata = {},
): DashboardRevision {
const revision = this.getRevision(revisionId);
if (!revision || revision.dashboardId !== this.dashboardId) {
throw new DashboardRevisionNotFoundError(revisionId);
}
return this.writeRevision(revision.document, "rollback", metadata, revision.id);
}
close() {
this.connection.close();
}
private writeRevision(
document: DashboardDocument,
operation: DashboardRevisionOperation,
metadata: DashboardWriteMetadata,
sourceRevisionId: string | null = null,
): DashboardRevision {
const migration = migrateDashboardDocumentForPersistence(document);
if (!migration.valid) {
throw new DashboardPersistenceValidationError(migration.failure);
}
const now = this.nextRevisionTimestamp();
const revision: DashboardRevision = {
id: randomUUID(),
dashboardId: this.dashboardId,
schemaVersion: migration.document.schemaVersion,
document: structuredClone(migration.document),
actor: metadata.actor || DEFAULT_ACTOR,
message: metadata.message || null,
operation,
sourceRevisionId,
createdAt: now,
};
this.connection.db.transaction((tx) => {
tx.insert(dashboardRevisions).values({
id: revision.id,
dashboardId: revision.dashboardId,
schemaVersion: revision.schemaVersion,
document: revision.document,
actor: revision.actor,
message: revision.message,
operation: revision.operation,
sourceRevisionId: revision.sourceRevisionId,
createdAt: revision.createdAt,
}).run();
tx.insert(dashboardDocuments)
.values({
id: this.dashboardId,
currentRevisionId: revision.id,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: dashboardDocuments.id,
set: {
currentRevisionId: revision.id,
updatedAt: now,
},
})
.run();
});
return revision;
}
private nextRevisionTimestamp(): Date {
const active = this.getActiveDashboard();
const activeUpdatedAtMs = active?.updatedAt.getTime() || 0;
return new Date(Math.max(Date.now(), activeUpdatedAtMs + 1));
}
}
type DashboardRevisionRow = typeof dashboardRevisions.$inferSelect;
function toRevision(row: DashboardRevisionRow): DashboardRevision {
const migration = migrateDashboardDocumentForPersistence(row.document);
if (!migration.valid) {
throw new DashboardPersistenceValidationError(migration.failure);
}
return {
id: row.id,
dashboardId: row.dashboardId,
schemaVersion: row.schemaVersion,
document: migration.document,
actor: row.actor,
message: row.message,
operation: row.operation as DashboardRevisionOperation,
sourceRevisionId: row.sourceRevisionId,
createdAt: row.createdAt,
};
}