diff --git a/README.md b/README.md index 725adae..6d08c3e 100644 --- a/README.md +++ b/README.md @@ -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['up'] = async (db: SqliteDatabase, ctx: MyContext): Promise => { + // Migration code here + // context is passed as ctx +}; + +export const down: Migration['down'] = async (db: SqliteDatabase, ctx: MyContext): Promise => { + // 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({ + db, + migrationsDir: './migrations', + }); + + const result = await migrator.apply({ foo: 'bar' }); +} + +main().catch(console.error); +``` + ## Error Handling ```typescript diff --git a/src/index.test.ts b/src/index.test.ts index 14d9452..74632bf 100644 --- a/src/index.test.ts +++ b/src/index.test.ts @@ -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'), diff --git a/src/index.ts b/src/index.ts index 152b859..263dace 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, @@ -21,7 +22,7 @@ import type { * const migrator = new Migrator({ db, migrationsDir: 'migrations' }); * await migrator.apply(); */ -export class Migrator extends EventEmitter { +export class Migrator extends EventEmitter { /** * Driver-specific provider normalized to the sqlite-up database surface. */ @@ -50,7 +51,7 @@ export class Migrator extends EventEmitter { /** * Migration modules loaded from disk in execution order. */ - private migrations: Migration[] = []; + private migrations: Migration[] = []; /** * Whether internal tables and migration files have already been initialized. @@ -141,13 +142,13 @@ export class Migrator extends EventEmitter { }) .sort(); - const loadedMigrations: Migration[] = []; + const loadedMigrations: Migration[] = []; for (const file of migrationFiles) { const fullPath = path.join(this.migrationsDir, file); let imported: { - up: (db: SqliteDatabase) => MaybePromise; - down: (db: SqliteDatabase) => MaybePromise; + up: Migration['up']; + down: Migration['down']; }; try { @@ -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 { + async apply(...args: ContextArg): Promise { // Initialize the migrator try { await this.init(); @@ -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); @@ -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 { + async rollback(...args: ContextArg): Promise { // Initialize the migrator try { await this.init(); @@ -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); @@ -499,6 +500,7 @@ export { } from './errors.js'; export type { BunSqliteDatabase, + ContextArg, MaybePromise, Migration, MigrationPlan, diff --git a/src/types.test.ts b/src/types.test.ts new file mode 100644 index 0000000..6209cfd --- /dev/null +++ b/src/types.test.ts @@ -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['up'] = async (_db: SqliteDatabase, ctx: MyContext): Promise => { + expectTypeOf(ctx.foo).toEqualTypeOf(); + }; + + const down: Migration['down'] = async (_db: SqliteDatabase, ctx: MyContext): Promise => { + expectTypeOf(ctx.foo).toEqualTypeOf(); + }; + + expectTypeOf(up).toEqualTypeOf['up']>(); + expectTypeOf(down).toEqualTypeOf['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(); + }); + + it('should require context for typed migrator apply and rollback calls', () => { + expectTypeOf['apply']>>().toEqualTypeOf<[value: MyContext]>(); + expectTypeOf['rollback']>>().toEqualTypeOf<[value: MyContext]>(); + expectTypeOf['apply']>>().toEqualTypeOf>(); + expectTypeOf['rollback']>>().toEqualTypeOf>(); + + expectTypeOf>().toEqualTypeOf<[]>(); + expectTypeOf>().toEqualTypeOf<[]>(); + expectTypeOf>().toEqualTypeOf>(); + expectTypeOf>().toEqualTypeOf>(); + }); + + it('should treat context unions as one required argument', () => { + expectTypeOf>().toEqualTypeOf<[ctx: MyContext | undefined]>(); + expectTypeOf['apply']>>().toEqualTypeOf<[ctx: MyContext | undefined]>(); + }); +}); diff --git a/src/types.ts b/src/types.ts index 26ac968..ea0795a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,12 @@ */ export type MaybePromise = T | Promise; +/** + * 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] extends [undefined] ? [] : [ctx: T]; + /** * Result returned by SQLite statement execution. */ @@ -135,7 +141,7 @@ export interface MigratorOptions { /** * Represents a single migration file/module. */ -export interface Migration { +export interface Migration { /** * Name of the migration (derived from filename) */ @@ -144,12 +150,12 @@ export interface Migration { /** * Function to apply the migration */ - up: (db: SqliteDatabase) => MaybePromise; + up: (db: SqliteDatabase, ...args: ContextArg) => MaybePromise; /** * Function to revert the migration */ - down: (db: SqliteDatabase) => MaybePromise; + down: (db: SqliteDatabase, ...args: ContextArg) => MaybePromise; } /**