Skip to content

core: previous V2 import is skipped when a fresh target has no legacy session table #56732

Open

Description

@521ox

core: previous V2 import is skipped when a fresh target has no legacy session table

Summary

When an existing, importable opencode-next.db is beside a newly bootstrapped current database, previous-V2 import is skipped if the target lacks the legacy V1 session table. Both migration status and execution report completed, although no source sessions or messages were imported. The same source imports successfully when empty legacy tables are present.

Environment

  • Bun 1.4.2 (744846f84); Windows 10.0.22631, x64.
  • Executed custom source: 72d0dce8ea437ea061e0680110fa19a81aa118fd.
  • Official comparison: 63c1a27af5a391d9caa55b82694f496911e1859b; the affected importer implementation is identical.
  • Follow-up comparison against official dev 60a5a6c found only three AI-file changes relative to 63c1a27; the relevant source remains unchanged. That revision was not separately executed.
  • Fully private synthetic source/target databases, real SQLite driver and target bootstrap; no user database, service, credentials, provider, or external HTTP request was accessed. The custom schema's additional start_directory column does not change the missing-legacy-table condition.

Reproduction

Line references below use the executed custom commit.

  1. In a new private data directory, create opencode-next.db using the existing source fixture from packages/core/test/v1-migration.test.ts:934-970 (imports previous V2 sessions and messages containing apostrophes). It has the historical project, session, and session_message tables, with three source sessions and three messages. Close the source connection.
  2. Build a separate, initially nonexistent opencode.db using the actual Database.layer({ path: targetPath }) from packages/core/src/database/database.ts:67-79. Provide Global.Service with that private directory as data and other paths isolated. This runs the real current schema bootstrap. Confirm that the target contains session_v2 but no legacy session table.
  3. Do not call createLegacyTables in this case. The existing shared test helper at packages/core/test/v1-migration.test.ts:880-887 calls it unconditionally at line 885, masking the fresh-target condition. Reuse the source fixture, but omit this one legacy-table setup call for the failing case.
  4. With the bootstrapped target's Database.Service, call the actual V1Migration.status(), V1Migration.run(), then V1Migration.status(). Leave OPENCODE_DB unset, not :memory:, so run() discovers the private opencode-next.db through its normal path. Query target session_v2, session_message, and the kv row keyed by migration.v1-v2.
  5. Control: repeat with a second fresh target and the identical source fixture, but call the existing createLegacyTables helper at packages/core/test/v1-migration.test.ts:825-878 after bootstrap. No legacy rows need to be inserted. Compare source-file hashes before/after both cases.

Expected Behavior

The supported previous-V2 source is imported regardless of whether the destination has V1 tables. Status must not report completion while that applicable import is skipped; successful migration should record its completion marker using the current kv protocol.

Actual Behavior

Observation Empty legacy tables present No legacy tables
Status before execution required completed
Execution result / final status completed / completed completed / completed
Imported sessions / messages 3 / 3 0 / 0
kv["migration.v1-v2"] {"phase":"completed"} No row
Source file unchanged Yes Yes

The failing regression expected three imported sessions and received an empty array. The source remains intact: this is skipped import and false completion, not evidence of permanent data loss or of every upgrade being affected.

Additional Context

  • packages/core/src/database/v1-migration.bun.ts:493-501,534-541 returns completed when the destination lacks legacy session. That returns before previous-V2 discovery/import at 567-579. The legacy-table check is at 1130-1134; the default previous-V2 source path is at 702-705.
  • Real fresh-database bootstrap is at packages/core/src/database/migration.ts:22-46; it installs the current schema rather than a V1 session table.
  • The historical source shape is explicitly supported: official ancestor 0fbe489e357c5463bf2f5ef1b6cfcb619bab88cf, docs/design/v1-v2-database-migration.md:268-277, specifies importing pre-launch opencode-next.db and its three tables. Lines 282-284 discuss fresh-database migration. This is historical intent evidence, not a claim that its old endpoint/key details remain current. Current positive import tests also retain this source fixture.
  • The affected condition is an existing previous-V2 source plus a fresh destination without V1 tables. The prevalence of that installation/recovery condition was not measured. An initial duplicate search found no exact match; it was not exhaustive.

Suggested solution (not implemented)

Decouple previous-V2 source discovery/import from detection of V1 tables. Preserve the existing per-session transactions and collision/idempotency behavior; run V1 backfill only when its tables exist. Derive accurate completion status from all applicable work and write the current completion marker when that work succeeds. Add the no-legacy fresh-target case beside the existing legacy-table control; no migration-framework rewrite is needed.

