ReviewOS

also looking at this

stacks/bun-query-builder

fix(migrate)!: run the directories executeMigration was given

#1138
Merged glennmichael123 wants to merge fix/execute-migration-dirs into main
7 files +348 -49

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

docs/api/reference.mdmodified+12-2
Changes to docs/api/reference.md
@@ -231,12 +231,22 @@ const migration = await generateMigration('./models', {
231231
232232### executeMigration
233233
234Execute a migration.
234Run every pending migration file in the corpus and record each one.
235235
236236```typescript
237237import { executeMigration } from 'bun-query-builder'
238238
239await executeMigration(migration)
239// The configured `migrationDir`.
240await executeMigration()
241
242// A corpus assembled from several directories — the application's own
243// migrations plus the ones each installed package ships. Files from every
244// directory are ordered together by file name, so names must be unique
245// across the whole run.
246await executeMigration([
247 './database/migrations',
248 './node_modules/loghq/database/migrations',
249])
240250```
241251
242252## Seeders
docs/features/migrations.mdmodified+35-12
Changes to docs/features/migrations.md
@@ -22,17 +22,39 @@ The migration system automatically generates SQL DDL statements from your model
2222```ts
2323import { generateMigration, executeMigration } from 'bun-query-builder'
2424
25// Generate migration from models directory
26const migration = await generateMigration('./models', {
25// Generate migration files from the models directory
26await generateMigration('./models', {
2727 dialect: 'postgres',
28 apply: true,
2928 full: true
3029})
3130
32// Execute the migration
33await executeMigration(migration)
31// Run every pending file in the configured `migrationDir`
32await executeMigration()
3433```
3534
35### Run a Corpus Spread Across Directories
36
37`executeMigration` takes the directory, or the list of directories, to run.
38Omit it and it uses the configured `migrationDir`.
39
40```ts
41// The application's own migrations, plus the ones each installed package ships
42await executeMigration([
43 './database/migrations',
44 './node_modules/loghq/database/migrations',
45])
46```
47
48Files from every directory are ordered together by file name, not one directory
49at a time — the file name is the ordinal, and a package's tables routinely carry
50foreign keys into the application's. So a package migration can be placed before
51or after an application one deliberately.
52
53Because migrations are recorded in the ledger by their bare file name, a name
54cannot repeat across directories; a repeat is refused rather than recorded once
55and silently skipped. A directory that does not exist is refused too, rather
56than read as an empty corpus.
57
3658### Migration Options
3759
3860```ts
@@ -200,12 +222,11 @@ const models = defineModels({
200222
201223```ts
202224try {
203 const migration = await generateMigration('./models', {
204 dialect: 'postgres',
205 apply: true
225 await generateMigration('./models', {
226 dialect: 'postgres'
206227 })
207228
208 await executeMigration(migration)
229 await executeMigration()
209230} catch (err) {
210231 console.error('Migration failed:', err)
211232 // Handle rollback or retry logic
@@ -270,7 +291,7 @@ beforeAll(async () => {
270291 })
271292
272293 if (result.sqlStatements.length > 0) {
273 await executeMigration(result)
294 await executeMigration()
274295 }
275296})
276297```
@@ -302,13 +323,15 @@ function generateMigration(
302323
303324```ts
304325function executeMigration(
305 migration: GenerateMigrationResult
326 dirs?: string | string[]
306327): Promise<boolean>
307328```
308329
309330**Parameters:**
310331
311- `migration`: Result from generateMigration
332- `dirs`: Directory, or list of directories, holding the `.sql` corpus to run.
333 Defaults to the configured `migrationDir`. Relative paths resolve against the
334 workspace root. File names must be unique across every directory in a run.
312335
313336**Returns:**
314337
packages/bun-query-builder/src/actions/migrate.tsmodified+92-16
Changes to packages/bun-query-builder/src/actions/migrate.ts
@@ -2,7 +2,7 @@ import type { MigrationPlan } from '@/migrations'
22import type { GenerateMigrationResult, MigrateOptions, SupportedDialect } from '@/types'
33import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs'
44import { tmpdir } from 'node:os'
5import { join } from 'node:path'
5import { isAbsolute, join } from 'node:path'
66import process from 'node:process'
77import { config, getConfig } from '@/config'
88import { withFreshConnection } from '@/db'
@@ -51,9 +51,9 @@ async function settleAppliedFiles(qb: any, dialect: SupportedDialect, files: str
5151 * guessing from the name is how the runner used to delete their file. An
5252 * unreadable file counts as authored, which is the cautious reading.
5353 */
54function isGeneratedMigration(sqlDir: string, file: string): boolean {
54function isGeneratedMigration(filePath: string): boolean {
5555 try {
56 return isGeneratedMigrationSql(readFileSync(join(sqlDir, file), 'utf8'))
56 return isGeneratedMigrationSql(readFileSync(filePath, 'utf8'))
5757 }
5858 catch {
5959 return false
@@ -387,8 +387,71 @@ export async function generateMigration(dir?: string, opts: MigrateOptions = {})
387387 return { sql, sqlStatements, hasChanges, plan, operations }
388388}
389389
390// `dir` is the models directory. This function runs the SQL corpus, which it
391// finds from the workspace root, so it has no use for it — the parameter stays
390/**
391 * The directories `executeMigration` should enumerate, in the order given.
392 *
393 * Omitting the argument means the configured corpus, and creates it when it is
394 * missing — a fresh project has no `database/migrations` until something writes
395 * one. Naming directories means exactly those: they are not created, and one
396 * that does not exist is a caller error rather than an empty corpus, because
397 * the alternative is a run that silently applies nothing and reports success.
398 *
399 * Relative paths resolve against the workspace root, the same root
400 * `config.migrationDir` resolves against, so a caller does not have to know
401 * which directory the process happens to have been started from.
402 */
403function resolveMigrationDirs(dirs: string | string[] | undefined, workspaceRoot: string): string[] {
404 if (dirs === undefined)
405 return [ensureSqlDirectory(workspaceRoot)]
406
407 const named = Array.isArray(dirs) ? dirs : [dirs]
408
409 // An empty list is refused rather than read as "the configured corpus" or as
410 // "no corpus". Both readings are silent: the first ignores what the caller
411 // asked for, the second skips every migration and exits zero. A caller that
412 // means the configured default omits the argument.
413 if (named.length === 0)
414 throw new Error('executeMigration was given an empty list of directories. Pass at least one directory, or omit the argument to use the configured migrationDir.')
415
416 return named.map(dir => (isAbsolute(dir) ? dir : join(workspaceRoot, dir)))
417}
418
419/**
420 * The `.sql` corpus across every directory, in run order.
421 *
422 * Sorted globally by basename rather than per directory, because the basename
423 * IS the ordinal: a package's tables carry foreign keys into the application's
424 * and never the reverse, so sorting each directory separately and running them
425 * back to back puts `REFERENCES "users"` before `users` exists. Postgres and
426 * MySQL reject that; SQLite does not, so per-directory ordering fails on deploy
427 * having passed locally.
428 *
429 * The ledger keys on the bare basename, so a basename cannot repeat across
430 * directories. Left alone, the second file would look already-executed and be
431 * skipped — a migration that never ran, on a run that reported success. It is
432 * refused here instead, naming both paths.
433 */
434function collectMigrationFiles(dirs: string[]): Array<{ name: string, path: string }> {
435 const byName = new Map<string, string>()
436
437 for (const dir of dirs) {
438 if (!existsSync(dir))
439 throw new Error(`Migration directory not found: ${dir}`)
440
441 for (const name of readdirSync(dir).filter(file => file.endsWith('.sql'))) {
442 const claimed = byName.get(name)
443 if (claimed !== undefined)
444 throw new Error(`Duplicate migration file name across directories: "${name}" appears in both ${claimed} and ${join(dir, name)}. Migrations are recorded by file name, so names must be unique across every directory in a run.`)
445
446 byName.set(name, join(dir, name))
447 }
448 }
449
450 return [...byName]
451 .map(([name, path]) => ({ name, path }))
452 .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
453}
454
392455/**
393456 * The advisory-lock name every migrate run contends on.
394457 *
@@ -479,9 +542,25 @@ async function withMigrationLock<T>(dialect: SupportedDialect, fn: () => Promise
479542 }
480543}
481544
482// because it is public API and every caller passes it.
483// eslint-disable-next-line pickier/no-unused-vars
484export async function executeMigration(dir?: string): Promise<boolean> {
545/**
546 * Run every pending migration file, in order, and record each one.
547 *
548 * `dirs` is the corpus to run. Omit it for the configured `migrationDir`,
549 * which is what the CLI and every caller inside this package do. Pass a
550 * directory, or a list of them, to run a corpus assembled from more than one
551 * place — an application's own migrations plus those shipped by each installed
552 * package, say. Files from every directory are ordered together by file name,
553 * so a package's migration can be placed before or after the application's
554 * deliberately; see {@link collectMigrationFiles} for why that has to be one
555 * global ordering and why file names must be unique across the whole run.
556 *
557 * The argument used to be declared and then ignored, so a caller passing the
558 * wrong directory got the configured one anyway and never found out. It is
559 * honoured now, which means a caller that was passing something other than a
560 * migrations directory — the models directory was the common one — will see
561 * that directory enumerated instead.
562 */
563export async function executeMigration(dirs?: string | string[]): Promise<boolean> {
485564 // Load the config file before anything reads `snapshotDir`.
486565 //
487566 // `config` is a synchronous singleton of defaults plus whatever `setConfig`
@@ -494,11 +573,9 @@ export async function executeMigration(dir?: string): Promise<boolean> {
494573 await getConfig()
495574
496575 const workspaceRoot = getWorkspaceRoot()
497 const sqlDir = ensureSqlDirectory(workspaceRoot)
498576 const dialect = config.dialect || 'postgres'
499577
500 const files = readdirSync(sqlDir)
501 const scriptFiles = files.filter(file => file.endsWith('.sql')).sort()
578 const scriptFiles = collectMigrationFiles(resolveMigrationDirs(dirs, workspaceRoot))
502579
503580 if (scriptFiles.length === 0) {
504581 info('-- No migration files found to execute')
@@ -542,7 +619,7 @@ export async function executeMigration(dir?: string): Promise<boolean> {
542619 * corpus already contains), so there is nothing left for replay to fix,
543620 * and everything to lose by deleting the evidence.
544621 */
545 const pending = scriptFiles.filter(file => !executedMigrations.includes(file))
622 const pending = scriptFiles.filter(file => !executedMigrations.includes(file.name))
546623
547624 if (pending.length === 0) {
548625 info('-- No pending migrations to execute')
@@ -551,8 +628,7 @@ export async function executeMigration(dir?: string): Promise<boolean> {
551628
552629 info(`-- Executing ${pending.length} migration${pending.length === 1 ? '' : 's'}`)
553630
554 for (const file of pending) {
555 const filePath = join(sqlDir, file)
631 for (const { name: file, path: filePath } of pending) {
556632 info(`-- Executing: ${file}`)
557633
558634 try {
@@ -589,7 +665,7 @@ export async function executeMigration(dir?: string): Promise<boolean> {
589665 // never recorded it at all. Record it and carry on — the schema is
590666 // where the file says it should be. Anything else, including every
591667 // authored migration, fails loudly.
592 if (isGeneratedMigration(sqlDir, file) && isAlreadyAppliedError(err)) {
668 if (isGeneratedMigration(filePath) && isAlreadyAppliedError(err)) {
593669 await recordMigration(qb, file, dialect)
594670 info(`-- ✓ Migration ${file} was already applied; recorded it (${err instanceof Error ? err.message : String(err)})`)
595671 continue
@@ -833,7 +909,7 @@ function removeGeneratedMigrationFiles(sqlDir: string): void {
833909 const kept: string[] = []
834910
835911 for (const file of readdirSync(sqlDir).filter(f => f.endsWith('.sql'))) {
836 if (!isGeneratedMigration(sqlDir, file)) {
912 if (!isGeneratedMigration(join(sqlDir, file))) {
837913 kept.push(file)
838914 continue
839915 }
packages/bun-query-builder/test/migrate-atomicity.test.tsmodified+3-3
Changes to packages/bun-query-builder/test/migrate-atomicity.test.ts
@@ -78,7 +78,7 @@ import { Database } from 'bun:sqlite'
7878import { setConfig, executeMigration } from ${JSON.stringify(SRC)}
7979
8080setConfig({ dialect: 'sqlite', database: { database: './t.sqlite' }, verbose: false })
81try { await executeMigration(process.cwd()) } catch {}
81try { await executeMigration() } catch {}
8282
8383const db = new Database('./t.sqlite')
8484// Explicit names, not a LIKE: 'mig_%' also matches the 'migrations' ledger.
@@ -121,7 +121,7 @@ import { setConfig, executeMigration } from ${JSON.stringify(SRC)}
121121
122122setConfig({ dialect: 'sqlite', database: { database: './t.sqlite' }, verbose: false })
123123let error = ''
124try { await executeMigration(process.cwd()) } catch (e) { error = e instanceof Error ? e.message : String(e) }
124try { await executeMigration() } catch (e) { error = e instanceof Error ? e.message : String(e) }
125125
126126const db = new Database('./t.sqlite')
127127const type = db.query("SELECT type FROM pragma_table_info('rebuilt') WHERE name = 'qty'").get()
@@ -165,7 +165,7 @@ describe.skipIf(!pgAvailable)('migration locking (#1067)', () => {
165165 writeFileSync(join(dir, 'worker.ts'), `
166166import { setConfig, executeMigration } from ${JSON.stringify(SRC)}
167167setConfig({ dialect: 'postgres', database: { url: ${JSON.stringify(PG_URL)} }, verbose: false })
168await executeMigration(process.cwd())
168await executeMigration()
169169`)
170170
171171 const result = run(dir, 'probe.ts', `
packages/bun-query-builder/test/migrate-multiple-dirs.test.tsadded+190-0
Changes to packages/bun-query-builder/test/migrate-multiple-dirs.test.ts
@@ -0,0 +1,190 @@
1/**
2 * Running one migration corpus that lives in more than one directory.
3 *
4 * `executeMigration` declared a `dir` argument and then threw it away, always
5 * enumerating `config.migrationDir` — so a caller could not point it anywhere
6 * else, and a caller pointing it at the wrong place never found out. Honouring
7 * the argument is the small half. The half that matters is a list: an
8 * application's own migrations plus the ones each installed package ships,
9 * where a package's tables carry foreign keys into the application's and never
10 * the reverse.
11 *
12 * Running each directory in turn does not work, because the file name is the
13 * ordinal and per-directory sorting loses the cross-directory order. These
14 * tests pin the global ordering, and the two ways a multi-directory run can go
15 * quietly wrong: a name that repeats across directories (the ledger keys on the
16 * bare name, so one file would be recorded and the other silently skipped) and
17 * a directory that is not there.
18 */
19import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test'
20import { Database } from 'bun:sqlite'
21import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
22import { tmpdir } from 'node:os'
23import { join } from 'node:path'
24import process from 'node:process'
25import { executeMigration } from '../src/actions/migrate'
26import { config, setConfig } from '../src/config'
27
28let workspace: string
29let appDir: string
30let packageDir: string
31let dbFile: string
32let originalCwd: string
33let prevDatabase: any
34let prevDialect: any
35let dbCounter = 0
36
37/** The application's own corpus, at the configured `database/migrations`. */
38const USERS = '0000000001-create-users.sql'
39const USERS_SQL = 'CREATE TABLE "users" ("id" INTEGER PRIMARY KEY, "email" TEXT);\n'
40
41/**
42 * The application's, again — but ordered AFTER the package's file, and
43 * depending on it. A run that sorts each directory separately reaches this one
44 * while "posts" does not yet exist, and `ALTER TABLE` on a missing table fails
45 * on every dialect including SQLite, which tolerates a dangling REFERENCES.
46 */
47const AUTHORS = '0000000003-add-author-to-posts.sql'
48const AUTHORS_SQL = 'ALTER TABLE "posts" ADD COLUMN "author_id" INTEGER;\n'
49
50/** The package's, ordered between the two application files. */
51const POSTS = '0000000002-create-posts.sql'
52const POSTS_SQL = 'CREATE TABLE "posts" ("id" INTEGER PRIMARY KEY, "user_id" INTEGER REFERENCES "users"("id"));\n'
53
54/** Point the builder at a database file no migration has ever run against. */
55function useFreshDatabase(): string {
56 dbCounter += 1
57 dbFile = join(workspace, 'database', `app-${dbCounter}.sqlite`)
58 setConfig({ dialect: 'sqlite', database: { ...prevDatabase, database: dbFile } })
59 return dbFile
60}
61
62/** Reads the database as it stands, treating "never created" as "nothing there". */
63function read<T>(query: string): T[] {
64 if (!existsSync(dbFile))
65 return []
66
67 const db = new Database(dbFile, { readonly: true })
68 try { return db.query(query).all() as T[] }
69 finally { db.close() }
70}
71
72function recorded(): string[] {
73 return read<{ migration: string }>('SELECT migration FROM migrations ORDER BY id').map(row => row.migration)
74}
75
76function columnsOf(table: string): string[] {
77 return read<{ name: string }>(`PRAGMA table_info("${table}")`).map(column => column.name)
78}
79
80beforeAll(() => {
81 originalCwd = process.cwd()
82 workspace = realpathSync(mkdtempSync(join(tmpdir(), 'qb-multi-dir-')))
83 appDir = join(workspace, 'database', 'migrations')
84 packageDir = join(workspace, 'node_modules', 'loghq', 'database', 'migrations')
85 mkdirSync(appDir, { recursive: true })
86 mkdirSync(packageDir, { recursive: true })
87 writeFileSync(join(workspace, 'package.json'), '{"name":"multi-dir-fixture"}')
88 process.chdir(workspace)
89
90 prevDatabase = config.database
91 prevDialect = config.dialect
92})
93
94afterAll(() => {
95 setConfig({ dialect: prevDialect, database: prevDatabase })
96 process.chdir(originalCwd)
97 rmSync(workspace, { recursive: true, force: true })
98})
99
100beforeEach(() => {
101 rmSync(appDir, { recursive: true, force: true })
102 rmSync(packageDir, { recursive: true, force: true })
103 mkdirSync(appDir, { recursive: true })
104 mkdirSync(packageDir, { recursive: true })
105 useFreshDatabase()
106})
107
108describe('executeMigration across several directories', () => {
109 beforeEach(() => {
110 writeFileSync(join(appDir, USERS), USERS_SQL)
111 writeFileSync(join(appDir, AUTHORS), AUTHORS_SQL)
112 writeFileSync(join(packageDir, POSTS), POSTS_SQL)
113 })
114
115 it('orders every file by name across all of them, not one directory at a time', async () => {
116 await executeMigration([appDir, packageDir])
117
118 expect(recorded()).toEqual([USERS, POSTS, AUTHORS])
119 expect(columnsOf('posts')).toContain('author_id')
120 })
121
122 it('orders the same way whichever order the directories are given in', async () => {
123 await executeMigration([packageDir, appDir])
124
125 expect(recorded()).toEqual([USERS, POSTS, AUTHORS])
126 })
127
128 it('records the bare file name, whichever directory the file came from', async () => {
129 await executeMigration([appDir, packageDir])
130
131 expect(recorded()).not.toContain(join(packageDir, POSTS))
132 })
133
134 it('runs nothing on a second pass', async () => {
135 await executeMigration([appDir, packageDir])
136 await executeMigration([appDir, packageDir])
137
138 expect(recorded()).toEqual([USERS, POSTS, AUTHORS])
139 })
140
141 it('refuses a directory that is not there rather than reporting an empty corpus', async () => {
142 const missing = join(workspace, 'node_modules', 'not-installed', 'database', 'migrations')
143
144 await expect(executeMigration([appDir, missing])).rejects.toThrow(/not found/i)
145 expect(recorded()).toEqual([])
146 })
147
148 it('refuses a file name that repeats across directories', async () => {
149 // The ledger keys on the bare name, so the second file would look already
150 // executed and be skipped — a migration that never ran, on a run that
151 // reported success.
152 writeFileSync(join(packageDir, USERS), 'CREATE TABLE "loghq_users" ("id" INTEGER PRIMARY KEY);\n')
153
154 await expect(executeMigration([appDir, packageDir])).rejects.toThrow(new RegExp(USERS))
155 expect(recorded()).toEqual([])
156 })
157
158 it('refuses an empty list rather than guessing what it meant', async () => {
159 await expect(executeMigration([])).rejects.toThrow(/empty list/i)
160 })
161})
162
163describe('executeMigration with a single directory', () => {
164 it('runs the directory it was given', async () => {
165 writeFileSync(join(packageDir, POSTS), 'CREATE TABLE "posts" ("id" INTEGER PRIMARY KEY);\n')
166
167 await executeMigration(packageDir)
168
169 expect(recorded()).toEqual([POSTS])
170 })
171
172 it('resolves a relative directory against the workspace root', async () => {
173 writeFileSync(join(packageDir, POSTS), 'CREATE TABLE "posts" ("id" INTEGER PRIMARY KEY);\n')
174
175 await executeMigration('node_modules/loghq/database/migrations')
176
177 expect(recorded()).toEqual([POSTS])
178 })
179})
180
181describe('executeMigration with no argument', () => {
182 it('still runs the configured migrationDir, so existing callers are unchanged', async () => {
183 writeFileSync(join(appDir, USERS), USERS_SQL)
184 writeFileSync(join(packageDir, POSTS), POSTS_SQL)
185
186 await executeMigration()
187
188 expect(recorded()).toEqual([USERS])
189 })
190})
packages/bun-query-builder/test/migrations.attribute-change.test.tsmodified+13-13
Changes to packages/bun-query-builder/test/migrations.attribute-change.test.ts
@@ -97,7 +97,7 @@ describe('an attribute added after the table was migrated', () => {
9797 useOriginalDatabase()
9898 writeModel(`name: { validation: { rule: {} } }`)
9999 await generateMigration(modelsDir, { dialect: 'sqlite' })
100 await executeMigration(modelsDir)
100 await executeMigration()
101101 })
102102
103103 it('writes a migration for the change', async () => {
@@ -111,7 +111,7 @@ describe('an attribute added after the table was migrated', () => {
111111 })
112112
113113 it('applies it to the database it was generated on', async () => {
114 await executeMigration(modelsDir)
114 await executeMigration()
115115 expect(columnsOf(dbFile, 'widgets')).toContain('colour')
116116 })
117117
@@ -126,7 +126,7 @@ describe('an attribute added after the table was migrated', () => {
126126 // The point of the whole corpus: a teammate clones, migrates, and gets the
127127 // same table. If the ALTER is not in the files, they get yesterday's table.
128128 const fresh = useFreshDatabase()
129 await executeMigration(modelsDir)
129 await executeMigration()
130130 useOriginalDatabase()
131131
132132 expect(columnsOf(fresh, 'widgets')).toContain('name')
@@ -135,7 +135,7 @@ describe('an attribute added after the table was migrated', () => {
135135
136136 it('does not re-run on the machine that already applied it', async () => {
137137 const before = migrationFiles()
138 await executeMigration(modelsDir)
138 await executeMigration()
139139
140140 // Recorded, so it is not replayed — replaying an ADD COLUMN is the
141141 // `duplicate column name` failure that takes the whole run down.
@@ -157,16 +157,16 @@ describe('a series of changes, each landing its own migration', () => {
157157
158158 writeModel(`name: { validation: { rule: {} } }, colour: { validation: { rule: {} } }, size: { validation: { rule: {} } }`)
159159 await generateMigration(modelsDir, { dialect: 'sqlite' })
160 await executeMigration(modelsDir)
160 await executeMigration()
161161
162162 writeModel(`name: { validation: { rule: {} } }, colour: { validation: { rule: {} } }, size: { validation: { rule: {} } }, weight: { validation: { rule: {} } }`)
163163 await generateMigration(modelsDir, { dialect: 'sqlite' })
164 await executeMigration(modelsDir)
164 await executeMigration()
165165
166166 expect(migrationFiles().length).toBe(start + 2)
167167
168168 const fresh = useFreshDatabase()
169 await executeMigration(modelsDir)
169 await executeMigration()
170170 useOriginalDatabase()
171171
172172 const columns = columnsOf(fresh, 'widgets')
@@ -196,12 +196,12 @@ describe('two changes migrated inside the same second', () => {
196196 })
197197
198198 it('runs both, on this machine and on a fresh one', async () => {
199 await executeMigration(modelsDir)
199 await executeMigration()
200200 expect(columnsOf(dbFile, 'widgets')).toContain('finish')
201201 expect(columnsOf(dbFile, 'widgets')).toContain('grade')
202202
203203 const fresh = useFreshDatabase()
204 await executeMigration(modelsDir)
204 await executeMigration()
205205 useOriginalDatabase()
206206
207207 const columns = columnsOf(fresh, 'widgets')
@@ -230,7 +230,7 @@ describe('a migration applied but never recorded', () => {
230230 writeFileSync(orphan, `-- qb:generated\nALTER TABLE widgets ADD COLUMN colour TEXT;\n`)
231231
232232 // `colour` is already on the table, so this SQL cannot succeed.
233 await executeMigration(modelsDir)
233 await executeMigration()
234234
235235 expect(existsSync(orphan)).toBe(true)
236236 expect(columnsOf(dbFile, 'widgets')).toContain('colour')
@@ -244,7 +244,7 @@ describe('a migration applied but never recorded', () => {
244244 // recorded away. Only an unambiguous "already exists" is forgiven, and
245245 // only for a migration the generator wrote.
246246 let threw = false
247 try { await executeMigration(modelsDir) }
247 try { await executeMigration() }
248248 catch { threw = true }
249249 finally { rmSync(broken, { force: true }) }
250250
@@ -267,11 +267,11 @@ describe('the snapshot going missing does not duplicate work', () => {
267267 })
268268
269269 it('leaves the database untouched and still replayable afterwards', async () => {
270 await executeMigration(modelsDir)
270 await executeMigration()
271271 expect(columnsOf(dbFile, 'widgets')).toContain('weight')
272272
273273 const fresh = useFreshDatabase()
274 await executeMigration(modelsDir)
274 await executeMigration()
275275 useOriginalDatabase()
276276 expect(columnsOf(fresh, 'widgets')).toContain('weight')
277277 })
packages/bun-query-builder/test/migrations.handwritten-lifecycle.test.tsmodified+3-3
Changes to packages/bun-query-builder/test/migrations.handwritten-lifecycle.test.ts
@@ -211,7 +211,7 @@ describe('the runner tells authored migrations from generated ones', () => {
211211 const authored = join(migrationsDir(), '9000000001-alter-farms-table.sql')
212212 writeFileSync(authored, 'ALTER TABLE farms ADD COLUMN steward TEXT;\n')
213213
214 await executeMigration(modelsDir)
214 await executeMigration()
215215
216216 expect(existsSync(authored)).toBe(true)
217217 expect(columnsOf('farms')).toContain('steward')
@@ -221,7 +221,7 @@ describe('the runner tells authored migrations from generated ones', () => {
221221 it('does not replay it on the next run, so it cannot fail as a duplicate', async () => {
222222 // Recorded means run-once. Replaying an ADD COLUMN is the `duplicate
223223 // column name` failure that takes a whole migrate run down with it.
224 await executeMigration(modelsDir)
224 await executeMigration()
225225
226226 expect(existsSync(join(migrationsDir(), '9000000001-alter-farms-table.sql'))).toBe(true)
227227 expect(columnsOf('farms')).toContain('steward')
@@ -234,7 +234,7 @@ describe('the runner tells authored migrations from generated ones', () => {
234234 const generated = join(migrationsDir(), '9000000002-alter-farms-table.sql')
235235 writeFileSync(generated, `${GENERATED_MARKER}\nALTER TABLE farms ADD COLUMN drainage TEXT;\n`)
236236
237 await executeMigration(modelsDir)
237 await executeMigration()
238238
239239 expect(existsSync(generated)).toBe(true)
240240 expect(columnsOf('farms')).toContain('drainage')