database-migration.test.ts 22 KB

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