Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,48 @@ Files should be named using the format: `XXX_description.ts` where XXX is a sequ

The migrator loads migration files with dynamic `import()`. Bun can load TypeScript migrations directly. In Node.js, run your migration script with a TypeScript runtime such as `tsx`, or compile migrations to JavaScript and use `.js` migration files. You can also customize loaded extensions with `fileExtensions`; `.d.ts` files are always ignored.

### Providing Context

A second argument can be provided to migration file functions in order to supply your own context. Context should be passed in `apply` or `rollback`.

To enforce type correctness, type the `Migrator` and use `satisfies` in your migration files:

```typescript
import type { SqliteDatabase, Migration } from 'sqlite-up';

interface MyContext {
foo: string;
}

export const up: Migration<MyContext>['up'] = async (db: SqliteDatabase, ctx: MyContext): Promise<void> => {
// Migration code here
// context is passed as ctx
};

export const down: Migration<MyContext>['down'] = async (db: SqliteDatabase, ctx: MyContext): Promise<void> => {
// Rollback code here
// context is passed as ctx
};
```

```typescript
import { DatabaseSync } from 'node:sqlite';
import { Migrator } from 'sqlite-up';

async function main() {
const db = new DatabaseSync('myapp.db');

const migrator = new Migrator<MyContext>({
db,
migrationsDir: './migrations',
});

const result = await migrator.apply({ foo: 'bar' });
}

main().catch(console.error);
```

## Error Handling

```typescript
Expand Down
41 changes: 41 additions & 0 deletions src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,47 @@ describe('Migrator', () => {
expect(tables).toHaveLength(0);
});

it('should pass typed context to apply and rollback migrations', async () => {
await fs.writeFile(
path.join(migrationsDir, '003_context.ts'),
`
export function up(db, ctx) {
if (ctx.prefix !== 'test') {
throw new Error('Unexpected apply context');
}

db.exec('CREATE TABLE context_values (value TEXT)');
db.prepare('INSERT INTO context_values (value) VALUES (?)').run(ctx.prefix);
}

