database-migration.test.ts 67 KB

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