303 lines
13 KiB
TypeScript
303 lines
13 KiB
TypeScript
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 = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
|
|
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" }])
|
|
}),
|
|
)
|
|
})
|
|
})
|