| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227 |
- 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"
- export namespace Storage {
- const log = Log.create({ service: "storage" })
- type Migration = (dir: string) => Promise<void>
- 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
- for await (const projectDir of new Bun.Glob("*").scan({
- cwd: project,
- onlyFiles: false,
- })) {
- log.info(`migrating project ${projectDir}`)
- let projectID = projectDir
- const fullProjectDir = path.join(project, projectDir)
- let worktree = "/"
- if (projectID !== "global") {
- for await (const msgFile of new Bun.Glob("storage/session/message/*/*.json").scan({
- cwd: path.join(project, projectDir),
- absolute: true,
- })) {
- const json = await Bun.file(msgFile).json()
- 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 Bun.write(
- path.join(dir, "project", projectID + ".json"),
- JSON.stringify({
- id,
- vcs: "git",
- worktree,
- time: {
- created: Date.now(),
- initialized: Date.now(),
- },
- }),
- )
- log.info(`migrating sessions for project ${projectID}`)
- for await (const sessionFile of new Bun.Glob("storage/session/info/*.json").scan({
- cwd: fullProjectDir,
- absolute: true,
- })) {
- const dest = path.join(dir, "session", projectID, path.basename(sessionFile))
- log.info("copying", {
- sessionFile,
- dest,
- })
- const session = await Bun.file(sessionFile).json()
- await Bun.write(dest, JSON.stringify(session))
- log.info(`migrating messages for session ${session.id}`)
- for await (const msgFile of new Bun.Glob(`storage/session/message/${session.id}/*.json`).scan({
- cwd: fullProjectDir,
- absolute: true,
- })) {
- const dest = path.join(dir, "message", session.id, path.basename(msgFile))
- log.info("copying", {
- msgFile,
- dest,
- })
- const message = await Bun.file(msgFile).json()
- await Bun.write(dest, JSON.stringify(message))
- log.info(`migrating parts for message ${message.id}`)
- for await (const partFile of new Bun.Glob(`storage/session/part/${session.id}/${message.id}/*.json`).scan(
- {
- cwd: fullProjectDir,
- absolute: true,
- },
- )) {
- const dest = path.join(dir, "part", message.id, path.basename(partFile))
- const part = await Bun.file(partFile).json()
- log.info("copying", {
- partFile,
- dest,
- })
- await Bun.write(dest, JSON.stringify(part))
- }
- }
- }
- }
- }
- },
- async (dir) => {
- for await (const item of new Bun.Glob("session/*/*.json").scan({
- cwd: dir,
- absolute: true,
- })) {
- const session = await Bun.file(item).json()
- if (!session.projectID) continue
- if (!session.summary?.diffs) continue
- const { diffs } = session.summary
- await Bun.file(path.join(dir, "session_diff", session.id + ".json")).write(JSON.stringify(diffs))
- await Bun.file(path.join(dir, "session", session.projectID, session.id + ".json")).write(
- JSON.stringify({
- ...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 Bun.file(path.join(dir, "migration"))
- .json()
- .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 Bun.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<T>(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 Bun.file(target).json()
- return result as T
- })
- }
- export async function update<T>(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 Bun.file(target).json()
- fn(content)
- await Bun.write(target, JSON.stringify(content, null, 2))
- return content as T
- })
- }
- export async function write<T>(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 Bun.write(target, JSON.stringify(content, null, 2))
- })
- }
- async function withErrorHandling<T>(body: () => Promise<T>) {
- 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
- })
- }
- const glob = new Bun.Glob("**/*")
- export async function list(prefix: string[]) {
- const dir = await state().then((x) => x.dir)
- try {
- const result = await Array.fromAsync(
- glob.scan({
- cwd: path.join(dir, ...prefix),
- onlyFiles: true,
- }),
- ).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
- result.sort()
- return result
- } catch {
- return []
- }
- }
- }
|