feat: add dashboard persistence store

This commit is contained in:
vince 2026-06-18 18:09:17 +02:00
parent 5eeec0dcb5
commit 5e42ee8869
18 changed files with 1181 additions and 19 deletions

View file

@ -0,0 +1,42 @@
import { rmSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test } from "vitest";
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
import { createDashboardStore, type DashboardStore } from "./db/dashboard-store";
import { loadDashboardRuntime } from "./dashboard";
const stores: DashboardStore[] = [];
const tempRoots: string[] = [];
afterEach(() => {
stores.splice(0).forEach((store) => store.close());
tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true }));
});
describe("dashboard runtime loader", () => {
test("seeds and loads the active dashboard from sqlite", async () => {
const store = await createTestStore();
const runtime = loadDashboardRuntime(store);
expect(runtime.title).toBe(dimensionLabDashboardFixture.metadata.title);
expect(runtime.subtitle).toBe(dimensionLabDashboardFixture.metadata.subtitle);
expect(runtime.status).toBe("building");
expect(runtime.schemaVersion).toBe(dimensionLabDashboardFixture.schemaVersion);
expect(runtime.currentRevisionId).toBe(store.getActiveDashboard()?.currentRevisionId);
expect(store.listRevisions()).toHaveLength(1);
});
});
async function createTestStore() {
const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-runtime-"));
tempRoots.push(root);
const store = createDashboardStore({
databaseUrl: `file:${join(root, "dashboard.sqlite")}`,
});
stores.push(store);
return store;
}

View file

