refactor(core): auto-start v1 migration
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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" }))
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user