storage.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. import { describe, expect } from "bun:test"
  2. import path from "path"
  3. import { Effect, Exit, Layer } from "effect"
  4. import { AppFileSystem } from "@opencode-ai/core/filesystem"
  5. import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner"
  6. import { Git } from "../../src/git"
  7. import { Global } from "@opencode-ai/core/global"
  8. import { Storage } from "@/storage/storage"
  9. import { tmpdirScoped } from "../fixture/fixture"
  10. import { testEffect } from "../lib/effect"
  11. const dir = path.join(Global.Path.data, "storage")
  12. const it = testEffect(Layer.mergeAll(Storage.defaultLayer, AppFileSystem.defaultLayer, CrossSpawnSpawner.defaultLayer))
  13. const scope = Effect.fnUntraced(function* () {
  14. const root = ["storage_test", crypto.randomUUID()]
  15. const fs = yield* AppFileSystem.Service
  16. const svc = yield* Storage.Service
  17. yield* Effect.addFinalizer(() =>
  18. fs.remove(path.join(dir, ...root), { recursive: true, force: true }).pipe(Effect.ignore),
  19. )
  20. return { root, svc }
  21. })
  22. // remap(root) rewrites any path under Global.Path.data to live under `root` instead.
  23. // Used by remappedFs to build an AppFileSystem that Storage thinks is the real global
  24. // data dir but actually targets a tmp dir — letting migration tests stage legacy layouts.
  25. // NOTE: only the 6 methods below are intercepted. If Storage starts using a different
  26. // AppFileSystem method that touches Global.Path.data, add it here.
  27. function remap(root: string, file: string) {
  28. if (file === Global.Path.data) return root
  29. if (file.startsWith(Global.Path.data + path.sep)) return path.join(root, path.relative(Global.Path.data, file))
  30. return file
  31. }
  32. function remappedFs(root: string) {
  33. return Layer.effect(
  34. AppFileSystem.Service,
  35. Effect.gen(function* () {
  36. const fs = yield* AppFileSystem.Service
  37. return AppFileSystem.Service.of({
  38. ...fs,
  39. isDir: (file) => fs.isDir(remap(root, file)),
  40. readJson: (file) => fs.readJson(remap(root, file)),
  41. writeWithDirs: (file, content, mode) => fs.writeWithDirs(remap(root, file), content, mode),
  42. readFileString: (file) => fs.readFileString(remap(root, file)),
  43. remove: (file) => fs.remove(remap(root, file)),
  44. glob: (pattern, options) =>
  45. fs.glob(pattern, options?.cwd ? { ...options, cwd: remap(root, options.cwd) } : options),
  46. })
  47. }),
  48. ).pipe(Layer.provide(AppFileSystem.defaultLayer))
  49. }
  50. // Layer.fresh forces a new Storage instance — without it, Effect's in-test layer cache
  51. // returns the outer testEffect's Storage (which uses the real AppFileSystem), not a new
  52. // one built on top of remappedFs.
  53. const remappedStorage = (root: string) =>
  54. Layer.fresh(Storage.layer.pipe(Layer.provide(remappedFs(root)), Layer.provide(Git.defaultLayer)))
  55. describe("Storage", () => {
  56. it.live("round-trips JSON content", () =>
  57. Effect.gen(function* () {
  58. const { root, svc } = yield* scope()
  59. const key = [...root, "session_diff", "roundtrip"]
  60. const value = [{ file: "a.ts", additions: 2, deletions: 1 }]
  61. yield* svc.write(key, value)
  62. expect(yield* svc.read<typeof value>(key)).toEqual(value)
  63. }),
  64. )
  65. it.live("maps missing reads to NotFoundError", () =>
  66. Effect.gen(function* () {
  67. const { root, svc } = yield* scope()
  68. const error = yield* Effect.flip(svc.read([...root, "missing", "value"]))
  69. expect(error).toBeInstanceOf(Storage.NotFoundError)
  70. expect(error._tag).toBe("NotFoundError")
  71. expect(error.message).toContain(path.join(...root, "missing", "value") + ".json")
  72. }),
  73. )
  74. it.live("update on missing key throws NotFoundError", () =>
  75. Effect.gen(function* () {
  76. const { root, svc } = yield* scope()
  77. const error = yield* Effect.flip(
  78. svc.update<{ value: number }>([...root, "missing", "key"], (draft) => {
  79. draft.value += 1
  80. }),
  81. )
  82. expect(error).toBeInstanceOf(Storage.NotFoundError)
  83. expect(error._tag).toBe("NotFoundError")
  84. }),
  85. )
  86. it.live("write overwrites existing value", () =>
  87. Effect.gen(function* () {
  88. const { root, svc } = yield* scope()
  89. const key = [...root, "overwrite", "test"]
  90. yield* svc.write<{ v: number }>(key, { v: 1 })
  91. yield* svc.write<{ v: number }>(key, { v: 2 })
  92. expect(yield* svc.read<{ v: number }>(key)).toEqual({ v: 2 })
  93. }),
  94. )
  95. it.live("remove on missing key is a no-op", () =>
  96. Effect.gen(function* () {
  97. const { root, svc } = yield* scope()
  98. yield* svc.remove([...root, "nonexistent", "key"])
  99. }),
  100. )
  101. it.live("list on missing prefix returns empty", () =>
  102. Effect.gen(function* () {
  103. const { root, svc } = yield* scope()
  104. expect(yield* svc.list([...root, "nonexistent"])).toEqual([])
  105. }),
  106. )
  107. it.live("serializes concurrent updates for the same key", () =>
  108. Effect.gen(function* () {
  109. const { root, svc } = yield* scope()
  110. const key = [...root, "counter", "shared"]
  111. yield* svc.write(key, { value: 0 })
  112. yield* Effect.all(
  113. Array.from({ length: 25 }, () =>
  114. svc.update<{ value: number }>(key, (draft) => {
  115. draft.value += 1
  116. }),
  117. ),
  118. { concurrency: "unbounded" },
  119. )
  120. expect(yield* svc.read<{ value: number }>(key)).toEqual({ value: 25 })
  121. }),
  122. )
  123. it.live("concurrent reads do not block each other", () =>
  124. Effect.gen(function* () {
  125. const { root, svc } = yield* scope()
  126. const key = [...root, "concurrent", "reads"]
  127. yield* svc.write(key, { ok: true })
  128. const results = yield* Effect.all(
  129. Array.from({ length: 10 }, () => svc.read(key)),
  130. { concurrency: "unbounded" },
  131. )
  132. expect(results).toHaveLength(10)
  133. for (const r of results) expect(r).toEqual({ ok: true })
  134. }),
  135. )
  136. it.live("nested keys create deep paths", () =>
  137. Effect.gen(function* () {
  138. const { root, svc } = yield* scope()
  139. const key = [...root, "a", "b", "c", "deep"]
  140. yield* svc.write<{ nested: boolean }>(key, { nested: true })
  141. expect(yield* svc.read<{ nested: boolean }>(key)).toEqual({ nested: true })
  142. expect(yield* svc.list([...root, "a"])).toEqual([key])
  143. }),
  144. )
  145. it.live("lists and removes stored entries", () =>
  146. Effect.gen(function* () {
  147. const { root, svc } = yield* scope()
  148. const a = [...root, "list", "a"]
  149. const b = [...root, "list", "b"]
  150. const prefix = [...root, "list"]
  151. yield* svc.write(b, { value: 2 })
  152. yield* svc.write(a, { value: 1 })
  153. expect(yield* svc.list(prefix)).toEqual([a, b])
  154. yield* svc.remove(a)
  155. expect(yield* svc.list(prefix)).toEqual([b])
  156. const exit = yield* svc.read(a).pipe(Effect.exit)
  157. expect(Exit.isFailure(exit)).toBe(true)
  158. }),
  159. )
  160. it.live("migration 2 runs when marker contents are invalid", () =>
  161. Effect.gen(function* () {
  162. const fs = yield* AppFileSystem.Service
  163. const tmp = yield* tmpdirScoped()
  164. const storage = path.join(tmp, "storage")
  165. const diffs = [
  166. { additions: 2, deletions: 1 },
  167. { additions: 3, deletions: 4 },
  168. ]
  169. yield* fs.writeWithDirs(path.join(storage, "migration"), "wat")
  170. yield* fs.writeWithDirs(
  171. path.join(storage, "session", "proj_test", "ses_test.json"),
  172. JSON.stringify({
  173. id: "ses_test",
  174. projectID: "proj_test",
  175. title: "legacy",
  176. summary: { diffs },
  177. }),
  178. )
  179. yield* Effect.gen(function* () {
  180. const svc = yield* Storage.Service
  181. expect(yield* svc.list(["session_diff"])).toEqual([["session_diff", "ses_test"]])
  182. expect(yield* svc.read<typeof diffs>(["session_diff", "ses_test"])).toEqual(diffs)
  183. expect(
  184. yield* svc.read<{
  185. id: string
  186. projectID: string
  187. title: string
  188. summary: { additions: number; deletions: number }
  189. }>(["session", "proj_test", "ses_test"]),
  190. ).toEqual({
  191. id: "ses_test",
  192. projectID: "proj_test",
  193. title: "legacy",
  194. summary: { additions: 5, deletions: 5 },
  195. })
  196. }).pipe(Effect.provide(remappedStorage(tmp)))
  197. expect(yield* fs.readFileString(path.join(storage, "migration"))).toBe("2")
  198. }),
  199. )
  200. it.live("migration 1 tolerates malformed legacy records", () =>
  201. Effect.gen(function* () {
  202. const fs = yield* AppFileSystem.Service
  203. const tmp = yield* tmpdirScoped({ git: true })
  204. const storage = path.join(tmp, "storage")
  205. const legacy = path.join(tmp, "project", "legacy")
  206. yield* fs.writeWithDirs(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "[]")
  207. yield* fs.writeWithDirs(
  208. path.join(legacy, "storage", "session", "message", "probe", "1.json"),
  209. JSON.stringify({ path: { root: tmp } }),
  210. )
  211. yield* fs.writeWithDirs(
  212. path.join(legacy, "storage", "session", "info", "ses_legacy.json"),
  213. JSON.stringify({ id: "ses_legacy", title: "legacy" }),
  214. )
  215. yield* fs.writeWithDirs(
  216. path.join(legacy, "storage", "session", "message", "ses_legacy", "msg_legacy.json"),
  217. JSON.stringify({ role: "user", text: "hello" }),
  218. )
  219. yield* Effect.gen(function* () {
  220. const svc = yield* Storage.Service
  221. const projects = yield* svc.list(["project"])
  222. expect(projects).toHaveLength(1)
  223. const project = projects[0]![1]
  224. expect(yield* svc.list(["session", project])).toEqual([["session", project, "ses_legacy"]])
  225. expect(yield* svc.read<{ id: string; title: string }>(["session", project, "ses_legacy"])).toEqual({
  226. id: "ses_legacy",
  227. title: "legacy",
  228. })
  229. expect(yield* svc.read<{ role: string; text: string }>(["message", "ses_legacy", "msg_legacy"])).toEqual({
  230. role: "user",
  231. text: "hello",
  232. })
  233. }).pipe(Effect.provide(remappedStorage(tmp)))
  234. expect(yield* fs.readFileString(path.join(storage, "migration"))).toBe("2")
  235. }),
  236. )
  237. it.live("failed migrations do not advance the marker", () =>
  238. Effect.gen(function* () {
  239. const fs = yield* AppFileSystem.Service
  240. const tmp = yield* tmpdirScoped()
  241. const storage = path.join(tmp, "storage")
  242. const legacy = path.join(tmp, "project", "legacy")
  243. yield* fs.writeWithDirs(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "{")
  244. yield* Effect.gen(function* () {
  245. const svc = yield* Storage.Service
  246. expect(yield* svc.list(["project"])).toEqual([])
  247. }).pipe(Effect.provide(remappedStorage(tmp)))
  248. const exit = yield* fs.access(path.join(storage, "migration")).pipe(Effect.exit)
  249. expect(Exit.isFailure(exit)).toBe(true)
  250. }),
  251. )
  252. })