import { Log } from "../util/log" import path from "path" import fs from "fs/promises" import { Global } from "../global" import { Filesystem } from "../util/filesystem" import { lazy } from "../util/lazy" import { Lock } from "../util/lock" import { $ } from "bun" import { NamedError } from "@opencode-ai/util/error" import z from "zod" import { Glob } from "../util/glob" export namespace Storage { const log = Log.create({ service: "storage" }) type Migration = (dir: string) => Promise export const NotFoundError = NamedError.create( "NotFoundError", z.object({ message: z.string(), }), ) const MIGRATIONS: Migration[] = [ async (dir) => { const project = path.resolve(dir, "../project") if (!(await Filesystem.isDir(project))) return const projectDirs = await Glob.scan("*", { cwd: project, include: "all", }) for (const projectDir of projectDirs) { const fullPath = path.join(project, projectDir) if (!(await Filesystem.isDir(fullPath))) continue log.info(`migrating project ${projectDir}`) let projectID = projectDir const fullProjectDir = path.join(project, projectDir) let worktree = "/" if (projectID !== "global") { for (const msgFile of await Glob.scan("storage/session/message/*/*.json", { cwd: path.join(project, projectDir), absolute: true, })) { const json = await Filesystem.readJson(msgFile) worktree = json.path?.root if (worktree) break } if (!worktree) continue if (!(await Filesystem.isDir(worktree))) continue const [id] = await $`git rev-list --max-parents=0 --all` .quiet() .nothrow() .cwd(worktree) .text() .then((x) => x .split("\n") .filter(Boolean) .map((x) => x.trim()) .toSorted(), ) if (!id) continue projectID = id await Filesystem.writeJson(path.join(dir, "project", projectID + ".json"), { id, vcs: "git", worktree, time: { created: Date.now(), initialized: Date.now(), }, }) log.info(`migrating sessions for project ${projectID}`) for (const sessionFile of await Glob.scan("storage/session/info/*.json", { cwd: fullProjectDir, absolute: true, })) { const dest = path.join(dir, "session", projectID, path.basename(sessionFile)) log.info("copying", { sessionFile, dest, }) const session = await Filesystem.readJson(sessionFile) await Filesystem.writeJson(dest, session) log.info(`migrating messages for session ${session.id}`) for (const msgFile of await Glob.scan(`storage/session/message/${session.id}/*.json`, { cwd: fullProjectDir, absolute: true, })) { const dest = path.join(dir, "message", session.id, path.basename(msgFile)) log.info("copying", { msgFile, dest, }) const message = await Filesystem.readJson(msgFile) await Filesystem.writeJson(dest, message) log.info(`migrating parts for message ${message.id}`) for (const partFile of await Glob.scan(`storage/session/part/${session.id}/${message.id}/*.json`, { cwd: fullProjectDir, absolute: true, })) { const dest = path.join(dir, "part", message.id, path.basename(partFile)) const part = await Filesystem.readJson(partFile) log.info("copying", { partFile, dest, }) await Filesystem.writeJson(dest, part) } } } } } }, async (dir) => { for (const item of await Glob.scan("session/*/*.json", { cwd: dir, absolute: true, })) { const session = await Filesystem.readJson(item) if (!session.projectID) continue if (!session.summary?.diffs) continue const { diffs } = session.summary await Filesystem.write(path.join(dir, "session_diff", session.id + ".json"), JSON.stringify(diffs)) await Filesystem.writeJson(path.join(dir, "session", session.projectID, session.id + ".json"), { ...session, summary: { additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0), deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0), }, }) } }, ] const state = lazy(async () => { const dir = path.join(Global.Path.data, "storage") const migration = await Filesystem.readJson(path.join(dir, "migration")) .then((x) => parseInt(x)) .catch(() => 0) for (let index = migration; index < MIGRATIONS.length; index++) { log.info("running migration", { index }) const migration = MIGRATIONS[index] await migration(dir).catch(() => log.error("failed to run migration", { index })) await Filesystem.write(path.join(dir, "migration"), (index + 1).toString()) } return { dir, } }) export async function remove(key: string[]) { const dir = await state().then((x) => x.dir) const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { await fs.unlink(target).catch(() => {}) }) } export async function read(key: string[]) { const dir = await state().then((x) => x.dir) const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { using _ = await Lock.read(target) const result = await Filesystem.readJson(target) return result as T }) } export async function update(key: string[], fn: (draft: T) => void) { const dir = await state().then((x) => x.dir) const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { using _ = await Lock.write(target) const content = await Filesystem.readJson(target) fn(content as T) await Filesystem.writeJson(target, content) return content }) } export async function write(key: string[], content: T) { const dir = await state().then((x) => x.dir) const target = path.join(dir, ...key) + ".json" return withErrorHandling(async () => { using _ = await Lock.write(target) await Filesystem.writeJson(target, content) }) } async function withErrorHandling(body: () => Promise) { return body().catch((e) => { if (!(e instanceof Error)) throw e const errnoException = e as NodeJS.ErrnoException if (errnoException.code === "ENOENT") { throw new NotFoundError({ message: `Resource not found: ${errnoException.path}` }) } throw e }) } export async function list(prefix: string[]) { const dir = await state().then((x) => x.dir) try { const result = await Glob.scan("**/*", { cwd: path.join(dir, ...prefix), include: "file", }).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)])) result.sort() return result } catch { return [] } } }