From a2935ea0bc1acd44373baea0dbdd4e564862bc91 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 13 May 2026 14:23:45 -0400 Subject: [PATCH 1/4] feat: Add optional context to migrations * Implements an optional, typed second argument to up/down that is passed through apply/rollback * Allows users to provide their own context for migration code * Update docs to provide context example Closes #2 --- README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 18 +++++++++--------- src/types.ts | 19 ++++++++++++------- 3 files changed, 66 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 725adae..c9fbe3d 100644 --- a/README.md +++ b/README.md @@ -283,6 +283,51 @@ 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 +} + +const up = async (db: SqliteDatabase, ctx: MyContext): Promise => { + // Migration code here + // context is passed as ctx +}; + +const down = async (db: SqliteDatabase, ctx: MyContext): Promise => { + // Rollback code here + // context is passed as ctx +}; + +const exports = { up, down } satisfies Migration; +export { up, down }; +``` + +```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.ts b/src/index.ts index 152b859..3e04972 100644 --- a/src/index.ts +++ b/src/index.ts @@ -5,7 +5,7 @@ import { MigrationError, MigrationExecutionError, MigrationFileError, MigrationL import { createSqliteProvider, type SqliteProvider } from './providers/index.js'; import type { MaybePromise, - Migration, + NamedMigration, MigrationPlan, MigrationRecord, MigrationResult, @@ -21,7 +21,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 +50,7 @@ export class Migrator extends EventEmitter { /** * Migration modules loaded from disk in execution order. */ - private migrations: Migration[] = []; + private migrations: NamedMigration[] = []; /** * Whether internal tables and migration files have already been initialized. @@ -141,7 +141,7 @@ export class Migrator extends EventEmitter { }) .sort(); - const loadedMigrations: Migration[] = []; + const loadedMigrations: NamedMigration[] = []; for (const file of migrationFiles) { const fullPath = path.join(this.migrationsDir, file); @@ -260,7 +260,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(ctx?: T): Promise { // Initialize the migrator try { await this.init(); @@ -306,7 +306,7 @@ export class Migrator extends EventEmitter { for (const migration of pendingMigrations) { try { // Apply migration - await migration.up(this.db); + await migration.up(this.db, ctx); // Record migration await this.recordMigration(migration.name, nextBatch); @@ -337,7 +337,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(ctx?: T): Promise { // Initialize the migrator try { await this.init(); @@ -395,7 +395,7 @@ export class Migrator extends EventEmitter { try { // Revert migration - await migration.down(this.db); + await migration.down(this.db, ctx); // Remove migration record await this.removeMigration(migration.name, currentBatch); @@ -500,7 +500,7 @@ export { export type { BunSqliteDatabase, MaybePromise, - Migration, + NamedMigration as Migration, MigrationPlan, MigrationRecord, MigrationResult, diff --git a/src/types.ts b/src/types.ts index 26ac968..d2fd0dc 100644 --- a/src/types.ts +++ b/src/types.ts @@ -135,21 +135,26 @@ export interface MigratorOptions { /** * Represents a single migration file/module. */ -export interface Migration { +export interface Migration { /** - * Name of the migration (derived from filename) + * Function to apply the migration */ - name: string; + up: (db: SqliteDatabase, ctx?: T) => MaybePromise; /** - * Function to apply the migration + * Function to revert the migration */ - up: (db: SqliteDatabase) => MaybePromise; + down: (db: SqliteDatabase, ctx?: T) => MaybePromise; +} +/** + * Represents a single migration file/module after being loaded + */ +export interface NamedMigration extends Migration { /** - * Function to revert the migration + * Name of the migration (derived from filename) */ - down: (db: SqliteDatabase) => MaybePromise; + name: string; } /** From 7280b88c93d62feb966b5ae66bc9524a8b77973e Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 13 May 2026 15:00:02 -0400 Subject: [PATCH 2/4] fix: Enforce rollback/apply arg if Migrator is typed --- src/index.ts | 13 +++++++------ src/types.ts | 5 +++++ 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/index.ts b/src/index.ts index 3e04972..d607b54 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,7 @@ import type { MigrationStatus, MigratorOptions, SqliteDatabase, + OptionalArg, } from './types.js'; /** @@ -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. */ @@ -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(ctx?: T): Promise { + async apply(...args: OptionalArg): 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, ctx); + await migration.up(this.db, args[0]); // 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(ctx?: T): Promise { + async rollback(...args: OptionalArg): 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, ctx); + await migration.down(this.db, args[0]); // Remove migration record await this.removeMigration(migration.name, currentBatch); @@ -500,7 +501,7 @@ export { export type { BunSqliteDatabase, MaybePromise, - NamedMigration as Migration, + Migration, MigrationPlan, MigrationRecord, MigrationResult, diff --git a/src/types.ts b/src/types.ts index d2fd0dc..34caa12 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,6 +3,11 @@ */ export type MaybePromise = T | Promise; +/** + * Argument defaults to undefined but is required if T is not undefined + */ +export type OptionalArg = T extends undefined ? [] : [value: T]; + /** * Result returned by SQLite statement execution. */ From 644728b624caa2a7129d8afb283ef6cdc73f1b79 Mon Sep 17 00:00:00 2001 From: FoxxMD Date: Wed, 13 May 2026 15:00:17 -0400 Subject: [PATCH 3/4] docs: Simplify typing example for migration files with context --- README.md | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index c9fbe3d..145a5fd 100644 --- a/README.md +++ b/README.md @@ -296,18 +296,15 @@ interface MyContext = { foo: string } -const up = async (db: SqliteDatabase, ctx: MyContext): Promise => { +export const up: Migration['up'] = async (db: SqliteDatabase, ctx: MyContext): Promise => { // Migration code here // context is passed as ctx }; -const down = async (db: SqliteDatabase, ctx: MyContext): Promise => { +export const down: Migration['down'] = async (db: SqliteDatabase, ctx: MyContext): Promise => { // Rollback code here // context is passed as ctx }; - -const exports = { up, down } satisfies Migration; -export { up, down }; ``` ```typescript From 84d0b43f3308cf934a23aad0428a102b8292ae43 Mon Sep 17 00:00:00 2001 From: Sandrino Di Mattia <655409+sandrinodimattia@users.noreply.github.com> Date: Sat, 16 May 2026 18:34:01 +0200 Subject: [PATCH 4/4] chore: tighten migration context typing --- README.md | 6 +++--- src/index.test.ts | 41 ++++++++++++++++++++++++++++++++++++++ src/index.ts | 21 ++++++++++---------- src/types.test.ts | 50 +++++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 24 ++++++++++------------- 5 files changed, 115 insertions(+), 27 deletions(-) create mode 100644 src/types.test.ts diff --git a/README.md b/README.md index 145a5fd..6d08c3e 100644 --- a/README.md +++ b/README.md @@ -292,8 +292,8 @@ To enforce type correctness, type the `Migrator` and use `satisfies` in your mig ```typescript import type { SqliteDatabase, Migration } from 'sqlite-up'; -interface MyContext = { - foo: string +interface MyContext { + foo: string; } export const up: Migration['up'] = async (db: SqliteDatabase, ctx: MyContext): Promise => { @@ -319,7 +319,7 @@ async function main() { migrationsDir: './migrations', }); - const result = await migrator.apply({foo: 'bar'}); + const result = await migrator.apply({ foo: 'bar' }); } main().catch(console.error); 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 d607b54..263dace 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,15 +4,15 @@ 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, - NamedMigration, + Migration, MigrationPlan, MigrationRecord, MigrationResult, MigrationStatus, MigratorOptions, SqliteDatabase, - OptionalArg, } from './types.js'; /** @@ -51,7 +51,7 @@ export class Migrator extends EventEmitter { /** * Migration modules loaded from disk in execution order. */ - private migrations: NamedMigration[] = []; + private migrations: Migration[] = []; /** * Whether internal tables and migration files have already been initialized. @@ -142,13 +142,13 @@ export class Migrator extends EventEmitter { }) .sort(); - const loadedMigrations: NamedMigration[] = []; + 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 { @@ -261,7 +261,7 @@ export class Migrator extends EventEmitter { * Apply all pending migrations in a single batch. * Returns the names of applied migrations. */ - async apply(...args: OptionalArg): Promise { + async apply(...args: ContextArg): Promise { // Initialize the migrator try { await this.init(); @@ -307,7 +307,7 @@ export class Migrator extends EventEmitter { for (const migration of pendingMigrations) { try { // Apply migration - await migration.up(this.db, args[0]); + await migration.up(this.db, ...args); // Record migration await this.recordMigration(migration.name, nextBatch); @@ -338,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(...args: OptionalArg): Promise { + async rollback(...args: ContextArg): Promise { // Initialize the migrator try { await this.init(); @@ -396,7 +396,7 @@ export class Migrator extends EventEmitter { try { // Revert migration - await migration.down(this.db, args[0]); + await migration.down(this.db, ...args); // Remove migration record await this.removeMigration(migration.name, currentBatch); @@ -500,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 34caa12..ea0795a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,9 +4,10 @@ export type MaybePromise = T | Promise; /** - * Argument defaults to undefined but is required if T is not undefined + * Optional context argument tuple. Context is omitted by default and required + * when a migrator or migration is typed with a context value. */ -export type OptionalArg = T extends undefined ? [] : [value: T]; +export type ContextArg = [T] extends [undefined] ? [] : [ctx: T]; /** * Result returned by SQLite statement execution. @@ -140,26 +141,21 @@ export interface MigratorOptions { /** * Represents a single migration file/module. */ -export interface Migration { +export interface Migration { /** - * Function to apply the migration + * Name of the migration (derived from filename) */ - up: (db: SqliteDatabase, ctx?: T) => MaybePromise; + name: string; /** - * Function to revert the migration + * Function to apply the migration */ - down: (db: SqliteDatabase, ctx?: T) => MaybePromise; -} + up: (db: SqliteDatabase, ...args: ContextArg) => MaybePromise; -/** - * Represents a single migration file/module after being loaded - */ -export interface NamedMigration extends Migration { /** - * Name of the migration (derived from filename) + * Function to revert the migration */ - name: string; + down: (db: SqliteDatabase, ...args: ContextArg) => MaybePromise; } /**