database-migration.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  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 { 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 { Database } from "@opencode-ai/core/database/database"
  12. import { tmpdir } from "./fixture/tmpdir"
  13. import type { SqlClient } from "effect/unstable/sql/SqlClient"
  14. import legacyCredentialsMigration from "@opencode-ai/core/database/migration/20260805200742_import_legacy_credentials"
  15. import consolidatedV2Migration from "@opencode-ai/core/database/migration/20260804233008_loose_psylocke"
  16. import { Global } from "@opencode-ai/util/global"
  17. const run = <A, E>(
  18. effect: Effect.Effect<A, E, SqlClient | Global.Service>,
  19. global = Global.make({ data: path.join(process.cwd(), ".test-data") }),
  20. ) =>
  21. Effect.runPromise(
  22. effect.pipe(
  23. Effect.provideService(Global.Service, global),
  24. Effect.provide(SqliteClient.layer({ filename: ":memory:", disableWAL: true })),
  25. Effect.scoped,
  26. ),
  27. )
  28. const makeDb = EffectDrizzleSqlite.makeWithDefaults()
  29. describe("DatabaseMigration", () => {
  30. test("serializes concurrent embedded initialization for one database path", async () => {
  31. await using tmp = await tmpdir()
  32. const filename = path.join(tmp.path, "embedded.sqlite")
  33. await Effect.runPromise(
  34. Effect.all(
  35. [Database.layer({ path: filename }), Database.layer({ path: filename })].map((layer) =>
  36. Effect.scoped(Layer.build(layer)),
  37. ),
  38. { concurrency: "unbounded" },
  39. ).pipe(Effect.provideService(Global.Service, Global.make({ data: tmp.path }))),
  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("bootstraps the current schema and records the migration registry", 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_v2'`)).toEqual(
  57. {
  58. name: "session_v2",
  59. },
  60. )
  61. expect(
  62. yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'session_pending'`),
  63. ).toEqual({ name: "session_pending" })
  64. expect(yield* db.get(sql`SELECT count(*) AS count FROM migration`)).toEqual({ count: migrations.length })
  65. }),
  66. )
  67. })
  68. test("rejects a non-empty database without a session table", async () => {
  69. await expect(
  70. run(
  71. Effect.gen(function* () {
  72. const db = yield* makeDb
  73. yield* db.run(sql`CREATE TABLE unrelated (id text PRIMARY KEY)`)
  74. yield* DatabaseMigration.apply(db)
  75. }),
  76. ),
  77. ).rejects.toThrow("Database is not empty and has no session table")
  78. })
  79. test("applies generic migrations once and records their order", async () => {
  80. await run(
  81. Effect.gen(function* () {
  82. const db = yield* makeDb
  83. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
  84. const input = [
  85. {
  86. id: "first",
  87. up: (tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) =>
  88. tx.run(sql`CREATE TABLE applied (id text PRIMARY KEY)`),
  89. },
  90. {
  91. id: "second",
  92. up: (tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) =>
  93. tx.run(sql`INSERT INTO applied (id) VALUES ('second')`),
  94. },
  95. ]
  96. yield* DatabaseMigration.applyOnly(db, input)
  97. yield* DatabaseMigration.applyOnly(db, input)
  98. expect(yield* db.all(sql`SELECT id FROM applied`)).toEqual([{ id: "second" }])
  99. expect(yield* db.all(sql`SELECT id FROM migration ORDER BY time_completed, id`)).toEqual([
  100. { id: "first" },
  101. { id: "second" },
  102. ])
  103. }),
  104. )
  105. })
  106. test("adopts the consolidated V2 migration after the superseded chain", async () => {
  107. await run(
  108. Effect.gen(function* () {
  109. const db = yield* makeDb
  110. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
  111. yield* db.run(sql`CREATE TABLE event (id text PRIMARY KEY, created integer DEFAULT 0 NOT NULL)`)
  112. yield* db.run(sql`CREATE TABLE migration (id text PRIMARY KEY, time_completed integer NOT NULL)`)
  113. yield* db.run(sql`
  114. INSERT INTO migration (id, time_completed)
  115. VALUES ('20260730195856_optional_session_title', 1)
  116. `)
  117. yield* DatabaseMigration.applyOnly(db, [consolidatedV2Migration])
  118. expect(yield* db.get(sql`SELECT id FROM migration WHERE id = ${consolidatedV2Migration.id}`)).toEqual({
  119. id: consolidatedV2Migration.id,
  120. })
  121. expect(yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'kv'`)).toBeUndefined()
  122. }),
  123. )
  124. })
  125. test("imports legacy JSON credentials without changing the source file or existing credentials", async () => {
  126. await using tmp = await tmpdir()
  127. const source = path.join(tmp.path, "auth.json")
  128. const content = JSON.stringify({
  129. openai: { type: "oauth", refresh: "refresh", access: "access", expires: 123, accountId: "account" },
  130. anthropic: { type: "api", key: "legacy-key", metadata: { region: "us" } },
  131. "https://example.com/": { type: "wellknown", key: "TOKEN", token: "wellknown-key" },
  132. invalid: { type: "unknown" },
  133. })
  134. await Bun.write(source, content)
  135. await run(
  136. Effect.gen(function* () {
  137. const db = yield* makeDb
  138. yield* DatabaseMigration.apply(db)
  139. const now = Date.now()
  140. yield* db.run(sql`
  141. INSERT INTO credential (id, integration_id, label, value, time_created, time_updated)
  142. VALUES ('existing', 'anthropic', 'Existing', ${JSON.stringify({ type: "key", key: "current-key" })}, ${now}, ${now})
  143. `)
  144. yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
  145. yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
  146. expect(yield* db.all(sql`SELECT integration_id, label, value FROM credential ORDER BY integration_id`)).toEqual(
  147. [
  148. {
  149. integration_id: "anthropic",
  150. label: "Existing",
  151. value: JSON.stringify({ type: "key", key: "current-key" }),
  152. },
  153. {
  154. integration_id: "https://example.com",
  155. label: "default",
  156. value: JSON.stringify({ type: "key", key: "wellknown-key" }),
  157. },
  158. {
  159. integration_id: "openai",
  160. label: "default",
  161. value: JSON.stringify({
  162. type: "oauth",
  163. methodID: "chatgpt-browser",
  164. refresh: "refresh",
  165. access: "access",
  166. expires: 123,
  167. metadata: { accountID: "account" },
  168. }),
  169. },
  170. ],
  171. )
  172. expect(yield* db.get(sql`SELECT value FROM kv WHERE key = 'wellknown:sources'`)).toEqual({
  173. value: JSON.stringify(["https://example.com"]),
  174. })
  175. }),
  176. Global.make({ data: tmp.path }),
  177. )
  178. expect(await Bun.file(source).text()).toBe(content)
  179. })
  180. test("skips legacy credential import when the source file is absent", async () => {
  181. await using tmp = await tmpdir()
  182. await run(
  183. Effect.gen(function* () {
  184. const db = yield* makeDb
  185. yield* DatabaseMigration.apply(db)
  186. yield* db.run(sql`DELETE FROM migration WHERE id = ${legacyCredentialsMigration.id}`)
  187. yield* DatabaseMigration.applyOnly(db, [legacyCredentialsMigration])
  188. expect(yield* db.all(sql`SELECT id FROM credential`)).toEqual([])
  189. }),
  190. Global.make({ data: tmp.path }),
  191. )
  192. })
  193. test("rolls back a failed migration without recording it", async () => {
  194. await run(
  195. Effect.gen(function* () {
  196. const db = yield* makeDb
  197. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY)`)
  198. const migration = {
  199. id: "failing",
  200. up: (tx: Parameters<Parameters<typeof db.transaction>[0]>[0]) =>
  201. Effect.gen(function* () {
  202. yield* tx.run(sql`CREATE TABLE rolled_back (id text PRIMARY KEY)`)
  203. yield* Effect.fail(new Error("stop"))
  204. }),
  205. }
  206. expect((yield* Effect.exit(DatabaseMigration.applyOnly(db, [migration])))._tag).toBe("Failure")
  207. expect(
  208. yield* db.get(sql`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'rolled_back'`),
  209. ).toBeUndefined()
  210. expect(yield* db.get(sql`SELECT id FROM migration WHERE id = 'failing'`)).toBeUndefined()
  211. }),
  212. )
  213. })
  214. test("suspends foreign keys outside migrations that rebuild referenced tables", async () => {
  215. await run(
  216. Effect.gen(function* () {
  217. const db = yield* makeDb
  218. yield* db.run(sql`PRAGMA foreign_keys = ON`)
  219. yield* db.run(sql`CREATE TABLE session (id text PRIMARY KEY, title text NOT NULL)`)
  220. yield* db.run(
  221. sql`CREATE TABLE message (id text PRIMARY KEY, session_id text NOT NULL REFERENCES session(id) ON DELETE CASCADE)`,
  222. )
  223. yield* db.run(sql`INSERT INTO session VALUES ('session', 'title')`)
  224. yield* db.run(sql`INSERT INTO message VALUES ('message', 'session')`)
  225. yield* DatabaseMigration.applyOnly(db, [
  226. {
  227. id: "rebuild",
  228. foreignKeys: false,
  229. up: (tx) =>
  230. Effect.gen(function* () {
  231. yield* tx.run(sql`CREATE TABLE next_session (id text PRIMARY KEY, title text)`)
  232. yield* tx.run(sql`INSERT INTO next_session SELECT * FROM session`)
  233. yield* tx.run(sql`DROP TABLE session`)
  234. yield* tx.run(sql`ALTER TABLE next_session RENAME TO session`)
  235. }),
  236. },
  237. ])
  238. expect(yield* db.get(sql`SELECT id FROM message`)).toEqual({ id: "message" })
  239. expect(yield* db.get<{ foreign_keys: number }>(sql`PRAGMA foreign_keys`)).toEqual({ foreign_keys: 1 })
  240. }),
  241. )
  242. })
  243. test("imports an existing Drizzle migration journal once", async () => {
  244. await run(
  245. Effect.gen(function* () {
  246. const db = yield* makeDb
  247. yield* db.run(
  248. sql`CREATE TABLE __drizzle_migrations (id INTEGER PRIMARY KEY, hash text NOT NULL, created_at numeric, name text, applied_at TEXT)`,
  249. )
  250. yield* db.run(sql`
  251. INSERT INTO __drizzle_migrations (hash, created_at, name, applied_at)
  252. VALUES ('hash', 1, 'legacy', ${new Date().toISOString()})
  253. `)
  254. yield* DatabaseMigration.applyOnly(db, [])
  255. expect(yield* db.all(sql`SELECT id FROM migration`)).toEqual([{ id: "legacy" }])
  256. yield* db.run(sql`INSERT INTO migration (id, time_completed) VALUES ('existing', 1)`)
  257. yield* db.run(sql`UPDATE __drizzle_migrations SET name = 'ignored'`)
  258. yield* DatabaseMigration.applyOnly(db, [])
  259. expect(yield* db.all(sql`SELECT id FROM migration ORDER BY id`)).toEqual([{ id: "existing" }, { id: "legacy" }])
  260. }),
  261. )
  262. })
  263. })