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
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 }