@ -1,3 +1,9 @@
import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
import {
createDashboardStore,
type DashboardStore,
} from "$lib/server/db/dashboard-store";
export type PlaceholderStatus = "ready" | "building";
export interface PlaceholderDashboard {
@ -5,6 +11,8 @@ export interface PlaceholderDashboard {
subtitle: string;
status: PlaceholderStatus;
message: string;
schemaVersion?: string;
currentRevisionId?: string;
}
export function loadPlaceholderDashboard(): PlaceholderDashboard {
@ -16,3 +24,29 @@ export function loadPlaceholderDashboard(): PlaceholderDashboard {
"SvelteKit is running. Later issues will replace this placeholder with the validated dashboard model.",
};
}
export function loadDashboardRuntime(store?: DashboardStore): PlaceholderDashboard {
const dashboardStore = store || createDashboardStore();
try {
const seeded = dashboardStore.seedDashboardIfEmpty(dimensionLabDashboardFixture, {
actor: "dimensionlab-seed",
message: "load initial dashboard seed",
});
const active = dashboardStore.getActiveDashboard();
const document = active?.document || seeded.document;
const currentRevisionId = active?.currentRevisionId || seeded.id;
return {
title: document.metadata.title,
subtitle: document.metadata.subtitle || "",
status: "building",
message:
"Active dashboard document loaded from SQLite. Later issues will replace this placeholder with model-driven rendering.",
schemaVersion: document.schemaVersion,
currentRevisionId,
};
} finally {
if (!store) dashboardStore.close();
}
}

View file

@ -0,0 +1,47 @@
import { mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { Database } from "bun:sqlite";
import { drizzle, type BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
import { dashboardDbSchema } from "./schema";
import { applyDashboardMigrations } from "./migrations";
export const DEFAULT_DATABASE_URL = "file:./data/dimensionlab.sqlite";
export type DashboardDatabase = BunSQLiteDatabase<typeof dashboardDbSchema> & {
$client: Database;
};
export interface DashboardDatabaseConnection {
db: DashboardDatabase;
sqlite: Database;
filename: string;
close(): void;
}
export function openDashboardDatabase(
databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL,
): DashboardDatabaseConnection {
const filename = resolveSqliteFilename(databaseUrl);
mkdirSync(dirname(filename), { recursive: true });
const sqlite = new Database(filename, { create: true, readwrite: true });
const db = drizzle(sqlite, { schema: dashboardDbSchema }) as DashboardDatabase;
applyDashboardMigrations(db);
return {
db,
sqlite,
filename,
close() {
sqlite.close();
},
};
}
export function resolveSqliteFilename(databaseUrl: string): string {
if (!databaseUrl.startsWith("file:")) {
throw new Error(`Only file: SQLite DATABASE_URL values are supported: ${databaseUrl}`);
}
return resolve(databaseUrl.slice("file:".length));
}

View file

@ -0,0 +1,205 @@
import { existsSync, rmSync } from "node:fs";
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, test, vi } from "vitest";
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
import type { DashboardDocument } from "$lib/model";
import {
DashboardPersistenceValidationError,
createDashboardStore,
type DashboardStore,
} from "./dashboard-store";
const stores: DashboardStore[] = [];
const tempRoots: string[] = [];
const originalCwd = process.cwd();
afterEach(() => {
vi.useRealTimers();
process.chdir(originalCwd);
stores.splice(0).forEach((store) => store.close());
tempRoots.splice(0).forEach((path) => rmSync(path, { force: true, recursive: true }));
});
describe("dashboard persistence store", () => {
test("seeds the first active dashboard and creates the sqlite file", async () => {
const { dbPath, store } = await createTestStore();
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
actor: "test-seed",
message: "initial fixture",
});
expect(existsSync(dbPath)).toBe(true);
expect(seed.operation).toBe("seed");
expect(seed.actor).toBe("test-seed");
expect(seed.schemaVersion).toBe(genericDashboardFixture.schemaVersion);
expect(seed.document.metadata.title).toBe("Operations Console");
const active = store.getActiveDashboard();
expect(active?.currentRevisionId).toBe(seed.id);
expect(active?.document.metadata.title).toBe("Operations Console");
expect(store.listRevisions()).toHaveLength(1);
});
test("commits validated updates and records revision metadata", async () => {
const { store } = await createTestStore();
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
actor: "test-seed",
});
const updated = cloneDashboard({
...genericDashboardFixture,
metadata: {
...genericDashboardFixture.metadata,
title: "Operations Console Updated",
},
});
const revision = store.commitDashboard(updated, {
actor: "agent",
message: "rename dashboard",
});
expect(revision.operation).toBe("commit");
expect(revision.actor).toBe("agent");
expect(revision.message).toBe("rename dashboard");
expect(revision.document.metadata.title).toBe("Operations Console Updated");
expect(store.getActiveDashboard()?.currentRevisionId).toBe(revision.id);
expect(store.getRevision(seed.id)?.document.metadata.title).toBe("Operations Console");
expect(store.listRevisions().map((item) => item.id)).toEqual([
revision.id,
seed.id,
]);
});
test("rejects invalid writes without changing the active revision", async () => {
const { store } = await createTestStore();
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
actor: "test-seed",
});
const invalid = cloneDashboard({
...genericDashboardFixture,
layout: {
...genericDashboardFixture.layout,
telemetry: ["missing-card"],
},
});
expect(() =>
store.commitDashboard(invalid, {
actor: "agent",
message: "invalid update",
}),
).toThrow(DashboardPersistenceValidationError);
const active = store.getActiveDashboard();
expect(active?.currentRevisionId).toBe(seed.id);
expect(active?.document.layout.telemetry).toEqual(genericDashboardFixture.layout.telemetry);
expect(store.listRevisions()).toHaveLength(1);
});
test("loads specific revisions and rolls back to a prior valid document", async () => {
const { store } = await createTestStore();
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
actor: "test-seed",
});
const updated = cloneDashboard({
...genericDashboardFixture,
metadata: {
...genericDashboardFixture.metadata,
title: "Changed Dashboard",
},
});
const change = store.commitDashboard(updated, {
actor: "agent",
message: "change title",
});
const rollback = store.rollbackToRevision(seed.id, {
actor: "operator",
message: "restore seed",
});
expect(store.getRevision(change.id)?.document.metadata.title).toBe("Changed Dashboard");
expect(rollback.operation).toBe("rollback");
expect(rollback.sourceRevisionId).toBe(seed.id);
expect(rollback.document.metadata.title).toBe("Operations Console");
expect(store.getActiveDashboard()?.currentRevisionId).toBe(rollback.id);
expect(store.getActiveDashboard()?.document.metadata.title).toBe("Operations Console");
expect(store.listRevisions().map((item) => item.operation)).toEqual([
"rollback",
"commit",
"seed",
]);
});
test("assigns monotonic revision timestamps for stable history ordering", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-06-18T12:00:00.000Z"));
const { store } = await createTestStore();
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
actor: "test-seed",
});
const updated = cloneDashboard({
...genericDashboardFixture,
metadata: {
...genericDashboardFixture.metadata,
title: "Changed Dashboard",
},
});
const commit = store.commitDashboard(updated, { actor: "agent" });
const rollback = store.rollbackToRevision(seed.id, { actor: "operator" });
expect([
seed.createdAt.toISOString(),
commit.createdAt.toISOString(),
rollback.createdAt.toISOString(),
]).toEqual([
"2026-06-18T12:00:00.000Z",
"2026-06-18T12:00:00.001Z",
"2026-06-18T12:00:00.002Z",
]);
expect(store.listRevisions().map((item) => item.id)).toEqual([
rollback.id,
commit.id,
seed.id,
]);
});
test("applies migrations when launched outside the repository root", async () => {
const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-cwd-"));
tempRoots.push(root);
process.chdir(root);
vi.resetModules();
const { createDashboardStore: createStore } = await import("./dashboard-store");
const store = createStore({
databaseUrl: `file:${join(root, "dashboard.sqlite")}`,
});
stores.push(store);
const seed = store.seedDashboardIfEmpty(genericDashboardFixture, {
actor: "test-seed",
});
expect(seed.document.metadata.title).toBe("Operations Console");
expect(store.getActiveDashboard()?.currentRevisionId).toBe(seed.id);
});
});
async function createTestStore() {
const root = await mkdtemp(join(tmpdir(), "dimensionlab-dashboard-store-"));
tempRoots.push(root);
const dbPath = join(root, "nested", "dashboard.sqlite");
const store = createDashboardStore({
databaseUrl: `file:${dbPath}`,
});
stores.push(store);
return { dbPath, store };
}
function cloneDashboard(document: DashboardDocument): DashboardDocument {
return structuredClone(document);
}

