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

This commit is contained in:
Dax 2026-06-02 19:05:14 -04:00 committed by GitHub
commit ca2acc4f8d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 17 additions and 1590 deletions

View file

@ -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
}

View file

@ -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)

View file

@ -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)
}

View file

@ -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()

View file

@ -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,
}
}

View file

@ -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

View file

@ -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"),

View file

@ -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>

View file

@ -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)

View file

@ -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) => {

View file

@ -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>

View file

@ -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)