Skip to content

Instantly share code, notes, and snippets.

@ragecryx
Last active July 8, 2025 19:39
Show Gist options
  • Save ragecryx/7107d2052f570b7a42b2e838c8285301 to your computer and use it in GitHub Desktop.
Save ragecryx/7107d2052f570b7a42b2e838c8285301 to your computer and use it in GitHub Desktop.
Small migration mechanism for bun.sh
// 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<appliedFilenames.length; ++i) {
if (migrationFilenames[i].endsWith(appliedFilenames[i])) {
appliedFullpath.push(migrationFilenames[i]);
} else {
throw new Error(`Migrations Error: Applied migration ${appliedFilenames[i]} doesnt exist as file or is in different order.`);
}
}
// Resolve the list of files and in which order they should be evaluated
if (rollback) {
return appliedFullpath.toReversed();
}
return migrationFilenames.slice(appliedFilenames.length);
}
async function executeMigrationFiles(sql: SQL, targetMigrationFiles: string[], numberOfSteps: int, rollback: boolean) {
let stepCount = 0;
for (let filename of targetMigrationFiles) {
if (stepCount >= 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);
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment