瀏覽代碼

refactor(opencode): remove JSON storage migration (#30461)

Dax 3 月之前
父節點
當前提交
ca2acc4f8d

+ 0 - 1
bun.lock

@@ -315,7 +315,6 @@
       "version": "1.15.13",
       "dependencies": {
         "@zip.js/zip.js": "2.7.62",
-        "drizzle-orm": "catalog:",
         "effect": "catalog:",
         "electron-context-menu": "4.1.2",
         "electron-log": "^5",

+ 0 - 1
packages/desktop/electron.vite.config.ts

@@ -88,7 +88,6 @@ export default defineConfig({
       rollupOptions: {
         input: {
           main: "src/renderer/index.html",
-          loading: "src/renderer/loading.html",
         },
       },
     },

+ 0 - 1
packages/desktop/package.json

@@ -31,7 +31,6 @@
     "electron-store": "^10",
     "electron-updater": "^6",
     "electron-window-state": "^5.0.3",
-    "drizzle-orm": "catalog:",
     "marked": "^15"
   },
   "devDependencies": {

+ 0 - 8
packages/desktop/src/main/env.d.ts

@@ -18,13 +18,5 @@ declare module "virtual:opencode-server" {
   export namespace Log {
     export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
   }
-  export namespace Database {
-    export const getPath: typeof import("../../../opencode/dist/types/src/node").Database.getPath
-    export const Client: typeof import("../../../opencode/dist/types/src/node").Database.Client
-  }
-  export namespace JsonMigration {
-    export type Progress = import("../../../opencode/dist/types/src/node").JsonMigration.Progress
-    export const run: typeof import("../../../opencode/dist/types/src/node").JsonMigration.run
-  }
   export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
 }

+ 8 - 63
packages/desktop/src/main/index.ts

@@ -1,6 +1,5 @@
 import { randomUUID } from "node:crypto"
-import { EventEmitter } from "node:events"
-import { existsSync, mkdirSync, rmSync } from "node:fs"
+import { mkdirSync, rmSync } from "node:fs"
 import * as http from "node:http"
 import { createServer } from "node:net"
 import { homedir, tmpdir } from "node:os"
@@ -11,10 +10,10 @@ import { app, BrowserWindow } from "electron"
 
 import contextMenu from "electron-context-menu"
 
-import type { InitStep, ServerReadyData, SqliteMigrationProgress, WslConfig } from "../preload/types"
+import type { ServerReadyData, WslConfig } from "../preload/types"
 import { checkAppExists, resolveAppPath, wslPath } from "./apps"
 import { CHANNEL, UPDATER_ENABLED } from "./constants"
-import { registerIpcHandlers, sendDeepLinks, sendMenuCommand, sendSqliteMigrationProgress } from "./ipc"
+import { registerIpcHandlers, sendDeepLinks, sendMenuCommand } from "./ipc"
 import { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
 import { parseMarkdown } from "./markdown"
 import { createMenu } from "./menu"
@@ -28,7 +27,6 @@ import {
   type SidecarListener,
 } from "./server"
 import {
-  createLoadingWindow,
   createMainWindow,
   registerRendererProtocol,
   setRelaunchHandler,
@@ -56,9 +54,6 @@ let logger: ReturnType<typeof initLogging>
 let mainWindow: BrowserWindow | null = null
 let server: SidecarListener | null = null
 
-const initEmitter = new EventEmitter()
-let initStep: InitStep = { phase: "server_waiting" }
-
 const pendingDeepLinks: string[] = []
 
 function useEnvProxy() {
@@ -76,12 +71,6 @@ function emitDeepLinks(urls: string[]) {
   if (mainWindow) sendDeepLinks(mainWindow, urls)
 }
 
-function setInitStep(step: InitStep) {
-  initStep = step
-  logger.log("init step", { step })
-  initEmitter.emit("step", step)
-}
-
 async function killSidecar() {
   if (!server) return
   const current = server
@@ -219,23 +208,15 @@ const main = Effect.gen(function* () {
   }
 
   const serverReady = Deferred.makeUnsafe<ServerReadyData>()
-  const loadingComplete = Deferred.makeUnsafe<void>()
 
   registerIpcHandlers({
     killSidecar: () => killSidecar(),
     awaitInitialization: Effect.fnUntraced(
-      function* (sendStep) {
-        sendStep(initStep)
-        const listener = (step: InitStep) => sendStep(step)
-        initEmitter.on("step", listener)
-        try {
-          logger.log("awaiting server ready")
-          const res = yield* Deferred.await(serverReady)
-          logger.log("server ready", { url: res.url })
-          return res
-        } finally {
-          initEmitter.off("step", listener)
-        }
+      function* () {
+        logger.log("awaiting server ready")
+        const res = yield* Deferred.await(serverReady)
+        logger.log("server ready", { url: res.url })
+        return res
       },
       (e) => Effect.runPromise(e),
     ),
@@ -251,7 +232,6 @@ const main = Effect.gen(function* () {
     checkAppExists: (appName) => checkAppExists(appName),
     wslPath: async (path, mode) => wslPath(path, mode),
     resolveAppPath: async (appName) => resolveAppPath(appName),
-    loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void),
     runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar),
     checkUpdate: async () => checkUpdate(),
     installUpdate: async () => installUpdate(killSidecar),
@@ -275,15 +255,6 @@ const main = Effect.gen(function* () {
     ),
   )
 
-  const needsMigration = ((): boolean => {
-    if (process.env.OPENCODE_DB === ":memory:") return false
-
-    const xdg = process.env.XDG_DATA_HOME
-    const base = xdg && xdg.length > 0 ? xdg : join(homedir(), ".local", "share")
-    return !existsSync(join(base, "opencode", "opencode.db"))
-  })()
-  let overlay: BrowserWindow | null = null
-
   const port = yield* Effect.gen(function* () {
     const fromEnv = process.env.OPENCODE_PORT
     if (fromEnv) {
@@ -314,21 +285,13 @@ const main = Effect.gen(function* () {
   const loadingTask = yield* Effect.gen(function* () {
     logger.log("sidecar connection started", { url })
 
-    initEmitter.on("sqlite", (progress: SqliteMigrationProgress) => {
-      setInitStep({ phase: "sqlite_waiting" })
-      if (overlay) sendSqliteMigrationProgress(overlay, progress)
-      if (mainWindow) sendSqliteMigrationProgress(mainWindow, progress)
-    })
-
     ensureLoopbackNoProxy()
     useEnvProxy()
 
     logger.log("spawning sidecar", { url })
     const { listener, health } = yield* Effect.promise(() =>
       spawnLocalServer(hostname, port, password, {
-        needsMigration,
         userDataPath: app.getPath("userData"),
-        onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
         onStdout: (message) => writeLog("server", "stdout", { message }),
         onStderr: (message) => writeLog("server", "stderr", { message }, "warn"),
         onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"),
@@ -353,23 +316,7 @@ const main = Effect.gen(function* () {
     logger.log("loading task finished")
   }).pipe(Effect.forkChild)
 
-  if (needsMigration) {
-    const show = yield* loadingTask.pipe(
-      Fiber.await,
-      Effect.timeout("1 second"),
-      Effect.as(false),
-      Effect.catch(() => Effect.succeed(true)),
-    )
-    if (show) {
-      overlay = createLoadingWindow()
-      yield* Effect.sleep("1 second")
-    }
-  }
-
   yield* Fiber.await(loadingTask)
-  setInitStep({ phase: "done" })
-
-  if (overlay) yield* Deferred.await(loadingComplete)
 
   mainWindow = createMainWindow()
   if (mainWindow) {
@@ -389,8 +336,6 @@ const main = Effect.gen(function* () {
       },
     })
   }
-
-  overlay?.close()
 })
 
 Effect.runFork(main)

+ 2 - 13
packages/desktop/src/main/ipc.ts

@@ -4,10 +4,8 @@ import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
 import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
 
 import type {
-  InitStep,
   FatalRendererError,
   ServerReadyData,
-  SqliteMigrationProgress,
   TitlebarTheme,
   WindowConfig,
   WslConfig,
@@ -23,7 +21,7 @@ const pickerFilters = (ext?: string[]) => {
 
 type Deps = {
   killSidecar: () => Promise<void> | void
-  awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
+  awaitInitialization: () => Promise<ServerReadyData>
   getWindowConfig: () => Promise<WindowConfig> | WindowConfig
   consumeInitialDeepLinks: () => Promise<string[]> | string[]
   getDefaultServerUrl: () => Promise<string | null> | string | null
@@ -36,7 +34,6 @@ type Deps = {
   checkAppExists: (appName: string) => Promise<boolean> | boolean
   wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
   resolveAppPath: (appName: string) => Promise<string | null>
-  loadingWindowComplete: () => void
   runUpdater: (alertOnFail: boolean) => Promise<void> | void
   checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
   installUpdate: () => Promise<void> | void
@@ -47,10 +44,7 @@ type Deps = {
 
 export function registerIpcHandlers(deps: Deps) {
   ipcMain.handle("kill-sidecar", () => deps.killSidecar())
-  ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
-    const send = (step: InitStep) => event.sender.send("init-step", step)
-    return deps.awaitInitialization(send)
-  })
+  ipcMain.handle("await-initialization", () => deps.awaitInitialization())
   ipcMain.handle("get-window-config", () => deps.getWindowConfig())
   ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
   ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
@@ -69,7 +63,6 @@ export function registerIpcHandlers(deps: Deps) {
     deps.wslPath(path, mode),
   )
   ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
-  ipcMain.on("loading-window-complete", () => deps.loadingWindowComplete())
   ipcMain.handle("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
   ipcMain.handle("check-update", () => deps.checkUpdate())
   ipcMain.handle("install-update", () => deps.installUpdate())
@@ -216,10 +209,6 @@ export function registerIpcHandlers(deps: Deps) {
   })
 }
 
-export function sendSqliteMigrationProgress(win: BrowserWindow, progress: SqliteMigrationProgress) {
-  win.webContents.send("sqlite-migration-progress", progress)
-}
-
 export function sendMenuCommand(win: BrowserWindow, id: string) {
   win.webContents.send("menu-command", id)
 }

+ 0 - 10
packages/desktop/src/main/server.ts

@@ -5,14 +5,12 @@ import type { Details } from "electron"
 import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
 import { getUserShell, loadShellEnv } from "./shell-env"
 import { getStore } from "./store"
-import type { SqliteMigrationProgress } from "../preload/types"
 
 export type WslConfig = { enabled: boolean }
 
 export type HealthCheck = { wait: Promise<void> }
 
 type SidecarMessage =
-  | { type: "sqlite"; progress: SqliteMigrationProgress }
   | { type: "ready" }
   | { type: "stopped" }
   | { type: "error"; error: { message: string; stack?: string } }
@@ -24,9 +22,7 @@ const SIDECAR_START_STALL_TIMEOUT = 60_000
 const SIDECAR_STOP_TIMEOUT = 6_000
 
 type SpawnLocalServerOptions = {
-  needsMigration: boolean
   userDataPath: string
-  onSqliteProgress?: (progress: SqliteMigrationProgress) => void
   onStdout?: (message: string) => void
   onStderr?: (message: string) => void
   onExit?: (code: number) => void
@@ -118,11 +114,6 @@ export async function spawnLocalServer(
     }
 
     const onMessage = (message: SidecarMessage) => {
-      if (message.type === "sqlite") {
-        refreshTimeout()
-        options.onSqliteProgress?.(message.progress)
-        return
-      }
       if (message.type === "ready") {
         if (done) return
         done = true
@@ -152,7 +143,6 @@ export async function spawnLocalServer(
       port,
       password,
       userDataPath: options.userDataPath,
-      needsMigration: options.needsMigration,
     })
   }).catch((error) => {
     if (!exited) child.kill()

+ 1 - 21
packages/desktop/src/main/sidecar.ts

@@ -1,4 +1,3 @@
-import { drizzle } from "drizzle-orm/node-sqlite/driver"
 import * as http from "node:http"
 import * as tls from "node:tls"
 
@@ -17,14 +16,12 @@ type StartCommand = {
   port: number
   password: string
   userDataPath: string
-  needsMigration: boolean
 }
 
 type StopCommand = { type: "stop" }
 type SidecarCommand = StartCommand | StopCommand
 
 type SidecarMessage =
-  | { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } }
   | { type: "ready" }
   | { type: "stopped" }
   | { type: "error"; error: { message: string; stack?: string } }
@@ -57,24 +54,9 @@ async function start(command: StartCommand) {
     ensureLoopbackNoProxy()
     useSystemCertificates()
     useEnvProxy()
-    const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server")
+    const { Log, Server } = await import("virtual:opencode-server")
     await Log.init({ level: "WARN" })
 
-    if (command.needsMigration) {
-      await JsonMigration.run(drizzle({ client: Database.Client().$client }), {
-        progress: (event: { current: number; total: number }) => {
-          parentPort.postMessage({
-            type: "sqlite",
-            progress: {
-              type: "InProgress",
-              value: event.total === 0 ? 100 : Math.round((event.current / event.total) * 100),
-            },
-          })
-        },
-      })
-      parentPort.postMessage({ type: "sqlite", progress: { type: "Done" } })
-    }
-
     listener = await Server.listen({
       port: command.port,
       hostname: command.hostname,
@@ -155,14 +137,12 @@ function parseCommand(value: unknown): SidecarCommand | undefined {
   if (typeof command.port !== "number") return
   if (typeof command.password !== "string") return
   if (typeof command.userDataPath !== "string") return
-  if (typeof command.needsMigration !== "boolean") return
   return {
     type: "start",
     hostname: command.hostname,
     port: command.port,
     password: command.password,
     userDataPath: command.userDataPath,
-    needsMigration: command.needsMigration,
   }
 }
 

+ 0 - 35
packages/desktop/src/main/windows.ts

@@ -181,41 +181,6 @@ export function createMainWindow() {
   return win
 }
 
-export function createLoadingWindow() {
-  const mode = tone()
-  const win = new BrowserWindow({
-    width: 640,
-    height: 480,
-    resizable: false,
-    center: true,
-    show: true,
-    autoHideMenuBar: true,
-    icon: iconPath(),
-    backgroundColor: backgroundColor ?? defaultBackgroundColor(),
-    ...(process.platform === "darwin" ? { titleBarStyle: "hidden" as const } : {}),
-    ...(process.platform === "win32"
-      ? {
-          frame: false,
-          titleBarStyle: "hidden" as const,
-          titleBarOverlay: overlay({ mode }),
-        }
-      : {}),
-    webPreferences: {
-      preload: join(root, "../preload/index.js"),
-      contextIsolation: true,
-      nodeIntegration: false,
-      sandbox: true,
-    },
-  })
-
-  allowRendererPermissions(win)
-  wireWindowRecovery(win, "loading")
-
-  loadWindow(win, "loading.html")
-
-  return win
-}
-
 export function registerRendererProtocol() {
   if (protocol.isProtocolHandled(rendererProtocol)) return
 

+ 2 - 14
packages/desktop/src/preload/index.ts

@@ -1,16 +1,10 @@
 import { contextBridge, ipcRenderer } from "electron"
-import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
+import type { ElectronAPI } from "./types"
 
 const api: ElectronAPI = {
   killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
   installCli: () => ipcRenderer.invoke("install-cli"),
-  awaitInitialization: (onStep) => {
-    const handler = (_: unknown, step: InitStep) => onStep(step)
-    ipcRenderer.on("init-step", handler)
-    return ipcRenderer.invoke("await-initialization").finally(() => {
-      ipcRenderer.removeListener("init-step", handler)
-    })
-  },
+  awaitInitialization: () => ipcRenderer.invoke("await-initialization"),
   getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
   consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
   getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
@@ -31,11 +25,6 @@ const api: ElectronAPI = {
   storeLength: (name) => ipcRenderer.invoke("store-length", name),
 
   getWindowCount: () => ipcRenderer.invoke("get-window-count"),
-  onSqliteMigrationProgress: (cb) => {
-    const handler = (_: unknown, progress: SqliteMigrationProgress) => cb(progress)
-    ipcRenderer.on("sqlite-migration-progress", handler)
-    return () => ipcRenderer.removeListener("sqlite-migration-progress", handler)
-  },
   onMenuCommand: (cb) => {
     const handler = (_: unknown, id: string) => cb(id)
     ipcRenderer.on("menu-command", handler)
@@ -74,7 +63,6 @@ const api: ElectronAPI = {
   },
   setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
   runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
-  loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
   runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
   checkUpdate: () => ipcRenderer.invoke("check-update"),
   installUpdate: () => ipcRenderer.invoke("install-update"),

+ 1 - 7
packages/desktop/src/preload/types.ts

@@ -1,15 +1,11 @@
 import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
 
-export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
-
 export type ServerReadyData = {
   url: string
   username: string | null
   password: string | null
 }
 
-export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
-
 export type WslConfig = { enabled: boolean }
 
 export type LinuxDisplayBackend = "wayland" | "auto"
@@ -31,7 +27,7 @@ export type FatalRendererError = {
 export type ElectronAPI = {
   killSidecar: () => Promise<void>
   installCli: () => Promise<string>
-  awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
+  awaitInitialization: () => Promise<ServerReadyData>
   getWindowConfig: () => Promise<WindowConfig>
   consumeInitialDeepLinks: () => Promise<string[]>
   getDefaultServerUrl: () => Promise<string | null>
@@ -52,7 +48,6 @@ export type ElectronAPI = {
   storeLength: (name: string) => Promise<number>
 
   getWindowCount: () => Promise<number>
-  onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void
   onMenuCommand: (cb: (id: string) => void) => () => void
   onDeepLink: (cb: (urls: string[]) => void) => () => void
 
@@ -85,7 +80,6 @@ export type ElectronAPI = {
   onZoomFactorChanged: (cb: (factor: number) => void) => () => void
   setTitlebar: (theme: TitlebarTheme) => Promise<void>
   runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
-  loadingWindowComplete: () => void
   runUpdater: (alertOnFail: boolean) => Promise<void>
   checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
   installUpdate: () => Promise<void>

+ 1 - 1
packages/desktop/src/renderer/html.test.ts

@@ -16,7 +16,7 @@ const html = async (name: string) => Bun.file(join(dir, name)).text()
  * All local resource references must use relative paths (`./`).
  */
 describe("electron renderer html", () => {
-  for (const name of ["index.html", "loading.html"]) {
+  for (const name of ["index.html"]) {
     describe(name, () => {
       test("script src attributes use relative paths", async () => {
         const content = await html(name)

+ 1 - 1
packages/desktop/src/renderer/index.tsx

@@ -319,7 +319,7 @@ render(() => {
   const [windowCount] = createResource(() => window.api.getWindowCount())
 
   // Fetch sidecar credentials (available immediately, before health check)
-  const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
+  const [sidecar] = createResource(() => window.api.awaitInitialization())
 
   const [defaultServer] = createResource(() =>
     platform.getDefaultServer?.().then((url) => {

+ 0 - 21
packages/desktop/src/renderer/loading.html

@@ -1,21 +0,0 @@
-<!doctype html>
-<html lang="en" style="background-color: var(--background-base)">
-  <head>
-    <meta charset="utf-8" />
-    <meta name="viewport" content="width=device-width, initial-scale=1" />
-    <title>OpenCode</title>
-    <link rel="icon" type="image/png" href="./favicon-96x96-v3.png" sizes="96x96" />
-    <link rel="icon" type="image/svg+xml" href="./favicon-v3.svg" />
-    <link rel="shortcut icon" href="./favicon-v3.ico" />
-    <link rel="apple-touch-icon" sizes="180x180" href="./apple-touch-icon-v3.png" />
-    <meta name="theme-color" content="#F8F7F7" />
-    <meta property="og:image" content="./social-share.png" />
-    <meta property="twitter:image" content="./social-share.png" />
-    <script id="oc-theme-preload-script" src="./oc-theme-preload.js"></script>
-  </head>
-  <body class="antialiased overscroll-none text-12-regular overflow-hidden">
-    <noscript>You need to enable JavaScript to run this app.</noscript>
-    <div id="root" class="flex flex-col h-dvh"></div>
-    <script src="./loading.tsx" type="module"></script>
-  </body>
-</html>

+ 0 - 83
packages/desktop/src/renderer/loading.tsx

@@ -1,83 +0,0 @@
-import { MetaProvider } from "@solidjs/meta"
-import { render } from "solid-js/web"
-import "@opencode-ai/app/index.css"
-import { Font } from "@opencode-ai/ui/font"
-import { Splash } from "@opencode-ai/ui/logo"
-import { Progress } from "@opencode-ai/ui/progress"
-import "./styles.css"
-import { createEffect, createMemo, createSignal, onCleanup, onMount } from "solid-js"
-import type { InitStep, SqliteMigrationProgress } from "../preload/types"
-
-const root = document.getElementById("root")!
-const lines = ["Just a moment...", "Migrating your database", "This may take a couple of minutes"]
-const delays = [3000, 9000]
-
-render(() => {
-  const [step, setStep] = createSignal<InitStep | null>(null)
-  const [line, setLine] = createSignal(0)
-  const [percent, setPercent] = createSignal(0)
-
-  const phase = createMemo(() => step()?.phase)
-
-  const value = createMemo(() => {
-    if (phase() === "done") return 100
-    return Math.max(25, Math.min(100, percent()))
-  })
-
-  window.api.awaitInitialization((next) => setStep(next as InitStep)).catch(() => undefined)
-
-  onMount(() => {
-    setLine(0)
-    setPercent(0)
-
-    const timers = delays.map((ms, i) => setTimeout(() => setLine(i + 1), ms))
-
-    const listener = window.api.onSqliteMigrationProgress((progress: SqliteMigrationProgress) => {
-      if (progress.type === "InProgress") setPercent(Math.max(0, Math.min(100, progress.value)))
-      if (progress.type === "Done") {
-        setPercent(100)
-        setStep({ phase: "done" })
-      }
-    })
-
-    onCleanup(() => {
-      listener()
-      timers.forEach(clearTimeout)
-    })
-  })
-
-  createEffect(() => {
-    if (phase() !== "done") return
-
-    const timer = setTimeout(() => window.api.loadingWindowComplete(), 1000)
-    onCleanup(() => clearTimeout(timer))
-  })
-
-  const status = createMemo(() => {
-    if (phase() === "done") return "All done"
-    if (phase() === "sqlite_waiting") return lines[line()]
-    return "Just a moment..."
-  })
-
-  return (
-    <MetaProvider>
-      <div class="w-screen h-screen bg-background-base flex items-center justify-center">
-        <Font />
-        <div class="flex flex-col items-center gap-11">
-          <Splash class="w-20 h-25 opacity-15" />
-          <div class="w-60 flex flex-col items-center gap-4" aria-live="polite">
-            <span class="w-full overflow-hidden text-center text-ellipsis whitespace-nowrap text-text-strong text-14-normal">
-              {status()}
-            </span>
-            <Progress
-              value={value()}
-              class="w-20 [&_[data-slot='progress-track']]:h-1 [&_[data-slot='progress-track']]:border-0 [&_[data-slot='progress-track']]:rounded-none [&_[data-slot='progress-track']]:bg-surface-weak [&_[data-slot='progress-fill']]:rounded-none [&_[data-slot='progress-fill']]:bg-icon-warning-base"
-              aria-label="Database migration progress"
-              getValueLabel={({ value }) => `${Math.round(value)}%`}
-            />
-          </div>
-        </div>
-      </div>
-    </MetaProvider>
-  )
-}, root)

+ 0 - 43
packages/opencode/src/index.ts

@@ -15,7 +15,6 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
 import { NamedError } from "@opencode-ai/core/util/error"
 import { FormatError } from "./cli/error"
 import { ServeCommand } from "./cli/cmd/serve"
-import { Filesystem } from "@/util/filesystem"
 import { DebugCommand } from "./cli/cmd/debug"
 import { StatsCommand } from "./cli/cmd/stats"
 import { McpCommand } from "./cli/cmd/mcp"
@@ -30,13 +29,9 @@ import { WebCommand } from "./cli/cmd/web"
 import { PrCommand } from "./cli/cmd/pr"
 import { SessionCommand } from "./cli/cmd/session"
 import { DbCommand } from "./cli/cmd/db"
-import { Global } from "@opencode-ai/core/global"
-import { JsonMigration } from "@/storage/json-migration"
-import { Database } from "@opencode-ai/core/database/database"
 import { errorMessage } from "./util/error"
 import { PluginCommand } from "./cli/cmd/plug"
 import { Heap } from "./cli/heap"
-import { drizzle } from "drizzle-orm/bun-sqlite"
 import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
 import { isRecord } from "@/util/record"
 
@@ -115,44 +110,6 @@ const cli = yargs(args)
       run_id: processMetadata.runID,
     })
 
-    const marker = Database.path()
-    if (!(await Filesystem.exists(marker))) {
-      const tty = process.stderr.isTTY
-      process.stderr.write("Performing one time database migration, may take a few minutes..." + EOL)
-      const width = 36
-      const orange = "\x1b[38;5;214m"
-      const muted = "\x1b[0;2m"
-      const reset = "\x1b[0m"
-      let last = -1
-      if (tty) process.stderr.write("\x1b[?25l")
-      const sqlite = new (await import("bun:sqlite")).Database(marker)
-      try {
-        await JsonMigration.run(drizzle({ client: sqlite }), {
-          progress: (event) => {
-            const percent = Math.floor((event.current / event.total) * 100)
-            if (percent === last && event.current !== event.total) return
-            last = percent
-            if (tty) {
-              const fill = Math.round((percent / 100) * width)
-              const bar = `${"■".repeat(fill)}${"・".repeat(width - fill)}`
-              process.stderr.write(
-                `\r${orange}${bar} ${percent.toString().padStart(3)}%${reset} ${muted}${event.label.padEnd(12)} ${event.current}/${event.total}${reset}`,
-              )
-              if (event.current === event.total) process.stderr.write("\n")
-            } else {
-              process.stderr.write(`sqlite-migration:${percent}${EOL}`)
-            }
-          },
-        })
-      } finally {
-        sqlite.close()
-        if (tty) process.stderr.write("\x1b[?25h")
-        else {
-          process.stderr.write(`sqlite-migration:done${EOL}`)
-        }
-      }
-      process.stderr.write("Database migration complete." + EOL)
-    }
   })
   .usage("")
   .completion("completion", "generate shell completion script")

+ 0 - 1
packages/opencode/src/node.ts

@@ -3,4 +3,3 @@ export { Server } from "./server/server"
 export { bootstrap } from "./cli/bootstrap"
 export * as Log from "@opencode-ai/core/util/log"
 export { Database } from "@opencode-ai/core/database/database"
-export { JsonMigration } from "@/storage/json-migration"

+ 0 - 409
packages/opencode/src/storage/json-migration.ts

@@ -1,409 +0,0 @@
-import type { SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
-import type { NodeSQLiteDatabase } from "drizzle-orm/node-sqlite"
-import { Global } from "@opencode-ai/core/global"
-import * as Log from "@opencode-ai/core/util/log"
-import { ProjectTable } from "@opencode-ai/core/project/sql"
-import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
-import { SessionShareTable } from "@opencode-ai/core/share/sql"
-import path from "path"
-import { existsSync } from "fs"
-import { Filesystem } from "@/util/filesystem"
-import { Glob } from "@opencode-ai/core/util/glob"
-
-const log = Log.create({ service: "json-migration" })
-
-export type Progress = {
-  current: number
-  total: number
-  label: string
-}
-
-type Options = {
-  progress?: (event: Progress) => void
-}
-
-export async function run(db: SQLiteBunDatabase<any, any> | NodeSQLiteDatabase<any, any>, options?: Options) {
-  const storageDir = path.join(Global.Path.data, "storage")
-
-  if (!existsSync(storageDir)) {
-    log.info("storage directory does not exist, skipping migration")
-    return {
-      projects: 0,
-      sessions: 0,
-      messages: 0,
-      parts: 0,
-      todos: 0,
-      permissions: 0,
-      shares: 0,
-      errors: [] as string[],
-    }
-  }
-
-  log.info("starting json to sqlite migration", { storageDir })
-  const start = performance.now()
-
-  // const db = drizzle({ client: sqlite })
-
-  // Optimize SQLite for bulk inserts
-  db.run("PRAGMA journal_mode = WAL")
-  db.run("PRAGMA synchronous = OFF")
-  db.run("PRAGMA cache_size = 10000")
-  db.run("PRAGMA temp_store = MEMORY")
-  const stats = {
-    projects: 0,
-    sessions: 0,
-    messages: 0,
-    parts: 0,
-    todos: 0,
-    permissions: 0,
-    shares: 0,
-    errors: [] as string[],
-  }
-  const orphans = {
-    sessions: 0,
-    todos: 0,
-    permissions: 0,
-    shares: 0,
-  }
-  const errs = stats.errors
-
-  const batchSize = 1000
-  const now = Date.now()
-
-  async function list(pattern: string) {
-    return Glob.scan(pattern, { cwd: storageDir, absolute: true })
-  }
-
-  async function read(files: string[], start: number, end: number) {
-    const count = end - start
-    // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
-    const tasks = new Array(count)
-    for (let i = 0; i < count; i++) {
-      tasks[i] = Filesystem.readJson(files[start + i])
-    }
-    const results = await Promise.allSettled(tasks)
-    // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
-    const items = new Array(count)
-    for (let i = 0; i < results.length; i++) {
-      const result = results[i]
-      if (result.status === "fulfilled") {
-        items[i] = result.value
-        continue
-      }
-      errs.push(`failed to read ${files[start + i]}: ${result.reason}`)
-    }
-    return items
-  }
-
-  function insert(values: unknown[], table: Parameters<typeof db.insert>[0], label: string) {
-    if (values.length === 0) return 0
-    try {
-      db.insert(table).values(values).onConflictDoNothing().run()
-      return values.length
-    } catch (e) {
-      errs.push(`failed to migrate ${label} batch: ${e}`)
-      return 0
-    }
-  }
-
-  // Pre-scan all files upfront to avoid repeated glob operations
-  log.info("scanning files...")
-  const [projectFiles, sessionFiles, messageFiles, partFiles, todoFiles, shareFiles] = await Promise.all([
-    list("project/*.json"),
-    list("session/*/*.json"),
-    list("message/*/*.json"),
-    list("part/*/*.json"),
-    list("todo/*.json"),
-    list("session_share/*.json"),
-  ])
-
-  log.info("file scan complete", {
-    projects: projectFiles.length,
-    sessions: sessionFiles.length,
-    messages: messageFiles.length,
-    parts: partFiles.length,
-    todos: todoFiles.length,
-    shares: shareFiles.length,
-  })
-
-  const total = Math.max(
-    1,
-    projectFiles.length +
-      sessionFiles.length +
-      messageFiles.length +
-      partFiles.length +
-      todoFiles.length +
-      shareFiles.length,
-  )
-  const progress = options?.progress
-  let current = 0
-  const step = (label: string, count: number) => {
-    current = Math.min(total, current + count)
-    progress?.({ current, total, label })
-  }
-
-  progress?.({ current, total, label: "starting" })
-
-  db.run("BEGIN TRANSACTION")
-
-  // Migrate projects first (no FK deps)
-  // Derive all IDs from file paths, not JSON content
-  const projectIds = new Set<string>()
-  const projectValues: unknown[] = []
-  for (let i = 0; i < projectFiles.length; i += batchSize) {
-    const end = Math.min(i + batchSize, projectFiles.length)
-    const batch = await read(projectFiles, i, end)
-    projectValues.length = 0
-    for (let j = 0; j < batch.length; j++) {
-      const data = batch[j]
-      if (!data) continue
-      const id = path.basename(projectFiles[i + j], ".json")
-      projectIds.add(id)
-      projectValues.push({
-        id,
-        worktree: data.worktree ?? "/",
-        vcs: data.vcs,
-        name: data.name ?? undefined,
-        icon_url: data.icon?.url,
-        icon_url_override: data.icon?.override,
-        icon_color: data.icon?.color,
-        time_created: data.time?.created ?? now,
-        time_updated: data.time?.updated ?? now,
-        time_initialized: data.time?.initialized,
-        sandboxes: data.sandboxes ?? [],
-        commands: data.commands,
-      })
-    }
-    stats.projects += insert(projectValues, ProjectTable, "project")
-    step("projects", end - i)
-  }
-  log.info("migrated projects", { count: stats.projects, duration: Math.round(performance.now() - start) })
-
-  // Migrate sessions (depends on projects)
-  // Derive all IDs from directory/file paths, not JSON content, since earlier
-  // migrations may have moved sessions to new directories without updating the JSON
-  const sessionProjects = sessionFiles.map((file) => path.basename(path.dirname(file)))
-  const sessionIds = new Set<string>()
-  const sessionValues: unknown[] = []
-  for (let i = 0; i < sessionFiles.length; i += batchSize) {
-    const end = Math.min(i + batchSize, sessionFiles.length)
-    const batch = await read(sessionFiles, i, end)
-    sessionValues.length = 0
-    for (let j = 0; j < batch.length; j++) {
-      const data = batch[j]
-      if (!data) continue
-      const id = path.basename(sessionFiles[i + j], ".json")
-      const projectID = sessionProjects[i + j]
-      if (!projectIds.has(projectID)) {
-        orphans.sessions++
-        continue
-      }
-      sessionIds.add(id)
-      sessionValues.push({
-        id,
-        project_id: projectID,
-        parent_id: data.parentID ?? null,
-        slug: data.slug ?? "",
-        directory: data.directory ?? "",
-        path: data.path ?? null,
-        title: data.title ?? "",
-        version: data.version ?? "",
-        share_url: data.share?.url ?? null,
-        summary_additions: data.summary?.additions ?? null,
-        summary_deletions: data.summary?.deletions ?? null,
-        summary_files: data.summary?.files ?? null,
-        summary_diffs: data.summary?.diffs ?? null,
-        cost: 0,
-        tokens_input: 0,
-        tokens_output: 0,
-        tokens_reasoning: 0,
-        tokens_cache_read: 0,
-        tokens_cache_write: 0,
-        revert: data.revert ?? null,
-        permission: data.permission ?? null,
-        time_created: data.time?.created ?? now,
-        time_updated: data.time?.updated ?? now,
-        time_compacting: data.time?.compacting ?? null,
-        time_archived: data.time?.archived ?? null,
-      })
-    }
-    stats.sessions += insert(sessionValues, SessionTable, "session")
-    step("sessions", end - i)
-  }
-  log.info("migrated sessions", { count: stats.sessions })
-  if (orphans.sessions > 0) {
-    log.warn("skipped orphaned sessions", { count: orphans.sessions })
-  }
-
-  // Migrate messages using pre-scanned file map
-  const allMessageFiles = [] as string[]
-  const allMessageSessions = [] as string[]
-  const messageSessions = new Map<string, string>()
-  for (const file of messageFiles) {
-    const sessionID = path.basename(path.dirname(file))
-    if (!sessionIds.has(sessionID)) continue
-    allMessageFiles.push(file)
-    allMessageSessions.push(sessionID)
-  }
-
-  for (let i = 0; i < allMessageFiles.length; i += batchSize) {
-    const end = Math.min(i + batchSize, allMessageFiles.length)
-    const batch = await read(allMessageFiles, i, end)
-    // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
-    const values = new Array(batch.length)
-    let count = 0
-    for (let j = 0; j < batch.length; j++) {
-      const data = batch[j]
-      if (!data) continue
-      const file = allMessageFiles[i + j]
-      const id = path.basename(file, ".json")
-      const sessionID = allMessageSessions[i + j]
-      messageSessions.set(id, sessionID)
-      const rest = data
-      delete rest.id
-      delete rest.sessionID
-      values[count++] = {
-        id,
-        session_id: sessionID,
-        time_created: data.time?.created ?? now,
-        time_updated: data.time?.updated ?? now,
-        data: rest,
-      }
-    }
-    values.length = count
-    stats.messages += insert(values, MessageTable, "message")
-    step("messages", end - i)
-  }
-  log.info("migrated messages", { count: stats.messages })
-
-  // Migrate parts using pre-scanned file map
-  for (let i = 0; i < partFiles.length; i += batchSize) {
-    const end = Math.min(i + batchSize, partFiles.length)
-    const batch = await read(partFiles, i, end)
-    // oxlint-disable-next-line unicorn/no-new-array -- pre-allocated for index-based batch fill
-    const values = new Array(batch.length)
-    let count = 0
-    for (let j = 0; j < batch.length; j++) {
-      const data = batch[j]
-      if (!data) continue
-      const file = partFiles[i + j]
-      const id = path.basename(file, ".json")
-      const messageID = path.basename(path.dirname(file))
-      const sessionID = messageSessions.get(messageID)
-      if (!sessionID) {
-        errs.push(`part missing message session: ${file}`)
-        continue
-      }
-      if (!sessionIds.has(sessionID)) continue
-      const rest = data
-      delete rest.id
-      delete rest.messageID
-      delete rest.sessionID
-      values[count++] = {
-        id,
-        message_id: messageID,
-        session_id: sessionID,
-        time_created: data.time?.created ?? now,
-        time_updated: data.time?.updated ?? now,
-        data: rest,
-      }
-    }
-    values.length = count
-    stats.parts += insert(values, PartTable, "part")
-    step("parts", end - i)
-  }
-  log.info("migrated parts", { count: stats.parts })
-
-  // Migrate todos
-  const todoSessions = todoFiles.map((file) => path.basename(file, ".json"))
-  for (let i = 0; i < todoFiles.length; i += batchSize) {
-    const end = Math.min(i + batchSize, todoFiles.length)
-    const batch = await read(todoFiles, i, end)
-    const values: unknown[] = []
-    for (let j = 0; j < batch.length; j++) {
-      const data = batch[j]
-      if (!data) continue
-      const sessionID = todoSessions[i + j]
-      if (!sessionIds.has(sessionID)) {
-        orphans.todos++
-        continue
-      }
-      if (!Array.isArray(data)) {
-        errs.push(`todo not an array: ${todoFiles[i + j]}`)
-        continue
-      }
-      for (let position = 0; position < data.length; position++) {
-        const todo = data[position]
-        if (!todo?.content || !todo?.status || !todo?.priority) continue
-        values.push({
-          session_id: sessionID,
-          content: todo.content,
-          status: todo.status,
-          priority: todo.priority,
-          position,
-          time_created: now,
-          time_updated: now,
-        })
-      }
-    }
-    stats.todos += insert(values, TodoTable, "todo")
-    step("todos", end - i)
-  }
-  log.info("migrated todos", { count: stats.todos })
-  if (orphans.todos > 0) {
-    log.warn("skipped orphaned todos", { count: orphans.todos })
-  }
-
-  // Migrate session shares
-  const shareSessions = shareFiles.map((file) => path.basename(file, ".json"))
-  const shareValues: unknown[] = []
-  for (let i = 0; i < shareFiles.length; i += batchSize) {
-    const end = Math.min(i + batchSize, shareFiles.length)
-    const batch = await read(shareFiles, i, end)
-    shareValues.length = 0
-    for (let j = 0; j < batch.length; j++) {
-      const data = batch[j]
-      if (!data) continue
-      const sessionID = shareSessions[i + j]
-      if (!sessionIds.has(sessionID)) {
-        orphans.shares++
-        continue
-      }
-      if (!data?.id || !data?.secret || !data?.url) {
-        errs.push(`session_share missing id/secret/url: ${shareFiles[i + j]}`)
-        continue
-      }
-      shareValues.push({ session_id: sessionID, id: data.id, secret: data.secret, url: data.url })
-    }
-    stats.shares += insert(shareValues, SessionShareTable, "session_share")
-    step("shares", end - i)
-  }
-  log.info("migrated session shares", { count: stats.shares })
-  if (orphans.shares > 0) {
-    log.warn("skipped orphaned session shares", { count: orphans.shares })
-  }
-
-  db.run("COMMIT")
-
-  log.info("json migration complete", {
-    projects: stats.projects,
-    sessions: stats.sessions,
-    messages: stats.messages,
-    parts: stats.parts,
-    todos: stats.todos,
-    permissions: stats.permissions,
-    shares: stats.shares,
-    errorCount: stats.errors.length,
-    duration: Math.round(performance.now() - start),
-  })
-
-  if (stats.errors.length > 0) {
-    log.warn("migration errors", { errors: stats.errors.slice(0, 20) })
-  }
-
-  progress?.({ current: total, total, label: "complete" })
-
-  return stats
-}
-
-export * as JsonMigration from "./json-migration"

+ 0 - 856
packages/opencode/test/storage/json-migration.test.ts

@@ -1,856 +0,0 @@
-import { describe, test, expect, beforeEach, afterEach } from "bun:test"
-import { Database } from "bun:sqlite"
-import { drizzle, SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
-import { migrate } from "drizzle-orm/bun-sqlite/migrator"
-import path from "path"
-import fs from "fs/promises"
-import { existsSync, readFileSync, readdirSync } from "fs"
-import { JsonMigration } from "@/storage/json-migration"
-import { Global } from "@opencode-ai/core/global"
-import { ProjectTable } from "@opencode-ai/core/project/sql"
-import { ProjectV2 } from "@opencode-ai/core/project"
-import { AbsolutePath } from "@opencode-ai/core/schema"
-import { SessionTable, MessageTable, PartTable, TodoTable } from "@opencode-ai/core/session/sql"
-import { SessionShareTable } from "@opencode-ai/core/share/sql"
-import { SessionID, MessageID, PartID } from "../../src/session/schema"
-
-// Test fixtures
-const fixtures = {
-  project: {
-    id: "proj_test123abc",
-    name: "Test Project",
-    worktree: "/test/path",
-    vcs: "git" as const,
-    sandboxes: [],
-  },
-  session: {
-    id: "ses_test456def",
-    projectID: "proj_test123abc",
-    slug: "test-session",
-    directory: "/test/path",
-    title: "Test Session",
-    version: "1.0.0",
-    time: { created: 1700000000000, updated: 1700000001000 },
-  },
-  message: {
-    id: "msg_test789ghi",
-    sessionID: "ses_test456def",
-    role: "user" as const,
-    agent: "default",
-    model: { providerID: "openai", modelID: "gpt-4" },
-    time: { created: 1700000000000 },
-  },
-  part: {
-    id: "prt_testabc123",
-    messageID: "msg_test789ghi",
-    sessionID: "ses_test456def",
-    type: "text" as const,
-    text: "Hello, world!",
-  },
-}
-
-// Helper to create test storage directory structure
-async function setupStorageDir() {
-  const storageDir = path.join(Global.Path.data, "storage")
-  await fs.rm(storageDir, { recursive: true, force: true })
-  await fs.mkdir(path.join(storageDir, "project"), { recursive: true })
-  await fs.mkdir(path.join(storageDir, "session", "proj_test123abc"), { recursive: true })
-  await fs.mkdir(path.join(storageDir, "message", "ses_test456def"), { recursive: true })
-  await fs.mkdir(path.join(storageDir, "part", "msg_test789ghi"), { recursive: true })
-  await fs.mkdir(path.join(storageDir, "session_diff"), { recursive: true })
-  await fs.mkdir(path.join(storageDir, "todo"), { recursive: true })
-  await fs.mkdir(path.join(storageDir, "permission"), { recursive: true })
-  await fs.mkdir(path.join(storageDir, "session_share"), { recursive: true })
-  // Create legacy marker to indicate JSON storage exists
-  await Bun.write(path.join(storageDir, "migration"), "1")
-  return storageDir
-}
-
-async function writeProject(storageDir: string, project: Record<string, unknown>) {
-  await Bun.write(path.join(storageDir, "project", `${project.id}.json`), JSON.stringify(project))
-}
-
-async function writeSession(storageDir: string, projectID: string, session: Record<string, unknown>) {
-  await Bun.write(path.join(storageDir, "session", projectID, `${session.id}.json`), JSON.stringify(session))
-}
-
-// Helper to create in-memory test database with schema
-function createTestDb() {
-  const sqlite = new Database(":memory:")
-  sqlite.exec("PRAGMA foreign_keys = ON")
-
-  // Apply schema migrations using drizzle migrate
-  const dir = path.join(import.meta.dirname, "../../../core/migration")
-  const entries = readdirSync(dir, { withFileTypes: true })
-  const migrations = entries
-    .filter((entry) => entry.isDirectory() && existsSync(path.join(dir, entry.name, "migration.sql")))
-    .map((entry) => ({
-      sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"),
-      timestamp: Number(entry.name.split("_")[0]),
-      name: entry.name,
-    }))
-    .sort((a, b) => a.timestamp - b.timestamp)
-
-  const db = drizzle({ client: sqlite })
-  migrate(db, migrations)
-
-  return [sqlite, db] as const
-}
-
-describe("JSON to SQLite migration", () => {
-  let storageDir: string
-  let sqlite: Database
-  let db: SQLiteBunDatabase
-
-  beforeEach(async () => {
-    storageDir = await setupStorageDir()
-    ;[sqlite, db] = createTestDb()
-  })
-
-  afterEach(async () => {
-    sqlite.close()
-    await fs.rm(storageDir, { recursive: true, force: true })
-  })
-
-  test("migrates project", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/test/path",
-      vcs: "git",
-      name: "Test Project",
-      time: { created: 1700000000000, updated: 1700000001000 },
-      sandboxes: ["/test/sandbox"],
-    })
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.projects).toBe(1)
-
-    const projects = db.select().from(ProjectTable).all()
-    expect(projects.length).toBe(1)
-    expect(projects[0].id).toBe(ProjectV2.ID.make("proj_test123abc"))
-    expect(projects[0].worktree).toBe(AbsolutePath.make("/test/path"))
-    expect(projects[0].name).toBe("Test Project")
-    expect(projects[0].sandboxes).toEqual([AbsolutePath.make("/test/sandbox")])
-  })
-
-  test("stores imported Windows project and session paths in storage form", async () => {
-    if (process.platform !== "win32") return
-
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "C:\\Repo\\Thing",
-      vcs: "git",
-      sandboxes: ["C:\\Repo\\Thing\\sandbox"],
-    })
-    await writeSession(storageDir, "proj_test123abc", {
-      id: "ses_test456def",
-      slug: "storage-path",
-      directory: "C:\\Repo\\Thing\\packages\\api",
-      path: "packages\\api",
-      title: "Storage Path",
-      version: "test",
-    })
-
-    await JsonMigration.run(db)
-
-    expect(sqlite.query("SELECT worktree, sandboxes FROM project WHERE id = ?").get("proj_test123abc")).toEqual({
-      worktree: "C:/Repo/Thing",
-      sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
-    })
-    expect(sqlite.query("SELECT directory, path FROM session WHERE id = ?").get("ses_test456def")).toEqual({
-      directory: "C:/Repo/Thing/packages/api",
-      path: "packages/api",
-    })
-  })
-
-  test("uses filename for project id when JSON has different value", async () => {
-    await Bun.write(
-      path.join(storageDir, "project", "proj_filename.json"),
-      JSON.stringify({
-        id: "proj_different_in_json", // Stale! Should be ignored
-        worktree: "/test/path",
-        vcs: "git",
-        name: "Test Project",
-        sandboxes: [],
-      }),
-    )
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.projects).toBe(1)
-
-    const projects = db.select().from(ProjectTable).all()
-    expect(projects.length).toBe(1)
-    expect(projects[0].id).toBe(ProjectV2.ID.make("proj_filename")) // Uses filename, not JSON id
-  })
-
-  test("migrates project with commands", async () => {
-    await writeProject(storageDir, {
-      id: "proj_with_commands",
-      worktree: "/test/path",
-      vcs: "git",
-      name: "Project With Commands",
-      time: { created: 1700000000000, updated: 1700000001000 },
-      sandboxes: ["/test/sandbox"],
-      commands: { start: "npm run dev" },
-    })
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.projects).toBe(1)
-
-    const projects = db.select().from(ProjectTable).all()
-    expect(projects.length).toBe(1)
-    expect(projects[0].id).toBe(ProjectV2.ID.make("proj_with_commands"))
-    expect(projects[0].commands).toEqual({ start: "npm run dev" })
-  })
-
-  test("migrates project without commands field", async () => {
-    await writeProject(storageDir, {
-      id: "proj_no_commands",
-      worktree: "/test/path",
-      vcs: "git",
-      name: "Project Without Commands",
-      time: { created: 1700000000000, updated: 1700000001000 },
-      sandboxes: [],
-    })
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.projects).toBe(1)
-
-    const projects = db.select().from(ProjectTable).all()
-    expect(projects.length).toBe(1)
-    expect(projects[0].id).toBe(ProjectV2.ID.make("proj_no_commands"))
-    expect(projects[0].commands).toBeNull()
-  })
-
-  test("migrates session with individual columns", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/test/path",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-
-    await writeSession(storageDir, "proj_test123abc", {
-      id: "ses_test456def",
-      projectID: "proj_test123abc",
-      slug: "test-session",
-      directory: "/test/dir",
-      title: "Test Session Title",
-      version: "1.0.0",
-      time: { created: 1700000000000, updated: 1700000001000 },
-      summary: { additions: 10, deletions: 5, files: 3 },
-      share: { url: "https://example.com/share" },
-    })
-
-    await JsonMigration.run(db)
-
-    const sessions = db.select().from(SessionTable).all()
-    expect(sessions.length).toBe(1)
-    expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
-    expect(sessions[0].project_id).toBe(ProjectV2.ID.make("proj_test123abc"))
-    expect(sessions[0].slug).toBe("test-session")
-    expect(sessions[0].title).toBe("Test Session Title")
-    expect(sessions[0].summary_additions).toBe(10)
-    expect(sessions[0].summary_deletions).toBe(5)
-    expect(sessions[0].share_url).toBe("https://example.com/share")
-  })
-
-  test("migrates messages and parts", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-    await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
-    await Bun.write(
-      path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"),
-      JSON.stringify({ ...fixtures.message }),
-    )
-    await Bun.write(
-      path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"),
-      JSON.stringify({ ...fixtures.part }),
-    )
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.messages).toBe(1)
-    expect(stats?.parts).toBe(1)
-
-    const messages = db.select().from(MessageTable).all()
-    expect(messages.length).toBe(1)
-    expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
-
-    const parts = db.select().from(PartTable).all()
-    expect(parts.length).toBe(1)
-    expect(parts[0].id).toBe(PartID.make("prt_testabc123"))
-  })
-
-  test("migrates legacy parts without ids in body", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-    await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
-    await Bun.write(
-      path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"),
-      JSON.stringify({
-        role: "user",
-        agent: "default",
-        model: { providerID: "openai", modelID: "gpt-4" },
-        time: { created: 1700000000000 },
-      }),
-    )
-    await Bun.write(
-      path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"),
-      JSON.stringify({
-        type: "text",
-        text: "Hello, world!",
-      }),
-    )
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.messages).toBe(1)
-    expect(stats?.parts).toBe(1)
-
-    const messages = db.select().from(MessageTable).all()
-    expect(messages.length).toBe(1)
-    expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
-    expect(messages[0].session_id).toBe(SessionID.make("ses_test456def"))
-    expect(messages[0].data).not.toHaveProperty("id")
-    expect(messages[0].data).not.toHaveProperty("sessionID")
-
-    const parts = db.select().from(PartTable).all()
-    expect(parts.length).toBe(1)
-    expect(parts[0].id).toBe(PartID.make("prt_testabc123"))
-    expect(parts[0].message_id).toBe(MessageID.make("msg_test789ghi"))
-    expect(parts[0].session_id).toBe(SessionID.make("ses_test456def"))
-    expect(parts[0].data).not.toHaveProperty("id")
-    expect(parts[0].data).not.toHaveProperty("messageID")
-    expect(parts[0].data).not.toHaveProperty("sessionID")
-  })
-
-  test("uses filename for message id when JSON has different value", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-    await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
-    await Bun.write(
-      path.join(storageDir, "message", "ses_test456def", "msg_from_filename.json"),
-      JSON.stringify({
-        id: "msg_different_in_json", // Stale! Should be ignored
-        sessionID: "ses_test456def",
-        role: "user",
-        agent: "default",
-        time: { created: 1700000000000 },
-      }),
-    )
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.messages).toBe(1)
-
-    const messages = db.select().from(MessageTable).all()
-    expect(messages.length).toBe(1)
-    expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id
-    expect(messages[0].session_id).toBe(SessionID.make("ses_test456def"))
-  })
-
-  test("uses paths for part id and messageID when JSON has different values", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-    await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
-    await Bun.write(
-      path.join(storageDir, "message", "ses_test456def", "msg_realmsgid.json"),
-      JSON.stringify({
-        role: "user",
-        agent: "default",
-        time: { created: 1700000000000 },
-      }),
-    )
-    await Bun.write(
-      path.join(storageDir, "part", "msg_realmsgid", "prt_from_filename.json"),
-      JSON.stringify({
-        id: "prt_different_in_json", // Stale! Should be ignored
-        messageID: "msg_different_in_json", // Stale! Should be ignored
-        sessionID: "ses_test456def",
-        type: "text",
-        text: "Hello",
-      }),
-    )
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.parts).toBe(1)
-
-    const parts = db.select().from(PartTable).all()
-    expect(parts.length).toBe(1)
-    expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id
-    expect(parts[0].message_id).toBe(MessageID.make("msg_realmsgid")) // Uses parent dir, not JSON messageID
-  })
-
-  test("skips orphaned sessions (no parent project)", async () => {
-    await Bun.write(
-      path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
-      JSON.stringify({
-        id: "ses_orphan",
-        projectID: "proj_nonexistent",
-        slug: "orphan",
-        directory: "/",
-        title: "Orphan",
-        version: "1.0.0",
-        time: { created: Date.now(), updated: Date.now() },
-      }),
-    )
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.sessions).toBe(0)
-  })
-
-  test("uses directory path for projectID when JSON has stale value", async () => {
-    // Simulates the scenario where earlier migration moved sessions to new
-    // git-based project directories but didn't update the projectID field
-    const gitBasedProjectID = "abc123gitcommit"
-    await writeProject(storageDir, {
-      id: gitBasedProjectID,
-      worktree: "/test/path",
-      vcs: "git",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-
-    // Session is in the git-based directory but JSON still has old projectID
-    await writeSession(storageDir, gitBasedProjectID, {
-      id: "ses_migrated",
-      projectID: "old-project-name", // Stale! Should be ignored
-      slug: "migrated-session",
-      directory: "/test/path",
-      title: "Migrated Session",
-      version: "1.0.0",
-      time: { created: 1700000000000, updated: 1700000001000 },
-    })
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.sessions).toBe(1)
-
-    const sessions = db.select().from(SessionTable).all()
-    expect(sessions.length).toBe(1)
-    expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
-    expect(sessions[0].project_id).toBe(ProjectV2.ID.make(gitBasedProjectID)) // Uses directory, not stale JSON
-  })
-
-  test("uses filename for session id when JSON has different value", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/test/path",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-
-    await Bun.write(
-      path.join(storageDir, "session", "proj_test123abc", "ses_from_filename.json"),
-      JSON.stringify({
-        id: "ses_different_in_json", // Stale! Should be ignored
-        projectID: "proj_test123abc",
-        slug: "test-session",
-        directory: "/test/path",
-        title: "Test Session",
-        version: "1.0.0",
-        time: { created: 1700000000000, updated: 1700000001000 },
-      }),
-    )
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.sessions).toBe(1)
-
-    const sessions = db.select().from(SessionTable).all()
-    expect(sessions.length).toBe(1)
-    expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
-    expect(sessions[0].project_id).toBe(ProjectV2.ID.make("proj_test123abc"))
-  })
-
-  test("is idempotent (running twice doesn't duplicate)", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-
-    await JsonMigration.run(db)
-    await JsonMigration.run(db)
-
-    const projects = db.select().from(ProjectTable).all()
-    expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
-  })
-
-  test("migrates todos", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-    await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
-
-    // Create todo file (named by sessionID, contains array of todos)
-    await Bun.write(
-      path.join(storageDir, "todo", "ses_test456def.json"),
-      JSON.stringify([
-        {
-          id: "todo_1",
-          content: "First todo",
-          status: "pending",
-          priority: "high",
-        },
-        {
-          id: "todo_2",
-          content: "Second todo",
-          status: "completed",
-          priority: "medium",
-        },
-      ]),
-    )
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.todos).toBe(2)
-
-    const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
-    expect(todos.length).toBe(2)
-    expect(todos[0].content).toBe("First todo")
-    expect(todos[0].status).toBe("pending")
-    expect(todos[0].priority).toBe("high")
-    expect(todos[0].position).toBe(0)
-    expect(todos[1].content).toBe("Second todo")
-    expect(todos[1].position).toBe(1)
-  })
-
-  test("todos are ordered by position", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-    await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
-
-    await Bun.write(
-      path.join(storageDir, "todo", "ses_test456def.json"),
-      JSON.stringify([
-        { content: "Third", status: "pending", priority: "low" },
-        { content: "First", status: "pending", priority: "high" },
-        { content: "Second", status: "in_progress", priority: "medium" },
-      ]),
-    )
-
-    await JsonMigration.run(db)
-
-    const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
-
-    expect(todos.length).toBe(3)
-    expect(todos[0].content).toBe("Third")
-    expect(todos[0].position).toBe(0)
-    expect(todos[1].content).toBe("First")
-    expect(todos[1].position).toBe(1)
-    expect(todos[2].content).toBe("Second")
-    expect(todos[2].position).toBe(2)
-  })
-
-  test("does not migrate legacy permissions", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-
-    // Create permission file (named by projectID, contains array of rules)
-    const permissionData = [
-      { permission: "file.read", pattern: "/test/file1.ts", action: "allow" as const },
-      { permission: "file.write", pattern: "/test/file2.ts", action: "ask" as const },
-      { permission: "command.run", pattern: "npm install", action: "deny" as const },
-    ]
-    await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData))
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.permissions).toBe(0)
-  })
-
-  test("migrates session shares", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-    await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
-
-    // Create session share file (named by sessionID)
-    await Bun.write(
-      path.join(storageDir, "session_share", "ses_test456def.json"),
-      JSON.stringify({
-        id: "share_123",
-        secret: "supersecretkey",
-        url: "https://share.example.com/ses_test456def",
-      }),
-    )
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats?.shares).toBe(1)
-
-    const shares = db.select().from(SessionShareTable).all()
-    expect(shares.length).toBe(1)
-    expect(shares[0].session_id).toBe("ses_test456def")
-    expect(shares[0].id).toBe("share_123")
-    expect(shares[0].secret).toBe("supersecretkey")
-    expect(shares[0].url).toBe("https://share.example.com/ses_test456def")
-  })
-
-  test("returns empty stats when storage directory does not exist", async () => {
-    await fs.rm(storageDir, { recursive: true, force: true })
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats.projects).toBe(0)
-    expect(stats.sessions).toBe(0)
-    expect(stats.messages).toBe(0)
-    expect(stats.parts).toBe(0)
-    expect(stats.todos).toBe(0)
-    expect(stats.permissions).toBe(0)
-    expect(stats.shares).toBe(0)
-    expect(stats.errors).toEqual([])
-  })
-
-  test("continues when a JSON file is unreadable and records an error", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-    await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json")
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats.projects).toBe(1)
-    expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true)
-
-    const projects = db.select().from(ProjectTable).all()
-    expect(projects.length).toBe(1)
-    expect(projects[0].id).toBe(ProjectV2.ID.make("proj_test123abc"))
-  })
-
-  test("skips invalid todo entries while preserving source positions", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-    await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
-
-    await Bun.write(
-      path.join(storageDir, "todo", "ses_test456def.json"),
-      JSON.stringify([
-        { content: "keep-0", status: "pending", priority: "high" },
-        { content: "drop-1", priority: "low" },
-        { content: "keep-2", status: "completed", priority: "medium" },
-      ]),
-    )
-
-    const stats = await JsonMigration.run(db)
-    expect(stats.todos).toBe(2)
-
-    const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
-    expect(todos.length).toBe(2)
-    expect(todos[0].content).toBe("keep-0")
-    expect(todos[0].position).toBe(0)
-    expect(todos[1].content).toBe("keep-2")
-    expect(todos[1].position).toBe(2)
-  })
-
-  test("skips orphaned todos and shares", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/",
-      time: { created: Date.now(), updated: Date.now() },
-      sandboxes: [],
-    })
-    await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
-
-    await Bun.write(
-      path.join(storageDir, "todo", "ses_test456def.json"),
-      JSON.stringify([{ content: "valid", status: "pending", priority: "high" }]),
-    )
-    await Bun.write(
-      path.join(storageDir, "todo", "ses_missing.json"),
-      JSON.stringify([{ content: "orphan", status: "pending", priority: "high" }]),
-    )
-
-    await Bun.write(
-      path.join(storageDir, "permission", "proj_test123abc.json"),
-      JSON.stringify([{ permission: "file.read" }]),
-    )
-    await Bun.write(
-      path.join(storageDir, "permission", "proj_missing.json"),
-      JSON.stringify([{ permission: "file.write" }]),
-    )
-
-    await Bun.write(
-      path.join(storageDir, "session_share", "ses_test456def.json"),
-      JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
-    )
-    await Bun.write(
-      path.join(storageDir, "session_share", "ses_missing.json"),
-      JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }),
-    )
-
-    const stats = await JsonMigration.run(db)
-
-    expect(stats.todos).toBe(1)
-    expect(stats.permissions).toBe(0)
-    expect(stats.shares).toBe(1)
-
-    expect(db.select().from(TodoTable).all().length).toBe(1)
-    expect(db.select().from(SessionShareTable).all().length).toBe(1)
-  })
-
-  test("handles mixed corruption and partial validity in one migration run", async () => {
-    await writeProject(storageDir, {
-      id: "proj_test123abc",
-      worktree: "/ok",
-      time: { created: 1700000000000, updated: 1700000001000 },
-      sandboxes: [],
-    })
-    await Bun.write(
-      path.join(storageDir, "project", "proj_missing_id.json"),
-      JSON.stringify({ worktree: "/bad", sandboxes: [] }),
-    )
-    await Bun.write(path.join(storageDir, "project", "proj_broken.json"), "{ nope")
-
-    await writeSession(storageDir, "proj_test123abc", {
-      id: "ses_test456def",
-      projectID: "proj_test123abc",
-      slug: "ok",
-      directory: "/ok",
-      title: "Ok",
-      version: "1",
-      time: { created: 1700000000000, updated: 1700000001000 },
-    })
-    await Bun.write(
-      path.join(storageDir, "session", "proj_test123abc", "ses_missing_project.json"),
-      JSON.stringify({
-        id: "ses_missing_project",
-        slug: "bad",
-        directory: "/bad",
-        title: "Bad",
-        version: "1",
-      }),
-    )
-    await Bun.write(
-      path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
-      JSON.stringify({
-        id: "ses_orphan",
-        projectID: "proj_missing",
-        slug: "orphan",
-        directory: "/bad",
-        title: "Orphan",
-        version: "1",
-      }),
-    )
-
-    await Bun.write(
-      path.join(storageDir, "message", "ses_test456def", "msg_ok.json"),
-      JSON.stringify({ role: "user", time: { created: 1700000000000 } }),
-    )
-    await Bun.write(path.join(storageDir, "message", "ses_test456def", "msg_broken.json"), "{ nope")
-    await Bun.write(
-      path.join(storageDir, "message", "ses_missing", "msg_orphan.json"),
-      JSON.stringify({ role: "user", time: { created: 1700000000000 } }),
-    )
-
-    await Bun.write(
-      path.join(storageDir, "part", "msg_ok", "part_ok.json"),
-      JSON.stringify({ type: "text", text: "ok" }),
-    )
-    await Bun.write(
-      path.join(storageDir, "part", "msg_missing", "part_missing_message.json"),
-      JSON.stringify({ type: "text", text: "bad" }),
-    )
-    await Bun.write(path.join(storageDir, "part", "msg_ok", "part_broken.json"), "{ nope")
-
-    await Bun.write(
-      path.join(storageDir, "todo", "ses_test456def.json"),
-      JSON.stringify([
-        { content: "ok", status: "pending", priority: "high" },
-        { content: "skip", status: "pending" },
-      ]),
-    )
-    await Bun.write(
-      path.join(storageDir, "todo", "ses_missing.json"),
-      JSON.stringify([{ content: "orphan", status: "pending", priority: "high" }]),
-    )
-    await Bun.write(path.join(storageDir, "todo", "ses_broken.json"), "{ nope")
-
-    await Bun.write(
-      path.join(storageDir, "permission", "proj_test123abc.json"),
-      JSON.stringify([{ permission: "file.read" }]),
-    )
-    await Bun.write(
-      path.join(storageDir, "permission", "proj_missing.json"),
-      JSON.stringify([{ permission: "file.write" }]),
-    )
-    await Bun.write(path.join(storageDir, "permission", "proj_broken.json"), "{ nope")
-
-    await Bun.write(
-      path.join(storageDir, "session_share", "ses_test456def.json"),
-      JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
-    )
-    await Bun.write(
-      path.join(storageDir, "session_share", "ses_missing.json"),
-      JSON.stringify({ id: "share_orphan", secret: "secret", url: "https://missing.example.com" }),
-    )
-    await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope")
-
-    const stats = await JsonMigration.run(db)
-
-    // Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename)
-    // Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
-    // ses_orphan (now uses dir path, ignores stale projectID)
-    expect(stats.projects).toBe(2)
-    expect(stats.sessions).toBe(3)
-    expect(stats.messages).toBe(1)
-    expect(stats.parts).toBe(1)
-    expect(stats.todos).toBe(1)
-    expect(stats.permissions).toBe(0)
-    expect(stats.shares).toBe(1)
-    expect(stats.errors.length).toBeGreaterThanOrEqual(6)
-
-    expect(db.select().from(ProjectTable).all().length).toBe(2)
-    expect(db.select().from(SessionTable).all().length).toBe(3)
-    expect(db.select().from(MessageTable).all().length).toBe(1)
-    expect(db.select().from(PartTable).all().length).toBe(1)
-    expect(db.select().from(TodoTable).all().length).toBe(1)
-    expect(db.select().from(SessionShareTable).all().length).toBe(1)
-  })
-})

+ 1 - 1
specs/storage/remove-opencode-db.md

@@ -58,7 +58,7 @@ Files:
 Current usage:
 
 - `storage/db.ts` opens the singleton database, applies pragmas, exposes callback-style access, holds ambient transaction context, and queues post-commit effects.
-- `index.ts` checks `Database.getPath()` to decide whether JSON migration is needed, then runs `JsonMigration.run(drizzle({ client: Database.Client().$client }), ...)`.
+- `index.ts` no longer performs the removed JSON-to-SQLite migration during startup.
 - `node.ts` publicly re-exports `Database` from the legacy module.
 - `cli/cmd/db.ts` uses `Database.getPath()` to print the path, open a readonly Bun SQLite handle, run `sqlite3`, and vacuum.