also looking at this
fix(migrate)!: run the directories executeMigration was given
#1138
7 files
+348
-49
| @@ -2,7 +2,7 @@ import type { MigrationPlan } from '@/migrations' | ||
| 2 | 2 | import type { GenerateMigrationResult, MigrateOptions, SupportedDialect } from '@/types' |
| 3 | 3 | import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from 'node:fs' |
| 4 | 4 | import { tmpdir } from 'node:os' |
| 5 | import { join } from 'node:path' | |
| 5 | import { isAbsolute, join } from 'node:path' | |
| 6 | 6 | import process from 'node:process' |
| 7 | 7 | import { config, getConfig } from '@/config' |
| 8 | 8 | import { withFreshConnection } from '@/db' |
| @@ -51,9 +51,9 @@ async function settleAppliedFiles(qb: any, dialect: SupportedDialect, files: str | ||
| 51 | 51 | * guessing from the name is how the runner used to delete their file. An |
| 52 | 52 | * unreadable file counts as authored, which is the cautious reading. |
| 53 | 53 | */ |
| 54 | function isGeneratedMigration(sqlDir: string, file: string): boolean { | |
| 54 | function isGeneratedMigration(filePath: string): boolean { | |
| 55 | 55 | try { |
| 56 | return isGeneratedMigrationSql(readFileSync(join(sqlDir, file), 'utf8')) | |
| 56 | return isGeneratedMigrationSql(readFileSync(filePath, 'utf8')) | |
| 57 | 57 | } |
| 58 | 58 | catch { |
| 59 | 59 | return false |
| @@ -387,8 +387,71 @@ export async function generateMigration(dir?: string, opts: MigrateOptions = {}) | ||
| 387 | 387 | return { sql, sqlStatements, hasChanges, plan, operations } |
| 388 | 388 | } |
| 389 | 389 | |
| 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 | */ | |
| 403 | function 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 | */ | |
| 434 | function 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 | ||
| 392 | 455 | /** |
| 393 | 456 | * The advisory-lock name every migrate run contends on. |
| 394 | 457 | * |
| @@ -479,9 +542,25 @@ async function withMigrationLock<T>(dialect: SupportedDialect, fn: () => Promise | ||
| 479 | 542 | } |
| 480 | 543 | } |
| 481 | 544 | |
| 482 | // because it is public API and every caller passes it. | |
| 483 | // eslint-disable-next-line pickier/no-unused-vars | |
| 484 | export 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 | */ | |
| 563 | export async function executeMigration(dirs?: string | string[]): Promise<boolean> { | |
| 485 | 564 | // Load the config file before anything reads `snapshotDir`. |
| 486 | 565 | // |
| 487 | 566 | // `config` is a synchronous singleton of defaults plus whatever `setConfig` |
| @@ -494,11 +573,9 @@ export async function executeMigration(dir?: string): Promise<boolean> { | ||
| 494 | 573 | await getConfig() |
| 495 | 574 | |
| 496 | 575 | const workspaceRoot = getWorkspaceRoot() |
| 497 | const sqlDir = ensureSqlDirectory(workspaceRoot) | |
| 498 | 576 | const dialect = config.dialect || 'postgres' |
| 499 | 577 | |
| 500 | const files = readdirSync(sqlDir) | |
| 501 | const scriptFiles = files.filter(file => file.endsWith('.sql')).sort() | |
| 578 | const scriptFiles = collectMigrationFiles(resolveMigrationDirs(dirs, workspaceRoot)) | |
| 502 | 579 | |
| 503 | 580 | if (scriptFiles.length === 0) { |
| 504 | 581 | info('-- No migration files found to execute') |
| @@ -542,7 +619,7 @@ export async function executeMigration(dir?: string): Promise<boolean> { | ||
| 542 | 619 | * corpus already contains), so there is nothing left for replay to fix, |
| 543 | 620 | * and everything to lose by deleting the evidence. |
| 544 | 621 | */ |
| 545 | const pending = scriptFiles.filter(file => !executedMigrations.includes(file)) | |
| 622 | const pending = scriptFiles.filter(file => !executedMigrations.includes(file.name)) | |
| 546 | 623 | |
| 547 | 624 | if (pending.length === 0) { |
| 548 | 625 | info('-- No pending migrations to execute') |
| @@ -551,8 +628,7 @@ export async function executeMigration(dir?: string): Promise<boolean> { | ||
| 551 | 628 | |
| 552 | 629 | info(`-- Executing ${pending.length} migration${pending.length === 1 ? '' : 's'}`) |
| 553 | 630 | |
| 554 | for (const file of pending) { | |
| 555 | const filePath = join(sqlDir, file) | |
| 631 | for (const { name: file, path: filePath } of pending) { | |
| 556 | 632 | info(`-- Executing: ${file}`) |
| 557 | 633 | |
| 558 | 634 | try { |
| @@ -589,7 +665,7 @@ export async function executeMigration(dir?: string): Promise<boolean> { | ||
| 589 | 665 | // never recorded it at all. Record it and carry on — the schema is |
| 590 | 666 | // where the file says it should be. Anything else, including every |
| 591 | 667 | // authored migration, fails loudly. |
| 592 | if (isGeneratedMigration(sqlDir, file) && isAlreadyAppliedError(err)) { | |
| 668 | if (isGeneratedMigration(filePath) && isAlreadyAppliedError(err)) { | |
| 593 | 669 | await recordMigration(qb, file, dialect) |
| 594 | 670 | info(`-- ✓ Migration ${file} was already applied; recorded it (${err instanceof Error ? err.message : String(err)})`) |
| 595 | 671 | continue |
| @@ -833,7 +909,7 @@ function removeGeneratedMigrationFiles(sqlDir: string): void { | ||
| 833 | 909 | const kept: string[] = [] |
| 834 | 910 | |
| 835 | 911 | for (const file of readdirSync(sqlDir).filter(f => f.endsWith('.sql'))) { |
| 836 | if (!isGeneratedMigration(sqlDir, file)) { | |
| 912 | if (!isGeneratedMigration(join(sqlDir, file))) { | |
| 837 | 913 | kept.push(file) |
| 838 | 914 | continue |
| 839 | 915 | } |