diff --git a/packages/core/src/database/migration/20260804233008_loose_psylocke.ts b/packages/core/src/database/migration/20260804233008_loose_psylocke.ts index 08c0ebf461..db553c959d 100644 --- a/packages/core/src/database/migration/20260804233008_loose_psylocke.ts +++ b/packages/core/src/database/migration/20260804233008_loose_psylocke.ts @@ -1,10 +1,43 @@ import { Effect } from "effect" +import { sql } from "drizzle-orm" import type { DatabaseMigration } from "../migration.js" +const previousV2Marker = "20260730195856_optional_session_title" + const migration: DatabaseMigration.Migration = { id: "20260804233008_loose_psylocke", up(tx) { return Effect.gen(function* () { + // This marker identifies the completed pre-split V2 lineage. Its V2 tables + // are canonical, so rename them in place instead of replaying the V1 squash. + if (yield* tx.get(sql`SELECT id FROM migration WHERE id = ${previousV2Marker}`)) { + const v1Only = yield* tx.get(sql` + SELECT 1 + FROM message + WHERE NOT EXISTS ( + SELECT 1 FROM session_message WHERE session_message.session_id = message.session_id + ) + LIMIT 1 + `) + if (v1Only) return yield* Effect.die(new Error("Previous V2 database contains V1-only session history")) + + yield* tx.run(`DROP INDEX IF EXISTS \`session_project_idx\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`session_workspace_idx\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`session_parent_idx\`;`) + yield* tx.run(`DROP INDEX IF EXISTS \`session_time_suspended_idx\`;`) + yield* tx.run(`ALTER TABLE \`session\` RENAME TO \`session_v2\`;`) + yield* tx.run(`CREATE INDEX \`session_v2_project_idx\` ON \`session_v2\` (\`project_id\`);`) + yield* tx.run(`CREATE INDEX \`session_v2_workspace_idx\` ON \`session_v2\` (\`workspace_id\`);`) + yield* tx.run(`CREATE INDEX \`session_v2_parent_idx\` ON \`session_v2\` (\`parent_id\`);`) + yield* tx.run( + `CREATE INDEX \`session_v2_time_suspended_idx\` ON \`session_v2\` (\`time_suspended\`) WHERE "session_v2"."time_suspended" is not null;`, + ) + yield* tx.run(`DROP TABLE IF EXISTS \`data_migration\`;`) + yield* tx.run(`DROP TABLE IF EXISTS \`session_context_epoch\`;`) + yield* tx.run(`DROP TABLE IF EXISTS \`session_input\`;`) + return + } + yield* tx.run(` CREATE TABLE IF NOT EXISTS \`kv\` ( \`key\` text PRIMARY KEY, diff --git a/packages/core/test/database-migration.test.ts b/packages/core/test/database-migration.test.ts index f36124bb2b..1a5460eb88 100644 --- a/packages/core/test/database-migration.test.ts +++ b/packages/core/test/database-migration.test.ts @@ -13,6 +13,10 @@ import { tmpdir } from "./fixture/tmpdir" import type { SqlClient } from "effect/unstable/sql/SqlClient" import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials" import worktreeMigration from "@opencode-ai/core/database/migration/20260812213948_worktree" +import previousV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke" +import workspaceMigration from "@opencode-ai/core/database/migration/20260808023530_workspace_domain" +import executionClaimsMigration from "@opencode-ai/core/database/migration/20260811161259_execution_claim_attempts" +import sessionInboxMigration from "@opencode-ai/core/database/migration/20260812181746_session_inbox" import { Global } from "@opencode-ai/util/global" const run = ( @@ -128,6 +132,142 @@ describe("DatabaseMigration", () => { ) }) + test("preserves previous V2 state through the current migration lineage", async () => { + await run( + Effect.gen(function* () { + const db = yield* makeDb + yield* db.run(sql`PRAGMA foreign_keys = ON`) + 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 ('20260730195856_optional_session_title', 1) + `) + yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY)`) + yield* db.run(sql` + CREATE TABLE project_directory ( + project_id text NOT NULL, + directory text NOT NULL, + type text, + strategy text, + time_created integer NOT NULL, + PRIMARY KEY (project_id, directory) + ) + `) + yield* db.run(sql` + CREATE TABLE workspace ( + id text PRIMARY KEY, + type text NOT NULL, + name text NOT NULL, + project_id text NOT NULL, + time_used integer NOT NULL + ) + `) + yield* db.run(sql` + CREATE TABLE session ( + id text PRIMARY KEY, + project_id text NOT NULL REFERENCES project(id) ON DELETE CASCADE, + workspace_id text, + parent_id text, + time_suspended integer + ) + `) + yield* db.run(sql`CREATE INDEX session_project_idx ON session (project_id)`) + yield* db.run(sql`CREATE INDEX session_workspace_idx ON session (workspace_id)`) + yield* db.run(sql`CREATE INDEX session_parent_idx ON session (parent_id)`) + yield* db.run( + sql`CREATE INDEX session_time_suspended_idx ON session (time_suspended) WHERE "session"."time_suspended" IS NOT NULL`, + ) + yield* db.run(sql` + CREATE TABLE session_message ( + id text PRIMARY KEY, + session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE, + data text NOT NULL + ) + `) + yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`) + yield* db.run(sql` + CREATE TABLE session_pending ( + id text PRIMARY KEY, + session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE + ) + `) + yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`) + yield* db.run(sql` + CREATE TABLE event ( + id text PRIMARY KEY, + aggregate_id text NOT NULL REFERENCES event_sequence(aggregate_id) ON DELETE CASCADE, + seq integer NOT NULL, + created integer NOT NULL, + type text NOT NULL, + data text NOT NULL + ) + `) + yield* db.run(sql`CREATE TABLE data_migration (name text PRIMARY KEY)`) + yield* db.run(sql`INSERT INTO project VALUES ('project')`) + yield* db.run(sql`INSERT INTO project_directory VALUES ('project', '/repo', 'main', NULL, 1)`) + yield* db.run(sql`INSERT INTO session VALUES ('session', 'project', NULL, NULL, NULL)`) + yield* db.run(sql`INSERT INTO session_message VALUES ('message', 'session', '{"text":"preserved"}')`) + yield* db.run(sql`INSERT INTO session_pending VALUES ('pending', 'session')`) + yield* db.run(sql`INSERT INTO event_sequence VALUES ('session', 41)`) + yield* db.run(sql`INSERT INTO event VALUES ('event', 'session', 41, 1, 'session.text.ended.1', '{}')`) + + yield* DatabaseMigration.applyOnly(db, [ + previousV2Migration, + workspaceMigration, + executionClaimsMigration, + sessionInboxMigration, + worktreeMigration, + ]) + + expect(yield* db.get(sql`SELECT id, resume_attempts FROM session_v2`)).toEqual({ + id: "session", + resume_attempts: 0, + }) + expect(yield* db.get(sql`SELECT id, data FROM session_message`)).toEqual({ + id: "message", + data: '{"text":"preserved"}', + }) + expect(yield* db.get(sql`SELECT id FROM session_pending`)).toEqual({ id: "pending" }) + expect(yield* db.get(sql`SELECT seq FROM event_sequence`)).toEqual({ seq: 41 }) + expect(yield* db.get(sql`SELECT id, seq FROM event`)).toEqual({ id: "event", seq: 41 }) + expect( + yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`), + ).toBeUndefined() + expect(yield* db.get(sql`SELECT directory FROM worktree`)).toEqual({ directory: "/repo" }) + expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_message)`)).toContainEqual( + expect.objectContaining({ table: "session_v2" }), + ) + expect(yield* db.all<{ table: string }>(sql`PRAGMA foreign_key_list(session_pending)`)).toContainEqual( + expect.objectContaining({ table: "session_v2" }), + ) + }), + ) + }) + + test("rejects previous V2 databases with V1-only session history", 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 ('20260730195856_optional_session_title', 1) + `) + yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`) + yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL)`) + yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL)`) + yield* db.run(sql`INSERT INTO session VALUES ('session')`) + yield* db.run(sql`INSERT INTO message VALUES ('message', 'session')`) + + expect((yield* Effect.exit(DatabaseMigration.applyOnly(db, [previousV2Migration])))._tag).toBe("Failure") + 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 id FROM migration WHERE id = ${previousV2Migration.id}`)).toBeUndefined() + }), + ) + }) + test("copies project directories into worktrees without removing the old table", async () => { await run( Effect.gen(function* () {