database-migration.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513
  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 } 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 eventSourcedSessionInputMigration 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 { ProjectV2 } from "@opencode-ai/core/project"
  17. import { ProjectTable } from "@opencode-ai/core/project/sql"
  18. import { AbsolutePath } from "@opencode-ai/core/schema"
  19. import { SessionSchema } from "@opencode-ai/core/session/schema"
  20. import { SessionTable } from "@opencode-ai/core/session/sql"
  21. import sessionMetadataMigration from "@opencode-ai/core/database/migration/20260511173437_session-metadata"
  22. import type { SqlClient as SqlClientService } from "effect/unstable/sql/SqlClient"
  23. import { Database } from "@opencode-ai/core/database/database"
  24. import { tmpdir } from "./fixture/tmpdir"
  25. const run = <A, E>(effect: Effect.Effect<A, E, SqlClientService>) =>
  26. Effect.runPromise(
  27. effect.pipe(Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })), Effect.scoped),
  28. )
  29. const makeDb = EffectDrizzleSqlite.makeWithDefaults()
  30. describe("DatabaseMigration", () => {
  31. test("serializes concurrent embedded initialization for one database path", async () => {
  32. await using tmp = await tmpdir()
  33. const filename = path.join(tmp.path, "embedded.sqlite")
  34. const layers = [Database.layerFromPath(filename), Database.layerFromPath(filename)]
  35. await Effect.runPromise(
  36. Effect.all(
  37. layers.map((layer) => Effect.scoped(Layer.build(layer))),
  38. { concurrency: "unbounded" },
  39. ),
  40. )
  41. })
  42. if (process.platform === "linux") {
  43. test("declared schema has no ungenerated migrations", async () => {
  44. const result = await $`bun ${fileURLToPath(new URL("../script/migration.ts", import.meta.url))} --check`
  45. .quiet()
  46. .nothrow()
  47. expect(result.exitCode, result.stderr.toString()).toBe(0)
  48. expect(result.stdout.toString()).toContain("No schema changes, nothing to migrate")
  49. }, 30_000)
  50. }
  51. test("applies tracked migrations to an empty database", async () => {
  52. await run(
  53. Effect.gen(function* () {
  54. const db = yield* makeDb
  55. yield* DatabaseMigration.apply(db)
  56. expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session'`)).toEqual({
  57. name: "session",
  58. })
  59. expect(
  60. yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_input'`),
  61. ).toEqual({ name: "session_input" })
  62. expect(
  63. yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_context_epoch'`),
  64. ).toEqual({ name: "session_context_epoch" })
  65. expect(
  66. yield* db.get(
  67. sql`SELECT name, dflt_value FROM pragma_table_info('session_context_epoch') WHERE name = 'agent'`,
  68. ),
  69. ).toEqual({ name: "agent", dflt_value: "'build'" })
  70. expect(yield* db.get(sql`SELECT count(*) as count FROM migration`)).toEqual({ count: migrations.length })
  71. expect(
  72. yield* db.all(
  73. 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_admitted_seq_idx', 'session_input_session_promoted_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`,
  74. ),
  75. ).toEqual([
  76. { name: "event_aggregate_seq_idx" },
  77. { name: "event_aggregate_type_seq_idx" },
  78. { name: "session_input_session_admitted_seq_idx" },
  79. { name: "session_input_session_pending_delivery_seq_idx" },
  80. { name: "session_input_session_promoted_seq_idx" },
  81. { name: "session_message_session_seq_idx" },
  82. { name: "session_message_session_time_created_id_idx" },
  83. { name: "session_message_session_type_seq_idx" },
  84. ])
  85. }),
  86. )
  87. })
  88. test("backfills existing Context Epoch rows to the build agent", async () => {
  89. await run(
  90. Effect.gen(function* () {
  91. const db = yield* makeDb
  92. yield* db.run(
  93. 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)`,
  94. )
  95. yield* db.run(
  96. sql`INSERT INTO session_context_epoch (session_id, baseline, snapshot, baseline_seq) VALUES ('ses_existing', 'baseline', '{}', 0)`,
  97. )
  98. yield* DatabaseMigration.applyOnly(db, [contextEpochAgentMigration])
  99. expect(yield* db.get(sql`SELECT agent FROM session_context_epoch WHERE session_id = 'ses_existing'`)).toEqual({
  100. agent: "build",
  101. })
  102. }),
  103. )
  104. })
  105. test("resets beta history and rebuilds event-sourced Session input storage", async () => {
  106. await run(
  107. Effect.gen(function* () {
  108. const db = yield* makeDb
  109. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, workspace_id text)`)
  110. yield* db.run(sql`CREATE TABLE workspace (id text PRIMARY KEY)`)
  111. yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY)`)
  112. yield* db.run(sql`CREATE TABLE part (id text PRIMARY KEY)`)
  113. yield* db.run(sql`CREATE TABLE event_sequence (aggregate_id text PRIMARY KEY, seq integer NOT NULL)`)
  114. yield* db.run(
  115. 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)`,
  116. )
  117. yield* db.run(sql`CREATE INDEX event_aggregate_seq_idx ON event (aggregate_id, seq)`)
  118. yield* db.run(sql`CREATE INDEX event_aggregate_type_seq_idx ON event (aggregate_id, type, seq)`)
  119. yield* db.run(
  120. 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)`,
  121. )
  122. yield* db.run(sql`CREATE INDEX session_message_session_seq_idx ON session_message (session_id, seq)`)
  123. yield* db.run(
  124. 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)`,
  125. )
  126. yield* db.run(
  127. sql`CREATE INDEX session_input_session_pending_delivery_seq_idx ON session_input (session_id, promoted_seq, delivery, seq)`,
  128. )
  129. yield* db.run(sql`INSERT INTO session (id, workspace_id) VALUES ('session', 'wrk_old')`)
  130. yield* db.run(sql`INSERT INTO workspace (id) VALUES ('wrk_old')`)
  131. yield* db.run(sql`INSERT INTO message (id) VALUES ('message')`)
  132. yield* db.run(sql`INSERT INTO part (id) VALUES ('part')`)
  133. yield* db.run(sql`INSERT INTO event_sequence (aggregate_id, seq) VALUES ('session', 0)`)
  134. yield* db.run(
  135. sql`INSERT INTO event (id, aggregate_id, seq, type, data) VALUES ('evt_old', 'session', 0, 'old.1', '{}')`,
  136. )
  137. yield* db.run(
  138. sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('msg_old', 'session', 'user', 0, 1, 1, '{}')`,
  139. )
  140. yield* db.run(
  141. sql`INSERT INTO session_input (id, session_id, prompt, delivery, time_created) VALUES ('msg_pending', 'session', '{}', 'steer', 1)`,
  142. )
  143. yield* DatabaseMigration.applyOnly(db, [eventSourcedSessionInputMigration])
  144. expect(yield* db.all(sql`SELECT id, workspace_id FROM session`)).toEqual([
  145. { id: "session", workspace_id: null },
  146. ])
  147. expect(yield* db.all(sql`SELECT id FROM workspace`)).toEqual([])
  148. expect(yield* db.all(sql`SELECT id FROM message`)).toEqual([{ id: "message" }])
  149. expect(yield* db.all(sql`SELECT id FROM part`)).toEqual([{ id: "part" }])
  150. expect(yield* db.all(sql`SELECT id FROM event`)).toEqual([])
  151. expect(yield* db.all(sql`SELECT aggregate_id FROM event_sequence`)).toEqual([])
  152. expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
  153. expect(yield* db.all(sql`SELECT id FROM session_input`)).toEqual([])
  154. expect(
  155. (yield* db.all<{ name: string }>(sql`PRAGMA table_info(session_input)`)).map((column) => column.name),
  156. ).toEqual(["id", "session_id", "prompt", "delivery", "admitted_seq", "promoted_seq", "time_created"])
  157. expect(
  158. (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_message)`)).find(
  159. (index) => index.name === "session_message_session_seq_idx",
  160. ),
  161. ).toMatchObject({ unique: 1 })
  162. expect(
  163. (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(event)`)).find(
  164. (index) => index.name === "event_aggregate_seq_idx",
  165. ),
  166. ).toMatchObject({ unique: 1 })
  167. expect(
  168. (yield* db.all<{ name: string; unique: number }>(sql`PRAGMA index_list(session_input)`)).filter((index) =>
  169. ["session_input_session_admitted_seq_idx", "session_input_session_promoted_seq_idx"].includes(index.name),
  170. ),
  171. ).toEqual([
  172. expect.objectContaining({ name: "session_input_session_promoted_seq_idx", unique: 1 }),
  173. expect.objectContaining({ name: "session_input_session_admitted_seq_idx", unique: 1 }),
  174. ])
  175. }),
  176. )
  177. })
  178. test("resets incompatible projected Session messages before adding sequence order", async () => {
  179. await run(
  180. Effect.gen(function* () {
  181. const db = yield* makeDb
  182. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
  183. yield* db.run(
  184. 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)`,
  185. )
  186. yield* db.run(
  187. 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)`,
  188. )
  189. yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, seq integer NOT NULL)`)
  190. yield* db.run(
  191. 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)`,
  192. )
  193. yield* db.run(
  194. sql`CREATE INDEX session_message_session_time_created_id_idx ON session_message (session_id, time_created, id)`,
  195. )
  196. yield* db.run(
  197. sql`CREATE INDEX session_message_session_type_time_created_id_idx ON session_message (session_id, type, time_created, id)`,
  198. )
  199. yield* db.run(sql`INSERT INTO session (id) VALUES ('session')`)
  200. yield* db.run(
  201. sql`INSERT INTO message (id, session_id, time_created, time_updated, data) VALUES ('legacy_message', 'session', 1, 1, '{"role":"user"}')`,
  202. )
  203. yield* db.run(
  204. 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"}')`,
  205. )
  206. yield* db.run(
  207. sql`INSERT INTO session_message (id, session_id, type, time_created, time_updated, data) VALUES ('stale_projection', 'session', 'user', 1, 1, '{}')`,
  208. )
  209. yield* DatabaseMigration.applyOnly(db, [sessionMessageProjectionOrderMigration])
  210. expect(yield* db.all(sql`SELECT id, session_id, data FROM message`)).toEqual([
  211. { id: "legacy_message", session_id: "session", data: '{"role":"user"}' },
  212. ])
  213. expect(yield* db.all(sql`SELECT id, message_id, session_id, data FROM part`)).toEqual([
  214. {
  215. id: "legacy_part",
  216. message_id: "legacy_message",
  217. session_id: "session",
  218. data: '{"type":"text","text":"hello"}',
  219. },
  220. ])
  221. expect(yield* db.all(sql`SELECT id FROM session_message`)).toEqual([])
  222. yield* db.run(
  223. sql`INSERT INTO session_message (id, session_id, type, seq, time_created, time_updated, data) VALUES ('fresh_projection', 'session', 'user', 7, 2, 2, '{}')`,
  224. )
  225. expect(yield* db.get(sql`SELECT id, seq FROM session_message`)).toEqual({ id: "fresh_projection", seq: 7 })
  226. }),
  227. )
  228. })
  229. test("runs session usage backfill in order with schema changes", async () => {
  230. await run(
  231. Effect.gen(function* () {
  232. const db = yield* makeDb
  233. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, time_updated integer NOT NULL)`)
  234. yield* db.run(sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL, data text NOT NULL)`)
  235. yield* db.run(sql`INSERT INTO session (id, time_updated) VALUES ('session_1', 1)`)
  236. yield* db.run(
  237. 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}}}')`,
  238. )
  239. yield* DatabaseMigration.applyOnly(db, [sessionUsageMigration])
  240. expect(
  241. yield* db.get(
  242. sql`SELECT cost, tokens_input, tokens_output, tokens_reasoning, tokens_cache_read, tokens_cache_write FROM session WHERE id = 'session_1'`,
  243. ),
  244. ).toEqual({
  245. cost: 1.25,
  246. tokens_input: 2,
  247. tokens_output: 3,
  248. tokens_reasoning: 4,
  249. tokens_cache_read: 5,
  250. tokens_cache_write: 6,
  251. })
  252. }),
  253. )
  254. })
  255. test("normalizes Windows storage paths and leaves POSIX paths untouched", async () => {
  256. await run(
  257. Effect.gen(function* () {
  258. const db = yield* makeDb
  259. yield* db.run(sql`CREATE TABLE project (id text PRIMARY KEY, worktree text NOT NULL, sandboxes text NOT NULL)`)
  260. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, directory text NOT NULL, path text)`)
  261. // Windows-shaped rows (drive + backslash) must be normalized.
  262. yield* db.run(
  263. sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"win"}, ${"C:\\Repo\\Thing"}, ${JSON.stringify([
  264. "C:\\Repo\\Thing\\sandbox",
  265. ])})`,
  266. )
  267. yield* db.run(
  268. sql`INSERT INTO session (id, directory, path) VALUES (${"win"}, ${"C:\\Repo\\Thing\\packages\\api"}, ${"packages\\api"})`,
  269. )
  270. // UNC worktrees and their sandboxes must normalize too (not just drive paths).
  271. yield* db.run(
  272. sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"unc"}, ${"\\\\server\\share"}, ${JSON.stringify([
  273. "\\\\server\\share\\sandbox",
  274. ])})`,
  275. )
  276. // The "/" worktree sentinel and POSIX paths (including a pathological
  277. // backslash in a POSIX filename) must survive byte-for-byte.
  278. yield* db.run(sql`INSERT INTO project (id, worktree, sandboxes) VALUES (${"global"}, ${"/"}, ${"[]"})`)
  279. yield* db.run(
  280. sql`INSERT INTO session (id, directory, path) VALUES (${"posix"}, ${"/home/me/we\\ird"}, ${"src\\weird"})`,
  281. )
  282. yield* DatabaseMigration.applyOnly(db, [normalizeStoragePathsMigration])
  283. expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'win'`)).toEqual({
  284. worktree: "C:/Repo/Thing",
  285. sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
  286. })
  287. expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'win'`)).toEqual({
  288. directory: "C:/Repo/Thing/packages/api",
  289. path: "packages/api",
  290. })
  291. expect(yield* db.get(sql`SELECT worktree, sandboxes FROM project WHERE id = 'unc'`)).toEqual({
  292. worktree: "//server/share",
  293. sandboxes: JSON.stringify(["//server/share/sandbox"]),
  294. })
  295. expect(yield* db.get(sql`SELECT worktree FROM project WHERE id = 'global'`)).toEqual({ worktree: "/" })
  296. expect(yield* db.get(sql`SELECT directory, path FROM session WHERE id = 'posix'`)).toEqual({
  297. directory: "/home/me/we\\ird",
  298. path: "src\\weird",
  299. })
  300. }),
  301. )
  302. })
  303. test("maps native Windows paths through database columns", async () => {
  304. if (process.platform !== "win32") return
  305. await run(
  306. Effect.gen(function* () {
  307. const db = yield* makeDb
  308. yield* DatabaseMigration.apply(db)
  309. const projectID = ProjectV2.ID.make("codec_project")
  310. const worktree = AbsolutePath.make("C:\\Repo\\Thing")
  311. const sandbox = AbsolutePath.make("C:\\Repo\\Thing\\sandbox")
  312. const directory = "C:\\Repo\\Thing\\packages\\api"
  313. const sessionID = SessionSchema.ID.make("ses_codec")
  314. expect(() =>
  315. Effect.runSync(
  316. db
  317. .insert(ProjectTable)
  318. .values({
  319. id: ProjectV2.ID.make("invalid_path"),
  320. worktree: AbsolutePath.make("not-absolute"),
  321. sandboxes: [],
  322. time_created: 1,
  323. time_updated: 1,
  324. })
  325. .run(),
  326. ),
  327. ).toThrow()
  328. yield* db
  329. .insert(ProjectTable)
  330. .values({
  331. id: projectID,
  332. worktree,
  333. sandboxes: [sandbox],
  334. time_created: 1,
  335. time_updated: 1,
  336. })
  337. .run()
  338. yield* db
  339. .insert(SessionTable)
  340. .values({
  341. id: sessionID,
  342. project_id: projectID,
  343. slug: "codec",
  344. directory,
  345. path: "packages\\api",
  346. title: "Codec",
  347. version: "test",
  348. time_created: 1,
  349. time_updated: 1,
  350. })
  351. .run()
  352. expect(
  353. yield* db.get<{ worktree: string; sandboxes: string }>(
  354. sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`,
  355. ),
  356. ).toEqual({
  357. worktree: "C:/Repo/Thing",
  358. sandboxes: JSON.stringify(["C:/Repo/Thing/sandbox"]),
  359. })
  360. expect(
  361. yield* db.get<{ directory: string; path: string }>(
  362. sql`SELECT directory, path FROM session WHERE id = ${sessionID}`,
  363. ),
  364. ).toEqual({
  365. directory: "C:/Repo/Thing/packages/api",
  366. path: "packages/api",
  367. })
  368. const project = yield* db.select().from(ProjectTable).where(eq(ProjectTable.worktree, worktree)).get()
  369. const session = yield* db.select().from(SessionTable).where(eq(SessionTable.directory, directory)).get()
  370. expect(project?.worktree).toBe(worktree)
  371. expect(project?.sandboxes).toEqual([sandbox])
  372. expect(session?.directory).toBe(directory)
  373. expect(session?.path).toBe("packages/api")
  374. expect((yield* db.select().from(SessionTable).where(eq(SessionTable.path, "packages\\api")).get())?.id).toBe(
  375. sessionID,
  376. )
  377. const moved = AbsolutePath.make("D:\\Moved\\Thing")
  378. const updated = yield* db
  379. .update(ProjectTable)
  380. .set({ worktree: moved, sandboxes: [moved] })
  381. .where(eq(ProjectTable.id, projectID))
  382. .returning()
  383. .get()
  384. expect(updated?.worktree).toBe(moved)
  385. expect(updated?.sandboxes).toEqual([moved])
  386. expect(
  387. yield* db.get<{ worktree: string; sandboxes: string }>(
  388. sql`SELECT worktree, sandboxes FROM project WHERE id = ${projectID}`,
  389. ),
  390. ).toEqual({ worktree: "D:/Moved/Thing", sandboxes: JSON.stringify(["D:/Moved/Thing"]) })
  391. expect(
  392. (yield* db
  393. .select()
  394. .from(ProjectTable)
  395. .where(inArray(ProjectTable.worktree, [moved]))
  396. .get())?.id,
  397. ).toBe(projectID)
  398. yield* db.run(sql`UPDATE project SET worktree = ${"not-absolute"} WHERE id = ${projectID}`)
  399. expect(() =>
  400. Effect.runSync(db.select().from(ProjectTable).where(eq(ProjectTable.id, projectID)).get()),
  401. ).toThrow()
  402. }),
  403. )
  404. })
  405. test("imports existing drizzle migration state", async () => {
  406. await run(
  407. Effect.gen(function* () {
  408. const db = yield* makeDb
  409. yield* db.run(
  410. sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
  411. )
  412. yield* db.run(sql`
  413. INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at)
  414. VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()})
  415. `)
  416. yield* DatabaseMigration.applyOnly(db, [])
  417. expect(yield* db.get(sql`SELECT id FROM migration`)).toEqual({ id: "20260127222353_familiar_lady_ursula" })
  418. }),
  419. )
  420. })
  421. test("does not replay a migrated session metadata column", async () => {
  422. await run(
  423. Effect.gen(function* () {
  424. const db = yield* makeDb
  425. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
  426. yield* db.run(
  427. sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
  428. )
  429. yield* db.run(sql`
  430. INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at)
  431. VALUES ('hash', 1, '20260511173437_session-metadata', ${new Date().toISOString()})
  432. `)
  433. yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
  434. expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "20260511173437_session-metadata" }])
  435. }),
  436. )
  437. })
  438. test("accepts the temporary replacement session metadata migration id", async () => {
  439. await run(
  440. Effect.gen(function* () {
  441. const db = yield* makeDb
  442. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, metadata text)`)
  443. yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
  444. yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('20260530232709_lovely_romulus', 1)`)
  445. yield* DatabaseMigration.applyOnly(db, [sessionMetadataMigration])
  446. expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([
  447. { id: "20260511173437_session-metadata" },
  448. { id: "20260530232709_lovely_romulus" },
  449. ])
  450. }),
  451. )
  452. })
  453. test("skips drizzle import when migration table already has state", async () => {
  454. await run(
  455. Effect.gen(function* () {
  456. const db = yield* makeDb
  457. yield* db.run(sql`CREATE TABLE migration (id TEXT PRIMARY KEY, time_completed INTEGER NOT NULL)`)
  458. yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`)
  459. yield* db.run(
  460. sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
  461. )
  462. yield* db.run(sql`
  463. INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at)
  464. VALUES ('hash', 1, '20260127222353_familiar_lady_ursula', ${new Date().toISOString()})
  465. `)
  466. yield* DatabaseMigration.applyOnly(db, [])
  467. expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }])
  468. }),
  469. )
  470. })
  471. })