// MIT License // Copyright (c) 2025 George Koutsikos // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // The above copyright notice and this permission notice shall be included in all // copies or substantial portions of the Software. // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE // SOFTWARE. /** * Quick "How to use" guide: * - Place migrator.ts in the `src/` directory of your project. * - Make a `src/migrations/` directory. * - Create one or more files like: * ```typescript * import { TransactionSQL } from 'bun'; * * export async function forward(tx: TransactionSQL) { * // Your code here * }; * * export async function backward(tx: TransactionSQL) { * // Your code here * }; * ``` * - Call `runMigrations` with your `SQL` instance. **/ import { SQL, TransactionSQL } from 'bun'; import { readdir } from 'node:fs/promises'; import { join, sep, basename } from 'node:path'; async function ensureMigrationsTable(sql: SQL) { await sql.begin(async tx => { await tx` create table if not exists migrations ( pk serial primary key, applied_on timestamp default now(), filename varchar(120) not null, unique(filename) ); `; await tx` create index if not exists idx_migrations_applied_on ON migrations (applied_on DESC); `; }); } async function getAppliedMigrations(sql: SQL): string[] { const data = await sql` select filename from migrations order by applied_on asc `.values(); if (data.length === 0) { return null; } return data; } async function markApplied(tx: TransactionSQL, filename: string) { await tx` insert into migrations (filename) values (${basename(filename)}) returning * `; } async function dropApplied(tx: TransactionSQL, filename: string) { await tx` delete from migrations where filename=${basename(filename)} returning * `; } async function getTargetMigrations(sql: SQL, migrationFilenames: string[], rollback: boolean = false): string[] { // Limit how many migrations need to be applied based on // the (existence and) value of the migrations already applied. const appliedFilenames = await getAppliedMigrations(sql); if (appliedFilenames === null || appliedFilenames.length === 0) { if (rollback) { // Nothing to rollback return []; } // Everything is up for applying return migrationFilenames; } if (appliedFilenames.length > migrationFilenames.length) { throw new Error("Migrations Error: There are more migrations applied than migration files."); } // Verify that whatever is applied still exists as file // and track the fullpath to return later. const appliedFullpath = []; for (let i=0; i= numberOfSteps) { break; } const { forward, backward } = await import(filename); const {action, postAction, msg} = rollback ? { action: backward, postAction: dropApplied, msg: `-- Rolling back: ${filename}` } : { action: forward, postAction: markApplied, msg: `-- Applying: ${filename}` }; await sql.begin(async tx => { console.log(msg); await action(tx); await postAction(tx, filename); }); stepCount += 1; } } export async function runMigrations(sql: SQL, numberOfSteps: int, rollback: boolean = false) { await ensureMigrationsTable(sql); const migrationsDir = join(import.meta.dir, '/migrations'); const filenames = await readdir(migrationsDir); const migrationPaths = filenames.map(f => '.' + sep + join('migrations', f)); const targetSteps = numberOfSteps === 0 ? migrationPaths.length : Math.abs(numberOfSteps); // Resolve which migrations need to be executed const targetMigrationFiles = await getTargetMigrations(sql, migrationPaths, rollback); if (targetMigrationFiles.length === 0) { console.log("-- Nothing to do."); return; } return await executeMigrationFiles(sql, targetMigrationFiles, targetSteps, rollback); };