json-migration.test.ts 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832
  1. import { describe, test, expect, beforeEach, afterEach } from "bun:test"
  2. import { Database } from "bun:sqlite"
  3. import { drizzle, SQLiteBunDatabase } from "drizzle-orm/bun-sqlite"
  4. import { migrate } from "drizzle-orm/bun-sqlite/migrator"
  5. import path from "path"
  6. import fs from "fs/promises"
  7. import { readFileSync, readdirSync } from "fs"
  8. import { JsonMigration } from "../../src/storage/json-migration"
  9. import { Global } from "../../src/global"
  10. import { ProjectTable } from "../../src/project/project.sql"
  11. import { ProjectID } from "../../src/project/schema"
  12. import { SessionTable, MessageTable, PartTable, TodoTable, PermissionTable } from "../../src/session/session.sql"
  13. import { SessionShareTable } from "../../src/share/share.sql"
  14. import { SessionID, MessageID, PartID } from "../../src/session/schema"
  15. // Test fixtures
  16. const fixtures = {
  17. project: {
  18. id: "proj_test123abc",
  19. name: "Test Project",
  20. worktree: "/test/path",
  21. vcs: "git" as const,
  22. sandboxes: [],
  23. },
  24. session: {
  25. id: "ses_test456def",
  26. projectID: "proj_test123abc",
  27. slug: "test-session",
  28. directory: "/test/path",
  29. title: "Test Session",
  30. version: "1.0.0",
  31. time: { created: 1700000000000, updated: 1700000001000 },
  32. },
  33. message: {
  34. id: "msg_test789ghi",
  35. sessionID: "ses_test456def",
  36. role: "user" as const,
  37. agent: "default",
  38. model: { providerID: "openai", modelID: "gpt-4" },
  39. time: { created: 1700000000000 },
  40. },
  41. part: {
  42. id: "prt_testabc123",
  43. messageID: "msg_test789ghi",
  44. sessionID: "ses_test456def",
  45. type: "text" as const,
  46. text: "Hello, world!",
  47. },
  48. }
  49. // Helper to create test storage directory structure
  50. async function setupStorageDir() {
  51. const storageDir = path.join(Global.Path.data, "storage")
  52. await fs.rm(storageDir, { recursive: true, force: true })
  53. await fs.mkdir(path.join(storageDir, "project"), { recursive: true })
  54. await fs.mkdir(path.join(storageDir, "session", "proj_test123abc"), { recursive: true })
  55. await fs.mkdir(path.join(storageDir, "message", "ses_test456def"), { recursive: true })
  56. await fs.mkdir(path.join(storageDir, "part", "msg_test789ghi"), { recursive: true })
  57. await fs.mkdir(path.join(storageDir, "session_diff"), { recursive: true })
  58. await fs.mkdir(path.join(storageDir, "todo"), { recursive: true })
  59. await fs.mkdir(path.join(storageDir, "permission"), { recursive: true })
  60. await fs.mkdir(path.join(storageDir, "session_share"), { recursive: true })
  61. // Create legacy marker to indicate JSON storage exists
  62. await Bun.write(path.join(storageDir, "migration"), "1")
  63. return storageDir
  64. }
  65. async function writeProject(storageDir: string, project: Record<string, unknown>) {
  66. await Bun.write(path.join(storageDir, "project", `${project.id}.json`), JSON.stringify(project))
  67. }
  68. async function writeSession(storageDir: string, projectID: string, session: Record<string, unknown>) {
  69. await Bun.write(path.join(storageDir, "session", projectID, `${session.id}.json`), JSON.stringify(session))
  70. }
  71. // Helper to create in-memory test database with schema
  72. function createTestDb() {
  73. const sqlite = new Database(":memory:")
  74. sqlite.exec("PRAGMA foreign_keys = ON")
  75. // Apply schema migrations using drizzle migrate
  76. const dir = path.join(import.meta.dirname, "../../migration")
  77. const entries = readdirSync(dir, { withFileTypes: true })
  78. const migrations = entries
  79. .filter((entry) => entry.isDirectory())
  80. .map((entry) => ({
  81. sql: readFileSync(path.join(dir, entry.name, "migration.sql"), "utf-8"),
  82. timestamp: Number(entry.name.split("_")[0]),
  83. name: entry.name,
  84. }))
  85. .sort((a, b) => a.timestamp - b.timestamp)
  86. const db = drizzle({ client: sqlite })
  87. migrate(db, migrations)
  88. return [sqlite, db] as const
  89. }
  90. describe("JSON to SQLite migration", () => {
  91. let storageDir: string
  92. let sqlite: Database
  93. let db: SQLiteBunDatabase
  94. beforeEach(async () => {
  95. storageDir = await setupStorageDir()
  96. ;[sqlite, db] = createTestDb()
  97. })
  98. afterEach(async () => {
  99. sqlite.close()
  100. await fs.rm(storageDir, { recursive: true, force: true })
  101. })
  102. test("migrates project", async () => {
  103. await writeProject(storageDir, {
  104. id: "proj_test123abc",
  105. worktree: "/test/path",
  106. vcs: "git",
  107. name: "Test Project",
  108. time: { created: 1700000000000, updated: 1700000001000 },
  109. sandboxes: ["/test/sandbox"],
  110. })
  111. const stats = await JsonMigration.run(db)
  112. expect(stats?.projects).toBe(1)
  113. const projects = db.select().from(ProjectTable).all()
  114. expect(projects.length).toBe(1)
  115. expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
  116. expect(projects[0].worktree).toBe("/test/path")
  117. expect(projects[0].name).toBe("Test Project")
  118. expect(projects[0].sandboxes).toEqual(["/test/sandbox"])
  119. })
  120. test("uses filename for project id when JSON has different value", async () => {
  121. await Bun.write(
  122. path.join(storageDir, "project", "proj_filename.json"),
  123. JSON.stringify({
  124. id: "proj_different_in_json", // Stale! Should be ignored
  125. worktree: "/test/path",
  126. vcs: "git",
  127. name: "Test Project",
  128. sandboxes: [],
  129. }),
  130. )
  131. const stats = await JsonMigration.run(db)
  132. expect(stats?.projects).toBe(1)
  133. const projects = db.select().from(ProjectTable).all()
  134. expect(projects.length).toBe(1)
  135. expect(projects[0].id).toBe(ProjectID.make("proj_filename")) // Uses filename, not JSON id
  136. })
  137. test("migrates project with commands", async () => {
  138. await writeProject(storageDir, {
  139. id: "proj_with_commands",
  140. worktree: "/test/path",
  141. vcs: "git",
  142. name: "Project With Commands",
  143. time: { created: 1700000000000, updated: 1700000001000 },
  144. sandboxes: ["/test/sandbox"],
  145. commands: { start: "npm run dev" },
  146. })
  147. const stats = await JsonMigration.run(db)
  148. expect(stats?.projects).toBe(1)
  149. const projects = db.select().from(ProjectTable).all()
  150. expect(projects.length).toBe(1)
  151. expect(projects[0].id).toBe(ProjectID.make("proj_with_commands"))
  152. expect(projects[0].commands).toEqual({ start: "npm run dev" })
  153. })
  154. test("migrates project without commands field", async () => {
  155. await writeProject(storageDir, {
  156. id: "proj_no_commands",
  157. worktree: "/test/path",
  158. vcs: "git",
  159. name: "Project Without Commands",
  160. time: { created: 1700000000000, updated: 1700000001000 },
  161. sandboxes: [],
  162. })
  163. const stats = await JsonMigration.run(db)
  164. expect(stats?.projects).toBe(1)
  165. const projects = db.select().from(ProjectTable).all()
  166. expect(projects.length).toBe(1)
  167. expect(projects[0].id).toBe(ProjectID.make("proj_no_commands"))
  168. expect(projects[0].commands).toBeNull()
  169. })
  170. test("migrates session with individual columns", async () => {
  171. await writeProject(storageDir, {
  172. id: "proj_test123abc",
  173. worktree: "/test/path",
  174. time: { created: Date.now(), updated: Date.now() },
  175. sandboxes: [],
  176. })
  177. await writeSession(storageDir, "proj_test123abc", {
  178. id: "ses_test456def",
  179. projectID: "proj_test123abc",
  180. slug: "test-session",
  181. directory: "/test/dir",
  182. title: "Test Session Title",
  183. version: "1.0.0",
  184. time: { created: 1700000000000, updated: 1700000001000 },
  185. summary: { additions: 10, deletions: 5, files: 3 },
  186. share: { url: "https://example.com/share" },
  187. })
  188. await JsonMigration.run(db)
  189. const sessions = db.select().from(SessionTable).all()
  190. expect(sessions.length).toBe(1)
  191. expect(sessions[0].id).toBe(SessionID.make("ses_test456def"))
  192. expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc"))
  193. expect(sessions[0].slug).toBe("test-session")
  194. expect(sessions[0].title).toBe("Test Session Title")
  195. expect(sessions[0].summary_additions).toBe(10)
  196. expect(sessions[0].summary_deletions).toBe(5)
  197. expect(sessions[0].share_url).toBe("https://example.com/share")
  198. })
  199. test("migrates messages and parts", async () => {
  200. await writeProject(storageDir, {
  201. id: "proj_test123abc",
  202. worktree: "/",
  203. time: { created: Date.now(), updated: Date.now() },
  204. sandboxes: [],
  205. })
  206. await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
  207. await Bun.write(
  208. path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"),
  209. JSON.stringify({ ...fixtures.message }),
  210. )
  211. await Bun.write(
  212. path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"),
  213. JSON.stringify({ ...fixtures.part }),
  214. )
  215. const stats = await JsonMigration.run(db)
  216. expect(stats?.messages).toBe(1)
  217. expect(stats?.parts).toBe(1)
  218. const messages = db.select().from(MessageTable).all()
  219. expect(messages.length).toBe(1)
  220. expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
  221. const parts = db.select().from(PartTable).all()
  222. expect(parts.length).toBe(1)
  223. expect(parts[0].id).toBe(PartID.make("prt_testabc123"))
  224. })
  225. test("migrates legacy parts without ids in body", async () => {
  226. await writeProject(storageDir, {
  227. id: "proj_test123abc",
  228. worktree: "/",
  229. time: { created: Date.now(), updated: Date.now() },
  230. sandboxes: [],
  231. })
  232. await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
  233. await Bun.write(
  234. path.join(storageDir, "message", "ses_test456def", "msg_test789ghi.json"),
  235. JSON.stringify({
  236. role: "user",
  237. agent: "default",
  238. model: { providerID: "openai", modelID: "gpt-4" },
  239. time: { created: 1700000000000 },
  240. }),
  241. )
  242. await Bun.write(
  243. path.join(storageDir, "part", "msg_test789ghi", "prt_testabc123.json"),
  244. JSON.stringify({
  245. type: "text",
  246. text: "Hello, world!",
  247. }),
  248. )
  249. const stats = await JsonMigration.run(db)
  250. expect(stats?.messages).toBe(1)
  251. expect(stats?.parts).toBe(1)
  252. const messages = db.select().from(MessageTable).all()
  253. expect(messages.length).toBe(1)
  254. expect(messages[0].id).toBe(MessageID.make("msg_test789ghi"))
  255. expect(messages[0].session_id).toBe(SessionID.make("ses_test456def"))
  256. expect(messages[0].data).not.toHaveProperty("id")
  257. expect(messages[0].data).not.toHaveProperty("sessionID")
  258. const parts = db.select().from(PartTable).all()
  259. expect(parts.length).toBe(1)
  260. expect(parts[0].id).toBe(PartID.make("prt_testabc123"))
  261. expect(parts[0].message_id).toBe(MessageID.make("msg_test789ghi"))
  262. expect(parts[0].session_id).toBe(SessionID.make("ses_test456def"))
  263. expect(parts[0].data).not.toHaveProperty("id")
  264. expect(parts[0].data).not.toHaveProperty("messageID")
  265. expect(parts[0].data).not.toHaveProperty("sessionID")
  266. })
  267. test("uses filename for message id when JSON has different value", async () => {
  268. await writeProject(storageDir, {
  269. id: "proj_test123abc",
  270. worktree: "/",
  271. time: { created: Date.now(), updated: Date.now() },
  272. sandboxes: [],
  273. })
  274. await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
  275. await Bun.write(
  276. path.join(storageDir, "message", "ses_test456def", "msg_from_filename.json"),
  277. JSON.stringify({
  278. id: "msg_different_in_json", // Stale! Should be ignored
  279. sessionID: "ses_test456def",
  280. role: "user",
  281. agent: "default",
  282. time: { created: 1700000000000 },
  283. }),
  284. )
  285. const stats = await JsonMigration.run(db)
  286. expect(stats?.messages).toBe(1)
  287. const messages = db.select().from(MessageTable).all()
  288. expect(messages.length).toBe(1)
  289. expect(messages[0].id).toBe(MessageID.make("msg_from_filename")) // Uses filename, not JSON id
  290. expect(messages[0].session_id).toBe(SessionID.make("ses_test456def"))
  291. })
  292. test("uses paths for part id and messageID when JSON has different values", async () => {
  293. await writeProject(storageDir, {
  294. id: "proj_test123abc",
  295. worktree: "/",
  296. time: { created: Date.now(), updated: Date.now() },
  297. sandboxes: [],
  298. })
  299. await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
  300. await Bun.write(
  301. path.join(storageDir, "message", "ses_test456def", "msg_realmsgid.json"),
  302. JSON.stringify({
  303. role: "user",
  304. agent: "default",
  305. time: { created: 1700000000000 },
  306. }),
  307. )
  308. await Bun.write(
  309. path.join(storageDir, "part", "msg_realmsgid", "prt_from_filename.json"),
  310. JSON.stringify({
  311. id: "prt_different_in_json", // Stale! Should be ignored
  312. messageID: "msg_different_in_json", // Stale! Should be ignored
  313. sessionID: "ses_test456def",
  314. type: "text",
  315. text: "Hello",
  316. }),
  317. )
  318. const stats = await JsonMigration.run(db)
  319. expect(stats?.parts).toBe(1)
  320. const parts = db.select().from(PartTable).all()
  321. expect(parts.length).toBe(1)
  322. expect(parts[0].id).toBe(PartID.make("prt_from_filename")) // Uses filename, not JSON id
  323. expect(parts[0].message_id).toBe(MessageID.make("msg_realmsgid")) // Uses parent dir, not JSON messageID
  324. })
  325. test("skips orphaned sessions (no parent project)", async () => {
  326. await Bun.write(
  327. path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
  328. JSON.stringify({
  329. id: "ses_orphan",
  330. projectID: "proj_nonexistent",
  331. slug: "orphan",
  332. directory: "/",
  333. title: "Orphan",
  334. version: "1.0.0",
  335. time: { created: Date.now(), updated: Date.now() },
  336. }),
  337. )
  338. const stats = await JsonMigration.run(db)
  339. expect(stats?.sessions).toBe(0)
  340. })
  341. test("uses directory path for projectID when JSON has stale value", async () => {
  342. // Simulates the scenario where earlier migration moved sessions to new
  343. // git-based project directories but didn't update the projectID field
  344. const gitBasedProjectID = "abc123gitcommit"
  345. await writeProject(storageDir, {
  346. id: gitBasedProjectID,
  347. worktree: "/test/path",
  348. vcs: "git",
  349. time: { created: Date.now(), updated: Date.now() },
  350. sandboxes: [],
  351. })
  352. // Session is in the git-based directory but JSON still has old projectID
  353. await writeSession(storageDir, gitBasedProjectID, {
  354. id: "ses_migrated",
  355. projectID: "old-project-name", // Stale! Should be ignored
  356. slug: "migrated-session",
  357. directory: "/test/path",
  358. title: "Migrated Session",
  359. version: "1.0.0",
  360. time: { created: 1700000000000, updated: 1700000001000 },
  361. })
  362. const stats = await JsonMigration.run(db)
  363. expect(stats?.sessions).toBe(1)
  364. const sessions = db.select().from(SessionTable).all()
  365. expect(sessions.length).toBe(1)
  366. expect(sessions[0].id).toBe(SessionID.make("ses_migrated"))
  367. expect(sessions[0].project_id).toBe(ProjectID.make(gitBasedProjectID)) // Uses directory, not stale JSON
  368. })
  369. test("uses filename for session id when JSON has different value", async () => {
  370. await writeProject(storageDir, {
  371. id: "proj_test123abc",
  372. worktree: "/test/path",
  373. time: { created: Date.now(), updated: Date.now() },
  374. sandboxes: [],
  375. })
  376. await Bun.write(
  377. path.join(storageDir, "session", "proj_test123abc", "ses_from_filename.json"),
  378. JSON.stringify({
  379. id: "ses_different_in_json", // Stale! Should be ignored
  380. projectID: "proj_test123abc",
  381. slug: "test-session",
  382. directory: "/test/path",
  383. title: "Test Session",
  384. version: "1.0.0",
  385. time: { created: 1700000000000, updated: 1700000001000 },
  386. }),
  387. )
  388. const stats = await JsonMigration.run(db)
  389. expect(stats?.sessions).toBe(1)
  390. const sessions = db.select().from(SessionTable).all()
  391. expect(sessions.length).toBe(1)
  392. expect(sessions[0].id).toBe(SessionID.make("ses_from_filename")) // Uses filename, not JSON id
  393. expect(sessions[0].project_id).toBe(ProjectID.make("proj_test123abc"))
  394. })
  395. test("is idempotent (running twice doesn't duplicate)", async () => {
  396. await writeProject(storageDir, {
  397. id: "proj_test123abc",
  398. worktree: "/",
  399. time: { created: Date.now(), updated: Date.now() },
  400. sandboxes: [],
  401. })
  402. await JsonMigration.run(db)
  403. await JsonMigration.run(db)
  404. const projects = db.select().from(ProjectTable).all()
  405. expect(projects.length).toBe(1) // Still only 1 due to onConflictDoNothing
  406. })
  407. test("migrates todos", async () => {
  408. await writeProject(storageDir, {
  409. id: "proj_test123abc",
  410. worktree: "/",
  411. time: { created: Date.now(), updated: Date.now() },
  412. sandboxes: [],
  413. })
  414. await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
  415. // Create todo file (named by sessionID, contains array of todos)
  416. await Bun.write(
  417. path.join(storageDir, "todo", "ses_test456def.json"),
  418. JSON.stringify([
  419. {
  420. id: "todo_1",
  421. content: "First todo",
  422. status: "pending",
  423. priority: "high",
  424. },
  425. {
  426. id: "todo_2",
  427. content: "Second todo",
  428. status: "completed",
  429. priority: "medium",
  430. },
  431. ]),
  432. )
  433. const stats = await JsonMigration.run(db)
  434. expect(stats?.todos).toBe(2)
  435. const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
  436. expect(todos.length).toBe(2)
  437. expect(todos[0].content).toBe("First todo")
  438. expect(todos[0].status).toBe("pending")
  439. expect(todos[0].priority).toBe("high")
  440. expect(todos[0].position).toBe(0)
  441. expect(todos[1].content).toBe("Second todo")
  442. expect(todos[1].position).toBe(1)
  443. })
  444. test("todos are ordered by position", async () => {
  445. await writeProject(storageDir, {
  446. id: "proj_test123abc",
  447. worktree: "/",
  448. time: { created: Date.now(), updated: Date.now() },
  449. sandboxes: [],
  450. })
  451. await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
  452. await Bun.write(
  453. path.join(storageDir, "todo", "ses_test456def.json"),
  454. JSON.stringify([
  455. { content: "Third", status: "pending", priority: "low" },
  456. { content: "First", status: "pending", priority: "high" },
  457. { content: "Second", status: "in_progress", priority: "medium" },
  458. ]),
  459. )
  460. await JsonMigration.run(db)
  461. const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
  462. expect(todos.length).toBe(3)
  463. expect(todos[0].content).toBe("Third")
  464. expect(todos[0].position).toBe(0)
  465. expect(todos[1].content).toBe("First")
  466. expect(todos[1].position).toBe(1)
  467. expect(todos[2].content).toBe("Second")
  468. expect(todos[2].position).toBe(2)
  469. })
  470. test("migrates permissions", async () => {
  471. await writeProject(storageDir, {
  472. id: "proj_test123abc",
  473. worktree: "/",
  474. time: { created: Date.now(), updated: Date.now() },
  475. sandboxes: [],
  476. })
  477. // Create permission file (named by projectID, contains array of rules)
  478. const permissionData = [
  479. { permission: "file.read", pattern: "/test/file1.ts", action: "allow" as const },
  480. { permission: "file.write", pattern: "/test/file2.ts", action: "ask" as const },
  481. { permission: "command.run", pattern: "npm install", action: "deny" as const },
  482. ]
  483. await Bun.write(path.join(storageDir, "permission", "proj_test123abc.json"), JSON.stringify(permissionData))
  484. const stats = await JsonMigration.run(db)
  485. expect(stats?.permissions).toBe(1)
  486. const permissions = db.select().from(PermissionTable).all()
  487. expect(permissions.length).toBe(1)
  488. expect(permissions[0].project_id).toBe("proj_test123abc")
  489. expect(permissions[0].data).toEqual(permissionData)
  490. })
  491. test("migrates session shares", async () => {
  492. await writeProject(storageDir, {
  493. id: "proj_test123abc",
  494. worktree: "/",
  495. time: { created: Date.now(), updated: Date.now() },
  496. sandboxes: [],
  497. })
  498. await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
  499. // Create session share file (named by sessionID)
  500. await Bun.write(
  501. path.join(storageDir, "session_share", "ses_test456def.json"),
  502. JSON.stringify({
  503. id: "share_123",
  504. secret: "supersecretkey",
  505. url: "https://share.example.com/ses_test456def",
  506. }),
  507. )
  508. const stats = await JsonMigration.run(db)
  509. expect(stats?.shares).toBe(1)
  510. const shares = db.select().from(SessionShareTable).all()
  511. expect(shares.length).toBe(1)
  512. expect(shares[0].session_id).toBe("ses_test456def")
  513. expect(shares[0].id).toBe("share_123")
  514. expect(shares[0].secret).toBe("supersecretkey")
  515. expect(shares[0].url).toBe("https://share.example.com/ses_test456def")
  516. })
  517. test("returns empty stats when storage directory does not exist", async () => {
  518. await fs.rm(storageDir, { recursive: true, force: true })
  519. const stats = await JsonMigration.run(db)
  520. expect(stats.projects).toBe(0)
  521. expect(stats.sessions).toBe(0)
  522. expect(stats.messages).toBe(0)
  523. expect(stats.parts).toBe(0)
  524. expect(stats.todos).toBe(0)
  525. expect(stats.permissions).toBe(0)
  526. expect(stats.shares).toBe(0)
  527. expect(stats.errors).toEqual([])
  528. })
  529. test("continues when a JSON file is unreadable and records an error", async () => {
  530. await writeProject(storageDir, {
  531. id: "proj_test123abc",
  532. worktree: "/",
  533. time: { created: Date.now(), updated: Date.now() },
  534. sandboxes: [],
  535. })
  536. await Bun.write(path.join(storageDir, "project", "broken.json"), "{ invalid json")
  537. const stats = await JsonMigration.run(db)
  538. expect(stats.projects).toBe(1)
  539. expect(stats.errors.some((x) => x.includes("failed to read") && x.includes("broken.json"))).toBe(true)
  540. const projects = db.select().from(ProjectTable).all()
  541. expect(projects.length).toBe(1)
  542. expect(projects[0].id).toBe(ProjectID.make("proj_test123abc"))
  543. })
  544. test("skips invalid todo entries while preserving source positions", async () => {
  545. await writeProject(storageDir, {
  546. id: "proj_test123abc",
  547. worktree: "/",
  548. time: { created: Date.now(), updated: Date.now() },
  549. sandboxes: [],
  550. })
  551. await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
  552. await Bun.write(
  553. path.join(storageDir, "todo", "ses_test456def.json"),
  554. JSON.stringify([
  555. { content: "keep-0", status: "pending", priority: "high" },
  556. { content: "drop-1", priority: "low" },
  557. { content: "keep-2", status: "completed", priority: "medium" },
  558. ]),
  559. )
  560. const stats = await JsonMigration.run(db)
  561. expect(stats.todos).toBe(2)
  562. const todos = db.select().from(TodoTable).orderBy(TodoTable.position).all()
  563. expect(todos.length).toBe(2)
  564. expect(todos[0].content).toBe("keep-0")
  565. expect(todos[0].position).toBe(0)
  566. expect(todos[1].content).toBe("keep-2")
  567. expect(todos[1].position).toBe(2)
  568. })
  569. test("skips orphaned todos, permissions, and shares", async () => {
  570. await writeProject(storageDir, {
  571. id: "proj_test123abc",
  572. worktree: "/",
  573. time: { created: Date.now(), updated: Date.now() },
  574. sandboxes: [],
  575. })
  576. await writeSession(storageDir, "proj_test123abc", { ...fixtures.session })
  577. await Bun.write(
  578. path.join(storageDir, "todo", "ses_test456def.json"),
  579. JSON.stringify([{ content: "valid", status: "pending", priority: "high" }]),
  580. )
  581. await Bun.write(
  582. path.join(storageDir, "todo", "ses_missing.json"),
  583. JSON.stringify([{ content: "orphan", status: "pending", priority: "high" }]),
  584. )
  585. await Bun.write(
  586. path.join(storageDir, "permission", "proj_test123abc.json"),
  587. JSON.stringify([{ permission: "file.read" }]),
  588. )
  589. await Bun.write(
  590. path.join(storageDir, "permission", "proj_missing.json"),
  591. JSON.stringify([{ permission: "file.write" }]),
  592. )
  593. await Bun.write(
  594. path.join(storageDir, "session_share", "ses_test456def.json"),
  595. JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
  596. )
  597. await Bun.write(
  598. path.join(storageDir, "session_share", "ses_missing.json"),
  599. JSON.stringify({ id: "share_missing", secret: "secret", url: "https://missing.example.com" }),
  600. )
  601. const stats = await JsonMigration.run(db)
  602. expect(stats.todos).toBe(1)
  603. expect(stats.permissions).toBe(1)
  604. expect(stats.shares).toBe(1)
  605. expect(db.select().from(TodoTable).all().length).toBe(1)
  606. expect(db.select().from(PermissionTable).all().length).toBe(1)
  607. expect(db.select().from(SessionShareTable).all().length).toBe(1)
  608. })
  609. test("handles mixed corruption and partial validity in one migration run", async () => {
  610. await writeProject(storageDir, {
  611. id: "proj_test123abc",
  612. worktree: "/ok",
  613. time: { created: 1700000000000, updated: 1700000001000 },
  614. sandboxes: [],
  615. })
  616. await Bun.write(
  617. path.join(storageDir, "project", "proj_missing_id.json"),
  618. JSON.stringify({ worktree: "/bad", sandboxes: [] }),
  619. )
  620. await Bun.write(path.join(storageDir, "project", "proj_broken.json"), "{ nope")
  621. await writeSession(storageDir, "proj_test123abc", {
  622. id: "ses_test456def",
  623. projectID: "proj_test123abc",
  624. slug: "ok",
  625. directory: "/ok",
  626. title: "Ok",
  627. version: "1",
  628. time: { created: 1700000000000, updated: 1700000001000 },
  629. })
  630. await Bun.write(
  631. path.join(storageDir, "session", "proj_test123abc", "ses_missing_project.json"),
  632. JSON.stringify({
  633. id: "ses_missing_project",
  634. slug: "bad",
  635. directory: "/bad",
  636. title: "Bad",
  637. version: "1",
  638. }),
  639. )
  640. await Bun.write(
  641. path.join(storageDir, "session", "proj_test123abc", "ses_orphan.json"),
  642. JSON.stringify({
  643. id: "ses_orphan",
  644. projectID: "proj_missing",
  645. slug: "orphan",
  646. directory: "/bad",
  647. title: "Orphan",
  648. version: "1",
  649. }),
  650. )
  651. await Bun.write(
  652. path.join(storageDir, "message", "ses_test456def", "msg_ok.json"),
  653. JSON.stringify({ role: "user", time: { created: 1700000000000 } }),
  654. )
  655. await Bun.write(path.join(storageDir, "message", "ses_test456def", "msg_broken.json"), "{ nope")
  656. await Bun.write(
  657. path.join(storageDir, "message", "ses_missing", "msg_orphan.json"),
  658. JSON.stringify({ role: "user", time: { created: 1700000000000 } }),
  659. )
  660. await Bun.write(
  661. path.join(storageDir, "part", "msg_ok", "part_ok.json"),
  662. JSON.stringify({ type: "text", text: "ok" }),
  663. )
  664. await Bun.write(
  665. path.join(storageDir, "part", "msg_missing", "part_missing_message.json"),
  666. JSON.stringify({ type: "text", text: "bad" }),
  667. )
  668. await Bun.write(path.join(storageDir, "part", "msg_ok", "part_broken.json"), "{ nope")
  669. await Bun.write(
  670. path.join(storageDir, "todo", "ses_test456def.json"),
  671. JSON.stringify([
  672. { content: "ok", status: "pending", priority: "high" },
  673. { content: "skip", status: "pending" },
  674. ]),
  675. )
  676. await Bun.write(
  677. path.join(storageDir, "todo", "ses_missing.json"),
  678. JSON.stringify([{ content: "orphan", status: "pending", priority: "high" }]),
  679. )
  680. await Bun.write(path.join(storageDir, "todo", "ses_broken.json"), "{ nope")
  681. await Bun.write(
  682. path.join(storageDir, "permission", "proj_test123abc.json"),
  683. JSON.stringify([{ permission: "file.read" }]),
  684. )
  685. await Bun.write(
  686. path.join(storageDir, "permission", "proj_missing.json"),
  687. JSON.stringify([{ permission: "file.write" }]),
  688. )
  689. await Bun.write(path.join(storageDir, "permission", "proj_broken.json"), "{ nope")
  690. await Bun.write(
  691. path.join(storageDir, "session_share", "ses_test456def.json"),
  692. JSON.stringify({ id: "share_ok", secret: "secret", url: "https://ok.example.com" }),
  693. )
  694. await Bun.write(
  695. path.join(storageDir, "session_share", "ses_missing.json"),
  696. JSON.stringify({ id: "share_orphan", secret: "secret", url: "https://missing.example.com" }),
  697. )
  698. await Bun.write(path.join(storageDir, "session_share", "ses_broken.json"), "{ nope")
  699. const stats = await JsonMigration.run(db)
  700. // Projects: proj_test123abc (valid), proj_missing_id (now derives id from filename)
  701. // Sessions: ses_test456def (valid), ses_missing_project (now uses dir path),
  702. // ses_orphan (now uses dir path, ignores stale projectID)
  703. expect(stats.projects).toBe(2)
  704. expect(stats.sessions).toBe(3)
  705. expect(stats.messages).toBe(1)
  706. expect(stats.parts).toBe(1)
  707. expect(stats.todos).toBe(1)
  708. expect(stats.permissions).toBe(1)
  709. expect(stats.shares).toBe(1)
  710. expect(stats.errors.length).toBeGreaterThanOrEqual(6)
  711. expect(db.select().from(ProjectTable).all().length).toBe(2)
  712. expect(db.select().from(SessionTable).all().length).toBe(3)
  713. expect(db.select().from(MessageTable).all().length).toBe(1)
  714. expect(db.select().from(PartTable).all().length).toBe(1)
  715. expect(db.select().from(TodoTable).all().length).toBe(1)
  716. expect(db.select().from(PermissionTable).all().length).toBe(1)
  717. expect(db.select().from(SessionShareTable).all().length).toBe(1)
  718. })
  719. })