Activity

  1. opencode-agent commented on Oct 18, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I can confirm this on the latest dev (60a5a6c), and v2.0.32 has the same code.

    To reproduce it, I created an opencode-next.db with the historical project/session/session_message tables and bootstrapped a fresh target with the current schema only. I then ran V1Migration.status(), run() and status() twice: once with empty legacy V1 tables in the target and once without them.

    • With empty legacy tables: status is required and the source session is imported.
    • Without legacy tables: status and run() both report completed, session_v2 and session_message stay empty, and no migration.v1-v2 row is written to kv.

    The cause looks like the early hasLegacySessions return in both status() and run() in packages/core/src/database/v1-migration.bun.ts. It happens before the previous-V2 import runs. The source database isn't modified. The test script below reproduces it.

    Reproduction script (fresh-target-import.test.ts)
    // Copy to packages/core/test/ in an opencode checkout and run: bun test test/fresh-target-import.test.ts
    import { expect, test } from "bun:test"
    import { SqliteClient } from "@effect/sql-sqlite-bun"
    import { EffectDrizzleSqlite } from "@opencode/core/database/drizzle"
    import { Database } from "@opencode/core/database/database"
    import { DatabaseMigration } from "@opencode/core/database/migration"
    import { V1Migration } from "@opencode/core/database/v1-migration"
    import { Global } from "@opencode/util/global"
    import { Effect } from "effect"
    import { sql } from "drizzle-orm"
    import { Database as Sqlite } from "bun:sqlite"
    import fs from "fs"
    import os from "os"
    import path from "path"
    
    function makeSource() {
      const dir = fs.mkdtempSync(path.join(os.tmpdir(), "oc-next-"))
      const filename = path.join(dir, "opencode-next.db")
      const s = new Sqlite(filename)
      s.run(`
        CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, icon_url text,
          icon_url_override text, icon_color text, time_created integer NOT NULL, time_updated integer NOT NULL,
          time_initialized integer, sandboxes text NOT NULL, commands text);
        CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, parent_id text,
          fork_session_id text, fork_boundary text, slug text NOT NULL, directory text NOT NULL, path text, title text,
          version text NOT NULL, share_url text, summary_additions integer, summary_deletions integer,
          summary_files integer, summary_diffs text, metadata text, cost real DEFAULT 0 NOT NULL,
          tokens_input integer DEFAULT 0 NOT NULL, tokens_output integer DEFAULT 0 NOT NULL,
          tokens_reasoning integer DEFAULT 0 NOT NULL, tokens_cache_read integer DEFAULT 0 NOT NULL,
          tokens_cache_write integer DEFAULT 0 NOT NULL, revert text, permission text, agent text, model text,
          time_created integer NOT NULL, time_updated integer NOT NULL, time_compacting integer,
          time_archived integer, time_suspended integer);
        CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL,
          seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL);
        INSERT INTO project VALUES ('next-project','/tmp/next','git','Source',NULL,NULL,NULL,1,2,NULL,'[]',NULL);
        INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated)
          VALUES ('ses_next','next-project','next','/tmp/next','Imported','2',10,20);
        INSERT INTO session_message VALUES ('msg_next','ses_next','user',0,12,13,'{"text":"hi","time":{"created":12}}');
      `)
      s.close()
      return { dir, filename }
    }
    
    async function scenario(withLegacy: boolean) {
      const src = makeSource()
      return Effect.runPromise(
        Effect.scoped(
          Effect.gen(function* () {
            const db = yield* EffectDrizzleSqlite.makeWithDefaults()
            yield* DatabaseMigration.apply(db) // real current-schema bootstrap: session_v2, no legacy `session`
            if (withLegacy)
              yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, project_id text NOT NULL, workspace_id text,
                parent_id text, slug text NOT NULL, directory text NOT NULL, path text, title text NOT NULL,
                version text NOT NULL, share_url text, summary_additions integer, summary_deletions integer,
                summary_files integer, summary_diffs text, metadata text, cost real DEFAULT 0 NOT NULL,
                tokens_input integer DEFAULT 0 NOT NULL, tokens_output integer DEFAULT 0 NOT NULL,
                tokens_reasoning integer DEFAULT 0 NOT NULL, tokens_cache_read integer DEFAULT 0 NOT NULL,
                tokens_cache_write integer DEFAULT 0 NOT NULL, revert text, permission text, agent text, model text,
                time_created integer NOT NULL, time_updated integer NOT NULL, time_compacting integer,
                time_archived integer)`)
            if (withLegacy) {
              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)`)
            }
            return yield* Effect.gen(function* () {
              const before = yield* V1Migration.status()
              const result = yield* V1Migration.run({ nextDatabasePath: src.filename })
              const after = yield* V1Migration.status()
              const sessions = yield* db.all(sql`SELECT id FROM session_v2`)
              const messages = yield* db.all(sql`SELECT id FROM session_message`)
              const kv = yield* db.all(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)
              return { before, result, after, sessions, messages, kv }
            }).pipe(Effect.provideService(Database.Service, { db }))
          }).pipe(
            Effect.provideService(Global.Service, Global.make({ data: src.dir })),
            Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
          ),
        ),
      )
    }
    
    test("control: fresh target WITH empty legacy tables imports previous V2 db", async () => {
      const r = await scenario(true)
      console.log("with legacy:", JSON.stringify(r))
      expect(r.sessions).toEqual([{ id: "ses_next" }])
    })
    
    test("bug: fresh target WITHOUT legacy tables skips previous V2 import", async () => {
      const r = await scenario(false)
      console.log("without legacy:", JSON.stringify(r))
      expect(r.sessions).toEqual([{ id: "ses_next" }])
    })

    Run it: bun fresh-target-import.test.ts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions