diff --git a/README.md b/README.md index e4c2dec..3dadcbd 100644 --- a/README.md +++ b/README.md @@ -37,10 +37,12 @@ DATABASE_URL=file:./data/dimensionlab.sqlite SQLite files under `data/` are ignored. Drizzle schema lives in `src/lib/server/db/schema.ts`; tracked migrations live in `drizzle/`. Runtime -startup applies the checked-in dashboard migrations before reads or writes. The -current driver is `bun:sqlite`, which keeps this repo installable in the Bun -workflow. The store boundary is isolated so a later Postgres driver can replace -the SQLite connection without changing the dashboard model or renderer. +startup applies the checked-in dashboard migrations before reads or writes. If +the app is launched from outside the repo tree, set `DASHBOARD_MIGRATIONS_DIR` +to the tracked migrations directory. The current driver is `bun:sqlite`, which +keeps this repo installable in the Bun workflow. The store boundary is isolated +so a later Postgres driver can replace the SQLite connection without changing +the dashboard model or renderer. Stored dashboard documents pass through a version migration boundary before reads or writes; the MVP supports `dashboard.v1` and fails unsupported versions with an explicit migration error. diff --git a/src/lib/server/db/dashboard-store.test.ts b/src/lib/server/db/dashboard-store.test.ts index 71942f0..ecb80f3 100644 --- a/src/lib/server/db/dashboard-store.test.ts +++ b/src/lib/server/db/dashboard-store.test.ts @@ -13,9 +13,11 @@ import { 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 })); }); @@ -165,6 +167,25 @@ describe("dashboard persistence store", () => { 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() { diff --git a/src/lib/server/db/migrations.ts b/src/lib/server/db/migrations.ts index 917d777..9e34ba0 100644 --- a/src/lib/server/db/migrations.ts +++ b/src/lib/server/db/migrations.ts @@ -1,12 +1,52 @@ -import { resolve } from "node:path"; +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"; -export const DASHBOARD_MIGRATIONS_FOLDER = resolve("drizzle"); +const MIGRATION_JOURNAL_PATH = join("meta", "_journal.json"); export function applyDashboardMigrations( database: BunSQLiteDatabase>, - migrationsFolder = DASHBOARD_MIGRATIONS_FOLDER, + 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)); +}