storage.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221
  1. import { Log } from "../util/log"
  2. import path from "path"
  3. import fs from "fs/promises"
  4. import { Global } from "../global"
  5. import { Filesystem } from "../util/filesystem"
  6. import { lazy } from "../util/lazy"
  7. import { Lock } from "../util/lock"
  8. import { $ } from "bun"
  9. import { NamedError } from "@opencode-ai/util/error"
  10. import z from "zod"
  11. export namespace Storage {
  12. const log = Log.create({ service: "storage" })
  13. type Migration = (dir: string) => Promise<void>
  14. export const NotFoundError = NamedError.create(
  15. "NotFoundError",
  16. z.object({
  17. message: z.string(),
  18. }),
  19. )
  20. const MIGRATIONS: Migration[] = [
  21. async (dir) => {
  22. const project = path.resolve(dir, "../project")
  23. if (!(await Filesystem.isDir(project))) return
  24. for await (const projectDir of new Bun.Glob("*").scan({
  25. cwd: project,
  26. onlyFiles: false,
  27. })) {
  28. log.info(`migrating project ${projectDir}`)
  29. let projectID = projectDir
  30. const fullProjectDir = path.join(project, projectDir)
  31. let worktree = "/"
  32. if (projectID !== "global") {
  33. for await (const msgFile of new Bun.Glob("storage/session/message/*/*.json").scan({
  34. cwd: path.join(project, projectDir),
  35. absolute: true,
  36. })) {
  37. const json = await Filesystem.readJson<any>(msgFile)
  38. worktree = json.path?.root
  39. if (worktree) break
  40. }
  41. if (!worktree) continue
  42. if (!(await Filesystem.isDir(worktree))) continue
  43. const [id] = await $`git rev-list --max-parents=0 --all`
  44. .quiet()
  45. .nothrow()
  46. .cwd(worktree)
  47. .text()
  48. .then((x) =>
  49. x
  50. .split("\n")
  51. .filter(Boolean)
  52. .map((x) => x.trim())
  53. .toSorted(),
  54. )
  55. if (!id) continue
  56. projectID = id
  57. await Filesystem.writeJson(path.join(dir, "project", projectID + ".json"), {
  58. id,
  59. vcs: "git",
  60. worktree,
  61. time: {
  62. created: Date.now(),
  63. initialized: Date.now(),
  64. },
  65. })
  66. log.info(`migrating sessions for project ${projectID}`)
  67. for await (const sessionFile of new Bun.Glob("storage/session/info/*.json").scan({
  68. cwd: fullProjectDir,
  69. absolute: true,
  70. })) {
  71. const dest = path.join(dir, "session", projectID, path.basename(sessionFile))
  72. log.info("copying", {
  73. sessionFile,
  74. dest,
  75. })
  76. const session = await Filesystem.readJson<any>(sessionFile)
  77. await Filesystem.writeJson(dest, session)
  78. log.info(`migrating messages for session ${session.id}`)
  79. for await (const msgFile of new Bun.Glob(`storage/session/message/${session.id}/*.json`).scan({
  80. cwd: fullProjectDir,
  81. absolute: true,
  82. })) {
  83. const dest = path.join(dir, "message", session.id, path.basename(msgFile))
  84. log.info("copying", {
  85. msgFile,
  86. dest,
  87. })
  88. const message = await Filesystem.readJson<any>(msgFile)
  89. await Filesystem.writeJson(dest, message)
  90. log.info(`migrating parts for message ${message.id}`)
  91. for await (const partFile of new Bun.Glob(`storage/session/part/${session.id}/${message.id}/*.json`).scan(
  92. {
  93. cwd: fullProjectDir,
  94. absolute: true,
  95. },
  96. )) {
  97. const dest = path.join(dir, "part", message.id, path.basename(partFile))
  98. const part = await Filesystem.readJson(partFile)
  99. log.info("copying", {
  100. partFile,
  101. dest,
  102. })
  103. await Filesystem.writeJson(dest, part)
  104. }
  105. }
  106. }
  107. }
  108. }
  109. },
  110. async (dir) => {
  111. for await (const item of new Bun.Glob("session/*/*.json").scan({
  112. cwd: dir,
  113. absolute: true,
  114. })) {
  115. const session = await Filesystem.readJson<any>(item)
  116. if (!session.projectID) continue
  117. if (!session.summary?.diffs) continue
  118. const { diffs } = session.summary
  119. await Filesystem.write(path.join(dir, "session_diff", session.id + ".json"), JSON.stringify(diffs))
  120. await Filesystem.writeJson(path.join(dir, "session", session.projectID, session.id + ".json"), {
  121. ...session,
  122. summary: {
  123. additions: diffs.reduce((sum: any, x: any) => sum + x.additions, 0),
  124. deletions: diffs.reduce((sum: any, x: any) => sum + x.deletions, 0),
  125. },
  126. })
  127. }
  128. },
  129. ]
  130. const state = lazy(async () => {
  131. const dir = path.join(Global.Path.data, "storage")
  132. const migration = await Filesystem.readJson<string>(path.join(dir, "migration"))
  133. .then((x) => parseInt(x))
  134. .catch(() => 0)
  135. for (let index = migration; index < MIGRATIONS.length; index++) {
  136. log.info("running migration", { index })
  137. const migration = MIGRATIONS[index]
  138. await migration(dir).catch(() => log.error("failed to run migration", { index }))
  139. await Filesystem.write(path.join(dir, "migration"), (index + 1).toString())
  140. }
  141. return {
  142. dir,
  143. }
  144. })
  145. export async function remove(key: string[]) {
  146. const dir = await state().then((x) => x.dir)
  147. const target = path.join(dir, ...key) + ".json"
  148. return withErrorHandling(async () => {
  149. await fs.unlink(target).catch(() => {})
  150. })
  151. }
  152. export async function read<T>(key: string[]) {
  153. const dir = await state().then((x) => x.dir)
  154. const target = path.join(dir, ...key) + ".json"
  155. return withErrorHandling(async () => {
  156. using _ = await Lock.read(target)
  157. const result = await Filesystem.readJson<T>(target)
  158. return result as T
  159. })
  160. }
  161. export async function update<T>(key: string[], fn: (draft: T) => void) {
  162. const dir = await state().then((x) => x.dir)
  163. const target = path.join(dir, ...key) + ".json"
  164. return withErrorHandling(async () => {
  165. using _ = await Lock.write(target)
  166. const content = await Filesystem.readJson<T>(target)
  167. fn(content as T)
  168. await Filesystem.writeJson(target, content)
  169. return content
  170. })
  171. }
  172. export async function write<T>(key: string[], content: T) {
  173. const dir = await state().then((x) => x.dir)
  174. const target = path.join(dir, ...key) + ".json"
  175. return withErrorHandling(async () => {
  176. using _ = await Lock.write(target)
  177. await Filesystem.writeJson(target, content)
  178. })
  179. }
  180. async function withErrorHandling<T>(body: () => Promise<T>) {
  181. return body().catch((e) => {
  182. if (!(e instanceof Error)) throw e
  183. const errnoException = e as NodeJS.ErrnoException
  184. if (errnoException.code === "ENOENT") {
  185. throw new NotFoundError({ message: `Resource not found: ${errnoException.path}` })
  186. }
  187. throw e
  188. })
  189. }
  190. const glob = new Bun.Glob("**/*")
  191. export async function list(prefix: string[]) {
  192. const dir = await state().then((x) => x.dir)
  193. try {
  194. const result = await Array.fromAsync(
  195. glob.scan({
  196. cwd: path.join(dir, ...prefix),
  197. onlyFiles: true,
  198. }),
  199. ).then((results) => results.map((x) => [...prefix, ...x.slice(0, -5).split(path.sep)]))
  200. result.sort()
  201. return result
  202. } catch {
  203. return []
  204. }
  205. }
  206. }