database-migration.test.ts 58 KB

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