v1-migration.test.ts 50 KB

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