1
0

v1-migration.test.ts 51 KB

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