storage.test.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  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/shared/filesystem"
  5. import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
  6. import { Git } from "../../src/git"
  7. import { Global } from "../../src/global"
  8. import { Storage } from "../../src/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 exit = yield* svc.read([...root, "missing", "value"]).pipe(Effect.exit)
  69. expect(Exit.isFailure(exit)).toBe(true)
  70. }),
  71. )
  72. it.live("update on missing key throws NotFoundError", () =>
  73. Effect.gen(function* () {
  74. const { root, svc } = yield* scope()
  75. const exit = yield* svc
  76. .update<{ value: number }>([...root, "missing", "key"], (draft) => {
  77. draft.value += 1
  78. })
  79. .pipe(Effect.exit)
  80. expect(Exit.isFailure(exit)).toBe(true)
  81. }),
  82. )
  83. it.live("write overwrites existing value", () =>
  84. Effect.gen(function* () {
  85. const { root, svc } = yield* scope()
  86. const key = [...root, "overwrite", "test"]
  87. yield* svc.write<{ v: number }>(key, { v: 1 })
  88. yield* svc.write<{ v: number }>(key, { v: 2 })
  89. expect(yield* svc.read<{ v: number }>(key)).toEqual({ v: 2 })
  90. }),
  91. )
  92. it.live("remove on missing key is a no-op", () =>
  93. Effect.gen(function* () {
  94. const { root, svc } = yield* scope()
  95. yield* svc.remove([...root, "nonexistent", "key"])
  96. }),
  97. )
  98. it.live("list on missing prefix returns empty", () =>
  99. Effect.gen(function* () {
  100. const { root, svc } = yield* scope()
  101. expect(yield* svc.list([...root, "nonexistent"])).toEqual([])
  102. }),
  103. )
  104. it.live("serializes concurrent updates for the same key", () =>
  105. Effect.gen(function* () {
  106. const { root, svc } = yield* scope()
  107. const key = [...root, "counter", "shared"]
  108. yield* svc.write(key, { value: 0 })
  109. yield* Effect.all(
  110. Array.from({ length: 25 }, () =>
  111. svc.update<{ value: number }>(key, (draft) => {
  112. draft.value += 1
  113. }),
  114. ),
  115. { concurrency: "unbounded" },
  116. )
  117. expect(yield* svc.read<{ value: number }>(key)).toEqual({ value: 25 })
  118. }),
  119. )
  120. it.live("concurrent reads do not block each other", () =>
  121. Effect.gen(function* () {
  122. const { root, svc } = yield* scope()
  123. const key = [...root, "concurrent", "reads"]
  124. yield* svc.write(key, { ok: true })
  125. const results = yield* Effect.all(
  126. Array.from({ length: 10 }, () => svc.read(key)),
  127. { concurrency: "unbounded" },
  128. )
  129. expect(results).toHaveLength(10)
  130. for (const r of results) expect(r).toEqual({ ok: true })
  131. }),
  132. )
  133. it.live("nested keys create deep paths", () =>
  134. Effect.gen(function* () {
  135. const { root, svc } = yield* scope()
  136. const key = [...root, "a", "b", "c", "deep"]
  137. yield* svc.write<{ nested: boolean }>(key, { nested: true })
  138. expect(yield* svc.read<{ nested: boolean }>(key)).toEqual({ nested: true })
  139. expect(yield* svc.list([...root, "a"])).toEqual([key])
  140. }),
  141. )
  142. it.live("lists and removes stored entries", () =>
  143. Effect.gen(function* () {
  144. const { root, svc } = yield* scope()
  145. const a = [...root, "list", "a"]
  146. const b = [...root, "list", "b"]
  147. const prefix = [...root, "list"]
  148. yield* svc.write(b, { value: 2 })
  149. yield* svc.write(a, { value: 1 })
  150. expect(yield* svc.list(prefix)).toEqual([a, b])
  151. yield* svc.remove(a)
  152. expect(yield* svc.list(prefix)).toEqual([b])
  153. const exit = yield* svc.read(a).pipe(Effect.exit)
  154. expect(Exit.isFailure(exit)).toBe(true)
  155. }),
  156. )
  157. it.live("migration 2 runs when marker contents are invalid", () =>
  158. Effect.gen(function* () {
  159. const fs = yield* AppFileSystem.Service
  160. const tmp = yield* tmpdirScoped()
  161. const storage = path.join(tmp, "storage")
  162. const diffs = [
  163. { additions: 2, deletions: 1 },
  164. { additions: 3, deletions: 4 },
  165. ]
  166. yield* fs.writeWithDirs(path.join(storage, "migration"), "wat")
  167. yield* fs.writeWithDirs(
  168. path.join(storage, "session", "proj_test", "ses_test.json"),
  169. JSON.stringify({
  170. id: "ses_test",
  171. projectID: "proj_test",
  172. title: "legacy",
  173. summary: { diffs },
  174. }),
  175. )
  176. yield* Effect.gen(function* () {
  177. const svc = yield* Storage.Service
  178. expect(yield* svc.list(["session_diff"])).toEqual([["session_diff", "ses_test"]])
  179. expect(yield* svc.read<typeof diffs>(["session_diff", "ses_test"])).toEqual(diffs)
  180. expect(
  181. yield* svc.read<{
  182. id: string
  183. projectID: string
  184. title: string
  185. summary: { additions: number; deletions: number }
  186. }>(["session", "proj_test", "ses_test"]),
  187. ).toEqual({
  188. id: "ses_test",
  189. projectID: "proj_test",
  190. title: "legacy",
  191. summary: { additions: 5, deletions: 5 },
  192. })
  193. }).pipe(Effect.provide(remappedStorage(tmp)))
  194. expect(yield* fs.readFileString(path.join(storage, "migration"))).toBe("2")
  195. }),
  196. )
  197. it.live("migration 1 tolerates malformed legacy records", () =>
  198. Effect.gen(function* () {
  199. const fs = yield* AppFileSystem.Service
  200. const tmp = yield* tmpdirScoped({ git: true })
  201. const storage = path.join(tmp, "storage")
  202. const legacy = path.join(tmp, "project", "legacy")
  203. yield* fs.writeWithDirs(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "[]")
  204. yield* fs.writeWithDirs(
  205. path.join(legacy, "storage", "session", "message", "probe", "1.json"),
  206. JSON.stringify({ path: { root: tmp } }),
  207. )
  208. yield* fs.writeWithDirs(
  209. path.join(legacy, "storage", "session", "info", "ses_legacy.json"),
  210. JSON.stringify({ id: "ses_legacy", title: "legacy" }),
  211. )
  212. yield* fs.writeWithDirs(
  213. path.join(legacy, "storage", "session", "message", "ses_legacy", "msg_legacy.json"),
  214. JSON.stringify({ role: "user", text: "hello" }),
  215. )
  216. yield* Effect.gen(function* () {
  217. const svc = yield* Storage.Service
  218. const projects = yield* svc.list(["project"])
  219. expect(projects).toHaveLength(1)
  220. const project = projects[0]![1]
  221. expect(yield* svc.list(["session", project])).toEqual([["session", project, "ses_legacy"]])
  222. expect(yield* svc.read<{ id: string; title: string }>(["session", project, "ses_legacy"])).toEqual({
  223. id: "ses_legacy",
  224. title: "legacy",
  225. })
  226. expect(yield* svc.read<{ role: string; text: string }>(["message", "ses_legacy", "msg_legacy"])).toEqual({
  227. role: "user",
  228. text: "hello",
  229. })
  230. }).pipe(Effect.provide(remappedStorage(tmp)))
  231. expect(yield* fs.readFileString(path.join(storage, "migration"))).toBe("2")
  232. }),
  233. )
  234. it.live("failed migrations do not advance the marker", () =>
  235. Effect.gen(function* () {
  236. const fs = yield* AppFileSystem.Service
  237. const tmp = yield* tmpdirScoped()
  238. const storage = path.join(tmp, "storage")
  239. const legacy = path.join(tmp, "project", "legacy")
  240. yield* fs.writeWithDirs(path.join(legacy, "storage", "session", "message", "probe", "0.json"), "{")
  241. yield* Effect.gen(function* () {
  242. const svc = yield* Storage.Service
  243. expect(yield* svc.list(["project"])).toEqual([])
  244. }).pipe(Effect.provide(remappedStorage(tmp)))
  245. const exit = yield* fs.access(path.join(storage, "migration")).pipe(Effect.exit)
  246. expect(Exit.isFailure(exit)).toBe(true)
  247. }),
  248. )
  249. })