also looking at this
fix(migrate): apply each migration atomically, and one run at a time
#1076
3 files
+364
-54
| @@ -383,6 +383,88 @@ export async function generateMigration(dir?: string, opts: MigrateOptions = {}) | ||
| 383 | 383 | |
| 384 | 384 | // `dir` is the models directory. This function runs the SQL corpus, which it |
| 385 | 385 | // finds from the workspace root, so it has no use for it — the parameter stays |
| 386 | /** | |
| 387 | * The advisory-lock name every migrate run contends on. | |
| 388 | * | |
| 389 | * Constant on purpose: two processes must pick the same key to serialise, and | |
| 390 | * migrations are global to a database rather than per-model or per-directory. | |
| 391 | */ | |
| 392 | const MIGRATION_LOCK_KEY = 'bun-query-builder:migrate' | |
| 393 | ||
| 394 | /** | |
| 395 | * Statements that cannot run inside a transaction, so a file containing one | |
| 396 | * must be applied unwrapped. | |
| 397 | * | |
| 398 | * Postgres rejects `CREATE INDEX CONCURRENTLY` and `DROP INDEX CONCURRENTLY` | |
| 399 | * inside a transaction block outright, and `VACUUM` likewise — and building an | |
| 400 | * index concurrently is exactly what a migration on a live table SHOULD do, so | |
| 401 | * refusing to support it is not an option. Files that use them keep the old | |
| 402 | * unwrapped behaviour and say so, rather than failing on something they were | |
| 403 | * right to write. | |
| 404 | */ | |
| 405 | const NON_TRANSACTIONAL_SQL = /\bCONCURRENTLY\b|^\s*VACUUM\b/im | |
| 406 | ||
| 407 | /** | |
| 408 | * Whether this dialect can roll a failed migration file back. | |
| 409 | * | |
| 410 | * Postgres has fully transactional DDL. SQLite does too, for the statements a | |
| 411 | * migration uses. MySQL does NOT: `CREATE TABLE`, `ALTER TABLE` and friends | |
| 412 | * force an implicit commit, so wrapping a file there produces a transaction | |
| 413 | * that silently commits mid-way and hands back false confidence — worse than | |
| 414 | * not wrapping, because it looks like it worked. | |
| 415 | */ | |
| 416 | function supportsTransactionalDdl(dialect: SupportedDialect): boolean { | |
| 417 | return dialect === 'postgres' || dialect === 'sqlite' | |
| 418 | } | |
| 419 | ||
| 420 | /** | |
| 421 | * Run `fn` while holding the migration advisory lock. | |
| 422 | * | |
| 423 | * Two processes booting at once — an ordinary rolling deploy — both read the | |
| 424 | * same pending set and both apply it. The lock makes the second wait for the | |
| 425 | * first, then find nothing pending. | |
| 426 | * | |
| 427 | * The lock is taken on a RESERVED connection and released on the same one. | |
| 428 | * That is not incidental: a Postgres session lock lives until it is released | |
| 429 | * or the session ends, and a pooled connection does not end when it goes back | |
| 430 | * to the pool. Taking it on a pooled builder would strand the lock on whatever | |
| 431 | * connection happened to serve the call, and the next migrate would wait | |
| 432 | * forever on a lock nobody meant to hold. | |
| 433 | * | |
| 434 | * SQLite has no advisory-lock primitive and a single-writer file, so it runs | |
| 435 | * unlocked rather than throwing. | |
| 436 | */ | |
| 437 | async function withMigrationLock<T>(dialect: SupportedDialect, fn: () => Promise<T>): Promise<T> { | |
| 438 | if (dialect === 'sqlite') | |
| 439 | return await fn() | |
| 440 | ||
| 441 | const qb = createQueryBuilder() as any | |
| 442 | let reserved: any | |
| 443 | try { | |
| 444 | reserved = await qb.reserve() | |
| 445 | } | |
| 446 | catch { | |
| 447 | // A driver that cannot reserve (or a mock) should not block migrating. | |
| 448 | return await fn() | |
| 449 | } | |
| 450 | ||
| 451 | let held = false | |
| 452 | try { | |
| 453 | await reserved.advisoryLock(MIGRATION_LOCK_KEY) | |
| 454 | held = true | |
| 455 | return await fn() | |
| 456 | } | |
| 457 | finally { | |
| 458 | try { | |
| 459 | if (held) | |
| 460 | await reserved.advisoryUnlock(MIGRATION_LOCK_KEY) | |
| 461 | } | |
| 462 | finally { | |
| 463 | reserved.release?.() | |
| 464 | } | |
| 465 | } | |
| 466 | } | |
| 467 | ||
| 386 | 468 | // because it is public API and every caller passes it. |
| 387 | 469 | // eslint-disable-next-line pickier/no-unused-vars |
| 388 | 470 | export async function executeMigration(dir?: string): Promise<boolean> { |
| @@ -417,58 +499,86 @@ export async function executeMigration(dir?: string): Promise<boolean> { | ||
| 417 | 499 | // Create migrations table if it doesn't exist |
| 418 | 500 | await createMigrationsTable(qb, dialect) |
| 419 | 501 | |
| 420 | // Get already executed migrations | |
| 421 | const executedMigrations = await getExecutedMigrations(qb, dialect) | |
| 422 | ||
| 423 | /* | |
| 424 | * Every migration file is permanent, and every one that runs is recorded. | |
| 425 | * | |
| 426 | * Generated ALTERs used to be "transient": replayed on every migrate | |
| 427 | * rather than recorded, then deleted from disk once applied. That made | |
| 428 | * the corpus a log of one machine's session instead of the schema. Change | |
| 429 | * an attribute, migrate, and the ALTER that carried the change was gone — | |
| 430 | * so a teammate who cloned the repo and migrated got the table as it was | |
| 431 | * BEFORE the change, with no error and no clue. The generator writes each | |
| 432 | * change once (`createMigrationFile` refuses to write a statement the | |
| 433 | * corpus already contains), so there is nothing left for replay to fix, | |
| 434 | * and everything to lose by deleting the evidence. | |
| 435 | */ | |
| 436 | const pending = scriptFiles.filter(file => !executedMigrations.includes(file)) | |
| 437 | ||
| 438 | if (pending.length === 0) { | |
| 439 | info('-- No pending migrations to execute') | |
| 440 | return true | |
| 441 | } | |
| 502 | // Everything from here runs under the migration lock, and the read of the | |
| 503 | // ledger is deliberately INSIDE it: computing `pending` before locking is | |
| 504 | // what lets two processes agree on the same work and then both do it. | |
| 505 | return await withMigrationLock(dialect, async () => { | |
| 506 | // Get already executed migrations | |
| 507 | const executedMigrations = await getExecutedMigrations(qb, dialect) | |
| 508 | ||
| 509 | /* | |
| 510 | * Every migration file is permanent, and every one that runs is recorded. | |
| 511 | * | |
| 512 | * Generated ALTERs used to be "transient": replayed on every migrate | |
| 513 | * rather than recorded, then deleted from disk once applied. That made | |
| 514 | * the corpus a log of one machine's session instead of the schema. Change | |
| 515 | * an attribute, migrate, and the ALTER that carried the change was gone — | |
| 516 | * so a teammate who cloned the repo and migrated got the table as it was | |
| 517 | * BEFORE the change, with no error and no clue. The generator writes each | |
| 518 | * change once (`createMigrationFile` refuses to write a statement the | |
| 519 | * corpus already contains), so there is nothing left for replay to fix, | |
| 520 | * and everything to lose by deleting the evidence. | |
| 521 | */ | |
| 522 | const pending = scriptFiles.filter(file => !executedMigrations.includes(file)) | |
| 523 | ||
| 524 | if (pending.length === 0) { | |
| 525 | info('-- No pending migrations to execute') | |
| 526 | return true | |
| 527 | } | |
| 442 | 528 | |
| 443 | info(`-- Executing ${pending.length} migration${pending.length === 1 ? '' : 's'}`) | |
| 529 | info(`-- Executing ${pending.length} migration${pending.length === 1 ? '' : 's'}`) | |
| 444 | 530 | |
| 445 | for (const file of pending) { | |
| 446 | const filePath = join(sqlDir, file) | |
| 447 | info(`-- Executing: ${file}`) | |
| 531 | for (const file of pending) { | |
| 532 | const filePath = join(sqlDir, file) | |
| 533 | info(`-- Executing: ${file}`) | |
| 448 | 534 | |
| 449 | try { | |
| 450 | await qb.file(filePath) | |
| 451 | await recordMigration(qb, file, dialect) | |
| 452 | info(`-- ✓ Migration ${file} executed and recorded`) | |
| 453 | } | |
| 454 | catch (err) { | |
| 455 | // A generated migration whose change is already in place is not a | |
| 456 | // failure, it is a bookkeeping gap: the file was applied on a run that | |
| 457 | // died before recording it, or under the old transient behaviour that | |
| 458 | // never recorded it at all. Record it and carry on — the schema is | |
| 459 | // where the file says it should be. Anything else, including every | |
| 460 | // authored migration, fails loudly. | |
| 461 | if (isGeneratedMigration(sqlDir, file) && isAlreadyAppliedError(err)) { | |
| 462 | await recordMigration(qb, file, dialect) | |
| 463 | info(`-- ✓ Migration ${file} was already applied; recorded it (${err instanceof Error ? err.message : String(err)})`) | |
| 464 | continue | |
| 535 | try { | |
| 536 | // Apply the file and record it as ONE unit where the dialect allows. | |
| 537 | // | |
| 538 | // Unwrapped, a file is applied statement by statement: if statement 3 | |
| 539 | // throws, statements 1 and 2 are already committed and the file is | |
| 540 | // neither applied nor cleanly re-runnable. Recording inside the same | |
| 541 | // transaction closes the matching gap at the other end, where the | |
| 542 | // schema changed but the process died before the ledger was written. | |
| 543 | const wrappable = supportsTransactionalDdl(dialect) | |
| 544 | && !NON_TRANSACTIONAL_SQL.test(readFileSync(filePath, 'utf-8')) | |
| 545 | ||
| 546 | if (wrappable) { | |
| 547 | await qb.transaction(async (tx: any) => { | |
| 548 | await tx.file(filePath) | |
| 549 | await recordMigration(tx, file, dialect) | |
| 550 | }) | |
| 551 | } | |
| 552 | else { | |
| 553 | await qb.file(filePath) | |
| 554 | await recordMigration(qb, file, dialect) | |
| 555 | if (!supportsTransactionalDdl(dialect)) | |
| 556 | info(`-- (${dialect} commits DDL implicitly, so ${file} was not applied atomically)`) | |
| 557 | else | |
| 558 | info(`-- (${file} contains a statement that cannot run in a transaction, so it was not applied atomically)`) | |
| 559 | } | |
| 560 | info(`-- ✓ Migration ${file} executed and recorded`) | |
| 561 | } | |
| 562 | catch (err) { | |
| 563 | // A generated migration whose change is already in place is not a | |
| 564 | // failure, it is a bookkeeping gap: the file was applied on a run that | |
| 565 | // died before recording it, or under the old transient behaviour that | |
| 566 | // never recorded it at all. Record it and carry on — the schema is | |
| 567 | // where the file says it should be. Anything else, including every | |
| 568 | // authored migration, fails loudly. | |
| 569 | if (isGeneratedMigration(sqlDir, file) && isAlreadyAppliedError(err)) { | |
| 570 | await recordMigration(qb, file, dialect) | |
| 571 | info(`-- ✓ Migration ${file} was already applied; recorded it (${err instanceof Error ? err.message : String(err)})`) | |
| 572 | continue | |
| 573 | } | |
| 574 | console.error(`-- ✗ Migration ${file} failed:`, err) | |
| 575 | throw err | |
| 465 | 576 | } |
| 466 | console.error(`-- ✗ Migration ${file} failed:`, err) | |
| 467 | throw err | |
| 468 | 577 | } |
| 469 | } | |
| 470 | 578 | |
| 471 | info('-- All migrations executed successfully') | |
| 579 | info('-- All migrations executed successfully') | |
| 580 | return true | |
| 581 | }) | |
| 472 | 582 | } |
| 473 | 583 | catch (err) { |
| 474 | 584 | console.error('-- Migration execution failed:', err) |
| @@ -2496,6 +2496,20 @@ finally { reserved.release() } | ||
| 2496 | 2496 | advisoryLock?: (key: number | string) => Promise<void> |
| 2497 | 2497 | /** Try to take an advisory lock and return false if unavailable (PostgreSQL only). */ |
| 2498 | 2498 | tryAdvisoryLock?: (key: number | string) => Promise<boolean> |
| 2499 | /** | |
| 2500 | * Release an advisory lock taken on THIS builder's connection. | |
| 2501 | * | |
| 2502 | * Both lock helpers existed without this one, which made them a trap rather | |
| 2503 | * than a tool: a Postgres session lock is held until it is released or the | |
| 2504 | * SESSION ends, and a pooled connection does not end when it is returned to | |
| 2505 | * the pool. So `advisoryLock()` on a pooled builder leaked the lock onto | |
| 2506 | * whichever connection happened to serve it, and the next caller waited | |
| 2507 | * forever on a lock nobody was holding on purpose. | |
| 2508 | * | |
| 2509 | * Take the lock on a `reserve()`d connection and release it through the same | |
| 2510 | * builder. | |
| 2511 | */ | |
| 2512 | advisoryUnlock?: (key: number | string) => Promise<boolean> | |
| 2499 | 2513 | /** Get all relationships defined for a table. */ |
| 2500 | 2514 | getRelationships?: (table: string) => Record<string, any> |
| 2501 | 2515 | /** Check if a table has a specific relationship. */ |
| @@ -2781,6 +2795,23 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 2781 | 2795 | const meta = state?.meta |
| 2782 | 2796 | const schema = state?.schema |
| 2783 | 2797 | |
| 2798 | /** | |
| 2799 | * The 32-bit key Postgres advisory locks take, derived from a string name. | |
| 2800 | * | |
| 2801 | * Extracted because lock, try-lock and unlock each carried their own copy: | |
| 2802 | * three transcriptions of one hash, where any drift between them means | |
| 2803 | * `advisoryUnlock` computes a different key than `advisoryLock` did and | |
| 2804 | * silently releases nothing. | |
| 2805 | */ | |
| 2806 | function advisoryLockKey(key: number | string): number { | |
| 2807 | if (typeof key === 'number') | |
| 2808 | return key | |
| 2809 | const s = String(key) | |
| 2810 | let hash = 7 | |
| 2811 | for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0 | |
| 2812 | return Math.abs(hash) | |
| 2813 | } | |
| 2814 | ||
| 2784 | 2815 | /** |
| 2785 | 2816 | * The hooks this builder should fire. |
| 2786 | 2817 | * |
| @@ -7025,10 +7056,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7025 | 7056 | }, |
| 7026 | 7057 | async advisoryLock(key: number | string): Promise<void> { |
| 7027 | 7058 | if (config.dialect === 'postgres') { |
| 7028 | const s = String(key) | |
| 7029 | let hash = 7 | |
| 7030 | for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0 | |
| 7031 | const k = typeof key === 'number' ? key : Math.abs(hash) | |
| 7059 | const k = advisoryLockKey(key) | |
| 7032 | 7060 | const q = _sql`SELECT pg_advisory_lock(${k})` |
| 7033 | 7061 | await runWithHooks<any[]>(q, 'raw') |
| 7034 | 7062 | return |
| @@ -7049,10 +7077,7 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7049 | 7077 | }, |
| 7050 | 7078 | async tryAdvisoryLock(key: number | string): Promise<boolean> { |
| 7051 | 7079 | if (config.dialect === 'postgres') { |
| 7052 | const s = String(key) | |
| 7053 | let hash = 7 | |
| 7054 | for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0 | |
| 7055 | const k = typeof key === 'number' ? key : Math.abs(hash) | |
| 7080 | const k = advisoryLockKey(key) | |
| 7056 | 7081 | const q = _sql`SELECT pg_try_advisory_lock(${k}) as ok` |
| 7057 | 7082 | const rows = await runWithHooks<any[]>(q, 'raw') |
| 7058 | 7083 | return Boolean(rows?.[0]?.ok) |
| @@ -7071,6 +7096,24 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 7071 | 7096 | // when locks aren't held. |
| 7072 | 7097 | return false |
| 7073 | 7098 | }, |
| 7099 | async advisoryUnlock(key: number | string): Promise<boolean> { | |
| 7100 | // Must run on the same connection that took the lock — hence `_sql`, | |
| 7101 | // and hence the requirement that callers hold a reserved builder. | |
| 7102 | if (config.dialect === 'postgres') { | |
| 7103 | const k = advisoryLockKey(key) | |
| 7104 | const q = _sql`SELECT pg_advisory_unlock(${k}) as ok` | |
| 7105 | const rows = await runWithHooks<any[]>(q, 'raw') | |
| 7106 | return Boolean(rows?.[0]?.ok) | |
| 7107 | } | |
| 7108 | if (isMysqlLike(config.dialect)) { | |
| 7109 | const lockName = `bqb:${String(key)}` | |
| 7110 | const q = _sql`SELECT RELEASE_LOCK(${lockName}) AS ok` | |
| 7111 | const rows = await runWithHooks<any[]>(q, 'raw') | |
| 7112 | return Number(rows?.[0]?.ok) === 1 | |
| 7113 | } | |
| 7114 | // SQLite never took one; mirror tryAdvisoryLock's graceful shape. | |
| 7115 | return false | |
| 7116 | }, | |
| 7074 | 7117 | unsafe(query: string, params?: any[]) { |
| 7075 | 7118 | // Use this builder's connection. Transaction callbacks receive a |
| 7076 | 7119 | // builder whose `_sql` is the reserved transaction connection; routing |
| @@ -0,0 +1,157 @@ | ||
| 1 | /** | |
| 2 | * Regression coverage for stacksjs/bun-query-builder#1067. | |
| 3 | * | |
| 4 | * `executeMigration` applied each `.sql` file with no transaction and no lock, | |
| 5 | * recording it afterwards. Two consequences, and they need different dialects | |
| 6 | * to observe, which is worth stating because it is not obvious: | |
| 7 | * | |
| 8 | * - PARTIAL APPLICATION shows up on SQLite. The sqlite wrapper splits a file | |
| 9 | * into statements and runs them one at a time, so a file that fails partway | |
| 10 | * leaves the earlier statements committed. (Postgres does not reproduce it | |
| 11 | * through this path: Bun sends the file as one multi-statement query, which | |
| 12 | * the server already runs in an implicit transaction. The wrap still closes | |
| 13 | * the apply/record gap there, and matters for anyone applying files another | |
| 14 | * way — but the statement-level hole is a sqlite one.) | |
| 15 | * | |
| 16 | * - CONCURRENT RUNS need a real pooled driver, so that half is gated on a live | |
| 17 | * Postgres. SQLite has no advisory-lock primitive and a single-writer file, | |
| 18 | * so it has no collision to prevent. | |
| 19 | */ | |
| 20 | ||
| 21 | import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' | |
| 22 | import { tmpdir } from 'node:os' | |
| 23 | import { join, resolve } from 'node:path' | |
| 24 | import process from 'node:process' | |
| 25 | import { SQL } from 'bun' | |
| 26 | import { describe, expect, it } from 'bun:test' | |
| 27 | ||
| 28 | const SRC = resolve(import.meta.dir, '../src/index.ts') | |
| 29 | const PG_URL = `postgres://${process.env.USER}@localhost:5432/postgres` | |
| 30 | ||
| 31 | let pgAvailable = false | |
| 32 | try { | |
| 33 | const probe = new SQL(PG_URL) | |
| 34 | await probe.unsafe('SELECT 1') | |
| 35 | await probe.end() | |
| 36 | pgAvailable = true | |
| 37 | } | |
| 38 | catch { | |
| 39 | pgAvailable = false | |
| 40 | } | |
| 41 | ||
| 42 | /** A throwaway workspace with a migrations directory. */ | |
| 43 | function workspace(files: Record<string, string>): string { | |
| 44 | const dir = mkdtempSync(join(tmpdir(), 'qb-mig-')) | |
| 45 | writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'probe' })) | |
| 46 | const migrations = join(dir, 'database', 'migrations') | |
| 47 | mkdirSync(migrations, { recursive: true }) | |
| 48 | for (const [name, body] of Object.entries(files)) | |
| 49 | writeFileSync(join(migrations, name), body) | |
| 50 | return dir | |
| 51 | } | |
| 52 | ||
| 53 | function run(dir: string, name: string, script: string): { code: number, out: string, err: string } { | |
| 54 | const path = join(dir, name) | |
| 55 | writeFileSync(path, script) | |
| 56 | const proc = Bun.spawnSync({ cmd: ['bun', path], cwd: dir, stdout: 'pipe', stderr: 'pipe', env: { ...process.env } }) | |
| 57 | return { | |
| 58 | code: proc.exitCode ?? -1, | |
| 59 | out: new TextDecoder().decode(proc.stdout).trim(), | |
| 60 | err: new TextDecoder().decode(proc.stderr).trim(), | |
| 61 | } | |
| 62 | } | |
| 63 | ||
| 64 | const lastJson = (out: string): any => JSON.parse(out.split('\n').filter(Boolean).at(-1) ?? '{}') | |
| 65 | ||
| 66 | describe('migration atomicity (#1067)', () => { | |
| 67 | it('rolls the whole file back when one of its statements fails', () => { | |
| 68 | // Statement 1 is valid, statement 2 is not. Unwrapped, `mig_a` survives the | |
| 69 | // failure and the file is left neither applied nor cleanly re-runnable. | |
| 70 | const dir = workspace({ | |
| 71 | '0000000001-partial.sql': [ | |
| 72 | 'CREATE TABLE mig_a (id INTEGER PRIMARY KEY);', | |
| 73 | 'CREATE TABLE mig_b (id INTEGER PRIMARY KEY, oops NOT_A_TYPE CHECK());', | |
| 74 | ].join('\n'), | |
| 75 | }) | |
| 76 | ||
| 77 | const result = run(dir, 'probe.ts', ` | |
| 78 | import { Database } from 'bun:sqlite' | |
| 79 | import { setConfig, executeMigration } from ${JSON.stringify(SRC)} | |
| 80 | ||
| 81 | setConfig({ dialect: 'sqlite', database: { database: './t.sqlite' }, verbose: false }) | |
| 82 | try { await executeMigration(process.cwd()) } catch {} | |
| 83 | ||
| 84 | const db = new Database('./t.sqlite') | |
| 85 | // Explicit names, not a LIKE: 'mig_%' also matches the 'migrations' ledger. | |
| 86 | const tables = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name IN ('mig_a','mig_b')").all() | |
| 87 | console.log(JSON.stringify({ leftBehind: tables.map(r => r.name) })) | |
| 88 | `) | |
| 89 | ||
| 90 | rmSync(dir, { recursive: true, force: true }) | |
| 91 | if (result.code !== 0) | |
| 92 | throw new Error(`probe exited ${result.code}: ${result.err || result.out}`) | |
| 93 | ||
| 94 | // Was ['mig_a'] — the first statement had already committed. | |
| 95 | expect(lastJson(result.out).leftBehind).toEqual([]) | |
| 96 | }) | |
| 97 | }) | |
| 98 | ||
| 99 | describe.skipIf(!pgAvailable)('migration locking (#1067)', () => { | |
| 100 | it('serialises concurrent runs instead of letting both apply', () => { | |
| 101 | // Both processes start against an empty ledger, so without the lock both | |
| 102 | // see the migration as pending, both run it, and the loser dies on | |
| 103 | // 'relation already exists'. | |
| 104 | // `pg_sleep` widens the window on purpose. Without it the two processes may | |
| 105 | // simply not overlap, and the test passes whether or not the lock exists — | |
| 106 | // a concurrency test that only sometimes exercises concurrency is worse | |
| 107 | // than none. One second is far longer than the other process needs to reach | |
| 108 | // the same point, so they are guaranteed to contend. | |
| 109 | const dir = workspace({ | |
| 110 | '0000000001-concurrent.sql': [ | |
| 111 | 'SELECT pg_sleep(1);', | |
| 112 | 'CREATE TABLE mig_concurrent (id serial primary key);', | |
| 113 | ].join('\n'), | |
| 114 | }) | |
| 115 | ||
| 116 | writeFileSync(join(dir, 'worker.ts'), ` | |
| 117 | import { setConfig, executeMigration } from ${JSON.stringify(SRC)} | |
| 118 | setConfig({ dialect: 'postgres', database: { url: ${JSON.stringify(PG_URL)} }, verbose: false }) | |
| 119 | await executeMigration(process.cwd()) | |
| 120 | `) | |
| 121 | ||
| 122 | const result = run(dir, 'probe.ts', ` | |
| 123 | import { SQL } from 'bun' | |
| 124 | // Clear only this migration's traces — the ledger is shared with the rest of | |
| 125 | // the suite, so dropping it wholesale would delete other files' bookkeeping. | |
| 126 | const reset = new SQL(${JSON.stringify(PG_URL)}) | |
| 127 | await reset.unsafe('DROP TABLE IF EXISTS mig_concurrent') | |
| 128 | await reset.unsafe("DELETE FROM migrations WHERE migration = '0000000001-concurrent.sql'").catch(() => {}) | |
| 129 | await reset.end() | |
| 130 | ||
| 131 | const a = Bun.spawn({ cmd: ['bun', 'worker.ts'], cwd: process.cwd(), stdout: 'pipe', stderr: 'pipe' }) | |
| 132 | const b = Bun.spawn({ cmd: ['bun', 'worker.ts'], cwd: process.cwd(), stdout: 'pipe', stderr: 'pipe' }) | |
| 133 | const codes = await Promise.all([a.exited, b.exited]) | |
| 134 | ||
| 135 | // Count only THIS migration. The suite shares one Postgres database, so other | |
| 136 | // files leave rows in the ledger and a total count is not ours to assert on. | |
| 137 | const check = new SQL(${JSON.stringify(PG_URL)}) | |
| 138 | const [{ count }] = await check.unsafe( | |
| 139 | "SELECT count(*)::int as count FROM migrations WHERE migration = '0000000001-concurrent.sql'", | |
| 140 | ) | |
| 141 | await check.unsafe('DROP TABLE IF EXISTS mig_concurrent') | |
| 142 | await check.unsafe("DELETE FROM migrations WHERE migration = '0000000001-concurrent.sql'") | |
| 143 | await check.end() | |
| 144 | console.log(JSON.stringify({ codes, recorded: count })) | |
| 145 | `) | |
| 146 | ||
| 147 | rmSync(dir, { recursive: true, force: true }) | |
| 148 | if (result.code !== 0) | |
| 149 | throw new Error(`probe exited ${result.code}: ${result.err || result.out}`) | |
| 150 | ||
| 151 | const { codes, recorded } = lastJson(result.out) | |
| 152 | // Both succeed: the loser waits for the lock, then finds nothing pending. | |
| 153 | expect(codes).toEqual([0, 0]) | |
| 154 | // And it is recorded once, not twice. | |
| 155 | expect(recorded).toBe(1) | |
| 156 | }) | |
| 157 | }) | |