261 lines
7.1 KiB
TypeScript
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,
|
|
};
|
|
}
|