v1-migration.test.ts 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207
  1. import { describe, expect, test } from "bun:test"
  2. import { SqliteClient } from "@effect/sql-sqlite-bun"
  3. import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
  4. import { Database } from "@opencode-ai/core/database/database"
  5. import { DatabaseMigration } from "@opencode-ai/core/database/migration"
  6. import { V1Migration } from "@opencode-ai/core/database/v1-migration"
  7. import { SessionMessage } from "@opencode-ai/core/session/message"
  8. import { SessionSchema } from "@opencode-ai/core/session/schema"
  9. import { SessionTable } from "@opencode-ai/core/session/sql"
  10. import { Project } from "@opencode-ai/core/project"
  11. import { ProjectTable } from "@opencode-ai/core/project/sql"
  12. import { AbsolutePath } from "@opencode-ai/core/schema"
  13. import { Global } from "@opencode-ai/util/global"
  14. import { Effect, Layer, Logger, Schedule, Schema, Scope } from "effect"
  15. import { eq, sql } from "drizzle-orm"
  16. import type { SqlClient } from "effect/unstable/sql/SqlClient"
  17. import { tmpdir } from "./fixture/tmpdir"
  18. import path from "path"
  19. const makeDb = EffectDrizzleSqlite.makeWithDefaults()
  20. const run = <A, E>(effect: Effect.Effect<A, E, SqlClient | Scope.Scope>) =>
  21. Effect.runPromise(
  22. Effect.scoped(effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })))),
  23. )
  24. const session = (
  25. overrides: Partial<V1Migration.TransformInput["session"]> = {},
  26. ): V1Migration.TransformInput["session"] => ({
  27. id: SessionSchema.ID.make("ses_test"),
  28. project_id: Project.ID.global,
  29. workspace_id: null,
  30. parent_id: null,
  31. fork_session_id: null,
  32. fork_boundary: null,
  33. slug: "test",
  34. directory: "/tmp/test",
  35. path: null,
  36. title: "Test",
  37. version: "1",
  38. share_url: null,
  39. summary_additions: null,
  40. summary_deletions: null,
  41. summary_files: null,
  42. summary_diffs: null,
  43. metadata: null,
  44. cost: 99,
  45. tokens_input: 99,
  46. tokens_output: 99,
  47. tokens_reasoning: 99,
  48. tokens_cache_read: 99,
  49. tokens_cache_write: 99,
  50. revert: null,
  51. permission: null,
  52. agent: null,
  53. model: null,
  54. time_created: 1,
  55. time_updated: 2,
  56. time_compacting: 3,
  57. time_archived: null,
  58. time_suspended: null,
  59. ...overrides,
  60. })
  61. const user = (id: string, overrides: Record<string, unknown> = {}, time = 10): V1Migration.SourceMessage => ({
  62. id,
  63. session_id: "ses_test",
  64. time_created: time,
  65. time_updated: time + 1,
  66. data: JSON.stringify({
  67. role: "user",
  68. time: { created: time },
  69. agent: "build",
  70. model: { providerID: "provider", modelID: "model" },
  71. ...overrides,
  72. }),
  73. })
  74. const assistant = (
  75. id: string,
  76. parentID: string,
  77. overrides: Record<string, unknown> = {},
  78. time = 20,
  79. ): V1Migration.SourceMessage => ({
  80. id,
  81. session_id: "ses_test",
  82. time_created: time,
  83. time_updated: time + 1,
  84. data: JSON.stringify({
  85. role: "assistant",
  86. time: { created: time, completed: time + 5 },
  87. parentID,
  88. modelID: "model",
  89. providerID: "provider",
  90. mode: "build",
  91. agent: "build",
  92. path: { cwd: "/tmp/test", root: "/tmp/test" },
  93. cost: 1,
  94. tokens: { total: 10, input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } },
  95. ...overrides,
  96. }),
  97. })
  98. const part = (id: string, messageID: string, data: Record<string, unknown> | string): V1Migration.SourcePart => ({
  99. id,
  100. message_id: messageID,
  101. session_id: "ses_test",
  102. time_created: 1,
  103. time_updated: 2,
  104. data: typeof data === "string" ? data : JSON.stringify(data),
  105. })
  106. const transform = (messages: V1Migration.SourceMessage[], parts: V1Migration.SourcePart[], info = session()) => {
  107. const result = V1Migration.transformSession({ session: info, messages, parts })
  108. result.messages.forEach((row) =>
  109. Schema.decodeUnknownSync(SessionMessage.Info)({ id: row.id, type: row.type, ...row.data }),
  110. )
  111. return result
  112. }
  113. describe("V1Migration.transformSession", () => {
  114. test("maps ordinary user text, agents, ignored fields, order, and timestamps", () => {
  115. const message = user("msg_000000000001aaaaaaaaaaaaaa", {
  116. system: "discard",
  117. tools: { read: false },
  118. format: { type: "text" },
  119. summary: { title: "discard", diffs: [] },
  120. })
  121. const result = transform(
  122. [message],
  123. [
  124. part("prt_4", message.id, { type: "agent", name: "review" }),
  125. part("prt_2", message.id, { type: "text", text: "ignored", ignored: true }),
  126. part("prt_3", message.id, { type: "agent", name: "build", source: { value: "@build", start: 2, end: 8 } }),
  127. part("prt_1", message.id, { type: "text", text: "first" }),
  128. part("prt_5", message.id, { type: "text", text: "second" }),
  129. ],
  130. )
  131. expect(result.messages).toEqual([
  132. {
  133. id: message.id,
  134. session_id: "ses_test",
  135. type: "user",
  136. seq: 0,
  137. time_created: 10,
  138. time_updated: 11,
  139. data: {
  140. text: "first\n\nsecond",
  141. agents: [{ name: "build", mention: { text: "@build", start: 2, end: 8 } }, { name: "review" }],
  142. time: { created: 10 },
  143. },
  144. },
  145. ])
  146. expect(result.watermark).toBe(0)
  147. })
  148. test("maps embedded files and deterministic placeholders without external IO", () => {
  149. const message = user("msg_000000000002aaaaaaaaaaaaaa")
  150. const result = transform(
  151. [message],
  152. [
  153. part("prt_1", message.id, { type: "text", text: "prompt" }),
  154. part("prt_2", message.id, {
  155. type: "file",
  156. mime: "text/plain",
  157. filename: "inline.txt",
  158. url: "data:text/plain,hello%20world",
  159. }),
  160. part("prt_3", message.id, {
  161. type: "file",
  162. mime: "text/plain",
  163. url: "data:text/plain;base64,aGk=",
  164. source: {
  165. type: "resource",
  166. clientName: "mcp",
  167. uri: "resource://item",
  168. text: { value: "item", start: 1, end: 5 },
  169. },
  170. }),
  171. part("prt_4", message.id, {
  172. type: "file",
  173. mime: "text/plain",
  174. filename: "named.txt",
  175. url: "file:///tmp/named.txt",
  176. }),
  177. part("prt_5", message.id, { type: "file", mime: "application/octet-stream", url: "https://example.test/raw" }),
  178. ],
  179. )
  180. expect(result.messages[0].data).toEqual({
  181. text: "prompt\n\n[Attachment unavailable after migration: named.txt (text/plain)]\n\n[Attachment unavailable after migration: https://example.test/raw (application/octet-stream)]",
  182. files: [
  183. { data: "aGVsbG8gd29ybGQ=", mime: "text/plain", source: { type: "inline" }, name: "inline.txt" },
  184. {
  185. data: "aGk=",
  186. mime: "text/plain",
  187. source: { type: "uri", uri: "resource://item" },
  188. mention: { text: "item", start: 1, end: 5 },
  189. },
  190. ],
  191. time: { created: 10 },
  192. })
  193. })
  194. test("maps attachment-only source variants and uses resource URIs as unavailable labels", () => {
  195. const message = user("msg_000000000049aaaaaaaaaaaaaa")
  196. const result = transform(
  197. [message],
  198. [
  199. part("prt_1", message.id, {
  200. type: "file",
  201. mime: "text/plain",
  202. url: "data:text/plain,file",
  203. source: { type: "file", path: "/tmp/a", text: { value: "a", start: 0, end: 1 } },
  204. }),
  205. part("prt_2", message.id, {
  206. type: "file",
  207. mime: "text/plain",
  208. url: "data:text/plain,symbol",
  209. source: {
  210. type: "symbol",
  211. path: "/tmp/a",
  212. range: { start: { line: 0, character: 0 }, end: { line: 0, character: 1 } },
  213. name: "a",
  214. kind: 1,
  215. text: { value: "symbol", start: 2, end: 8 },
  216. },
  217. }),
  218. part("prt_3", message.id, {
  219. type: "file",
  220. mime: "application/json",
  221. url: "https://example.test/resource",
  222. source: {
  223. type: "resource",
  224. clientName: "mcp",
  225. uri: "resource://fallback",
  226. text: { value: "resource", start: 0, end: 8 },
  227. },
  228. }),
  229. ],
  230. )
  231. expect(result.messages[0].data).toEqual({
  232. text: "[Attachment unavailable after migration: resource://fallback (application/json)]",
  233. files: [
  234. {
  235. data: "ZmlsZQ==",
  236. mime: "text/plain",
  237. source: { type: "inline" },
  238. mention: { text: "a", start: 0, end: 1 },
  239. },
  240. {
  241. data: "c3ltYm9s",
  242. mime: "text/plain",
  243. source: { type: "inline" },
  244. mention: { text: "symbol", start: 2, end: 8 },
  245. },
  246. ],
  247. time: { created: 10 },
  248. })
  249. })
  250. test("splits mixed synthetic content deterministically and preserves adjacency", () => {
  251. const all = user("msg_000000000003aaaaaaaaaaaaaa", {}, 1)
  252. const mixed = user("msg_000000000004aaaaaaaaaaaaaa", {}, 2)
  253. const later = user("msg_000000000005aaaaaaaaaaaaaa", {}, 3)
  254. const parts = [
  255. part("prt_1", all.id, { type: "text", text: "context", synthetic: true }),
  256. part("prt_2", mixed.id, { type: "text", text: "hello" }),
  257. part("prt_3", mixed.id, { type: "text", text: "hidden", synthetic: true }),
  258. part("prt_4", mixed.id, { type: "text", text: "ignored", synthetic: true, ignored: true }),
  259. part("prt_5", later.id, { type: "text", text: "later" }),
  260. ]
  261. const first = transform([later, mixed, all], parts)
  262. const second = transform([later, mixed, all], parts)
  263. expect(first.messages.map((row) => row.type)).toEqual(["synthetic", "user", "synthetic", "user"])
  264. expect(first.messages[0].id).toBe(all.id)
  265. expect(first.messages[2].id).not.toBe(mixed.id)
  266. expect(first.messages[2].id).toBe("msg_000000000004ST20Jh98kGJtjL")
  267. expect(first.messages[2].id.slice(0, 16)).toBe(mixed.id.slice(0, 16))
  268. expect(first.messages[2].id).toMatch(/^msg_[0-9A-Za-z]{26}$/)
  269. expect(first.messages[2].id).toBe(second.messages[2].id)
  270. expect(first.messages[2].data).toEqual({ text: "hidden", time: { created: 2 } })
  271. const collision = user(first.messages[2].id, {}, 4)
  272. const collided = transform(
  273. [all, mixed, later, collision],
  274. [...parts, part("prt_6", collision.id, { type: "text", text: "collision" })],
  275. )
  276. expect(collided.messages[2].id).not.toBe(first.messages[2].id)
  277. expect(collided.messages[2].id.slice(0, 16)).toBe(mixed.id.slice(0, 16))
  278. })
  279. test("preserves assistant content, model, usage, finish, snapshots, and marker filtering", () => {
  280. const parent = user("msg_000000000006aaaaaaaaaaaaaa")
  281. const message = assistant("msg_000000000007aaaaaaaaaaaaaa", parent.id, {
  282. variant: "fast",
  283. structured: { discard: true },
  284. finish: "stop",
  285. cost: 2.5,
  286. })
  287. const result = transform(
  288. [parent, message],
  289. [
  290. part("prt_1", message.id, { type: "text", text: "", metadata: { separator: true } }),
  291. part("prt_2", message.id, {
  292. type: "reasoning",
  293. text: "think",
  294. metadata: { provider: 1 },
  295. time: { start: 21, end: 22 },
  296. }),
  297. part("prt_3", message.id, { type: "step-start", snapshot: "snap_start" }),
  298. part("prt_4", message.id, { type: "snapshot", snapshot: "snap_ignored" }),
  299. part("prt_5", message.id, { type: "patch", hash: "snap_patch", files: ["a.ts", "b.ts"] }),
  300. part("prt_6", message.id, { type: "patch", hash: "snap_patch_2", files: ["b.ts", "c.ts"] }),
  301. part("prt_7", message.id, {
  302. type: "step-finish",
  303. reason: "stop",
  304. snapshot: "snap_end",
  305. cost: 99,
  306. tokens: { input: 99, output: 99, reasoning: 99, cache: { read: 99, write: 99 } },
  307. }),
  308. part("prt_8", message.id, {
  309. type: "retry",
  310. attempt: 1,
  311. error: { name: "APIError", data: { message: "retry", isRetryable: true } },
  312. time: { created: 1 },
  313. }),
  314. ],
  315. )
  316. expect(result.messages[1].data).toEqual({
  317. agent: "build",
  318. model: { id: "model", providerID: "provider", variant: "fast" },
  319. content: [
  320. { type: "text", text: "", state: { separator: true } },
  321. { type: "reasoning", text: "think", state: { provider: 1 }, time: { created: 21, completed: 22 } },
  322. ],
  323. snapshot: { start: "snap_start", end: "snap_end", files: ["a.ts", "b.ts", "c.ts"] },
  324. finish: "stop",
  325. cost: 2.5,
  326. tokens: { input: 2, output: 3, reasoning: 4, cache: { read: 5, write: 6 } },
  327. time: { created: 20, completed: 21 },
  328. })
  329. expect(result.messages[1]).toMatchObject({ time_created: 20, time_updated: 21 })
  330. })
  331. test("normalizes every tool state", () => {
  332. const parent = user("msg_000000000008aaaaaaaaaaaaaa")
  333. const message = assistant("msg_000000000009aaaaaaaaaaaaaa", parent.id)
  334. const tool = (id: string, callID: string, state: Record<string, unknown>, metadata?: Record<string, unknown>) =>
  335. part(id, message.id, { type: "tool", callID, tool: "read", state, ...(metadata ? { metadata } : {}) })
  336. const result = transform(
  337. [parent, message],
  338. [
  339. tool("prt_1", "pending", { status: "pending", input: { a: 1 }, raw: "{}" }),
  340. tool("prt_2", "running", {
  341. status: "running",
  342. input: { b: 2 },
  343. metadata: { phase: "read" },
  344. time: { start: 30 },
  345. }),
  346. tool(
  347. "prt_3",
  348. "completed",
  349. {
  350. status: "completed",
  351. input: { c: 3 },
  352. output: "done",
  353. title: "Read",
  354. metadata: { result: true },
  355. time: { start: 31, end: 32 },
  356. attachments: [
  357. {
  358. id: "prt_attachment",
  359. sessionID: "ses_test",
  360. messageID: message.id,
  361. type: "file",
  362. mime: "text/plain",
  363. filename: "out.txt",
  364. url: "file:///out.txt",
  365. },
  366. ],
  367. },
  368. { provider: true },
  369. ),
  370. tool("prt_4", "compacted", {
  371. status: "completed",
  372. input: {},
  373. output: "secret",
  374. title: "Read",
  375. metadata: {},
  376. time: { start: 33, end: 34, compacted: 35 },
  377. attachments: [],
  378. }),
  379. tool("prt_5", "failed", {
  380. status: "error",
  381. input: { e: 5 },
  382. error: "boom",
  383. metadata: { output: "partial" },
  384. time: { start: 36, end: 37 },
  385. }),
  386. tool("prt_6", "failed-object-output", {
  387. status: "error",
  388. input: { f: 6 },
  389. error: "object output",
  390. metadata: { output: { nested: true } },
  391. time: { start: 38, end: 39 },
  392. }),
  393. ],
  394. )
  395. const content = result.messages[1].data.content
  396. if (!Array.isArray(content)) throw new Error("Expected assistant content")
  397. expect(content[0]).toMatchObject({
  398. id: "pending",
  399. state: {
  400. status: "error",
  401. error: { type: "tool.interrupted", message: "Tool execution was interrupted before V2 migration" },
  402. },
  403. time: { created: 20 },
  404. })
  405. expect(content[1]).toMatchObject({
  406. id: "running",
  407. state: { status: "error", metadata: { phase: "read" } },
  408. time: { created: 30 },
  409. })
  410. expect(content[2]).toMatchObject({
  411. id: "completed",
  412. providerState: { provider: true },
  413. state: {
  414. status: "completed",
  415. content: [
  416. { type: "text", text: "done" },
  417. { type: "file", uri: "file:///out.txt", mime: "text/plain", name: "out.txt" },
  418. ],
  419. metadata: { result: true },
  420. },
  421. time: { created: 31, completed: 32 },
  422. })
  423. expect(content[3]).toMatchObject({
  424. id: "compacted",
  425. state: { content: [{ type: "text", text: "[Old tool result content cleared]" }] },
  426. })
  427. expect(content[4]).toMatchObject({
  428. id: "failed",
  429. state: {
  430. status: "error",
  431. error: { type: "tool.execution", message: "boom" },
  432. content: [{ type: "text", text: "partial" }],
  433. metadata: { output: "partial" },
  434. },
  435. time: { created: 36, completed: 37 },
  436. })
  437. expect(content[5]).toEqual({
  438. type: "tool",
  439. id: "failed-object-output",
  440. name: "read",
  441. state: {
  442. status: "error",
  443. input: { f: 6 },
  444. error: { type: "tool.execution", message: "object output" },
  445. metadata: { output: { nested: true } },
  446. },
  447. time: { created: 38, completed: 39 },
  448. })
  449. })
  450. test("normalizes assistant errors and finish reasons", () => {
  451. const parent = user("msg_000000000010aaaaaaaaaaaaaa")
  452. const cases = [
  453. ["ProviderAuthError", { providerID: "provider", message: "auth" }, "provider.auth", "auth"],
  454. ["ContentFilterError", { message: "filtered" }, "provider.content-filter", "filtered"],
  455. ["ContextOverflowError", { message: "overflow" }, "provider.invalid-request", "overflow"],
  456. ["StructuredOutputError", { message: "shape", retries: 2 }, "provider.invalid-output", "shape"],
  457. ["MessageOutputLengthError", {}, "provider.invalid-output", "The model exceeded its output limit"],
  458. ["MessageAbortedError", { message: "stopped" }, "aborted", "stopped"],
  459. [
  460. "APIError",
  461. { message: "api", statusCode: 503, isRetryable: true, responseBody: "discard" },
  462. "provider.error",
  463. "api",
  464. ],
  465. ["UnknownError", { message: "unknown", ref: "discard" }, "unknown", "unknown"],
  466. ] as const
  467. const messages = cases.map(([name, data], index) =>
  468. assistant(
  469. `msg_00000000001${index}aaaaaaaaaaaaaa`,
  470. parent.id,
  471. { error: { name, data }, finish: index === 0 ? "new-provider-value" : undefined },
  472. 20 + index,
  473. ),
  474. )
  475. const result = transform([parent, ...messages], [])
  476. cases.forEach((entry, index) => {
  477. expect(result.messages[index + 1].data.error).toMatchObject({ type: entry[2], message: entry[3] })
  478. const error = result.messages[index + 1].data.error
  479. if (!error || typeof error !== "object") throw new Error("Expected assistant error")
  480. expect(Object.keys(error).sort()).toEqual(["message", "type"])
  481. })
  482. expect(result.messages[1].data.finish).toBe("unknown")
  483. })
  484. test("preserves every supported finish reason and omits an absent finish", () => {
  485. const parent = user("msg_000000000050aaaaaaaaaaaaaa")
  486. const finishes = ["stop", "length", "tool-calls", "content-filter", "error", "unknown", undefined] as const
  487. const result = transform(
  488. [
  489. parent,
  490. ...finishes.map((finish, index) =>
  491. assistant(`msg_00000000005${index + 1}aaaaaaaaaaaaaa`, parent.id, finish ? { finish } : {}, 20 + index),
  492. ),
  493. ],
  494. [],
  495. )
  496. expect(result.messages.slice(1).map((row) => row.data.finish)).toEqual([...finishes])
  497. })
  498. test("filters subtasks, collapses compactions, and keeps contiguous sequences", () => {
  499. const subtask = user("msg_000000000020aaaaaaaaaaaaaa", {}, 1)
  500. const taskAssistant = assistant("msg_000000000021aaaaaaaaaaaaaa", subtask.id, {}, 2)
  501. const compact = user("msg_000000000022aaaaaaaaaaaaaa", {}, 3)
  502. const unrelated = assistant("msg_000000000023aaaaaaaaaaaaaa", subtask.id, {}, 4)
  503. const summary = assistant("msg_000000000024aaaaaaaaaaaaaa", compact.id, { summary: true }, 5)
  504. const result = transform(
  505. [summary, compact, unrelated, taskAssistant, subtask],
  506. [
  507. part("prt_1", subtask.id, { type: "subtask", prompt: "work", description: "work", agent: "build" }),
  508. part("prt_2", taskAssistant.id, {
  509. type: "tool",
  510. callID: "task",
  511. tool: "task",
  512. state: {
  513. status: "completed",
  514. input: {},
  515. output: "done",
  516. title: "Task",
  517. metadata: {},
  518. time: { start: 1, end: 2 },
  519. },
  520. }),
  521. part("prt_3", unrelated.id, { type: "text", text: "keep" }),
  522. part("prt_4", compact.id, { type: "compaction", auto: false }),
  523. part("prt_5", summary.id, { type: "text", text: "summary" }),
  524. part("prt_6", summary.id, { type: "text", text: "" }),
  525. ],
  526. )
  527. expect(result.messages.map((row) => [row.type, row.seq])).toEqual([
  528. ["compaction", 0],
  529. ["assistant", 1],
  530. ])
  531. expect(result.messages[0]).toMatchObject({
  532. id: compact.id,
  533. time_created: 3,
  534. time_updated: 6,
  535. data: { status: "completed", reason: "manual", summary: "summary", recent: "" },
  536. })
  537. })
  538. test("serializes the retained compaction tail and preserves existing session selections", () => {
  539. const tailUser = user("msg_000000000025aaaaaaaaaaaaaa", {}, 1)
  540. const tailAssistant = assistant("msg_000000000026aaaaaaaaaaaaaa", tailUser.id, {}, 2)
  541. const compact = user("msg_000000000027aaaaaaaaaaaaaa", {}, 3)
  542. const summary = assistant("msg_000000000028aaaaaaaaaaaaaa", compact.id, { summary: true }, 4)
  543. const existing = session({
  544. agent: "existing",
  545. model: { id: "existing-model", providerID: "existing-provider", variant: "existing" },
  546. })
  547. const result = transform(
  548. [summary, compact, tailAssistant, tailUser],
  549. [
  550. part("prt_1", tailUser.id, { type: "text", text: "question" }),
  551. part("prt_2", tailAssistant.id, { type: "text", text: "answer" }),
  552. part("prt_3", compact.id, { type: "compaction", auto: true, tail_start_id: tailUser.id }),
  553. part("prt_4", summary.id, { type: "text", text: "summary" }),
  554. ],
  555. existing,
  556. )
  557. expect(result.messages[2].data).toMatchObject({
  558. reason: "auto",
  559. summary: "summary",
  560. recent: "[User]: question\n\n[Assistant]: answer",
  561. })
  562. expect(result.session.agent).toBe("existing")
  563. expect(result.session.model).toEqual(existing.model)
  564. })
  565. test("skips malformed rows, reports exact identifiers, and still backfills session aggregates", () => {
  566. const good = user(
  567. "msg_000000000030aaaaaaaaaaaaaa",
  568. { agent: "review", model: { providerID: "p2", modelID: "m2" } },
  569. 2,
  570. )
  571. const badJson = { ...user("msg_000000000031aaaaaaaaaaaaaa"), data: "{" }
  572. const badSchema = { ...user("msg_000000000032aaaaaaaaaaaaaa"), data: JSON.stringify({ role: "user" }) }
  573. const internal = assistant(
  574. "msg_000000000033aaaaaaaaaaaaaa",
  575. good.id,
  576. { cost: 7, tokens: { input: 8, output: 9, reasoning: 10, cache: { read: 11, write: 12 } } },
  577. 3,
  578. )
  579. const source = [good, badJson, badSchema, internal]
  580. const result = transform(source, [
  581. part("prt_1", good.id, { type: "text", text: "before" }),
  582. part("prt_2", good.id, "{"),
  583. part("prt_3", good.id, { type: "future", value: true }),
  584. part("prt_4", "msg_missing", { type: "text", text: "orphan" }),
  585. part("prt_5", good.id, { type: "text", text: "after" }),
  586. ])
  587. expect(result.messages[0].data.text).toBe("before\n\nafter")
  588. expect(result.messages.map((row) => row.seq)).toEqual([0, 1])
  589. expect(result.warnings).toEqual([
  590. { reason: "invalid-message", sessionID: "ses_test", messageID: badJson.id },
  591. { reason: "invalid-message", sessionID: "ses_test", messageID: badSchema.id },
  592. { reason: "invalid-part", sessionID: "ses_test", messageID: good.id, partID: "prt_2", observedType: undefined },
  593. { reason: "invalid-part", sessionID: "ses_test", messageID: good.id, partID: "prt_3", observedType: "future" },
  594. {
  595. reason: "orphan-part",
  596. sessionID: "ses_test",
  597. messageID: "msg_missing",
  598. partID: "prt_4",
  599. observedType: "text",
  600. },
  601. ])
  602. expect(result.session).toEqual({
  603. agent: "review",
  604. model: { id: "m2", providerID: "p2", variant: "default" },
  605. cost: 7,
  606. tokens_input: 8,
  607. tokens_output: 9,
  608. tokens_reasoning: 10,
  609. tokens_cache_read: 11,
  610. tokens_cache_write: 12,
  611. revert: null,
  612. time_compacting: null,
  613. })
  614. expect(source[0]).toBe(good)
  615. })
  616. test("retains empty ordinary messages and omits failed compactions", () => {
  617. const empty = user("msg_000000000034aaaaaaaaaaaaaa", {}, 1)
  618. const assistantMessage = assistant("msg_000000000035aaaaaaaaaaaaaa", empty.id, {}, 2)
  619. const compact = user("msg_000000000036aaaaaaaaaaaaaa", {}, 3)
  620. const failed = assistant(
  621. "msg_000000000037aaaaaaaaaaaaaa",
  622. compact.id,
  623. { summary: true, error: { name: "UnknownError", data: { message: "failed" } } },
  624. 4,
  625. )
  626. const result = transform(
  627. [empty, assistantMessage, compact, failed],
  628. [
  629. part("prt_1", empty.id, { type: "text", text: "ignored", ignored: true }),
  630. part("prt_2", assistantMessage.id, { type: "snapshot", snapshot: "standalone" }),
  631. part("prt_3", compact.id, { type: "compaction", auto: true }),
  632. part("prt_4", failed.id, { type: "text", text: "not committed" }),
  633. ],
  634. )
  635. expect(result.messages.map((row) => row.type)).toEqual(["user", "assistant"])
  636. expect(result.messages[0].data).toEqual({ text: "", time: { created: 1 } })
  637. expect(result.messages[1].data).toMatchObject({ content: [], snapshot: { start: "standalone" } })
  638. expect(result.watermark).toBe(1)
  639. })
  640. test("orders equal-time rows by ID and keeps SQL and payload timestamps consistent", () => {
  641. const first = user("msg_000000000041aaaaaaaaaaaaaa", { time: { created: 999 } }, 10)
  642. const second = assistant("msg_000000000042aaaaaaaaaaaaaa", first.id, { time: { created: 998, completed: 997 } }, 10)
  643. const result = transform(
  644. [second, first],
  645. [
  646. part("prt_1", first.id, { type: "text", text: "first" }),
  647. part("prt_2", second.id, { type: "text", text: "second" }),
  648. ],
  649. )
  650. expect(result.messages.map((row) => row.id)).toEqual([first.id, second.id])
  651. expect(result.messages.map((row) => [row.time_created, row.time_updated, row.data.time])).toEqual([
  652. [10, 11, { created: 10 }],
  653. [10, 11, { created: 10, completed: 11 }],
  654. ])
  655. })
  656. test("omits incomplete compactions and subtask assistants while retaining their aggregate usage", () => {
  657. const mixed = user("msg_000000000043aaaaaaaaaaaaaa", {}, 1)
  658. const task = assistant(
  659. "msg_000000000044aaaaaaaaaaaaaa",
  660. mixed.id,
  661. { cost: 4, tokens: { input: 5, output: 6, reasoning: 7, cache: { read: 8, write: 9 } } },
  662. 2,
  663. )
  664. const compact = user("msg_000000000045aaaaaaaaaaaaaa", {}, 3)
  665. const unfinished = assistant(
  666. "msg_000000000046aaaaaaaaaaaaaa",
  667. compact.id,
  668. { summary: true, time: { created: 4 } },
  669. 4,
  670. )
  671. const result = transform(
  672. [unfinished, compact, task, mixed],
  673. [
  674. part("prt_1", mixed.id, { type: "text", text: "keep" }),
  675. part("prt_2", mixed.id, { type: "subtask", prompt: "work", description: "work", agent: "build" }),
  676. part("prt_3", task.id, {
  677. type: "tool",
  678. callID: "task",
  679. tool: "task",
  680. state: { status: "pending", input: {}, raw: "{}" },
  681. }),
  682. part("prt_4", compact.id, { type: "compaction", auto: true }),
  683. part("prt_5", unfinished.id, { type: "text", text: "unfinished" }),
  684. ],
  685. )
  686. expect(result.messages.map((row) => [row.id, row.type])).toEqual([[mixed.id, "user"]])
  687. expect(result.watermark).toBe(0)
  688. expect(result.session).toMatchObject({
  689. cost: 5,
  690. tokens_input: 7,
  691. tokens_output: 9,
  692. tokens_reasoning: 11,
  693. tokens_cache_read: 13,
  694. tokens_cache_write: 15,
  695. })
  696. })
  697. })
  698. describe("V1Migration database workflow", () => {
  699. const createLegacyTables = Effect.fnUntraced(function* (db: Effect.Success<typeof makeDb>) {
  700. yield* db.run(sql`
  701. CREATE TABLE session (
  702. id text PRIMARY KEY,
  703. project_id text NOT NULL,
  704. workspace_id text,
  705. parent_id text,
  706. slug text NOT NULL,
  707. directory text NOT NULL,
  708. path text,
  709. title text NOT NULL,
  710. version text NOT NULL,
  711. share_url text,
  712. summary_additions integer,
  713. summary_deletions integer,
  714. summary_files integer,
  715. summary_diffs text,
  716. metadata text,
  717. cost real DEFAULT 0 NOT NULL,
  718. tokens_input integer DEFAULT 0 NOT NULL,
  719. tokens_output integer DEFAULT 0 NOT NULL,
  720. tokens_reasoning integer DEFAULT 0 NOT NULL,
  721. tokens_cache_read integer DEFAULT 0 NOT NULL,
  722. tokens_cache_write integer DEFAULT 0 NOT NULL,
  723. revert text,
  724. permission text,
  725. agent text,
  726. model text,
  727. time_created integer NOT NULL,
  728. time_updated integer NOT NULL,
  729. time_compacting integer,
  730. time_archived integer
  731. )
  732. `)
  733. yield* db.run(sql`
  734. CREATE TABLE message (
  735. id text PRIMARY KEY,
  736. session_id text NOT NULL,
  737. time_created integer NOT NULL,
  738. time_updated integer NOT NULL,
  739. data text NOT NULL
  740. )
  741. `)
  742. yield* db.run(sql`
  743. CREATE TABLE part (
  744. id text PRIMARY KEY,
  745. message_id text NOT NULL,
  746. session_id text NOT NULL,
  747. time_created integer NOT NULL,
  748. time_updated integer NOT NULL,
  749. data text NOT NULL
  750. )
  751. `)
  752. })
  753. const database = <A, E>(effect: Effect.Effect<A, E, Database.Service | Scope.Scope>) =>
  754. run(
  755. Effect.gen(function* () {
  756. const db = yield* makeDb
  757. yield* DatabaseMigration.apply(db)
  758. yield* createLegacyTables(db)
  759. return yield* effect.pipe(Effect.provideService(Database.Service, { db }))
  760. }),
  761. )
  762. test("reports required and completed status and completes an empty database idempotently", async () => {
  763. await database(
  764. Effect.gen(function* () {
  765. expect(yield* V1Migration.status()).toEqual({ status: "required" })
  766. expect(yield* V1Migration.run()).toEqual({ status: "completed" })
  767. expect(yield* V1Migration.status()).toEqual({ status: "completed" })
  768. expect(yield* V1Migration.run()).toEqual({ status: "completed" })
  769. }),
  770. )
  771. })
  772. test("imports previous V2 sessions and messages as part of the migration", async () => {
  773. await using tmp = await tmpdir()
  774. const filename = path.join(tmp.path, "opencode-next.db")
  775. const sqlite = await import("bun:sqlite")
  776. const source = new sqlite.Database(filename)
  777. source.run(`
  778. CREATE TABLE project (
  779. id text PRIMARY KEY, worktree text NOT NULL, vcs text, name text, icon_url text, icon_url_override text,
  780. icon_color text, time_created integer NOT NULL, time_updated integer NOT NULL, time_initialized integer,
  781. sandboxes text NOT NULL, commands text
  782. );
  783. CREATE TABLE session (
  784. id text PRIMARY KEY, project_id text NOT NULL, workspace_id text, parent_id text, fork_session_id text,
  785. fork_boundary text, slug text NOT NULL, directory text NOT NULL, path text, title text, version text NOT NULL,
  786. share_url text, summary_additions integer, summary_deletions integer, summary_files integer, summary_diffs text,
  787. metadata text, cost real DEFAULT 0 NOT NULL, tokens_input integer DEFAULT 0 NOT NULL,
  788. tokens_output integer DEFAULT 0 NOT NULL, tokens_reasoning integer DEFAULT 0 NOT NULL,
  789. tokens_cache_read integer DEFAULT 0 NOT NULL, tokens_cache_write integer DEFAULT 0 NOT NULL, revert text,
  790. permission text, agent text, model text, time_created integer NOT NULL, time_updated integer NOT NULL,
  791. time_compacting integer, time_archived integer, time_suspended integer
  792. );
  793. CREATE TABLE session_message (
  794. id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL,
  795. time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL
  796. );
  797. INSERT INTO project VALUES (
  798. 'next-project', 'C:/Users/sewer', 'git', 'Source project', NULL, NULL, NULL, 1, 2, NULL, '[]', NULL
  799. );
  800. INSERT INTO session (
  801. id, project_id, slug, directory, title, version, agent, model, time_created, time_updated
  802. ) VALUES
  803. ('ses_next', 'next-project', 'next', 'C:/Users/sewer', 'Imported', '2', 'build',
  804. '{"id":"model","providerID":"provider"}', 10, 20),
  805. ('ses_existing', 'next-project', 'source-existing', '/tmp/next', 'Source existing', '2', NULL, NULL, 11, 21),
  806. ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '2', NULL, NULL, 12, 22);
  807. INSERT INTO session_message VALUES
  808. ('msg_next', 'ses_next', 'user', 4, 12, 13, '{"text":"from next","time":{"created":12}}'),
  809. ('msg_source_existing', 'ses_existing', 'user', 2, 12, 13, '{"text":"source","time":{"created":12}}'),
  810. ('msg_orphan', 'ses_orphan', 'user', 0, 12, 13, '{"text":"orphan","time":{"created":12}}');
  811. `)
  812. source.close()
  813. await database(
  814. Effect.gen(function* () {
  815. const { db } = yield* Database.Service
  816. yield* db.run(sql`
  817. INSERT INTO project (id, worktree, name, time_created, time_updated, sandboxes)
  818. VALUES ('next-project', '/tmp/current', 'Current project', 1, 2, '[]')
  819. `)
  820. yield* db.run(sql`
  821. INSERT INTO session_v2 (id, project_id, slug, directory, title, version, time_created, time_updated)
  822. VALUES ('ses_existing', 'next-project', 'current-existing', '/tmp/current', 'Current existing', '2', 1, 2)
  823. `)
  824. yield* db.run(sql`
  825. INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data)
  826. VALUES ('msg_current_existing', 'ses_existing', 'user', 0, 1, 2, '{"text":"current","time":{"created":1}}')
  827. `)
  828. expect(yield* V1Migration.status()).toEqual({
  829. status: "required",
  830. })
  831. expect(yield* V1Migration.run({ nextDatabasePath: filename })).toEqual({ status: "completed" })
  832. expect(yield* V1Migration.status()).toEqual({
  833. status: "completed",
  834. })
  835. expect(yield* db.get(sql`SELECT title, agent, model FROM session_v2 WHERE id = 'ses_next'`)).toEqual({
  836. title: "Imported",
  837. agent: "build",
  838. model: '{"id":"model","providerID":"provider"}',
  839. })
  840. expect(
  841. yield* db
  842. .select({ directory: SessionTable.directory })
  843. .from(SessionTable)
  844. .where(eq(SessionTable.id, SessionSchema.ID.make("ses_next")))
  845. .get(),
  846. ).toEqual({ directory: process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer" })
  847. expect(yield* db.all(sql`SELECT id, seq, data FROM session_message WHERE session_id = 'ses_next'`)).toEqual([
  848. {
  849. id: "msg_next",
  850. seq: 4,
  851. data: '{"text":"from next","time":{"created":12}}',
  852. },
  853. ])
  854. expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_next'`)).toEqual({
  855. seq: 4,
  856. owner_id: null,
  857. })
  858. expect(yield* db.get(sql`SELECT title FROM session_v2 WHERE id = 'ses_existing'`)).toEqual({
  859. title: "Current existing",
  860. })
  861. expect(yield* db.all(sql`SELECT id FROM session_message WHERE session_id = 'ses_existing'`)).toEqual([
  862. { id: "msg_current_existing" },
  863. ])
  864. expect(yield* db.get(sql`SELECT project_id FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({
  865. project_id: "global",
  866. })
  867. expect(yield* db.get(sql`SELECT name, worktree FROM project WHERE id = 'next-project'`)).toEqual({
  868. name: "Current project",
  869. worktree: "/tmp/current",
  870. })
  871. yield* db.run(sql`UPDATE project SET worktree = 'C:/Users/sewer' WHERE id = 'next-project'`)
  872. expect(
  873. yield* db
  874. .select({ worktree: ProjectTable.worktree })
  875. .from(ProjectTable)
  876. .where(eq(ProjectTable.id, Project.ID.make("next-project")))
  877. .get(),
  878. ).toEqual({
  879. worktree: AbsolutePath.make(process.platform === "win32" ? "C:\\Users\\sewer" : "C:/Users/sewer"),
  880. })
  881. expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
  882. value: '{"phase":"completed"}',
  883. })
  884. }),
  885. )
  886. })
  887. test("derives required status from the durable cursor", async () => {
  888. await database(
  889. Effect.gen(function* () {
  890. const { db } = yield* Database.Service
  891. yield* db.run(
  892. sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/tmp/test', 1, 2, '[]')`,
  893. )
  894. yield* Effect.forEach(["ses_c", "ses_a", "ses_b"], (id) =>
  895. db.run(
  896. sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (${id}, 'global', ${id}, '/tmp/test', 'Test', '1', 1, 2)`,
  897. ),
  898. )
  899. yield* db.run(
  900. sql`INSERT INTO kv (key, value, time_created, time_updated) VALUES ('migration.v1-v2', '{"phase":"sessions","cursor":"ses_b"}', 1, 1)`,
  901. )
  902. expect(yield* V1Migration.status()).toEqual({ status: "required" })
  903. }),
  904. )
  905. })
  906. test("reassigns V1 sessions whose projects are missing to the global project", async () => {
  907. await database(
  908. Effect.gen(function* () {
  909. const { db } = yield* Database.Service
  910. yield* db.run(
  911. sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_orphan', 'missing-project', 'orphan', '/tmp/orphan', 'Orphan', '1', 1, 2)`,
  912. )
  913. expect(yield* V1Migration.run()).toEqual({ status: "completed" })
  914. expect(yield* db.get(sql`SELECT project_id FROM session_v2 WHERE id = 'ses_orphan'`)).toEqual({
  915. project_id: "global",
  916. })
  917. expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({
  918. worktree: path.parse(Global.Path.data).root,
  919. })
  920. expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
  921. value: '{"phase":"completed"}',
  922. })
  923. }),
  924. )
  925. })
  926. test("replaces projections, updates sessions, preserves V1 rows, and checkpoints completion", async () => {
  927. await database(
  928. Effect.gen(function* () {
  929. const { db } = yield* Database.Service
  930. yield* db.run(
  931. sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/tmp/test', 1, 2, '[]')`,
  932. )
  933. yield* db.run(sql`INSERT INTO session (
  934. id, project_id, slug, directory, title, version, cost, tokens_input, tokens_output, tokens_reasoning,
  935. tokens_cache_read, tokens_cache_write, revert, agent, model, metadata, time_created, time_updated,
  936. time_compacting, time_archived
  937. ) VALUES (
  938. 'ses_test', 'global', 'test', '/tmp/test', 'Test', '1', 99, 99, 99, 99, 99, 99, '{}', 'preserved',
  939. '{"id":"selected","providerID":"selected-provider","variant":"selected-variant"}', '{"keep":true}',
  940. 1, 2, 3, 4
  941. )`)
  942. const source = user("msg_000000000040aaaaaaaaaaaaaa")
  943. const sourcePart = part("prt_1", source.id, { type: "text", text: "hello" })
  944. yield* db.run(
  945. sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (${source.id}, 'ses_test', 10, 11, ${source.data})`,
  946. )
  947. yield* db.run(
  948. sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('prt_1', ${source.id}, 'ses_test', 1, 2, ${sourcePart.data})`,
  949. )
  950. yield* db.run(
  951. sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale', 'ses_test', 'user', 0, 1, 1, '{"text":"stale","time":{"created":1}}')`,
  952. )
  953. yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('ses_test', 9)`)
  954. yield* db.run(
  955. sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('event_stale', 'ses_test', 9, 1, 'session.renamed.1', '{}')`,
  956. )
  957. expect(yield* V1Migration.run()).toEqual({ status: "completed" })
  958. expect(yield* db.all(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message`)).toEqual(
  959. [
  960. {
  961. id: source.id,
  962. type: "user",
  963. seq: 0,
  964. time_created: 10,
  965. time_updated: 11,
  966. data: '{"text":"hello","time":{"created":10}}',
  967. },
  968. ],
  969. )
  970. expect(yield* db.all(sql`SELECT id, data FROM message`)).toEqual([{ id: source.id, data: source.data }])
  971. expect(yield* db.all(sql`SELECT id, data FROM part`)).toEqual([{ id: "prt_1", data: sourcePart.data }])
  972. expect(yield* db.get(sql`SELECT seq FROM event_sequence WHERE aggregate_id = 'ses_test'`)).toEqual({ seq: 0 })
  973. expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([])
  974. expect(
  975. yield* db.get(
  976. sql`SELECT agent, model, metadata, cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write, revert, time_created, time_updated, time_compacting, time_archived FROM session_v2 WHERE id = 'ses_test'`,
  977. ),
  978. ).toEqual({
  979. agent: "preserved",
  980. model: '{"id":"selected","providerID":"selected-provider","variant":"selected-variant"}',
  981. metadata: '{"keep":true}',
  982. cost: 0,
  983. tokens_input: 0,
  984. tokens_output: 0,
  985. tokens_reasoning: 0,
  986. tokens_cache_read: 0,
  987. tokens_cache_write: 0,
  988. revert: null,
  989. time_created: 1,
  990. time_updated: 2,
  991. time_compacting: null,
  992. time_archived: 4,
  993. })
  994. expect(yield* db.get(sql`SELECT cost, revert, time_compacting FROM session WHERE id = 'ses_test'`)).toEqual({
  995. cost: 99,
  996. revert: "{}",
  997. time_compacting: 3,
  998. })
  999. expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
  1000. value: '{"phase":"completed"}',
  1001. })
  1002. }),
  1003. )
  1004. })
  1005. test("rolls back one session atomically and resumes from the committed cursor", async () => {
  1006. await database(
  1007. Effect.gen(function* () {
  1008. const { db } = yield* Database.Service
  1009. yield* db.run(
  1010. sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/tmp/test', 1, 2, '[]')`,
  1011. )
  1012. yield* Effect.forEach(["ses_a", "ses_b", "ses_c"], (id) =>
  1013. db.run(
  1014. sql`INSERT INTO session (id, project_id, slug, directory, title, version, cost, time_created, time_updated) VALUES (${id}, 'global', ${id}, '/tmp/test', 'Test', '1', 99, 1, 2)`,
  1015. ),
  1016. )
  1017. yield* db.run(
  1018. sql`CREATE TRIGGER fail_b BEFORE UPDATE ON session_v2 WHEN NEW.id = 'ses_b' BEGIN SELECT RAISE(ABORT, 'stop'); END`,
  1019. )
  1020. yield* db.run(
  1021. sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_stale_b', 'ses_b', 'user', 0, 7, 8, '{"text":"stale","time":{"created":7}}')`,
  1022. )
  1023. yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq, owner_id) VALUES ('ses_b', 7, 'owner')`)
  1024. yield* db.run(
  1025. sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('event_stale_b', 'ses_b', 7, 1, 'session.renamed.1', '{}')`,
  1026. )
  1027. yield* Layer.launch(V1Migration.layer).pipe(Effect.forkScoped)
  1028. const failed = yield* V1Migration.status().pipe(
  1029. Effect.filterOrFail((status) => status.status === "error"),
  1030. Effect.retry(Schedule.spaced("10 millis")),
  1031. )
  1032. expect(failed.status).toBe("error")
  1033. if (failed.status === "error") expect(failed.error).toContain("stop")
  1034. expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
  1035. value: '{"phase":"sessions","cursor":"ses_c"}',
  1036. })
  1037. expect(yield* db.get(sql`SELECT cost FROM session_v2 WHERE id = 'ses_c'`)).toEqual({ cost: 0 })
  1038. expect(yield* db.get(sql`SELECT cost FROM session_v2 WHERE id = 'ses_b'`)).toBeUndefined()
  1039. expect(yield* db.get(sql`SELECT cost FROM session WHERE id = 'ses_b'`)).toEqual({ cost: 99 })
  1040. expect(
  1041. yield* db.all(
  1042. sql`SELECT id, seq, time_created, time_updated, data FROM session_message WHERE session_id = 'ses_b'`,
  1043. ),
  1044. ).toEqual([
  1045. {
  1046. id: "msg_stale_b",
  1047. seq: 0,
  1048. time_created: 7,
  1049. time_updated: 8,
  1050. data: '{"text":"stale","time":{"created":7}}',
  1051. },
  1052. ])
  1053. expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_b'`)).toEqual({
  1054. seq: 7,
  1055. owner_id: "owner",
  1056. })
  1057. expect(yield* db.all(sql`SELECT id FROM event WHERE aggregate_id = 'ses_b'`)).toEqual([])
  1058. expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'migration.v1-v2'`)).toEqual({
  1059. value: '{"phase":"sessions","cursor":"ses_c"}',
  1060. })
  1061. yield* db.run(
  1062. sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('event_after_clear', 'ses_c', 0, 2, 'session.renamed.1', '{}')`,
  1063. )
  1064. yield* db.run(sql`DROP TRIGGER fail_b`)
  1065. yield* Layer.launch(V1Migration.layer).pipe(Effect.forkScoped)
  1066. yield* V1Migration.status().pipe(
  1067. Effect.filterOrFail((status) => status.status === "completed"),
  1068. Effect.retry(Schedule.spaced("10 millis")),
  1069. )
  1070. expect(yield* db.get(sql`SELECT cost FROM session_v2 WHERE id = 'ses_b'`)).toEqual({ cost: 0 })
  1071. expect(yield* db.all(sql`SELECT id FROM session_message WHERE session_id = 'ses_b'`)).toEqual([])
  1072. expect(yield* db.get(sql`SELECT seq, owner_id FROM event_sequence WHERE aggregate_id = 'ses_b'`)).toEqual({
  1073. seq: -1,
  1074. owner_id: null,
  1075. })
  1076. expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([{ id: "event_after_clear" }])
  1077. }),
  1078. )
  1079. })
  1080. test("processes root, child, archived, empty, malformed-only, subtask-only, and incomplete-compaction sessions", async () => {
  1081. const output = new Array<ReturnType<typeof Logger.formatStructured.log>>()
  1082. await database(
  1083. Effect.gen(function* () {
  1084. const { db } = yield* Database.Service
  1085. yield* db.run(
  1086. sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/tmp/test', 1, 2, '[]')`,
  1087. )
  1088. yield* db.run(
  1089. sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_root', 'global', 'root', '/tmp/test', 'Root', '1', 1, 2)`,
  1090. )
  1091. yield* db.run(
  1092. sql`INSERT INTO session (id, project_id, parent_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_child', 'global', 'ses_root', 'child', '/tmp/test', 'Child', '1', 1, 2)`,
  1093. )
  1094. yield* db.run(
  1095. sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated, time_archived) VALUES ('ses_archived', 'global', 'archived', '/tmp/test', 'Archived', '1', 1, 2, 3)`,
  1096. )
  1097. yield* Effect.forEach(["ses_empty", "ses_malformed", "ses_subtask", "ses_compaction"], (id) =>
  1098. db.run(
  1099. sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES (${id}, 'global', ${id}, '/tmp/test', ${id}, '1', 1, 2)`,
  1100. ),
  1101. )
  1102. yield* db.run(
  1103. sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('msg_bad', 'ses_malformed', 1, 2, '{')`,
  1104. )
  1105. const subtask = user("msg_000000000047aaaaaaaaaaaaaa")
  1106. yield* db.run(
  1107. sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (${subtask.id}, 'ses_subtask', 10, 11, ${subtask.data})`,
  1108. )
  1109. yield* db.run(
  1110. sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('prt_subtask', ${subtask.id}, 'ses_subtask', 1, 2, '{"type":"subtask","prompt":"work","description":"work","agent":"build"}')`,
  1111. )
  1112. const compact = user("msg_000000000048aaaaaaaaaaaaaa")
  1113. yield* db.run(
  1114. sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES (${compact.id}, 'ses_compaction', 10, 11, ${compact.data})`,
  1115. )
  1116. yield* db.run(
  1117. sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('prt_compaction', ${compact.id}, 'ses_compaction', 1, 2, '{"type":"compaction","auto":true}')`,
  1118. )
  1119. expect(yield* V1Migration.run()).toEqual({ status: "completed" })
  1120. expect(yield* db.all(sql`SELECT aggregate_id, seq FROM event_sequence ORDER BY aggregate_id`)).toEqual([
  1121. { aggregate_id: "ses_archived", seq: -1 },
  1122. { aggregate_id: "ses_child", seq: -1 },
  1123. { aggregate_id: "ses_compaction", seq: -1 },
  1124. { aggregate_id: "ses_empty", seq: -1 },
  1125. { aggregate_id: "ses_malformed", seq: -1 },
  1126. { aggregate_id: "ses_root", seq: -1 },
  1127. { aggregate_id: "ses_subtask", seq: -1 },
  1128. ])
  1129. expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
  1130. expect(yield* V1Migration.status()).toEqual({ status: "completed" })
  1131. expect(output.map((entry) => entry.message)).toContainEqual([
  1132. "Skipped V1 migration row",
  1133. {
  1134. reason: "invalid-message",
  1135. sessionID: "ses_malformed",
  1136. messageID: "msg_bad",
  1137. },
  1138. ])
  1139. }).pipe(
  1140. Effect.provide(
  1141. Logger.layer([
  1142. Logger.map(Logger.formatStructured, (entry) => {
  1143. output.push(entry)
  1144. }),
  1145. ]),
  1146. ),
  1147. ),
  1148. )
  1149. })
  1150. test("serializes concurrent callers and migrates each session once", async () => {
  1151. await database(
  1152. Effect.gen(function* () {
  1153. const { db } = yield* Database.Service
  1154. yield* db.run(
  1155. sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/tmp/test', 1, 2, '[]')`,
  1156. )
  1157. yield* db.run(
  1158. sql`INSERT INTO session (id, project_id, slug, directory, title, version, time_created, time_updated) VALUES ('ses_test', 'global', 'test', '/tmp/test', 'Test', '1', 1, 2)`,
  1159. )
  1160. yield* db.run(sql`CREATE TABLE audit (count integer NOT NULL)`)
  1161. yield* db.run(sql`INSERT INTO audit VALUES (0)`)
  1162. yield* db.run(
  1163. sql`CREATE TRIGGER audit_session AFTER UPDATE ON session_v2 BEGIN UPDATE audit SET count = count + 1; END`,
  1164. )
  1165. expect(yield* Effect.all([V1Migration.run(), V1Migration.run()], { concurrency: "unbounded" })).toEqual([
  1166. { status: "completed" },
  1167. { status: "completed" },
  1168. ])
  1169. expect(yield* db.get(sql`SELECT count FROM audit`)).toEqual({ count: 1 })
  1170. }),
  1171. )
  1172. })
  1173. })