2026-05-31 09:08:38 +08:00
import { describe , expect , test } from "bun:test"
import { $ } from "bun"
import { fileURLToPath } from "url"
2026-06-04 11:02:17 +08:00
import path from "path"
2026-05-31 09:08:38 +08:00
import { SqliteClient } from "@effect/sql-sqlite-bun"
import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
2026-06-04 11:02:17 +08:00
import { Effect , Layer } from "effect"
2026-06-02 08:54:41 +08:00
import { eq , inArray , sql } from "drizzle-orm"
2026-05-31 09:08:38 +08:00
import { DatabaseMigration } from "@opencode-ai/core/database/migration"
import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
2026-06-02 08:54:41 +08:00
import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
2026-06-04 11:02:17 +08:00
import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
2026-06-02 08:54:41 +08:00
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"
2026-06-01 12:50:43 +08:00
import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
2026-05-31 09:08:38 +08:00
import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
2026-06-04 11:02:17 +08:00
import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
2026-05-31 09:08:38 +08:00
const run = < A , E > ( effect : Effect.Effect < A , E , SqlClientService > ) = >
2026-05-31 09:09:55 +08:00
Effect . runPromise (
effect . pipe ( Effect . provide ( SqliteClient . layer ( { filename : ":memory:" , disableWAL : true } ) ) , Effect . scoped ) ,
)
2026-05-31 09:08:38 +08:00
const makeDb = EffectDrizzleSqlite . makeWithDefaults ( )
describe ( "DatabaseMigration" , ( ) = > {
2026-06-04 11:02:17 +08:00
test ( "serializes concurrent embedded initialization for one database path" , async ( ) = > {
await using tmp = await tmpdir ( )
const filename = path . join ( tmp . path , "embedded.sqlite" )
const layers = [ Database . layerFromPath ( filename ) , Database . layerFromPath ( filename ) ]
await Effect . runPromise (
2026-06-04 11:03:39 +08:00
Effect . all (
layers . map ( ( layer ) = > Effect . scoped ( Layer . build ( layer ) ) ) ,
{ concurrency : "unbounded" } ,
) ,
2026-06-04 11:02:17 +08:00
)
} )
2026-05-31 09:08:38 +08:00
if ( process . platform === "linux" ) {
test ( "declared schema has no ungenerated migrations" , async ( ) = > {
2026-05-31 09:09:55 +08:00
const result = await $ ` bun ${ fileURLToPath ( new URL ( "../script/migration.ts" , import . meta . url ) ) } --check `
. quiet ( )
. nothrow ( )
2026-05-31 09:08:38 +08:00
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" ,
} )
2026-06-04 11:02:17 +08:00
expect (
yield * db . get ( sql ` SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input' ` ) ,
) . toEqual ( { name : "session_input" } )
expect ( yield * db . get ( sql ` SELECT count(*) as count FROM migration ` ) ) . toEqual ( { count : 29 } )
expect (
yield * db . all (
sql ` SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name ` ,
) ,
) . toEqual ( [
{ name : "event_aggregate_seq_idx" } ,
{ name : "event_aggregate_type_seq_idx" } ,
{ name : "session_input_session_pending_delivery_seq_idx" } ,
{ name : "session_message_session_seq_idx" } ,
{ name : "session_message_session_time_created_id_idx" } ,
{ name : "session_message_session_type_seq_idx" } ,
] )
2026-05-31 09:08:38 +08:00
} ) ,
)
} )
2026-06-04 21:43:10 +08:00
test ( "resets incompatible projected Session messages before adding sequence order" , async ( ) = > {
2026-06-04 11:02:17 +08:00
await run (
Effect . gen ( function * ( ) {
const db = yield * makeDb
2026-06-04 21:43:10 +08:00
yield * db . run ( sql ` CREATE TABLE session (id text PRIMARY KEY) ` )
yield * db . run (
sql ` CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL) ` ,
)
yield * db . run (
sql ` CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL) ` ,
)
2026-06-04 11:02:17 +08:00
yield * db . run ( sql ` CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL) ` )
yield * db . run (
2026-06-04 21:43:10 +08:00
sql ` CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL) ` ,
2026-06-04 11:02:17 +08:00
)
yield * db . run (
sql ` CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id) ` ,
)
yield * db . run (
sql ` CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id) ` ,
)
2026-06-04 21:43:10 +08:00
yield * db . run ( sql ` INSERT INTO session (id) VALUES ('session') ` )
yield * db . run (
sql ` INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{"role":"user"}') ` ,
)
yield * db . run (
sql ` INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy_part', 'legacy_message', 'session', 1, 1, '{"type":"text","text":"hello"}') ` ,
)
2026-06-04 11:02:17 +08:00
yield * db . run (
2026-06-04 21:43:10 +08:00
sql ` INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('stale_projection', 'session', 'user', 1, 1, '{}') ` ,
2026-06-04 11:02:17 +08:00
)
yield * DatabaseMigration . applyOnly ( db , [ sessionMessageProjectionOrderMigration ] )
2026-06-04 21:43:10 +08:00
expect ( yield * db . all ( sql ` SELECT id, session_id, data FROM message ` ) ) . toEqual ( [
{ id : "legacy_message" , session_id : "session" , data : '{"role":"user"}' } ,
2026-06-04 11:02:17 +08:00
] )
2026-06-04 21:43:10 +08:00
expect ( yield * db . all ( sql ` SELECT id, message_id, session_id, data FROM part ` ) ) . toEqual ( [
{
id : "legacy_part" ,
message_id : "legacy_message" ,
session_id : "session" ,
data : '{"type":"text","text":"hello"}' ,
} ,
] )
expect ( yield * db . all ( sql ` SELECT id FROM session_message ` ) ) . toEqual ( [ ] )
yield * db . run (
sql ` INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('fresh_projection', 'session', 'user', 7, 2, 2, '{}') ` ,
)
expect ( yield * db . get ( sql ` SELECT id, seq FROM session_message ` ) ) . toEqual ( { id : "fresh_projection" , seq : 7 } )
2026-06-04 11:02:17 +08:00
} ) ,
)
} )
2026-05-31 09:08:38 +08:00
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 ,
} )
} ) ,
)
} )
2026-06-02 08:54:41 +08:00
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 ( )
2026-06-02 08:56:03 +08:00
expect (
yield * db . get < { worktree : string ; sandboxes : string } > (
sql ` SELECT worktree, sandboxes FROM project WHERE id = ${ projectID } ` ,
) ,
) . toEqual ( {
2026-06-02 08:54:41 +08:00
worktree : "C:/Repo/Thing" ,
sandboxes : JSON.stringify ( [ "C:/Repo/Thing/sandbox" ] ) ,
} )
2026-06-02 08:56:03 +08:00
expect (
yield * db . get < { directory : string ; path : string } > (
sql ` SELECT directory, path FROM session WHERE id = ${ sessionID } ` ,
) ,
) . toEqual ( {
2026-06-02 08:54:41 +08:00
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 (
2026-06-02 08:56:03 +08:00
yield * db . get < { worktree : string ; sandboxes : string } > (
sql ` SELECT worktree, sandboxes FROM project WHERE id = ${ projectID } ` ,
) ,
2026-06-02 08:54:41 +08:00
) . toEqual ( { worktree : "D:/Moved/Thing" , sandboxes : JSON.stringify ( [ "D:/Moved/Thing" ] ) } )
2026-06-02 08:56:03 +08:00
expect (
( yield * db
. select ( )
. from ( ProjectTable )
. where ( inArray ( ProjectTable . worktree , [ moved ] ) )
. get ( ) ) ? . id ,
) . toBe ( projectID )
2026-06-02 08:54:41 +08:00
yield * db . run ( sql ` UPDATE project SET worktree = ${ "not-absolute" } WHERE id = ${ projectID } ` )
2026-06-02 08:56:03 +08:00
expect ( ( ) = >
Effect . runSync ( db . select ( ) . from ( ProjectTable ) . where ( eq ( ProjectTable . id , projectID ) ) . get ( ) ) ,
) . toThrow ( )
2026-06-02 08:54:41 +08:00
} ) ,
)
} )
2026-05-31 09:08:38 +08:00
test ( "imports existing drizzle migration state" , async ( ) = > {
await run (
Effect . gen ( function * ( ) {
const db = yield * makeDb
2026-05-31 09:09:55 +08:00
yield * db . run (
sql ` CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT) ` ,
)
2026-05-31 09:08:38 +08:00
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" } )
} ) ,
)
} )
2026-06-01 12:50:43 +08:00
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" } ,
] )
} ) ,
)
} )
2026-05-31 09:08:38 +08:00
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) ` )
2026-05-31 09:09:55 +08:00
yield * db . run (
sql ` CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT) ` ,
)
2026-05-31 09:08:38 +08:00
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" } ] )
} ) ,
)
} )
} )