Compare commits

..

1 Commits

Author SHA1 Message Date
Filip 4a6f5635f9 fix(core): adopt consolidated migration aliases
The V1-to-V2 consolidation replaced a shipped migration chain with a new ID, causing upgraded databases to replay already-applied schema changes. Treat the terminal pre-consolidation migration as an alias and record the canonical migration without rerunning its SQL.
2026-08-11 12:43:37 +00:00
18 changed files with 121 additions and 175 deletions
+5 -4
View File
@@ -194,10 +194,11 @@ async function formatTypescript(input: string) {
function renderRegistry(names: string[]) {
return `import type { DatabaseMigration } from "./migration"
${names.map((name, index) => `import m${index.toString().padStart(2, "0")} from "./migration/${name}"`).join("\n")}
export const migrations = [
${names.map((_, index) => ` m${index.toString().padStart(2, "0")},`).join("\n")}
] satisfies DatabaseMigration.Migration[]
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
${names.map((name) => ` import("./migration/${name}"),`).join("\n")}
])
).map((module) => module.default)
`
}
+3 -3
View File
@@ -153,12 +153,10 @@ const scan = Effect.fn("ConfigPluginSource.scan")(function* (
})
})
const sourceDirectories = ["plugin", "plugins"] as const
function discoverDirectory(fs: FSUtil.Interface, directory: string) {
return Effect.gen(function* () {
const files = yield* fs
.scan(`{${sourceDirectories.join(",")}}/*.{ts,js}`, {
.scan("{plugin,plugins}/*.{ts,js}", {
cwd: directory,
absolute: true,
include: "file",
@@ -170,6 +168,8 @@ function discoverDirectory(fs: FSUtil.Interface, directory: string) {
})
}
const sourceDirectories = ["plugin", "plugins"] as const
function isPluginSource(entries: readonly Entry[], file: string) {
return entries.some(
(entry) =>
+1 -1
View File
@@ -42,10 +42,10 @@ const databaseLayer = Layer.effect(
export function layer(options: Options = { path: ":memory:" }) {
return Layer.unwrap(
Effect.gen(function* () {
const global = yield* Global.Service
const provide = (filename: string) => databaseLayer.pipe(Layer.provide(sqliteLayer({ filename })))
const filename = options.path ?? ":memory:"
if (filename === ":memory:" || isAbsolute(filename)) return provide(filename)
const global = yield* Global.Service
return provide(join(global.data, filename))
}),
)
+45 -84
View File
@@ -1,86 +1,47 @@
import type { DatabaseMigration } from "./migration"
import m00 from "./migration/20260127222353_familiar_lady_ursula"
import m01 from "./migration/20260211171708_add_project_commands"
import m02 from "./migration/20260213144116_wakeful_the_professor"
import m03 from "./migration/20260225215848_workspace"
import m04 from "./migration/20260227213759_add_session_workspace_id"
import m05 from "./migration/20260228203230_blue_harpoon"
import m06 from "./migration/20260303231226_add_workspace_fields"
import m07 from "./migration/20260309230000_move_org_to_state"
import m08 from "./migration/20260312043431_session_message_cursor"
import m09 from "./migration/20260323234822_events"
import m10 from "./migration/20260410174513_workspace-name"
import m11 from "./migration/20260413175956_chief_energizer"
import m12 from "./migration/20260423070820_add_icon_url_override"
import m13 from "./migration/20260427172553_slow_nightmare"
import m14 from "./migration/20260428004200_add_session_path"
import m15 from "./migration/20260501142318_next_venus"
import m16 from "./migration/20260504145000_add_sync_owner"
import m17 from "./migration/20260507164347_add_workspace_time"
import m18 from "./migration/20260510033149_session_usage"
import m19 from "./migration/20260511000411_data_migration_state"
import m20 from "./migration/20260511173437_session-metadata"
import m21 from "./migration/20260601010001_normalize_storage_paths"
import m22 from "./migration/20260601202201_amazing_prowler"
import m23 from "./migration/20260602002951_lowly_union_jack"
import m24 from "./migration/20260602182828_add_project_directories"
import m25 from "./migration/20260603001617_session_message_projection_indexes"
import m26 from "./migration/20260603040000_session_message_projection_order"
import m27 from "./migration/20260603141458_session_input_inbox"
import m28 from "./migration/20260603160727_jittery_ezekiel_stane"
import m29 from "./migration/20260604172448_event_sourced_session_input"
import m30 from "./migration/20260605003541_add_session_context_snapshot"
import m31 from "./migration/20260605042240_add_context_epoch_agent"
import m32 from "./migration/20260611035744_credential"
import m33 from "./migration/20260611192811_lush_chimera"
import m34 from "./migration/20260612174303_project_dir_strategy"
import m35 from "./migration/20260622142730_simplify_session_context_epoch"
import m36 from "./migration/20260622170816_reset_v2_session_state"
import m37 from "./migration/20260622202450_simplify_session_input"
import m38 from "./migration/20260804233008_loose_psylocke"
import m39 from "./migration/20260805200742_import_legacy_credentials"
import m40 from "./migration/20260808023530_workspace_domain"
export const migrations = [
m00,
m01,
m02,
m03,
m04,
m05,
m06,
m07,
m08,
m09,
m10,
m11,
m12,
m13,
m14,
m15,
m16,
m17,
m18,
m19,
m20,
m21,
m22,
m23,
m24,
m25,
m26,
m27,
m28,
m29,
m30,
m31,
m32,
m33,
m34,
m35,
m36,
m37,
m38,
m39,
m40,
] satisfies DatabaseMigration.Migration[]
export const migrations: DatabaseMigration.Migration[] = (
await Promise.all([
import("./migration/20260127222353_familiar_lady_ursula"),
import("./migration/20260211171708_add_project_commands"),
import("./migration/20260213144116_wakeful_the_professor"),
import("./migration/20260225215848_workspace"),
import("./migration/20260227213759_add_session_workspace_id"),
import("./migration/20260228203230_blue_harpoon"),
import("./migration/20260303231226_add_workspace_fields"),
import("./migration/20260309230000_move_org_to_state"),
import("./migration/20260312043431_session_message_cursor"),
import("./migration/20260323234822_events"),
import("./migration/20260410174513_workspace-name"),
import("./migration/20260413175956_chief_energizer"),
import("./migration/20260423070820_add_icon_url_override"),
import("./migration/20260427172553_slow_nightmare"),
import("./migration/20260428004200_add_session_path"),
import("./migration/20260501142318_next_venus"),
import("./migration/20260504145000_add_sync_owner"),
import("./migration/20260507164347_add_workspace_time"),
import("./migration/20260510033149_session_usage"),
import("./migration/20260511000411_data_migration_state"),
import("./migration/20260511173437_session-metadata"),
import("./migration/20260601010001_normalize_storage_paths"),
import("./migration/20260601202201_amazing_prowler"),
import("./migration/20260602002951_lowly_union_jack"),
import("./migration/20260602182828_add_project_directories"),
import("./migration/20260603001617_session_message_projection_indexes"),
import("./migration/20260603040000_session_message_projection_order"),
import("./migration/20260603141458_session_input_inbox"),
import("./migration/20260603160727_jittery_ezekiel_stane"),
import("./migration/20260604172448_event_sourced_session_input"),
import("./migration/20260605003541_add_session_context_snapshot"),
import("./migration/20260605042240_add_context_epoch_agent"),
import("./migration/20260611035744_credential"),
import("./migration/20260611192811_lush_chimera"),
import("./migration/20260612174303_project_dir_strategy"),
import("./migration/20260622142730_simplify_session_context_epoch"),
import("./migration/20260622170816_reset_v2_session_state"),
import("./migration/20260622202450_simplify_session_input"),
import("./migration/20260804233008_loose_psylocke"),
import("./migration/20260805200742_import_legacy_credentials"),
import("./migration/20260808023530_workspace_domain"),
])
).map((module) => module.default)
+9
View File
@@ -13,6 +13,7 @@ const lock = Semaphore.makeUnsafe(1)
export type Migration = {
id: string
aliases?: string[]
foreignKeys?: boolean
up: (tx: Transaction) => Effect.Effect<void, unknown, Global.Service>
}
@@ -77,6 +78,14 @@ export function applyOnly(db: Database, input: Migration[]) {
for (const migration of input) {
if (completed.has(migration.id)) continue
const alias = migration.aliases?.find((id) => completed.has(id))
if (alias) {
yield* db.run(
sql`INSERT INTO ${sql.identifier("migration")} (id, time_completed) VALUES (${migration.id}, ${Date.now()})`,
)
yield* Effect.logInfo("database migration adopted", { migration: migration.id, alias })
continue
}
const started = Date.now()
yield* Effect.logInfo("database migration started", { migration: migration.id })
const apply = db.transaction((tx) =>
@@ -3,6 +3,7 @@ import type { DatabaseMigration } from "../migration"
const migration: DatabaseMigration.Migration = {
id: "20260804233008_loose_psylocke",
aliases: ["20260730195856_optional_session_title"],
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`
+2 -11
View File
@@ -1,4 +1,4 @@
import { Cause, Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { Context, Duration, Effect, Layer, Option, Schedule, Schema, Semaphore } from "effect"
import { HttpClient, HttpClientRequest } from "effect/unstable/http"
import { ModelsDev } from "@opencode-ai/schema/models-dev"
import { Money } from "@opencode-ai/schema/money"
@@ -612,16 +612,7 @@ export const layer = (options?: Options) =>
const fetchAndWrite = Effect.fn("ModelsDev.fetchAndWrite")(function* () {
const text = yield* fetchApi()
const catalog = (yield* Schema.decodeUnknownEffect(CatalogJson)(text)) as Record<string, SourceProvider>
// Best-effort: a cache-write failure must never kill catalog
// population. The payload has outgrown some KV backends' per-value
// limits (Durable Object SQLite caps values at 2 MB and api.json
// passed it in Aug 2026); a boot without a cache hit just refetches.
yield* kv.set(key, { updatedAt: Date.now(), body: text }).pipe(
Effect.catchCauseIf(
(cause) => !Cause.hasInterruptsOnly(cause),
(cause) => Effect.logWarning("Failed to cache models.dev catalog", { cause }),
),
)
yield* kv.set(key, { updatedAt: Date.now(), body: text })
return catalog
})
@@ -65,15 +65,15 @@ const userAttachmentContent = (files: readonly FileAttachment[]) => {
)
if (eligible.length < 2) return files.flatMap(attachmentContent)
const seen = new Map<string, Set<string>>()
const seen = new Map<string, string[]>()
return files.flatMap((file) => {
if (!imageMimes.has(file.mime) || file.source.type !== "inline" || !file.mention?.text)
return attachmentContent(file)
const metadata = JSON.stringify([file.mime, file.name ?? null, file.description ?? null, file.mention.text])
const payloads = seen.get(metadata) ?? new Set<string>()
if (payloads.has(file.data)) return []
payloads.add(file.data)
seen.set(metadata, payloads)
const matches = seen.get(metadata)
if (matches?.includes(file.data)) return []
if (matches) matches.push(file.data)
if (!matches) seen.set(metadata, [file.data])
return attachmentContent(file)
})
}
@@ -12,6 +12,7 @@ import { Database } from "@opencode-ai/core/database/database"
import { tmpdir } from "./fixture/tmpdir"
import type { SqlClient } from "effect/unstable/sql/SqlClient"
import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
import consolidatedV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
import { Global } from "@opencode-ai/util/global"
const run = <A, E>(
@@ -114,6 +115,28 @@ describe("DatabaseMigration", () => {
)
})
test("adopts the consolidated V2 migration after the superseded chain", async () => {
await run(
Effect.gen(function* () {
const db = yield* makeDb
yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, created integer DEFAULT 0 NOT NULL)`)
yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
yield* db.run(sql`
INSERT INTO migration (id, time_completed)
VALUES ('20260730195856_optional_session_title', 1)
`)
yield* DatabaseMigration.applyOnly(db, [consolidatedV2Migration])
expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${consolidatedV2Migration.id}`)).toEqual({
id: consolidatedV2Migration.id,
})
expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'kv'`)).toBeUndefined()
}),
)
})
test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
await using tmp = await tmpdir()
const source = path.join(tmp.path, "auth.json")
-28
View File
@@ -185,15 +185,6 @@ const buildLayer = (state: Ref.Ref<MockState>, cache: MockCache, options: Models
]),
)
// Mirrors production KV backends whose writes die as defects (e.g. Durable
// Object SQLite rejecting values over its 2 MB cap with EffectDrizzleQueryError).
const makeFailingWriteKV = (cache: MockCache) =>
Layer.mock(KV.Service, {
get: (key) => Effect.sync(() => cache.values.get(key)),
set: () => Effect.die(new Error('Failed query: insert into "kv"')),
remove: (key) => Effect.sync(() => cache.values.delete(key)).pipe(Effect.asVoid),
})
const makeCache = (): MockCache => ({ values: new Map() })
const writeCacheText = (cache: MockCache, text: string, updatedAt = Date.now()) =>
@@ -257,25 +248,6 @@ describe("ModelsDev Service", () => {
}),
)
it.live("get() still populates the catalog when the KV cache write fails", () =>
Effect.gen(function* () {
const cache = makeCache()
const state = yield* Ref.make({ ...initialState, body: JSON.stringify(fixture2) })
const layer = Layer.fresh(
AppNodeBuilder.build(ModelsDev.node, [
[ModelsDev.node, ModelsDev.configured({ fetch: true })],
[LayerNodePlatform.httpClient, Layer.succeed(HttpClient.HttpClient, makeMockClient(state))],
[KV.node, makeFailingWriteKV(cache)],
]),
)
const result = yield* ModelsDev.Service.use((s) => s.get()).pipe(Effect.provide(layer))
expect(result).toEqual(fixture2Snapshot)
expect(cache.values.has(cacheKey)).toBe(false)
const final = yield* Ref.get(state)
expect(final.calls.length).toBe(1)
}),
)
it.live("uses the default models URL when the configured URL is empty", () =>
Effect.gen(function* () {
const cache = makeCache()
+7 -8
View File
@@ -77,21 +77,20 @@ export function spatialPathSpans(points: readonly DiagramPoint[]): SpatialSpan[]
.sort(([left], [right]) => left - right)
.flatMap(([y, xs]) => {
const sorted = [...xs].sort((left, right) => left - right)
const [first, ...rest] = sorted
if (first === undefined) return []
const spans: SpatialSpan[] = []
let start = first
let end = first
for (const x of rest) {
if (x === end + 1) {
let start = sorted[0]
let end = start
if (start === undefined) return spans
for (const x of sorted.slice(1)) {
if (x === end! + 1) {
end = x
continue
}
spans.push(normalizedSpan(y, start, end))
spans.push(normalizedSpan(y, start, end!))
start = x
end = x
}
spans.push(normalizedSpan(y, start, end))
spans.push(normalizedSpan(y, start, end!))
return spans
})
}
+5 -1
View File
@@ -62,9 +62,13 @@ function createMarquee(hovered: () => string | undefined, animations: () => bool
const leading = createAnimatable({ opacity: 0 }, { enabled: animations, transition: tween({ duration: 0.25 }) })
createEffect(() => {
if (!hovered()) {
setOffset(0)
leading.jump({ opacity: 0 })
return
}
setOffset(0)
leading.jump({ opacity: 0 })
if (!hovered()) return
let interval: ReturnType<typeof setInterval> | undefined
const delay = setTimeout(() => {
setOffset(1)
+6 -6
View File
@@ -25,14 +25,14 @@ function deduplicateByIdentity<T>(
items: readonly T[],
identity: (item: T) => { metadata: string; payload: string } | undefined,
) {
const seen = new Map<string, Set<string>>()
const seen = new Map<string, string[]>()
return items.filter((item) => {
const key = identity(item)
if (!key) return true
const payloads = seen.get(key.metadata) ?? new Set<string>()
if (payloads.has(key.payload)) return false
payloads.add(key.payload)
seen.set(key.metadata, payloads)
const matches = seen.get(key.metadata)
if (matches?.includes(key.payload)) return false
if (matches) matches.push(key.payload)
if (!matches) seen.set(key.metadata, [key.payload])
return true
})
}
@@ -42,7 +42,7 @@ export function deduplicatePromptImages(files: readonly PromptFile[] | undefined
return deduplicateByIdentity(files, (file) =>
file.uri.startsWith("data:image/") && file.mention?.text
? {
metadata: JSON.stringify([file.name ?? null, file.description ?? null, file.mention.text]),
metadata: JSON.stringify([attachmentMetadata(file), file.mention.text]),
payload: file.uri,
}
: undefined,
+2 -4
View File
@@ -7,15 +7,13 @@ import Config from "@npmcli/config"
import { definitions, flatten, nerfDarts, shorthands } from "@npmcli/config/lib/definitions/index.js"
import { Effect } from "effect"
// Lazy: on workerd import.meta.url is undefined and constructing a URL from it
// at module scope fails startup validation; npm config is never used there.
const npmPath = () => fileURLToPath(new URL("..", import.meta.url))
const npmPath = fileURLToPath(new URL("..", import.meta.url))
export const load = (dir: string) =>
Effect.tryPromise({
try: async () => {
const config = new Config({
npmPath: npmPath(),
npmPath,
cwd: dir,
env: { ...process.env },
argv: [process.execPath, process.execPath, "--prefix", dir],
+2 -8
View File
@@ -1,6 +1,6 @@
export * as Observability from "./observability.js"
import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem"
import { NodeFileSystem } from "@effect/platform-node"
import { LayerNode } from "./effect/layer-node.js"
import { Effect, Layer, Logger, References, Schema } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
@@ -50,10 +50,4 @@ export function layer(
).pipe(Layer.catchCause(() => local))
}
// Layer.suspend: constructing the loggers eagerly at module scope performs
// I/O (file logger, run id) that workerd forbids in global scope.
export const node = LayerNode.make({
name: "observability",
layer: Layer.suspend(() => layer()),
deps: [],
})
export const node = LayerNode.make({ name: "observability", layer: layer(), deps: [] })
+2 -2
View File
@@ -3,7 +3,7 @@ import path from "path"
import { Global } from "../global.js"
import { runID } from "./shared.js"
function formatter(id: string = runID()) {
function formatter(id: string = runID) {
return Logger.map(Logger.formatStructured, (output) => {
const messages = Array.isArray(output.message) ? output.message : [output.message]
return [
@@ -51,7 +51,7 @@ export function file(local = true, channel = "local") {
return path.join(Global.Path.log, `opencode-${channel.replace(/[^a-zA-Z0-9._-]/g, "-")}.log`)
}
export function fileLogger(target = file(), id: string = runID()) {
export function fileLogger(target = file(), id: string = runID) {
// Do not set batchWindow to 0; it causes high idle CPU usage.
return Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem
+2 -2
View File
@@ -54,8 +54,8 @@ export function resource(app: App = { client: "opencode", version: "unknown", ch
...resourceAttributes(),
"deployment.environment.name": app.channel,
"opencode.client": app.client,
"opencode.run": runID(),
"service.instance.id": runID(),
"opencode.run": runID,
"service.instance.id": runID,
},
}
}
+1 -8
View File
@@ -1,8 +1 @@
// Lazy: workerd forbids generating random values in global scope, so the id
// materializes on first call (inside a handler) and stays stable afterwards.
let generated: string | undefined
export function runID(): string {
generated ??= crypto.randomUUID().slice(0, 8)
return generated
}
export const runID = crypto.randomUUID().slice(0, 8)