import { describe, expect, test } from "bun:test" import { $ } from "bun" import { fileURLToPath } from "url" import { SqliteClient } from "@effect/sql-sqlite-bun" import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite" import { Effect } from "effect" import { eq, inArray, sql } from "drizzle-orm" import { DatabaseMigration } from "@opencode-ai/core/database/migration" import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage" import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths" import { ProjectV2 } from "@opencode-ai/core/project" import { ProjectTable } from "@opencode-ai/core/project/sql" import { AbsolutePath } from "@opencode-ai/core/schema" import { SessionSchema } from "@opencode-ai/core/session/schema" import { SessionTable } from "@opencode-ai/core/session/sql" import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata" import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient" const run = (effect: Effect.Effect) => Effect.runPromise( effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped), ) const makeDb = EffectDrizzleSqlite.makeWithDefaults() describe("DatabaseMigration", () => { if (process.platform === "linux") { test("declared schema has no ungenerated migrations", async () => { const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check` .quiet() .nothrow() expect(result.exitCode, result.stderr.toString()).toBe(0) expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate") }, 30_000) } test("applies tracked migrations to an empty database", async () => { await run( Effect.gen(function* () { const db = yield* makeDb yield* DatabaseMigration.apply(db) expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({ name: "session", }) expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: 22 }) }), ) }) test("runs session usage backfill in order with schema changes", async () => { await run( Effect.gen(function* () { const db = yield* makeDb yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, time_updated integer NOT NULL)`) yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`) yield* db.run(sql`INSERT INTO session (id, time_updated) VALUES ('session_1', 1)`) yield* db.run( sql`INSERT INTO message (id, session_id, data) VALUES ('message_1', 'session_1', '{"role":"assistant","cost":1.25,"tokens":{"input":2,"output":3,"reasoning":4,"cache":{"read":5,"write":6}}}')`, ) yield* DatabaseMigration.applyOnly(db, [sessionUsageMigration]) expect( yield* db.get( sql`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE id = 'session_1'`, ), ).toEqual({ cost: 1.25, tokens_input: 2, tokens_output: 3, tokens_reasoning: 4, tokens_cache_read: 5, tokens_cache_write: 6, }) }), ) }) test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => { await run( Effect.gen(function* () { const db = yield* makeDb yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`) yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`) // Windows-shaped rows (drive + backslash) must be normalized. yield* db.run( sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([ "C:\\Repo\\Thing\\sandbox", ])})`, ) yield* db.run( sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`, ) // UNC worktrees and their sandboxes must normalize too (not just drive paths). yield* db.run( sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([ "\\\\server\\share\\sandbox", ])})`, ) // The "/" worktree sentinel and POSIX paths (including a pathological // backslash in a POSIX filename) must survive byte-for-byte. yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`) yield* db.run( sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`, ) yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration]) expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({ worktree: "C:/Repo/Thing", sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), }) expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({ directory: "C:/Repo/Thing/packages/api", path: "packages/api", }) expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({ worktree: "//server/share", sandboxes: JSON.stringify(["//server/share/sandbox"]), }) expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" }) expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({ directory: "/home/me/we\\ird", path: "src\\weird", }) }), ) }) test("maps native Windows paths through database columns", async () => { if (process.platform !== "win32") return await run( Effect.gen(function* () { const db = yield* makeDb yield* DatabaseMigration.apply(db) const projectID = ProjectV2.ID.make("codec_project") const worktree = AbsolutePath.make("C:\\Repo\\Thing") const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox") const directory = "C:\\Repo\\Thing\\packages\\api" const sessionID = SessionSchema.ID.make("ses_codec") expect(() => Effect.runSync( db .insert(ProjectTable) .values({ id: ProjectV2.ID.make("invalid_path"), worktree: AbsolutePath.make("not-absolute"), sandboxes: [], time_created: 1, time_updated: 1, }) .run(), ), ).toThrow() yield* db .insert(ProjectTable) .values({ id: projectID, worktree, sandboxes: [sandbox], time_created: 1, time_updated: 1, }) .run() yield* db .insert(SessionTable) .values({ id: sessionID, project_id: projectID, slug: "codec", directory, path: "packages\\api", title: "Codec", version: "test", time_created: 1, time_updated: 1, }) .run() expect(yield* db.get<{ worktree: string; sandboxes: string }>(sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`)).toEqual({ worktree: "C:/Repo/Thing", sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]), }) expect(yield* db.get<{ directory: string; path: string }>(sql`SELECT directory, path FROM session WHERE id = ${sessionID}`)).toEqual({ directory: "C:/Repo/Thing/packages/api", path: "packages/api", }) const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get() const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get() expect(project?.worktree).toBe(worktree) expect(project?.sandboxes).toEqual([sandbox]) expect(session?.directory).toBe(directory) expect(session?.path).toBe("packages/api") expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe( sessionID, ) const moved = AbsolutePath.make("D:\\Moved\\Thing") const updated = yield* db .update(ProjectTable) .set({ worktree: moved, sandboxes: [moved] }) .where(eq(ProjectTable.id, projectID)) .returning() .get() expect(updated?.worktree).toBe(moved) expect(updated?.sandboxes).toEqual([moved]) expect( yield* db.get<{ worktree: string; sandboxes: string }>(sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`), ).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) }) expect((yield* db.select().from(ProjectTable).where(inArray(ProjectTable.worktree, [moved])).get())?.id).toBe( projectID, ) yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`) expect(() => Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get())).toThrow() }), ) }) test("imports existing drizzle migration state", async () => { await run( Effect.gen(function* () { const db = yield* makeDb yield* db.run( sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, ) yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) `) yield* DatabaseMigration.applyOnly(db, []) expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" }) }), ) }) test("does not replay a migrated session metadata column", async () => { await run( Effect.gen(function* () { const db = yield* makeDb yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) yield* db.run( sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, ) yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260511173437_session-metadata', ${new Date().toISOString()}) `) yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }]) }), ) }) test("accepts the temporary replacement session metadata migration id", async () => { await run( Effect.gen(function* () { const db = yield* makeDb yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`) yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`) yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('20260530232709_lovely_romulus', 1)`) yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration]) expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([ { id: "20260511173437_session-metadata" }, { id: "20260530232709_lovely_romulus" }, ]) }), ) }) test("skips drizzle import when migration table already has state", async () => { await run( Effect.gen(function* () { const db = yield* makeDb yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`) yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`) yield* db.run( sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`, ) yield* db.run(sql` INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at) VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()}) `) yield* DatabaseMigration.applyOnly(db, []) expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }]) }), ) }) })