json-migration.test.ts 29 KB

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