feat: add dashboard persistence store
This commit is contained in:
parent
5eeec0dcb5
commit
5e42ee8869
18 changed files with 1181 additions and 19 deletions
42
src/lib/server/dashboard.test.ts
Normal file
42
src/lib/server/dashboard.test.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
47
src/lib/server/db/connection.ts
Normal file
47
src/lib/server/db/connection.ts
Normal 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));
|
||||
}
|
||||
205
src/lib/server/db/dashboard-store.test.ts
Normal file
205
src/lib/server/db/dashboard-store.test.ts
Normal 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);
|
||||
}
|
||||
261
src/lib/server/db/dashboard-store.ts
Normal file
261
src/lib/server/db/dashboard-store.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
52
src/lib/server/db/migrations.ts
Normal file
52
src/lib/server/db/migrations.ts
Normal 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));
|
||||
}
|
||||
31
src/lib/server/db/model-migrations.test.ts
Normal file
31
src/lib/server/db/model-migrations.test.ts
Normal 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
62
src/lib/server/db/model-migrations.ts
Normal file
62
src/lib/server/db/model-migrations.ts
Normal 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;
|
||||
}
|
||||
39
src/lib/server/db/schema.ts
Normal file
39
src/lib/server/db/schema.ts
Normal 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";
|
||||
Loading…
Add table
Add a link
Reference in a new issue