database-migration.test.ts 69 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499
  1. import { describe, expect, test } from "bun:test"
  2. import { $ } from "bun"
  3. import { fileURLToPath } from "url"
  4. import path from "path"
  5. import { SqliteClient } from "@effect/sql-sqlite-bun"
  6. import { EffectDrizzleSqlite } from "@opencode-ai/effect-drizzle-sqlite"
  7. import { Effect, Layer, Schema } from "effect"
  8. import { eq, inArray, sql } from "drizzle-orm"
  9. import { DatabaseMigration } from "@opencode-ai/core/database/migration"
  10. import { migrations } from "@opencode-ai/core/database/migration.gen"
  11. import sessionUsageMigration from "@opencode-ai/core/database/migration/20260510033149_session_usage"
  12. import normalizeStoragePathsMigration from "@opencode-ai/core/database/migration/20260601010001_normalize_storage_paths"
  13. import sessionMessageProjectionOrderMigration from "@opencode-ai/core/database/migration/20260603040000_session_message_projection_order"
  14. import eventSourcedSessionPendingMigration from "@opencode-ai/core/database/migration/20260604172448_event_sourced_session_input"
  15. import contextEpochAgentMigration from "@opencode-ai/core/database/migration/20260605042240_add_context_epoch_agent"
  16. import simplifyIntegrationCredentialsMigration from "@opencode-ai/core/database/migration/20260611192811_lush_chimera"
  17. import simplifySessionPendingMigration from "@opencode-ai/core/database/migration/20260622202450_simplify_session_input"
  18. import resetSessionEventsMigration from "@opencode-ai/core/database/migration/20260703200000_reset_v2_session_events"
  19. import durableSessionInboxMigration from "@opencode-ai/core/database/migration/20260707010146_durable_session_inbox"
  20. import migratePrelaunchV2StateMigration from "@opencode-ai/core/database/migration/20260707120000_migrate_prelaunch_v2_state"
  21. import genericSessionPendingMigration from "@opencode-ai/core/database/migration/20260709013000_generic_session_input"
  22. import sessionPendingTableMigration from "@opencode-ai/core/database/migration/20260709190621_session_pending_table"
  23. import renameInstructionsMigration from "@opencode-ai/core/database/migration/20260705180000_rename_instructions"
  24. import addSessionForkMigration from "@opencode-ai/core/database/migration/20260706223930_add-session-fork"
  25. import timeSuspendedMigration from "@opencode-ai/core/database/migration/20260709163752_time_suspended"
  26. import instructionSyncMigration from "@opencode-ai/core/database/migration/20260710025429_instruction_sync"
  27. import deleteToolProgressEventsMigration from "@opencode-ai/core/database/migration/20260722011141_delete_tool_progress_events"
  28. import canonicalToolResultsMigration from "@opencode-ai/core/database/migration/20260722170000_canonical_tool_results"
  29. import optionalSessionTitleMigration from "@opencode-ai/core/database/migration/20260730195856_optional_session_title"
  30. import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
  31. import { LayerNode } from "@opencode-ai/util/effect/layer-node"
  32. import { Bus } from "@opencode-ai/core/bus"
  33. import { Project } from "@opencode-ai/core/project"
  34. import { ProjectTable } from "@opencode-ai/core/project/sql"
  35. import { AbsolutePath } from "@opencode-ai/core/schema"
  36. import { SessionSchema } from "@opencode-ai/core/session/schema"
  37. import { SessionMessage } from "@opencode-ai/core/session/message"
  38. import { SessionTable } from "@opencode-ai/core/session/sql"
  39. import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
  40. import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
  41. import { Database } from "@opencode-ai/core/database/database"
  42. import { SessionProjector } from "@opencode-ai/core/session/projector"
  43. import { SessionV1 } from "@opencode-ai/core/v1/session"
  44. import { tmpdir } from "./fixture/tmpdir"
  45. const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
  46. Effect.runPromise(
  47. effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
  48. )
  49. const makeDb = EffectDrizzleSqlite.makeWithDefaults()
  50. describe("DatabaseMigration", () => {
  51. test("migrates pre-launch V2 state in place", async () => {
  52. await run(
  53. Effect.gen(function* () {
  54. const db = yield* makeDb
  55. yield* db.run(
  56. sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
  57. )
  58. yield* db.run(
  59. sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
  60. )
  61. yield* db.run(
  62. sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
  63. )
  64. yield* db.run(
  65. sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL, owner_id text)`,
  66. )
  67. yield* db.run(
  68. sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`,
  69. )
  70. const messages = [
  71. ["msg_skill", "skill", { name: "effect", text: "Use Effect", time: { created: 1 } }],
  72. [
  73. "msg_shell",
  74. "shell",
  75. {
  76. shell: { id: "sh_old", command: "pwd", status: "exited", exit: 0, cwd: "/tmp" },
  77. output: { output: "/tmp", cursor: 4, size: 4, truncated: false },
  78. time: { created: 2, completed: 3 },
  79. },
  80. ],
  81. [
  82. "msg_assistant",
  83. "assistant",
  84. {
  85. agent: "build",
  86. model: { id: "model", providerID: "provider" },
  87. content: [
  88. {
  89. type: "tool",
  90. id: "call_old",
  91. name: "read",
  92. provider: "removed",
  93. state: { status: "pending", input: '{"path":"README.md"}', title: "removed" },
  94. time: { created: 3 },
  95. },
  96. ],
  97. time: { created: 3 },
  98. },
  99. ],
  100. [
  101. "msg_failed",
  102. "compaction",
  103. {
  104. status: "failed",
  105. reason: "manual",
  106. summary: "removed",
  107. recent: "removed",
  108. time: { created: 4 },
  109. },
  110. ],
  111. [
  112. "msg_queued",
  113. "compaction",
  114. { status: "queued", reason: "manual", summary: "", recent: "", time: { created: 5 } },
  115. ],
  116. [
  117. "msg_synthetic",
  118. "synthetic",
  119. { sessionID: "ses_test", text: "context", description: "source", time: { created: 6 } },
  120. ],
  121. [
  122. "msg_running",
  123. "compaction",
  124. { status: "running", reason: "auto", summary: "partial", recent: "recent", time: { created: 7 } },
  125. ],
  126. [
  127. "msg_completed",
  128. "compaction",
  129. { status: "completed", reason: "auto", summary: "summary", recent: "recent", time: { created: 8 } },
  130. ],
  131. ] as const
  132. for (const [id, type, data] of messages)
  133. yield* db.run(
  134. sql`INSERT INTO session_message VALUES (${id}, 'ses_test', ${type}, 1, 10, 11, ${JSON.stringify(data)})`,
  135. )
  136. yield* db.run(
  137. sql`INSERT INTO session_input VALUES ('msg_queued', 'ses_test', 'compaction', NULL, NULL, 4, NULL, 5)`,
  138. )
  139. yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 9, 'owner')`)
  140. yield* db.run(sql`INSERT INTO instruction_checkpoint VALUES ('ses_test', 'baseline', '{"source":"value"}', 7)`)
  141. const events = [
  142. ["evt_skill", 1, 101, "session.skill.activated.1", { sessionID: "ses_test", name: "effect", text: "Use" }],
  143. ["evt_started", 2, 102, "session.compaction.started.1", { sessionID: "ses_test", reason: "auto" }],
  144. ["evt_delta", 3, 103, "session.compaction.delta.1", { sessionID: "ses_test", text: "partial" }],
  145. ["evt_failed", 4, 104, "session.compaction.failed.1", { sessionID: "ses_test" }],
  146. [
  147. "evt_revert",
  148. 5,
  149. 105,
  150. "session.revert.staged.1",
  151. {
  152. sessionID: "ses_test",
  153. revert: {
  154. messageID: "msg_skill",
  155. snapshot: "tree",
  156. diff: "removed",
  157. files: [{ path: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }],
  158. },
  159. },
  160. ],
  161. [
  162. "evt_skill_current",
  163. 6,
  164. 106,
  165. "session.skill.activated.2",
  166. { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" },
  167. ],
  168. ] as const
  169. for (const [id, seq, created, type, data] of events)
  170. yield* db.run(
  171. sql`INSERT INTO event VALUES (${id}, 'ses_test', ${seq}, ${created}, ${type}, ${JSON.stringify(data)})`,
  172. )
  173. yield* DatabaseMigration.applyOnly(db, [migratePrelaunchV2StateMigration])
  174. const rows = yield* db.all<{
  175. id: string
  176. type: string
  177. seq: number
  178. time_created: number
  179. time_updated: number
  180. data: string
  181. }>(sql`SELECT id, type, seq, time_created, time_updated, data FROM session_message ORDER BY id`)
  182. for (const row of rows)
  183. Schema.decodeUnknownSync(SessionMessage.Info)({ ...JSON.parse(row.data), id: row.id, type: row.type })
  184. expect(rows.every((row) => row.seq === 1 && row.time_created === 10 && row.time_updated === 11)).toBe(true)
  185. expect(rows.map((row) => [row.id, JSON.parse(row.data)])).toEqual([
  186. [
  187. "msg_assistant",
  188. expect.objectContaining({
  189. content: [expect.objectContaining({ state: { status: "streaming", input: '{"path":"README.md"}' } })],
  190. }),
  191. ],
  192. ["msg_completed", expect.objectContaining({ status: "completed", summary: "summary", recent: "recent" })],
  193. [
  194. "msg_failed",
  195. {
  196. time: { created: 4 },
  197. status: "failed",
  198. reason: "manual",
  199. error: {
  200. type: "compaction.failed",
  201. message: "Compaction failed before recording an error",
  202. },
  203. },
  204. ],
  205. ["msg_running", expect.objectContaining({ status: "running", summary: "partial", recent: "recent" })],
  206. ["msg_shell", expect.objectContaining({ shellID: "sh_old", command: "pwd", status: "exited", exit: 0 })],
  207. ["msg_skill", { time: { created: 1 }, skill: "effect", name: "effect", text: "Use Effect" }],
  208. ["msg_synthetic", { time: { created: 6 }, text: "context", description: "source" }],
  209. ])
  210. expect(yield* db.get(sql`SELECT * FROM session_input`)).toEqual({
  211. id: "msg_queued",
  212. session_id: "ses_test",
  213. type: "compaction",
  214. prompt: null,
  215. delivery: null,
  216. admitted_seq: 4,
  217. promoted_seq: null,
  218. time_created: 5,
  219. })
  220. const migratedEvents = yield* db.all<{
  221. id: string
  222. aggregate_id: string
  223. seq: number
  224. created: number
  225. type: string
  226. data: string
  227. }>(sql`SELECT * FROM event ORDER BY seq`)
  228. expect(migratedEvents.map((event) => ({ ...event, data: JSON.parse(event.data) }))).toEqual([
  229. {
  230. id: "evt_skill",
  231. aggregate_id: "ses_test",
  232. seq: 1,
  233. created: 101,
  234. type: "session.skill.activated.1",
  235. data: { sessionID: "ses_test", id: "effect", name: "effect", text: "Use" },
  236. },
  237. {
  238. id: "evt_started",
  239. aggregate_id: "ses_test",
  240. seq: 2,
  241. created: 102,
  242. type: "session.compaction.started.1",
  243. data: { sessionID: "ses_test", reason: "auto", recent: "" },
  244. },
  245. {
  246. id: "evt_failed",
  247. aggregate_id: "ses_test",
  248. seq: 4,
  249. created: 104,
  250. type: "session.compaction.failed.1",
  251. data: {
  252. sessionID: "ses_test",
  253. reason: "auto",
  254. error: {
  255. type: "compaction.failed",
  256. message: "Compaction failed before recording an error",
  257. },
  258. },
  259. },
  260. {
  261. id: "evt_revert",
  262. aggregate_id: "ses_test",
  263. seq: 5,
  264. created: 105,
  265. type: "session.revert.staged.1",
  266. data: {
  267. sessionID: "ses_test",
  268. revert: {
  269. messageID: "msg_skill",
  270. snapshot: "tree",
  271. files: [{ file: "src/a.ts", patch: "@@", additions: 1, deletions: 0, status: "modified" }],
  272. },
  273. },
  274. },
  275. {
  276. id: "evt_skill_current",
  277. aggregate_id: "ses_test",
  278. seq: 6,
  279. created: 106,
  280. type: "session.skill.activated.1",
  281. data: { sessionID: "ses_test", id: "effect-id", name: "Effect", text: "Use" },
  282. },
  283. ])
  284. expect(yield* db.get(sql`SELECT * FROM event_sequence`)).toEqual({
  285. aggregate_id: "ses_test",
  286. seq: 9,
  287. owner_id: "owner",
  288. })
  289. expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({
  290. session_id: "ses_test",
  291. baseline: "baseline",
  292. snapshot: '{"source":"value"}',
  293. baseline_seq: 7,
  294. })
  295. }),
  296. )
  297. })
  298. test("resets incompatible V2 Session event history", async () => {
  299. await run(
  300. Effect.gen(function* () {
  301. const db = yield* makeDb
  302. yield* db.run(sql`CREATE TABLE session_input (id text PRIMARY KEY)`)
  303. yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY)`)
  304. yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY)`)
  305. yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
  306. yield* db.run(sql`INSERT INTO session_input (id) VALUES ('input')`)
  307. yield* db.run(sql`INSERT INTO session_message (id) VALUES ('message')`)
  308. yield* db.run(sql`INSERT INTO event (id) VALUES ('event')`)
  309. yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 1)`)
  310. yield* DatabaseMigration.applyOnly(db, [resetSessionEventsMigration])
  311. expect(yield* db.get(sql`SELECT id FROM session_input`)).toBeUndefined()
  312. expect(yield* db.get(sql`SELECT id FROM session_message`)).toBeUndefined()
  313. expect(yield* db.get(sql`SELECT id FROM event`)).toBeUndefined()
  314. expect(yield* db.get(sql`SELECT aggregate_id FROM event_sequence`)).toBeUndefined()
  315. }),
  316. )
  317. })
  318. test("serializes concurrent embedded initialization for one database path", async () => {
  319. await using tmp = await tmpdir()
  320. const filename = path.join(tmp.path, "embedded.sqlite")
  321. const layers = [Database.layer({ path: filename }), Database.layer({ path: filename })]
  322. await Effect.runPromise(
  323. Effect.all(
  324. layers.map((layer) => Effect.scoped(Layer.build(layer))),
  325. { concurrency: "unbounded" },
  326. ),
  327. )
  328. })
  329. if (process.platform === "linux") {
  330. test("declared schema has no ungenerated migrations", async () => {
  331. const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
  332. .quiet()
  333. .nothrow()
  334. expect(result.exitCode, result.stderr.toString()).toBe(0)
  335. expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate")
  336. }, 30_000)
  337. }
  338. test("applies tracked migrations to an empty database", async () => {
  339. await run(
  340. Effect.gen(function* () {
  341. const db = yield* makeDb
  342. yield* DatabaseMigration.apply(db)
  343. expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
  344. name: "session",
  345. })
  346. expect(
  347. yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
  348. ).toBeUndefined()
  349. expect(
  350. yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_pending'`),
  351. ).toEqual({ name: "session_pending" })
  352. expect(
  353. yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
  354. ).toBeUndefined()
  355. expect(
  356. yield* db.all(
  357. sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN ('instruction_blob', 'instruction_state') ORDER BY name`,
  358. ),
  359. ).toEqual([{ name: "instruction_blob" }, { name: "instruction_state" }])
  360. expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
  361. expect(
  362. yield* db.all(
  363. sql`SELECT name FROM sqlite_master WHERE type = 'index' AND name IN ('event_aggregate_seq_idx', 'event_aggregate_type_seq_idx', 'session_input_session_pending_seq_idx', 'session_input_session_pending_delivery_seq_idx', 'session_input_session_pending_type_delivery_seq_idx', 'session_input_session_pending_compaction_idx', 'session_input_session_admitted_seq_idx', 'session_input_session_promoted_seq_idx', 'session_pending_session_delivery_seq_idx', 'session_pending_session_compaction_idx', 'session_pending_session_admitted_seq_idx', 'session_message_session_idx', 'session_message_session_type_idx', 'session_message_session_seq_idx', 'session_message_session_type_seq_idx', 'session_message_session_time_created_id_idx') ORDER BY name`,
  364. ),
  365. ).toEqual([
  366. { name: "event_aggregate_seq_idx" },
  367. { name: "event_aggregate_type_seq_idx" },
  368. { name: "session_message_session_seq_idx" },
  369. { name: "session_message_session_time_created_id_idx" },
  370. { name: "session_message_session_type_seq_idx" },
  371. { name: "session_pending_session_admitted_seq_idx" },
  372. { name: "session_pending_session_compaction_idx" },
  373. { name: "session_pending_session_delivery_seq_idx" },
  374. ])
  375. }),
  376. )
  377. })
  378. test("rejects a non-empty database without a session table", async () => {
  379. await expect(
  380. run(
  381. Effect.gen(function* () {
  382. const db = yield* makeDb
  383. yield* db.run(sql`CREATE TABLE unrelated (id text PRIMARY KEY)`)
  384. yield* DatabaseMigration.apply(db)
  385. }),
  386. ),
  387. ).rejects.toThrow("Database is not empty and has no session table")
  388. })
  389. test("makes session titles nullable without deleting dependent rows", async () => {
  390. await run(
  391. Effect.gen(function* () {
  392. const db = yield* makeDb
  393. yield* db.run(sql`PRAGMA foreign_keys = ON`)
  394. yield* db.run(sql`
  395. CREATE TABLE session (
  396. id text PRIMARY KEY,
  397. title text NOT NULL
  398. )
  399. `)
  400. yield* db.run(sql`
  401. CREATE TABLE message (
  402. id text PRIMARY KEY,
  403. session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE
  404. )
  405. `)
  406. yield* db.run(sql`INSERT INTO session VALUES ('ses_existing', 'Existing title')`)
  407. yield* db.run(sql`INSERT INTO message VALUES ('msg_existing', 'ses_existing')`)
  408. yield* DatabaseMigration.applyOnly(db, [optionalSessionTitleMigration])
  409. expect(yield* db.get(sql`SELECT title FROM session WHERE id = 'ses_existing'`)).toEqual({
  410. title: "Existing title",
  411. })
  412. expect(yield* db.get(sql`SELECT id FROM message WHERE id = 'msg_existing'`)).toEqual({ id: "msg_existing" })
  413. expect(
  414. yield* db.get<{ notnull: number }>(sql`SELECT "notnull" FROM pragma_table_info('session') WHERE name = 'title'`),
  415. ).toEqual({ notnull: 0 })
  416. expect(yield* db.get<{ foreign_keys: number }>(sql`PRAGMA foreign_keys`)).toEqual({ foreign_keys: 1 })
  417. }),
  418. )
  419. })
  420. test("backfills existing Context Epoch rows to the build agent", async () => {
  421. await run(
  422. Effect.gen(function* () {
  423. const db = yield* makeDb
  424. yield* db.run(
  425. sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL, replacement_seq integer, revision integer DEFAULT 0 NOT NULL)`,
  426. )
  427. yield* db.run(
  428. sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('ses_existing', 'baseline', '{}', 0)`,
  429. )
  430. yield* DatabaseMigration.applyOnly(db, [contextEpochAgentMigration])
  431. expect(yield* db.get(sql`SELECT agent FROM session_context_epoch WHERE session_id = 'ses_existing'`)).toEqual({
  432. agent: "build",
  433. })
  434. }),
  435. )
  436. })
  437. test("separates existing fork provenance from subagent hierarchy", async () => {
  438. await run(
  439. Effect.gen(function* () {
  440. const db = yield* makeDb
  441. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, parent_id text)`)
  442. yield* db.run(
  443. sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
  444. )
  445. yield* db.run(sql`INSERT INTO session VALUES ('ses_source', NULL), ('ses_fork', 'ses_source')`)
  446. yield* db.run(
  447. sql`INSERT INTO event VALUES ('ses_fork', 0, 'session.forked', '{"sessionID":"ses_fork","parentID":"ses_source","from":"msg_boundary"}')`,
  448. )
  449. yield* DatabaseMigration.applyOnly(db, [addSessionForkMigration])
  450. expect(
  451. yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_fork'`),
  452. ).toEqual({
  453. parent_id: null,
  454. fork_session_id: "ses_source",
  455. fork_message_id: "msg_boundary",
  456. })
  457. expect(
  458. yield* db.get(sql`SELECT parent_id, fork_session_id, fork_message_id FROM session WHERE id = 'ses_source'`),
  459. ).toEqual({
  460. parent_id: null,
  461. fork_session_id: null,
  462. fork_message_id: null,
  463. })
  464. }),
  465. )
  466. })
  467. test("does not infer restart continuation from historical shutdown events", async () => {
  468. await run(
  469. Effect.gen(function* () {
  470. const db = yield* makeDb
  471. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
  472. yield* db.run(
  473. sql`CREATE TABLE event (aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
  474. )
  475. yield* db.run(sql`INSERT INTO session VALUES ('ses_shutdown')`)
  476. yield* db.run(
  477. sql`INSERT INTO event VALUES ('ses_shutdown', 0, 'session.execution.interrupted.1', '{"reason":"shutdown"}')`,
  478. )
  479. yield* DatabaseMigration.applyOnly(db, [timeSuspendedMigration])
  480. expect(yield* db.get(sql`SELECT time_suspended FROM session WHERE id = 'ses_shutdown'`)).toEqual({
  481. time_suspended: null,
  482. })
  483. }),
  484. )
  485. })
  486. test("renames instruction state without losing rows or durable updates", async () => {
  487. await run(
  488. Effect.gen(function* () {
  489. const db = yield* makeDb
  490. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
  491. yield* db.run(
  492. sql`CREATE TABLE session_context_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`,
  493. )
  494. yield* db.run(
  495. sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY, baseline text NOT NULL, snapshot text NOT NULL, baseline_seq integer NOT NULL)`,
  496. )
  497. yield* db.run(sql`CREATE TABLE event (type text NOT NULL)`)
  498. yield* db.run(sql`INSERT INTO session_context_entry VALUES ('ses_test', 'plan', '"ready"', 1, 2)`)
  499. yield* db.run(sql`INSERT INTO session_context_epoch VALUES ('ses_test', 'baseline', '{}', 7)`)
  500. yield* db.run(sql`INSERT INTO event VALUES ('session.context.updated.1')`)
  501. yield* DatabaseMigration.applyOnly(db, [renameInstructionsMigration])
  502. expect(yield* db.get(sql`SELECT * FROM instruction_entry`)).toEqual({
  503. session_id: "ses_test",
  504. key: "plan",
  505. value: '"ready"',
  506. time_created: 1,
  507. time_updated: 2,
  508. })
  509. expect(yield* db.get(sql`SELECT * FROM instruction_checkpoint`)).toEqual({
  510. session_id: "ses_test",
  511. baseline: "baseline",
  512. snapshot: "{}",
  513. baseline_seq: 7,
  514. })
  515. expect(yield* db.get(sql`SELECT type FROM event`)).toEqual({ type: "session.instructions.updated.1" })
  516. }),
  517. )
  518. })
  519. test("deletes pre-beta instruction events and projected System messages", async () => {
  520. await run(
  521. Effect.gen(function* () {
  522. const db = yield* makeDb
  523. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, fork_session_id text)`)
  524. yield* db.run(
  525. sql`CREATE TABLE instruction_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`,
  526. )
  527. yield* db.run(sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY)`)
  528. yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
  529. yield* db.run(
  530. sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
  531. )
  532. yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, type text NOT NULL)`)
  533. yield* db.run(sql`INSERT INTO session VALUES ('ses_test', NULL)`)
  534. yield* db.run(
  535. sql`INSERT INTO event VALUES ('evt_instruction', 'ses_test', 0, 'session.instructions.updated.1', '{"sessionID":"ses_test","text":"changed"}')`,
  536. )
  537. yield* db.run(
  538. sql`INSERT INTO event VALUES ('evt_other', 'ses_test', 1, 'session.synthetic.1', '{"sessionID":"ses_test","text":"keep"}')`,
  539. )
  540. yield* db.run(
  541. sql`INSERT INTO session_message VALUES ('msg_instruction', 'system'), ('msg_other', 'system'), ('msg_user', 'user')`,
  542. )
  543. yield* db.run(sql`INSERT INTO instruction_entry VALUES ('ses_test', 'plan', '"ready"', 1, 2)`)
  544. yield* DatabaseMigration.applyOnly(db, [instructionSyncMigration])
  545. expect(yield* db.all(sql`SELECT id, type FROM event`)).toEqual([
  546. { id: "evt_other", type: "session.synthetic.1" },
  547. ])
  548. expect(yield* db.all(sql`SELECT id, type FROM session_message ORDER BY id`)).toEqual([
  549. { id: "msg_user", type: "user" },
  550. ])
  551. expect(yield* db.get(sql`SELECT * FROM instruction_entry`)).toEqual({
  552. session_id: "ses_test",
  553. key: "plan",
  554. value: '"ready"',
  555. removed: 0,
  556. time_created: 1,
  557. time_updated: 2,
  558. })
  559. expect(
  560. yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'instruction_checkpoint'`),
  561. ).toBeUndefined()
  562. }),
  563. )
  564. })
  565. test("deletes durable tool progress without changing aggregate sequence watermarks", async () => {
  566. await run(
  567. Effect.gen(function* () {
  568. const db = yield* makeDb
  569. yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
  570. yield* db.run(
  571. sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
  572. )
  573. yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_test', 5)`)
  574. yield* db.run(sql`INSERT INTO event VALUES ('evt_success', 'ses_test', 4, 'session.tool.success.1', '{}')`)
  575. yield* db.run(sql`INSERT INTO event VALUES ('evt_progress', 'ses_test', 5, 'session.tool.progress.1', '{}')`)
  576. yield* DatabaseMigration.applyOnly(db, [deleteToolProgressEventsMigration])
  577. expect(yield* db.all(sql`SELECT id, seq, type, data FROM event ORDER BY seq`)).toEqual([
  578. { id: "evt_success", seq: 4, type: "session.tool.success.1", data: "{}" },
  579. ])
  580. expect(yield* db.get(sql`SELECT aggregate_id, seq FROM event_sequence`)).toEqual({
  581. aggregate_id: "ses_test",
  582. seq: 5,
  583. })
  584. }),
  585. )
  586. })
  587. test("rewrites projected tool rows into the canonical result shape", async () => {
  588. await run(
  589. Effect.gen(function* () {
  590. const db = yield* makeDb
  591. yield* db.run(
  592. sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
  593. )
  594. yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, type text NOT NULL, data text NOT NULL)`)
  595. const assistant = {
  596. agent: "build",
  597. model: { id: "model", providerID: "provider" },
  598. content: [
  599. { type: "text", text: "before" },
  600. {
  601. type: "tool",
  602. id: "call_content",
  603. name: "grep",
  604. state: {
  605. status: "completed",
  606. input: { pattern: "TODO" },
  607. content: [{ type: "text", text: "src/a.ts:1: TODO" }],
  608. structured: { value: [{ file: "src/a.ts", line: 1 }] },
  609. },
  610. time: { created: 1, completed: 2 },
  611. },
  612. {
  613. type: "tool",
  614. id: "call_structured_only",
  615. name: "read",
  616. state: {
  617. status: "completed",
  618. input: { path: "README.md" },
  619. content: [],
  620. structured: { text: "hello" },
  621. },
  622. time: { created: 1, completed: 2 },
  623. },
  624. {
  625. type: "tool",
  626. id: "call_hosted",
  627. name: "web_search",
  628. executed: true,
  629. providerResultState: { blockType: "web_search_tool_result" },
  630. state: {
  631. status: "completed",
  632. input: { query: "effect" },
  633. content: [],
  634. structured: {},
  635. result: { type: "json", value: [{ url: "https://example.com" }] },
  636. },
  637. time: { created: 1, completed: 2 },
  638. },
  639. {
  640. type: "tool",
  641. id: "call_failed",
  642. name: "shell",
  643. state: {
  644. status: "error",
  645. input: { command: "sleep 99" },
  646. error: { type: "tool.execution", message: "timed out" },
  647. content: [{ type: "text", text: "partial output" }],
  648. structured: { truncated: false },
  649. result: { type: "error", value: "timed out" },
  650. },
  651. time: { created: 1, completed: 2 },
  652. },
  653. {
  654. type: "tool",
  655. id: "call_running",
  656. name: "shell",
  657. state: {
  658. status: "running",
  659. input: { command: "sleep 1" },
  660. structured: { truncated: false },
  661. content: [{ type: "text", text: "tick" }],
  662. },
  663. time: { created: 1, ran: 2 },
  664. },
  665. ],
  666. time: { created: 1 },
  667. }
  668. yield* db.run(
  669. sql`INSERT INTO session_message VALUES ('msg_tools', 'ses_test', 'assistant', 1, 10, 11, ${JSON.stringify(assistant)})`,
  670. )
  671. yield* db.run(
  672. sql`INSERT INTO session_message VALUES ('msg_user', 'ses_test', 'user', 2, 12, 13, '{"text":"hi","time":{"created":1}}')`,
  673. )
  674. // A row that never decoded must be skipped, not fail the migration.
  675. yield* db.run(
  676. sql`INSERT INTO session_message VALUES ('msg_corrupt', 'ses_test', 'assistant', 3, 14, 15, 'not json')`,
  677. )
  678. yield* db.run(
  679. sql`INSERT INTO event VALUES ('evt_success', 'session.tool.success.1', ${JSON.stringify({
  680. sessionID: "ses_test",
  681. assistantMessageID: "msg_tools",
  682. id: "call_hosted",
  683. structured: {},
  684. content: [],
  685. result: { type: "json", value: [{ url: "https://example.com" }] },
  686. executed: true,
  687. })})`,
  688. )
  689. yield* db.run(
  690. sql`INSERT INTO event VALUES ('evt_failed', 'session.tool.failed.1', ${JSON.stringify({
  691. sessionID: "ses_test",
  692. assistantMessageID: "msg_tools",
  693. id: "call_failed",
  694. error: { type: "tool.execution", message: "timed out" },
  695. metadata: { truncated: false },
  696. executed: false,
  697. })})`,
  698. )
  699. yield* DatabaseMigration.applyOnly(db, [canonicalToolResultsMigration])
  700. const row = yield* db.get<{ data: string }>(sql`SELECT data FROM session_message WHERE id = 'msg_tools'`)
  701. const migrated = JSON.parse(row!.data)
  702. // Every migrated row must decode with the current schema; reload hard-fails otherwise.
  703. Schema.decodeUnknownSync(SessionMessage.Info)({ ...migrated, id: "msg_tools", type: "assistant" })
  704. const states = new Map(
  705. migrated.content.flatMap((part: { type: string; id?: string }) =>
  706. part.type === "tool" ? [[part.id, part]] : [],
  707. ),
  708. )
  709. expect(states.get("call_content")).toMatchObject({
  710. state: {
  711. status: "completed",
  712. input: { pattern: "TODO" },
  713. content: [{ type: "text", text: "src/a.ts:1: TODO" }],
  714. // Old generic structured payloads survive as canonical metadata.
  715. metadata: { value: [{ file: "src/a.ts", line: 1 }] },
  716. },
  717. })
  718. expect((states.get("call_content") as { state: Record<string, unknown> }).state).not.toHaveProperty(
  719. "structured",
  720. )
  721. expect(states.get("call_structured_only")).toMatchObject({
  722. state: {
  723. status: "completed",
  724. content: [{ type: "text", text: JSON.stringify({ text: "hello" }, null, 2) }],
  725. metadata: { text: "hello" },
  726. },
  727. })
  728. expect(states.get("call_hosted")).toMatchObject({
  729. executed: true,
  730. providerResultState: {
  731. blockType: "web_search_tool_result",
  732. result: [{ url: "https://example.com" }],
  733. },
  734. state: {
  735. status: "completed",
  736. content: [{ type: "text", text: JSON.stringify([{ url: "https://example.com" }], null, 2) }],
  737. },
  738. })
  739. expect(states.get("call_failed")).toMatchObject({
  740. state: {
  741. status: "error",
  742. error: { type: "tool.execution", message: "timed out" },
  743. content: [{ type: "text", text: "partial output" }],
  744. metadata: { truncated: false },
  745. },
  746. })
  747. const failedState = (states.get("call_failed") as { state: Record<string, unknown> }).state
  748. expect(failedState).not.toHaveProperty("result")
  749. expect(failedState).not.toHaveProperty("structured")
  750. expect(states.get("call_running")).toMatchObject({
  751. state: {
  752. status: "running",
  753. metadata: { truncated: false },
  754. },
  755. })
  756. const event = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_success'`)
  757. expect(event!.type).toBe("session.tool.success.1")
  758. expect(JSON.parse(event!.data)).toEqual({
  759. sessionID: "ses_test",
  760. assistantMessageID: "msg_tools",
  761. id: "call_hosted",
  762. structured: {},
  763. content: [],
  764. result: { type: "json", value: [{ url: "https://example.com" }] },
  765. executed: true,
  766. })
  767. const failedEvent = yield* db.get<{ type: string; data: string }>(sql`SELECT type, data FROM event WHERE id = 'evt_failed'`)
  768. expect(failedEvent!.type).toBe("session.tool.failed.1")
  769. expect(JSON.parse(failedEvent!.data)).toEqual({
  770. sessionID: "ses_test",
  771. assistantMessageID: "msg_tools",
  772. id: "call_failed",
  773. error: { type: "tool.execution", message: "timed out" },
  774. metadata: { truncated: false },
  775. executed: false,
  776. })
  777. expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_user'`)).toEqual({
  778. data: '{"text":"hi","time":{"created":1}}',
  779. })
  780. expect(yield* db.get(sql`SELECT data FROM session_message WHERE id = 'msg_corrupt'`)).toEqual({
  781. data: "not json",
  782. })
  783. }),
  784. )
  785. })
  786. test("records the authoritative parent sequence on existing forks", async () => {
  787. await run(
  788. Effect.gen(function* () {
  789. const db = yield* makeDb
  790. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, fork_session_id text)`)
  791. yield* db.run(
  792. sql`CREATE TABLE instruction_entry (session_id text NOT NULL, key text NOT NULL, value text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, PRIMARY KEY(session_id, key))`,
  793. )
  794. yield* db.run(sql`CREATE TABLE instruction_checkpoint (session_id text PRIMARY KEY)`)
  795. yield* db.run(sql`CREATE TABLE session_message (id text PRIMARY KEY, type text NOT NULL)`)
  796. yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
  797. yield* db.run(
  798. sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
  799. )
  800. yield* db.run(sql`INSERT INTO session VALUES ('ses_child', 'ses_parent')`)
  801. yield* db.run(sql`INSERT INTO event_sequence VALUES ('ses_child', 8)`)
  802. yield* db.run(
  803. sql`INSERT INTO event VALUES ('evt_fork', 'ses_child', 0, 'session.forked.1', '{"sessionID":"ses_child","parentID":"ses_parent"}')`,
  804. )
  805. yield* db.run(
  806. sql`INSERT INTO event VALUES ('evt_instruction', 'ses_child', 5, 'session.instructions.updated.1', '{"sessionID":"ses_child","text":"changed"}')`,
  807. )
  808. yield* db.run(sql`INSERT INTO event VALUES ('evt_input', 'ses_child', 6, 'session.input.admitted.1', '{}')`)
  809. yield* DatabaseMigration.applyOnly(db, [instructionSyncMigration])
  810. expect(yield* db.get(sql`SELECT fork_seq FROM session`)).toEqual({ fork_seq: 4 })
  811. expect(yield* db.get(sql`SELECT type, data FROM event WHERE seq = 0`)).toEqual({
  812. type: "session.forked.2",
  813. data: '{"sessionID":"ses_child","parentID":"ses_parent","parentSeq":4}',
  814. })
  815. expect(yield* db.get(sql`SELECT id FROM event WHERE id = 'evt_instruction'`)).toBeUndefined()
  816. }),
  817. )
  818. })
  819. test("keeps legacy credential fields nullable", async () => {
  820. await run(
  821. Effect.gen(function* () {
  822. const db = yield* makeDb
  823. yield* db.run(
  824. sql`CREATE TABLE credential (id text PRIMARY KEY, connector_id text NOT NULL, method_id text NOT NULL, label text NOT NULL, value text NOT NULL, active integer DEFAULT false NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL)`,
  825. )
  826. yield* db.run(
  827. sql`CREATE UNIQUE INDEX credential_connector_active_idx ON credential (connector_id) WHERE active = 1`,
  828. )
  829. yield* DatabaseMigration.applyOnly(db, [simplifyIntegrationCredentialsMigration])
  830. yield* db.run(
  831. sql`INSERT INTO credential (id, connector_id, method_id, label, value, active, time_created, time_updated) VALUES ('legacy', 'openai', 'oauth', 'Legacy', '{}', 1, 1, 1)`,
  832. )
  833. yield* db.run(
  834. sql`INSERT INTO credential (id, integration_id, label, value, time_created, time_updated) VALUES ('current', 'anthropic', 'Current', '{}', 2, 2)`,
  835. )
  836. expect(yield* db.get(sql`SELECT connector_id, method_id, active FROM credential WHERE id = 'current'`)).toEqual(
  837. { connector_id: null, method_id: null, active: null },
  838. )
  839. }),
  840. )
  841. })
  842. test("resets beta history and rebuilds event-sourced Session input storage", async () => {
  843. await run(
  844. Effect.gen(function* () {
  845. const db = yield* makeDb
  846. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`)
  847. yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`)
  848. yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY)`)
  849. yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY)`)
  850. yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
  851. yield* db.run(
  852. sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
  853. )
  854. yield* db.run(sql`CREATE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq)`)
  855. yield* db.run(sql`CREATE INDEX event_aggregate_type_seq_idx ON event (aggregate_id, type, seq)`)
  856. yield* db.run(
  857. sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, seq integer NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
  858. )
  859. yield* db.run(sql`CREATE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`)
  860. yield* db.run(
  861. sql`CREATE TABLE session_input (seq integer PRIMARY KEY AUTOINCREMENT, id text NOT NULL UNIQUE, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
  862. )
  863. yield* db.run(
  864. sql`CREATE INDEX session_input_session_pending_delivery_seq_idx ON session_input (session_id, promoted_seq, delivery, seq)`,
  865. )
  866. yield* db.run(sql`INSERT INTO session (id, workspace_id) VALUES ('session', 'wrk_old')`)
  867. yield* db.run(sql`INSERT INTO workspace (id) VALUES ('wrk_old')`)
  868. yield* db.run(sql`INSERT INTO message (id) VALUES ('message')`)
  869. yield* db.run(sql`INSERT INTO part (id) VALUES ('part')`)
  870. yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 0)`)
  871. yield* db.run(
  872. sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_old', 'session', 0, 'old.1', '{}')`,
  873. )
  874. yield* db.run(
  875. sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_old', 'session', 'user', 0, 1, 1, '{}')`,
  876. )
  877. yield* db.run(
  878. sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`,
  879. )
  880. yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionPendingMigration])
  881. expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([
  882. { id: "session", workspace_id: null },
  883. ])
  884. expect(yield* db.all(sql`SELECT id FROM workspace`)).toEqual([])
  885. expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "message" }])
  886. expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "part" }])
  887. expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([])
  888. expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([])
  889. expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
  890. expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([])
  891. expect(
  892. (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_input)`)).map((column) => column.name),
  893. ).toEqual(["id", "session_id", "prompt", "delivery", "admitted_seq", "promoted_seq", "time_created"])
  894. expect(
  895. (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_message)`)).find(
  896. (index) => index.name === "session_message_session_seq_idx",
  897. ),
  898. ).toMatchObject({ unique: 1 })
  899. expect(
  900. (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(event)`)).find(
  901. (index) => index.name === "event_aggregate_seq_idx",
  902. ),
  903. ).toMatchObject({ unique: 1 })
  904. expect(
  905. (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_input)`)).filter((index) =>
  906. ["session_input_session_admitted_seq_idx", "session_input_session_promoted_seq_idx"].includes(index.name),
  907. ),
  908. ).toEqual([
  909. expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }),
  910. expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }),
  911. ])
  912. }),
  913. )
  914. })
  915. test("preserves canonical V1 state and restarts its event stream", async () => {
  916. await run(
  917. Effect.gen(function* () {
  918. const db = yield* makeDb
  919. yield* db.run(sql`PRAGMA foreign_keys = ON`)
  920. yield* DatabaseMigration.apply(db)
  921. yield* db.run(
  922. sql`INSERT INTO project (id, worktree, time_created, time_updated, sandboxes) VALUES ('global', '/project', 1, 1, '[]')`,
  923. )
  924. yield* db.run(
  925. sql`INSERT INTO workspace (id, type, project_id, time_used) VALUES ('workspace', 'local', 'global', 1)`,
  926. )
  927. yield* db.run(
  928. sql`INSERT INTO session (id, project_id, workspace_id, slug, directory, title, version, time_created, time_updated) VALUES ('session', 'global', 'workspace', 'session', '/project', 'Before', 'test', 1, 1)`,
  929. )
  930. yield* db.run(
  931. sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('message', 'session', 1, 1, '{}')`,
  932. )
  933. yield* db.run(
  934. sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('part', 'message', 'session', 1, 1, '{}')`,
  935. )
  936. yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 9)`)
  937. yield* db.run(
  938. sql`INSERT INTO event (id, aggregate_id, seq, type, data, created) VALUES ('event', 'session', 9, 'session.updated.1', '{}', 1)`,
  939. )
  940. yield* db.run(
  941. sql`INSERT INTO session_pending (id, session_id, type, data, delivery, admitted_seq, time_created) VALUES ('input', 'session', 'user', '{}', 'steer', 9, 1)`,
  942. )
  943. yield* db.run(
  944. sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('projected', 'session', 'user', 9, 1, 1, '{}')`,
  945. )
  946. yield* db.run(sql`CREATE TABLE session_context_epoch (session_id text PRIMARY KEY)`)
  947. // The partial compaction index embeds the qualified table name, so it
  948. // must drop before the historical rename dance and recreate after.
  949. yield* db.run(sql`DROP INDEX session_pending_session_compaction_idx`)
  950. yield* db.run(sql`ALTER TABLE session_pending RENAME TO session_input`)
  951. yield* db.run(sql`DELETE FROM migration WHERE id = ${simplifySessionPendingMigration.id}`)
  952. yield* DatabaseMigration.applyOnly(db, [simplifySessionPendingMigration])
  953. yield* db.run(sql`DROP TABLE session_context_epoch`)
  954. yield* db.run(sql`ALTER TABLE session_input RENAME TO session_pending`)
  955. yield* db.run(
  956. sql`CREATE UNIQUE INDEX session_pending_session_compaction_idx ON session_pending (session_id) WHERE "session_pending"."type" = 'compaction'`,
  957. )
  958. const database = Layer.succeed(Database.Service, { db })
  959. yield* Bus.Service.use((service) =>
  960. service.publish(SessionV1.Event.Updated, {
  961. sessionID: SessionSchema.ID.make("session"),
  962. info: {
  963. id: SessionSchema.ID.make("session"),
  964. slug: "session",
  965. projectID: Project.ID.global,
  966. directory: "/project",
  967. title: "After",
  968. version: "test",
  969. time: { created: 1, updated: 2 },
  970. },
  971. }),
  972. ).pipe(
  973. Effect.provide(
  974. AppNodeBuilder.build(LayerNode.group([Bus.node, SessionProjector.node]), [[Database.node, database]]),
  975. ),
  976. )
  977. expect(
  978. yield* db.get(sql`
  979. SELECT
  980. (SELECT title FROM session WHERE id = 'session') AS title,
  981. (SELECT workspace_id FROM session WHERE id = 'session') AS workspaceID,
  982. (SELECT COUNT(*) FROM message WHERE id = 'message') AS messages,
  983. (SELECT COUNT(*) FROM part WHERE id = 'part') AS parts,
  984. (SELECT COUNT(*) FROM workspace) AS workspaces,
  985. (SELECT COUNT(*) FROM session_pending) AS sessionInputs,
  986. (SELECT COUNT(*) FROM session_message) AS sessionMessages,
  987. (SELECT COUNT(*) FROM instruction_state) AS instructionStates,
  988. (SELECT seq FROM event_sequence WHERE aggregate_id = 'session') AS seq,
  989. (SELECT type FROM event WHERE aggregate_id = 'session') AS eventType
  990. `),
  991. ).toEqual({
  992. title: "After",
  993. workspaceID: null,
  994. messages: 1,
  995. parts: 1,
  996. workspaces: 0,
  997. sessionInputs: 0,
  998. sessionMessages: 0,
  999. instructionStates: 0,
  1000. seq: 0,
  1001. eventType: "session.updated.1",
  1002. })
  1003. }),
  1004. )
  1005. })
  1006. test("preserves admitted prompts while generalizing the durable inbox", async () => {
  1007. await run(
  1008. Effect.gen(function* () {
  1009. const db = yield* makeDb
  1010. yield* db.run(
  1011. sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, prompt text NOT NULL, delivery text NOT NULL, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
  1012. )
  1013. yield* db.run(
  1014. sql`INSERT INTO session_input (id, session_id, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', '{"text":"hello"}', 'steer', 4, NULL, 1)`,
  1015. )
  1016. yield* DatabaseMigration.applyOnly(db, [durableSessionInboxMigration])
  1017. expect(
  1018. yield* db.all(
  1019. sql`SELECT id, type, prompt, delivery, admitted_seq, promoted_seq FROM session_input ORDER BY admitted_seq`,
  1020. ),
  1021. ).toEqual([
  1022. {
  1023. id: "input",
  1024. type: "prompt",
  1025. prompt: '{"text":"hello"}',
  1026. delivery: "steer",
  1027. admitted_seq: 4,
  1028. promoted_seq: null,
  1029. },
  1030. ])
  1031. }),
  1032. )
  1033. })
  1034. test("migrates prompt inbox rows and lifecycle events to generic user input", async () => {
  1035. await run(
  1036. Effect.gen(function* () {
  1037. const db = yield* makeDb
  1038. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
  1039. yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
  1040. yield* db.run(
  1041. sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, prompt text, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
  1042. )
  1043. yield* db.run(
  1044. sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('input', 'session', 'prompt', '{"text":"hello"}', 'queue', 4, NULL, 1)`,
  1045. )
  1046. yield* db.run(
  1047. sql`INSERT INTO session_input (id, session_id, type, prompt, delivery, admitted_seq, promoted_seq, time_created) VALUES ('empty', 'session', 'prompt', NULL, 'steer', 6, NULL, 2)`,
  1048. )
  1049. yield* db.run(
  1050. sql`CREATE TABLE event (id text PRIMARY KEY, aggregate_id text NOT NULL, seq integer NOT NULL, created integer NOT NULL, type text NOT NULL, data text NOT NULL)`,
  1051. )
  1052. yield* db.run(
  1053. sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('admitted', 'session', 4, 1, 'session.prompt.admitted.1', '{"sessionID":"session","inputID":"input","prompt":{"text":"hello"},"delivery":"queue"}')`,
  1054. )
  1055. yield* db.run(
  1056. sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('promoted', 'session', 5, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"input"}')`,
  1057. )
  1058. yield* db.run(
  1059. sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-admitted', 'session', 6, 2, 'session.prompt.admitted.1', '{"sessionID":"session","inputID":"empty","prompt":null,"delivery":"steer"}')`,
  1060. )
  1061. yield* db.run(
  1062. sql`INSERT INTO event (id, aggregate_id, seq, created, type, data) VALUES ('empty-promoted', 'session', 7, 2, 'session.prompt.promoted.1', '{"sessionID":"session","inputID":"empty"}')`,
  1063. )
  1064. yield* DatabaseMigration.applyOnly(db, [genericSessionPendingMigration])
  1065. expect(yield* db.all(sql`SELECT id, type, data, delivery FROM session_input ORDER BY admitted_seq`)).toEqual([
  1066. { id: "input", type: "user", data: '{"text":"hello"}', delivery: "queue" },
  1067. ])
  1068. expect(yield* db.all(sql`SELECT type, data FROM event ORDER BY seq`)).toEqual([
  1069. {
  1070. type: "session.input.admitted.1",
  1071. data: '{"sessionID":"session","inputID":"input","input":{"type":"user","data":{"text":"hello"},"delivery":"queue"}}',
  1072. },
  1073. {
  1074. type: "session.input.promoted.1",
  1075. data: '{"sessionID":"session","inputID":"input"}',
  1076. },
  1077. ])
  1078. }),
  1079. )
  1080. })
  1081. test("replaces the durable inbox with the empty session_pending table", async () => {
  1082. await run(
  1083. Effect.gen(function* () {
  1084. const db = yield* makeDb
  1085. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
  1086. yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
  1087. yield* db.run(
  1088. sql`CREATE TABLE session_input (id text PRIMARY KEY, session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE, type text NOT NULL, data text NOT NULL, delivery text, admitted_seq integer NOT NULL, promoted_seq integer, time_created integer NOT NULL)`,
  1089. )
  1090. // Interim v2 builds shipped differing index sets on real databases;
  1091. // dropping the table removes whatever variant exists.
  1092. yield* db.run(
  1093. sql`CREATE INDEX session_input_session_pending_type_delivery_seq_idx ON session_input (session_id, promoted_seq, type, delivery, admitted_seq)`,
  1094. )
  1095. yield* db.run(
  1096. sql`INSERT INTO session_input (id, session_id, type, data, delivery, admitted_seq, promoted_seq, time_created) VALUES ('pending', 'session', 'user', '{"text":"hello"}', 'steer', 4, NULL, 1)`,
  1097. )
  1098. yield* DatabaseMigration.applyOnly(db, [sessionPendingTableMigration])
  1099. expect(
  1100. yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
  1101. ).toBeUndefined()
  1102. expect(yield* db.all(sql`SELECT id FROM session_pending`)).toEqual([])
  1103. expect(
  1104. (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_pending)`)).map((column) => column.name),
  1105. ).toEqual(["id", "session_id", "type", "data", "delivery", "admitted_seq", "time_created"])
  1106. expect(
  1107. (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_pending)`))
  1108. .filter((index) => index.name.startsWith("session_"))
  1109. .map((index) => ({ name: index.name, unique: index.unique }))
  1110. .sort((a, b) => a.name.localeCompare(b.name)),
  1111. ).toEqual([
  1112. { name: "session_pending_session_admitted_seq_idx", unique: 1 },
  1113. { name: "session_pending_session_compaction_idx", unique: 1 },
  1114. { name: "session_pending_session_delivery_seq_idx", unique: 0 },
  1115. ])
  1116. }),
  1117. )
  1118. })
  1119. test("resets incompatible projected Session messages before adding sequence order", async () => {
  1120. await run(
  1121. Effect.gen(function* () {
  1122. const db = yield* makeDb
  1123. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
  1124. yield* db.run(
  1125. sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
  1126. )
  1127. yield* db.run(
  1128. sql`CREATE TABLE part (id text PRIMARY KEY, message_id text NOT NULL, session_id text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
  1129. )
  1130. yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`)
  1131. yield* db.run(
  1132. sql`CREATE TABLE session_message (id text PRIMARY KEY, session_id text NOT NULL, type text NOT NULL, time_created integer NOT NULL, time_updated integer NOT NULL, data text NOT NULL)`,
  1133. )
  1134. yield* db.run(
  1135. sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`,
  1136. )
  1137. yield* db.run(
  1138. sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`,
  1139. )
  1140. yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
  1141. yield* db.run(
  1142. sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{"role":"user"}')`,
  1143. )
  1144. yield* db.run(
  1145. sql`INSERT INTO part (id, message_id, session_id, time_created, time_updated, data) VALUES ('legacy_part', 'legacy_message', 'session', 1, 1, '{"type":"text","text":"hello"}')`,
  1146. )
  1147. yield* db.run(
  1148. sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('stale_projection', 'session', 'user', 1, 1, '{}')`,
  1149. )
  1150. yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration])
  1151. expect(yield* db.all(sql`SELECT id, session_id, data FROM message`)).toEqual([
  1152. { id: "legacy_message", session_id: "session", data: '{"role":"user"}' },
  1153. ])
  1154. expect(yield* db.all(sql`SELECT id, message_id, session_id, data FROM part`)).toEqual([
  1155. {
  1156. id: "legacy_part",
  1157. message_id: "legacy_message",
  1158. session_id: "session",
  1159. data: '{"type":"text","text":"hello"}',
  1160. },
  1161. ])
  1162. expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
  1163. yield* db.run(
  1164. sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('fresh_projection', 'session', 'user', 7, 2, 2, '{}')`,
  1165. )
  1166. expect(yield* db.get(sql`SELECT id, seq FROM session_message`)).toEqual({ id: "fresh_projection", seq: 7 })
  1167. }),
  1168. )
  1169. })
  1170. test("runs session usage backfill in order with schema changes", async () => {
  1171. await run(
  1172. Effect.gen(function* () {
  1173. const db = yield* makeDb
  1174. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, time_updated integer NOT NULL)`)
  1175. yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`)
  1176. yield* db.run(sql`INSERT INTO session (id, time_updated) VALUES ('session_1', 1)`)
  1177. yield* db.run(
  1178. sql`INSERT INTO message (id, session_id, data) VALUES ('message_1', 'session_1', '{"role":"assistant","cost":1.25,"tokens":{"input":2,"output":3,"reasoning":4,"cache":{"read":5,"write":6}}}')`,
  1179. )
  1180. yield* DatabaseMigration.applyOnly(db, [sessionUsageMigration])
  1181. expect(
  1182. yield* db.get(
  1183. sql`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE id = 'session_1'`,
  1184. ),
  1185. ).toEqual({
  1186. cost: 1.25,
  1187. tokens_input: 2,
  1188. tokens_output: 3,
  1189. tokens_reasoning: 4,
  1190. tokens_cache_read: 5,
  1191. tokens_cache_write: 6,
  1192. })
  1193. }),
  1194. )
  1195. })
  1196. test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
  1197. await run(
  1198. Effect.gen(function* () {
  1199. const db = yield* makeDb
  1200. yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
  1201. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
  1202. // Windows-shaped rows (drive + backslash) must be normalized.
  1203. yield* db.run(
  1204. sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([
  1205. "C:\\Repo\\Thing\\sandbox",
  1206. ])})`,
  1207. )
  1208. yield* db.run(
  1209. sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`,
  1210. )
  1211. // UNC worktrees and their sandboxes must normalize too (not just drive paths).
  1212. yield* db.run(
  1213. sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([
  1214. "\\\\server\\share\\sandbox",
  1215. ])})`,
  1216. )
  1217. // The "/" worktree sentinel and POSIX paths (including a pathological
  1218. // backslash in a POSIX filename) must survive byte-for-byte.
  1219. yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`)
  1220. yield* db.run(
  1221. sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`,
  1222. )
  1223. yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration])
  1224. expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({
  1225. worktree: "C:/Repo/Thing",
  1226. sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
  1227. })
  1228. expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({
  1229. directory: "C:/Repo/Thing/packages/api",
  1230. path: "packages/api",
  1231. })
  1232. expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({
  1233. worktree: "//server/share",
  1234. sandboxes: JSON.stringify(["//server/share/sandbox"]),
  1235. })
  1236. expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" })
  1237. expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({
  1238. directory: "/home/me/we\\ird",
  1239. path: "src\\weird",
  1240. })
  1241. }),
  1242. )
  1243. })
  1244. test("maps native Windows paths through database columns", async () => {
  1245. if (process.platform !== "win32") return
  1246. await run(
  1247. Effect.gen(function* () {
  1248. const db = yield* makeDb
  1249. yield* DatabaseMigration.apply(db)
  1250. const projectID = Project.ID.make("codec_project")
  1251. const worktree = AbsolutePath.make("C:\\Repo\\Thing")
  1252. const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox")
  1253. const directory = "C:\\Repo\\Thing\\packages\\api"
  1254. const sessionID = SessionSchema.ID.make("ses_codec")
  1255. expect(() =>
  1256. Effect.runSync(
  1257. db
  1258. .insert(ProjectTable)
  1259. .values({
  1260. id: Project.ID.make("invalid_path"),
  1261. worktree: AbsolutePath.make("not-absolute"),
  1262. sandboxes: [],
  1263. time_created: 1,
  1264. time_updated: 1,
  1265. })
  1266. .run(),
  1267. ),
  1268. ).toThrow()
  1269. yield* db
  1270. .insert(ProjectTable)
  1271. .values({
  1272. id: projectID,
  1273. worktree,
  1274. sandboxes: [sandbox],
  1275. time_created: 1,
  1276. time_updated: 1,
  1277. })
  1278. .run()
  1279. yield* db
  1280. .insert(SessionTable)
  1281. .values({
  1282. id: sessionID,
  1283. project_id: projectID,
  1284. slug: "codec",
  1285. directory,
  1286. path: "packages\\api",
  1287. title: "Codec",
  1288. version: "test",
  1289. time_created: 1,
  1290. time_updated: 1,
  1291. })
  1292. .run()
  1293. expect(
  1294. yield* db.get<{ worktree: string; sandboxes: string }>(
  1295. sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`,
  1296. ),
  1297. ).toEqual({
  1298. worktree: "C:/Repo/Thing",
  1299. sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
  1300. })
  1301. expect(
  1302. yield* db.get<{ directory: string; path: string }>(
  1303. sql`SELECT directory, path FROM session WHERE id = ${sessionID}`,
  1304. ),
  1305. ).toEqual({
  1306. directory: "C:/Repo/Thing/packages/api",
  1307. path: "packages/api",
  1308. })
  1309. const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get()
  1310. const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get()
  1311. expect(project?.worktree).toBe(worktree)
  1312. expect(project?.sandboxes).toEqual([sandbox])
  1313. expect(session?.directory).toBe(directory)
  1314. expect(session?.path).toBe("packages/api")
  1315. expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe(
  1316. sessionID,
  1317. )
  1318. const moved = AbsolutePath.make("D:\\Moved\\Thing")
  1319. const updated = yield* db
  1320. .update(ProjectTable)
  1321. .set({ worktree: moved, sandboxes: [moved] })
  1322. .where(eq(ProjectTable.id, projectID))
  1323. .returning()
  1324. .get()
  1325. expect(updated?.worktree).toBe(moved)
  1326. expect(updated?.sandboxes).toEqual([moved])
  1327. expect(
  1328. yield* db.get<{ worktree: string; sandboxes: string }>(
  1329. sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`,
  1330. ),
  1331. ).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) })
  1332. expect(
  1333. (yield* db
  1334. .select()
  1335. .from(ProjectTable)
  1336. .where(inArray(ProjectTable.worktree, [moved]))
  1337. .get())?.id,
  1338. ).toBe(projectID)
  1339. yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`)
  1340. expect(() =>
  1341. Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
  1342. ).toThrow()
  1343. }),
  1344. )
  1345. })
  1346. test("imports existing drizzle migration state", async () => {
  1347. await run(
  1348. Effect.gen(function* () {
  1349. const db = yield* makeDb
  1350. yield* db.run(
  1351. sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
  1352. )
  1353. yield* db.run(sql`
  1354. INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at)
  1355. VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()})
  1356. `)
  1357. yield* DatabaseMigration.applyOnly(db, [])
  1358. expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" })
  1359. }),
  1360. )
  1361. })
  1362. test("does not replay a migrated session metadata column", async () => {
  1363. await run(
  1364. Effect.gen(function* () {
  1365. const db = yield* makeDb
  1366. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
  1367. yield* db.run(
  1368. sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
  1369. )
  1370. yield* db.run(sql`
  1371. INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at)
  1372. VALUES ('hash', 1, '20260511173437_session-metadata', ${new Date().toISOString()})
  1373. `)
  1374. yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
  1375. expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
  1376. }),
  1377. )
  1378. })
  1379. test("accepts the temporary replacement session metadata migration id", async () => {
  1380. await run(
  1381. Effect.gen(function* () {
  1382. const db = yield* makeDb
  1383. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
  1384. yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
  1385. yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('20260530232709_lovely_romulus', 1)`)
  1386. yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
  1387. expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([
  1388. { id: "20260511173437_session-metadata" },
  1389. { id: "20260530232709_lovely_romulus" },
  1390. ])
  1391. }),
  1392. )
  1393. })
  1394. test("skips drizzle import when migration table already has state", async () => {
  1395. await run(
  1396. Effect.gen(function* () {
  1397. const db = yield* makeDb
  1398. yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
  1399. yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`)
  1400. yield* db.run(
  1401. sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
  1402. )
  1403. yield* db.run(sql`
  1404. INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at)
  1405. VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()})
  1406. `)
  1407. yield* DatabaseMigration.applyOnly(db, [])
  1408. expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }])
  1409. }),
  1410. )
  1411. })
  1412. })