Skip to content

migration: V1 migration aborts on legacy message ID failing Session.Message.ID schema #54736

Open

Description

@maciejmalysz

Summary

Upgrading from V1 to v2.0.27 aborts the V1 data migration with Data migration failed / Schema validation failed. The cause is that the migration validates legacy message IDs against Session.Message.ID, which requires a string starting with msg_. At least one V1 message in my database does not match, and the whole migration dies rather than skipping or repairing the offending record. The result is that no V1 session history is imported.

Environment

  • opencode version: 2.0.27
  • OS: Darwin 27.0.0 (darwin arm64)
  • Terminal: TERM_PROGRAM=herdr, TERM=xterm-256color, COLORTERM=truecolor
  • Shell: /bin/zsh
  • Install/channel: latest
  • Active plugins: oh-my-opencode-slim, superpowers@git+https://github.com/obra/superpowers.git (both load successfully under V2)

Reproduction

  1. Use OpenCode V1 for an extended period, accumulating session history (this database dates back to roughly May 2026).
  2. Upgrade to v2.0.27.
  3. Start OpenCode.
  4. A Data migration failed — Schema validation failed. Caused by: [object Object] toast appears, and ~/.local/share/opencode/log/opencode.log records V1 migration failed.

Reproducible consistently: the migration is retried and fails identically on every service start.

Expected Behavior

The V1 migration either imports the history, or skips/repairs individual records it cannot validate and reports how many were dropped. A single malformed message ID should not abort the entire migration.

Actual Behavior

The migration dies. No V1 session history is available in V2. From opencode.log:

level=ERROR message="V1 migration failed" cause="Cause([Die(Error: Schema validation failed (cause: _c({
  "_tag":"Composite","ast":<String>,"issues":[up({
    "_tag":"Filter",
    "filter":xB({"_tag":"Filter","annotations":{
      "expected":"a string starting with \"msg_\"",
      "representation":{"id":"effect/schema/isStartingWith","payload":{"startsWith":"msg_"}},
      "arbitraryConstraint":{"patterns":[{"source":"^msg_","flags":""}]},
      "identifier":"Session.Message.ID"
    },"aborted":false}),
    "issue":nr({"_tag":"InvalidValue","annotations":undefined})
  })]})))])

Two usability problems on top of the underlying bug:

  1. The user-facing toast renders the cause as [object Object], so it conveys nothing actionable.
  2. The logged cause is a dump of minified Effect schema internals. It names the failing schema (Session.Message.ID) and the constraint, but it does not identify which message, session, or row failed, so there is no way to locate or repair the offending record. Including the offending value and its session ID would make this self-serviceable.

Additional Context

  • Frequency: consistent, on every start.
  • Workarounds tried: none. The troubleshooting guide says not to edit the database while diagnosing, so the V1 data has been left untouched.
  • Happy to provide more detail from the V1 database if you can tell me what to query, or to test a patch.

