also looking at this
fix(migrate)!: run the directories executeMigration was given
#1138
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.
| @@ -231,12 +231,22 @@ const migration = await generateMigration('./models', { | ||
| 231 | 231 | |
| 232 | 232 | ### executeMigration |
| 233 | 233 | |
| 234 | Execute a migration. | |
| 234 | Run every pending migration file in the corpus and record each one. | |
| 235 | 235 | |
| 236 | 236 | ```typescript |
| 237 | 237 | import { executeMigration } from 'bun-query-builder' |
| 238 | 238 | |
| 239 | await executeMigration(migration) | |
| 239 | // The configured `migrationDir`. | |
| 240 | await 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. | |
| 246 | await executeMigration([ | |
| 247 | './database/migrations', | |
| 248 | './node_modules/loghq/database/migrations', | |
| 249 | ]) | |
| 240 | 250 | ``` |
| 241 | 251 | |
| 242 | 252 | ## Seeders |
| @@ -22,17 +22,39 @@ The migration system automatically generates SQL DDL statements from your model | ||
| 22 | 22 | ```ts |
| 23 | 23 | import { generateMigration, executeMigration } from 'bun-query-builder' |
| 24 | 24 | |
| 25 | // Generate migration from models directory | |
| 26 | const migration = await generateMigration('./models', { | |
| 25 | // Generate migration files from the models directory | |
| 26 | await generateMigration('./models', { | |
| 27 | 27 | dialect: 'postgres', |
| 28 | apply: true, | |
| 29 | 28 | full: true |
| 30 | 29 | }) |
| 31 | 30 | |
| 32 | // Execute the migration | |
| 33 | await executeMigration(migration) | |
| 31 | // Run every pending file in the configured `migrationDir` | |
| 32 | await executeMigration() | |
| 34 | 33 | ``` |
| 35 | 34 | |
| 35 | ### Run a Corpus Spread Across Directories | |
| 36 | ||
| 37 | `executeMigration` takes the directory, or the list of directories, to run. | |
| 38 | Omit it and it uses the configured `migrationDir`. | |
| 39 | ||
| 40 | ```ts | |
| 41 | // The application's own migrations, plus the ones each installed package ships | |
| 42 | await executeMigration([ | |
| 43 | './database/migrations', | |
| 44 | './node_modules/loghq/database/migrations', | |
| 45 | ]) | |
| 46 | ``` | |
| 47 | ||
| 48 | Files from every directory are ordered together by file name, not one directory | |
| 49 | at a time — the file name is the ordinal, and a package's tables routinely carry | |
| 50 | foreign keys into the application's. So a package migration can be placed before | |
| 51 | or after an application one deliberately. | |
| 52 | ||
| 53 | Because migrations are recorded in the ledger by their bare file name, a name | |
| 54 | cannot repeat across directories; a repeat is refused rather than recorded once | |
| 55 | and silently skipped. A directory that does not exist is refused too, rather | |
| 56 | than read as an empty corpus. | |
| 57 | ||
| 36 | 58 | ### Migration Options |
| 37 | 59 | |
| 38 | 60 | ```ts |
| @@ -200,12 +222,11 @@ const models = defineModels({ | ||
| 200 | 222 | |
| 201 | 223 | ```ts |
| 202 | 224 | try { |
| 203 | const migration = await generateMigration('./models', { | |
| 204 | dialect: 'postgres', | |
| 205 | apply: true | |
| 225 | await generateMigration('./models', { | |
| 226 | dialect: 'postgres' | |
| 206 | 227 | }) |
| 207 | 228 | |
| 208 | await executeMigration(migration) | |
| 229 | await executeMigration() | |
| 209 | 230 | } catch (err) { |
| 210 | 231 | console.error('Migration failed:', err) |
| 211 | 232 | // Handle rollback or retry logic |
| @@ -270,7 +291,7 @@ beforeAll(async () => { | ||
| 270 | 291 | }) |
| 271 | 292 | |
| 272 | 293 | if (result.sqlStatements.length > 0) { |
| 273 | await executeMigration(result) | |
| 294 | await executeMigration() | |
| 274 | 295 | } |
| 275 | 296 | }) |
| 276 | 297 | ``` |
| @@ -302,13 +323,15 @@ function generateMigration( | ||
| 302 | 323 | |
| 303 | 324 | ```ts |
| 304 | 325 | function executeMigration( |
| 305 | migration: GenerateMigrationResult | |
| 326 | dirs?: string | string[] | |
| 306 | 327 | ): Promise<boolean> |
| 307 | 328 | ``` |
| 308 | 329 | |
| 309 | 330 | **Parameters:** |
| 310 | 331 | |
| 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. | |
| 312 | 335 | |
| 313 | 336 | **Returns:** |
| 314 | 337 | |
| @@ -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 | } |
| @@ -78,7 +78,7 @@ import { Database } from 'bun:sqlite' | ||
| 78 | 78 | import { setConfig, executeMigration } from ${JSON.stringify(SRC)} |
| 79 | 79 | |
| 80 | 80 | setConfig({ dialect: 'sqlite', database: { database: './t.sqlite' }, verbose: false }) |
| 81 | try { await executeMigration(process.cwd()) } catch {} | |
| 81 | try { await executeMigration() } catch {} | |
| 82 | 82 | |
| 83 | 83 | const db = new Database('./t.sqlite') |
| 84 | 84 | // Explicit names, not a LIKE: 'mig_%' also matches the 'migrations' ledger. |
| @@ -121,7 +121,7 @@ import { setConfig, executeMigration } from ${JSON.stringify(SRC)} | ||
| 121 | 121 | |
| 122 | 122 | setConfig({ dialect: 'sqlite', database: { database: './t.sqlite' }, verbose: false }) |
| 123 | 123 | let error = '' |
| 124 | try { await executeMigration(process.cwd()) } catch (e) { error = e instanceof Error ? e.message : String(e) } | |
| 124 | try { await executeMigration() } catch (e) { error = e instanceof Error ? e.message : String(e) } | |
| 125 | 125 | |
| 126 | 126 | const db = new Database('./t.sqlite') |
| 127 | 127 | const type = db.query("SELECT type FROM pragma_table_info('rebuilt') WHERE name = 'qty'").get() |
| @@ -165,7 +165,7 @@ describe.skipIf(!pgAvailable)('migration locking (#1067)', () => { | ||
| 165 | 165 | writeFileSync(join(dir, 'worker.ts'), ` |
| 166 | 166 | import { setConfig, executeMigration } from ${JSON.stringify(SRC)} |
| 167 | 167 | setConfig({ dialect: 'postgres', database: { url: ${JSON.stringify(PG_URL)} }, verbose: false }) |
| 168 | await executeMigration(process.cwd()) | |
| 168 | await executeMigration() | |
| 169 | 169 | `) |
| 170 | 170 | |
| 171 | 171 | const result = run(dir, 'probe.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 | */ | |
| 19 | import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'bun:test' | |
| 20 | import { Database } from 'bun:sqlite' | |
| 21 | import { existsSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' | |
| 22 | import { tmpdir } from 'node:os' | |
| 23 | import { join } from 'node:path' | |
| 24 | import process from 'node:process' | |
| 25 | import { executeMigration } from '../src/actions/migrate' | |
| 26 | import { config, setConfig } from '../src/config' | |
| 27 | ||
| 28 | let workspace: string | |
| 29 | let appDir: string | |
| 30 | let packageDir: string | |
| 31 | let dbFile: string | |
| 32 | let originalCwd: string | |
| 33 | let prevDatabase: any | |
| 34 | let prevDialect: any | |
| 35 | let dbCounter = 0 | |
| 36 | ||
| 37 | /** The application's own corpus, at the configured `database/migrations`. */ | |
| 38 | const USERS = '0000000001-create-users.sql' | |
| 39 | const 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 | */ | |
| 47 | const AUTHORS = '0000000003-add-author-to-posts.sql' | |
| 48 | const AUTHORS_SQL = 'ALTER TABLE "posts" ADD COLUMN "author_id" INTEGER;\n' | |
| 49 | ||
| 50 | /** The package's, ordered between the two application files. */ | |
| 51 | const POSTS = '0000000002-create-posts.sql' | |
| 52 | const 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. */ | |
| 55 | function 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". */ | |
| 63 | function 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 | ||
| 72 | function recorded(): string[] { | |
| 73 | return read<{ migration: string }>('SELECT migration FROM migrations ORDER BY id').map(row => row.migration) | |
| 74 | } | |
| 75 | ||
| 76 | function columnsOf(table: string): string[] { | |
| 77 | return read<{ name: string }>(`PRAGMA table_info("${table}")`).map(column => column.name) | |
| 78 | } | |
| 79 | ||
| 80 | beforeAll(() => { | |
| 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 | ||
| 94 | afterAll(() => { | |
| 95 | setConfig({ dialect: prevDialect, database: prevDatabase }) | |
| 96 | process.chdir(originalCwd) | |
| 97 | rmSync(workspace, { recursive: true, force: true }) | |
| 98 | }) | |
| 99 | ||
| 100 | beforeEach(() => { | |
| 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 | ||
| 108 | describe('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 | ||
| 163 | describe('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 | ||
| 181 | describe('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 | }) | |
| @@ -97,7 +97,7 @@ describe('an attribute added after the table was migrated', () => { | ||
| 97 | 97 | useOriginalDatabase() |
| 98 | 98 | writeModel(`name: { validation: { rule: {} } }`) |
| 99 | 99 | await generateMigration(modelsDir, { dialect: 'sqlite' }) |
| 100 | await executeMigration(modelsDir) | |
| 100 | await executeMigration() | |
| 101 | 101 | }) |
| 102 | 102 | |
| 103 | 103 | it('writes a migration for the change', async () => { |
| @@ -111,7 +111,7 @@ describe('an attribute added after the table was migrated', () => { | ||
| 111 | 111 | }) |
| 112 | 112 | |
| 113 | 113 | it('applies it to the database it was generated on', async () => { |
| 114 | await executeMigration(modelsDir) | |
| 114 | await executeMigration() | |
| 115 | 115 | expect(columnsOf(dbFile, 'widgets')).toContain('colour') |
| 116 | 116 | }) |
| 117 | 117 | |
| @@ -126,7 +126,7 @@ describe('an attribute added after the table was migrated', () => { | ||
| 126 | 126 | // The point of the whole corpus: a teammate clones, migrates, and gets the |
| 127 | 127 | // same table. If the ALTER is not in the files, they get yesterday's table. |
| 128 | 128 | const fresh = useFreshDatabase() |
| 129 | await executeMigration(modelsDir) | |
| 129 | await executeMigration() | |
| 130 | 130 | useOriginalDatabase() |
| 131 | 131 | |
| 132 | 132 | expect(columnsOf(fresh, 'widgets')).toContain('name') |
| @@ -135,7 +135,7 @@ describe('an attribute added after the table was migrated', () => { | ||
| 135 | 135 | |
| 136 | 136 | it('does not re-run on the machine that already applied it', async () => { |
| 137 | 137 | const before = migrationFiles() |
| 138 | await executeMigration(modelsDir) | |
| 138 | await executeMigration() | |
| 139 | 139 | |
| 140 | 140 | // Recorded, so it is not replayed — replaying an ADD COLUMN is the |
| 141 | 141 | // `duplicate column name` failure that takes the whole run down. |
| @@ -157,16 +157,16 @@ describe('a series of changes, each landing its own migration', () => { | ||
| 157 | 157 | |
| 158 | 158 | writeModel(`name: { validation: { rule: {} } }, colour: { validation: { rule: {} } }, size: { validation: { rule: {} } }`) |
| 159 | 159 | await generateMigration(modelsDir, { dialect: 'sqlite' }) |
| 160 | await executeMigration(modelsDir) | |
| 160 | await executeMigration() | |
| 161 | 161 | |
| 162 | 162 | writeModel(`name: { validation: { rule: {} } }, colour: { validation: { rule: {} } }, size: { validation: { rule: {} } }, weight: { validation: { rule: {} } }`) |
| 163 | 163 | await generateMigration(modelsDir, { dialect: 'sqlite' }) |
| 164 | await executeMigration(modelsDir) | |
| 164 | await executeMigration() | |
| 165 | 165 | |
| 166 | 166 | expect(migrationFiles().length).toBe(start + 2) |
| 167 | 167 | |
| 168 | 168 | const fresh = useFreshDatabase() |
| 169 | await executeMigration(modelsDir) | |
| 169 | await executeMigration() | |
| 170 | 170 | useOriginalDatabase() |
| 171 | 171 | |
| 172 | 172 | const columns = columnsOf(fresh, 'widgets') |
| @@ -196,12 +196,12 @@ describe('two changes migrated inside the same second', () => { | ||
| 196 | 196 | }) |
| 197 | 197 | |
| 198 | 198 | it('runs both, on this machine and on a fresh one', async () => { |
| 199 | await executeMigration(modelsDir) | |
| 199 | await executeMigration() | |
| 200 | 200 | expect(columnsOf(dbFile, 'widgets')).toContain('finish') |
| 201 | 201 | expect(columnsOf(dbFile, 'widgets')).toContain('grade') |
| 202 | 202 | |
| 203 | 203 | const fresh = useFreshDatabase() |
| 204 | await executeMigration(modelsDir) | |
| 204 | await executeMigration() | |
| 205 | 205 | useOriginalDatabase() |
| 206 | 206 | |
| 207 | 207 | const columns = columnsOf(fresh, 'widgets') |
| @@ -230,7 +230,7 @@ describe('a migration applied but never recorded', () => { | ||
| 230 | 230 | writeFileSync(orphan, `-- qb:generated\nALTER TABLE widgets ADD COLUMN colour TEXT;\n`) |
| 231 | 231 | |
| 232 | 232 | // `colour` is already on the table, so this SQL cannot succeed. |
| 233 | await executeMigration(modelsDir) | |
| 233 | await executeMigration() | |
| 234 | 234 | |
| 235 | 235 | expect(existsSync(orphan)).toBe(true) |
| 236 | 236 | expect(columnsOf(dbFile, 'widgets')).toContain('colour') |
| @@ -244,7 +244,7 @@ describe('a migration applied but never recorded', () => { | ||
| 244 | 244 | // recorded away. Only an unambiguous "already exists" is forgiven, and |
| 245 | 245 | // only for a migration the generator wrote. |
| 246 | 246 | let threw = false |
| 247 | try { await executeMigration(modelsDir) } | |
| 247 | try { await executeMigration() } | |
| 248 | 248 | catch { threw = true } |
| 249 | 249 | finally { rmSync(broken, { force: true }) } |
| 250 | 250 | |
| @@ -267,11 +267,11 @@ describe('the snapshot going missing does not duplicate work', () => { | ||
| 267 | 267 | }) |
| 268 | 268 | |
| 269 | 269 | it('leaves the database untouched and still replayable afterwards', async () => { |
| 270 | await executeMigration(modelsDir) | |
| 270 | await executeMigration() | |
| 271 | 271 | expect(columnsOf(dbFile, 'widgets')).toContain('weight') |
| 272 | 272 | |
| 273 | 273 | const fresh = useFreshDatabase() |
| 274 | await executeMigration(modelsDir) | |
| 274 | await executeMigration() | |
| 275 | 275 | useOriginalDatabase() |
| 276 | 276 | expect(columnsOf(fresh, 'widgets')).toContain('weight') |
| 277 | 277 | }) |
| @@ -211,7 +211,7 @@ describe('the runner tells authored migrations from generated ones', () => { | ||
| 211 | 211 | const authored = join(migrationsDir(), '9000000001-alter-farms-table.sql') |
| 212 | 212 | writeFileSync(authored, 'ALTER TABLE farms ADD COLUMN steward TEXT;\n') |
| 213 | 213 | |
| 214 | await executeMigration(modelsDir) | |
| 214 | await executeMigration() | |
| 215 | 215 | |
| 216 | 216 | expect(existsSync(authored)).toBe(true) |
| 217 | 217 | expect(columnsOf('farms')).toContain('steward') |
| @@ -221,7 +221,7 @@ describe('the runner tells authored migrations from generated ones', () => { | ||
| 221 | 221 | it('does not replay it on the next run, so it cannot fail as a duplicate', async () => { |
| 222 | 222 | // Recorded means run-once. Replaying an ADD COLUMN is the `duplicate |
| 223 | 223 | // column name` failure that takes a whole migrate run down with it. |
| 224 | await executeMigration(modelsDir) | |
| 224 | await executeMigration() | |
| 225 | 225 | |
| 226 | 226 | expect(existsSync(join(migrationsDir(), '9000000001-alter-farms-table.sql'))).toBe(true) |
| 227 | 227 | expect(columnsOf('farms')).toContain('steward') |
| @@ -234,7 +234,7 @@ describe('the runner tells authored migrations from generated ones', () => { | ||
| 234 | 234 | const generated = join(migrationsDir(), '9000000002-alter-farms-table.sql') |
| 235 | 235 | writeFileSync(generated, `${GENERATED_MARKER}\nALTER TABLE farms ADD COLUMN drainage TEXT;\n`) |
| 236 | 236 | |
| 237 | await executeMigration(modelsDir) | |
| 237 | await executeMigration() | |
| 238 | 238 | |
| 239 | 239 | expect(existsSync(generated)).toBe(true) |
| 240 | 240 | expect(columnsOf('farms')).toContain('drainage') |