View file

@ -0,0 +1,261 @@
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,
};
}

View file

@ -0,0 +1,52 @@
import { existsSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/bun-sqlite/migrator";
import type { BunSQLiteDatabase } from "drizzle-orm/bun-sqlite";
const MIGRATION_JOURNAL_PATH = join("meta", "_journal.json");
export function applyDashboardMigrations(
database: BunSQLiteDatabase<Record<string, unknown>>,
migrationsFolder = resolveDashboardMigrationsFolder(),
) {
migrate(database, { migrationsFolder });
}
export function resolveDashboardMigrationsFolder(
explicitFolder = process.env.DASHBOARD_MIGRATIONS_DIR,
): string {
if (explicitFolder) return assertMigrationFolder(resolve(explicitFolder));
const cwdFolder = findMigrationFolder(process.cwd());
if (cwdFolder) return cwdFolder;
const moduleFolder = findMigrationFolder(dirname(fileURLToPath(import.meta.url)));
if (moduleFolder) return moduleFolder;
throw new Error(
"Unable to locate dashboard Drizzle migrations. Set DASHBOARD_MIGRATIONS_DIR.",
);
}
function findMigrationFolder(startPath: string): string | null {
let currentPath = resolve(startPath);
while (true) {
const candidate = join(currentPath, "drizzle");
if (hasMigrationJournal(candidate)) return candidate;
const parentPath = dirname(currentPath);
if (parentPath === currentPath) return null;
currentPath = parentPath;
}
}
function assertMigrationFolder(folder: string): string {
if (hasMigrationJournal(folder)) return folder;
throw new Error(`Dashboard migrations not found in ${folder}`);
}
function hasMigrationJournal(folder: string): boolean {
return existsSync(join(folder, MIGRATION_JOURNAL_PATH));
}

View file

@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { DASHBOARD_SCHEMA_VERSION } from "$lib/model";
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
import {
UnsupportedDashboardModelVersionError,
migrateDashboardDocumentForPersistence,
} from "./model-migrations";
describe("dashboard model migrations", () => {
test("accepts the current dashboard model version without migration", () => {
const result = migrateDashboardDocumentForPersistence(genericDashboardFixture);
expect(result.valid).toBe(true);
if (!result.valid) throw new Error("expected current fixture to be valid");
expect(result.document).toEqual(genericDashboardFixture);
expect(result.fromVersion).toBe(DASHBOARD_SCHEMA_VERSION);
expect(result.toVersion).toBe(DASHBOARD_SCHEMA_VERSION);
expect(result.migrated).toBe(false);
});
test("fails unsupported model versions with an explicit migration error", () => {
const previousVersion = {
...genericDashboardFixture,
schemaVersion: "dashboard.v0",
};
expect(() => migrateDashboardDocumentForPersistence(previousVersion)).toThrow(
UnsupportedDashboardModelVersionError,
);
});
});

View file

@ -0,0 +1,62 @@
import {
DASHBOARD_SCHEMA_VERSION,
validateDashboardDocument,
type DashboardDocument,
type DashboardValidationFailure,
} from "$lib/model";
export interface DashboardModelMigrationSuccess {
valid: true;
document: DashboardDocument;
fromVersion: string;
toVersion: typeof DASHBOARD_SCHEMA_VERSION;
migrated: boolean;
}
export interface DashboardModelMigrationFailure {
valid: false;
failure: DashboardValidationFailure;
}
export type DashboardModelMigrationResult =
| DashboardModelMigrationFailure
| DashboardModelMigrationSuccess;
export class UnsupportedDashboardModelVersionError extends Error {
constructor(
readonly fromVersion: string,
readonly toVersion: string,
) {
super(`No dashboard model migration from ${fromVersion} to ${toVersion}`);
this.name = "UnsupportedDashboardModelVersionError";
}
}
export function migrateDashboardDocumentForPersistence(
value: unknown,
): DashboardModelMigrationResult {
const version = readSchemaVersion(value);
if (version && version !== DASHBOARD_SCHEMA_VERSION) {
throw new UnsupportedDashboardModelVersionError(version, DASHBOARD_SCHEMA_VERSION);
}
const validation = validateDashboardDocument(value);
if (!validation.valid) {
return { valid: false, failure: validation };
}
return {
valid: true,
document: validation.data,
fromVersion: DASHBOARD_SCHEMA_VERSION,
toVersion: DASHBOARD_SCHEMA_VERSION,
migrated: false,
};
}
function readSchemaVersion(value: unknown): string | null {
if (!value || typeof value !== "object") return null;
const schemaVersion = (value as { schemaVersion?: unknown }).schemaVersion;
return typeof schemaVersion === "string" ? schemaVersion : null;
}

View file

@ -0,0 +1,39 @@
import type { DashboardDocument } from "$lib/model";
import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
export const dashboardDocuments = sqliteTable("dashboard_documents", {
id: text("id").primaryKey(),
currentRevisionId: text("current_revision_id"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
updatedAt: integer("updated_at", { mode: "timestamp_ms" }).notNull(),
});
export const dashboardRevisions = sqliteTable(
"dashboard_revisions",
{
id: text("id").primaryKey(),
dashboardId: text("dashboard_id").notNull(),
schemaVersion: text("schema_version").notNull(),
document: text("document", { mode: "json" }).$type<DashboardDocument>().notNull(),
actor: text("actor").notNull(),
message: text("message"),
operation: text("operation", {
enum: ["seed", "commit", "rollback"],
}).notNull(),
sourceRevisionId: text("source_revision_id"),
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
},
(table) => [
index("idx_dashboard_revisions_dashboard_created").on(
table.dashboardId,
table.createdAt,
),
],
);
export const dashboardDbSchema = {
dashboardDocuments,
dashboardRevisions,
};
export type DashboardRevisionOperation = "seed" | "commit" | "rollback";