Activity

  1. opencode-agent commented on Oct 12, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report. I was able to reproduce this on v2.0.27, and it still happens on the latest dev (b9ca4d21b).

    Steps:

    1. Create a database containing V1 tables with one session and two messages, one with a normal msg_… ID and one with an ID that starts with msg but has no underscore (e.g. msg000000000002…).
    2. Start OpenCode.
    3. The log shows V1 migration failed with the same Session.Message.ID / a string starting with "msg_" error as yours, nothing is imported, and it fails the same way on every start.

    The script below reproduces it.

    What seems to happen: the migration's V1 schema only requires message IDs to start with msg, so that row is accepted as valid. It then fails when inserted into V2, which requires msg_. That error aborts the whole migration instead of skipping the one row.

    If you'd like to confirm that this matches your data, this read-only query lists such IDs in your V1 database:

    sqlite3 -readonly ~/.local/share/opencode/opencode.db "SELECT id, session_id FROM message WHERE id NOT LIKE 'msg\_%' ESCAPE '\';"

    The migration does not change your V1 tables, so your history is still in the database. Related: #53971 and #52226 (V1 messages that fail validation get silently dropped).

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #54736. Uses `opencode` from PATH, or set OPENCODE to another command (e.g. a source checkout).
    set -u
    OPENCODE=${OPENCODE:-opencode}
    W=$(mktemp -d); mkdir -p $W/{data,config,state,cache,home}
    export HOME=$W/home XDG_DATA_HOME=$W/data XDG_CONFIG_HOME=$W/config XDG_STATE_HOME=$W/state XDG_CACHE_HOME=$W/cache
    cat > $W/seed.ts <<'TS'
    import { Database } from "bun:sqlite"
    const db = new Database(process.argv[2])
    db.run(`DELETE FROM kv WHERE key = 'migration.v1-v2'`)
    db.run(`CREATE TABLE IF NOT EXISTS 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)`)
    db.run(`CREATE TABLE IF NOT EXISTS message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`)
    db.run(`CREATE TABLE IF NOT EXISTS 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)`)
    db.run(`INSERT OR REPLACE INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_v1test', 'global', 'v1', '/tmp', 'V1 session', '1', 1, 2)`)
    const data = JSON.stringify({ role: "user", time: { created: 10 }, agent: "build", model: { providerID: "p", modelID: "m" } })
    for (const id of ["msg_000000000001aaaaaaaaaaaaaa", "msg000000000002aaaaaaaaaaaaaa"]) {
      db.run(`INSERT OR REPLACE INTO message VALUES (?, 'ses_v1test', 10, 11, ?)`, [id, data])
      db.run(`INSERT OR REPLACE INTO part VALUES (?, ?, 'ses_v1test', 1, 2, ?)`, ["prt_" + id, id, JSON.stringify({ type: "text", text: "hi" })])
    }
    console.log("seeded", process.argv[2])
    TS
    timeout 20 $OPENCODE serve --port 4977 > $W/serve1.log 2>&1   # creates a fresh V2 database
    DB=$(ls $W/data/opencode/*.db | head -1)
    bun $W/seed.ts "$DB"   # adds V1 tables: one session, a msg_ message and a "msg..." message without underscore
    timeout 25 $OPENCODE serve --port 4977 --print-logs > $W/serve2.log 2>&1
    grep "V1 migration" $W/serve2.log | cut -c1-400

    Run it: bash repro.sh

  2. maciejmalysz commented on Oct 12, 2026

    @maciejmalysz
    Author

    Confirmed — your diagnosis matches my data exactly.

    The read-only query returns 9 rows in my V1 database (~5.1 GB):

    msgf256a9b0-a456-4e5d-9b38-e823589937e0|ses_eddbf7d95ffeiy23e8IrI5pstD
    msg63895598-4005-4d3d-afa8-b045e9044349|ses_ee2e5d7f7ffeD2rHiWqO6cJ5jv
    msg158bb2b9-b602-4c65-870f-01febdcda244|ses_ee80c386fffeMwgqqaYMd6GlsD
    msg7ebf4691-f997-445b-8102-1a23d9f45fbf|ses_eed328f19fferC1Gy63bMqldZn
    msg85034dee-d488-4a8f-a3b7-793cca2b2872|ses_ef258f067ffeXCmoyrUF3joY8V
    msg8ec43776-fbfd-4df4-9ff7-aaef46c2ec02|ses_ef77f550effeknkw4gPSl2HP7P
    msgf3a9825c-eea5-4d22-b459-e0987cb2b815|ses_ef77f6566ffebiJKgUs6yE2QHr
    msg645596e9-b382-49ce-a2f5-fc8638a4f7d0|ses_efca04e3cffemKrYnJ5bGHzOdc
    msg61fbc6cd-b701-4879-a52e-9b20562b3fc7|ses_f01cc0a04ffeFNLqsuooJ7bNd9
    

    All 9 are msg + a bare UUID (no underscore), and each sits in a different session. So 9 malformed rows are blocking the import of an entire multi-gigabyte history — which makes the abort-on-first-invalid-row behaviour especially costly here.

    Two notes on the fix, given the overlap with #53971 / #52226:

    • Skipping the row is better than aborting, but a silent skip would lose the message. Repairing the ID (e.g. normalising msg<uuid> to msg_<uuid>, or minting a fresh msg_ ID and rewriting the matching part.message_id) would preserve it. These IDs look like they came from a much older ID scheme rather than corruption, so a deterministic rewrite seems safe.
    • Whatever the behaviour, a summary line in the log (migrated N messages, repaired/skipped M) would make it obvious that something was dropped instead of leaving it silent.
  3. Dante-dan commented on Oct 12, 2026

    @Dante-dan
    Contributor

    I traced this to the V1/V2 ID prefix mismatch. I’m working on a focused migration fix that retains the V1 IDs while resolving parts and compaction relationships, then assigns deterministic, collision-safe V2 IDs to messages without the msg_ prefix. The source V1 tables remain unchanged.

  4. jorge-trujillo commented on Oct 13, 2026

    @jorge-trujillo

    Confirming on 2.0.20 (Linux x86_64, latest channel, Homebrew install, no plugins configured).

    My data

    The read-only query returns 21 malformed IDs spread across 17 sessions:

    sqlite3 -readonly ~/.local/share/opencode/opencode.db \
      "SELECT id, session_id FROM message WHERE substr(id,1,4) <> 'msg_';"

    Examples: msg1234, msg100, msg1000000, msg1000001, msg1773607006292, msg1775951960183.

    Unlike the msg<uuid> IDs above, these are msg + bare digits with no underscore. They sit in sessions whose titles look like API/client smoke tests ("Model inquiry", "Greeting and model inquiry"), so these IDs were most likely supplied by the caller through the V1 HTTP API rather than produced by an older internal ID scheme. So at least two distinct sources of malformed IDs exist in real databases.

    Migration state at the time of failure:

    • kv row migration.v1-v2 = {"phase":"sessions","cursor":"ses_30cc…"}
    • 187 of 328 V1 sessions imported into session_v2; the remaining 155 never imported
    • failure repeated on every service start, with a new V1 migration failed log line each time

    Two additional findings

    1. The malformed IDs are referenced structurally, so a fix has to repair more than message.id.

    In my database the 21 bad IDs are referenced by:

    • 21 part.message_id foreign keys
    • 31 message.data.parentID values inside the JSON payload

    Renaming only message.id would leave dangling parentID references. A repair needs to rewrite all three in one transaction. (part.data has no messageID field in V1 rows, and no compaction.tail_start_id pointed at a bad ID, but those are worth checking too.)

    2. Restarting the TUI does not retry the migration — this makes the bug look unfixable from the client side.

    The failure status is cached in the long-lived opencode serve --service process, and a relaunched TUI just reattaches to that same service. So the "Data migration failed" toast keeps reappearing on every launch with no new log entry, which reads as "still broken" even after the underlying data is fixed. opencode service restart is required to re-run the migration. Worth documenting, because the obvious first troubleshooting step (relaunch opencode) cannot clear it.

    Workaround that worked

    After a consistent snapshot via SQLite's online backup API, I renamed msg<rest> → msg_<rest>, repointed the 21 part.message_id values, and rewrote the 31 message.data.parentID references in a single transaction. No collisions resulted.

    After opencode service restart the migration completed: 342 sessions and 15,901 messages in session_v2, 0 malformed IDs, integrity_check and foreign_key_check clean, and all previously blocked sessions present.

    This supports the deterministic rewrite option over skipping — a skip would have silently dropped those messages and their replies. I also agree a migrated N, repaired M, skipped K summary line in the log is the most important part of the fix.

  5. marcello-russo commented on Oct 15, 2026

    @marcello-russo

    Real-world confirmation on 2.0.29 (Linux, background service, 12 GB DB with 3681 V1 sessions / 248k messages / 943k parts).

    Exactly one offending row, found with SELECT id, session_id FROM message WHERE id NOT GLOB 'msg_*';:

    msg-init1 | ses_fc6f3f75fffeBdu0PffUQs5fgW
    

    The migration stopped after ~52 sessions with the same Session.Message.ID ... starting with "msg_" error, and GET /api/experimental/migration/v1 never reached completed. Renaming it to msg_init1 (plus its part.message_id reference) and restarting the service let the migration complete: all 3681 sessions landed in session_v2, V1 tables untouched.

    Full step-by-step recovery guide (diagnosis queries, backup/rollback notes): https://gist.github.com/marcello-russo/0b3bd4872a9eac4b0c31ebf7dc332c01

  6. Dante-dan commented on Oct 15, 2026

    @Dante-dan
    Contributor

    The msg-init1 example is covered by #54937: the patch handles every V1 message ID without the msg_ prefix, including hyphenated IDs, rather than only the UUID examples above. It resolves parts, parent links and compaction relationships using the original IDs, then assigns deterministic, collision-safe IDs to the V2 projection; it does not rename rows in the V1 source tables.

    Your manual repair confirms the same prefix mismatch on another real database. It is separate from testing the PR itself; the PR remains open for review.

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