refactor(opencode): remove JSON storage migration (#30461)
This commit is contained in:
parent
113e7be5ac
commit
ca2acc4f8d
1
bun.lock
1
bun.lock
@ -315,7 +315,6 @@
|
|||||||
"version": "1.15.13",
|
"version": "1.15.13",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@zip.js/zip.js": "2.7.62",
|
"@zip.js/zip.js": "2.7.62",
|
||||||
"drizzle-orm": "catalog:",
|
|
||||||
"effect": "catalog:",
|
"effect": "catalog:",
|
||||||
"electron-context-menu": "4.1.2",
|
"electron-context-menu": "4.1.2",
|
||||||
"electron-log": "^5",
|
"electron-log": "^5",
|
||||||
|
|||||||
@ -88,7 +88,6 @@ export default defineConfig({
|
|||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
input: {
|
input: {
|
||||||
main: "src/renderer/index.html",
|
main: "src/renderer/index.html",
|
||||||
loading: "src/renderer/loading.html",
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -31,7 +31,6 @@
|
|||||||
"electron-store": "^10",
|
"electron-store": "^10",
|
||||||
"electron-updater": "^6",
|
"electron-updater": "^6",
|
||||||
"electron-window-state": "^5.0.3",
|
"electron-window-state": "^5.0.3",
|
||||||
"drizzle-orm": "catalog:",
|
|
||||||
"marked": "^15"
|
"marked": "^15"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
8
packages/desktop/src/main/env.d.ts
vendored
8
packages/desktop/src/main/env.d.ts
vendored
@ -18,13 +18,5 @@ declare module "virtual:opencode-server" {
|
|||||||
export namespace Log {
|
export namespace Log {
|
||||||
export const init: typeof import("../../../opencode/dist/types/src/node").Log.init
|
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
|
export const bootstrap: typeof import("../../../opencode/dist/types/src/node").bootstrap
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from "node:crypto"
|
import { randomUUID } from "node:crypto"
|
||||||
import { EventEmitter } from "node:events"
|
import { mkdirSync, rmSync } from "node:fs"
|
||||||
import { existsSync, mkdirSync, rmSync } from "node:fs"
|
|
||||||
import * as http from "node:http"
|
import * as http from "node:http"
|
||||||
import { createServer } from "node:net"
|
import { createServer } from "node:net"
|
||||||
import { homedir, tmpdir } from "node:os"
|
import { homedir, tmpdir } from "node:os"
|
||||||
@ -11,10 +10,10 @@ import { app, BrowserWindow } from "electron"
|
|||||||
|
|
||||||
import contextMenu from "electron-context-menu"
|
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 { checkAppExists, resolveAppPath, wslPath } from "./apps"
|
||||||
import { CHANNEL, UPDATER_ENABLED } from "./constants"
|
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 { exportDebugLogs, initCrashReporter, initLogging, startNetLog, write as writeLog } from "./logging"
|
||||||
import { parseMarkdown } from "./markdown"
|
import { parseMarkdown } from "./markdown"
|
||||||
import { createMenu } from "./menu"
|
import { createMenu } from "./menu"
|
||||||
@ -28,7 +27,6 @@ import {
|
|||||||
type SidecarListener,
|
type SidecarListener,
|
||||||
} from "./server"
|
} from "./server"
|
||||||
import {
|
import {
|
||||||
createLoadingWindow,
|
|
||||||
createMainWindow,
|
createMainWindow,
|
||||||
registerRendererProtocol,
|
registerRendererProtocol,
|
||||||
setRelaunchHandler,
|
setRelaunchHandler,
|
||||||
@ -56,9 +54,6 @@ let logger: ReturnType<typeof initLogging>
|
|||||||
let mainWindow: BrowserWindow | null = null
|
let mainWindow: BrowserWindow | null = null
|
||||||
let server: SidecarListener | null = null
|
let server: SidecarListener | null = null
|
||||||
|
|
||||||
const initEmitter = new EventEmitter()
|
|
||||||
let initStep: InitStep = { phase: "server_waiting" }
|
|
||||||
|
|
||||||
const pendingDeepLinks: string[] = []
|
const pendingDeepLinks: string[] = []
|
||||||
|
|
||||||
function useEnvProxy() {
|
function useEnvProxy() {
|
||||||
@ -76,12 +71,6 @@ function emitDeepLinks(urls: string[]) {
|
|||||||
if (mainWindow) sendDeepLinks(mainWindow, urls)
|
if (mainWindow) sendDeepLinks(mainWindow, urls)
|
||||||
}
|
}
|
||||||
|
|
||||||
function setInitStep(step: InitStep) {
|
|
||||||
initStep = step
|
|
||||||
logger.log("init step", { step })
|
|
||||||
initEmitter.emit("step", step)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function killSidecar() {
|
async function killSidecar() {
|
||||||
if (!server) return
|
if (!server) return
|
||||||
const current = server
|
const current = server
|
||||||
@ -219,23 +208,15 @@ const main = Effect.gen(function* () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const serverReady = Deferred.makeUnsafe<ServerReadyData>()
|
const serverReady = Deferred.makeUnsafe<ServerReadyData>()
|
||||||
const loadingComplete = Deferred.makeUnsafe<void>()
|
|
||||||
|
|
||||||
registerIpcHandlers({
|
registerIpcHandlers({
|
||||||
killSidecar: () => killSidecar(),
|
killSidecar: () => killSidecar(),
|
||||||
awaitInitialization: Effect.fnUntraced(
|
awaitInitialization: Effect.fnUntraced(
|
||||||
function* (sendStep) {
|
function* () {
|
||||||
sendStep(initStep)
|
logger.log("awaiting server ready")
|
||||||
const listener = (step: InitStep) => sendStep(step)
|
const res = yield* Deferred.await(serverReady)
|
||||||
initEmitter.on("step", listener)
|
logger.log("server ready", { url: res.url })
|
||||||
try {
|
return res
|
||||||
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)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
(e) => Effect.runPromise(e),
|
(e) => Effect.runPromise(e),
|
||||||
),
|
),
|
||||||
@ -251,7 +232,6 @@ const main = Effect.gen(function* () {
|
|||||||
checkAppExists: (appName) => checkAppExists(appName),
|
checkAppExists: (appName) => checkAppExists(appName),
|
||||||
wslPath: async (path, mode) => wslPath(path, mode),
|
wslPath: async (path, mode) => wslPath(path, mode),
|
||||||
resolveAppPath: async (appName) => resolveAppPath(appName),
|
resolveAppPath: async (appName) => resolveAppPath(appName),
|
||||||
loadingWindowComplete: () => Deferred.doneUnsafe(loadingComplete, Effect.void),
|
|
||||||
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar),
|
runUpdater: async (alertOnFail) => checkForUpdates(alertOnFail, killSidecar),
|
||||||
checkUpdate: async () => checkUpdate(),
|
checkUpdate: async () => checkUpdate(),
|
||||||
installUpdate: async () => installUpdate(killSidecar),
|
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 port = yield* Effect.gen(function* () {
|
||||||
const fromEnv = process.env.OPENCODE_PORT
|
const fromEnv = process.env.OPENCODE_PORT
|
||||||
if (fromEnv) {
|
if (fromEnv) {
|
||||||
@ -314,21 +285,13 @@ const main = Effect.gen(function* () {
|
|||||||
const loadingTask = yield* Effect.gen(function* () {
|
const loadingTask = yield* Effect.gen(function* () {
|
||||||
logger.log("sidecar connection started", { url })
|
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()
|
ensureLoopbackNoProxy()
|
||||||
useEnvProxy()
|
useEnvProxy()
|
||||||
|
|
||||||
logger.log("spawning sidecar", { url })
|
logger.log("spawning sidecar", { url })
|
||||||
const { listener, health } = yield* Effect.promise(() =>
|
const { listener, health } = yield* Effect.promise(() =>
|
||||||
spawnLocalServer(hostname, port, password, {
|
spawnLocalServer(hostname, port, password, {
|
||||||
needsMigration,
|
|
||||||
userDataPath: app.getPath("userData"),
|
userDataPath: app.getPath("userData"),
|
||||||
onSqliteProgress: (progress) => initEmitter.emit("sqlite", progress),
|
|
||||||
onStdout: (message) => writeLog("server", "stdout", { message }),
|
onStdout: (message) => writeLog("server", "stdout", { message }),
|
||||||
onStderr: (message) => writeLog("server", "stderr", { message }, "warn"),
|
onStderr: (message) => writeLog("server", "stderr", { message }, "warn"),
|
||||||
onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"),
|
onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"),
|
||||||
@ -353,23 +316,7 @@ const main = Effect.gen(function* () {
|
|||||||
logger.log("loading task finished")
|
logger.log("loading task finished")
|
||||||
}).pipe(Effect.forkChild)
|
}).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)
|
yield* Fiber.await(loadingTask)
|
||||||
setInitStep({ phase: "done" })
|
|
||||||
|
|
||||||
if (overlay) yield* Deferred.await(loadingComplete)
|
|
||||||
|
|
||||||
mainWindow = createMainWindow()
|
mainWindow = createMainWindow()
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
@ -389,8 +336,6 @@ const main = Effect.gen(function* () {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
overlay?.close()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
Effect.runFork(main)
|
Effect.runFork(main)
|
||||||
|
|||||||
@ -4,10 +4,8 @@ import type { IpcMainEvent, IpcMainInvokeEvent } from "electron"
|
|||||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
InitStep,
|
|
||||||
FatalRendererError,
|
FatalRendererError,
|
||||||
ServerReadyData,
|
ServerReadyData,
|
||||||
SqliteMigrationProgress,
|
|
||||||
TitlebarTheme,
|
TitlebarTheme,
|
||||||
WindowConfig,
|
WindowConfig,
|
||||||
WslConfig,
|
WslConfig,
|
||||||
@ -23,7 +21,7 @@ const pickerFilters = (ext?: string[]) => {
|
|||||||
|
|
||||||
type Deps = {
|
type Deps = {
|
||||||
killSidecar: () => Promise<void> | void
|
killSidecar: () => Promise<void> | void
|
||||||
awaitInitialization: (sendStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
awaitInitialization: () => Promise<ServerReadyData>
|
||||||
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
getWindowConfig: () => Promise<WindowConfig> | WindowConfig
|
||||||
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
consumeInitialDeepLinks: () => Promise<string[]> | string[]
|
||||||
getDefaultServerUrl: () => Promise<string | null> | string | null
|
getDefaultServerUrl: () => Promise<string | null> | string | null
|
||||||
@ -36,7 +34,6 @@ type Deps = {
|
|||||||
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
checkAppExists: (appName: string) => Promise<boolean> | boolean
|
||||||
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
|
wslPath: (path: string, mode: "windows" | "linux" | null) => Promise<string>
|
||||||
resolveAppPath: (appName: string) => Promise<string | null>
|
resolveAppPath: (appName: string) => Promise<string | null>
|
||||||
loadingWindowComplete: () => void
|
|
||||||
runUpdater: (alertOnFail: boolean) => Promise<void> | void
|
runUpdater: (alertOnFail: boolean) => Promise<void> | void
|
||||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||||
installUpdate: () => Promise<void> | void
|
installUpdate: () => Promise<void> | void
|
||||||
@ -47,10 +44,7 @@ type Deps = {
|
|||||||
|
|
||||||
export function registerIpcHandlers(deps: Deps) {
|
export function registerIpcHandlers(deps: Deps) {
|
||||||
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
ipcMain.handle("kill-sidecar", () => deps.killSidecar())
|
||||||
ipcMain.handle("await-initialization", (event: IpcMainInvokeEvent) => {
|
ipcMain.handle("await-initialization", () => deps.awaitInitialization())
|
||||||
const send = (step: InitStep) => event.sender.send("init-step", step)
|
|
||||||
return deps.awaitInitialization(send)
|
|
||||||
})
|
|
||||||
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
|
ipcMain.handle("get-window-config", () => deps.getWindowConfig())
|
||||||
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
ipcMain.handle("consume-initial-deep-links", () => deps.consumeInitialDeepLinks())
|
||||||
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
ipcMain.handle("get-default-server-url", () => deps.getDefaultServerUrl())
|
||||||
@ -69,7 +63,6 @@ export function registerIpcHandlers(deps: Deps) {
|
|||||||
deps.wslPath(path, mode),
|
deps.wslPath(path, mode),
|
||||||
)
|
)
|
||||||
ipcMain.handle("resolve-app-path", (_event: IpcMainInvokeEvent, appName: string) => deps.resolveAppPath(appName))
|
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("run-updater", (_event: IpcMainInvokeEvent, alertOnFail: boolean) => deps.runUpdater(alertOnFail))
|
||||||
ipcMain.handle("check-update", () => deps.checkUpdate())
|
ipcMain.handle("check-update", () => deps.checkUpdate())
|
||||||
ipcMain.handle("install-update", () => deps.installUpdate())
|
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) {
|
export function sendMenuCommand(win: BrowserWindow, id: string) {
|
||||||
win.webContents.send("menu-command", id)
|
win.webContents.send("menu-command", id)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,14 +5,12 @@ import type { Details } from "electron"
|
|||||||
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
import { DEFAULT_SERVER_URL_KEY, WSL_ENABLED_KEY } from "./constants"
|
||||||
import { getUserShell, loadShellEnv } from "./shell-env"
|
import { getUserShell, loadShellEnv } from "./shell-env"
|
||||||
import { getStore } from "./store"
|
import { getStore } from "./store"
|
||||||
import type { SqliteMigrationProgress } from "../preload/types"
|
|
||||||
|
|
||||||
export type WslConfig = { enabled: boolean }
|
export type WslConfig = { enabled: boolean }
|
||||||
|
|
||||||
export type HealthCheck = { wait: Promise<void> }
|
export type HealthCheck = { wait: Promise<void> }
|
||||||
|
|
||||||
type SidecarMessage =
|
type SidecarMessage =
|
||||||
| { type: "sqlite"; progress: SqliteMigrationProgress }
|
|
||||||
| { type: "ready" }
|
| { type: "ready" }
|
||||||
| { type: "stopped" }
|
| { type: "stopped" }
|
||||||
| { type: "error"; error: { message: string; stack?: string } }
|
| { type: "error"; error: { message: string; stack?: string } }
|
||||||
@ -24,9 +22,7 @@ const SIDECAR_START_STALL_TIMEOUT = 60_000
|
|||||||
const SIDECAR_STOP_TIMEOUT = 6_000
|
const SIDECAR_STOP_TIMEOUT = 6_000
|
||||||
|
|
||||||
type SpawnLocalServerOptions = {
|
type SpawnLocalServerOptions = {
|
||||||
needsMigration: boolean
|
|
||||||
userDataPath: string
|
userDataPath: string
|
||||||
onSqliteProgress?: (progress: SqliteMigrationProgress) => void
|
|
||||||
onStdout?: (message: string) => void
|
onStdout?: (message: string) => void
|
||||||
onStderr?: (message: string) => void
|
onStderr?: (message: string) => void
|
||||||
onExit?: (code: number) => void
|
onExit?: (code: number) => void
|
||||||
@ -118,11 +114,6 @@ export async function spawnLocalServer(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const onMessage = (message: SidecarMessage) => {
|
const onMessage = (message: SidecarMessage) => {
|
||||||
if (message.type === "sqlite") {
|
|
||||||
refreshTimeout()
|
|
||||||
options.onSqliteProgress?.(message.progress)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (message.type === "ready") {
|
if (message.type === "ready") {
|
||||||
if (done) return
|
if (done) return
|
||||||
done = true
|
done = true
|
||||||
@ -152,7 +143,6 @@ export async function spawnLocalServer(
|
|||||||
port,
|
port,
|
||||||
password,
|
password,
|
||||||
userDataPath: options.userDataPath,
|
userDataPath: options.userDataPath,
|
||||||
needsMigration: options.needsMigration,
|
|
||||||
})
|
})
|
||||||
}).catch((error) => {
|
}).catch((error) => {
|
||||||
if (!exited) child.kill()
|
if (!exited) child.kill()
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
import { drizzle } from "drizzle-orm/node-sqlite/driver"
|
|
||||||
import * as http from "node:http"
|
import * as http from "node:http"
|
||||||
import * as tls from "node:tls"
|
import * as tls from "node:tls"
|
||||||
|
|
||||||
@ -17,14 +16,12 @@ type StartCommand = {
|
|||||||
port: number
|
port: number
|
||||||
password: string
|
password: string
|
||||||
userDataPath: string
|
userDataPath: string
|
||||||
needsMigration: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type StopCommand = { type: "stop" }
|
type StopCommand = { type: "stop" }
|
||||||
type SidecarCommand = StartCommand | StopCommand
|
type SidecarCommand = StartCommand | StopCommand
|
||||||
|
|
||||||
type SidecarMessage =
|
type SidecarMessage =
|
||||||
| { type: "sqlite"; progress: { type: "InProgress"; value: number } | { type: "Done" } }
|
|
||||||
| { type: "ready" }
|
| { type: "ready" }
|
||||||
| { type: "stopped" }
|
| { type: "stopped" }
|
||||||
| { type: "error"; error: { message: string; stack?: string } }
|
| { type: "error"; error: { message: string; stack?: string } }
|
||||||
@ -57,24 +54,9 @@ async function start(command: StartCommand) {
|
|||||||
ensureLoopbackNoProxy()
|
ensureLoopbackNoProxy()
|
||||||
useSystemCertificates()
|
useSystemCertificates()
|
||||||
useEnvProxy()
|
useEnvProxy()
|
||||||
const { Database, JsonMigration, Log, Server } = await import("virtual:opencode-server")
|
const { Log, Server } = await import("virtual:opencode-server")
|
||||||
await Log.init({ level: "WARN" })
|
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({
|
listener = await Server.listen({
|
||||||
port: command.port,
|
port: command.port,
|
||||||
hostname: command.hostname,
|
hostname: command.hostname,
|
||||||
@ -155,14 +137,12 @@ function parseCommand(value: unknown): SidecarCommand | undefined {
|
|||||||
if (typeof command.port !== "number") return
|
if (typeof command.port !== "number") return
|
||||||
if (typeof command.password !== "string") return
|
if (typeof command.password !== "string") return
|
||||||
if (typeof command.userDataPath !== "string") return
|
if (typeof command.userDataPath !== "string") return
|
||||||
if (typeof command.needsMigration !== "boolean") return
|
|
||||||
return {
|
return {
|
||||||
type: "start",
|
type: "start",
|
||||||
hostname: command.hostname,
|
hostname: command.hostname,
|
||||||
port: command.port,
|
port: command.port,
|
||||||
password: command.password,
|
password: command.password,
|
||||||
userDataPath: command.userDataPath,
|
userDataPath: command.userDataPath,
|
||||||
needsMigration: command.needsMigration,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -181,41 +181,6 @@ export function createMainWindow() {
|
|||||||
return win
|
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() {
|
export function registerRendererProtocol() {
|
||||||
if (protocol.isProtocolHandled(rendererProtocol)) return
|
if (protocol.isProtocolHandled(rendererProtocol)) return
|
||||||
|
|
||||||
|
|||||||
@ -1,16 +1,10 @@
|
|||||||
import { contextBridge, ipcRenderer } from "electron"
|
import { contextBridge, ipcRenderer } from "electron"
|
||||||
import type { ElectronAPI, InitStep, SqliteMigrationProgress } from "./types"
|
import type { ElectronAPI } from "./types"
|
||||||
|
|
||||||
const api: ElectronAPI = {
|
const api: ElectronAPI = {
|
||||||
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
|
killSidecar: () => ipcRenderer.invoke("kill-sidecar"),
|
||||||
installCli: () => ipcRenderer.invoke("install-cli"),
|
installCli: () => ipcRenderer.invoke("install-cli"),
|
||||||
awaitInitialization: (onStep) => {
|
awaitInitialization: () => ipcRenderer.invoke("await-initialization"),
|
||||||
const handler = (_: unknown, step: InitStep) => onStep(step)
|
|
||||||
ipcRenderer.on("init-step", handler)
|
|
||||||
return ipcRenderer.invoke("await-initialization").finally(() => {
|
|
||||||
ipcRenderer.removeListener("init-step", handler)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
|
getWindowConfig: () => ipcRenderer.invoke("get-window-config"),
|
||||||
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
consumeInitialDeepLinks: () => ipcRenderer.invoke("consume-initial-deep-links"),
|
||||||
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
getDefaultServerUrl: () => ipcRenderer.invoke("get-default-server-url"),
|
||||||
@ -31,11 +25,6 @@ const api: ElectronAPI = {
|
|||||||
storeLength: (name) => ipcRenderer.invoke("store-length", name),
|
storeLength: (name) => ipcRenderer.invoke("store-length", name),
|
||||||
|
|
||||||
getWindowCount: () => ipcRenderer.invoke("get-window-count"),
|
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) => {
|
onMenuCommand: (cb) => {
|
||||||
const handler = (_: unknown, id: string) => cb(id)
|
const handler = (_: unknown, id: string) => cb(id)
|
||||||
ipcRenderer.on("menu-command", handler)
|
ipcRenderer.on("menu-command", handler)
|
||||||
@ -74,7 +63,6 @@ const api: ElectronAPI = {
|
|||||||
},
|
},
|
||||||
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
setTitlebar: (theme) => ipcRenderer.invoke("set-titlebar", theme),
|
||||||
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
|
runDesktopMenuAction: (action) => ipcRenderer.invoke("run-desktop-menu-action", action),
|
||||||
loadingWindowComplete: () => ipcRenderer.send("loading-window-complete"),
|
|
||||||
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
|
runUpdater: (alertOnFail) => ipcRenderer.invoke("run-updater", alertOnFail),
|
||||||
checkUpdate: () => ipcRenderer.invoke("check-update"),
|
checkUpdate: () => ipcRenderer.invoke("check-update"),
|
||||||
installUpdate: () => ipcRenderer.invoke("install-update"),
|
installUpdate: () => ipcRenderer.invoke("install-update"),
|
||||||
|
|||||||
@ -1,15 +1,11 @@
|
|||||||
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu"
|
||||||
|
|
||||||
export type InitStep = { phase: "server_waiting" } | { phase: "sqlite_waiting" } | { phase: "done" }
|
|
||||||
|
|
||||||
export type ServerReadyData = {
|
export type ServerReadyData = {
|
||||||
url: string
|
url: string
|
||||||
username: string | null
|
username: string | null
|
||||||
password: string | null
|
password: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export type SqliteMigrationProgress = { type: "InProgress"; value: number } | { type: "Done" }
|
|
||||||
|
|
||||||
export type WslConfig = { enabled: boolean }
|
export type WslConfig = { enabled: boolean }
|
||||||
|
|
||||||
export type LinuxDisplayBackend = "wayland" | "auto"
|
export type LinuxDisplayBackend = "wayland" | "auto"
|
||||||
@ -31,7 +27,7 @@ export type FatalRendererError = {
|
|||||||
export type ElectronAPI = {
|
export type ElectronAPI = {
|
||||||
killSidecar: () => Promise<void>
|
killSidecar: () => Promise<void>
|
||||||
installCli: () => Promise<string>
|
installCli: () => Promise<string>
|
||||||
awaitInitialization: (onStep: (step: InitStep) => void) => Promise<ServerReadyData>
|
awaitInitialization: () => Promise<ServerReadyData>
|
||||||
getWindowConfig: () => Promise<WindowConfig>
|
getWindowConfig: () => Promise<WindowConfig>
|
||||||
consumeInitialDeepLinks: () => Promise<string[]>
|
consumeInitialDeepLinks: () => Promise<string[]>
|
||||||
getDefaultServerUrl: () => Promise<string | null>
|
getDefaultServerUrl: () => Promise<string | null>
|
||||||
@ -52,7 +48,6 @@ export type ElectronAPI = {
|
|||||||
storeLength: (name: string) => Promise<number>
|
storeLength: (name: string) => Promise<number>
|
||||||
|
|
||||||
getWindowCount: () => Promise<number>
|
getWindowCount: () => Promise<number>
|
||||||
onSqliteMigrationProgress: (cb: (progress: SqliteMigrationProgress) => void) => () => void
|
|
||||||
onMenuCommand: (cb: (id: string) => void) => () => void
|
onMenuCommand: (cb: (id: string) => void) => () => void
|
||||||
onDeepLink: (cb: (urls: string[]) => void) => () => void
|
onDeepLink: (cb: (urls: string[]) => void) => () => void
|
||||||
|
|
||||||
@ -85,7 +80,6 @@ export type ElectronAPI = {
|
|||||||
onZoomFactorChanged: (cb: (factor: number) => void) => () => void
|
onZoomFactorChanged: (cb: (factor: number) => void) => () => void
|
||||||
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
setTitlebar: (theme: TitlebarTheme) => Promise<void>
|
||||||
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
|
runDesktopMenuAction: (action: DesktopMenuAction) => Promise<void>
|
||||||
loadingWindowComplete: () => void
|
|
||||||
runUpdater: (alertOnFail: boolean) => Promise<void>
|
runUpdater: (alertOnFail: boolean) => Promise<void>
|
||||||
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
checkUpdate: () => Promise<{ updateAvailable: boolean; version?: string }>
|
||||||
installUpdate: () => Promise<void>
|
installUpdate: () => Promise<void>
|
||||||
|
|||||||
@ -16,7 +16,7 @@ const html = async (name: string) => Bun.file(join(dir, name)).text()
|
|||||||
* All local resource references must use relative paths (`./`).
|
* All local resource references must use relative paths (`./`).
|
||||||
*/
|
*/
|
||||||
describe("electron renderer html", () => {
|
describe("electron renderer html", () => {
|
||||||
for (const name of ["index.html", "loading.html"]) {
|
for (const name of ["index.html"]) {
|
||||||
describe(name, () => {
|
describe(name, () => {
|
||||||
test("script src attributes use relative paths", async () => {
|
test("script src attributes use relative paths", async () => {
|
||||||
const content = await html(name)
|
const content = await html(name)
|
||||||
|
|||||||
@ -319,7 +319,7 @@ render(() => {
|
|||||||
const [windowCount] = createResource(() => window.api.getWindowCount())
|
const [windowCount] = createResource(() => window.api.getWindowCount())
|
||||||
|
|
||||||
// Fetch sidecar credentials (available immediately, before health check)
|
// Fetch sidecar credentials (available immediately, before health check)
|
||||||
const [sidecar] = createResource(() => window.api.awaitInitialization(() => undefined))
|
const [sidecar] = createResource(() => window.api.awaitInitialization())
|
||||||
|
|
||||||
const [defaultServer] = createResource(() =>
|
const [defaultServer] = createResource(() =>
|
||||||
platform.getDefaultServer?.().then((url) => {
|
platform.getDefaultServer?.().then((url) => {
|
||||||
|
|||||||
@ -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>
|
|
||||||
@ -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)
|
|
||||||
@ -15,7 +15,6 @@ import { InstallationVersion } from "@opencode-ai/core/installation/version"
|
|||||||
import { NamedError } from "@opencode-ai/core/util/error"
|
import { NamedError } from "@opencode-ai/core/util/error"
|
||||||
import { FormatError } from "./cli/error"
|
import { FormatError } from "./cli/error"
|
||||||
import { ServeCommand } from "./cli/cmd/serve"
|
import { ServeCommand } from "./cli/cmd/serve"
|
||||||
import { Filesystem } from "@/util/filesystem"
|
|
||||||
import { DebugCommand } from "./cli/cmd/debug"
|
import { DebugCommand } from "./cli/cmd/debug"
|
||||||
import { StatsCommand } from "./cli/cmd/stats"
|
import { StatsCommand } from "./cli/cmd/stats"
|
||||||
import { McpCommand } from "./cli/cmd/mcp"
|
import { McpCommand } from "./cli/cmd/mcp"
|
||||||
@ -30,13 +29,9 @@ import { WebCommand } from "./cli/cmd/web"
|
|||||||
import { PrCommand } from "./cli/cmd/pr"
|
import { PrCommand } from "./cli/cmd/pr"
|
||||||
import { SessionCommand } from "./cli/cmd/session"
|
import { SessionCommand } from "./cli/cmd/session"
|
||||||
import { DbCommand } from "./cli/cmd/db"
|
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 { errorMessage } from "./util/error"
|
||||||
import { PluginCommand } from "./cli/cmd/plug"
|
import { PluginCommand } from "./cli/cmd/plug"
|
||||||
import { Heap } from "./cli/heap"
|
import { Heap } from "./cli/heap"
|
||||||
import { drizzle } from "drizzle-orm/bun-sqlite"
|
|
||||||
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
|
import { ensureProcessMetadata } from "@opencode-ai/core/util/opencode-process"
|
||||||
import { isRecord } from "@/util/record"
|
import { isRecord } from "@/util/record"
|
||||||
|
|
||||||
@ -115,44 +110,6 @@ const cli = yargs(args)
|
|||||||
run_id: processMetadata.runID,
|
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("")
|
.usage("")
|
||||||
.completion("completion", "generate shell completion script")
|
.completion("completion", "generate shell completion script")
|
||||||
|
|||||||
@ -3,4 +3,3 @@ export { Server } from "./server/server"
|
|||||||
export { bootstrap } from "./cli/bootstrap"
|
export { bootstrap } from "./cli/bootstrap"
|
||||||
export * as Log from "@opencode-ai/core/util/log"
|
export * as Log from "@opencode-ai/core/util/log"
|
||||||
export { Database } from "@opencode-ai/core/database/database"
|
export { Database } from "@opencode-ai/core/database/database"
|
||||||
export { JsonMigration } from "@/storage/json-migration"
|
|
||||||
|
|||||||
@ -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"
|
|
||||||
@ -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)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@ -58,7 +58,7 @@ Files:
|
|||||||
Current usage:
|
Current usage:
|
||||||
|
|
||||||
- `storage/db.ts` opens the singleton database, applies pragmas, exposes callback-style access, holds ambient transaction context, and queues post-commit effects.
|
- `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.
|
- `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.
|
- `cli/cmd/db.ts` uses `Database.getPath()` to print the path, open a readonly Bun SQLite handle, run `sqlite3`, and vacuum.
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user