mirror of
https://github.com/anomalyco/opencode.git
synced 2026-04-25 15:24:58 +00:00
This replaces the previous manual JSON file system with standard Drizzle migrations, enabling: - Proper database schema migrations with timestamp-based versioning - Batched migration for faster migration of large datasets - Better data integrity with proper table schemas instead of JSON blobs - Easier database upgrades and rollback capabilities Migration changes: - Todo table now uses individual columns with composite PK instead of JSON blob - Share table removes unused download share data - Session diff table moved from database table to file storage - All migrations now use proper Drizzle format with per-folder layout Users will see a one-time migration on next run that migrates existing JSON data to the new SQLite database.
244 lines
7.9 KiB
TypeScript
244 lines
7.9 KiB
TypeScript
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, readdirSync } from "fs"
|
|
import { JsonMigration } from "../../src/storage/json-migration"
|
|
import { Global } from "../../src/global"
|
|
import { ProjectTable } from "../../src/project/project.sql"
|
|
import { Project } from "../../src/project/project"
|
|
import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.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() {
|
|
const storageDir = path.join(Global.Path.data, "storage")
|
|
await fs.rm(storageDir, { recursive: true, force: true })
|
|
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 })
|
|
// 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 entries = readdirSync(dir, { withFileTypes: true })
|
|
const migrations = entries
|
|
.filter((entry) => entry.isDirectory())
|
|
.map((entry) => ({
|
|
sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"),
|
|
timestamp: Number(entry.name.split("_")[0]),
|
|
}))
|
|
.sort((a, b) => a.timestamp - b.timestamp)
|
|
migrate(drizzle({ client: sqlite }), migrations)
|
|
|
|
return sqlite
|
|
}
|
|
|
|
describe("JSON to SQLite migration", () => {
|
|
let storageDir: string
|
|
let sqlite: Database
|
|
|
|
beforeEach(async () => {
|
|
storageDir = await setupStorageDir()
|
|
sqlite = createTestDb()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
sqlite.close()
|
|
await fs.rm(storageDir, { 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 JsonMigration.run(sqlite)
|
|
|
|
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 JsonMigration.run(sqlite)
|
|
|
|
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 JsonMigration.run(sqlite)
|
|
|
|
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 JsonMigration.run(sqlite)
|
|
|
|
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 JsonMigration.run(sqlite)
|
|
await JsonMigration.run(sqlite)
|
|
|
|
const db = drizzle({ client: sqlite })
|
|
const projects = db.select().from(ProjectTable).all()
|
|
expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
|
|
})
|
|
})
|