export function down(db, ctx) {
if (ctx.prefix !== 'rollback') {
throw new Error('Unexpected rollback context');
}

db.exec('DROP TABLE context_values');
}
`
);

const contextMigrator = new Migrator<{ prefix: string }>({
db,
migrationsDir,
});

const applyResult = await contextMigrator.apply({ prefix: 'test' });
expect(applyResult.success).toBe(true);

const row = db.prepare('SELECT value FROM context_values').get() as { value: string };
expect(row.value).toBe('test');

const rollbackResult = await contextMigrator.rollback({ prefix: 'rollback' });
expect(rollbackResult.success).toBe(true);

const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='context_values'").all();
expect(tables).toHaveLength(0);
});

it('should not report or emit rolled back migrations when rollback transaction fails', async () => {
await fs.writeFile(
path.join(migrationsDir, '001_users.ts'),
Expand Down
20 changes: 11 additions & 9 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from 'node:path';
import { MigrationError, MigrationExecutionError, MigrationFileError, MigrationLockError } from './errors.js';
import { createSqliteProvider, type SqliteProvider } from './providers/index.js';
import type {
ContextArg,
MaybePromise,
Migration,
MigrationPlan,
Expand All @@ -21,7 +22,7 @@ import type {
* const migrator = new Migrator({ db, migrationsDir: 'migrations' });
* await migrator.apply();
*/
export class Migrator extends EventEmitter {
export class Migrator<T = undefined> extends EventEmitter {
/**
* Driver-specific provider normalized to the sqlite-up database surface.
*/
Expand Down Expand Up @@ -50,7 +51,7 @@ export class Migrator extends EventEmitter {
/**
* Migration modules loaded from disk in execution order.
*/
private migrations: Migration[] = [];
private migrations: Migration<T>[] = [];

/**
* Whether internal tables and migration files have already been initialized.
Expand Down Expand Up @@ -141,13 +142,13 @@ export class Migrator extends EventEmitter {
})
.sort();

const loadedMigrations: Migration[] = [];
const loadedMigrations: Migration<T>[] = [];
for (const file of migrationFiles) {
const fullPath = path.join(this.migrationsDir, file);

let imported: {
up: (db: SqliteDatabase) => MaybePromise<void>;
down: (db: SqliteDatabase) => MaybePromise<void>;
up: Migration<T>['up'];
down: Migration<T>['down'];
};

try {
Expand Down Expand Up @@ -260,7 +261,7 @@ export class Migrator extends EventEmitter {
* Apply all pending migrations in a single batch.
* Returns the names of applied migrations.
*/
async apply(): Promise<MigrationResult> {
async apply(...args: ContextArg<T>): Promise<MigrationResult> {
// Initialize the migrator
try {
await this.init();
Expand Down Expand Up @@ -306,7 +307,7 @@ export class Migrator extends EventEmitter {
for (const migration of pendingMigrations) {
try {
// Apply migration
await migration.up(this.db);
await migration.up(this.db, ...args);

// Record migration
await this.recordMigration(migration.name, nextBatch);
Expand Down Expand Up @@ -337,7 +338,7 @@ export class Migrator extends EventEmitter {
* Roll back the most recent batch of migrations.
* Returns the names of rolled back migrations.
*/
async rollback(): Promise<MigrationResult> {
async rollback(...args: ContextArg<T>): Promise<MigrationResult> {
// Initialize the migrator
try {
await this.init();
Expand Down Expand Up @@ -395,7 +396,7 @@ export class Migrator extends EventEmitter {

try {
// Revert migration
await migration.down(this.db);
await migration.down(this.db, ...args);

// Remove migration record
await this.removeMigration(migration.name, currentBatch);
Expand Down Expand Up @@ -499,6 +500,7 @@ export {
} from './errors.js';
export type {
BunSqliteDatabase,
ContextArg,
MaybePromise,
Migration,
MigrationPlan,
Expand Down
50 changes: 50 additions & 0 deletions src/types.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, expectTypeOf, it } from 'vitest';

import type { Migrator } from './index';
import type { ContextArg, Migration, MigrationResult, SqliteDatabase } from './types';

interface MyContext {
foo: string;
}

describe('migration context types', () => {
it('should allow typed migration callbacks to require context', () => {
const up: Migration<MyContext>['up'] = async (_db: SqliteDatabase, ctx: MyContext): Promise<void> => {
expectTypeOf(ctx.foo).toEqualTypeOf<string>();
};

const down: Migration<MyContext>['down'] = async (_db: SqliteDatabase, ctx: MyContext): Promise<void> => {
expectTypeOf(ctx.foo).toEqualTypeOf<string>();
};

expectTypeOf(up).toEqualTypeOf<Migration<MyContext>['up']>();
expectTypeOf(down).toEqualTypeOf<Migration<MyContext>['down']>();
});

it('should keep the public migration name property compatible', () => {
const migration: Migration = {
name: '001_init.ts',
up: async () => {},
down: async () => {},
};

expectTypeOf(migration.name).toEqualTypeOf<string>();
});

it('should require context for typed migrator apply and rollback calls', () => {
expectTypeOf<Parameters<Migrator<MyContext>['apply']>>().toEqualTypeOf<[value: MyContext]>();
expectTypeOf<Parameters<Migrator<MyContext>['rollback']>>().toEqualTypeOf<[value: MyContext]>();
expectTypeOf<ReturnType<Migrator<MyContext>['apply']>>().toEqualTypeOf<Promise<MigrationResult>>();
expectTypeOf<ReturnType<Migrator<MyContext>['rollback']>>().toEqualTypeOf<Promise<MigrationResult>>();

expectTypeOf<Parameters<Migrator['apply']>>().toEqualTypeOf<[]>();
expectTypeOf<Parameters<Migrator['rollback']>>().toEqualTypeOf<[]>();
expectTypeOf<ReturnType<Migrator['apply']>>().toEqualTypeOf<Promise<MigrationResult>>();
expectTypeOf<ReturnType<Migrator['rollback']>>().toEqualTypeOf<Promise<MigrationResult>>();
});

it('should treat context unions as one required argument', () => {
expectTypeOf<ContextArg<MyContext | undefined>>().toEqualTypeOf<[ctx: MyContext | undefined]>();
expectTypeOf<Parameters<Migrator<MyContext | undefined>['apply']>>().toEqualTypeOf<[ctx: MyContext | undefined]>();
});
});
12 changes: 9 additions & 3 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
*/
export type MaybePromise<T> = T | Promise<T>;

/**
* Optional context argument tuple. Context is omitted by default and required
* when a migrator or migration is typed with a context value.
*/
export type ContextArg<T> = [T] extends [undefined] ? [] : [ctx: T];

/**
* Result returned by SQLite statement execution.
*/
Expand Down Expand Up @@ -135,7 +141,7 @@ export interface MigratorOptions {
/**
* Represents a single migration file/module.
*/
export interface Migration {
export interface Migration<T = undefined> {
/**
* Name of the migration (derived from filename)
*/
Expand All @@ -144,12 +150,12 @@ export interface Migration {
/**
* Function to apply the migration
*/
up: (db: SqliteDatabase) => MaybePromise<void>;
up: (db: SqliteDatabase, ...args: ContextArg<T>) => MaybePromise<void>;

/**
* Function to revert the migration
*/
down: (db: SqliteDatabase) => MaybePromise<void>;
down: (db: SqliteDatabase, ...args: ContextArg<T>) => MaybePromise<void>;
}

/**
Expand Down
Loading