import { describe, test, expect, beforeEach, afterEach } from "bun:test" import { Database } from "bun:sqlite" import { drizzle } from "drizzle-orm/bun-sqlite" import { migrate } from "drizzle-orm/bun-sqlite/migrator" import { eq } from "drizzle-orm" import path from "path" import fs from "fs/promises" import { readFileSync } from "fs" import os from "os" import { migrateFromJson } from "../../src/storage/json-migration" import { ProjectTable } from "../../src/project/project.sql" import { Project } from "../../src/project/project" import { SessionTable, MessageTable, PartTable, SessionDiffTable, TodoTable, PermissionTable, } from "../../src/session/session.sql" import { SessionShareTable, ShareTable } from "../../src/share/share.sql" // Test fixtures const fixtures = { project: { id: "proj_test123abc", name: "Test Project", worktree: "/test/path", vcs: "git" as const, sandboxes: [], }, session: { id: "ses_test456def", projectID: "proj_test123abc", slug: "test-session", directory: "/test/path", title: "Test Session", version: "1.0.0", time: { created: 1700000000000, updated: 1700000001000 }, }, message: { id: "msg_test789ghi", sessionID: "ses_test456def", role: "user" as const, agent: "default", model: { providerID: "openai", modelID: "gpt-4" }, time: { created: 1700000000000 }, }, part: { id: "prt_testabc123", messageID: "msg_test789ghi", sessionID: "ses_test456def", type: "text" as const, text: "Hello, world!", }, } // Helper to create test storage directory structure async function setupStorageDir(baseDir: string) { const storageDir = path.join(baseDir, "storage") await fs.mkdir(path.join(storageDir, "project"), { recursive: true }) await fs.mkdir(path.join(storageDir, "session", "proj_test123abc"), { recursive: true }) await fs.mkdir(path.join(storageDir, "message", "ses_test456def"), { recursive: true }) await fs.mkdir(path.join(storageDir, "part", "msg_test789ghi"), { recursive: true }) await fs.mkdir(path.join(storageDir, "session_diff"), { recursive: true }) await fs.mkdir(path.join(storageDir, "todo"), { recursive: true }) await fs.mkdir(path.join(storageDir, "permission"), { recursive: true }) await fs.mkdir(path.join(storageDir, "session_share"), { recursive: true }) await fs.mkdir(path.join(storageDir, "share"), { recursive: true }) // Create legacy marker to indicate JSON storage exists await Bun.write(path.join(storageDir, "migration"), "1") return storageDir } // Helper to create in-memory test database with schema function createTestDb() { const sqlite = new Database(":memory:") sqlite.exec("PRAGMA foreign_keys = ON") // Apply schema migrations using drizzle migrate const dir = path.join(import.meta.dirname, "../../migration") const journal = JSON.parse(readFileSync(path.join(dir, "meta/_journal.json"), "utf-8")) as { entries: { tag: string; when: number }[] } const migrations = journal.entries.map((entry) => ({ sql: readFileSync(path.join(dir, `${entry.tag}.sql`), "utf-8"), timestamp: entry.when, })) migrate(drizzle({ client: sqlite }), migrations) return sqlite } describe("JSON to SQLite migration", () => { let tmpDir: string let storageDir: string let sqlite: Database beforeEach(async () => { tmpDir = path.join(os.tmpdir(), "opencode-migration-test-" + Math.random().toString(36).slice(2)) await fs.mkdir(tmpDir, { recursive: true }) storageDir = await setupStorageDir(tmpDir) sqlite = createTestDb() }) afterEach(async () => { sqlite.close() await fs.rm(tmpDir, { recursive: true, force: true }) }) test("migrates project", async () => { await Bun.write( path.join(storageDir, "project", "proj_test123abc.json"), JSON.stringify({ id: "proj_test123abc", worktree: "/test/path", vcs: "git", name: "Test Project", time: { created: 1700000000000, updated: 1700000001000 }, sandboxes: ["/test/sandbox"], }), ) const stats = await migrateFromJson(sqlite, storageDir) expect(stats?.projects).toBe(1) const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) expect(projects[0].id).toBe("proj_test123abc") expect(projects[0].worktree).toBe("/test/path") expect(projects[0].name).toBe("Test Project") expect(projects[0].sandboxes).toEqual(["/test/sandbox"]) }) test("migrates session with individual columns", async () => { // First create the project await Bun.write( path.join(storageDir, "project", "proj_test123abc.json"), JSON.stringify({ id: "proj_test123abc", worktree: "/test/path", time: { created: Date.now(), updated: Date.now() }, sandboxes: [], }), ) await Bun.write( path.join(storageDir, "session", "proj_test123abc", "ses_test456def.json"), JSON.stringify({ id: "ses_test456def", projectID: "proj_test123abc", slug: "test-session", directory: "/test/dir", title: "Test Session Title", version: "1.0.0", time: { created: 1700000000000, updated: 1700000001000 }, summary: { additions: 10, deletions: 5, files: 3 }, share: { url: "https://example.com/share" }, }), ) await migrateFromJson(sqlite, storageDir) const db = drizzle({ client: sqlite }) const sessions = db.select().from(SessionTable).all() expect(sessions.length).toBe(1) expect(sessions[0].id).toBe("ses_test456def") expect(sessions[0].project_id).toBe("proj_test123abc") expect(sessions[0].slug).toBe("test-session") expect(sessions[0].title).toBe("Test Session Title") expect(sessions[0].summary_additions).toBe(10) expect(sessions[0].summary_deletions).toBe(5) expect(sessions[0].share_url).toBe("https://example.com/share") }) test("migrates messages and parts", async () => { await Bun.write( path.join(storageDir, "project", "proj_test123abc.json"), JSON.stringify({ id: "proj_test123abc", worktree: "/", time: { created: Date.now(), updated: Date.now() }, sandboxes: [], }), ) await Bun.write( path.join(storageDir, "session", "proj_test123abc", "ses_test456def.json"), JSON.stringify({ ...fixtures.session }), ) await Bun.write( path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"), JSON.stringify({ ...fixtures.message }), ) await Bun.write( path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"), JSON.stringify({ ...fixtures.part }), ) const stats = await migrateFromJson(sqlite, storageDir) expect(stats?.messages).toBe(1) expect(stats?.parts).toBe(1) const db = drizzle({ client: sqlite }) const messages = db.select().from(MessageTable).all() expect(messages.length).toBe(1) expect(messages[0].data.id).toBe("msg_test789ghi") const parts = db.select().from(PartTable).all() expect(parts.length).toBe(1) expect(parts[0].data.id).toBe("prt_testabc123") }) test("skips orphaned sessions (no parent project)", async () => { await Bun.write( path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"), JSON.stringify({ id: "ses_orphan", projectID: "proj_nonexistent", slug: "orphan", directory: "/", title: "Orphan", version: "1.0.0", time: { created: Date.now(), updated: Date.now() }, }), ) const stats = await migrateFromJson(sqlite, storageDir) expect(stats?.sessions).toBe(0) }) test("is idempotent (running twice doesn't duplicate)", async () => { await Bun.write( path.join(storageDir, "project", "proj_test123abc.json"), JSON.stringify({ id: "proj_test123abc", worktree: "/", time: { created: Date.now(), updated: Date.now() }, sandboxes: [], }), ) await migrateFromJson(sqlite, storageDir) await migrateFromJson(sqlite, storageDir) const db = drizzle({ client: sqlite }) const projects = db.select().from(ProjectTable).all() expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing }) })