refactor(core): auto-start v1 migration

This commit is contained in:
Dax Raad
2026-08-05 19:41:22 -04:00
parent 675e8012b0
commit 35ac83fd29
13 changed files with 102 additions and 225 deletions
+1 -4
View File
@@ -1572,11 +1572,8 @@ export type Endpoint27_0Output =
| { readonly status: "error"; readonly error: string }
export type MigrationV1StatusOperation<E = never> = () => Effect.Effect<Endpoint27_0Output, E>
export type Endpoint27_1Output = { readonly status: "running" }
export type MigrationV1RunOperation<E = never> = () => Effect.Effect<Endpoint27_1Output, E>
export interface MigrationApi<E = never> {
readonly v1: { readonly status: MigrationV1StatusOperation<E>; readonly run: MigrationV1RunOperation<E> }
readonly v1: { readonly status: MigrationV1StatusOperation<E> }
}
export type Endpoint28_0Input = {
@@ -216,7 +216,6 @@ import type {
Endpoint26_1Input,
Endpoint26_1Output,
Endpoint27_0Output,
Endpoint27_1Output,
Endpoint28_0Input,
Endpoint28_0Output,
Endpoint28_1Input,
@@ -1222,12 +1221,7 @@ const adaptGroup26 = (raw: RawClient["server.debug"]) => ({
const Endpoint27_0 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint27_0Output>()(raw["migration.v1.status"]({}).pipe(Effect.mapError(mapClientError)))
const Endpoint27_1 = (raw: RawClient["server.migration"]) => () =>
preserveEffect<Endpoint27_1Output>()(raw["migration.v1.run"]({}).pipe(Effect.mapError(mapClientError)))
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({
v1: { status: Endpoint27_0(raw), run: Endpoint27_1(raw) },
})
const adaptGroup27 = (raw: RawClient["server.migration"]) => ({ v1: { status: Endpoint27_0(raw) } })
const Endpoint28_0 = (raw: RawClient["server.websearch"]) => (input?: Endpoint28_0Input) =>
preserveEffect<Endpoint28_0Output>()(
@@ -212,7 +212,6 @@ import type {
DebugLocationEvictInput,
DebugLocationEvictOutput,
MigrationV1StatusOutput,
MigrationV1RunOutput,
WebsearchProvidersInput,
WebsearchProvidersOutput,
WebsearchQueryInput,
@@ -1783,17 +1782,6 @@ export function make(options: ClientOptions) {
},
requestOptions,
),
run: (requestOptions?: RequestOptions) =>
request<MigrationV1RunOutput>(
{
method: "POST",
path: `/api/experimental/migration/v1`,
successStatus: 200,
declaredStatuses: [401, 400],
empty: false,
},
requestOptions,
),
},
},
websearch: {
@@ -4570,8 +4570,6 @@ export type MigrationV1StatusOutput =
| { status: "running"; progress: { label: string; numerator?: number | undefined; denominator?: number | undefined } }
| { status: "error"; error: string }
export type MigrationV1RunOutput = { status: "running" }
export type WebsearchProvidersInput = {
readonly location?: {
readonly location?: { readonly directory?: string | undefined; readonly workspace?: string | undefined } | undefined
+20 -24
View File
@@ -1,6 +1,6 @@
export * as V1Migration from "./v1-migration"
import { Cause, Effect, Option, Schema, Semaphore } from "effect"
import { Cause, Effect, Layer, Option, Schema, Semaphore } from "effect"
import { Database } from "./database"
import { SessionMessageTable, SessionTable } from "../session/sql"
import { SessionV1 } from "@opencode-ai/schema/session-v1"
@@ -84,10 +84,6 @@ export type Status =
| { readonly status: "running"; readonly progress: Progress }
| { readonly status: "error"; readonly error: string }
export type Result = {
readonly status: "running"
}
type RunResult = {
readonly status: "completed"
}
@@ -432,7 +428,7 @@ export function transformSession(input: TransformInput): TransformResult {
}
}
export function status(options: Options = {}): Effect.Effect<Status, never, Database.Service> {
export function status(): Effect.Effect<Status, never, Database.Service> {
return Effect.gen(function* () {
const { db } = yield* Database.Service
if (!(yield* hasLegacySessions(db))) return { status: "completed" as const }
@@ -444,24 +440,24 @@ export function status(options: Options = {}): Effect.Effect<Status, never, Data
}).pipe(Effect.orDie)
}
export const start = Effect.fn("V1Migration.start")(function* (options: Options = {}) {
if (runtimeState.status === "running") return { status: "running" as const }
runtimeState = { status: "running", progress: { label: "Clearing old events" } }
yield* run(options).pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.sync(() => {
runtimeState = { status: "error", error: errorText(Cause.squash(cause)) }
}).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))),
onSuccess: () =>
Effect.sync(() => {
runtimeState = { status: "idle" }
}),
}),
Effect.forkDetach({ startImmediately: true }),
)
return { status: "running" as const }
})
export const layer = Layer.effectDiscard(
Effect.gen(function* () {
runtimeState = { status: "running", progress: { label: "Clearing old events" } }
yield* run().pipe(
Effect.matchCauseEffect({
onFailure: (cause) =>
Effect.sync(() => {
runtimeState = { status: "error", error: errorText(Cause.squash(cause)) }
}).pipe(Effect.andThen(Effect.logError("V1 migration failed", { cause }))),
onSuccess: () =>
Effect.sync(() => {
runtimeState = { status: "idle" }
}),
}),
Effect.forkScoped({ startImmediately: true }),
)
}),
)
function errorText(input: unknown): string {
if (!(input instanceof Error)) return String(input)
+8 -8
View File
@@ -11,16 +11,16 @@ import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Global } from "@opencode-ai/util/global"
import { Effect, Logger, Schedule, Schema } from "effect"
import { Effect, Layer, Logger, Schedule, Schema, Scope } from "effect"
import { eq, sql } from "drizzle-orm"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import { tmpdir } from "./fixture/tmpdir"
import path from "path"
const makeDb = EffectDrizzleSqlite.makeWithDefaults()
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient>) =>
const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope>) =>
Effect.runPromise(
effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))),
)
const session = (
@@ -772,7 +772,7 @@ describe("V1Migration database workflow", () => {
`)
})
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service>) =>
const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Scope.Scope>) =>
run(
Effect.gen(function* () {
const db = yield* makeDb
@@ -851,11 +851,11 @@ describe("V1Migration database workflow", () => {
VALUES ('msg_current_existing', 'ses_existing', 'user', 0, 1, 2, '{"text":"current","time":{"created":1}}')
`)
expect(yield* V1Migration.status({ nextDatabasePath: filename })).toEqual({
expect(yield* V1Migration.status()).toEqual({
status: "required",
})
expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
expect(yield* V1Migration.status({ nextDatabasePath: filename })).toEqual({
expect(yield* V1Migration.status()).toEqual({
status: "completed",
})
expect(yield* db.get(sql`SELECT title, agent, model FROM session_v2 WHERE id = 'ses_next'`)).toEqual({
@@ -1055,7 +1055,7 @@ describe("V1Migration database workflow", () => {
yield* db.run(
sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('event_stale_b', 'ses_b', 7, 1, 'session.renamed.1', '{}')`,
)
expect(yield* V1Migration.start()).toEqual({ status: "running" })
yield* Layer.launch(V1Migration.layer).pipe(Effect.forkScoped)
const failed = yield* V1Migration.status().pipe(
Effect.filterOrFail((status) => status.status === "error"),
Effect.retry(Schedule.spaced("10 millis")),
@@ -1093,7 +1093,7 @@ describe("V1Migration database workflow", () => {
sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('event_after_clear', 'ses_c', 0, 2, 'session.renamed.1', '{}')`,
)
yield* db.run(sql`DROP TRIGGER fail_b`)
expect(yield* V1Migration.start()).toEqual({ status: "running" })
yield* Layer.launch(V1Migration.layer).pipe(Effect.forkScoped)
yield* V1Migration.status().pipe(
Effect.filterOrFail((status) => status.status === "completed"),
Effect.retry(Schedule.spaced("10 millis")),
-13
View File
@@ -13,8 +13,6 @@ export const V1MigrationStatus = Schema.Union([
Schema.Struct({ status: Schema.Literal("error"), error: Schema.String }),
])
export const V1MigrationResult = Schema.Struct({ status: Schema.Literal("running") })
export const MigrationGroup = HttpApiGroup.make("server.migration")
.add(
HttpApiEndpoint.get("migration.v1.status", "/api/experimental/migration/v1", {
@@ -27,15 +25,4 @@ export const MigrationGroup = HttpApiGroup.make("server.migration")
}),
),
)
.add(
HttpApiEndpoint.post("migration.v1.run", "/api/experimental/migration/v1", {
success: V1MigrationResult,
}).annotateMerge(
OpenApi.annotations({
identifier: "v2.experimental.migration.v1.run",
summary: "Run V1 migration",
description: "Start or resume the V1 to V2 session history migration in the background.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "migration" }))
+6 -13
View File
@@ -4,17 +4,10 @@ import { Effect } from "effect"
import { Api } from "../api"
export const MigrationHandler = HttpApiBuilder.group(Api, "server.migration", (handlers) =>
handlers
.handle(
"migration.v1.status",
Effect.fn(function* () {
return yield* V1Migration.status()
}),
)
.handle(
"migration.v1.run",
Effect.fn(function* () {
return yield* V1Migration.start()
}),
),
handlers.handle(
"migration.v1.status",
Effect.fn(function* () {
return yield* V1Migration.status()
}),
),
)
+3 -1
View File
@@ -1,4 +1,5 @@
import { Database } from "@opencode-ai/core/database/database"
import { V1Migration } from "@opencode-ai/core/database/v1-migration"
import { App } from "@opencode-ai/core/app"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
import { httpClient } from "@opencode-ai/util/effect/app-node-platform"
@@ -138,7 +139,7 @@ function makeRoutes<AuthError, AuthServices>(
),
ServerInfo.layer(serviceURLs, options.app),
)
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
const api = HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
Layer.provide(handlers.pipe(Layer.provide(services))),
Layer.provide(formLocationLayer),
Layer.provide(sessionLocationLayer),
@@ -150,6 +151,7 @@ function makeRoutes<AuthError, AuthServices>(
Layer.provideMerge(services),
Layer.provideMerge(HttpRouter.layer),
)
return Layer.merge(api, V1Migration.layer.pipe(Layer.provide(services)))
}),
Layer.provide(observability),
)
+1 -29
View File
@@ -97,7 +97,6 @@ import { destroyRenderer } from "./util/renderer"
import { cliErrorMessage, errorFormat } from "./util/error"
import { AttentionProvider } from "./context/attention"
import { StorageProvider } from "./context/storage"
import { Migration } from "./migration"
registerOpencodeSpinner()
@@ -458,31 +457,6 @@ function App(props: { pair?: DialogPairCredentials }) {
const promptRef = usePromptRef()
const plugins = usePlugin()
const clipboard = useClipboard()
const [migration, setMigration] = createStore({ active: false, progress: { label: "Preparing migration" } })
const migrationAbort = new AbortController()
onMount(async () => {
await Bun.sleep(1_000)
void Migration.run(
client.api,
(status) => {
setMigration("active", status.status === "running")
if (status.status === "running") setMigration("progress", status.progress)
},
migrationAbort.signal,
).catch((error) => {
if (migrationAbort.signal.aborted) return
setMigration("active", false)
toast.show({
variant: "error",
title: "Data migration failed",
message: error instanceof Error ? error.message : String(error),
duration: 10_000,
})
})
})
onCleanup(() => migrationAbort.abort())
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
// the same problem on every refresh while still re-alerting if the state changes.
@@ -1272,9 +1246,7 @@ function App(props: { pair?: DialogPairCredentials }) {
<Show when={showReconnecting()}>
<Reconnecting />
</Show>
<Show when={migration.active}>
<MigrationOverlay progress={migration.progress} />
</Show>
<MigrationOverlay />
<Toast />
</box>
)
@@ -1,37 +1,72 @@
import { createSignal, onCleanup, onMount, Show } from "solid-js"
import { useClient } from "../context/client"
import { useTheme } from "../context/theme"
import { SplitBorder } from "../ui/border"
import { useToast } from "../ui/toast"
import { Spinner } from "./spinner"
export function MigrationOverlay(props: {
progress: { label: string; numerator?: number; denominator?: number }
}) {
type Progress = { label: string; numerator?: number; denominator?: number }
export function MigrationOverlay() {
const client = useClient()
const toast = useToast()
const theme = useTheme("overlay")
const count = () => {
if (props.progress.numerator === undefined) return ""
if (props.progress.denominator === undefined) return ` ${props.progress.numerator}`
return ` ${props.progress.numerator}/${props.progress.denominator}`
const [progress, setProgress] = createSignal<Progress>()
const abort = new AbortController()
onMount(async () => {
await Bun.sleep(1_000)
void (async () => {
while (true) {
const status = await client.api.migration.v1.status({ signal: abort.signal })
setProgress(status.status === "running" ? status.progress : undefined)
if (status.status === "completed") return
if (status.status === "error") throw new Error(status.error)
await Bun.sleep(1_000)
}
})().catch((error) => {
if (abort.signal.aborted) return
setProgress(undefined)
toast.show({
variant: "error",
title: "Data migration failed",
message: error instanceof Error ? error.message : String(error),
duration: 10_000,
})
})
})
onCleanup(() => abort.abort())
const count = (value: Progress) => {
if (value.numerator === undefined) return ""
if (value.denominator === undefined) return ` ${value.numerator}`
return ` ${value.numerator}/${value.denominator}`
}
return (
<box
position="absolute"
zIndex={10_000}
top={1}
right={2}
flexDirection="row"
backgroundColor={theme.background.default}
border={["left"]}
borderColor={theme.text.feedback.info.default}
customBorderChars={SplitBorder.customBorderChars}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<Spinner color={theme.text.feedback.info.default}>
{props.progress.label}
{count()}
</Spinner>
</box>
<Show when={progress()}>
{(value) => (
<box
position="absolute"
zIndex={10_000}
top={1}
right={2}
flexDirection="row"
backgroundColor={theme.background.default}
border={["left"]}
borderColor={theme.text.feedback.info.default}
customBorderChars={SplitBorder.customBorderChars}
paddingLeft={2}
paddingRight={2}
paddingTop={1}
paddingBottom={1}
>
<Spinner color={theme.text.feedback.info.default}>
{value().label}
{count(value())}
</Spinner>
</box>
)}
</Show>
)
}
-64
View File
@@ -1,64 +0,0 @@
import { expect, test } from "bun:test"
import { Migration } from "./migration"
test("skips a completed migration", async () => {
const updates: Migration.Status[] = []
const client = {
migration: {
v1: {
status: async () => ({ status: "completed" as const }),
run: async () => ({ status: "running" as const }),
},
},
}
expect(await Migration.run(client, (status) => updates.push(status))).toBe(false)
expect(updates).toEqual([])
})
test("polls committed session progress after starting migration", async () => {
const updates: Migration.Status[] = []
let completed = 0
const client = {
migration: {
v1: {
status: async () =>
completed === 2
? { status: "completed" as const }
: {
status: "running" as const,
progress: { label: "Migrating sessions", numerator: completed, denominator: 2 },
},
run: async () => ({ status: "running" as const }),
},
},
}
const running = Migration.run(client, (status) => updates.push(status))
await Bun.sleep(50)
completed = 2
expect(await running).toBe(true)
expect(updates).toContainEqual({
status: "running",
progress: { label: "Migrating sessions", numerator: 0, denominator: 2 },
})
expect(updates).toContainEqual({
status: "completed",
})
})
test("surfaces a failed background migration", async () => {
const client = {
migration: {
v1: {
status: async () => ({
status: "error" as const,
error: "broken row",
}),
run: async () => ({ status: "running" as const }),
},
},
}
await expect(Migration.run(client, () => {})).rejects.toThrow("broken row")
})
-21
View File
@@ -1,21 +0,0 @@
import type { OpenCodeClient } from "@opencode-ai/client"
type Client = Pick<OpenCodeClient, "migration">
export type Status = Awaited<ReturnType<Client["migration"]["v1"]["status"]>>
export async function run(client: Client, update: (status: Status) => void, signal?: AbortSignal) {
const initial = await client.migration.v1.status({ signal })
if (initial.status === "completed") return false
update(initial)
await client.migration.v1.run({ signal })
while (true) {
const status = await client.migration.v1.status({ signal })
update(status)
if (status.status === "completed") return true
if (status.status === "error") throw new Error(status.error)
await Bun.sleep(1_000)
}
}
export * as Migration from "./migration"