├── .gitattributes ├── examples ├── .gitignore ├── node_modules │ └── umzug │ │ ├── index.d.ts │ │ └── index.js ├── 3-raw-sql │ ├── migrations │ │ ├── down │ │ │ └── 2020.11.24T18.00.40.users.sql │ │ └── 2020.11.24T18.00.40.users.sql │ ├── migrate.js │ ├── readme.md │ └── umzug.ts ├── 7-bundling-codegen │ ├── .gitignore │ ├── tsconfig.json │ ├── eslint.config.js │ ├── package.json │ ├── migrations │ │ ├── 2020.12.09T19.24.31.users-table.ts │ │ ├── 2020.12.09T19.25.09.roles-table.ts │ │ └── barrel.ts │ ├── umzug.ts │ └── readme.md ├── 6-events │ ├── migrate.js │ ├── readme.md │ ├── migrations │ │ └── 2020.11.24T16.52.04.users-table.ts │ └── umzug.ts ├── 4-sequelize-seeders │ ├── seed.js │ ├── migrate.js │ ├── seeders │ │ ├── 2020.11.24T18.46.19.sample-users.ts │ │ └── 2020.11.24T19.01.37.sample-user-roles.ts │ ├── migrations │ │ ├── 2020.11.24T16.52.04.users-table.ts │ │ └── 2020.11.24T18.54.32.roles.ts │ ├── readme.md │ └── umzug.ts ├── 5-custom-template │ ├── migrate.js │ ├── readme.md │ ├── template │ │ └── sample-migration.ts │ ├── migrations │ │ └── 2020.11.24T16.52.04.users-table.ts │ └── umzug.ts ├── 1-sequelize-typescript │ ├── migrate.js │ ├── umzug.ts │ ├── migrations │ │ └── 2020.11.24T16.52.04.users-table.ts │ └── readme.md ├── readme.md ├── 0-vanilla │ ├── migrate.js │ ├── migrations │ │ └── 2023.11.03T16.52.04.users-table.js │ └── readme.md ├── 0.5-vanilla-esm │ ├── migrate.mjs │ ├── migrations │ │ └── 2023.11.03T16.52.04.users-table.mjs │ └── readme.md └── 2-es-modules │ ├── migrations │ ├── 2020.11.24T18.31.34.roles-table.cjs │ └── 2020.11.24T16.52.04.users-table.mjs │ ├── umzug.mjs │ └── readme.md ├── test ├── tsconfig.json ├── __snapshots__ │ ├── 3-raw-sql.snap │ ├── 5-custom-template.snap │ ├── 7-bundling-codegen.snap │ ├── 2-es-modules.snap │ ├── 6-events.snap │ ├── 0-vanilla.snap │ ├── 0.5-vanilla-esm.snap │ ├── 1-sequelize-typescript.snap │ └── 4-sequelize-seeders.snap ├── lock.test.ts ├── storage │ ├── memory.test.ts │ ├── json.test.ts │ ├── mongodb.test.ts │ └── sequelize.test.ts ├── examples.test.ts ├── sequelize.test.ts └── cli.test.ts ├── src ├── index.ts ├── storage │ ├── index.ts │ ├── memory.ts │ ├── contract.ts │ ├── json.ts │ ├── mongodb.ts │ └── sequelize.ts ├── templates.ts ├── file-locker.ts ├── types.ts ├── cli.ts └── umzug.ts ├── tsconfig.lib.json ├── vite.config.ts ├── .gitignore ├── eslint.config.js ├── .github ├── workflows │ ├── post-release.yml │ ├── pkg.pr.new.yml │ └── ci.yml ├── FUNDING.yml └── ISSUE_TEMPLATE │ └── bug_report.md ├── codegen.js ├── tsconfig.json ├── renovate.json ├── LICENSE ├── package.json ├── CHANGELOG.md └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | **/*new-migration* 2 | **/*new-seed* 3 | -------------------------------------------------------------------------------- /examples/node_modules/umzug/index.d.ts: -------------------------------------------------------------------------------- 1 | export * from '../../../lib' 2 | -------------------------------------------------------------------------------- /examples/node_modules/umzug/index.js: -------------------------------------------------------------------------------- 1 | module.exports = require('../../..') 2 | -------------------------------------------------------------------------------- /examples/3-raw-sql/migrations/down/2020.11.24T18.00.40.users.sql: -------------------------------------------------------------------------------- 1 | drop table users; 2 | -------------------------------------------------------------------------------- /examples/7-bundling-codegen/.gitignore: -------------------------------------------------------------------------------- 1 | .cache 2 | package-lock.json 3 | yarn.lock 4 | dist 5 | -------------------------------------------------------------------------------- /examples/3-raw-sql/migrations/2020.11.24T18.00.40.users.sql: -------------------------------------------------------------------------------- 1 | create table users(id int, name text); 2 | -------------------------------------------------------------------------------- /examples/6-events/migrate.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register'); 2 | 3 | require('./umzug').migrator.runAsCLI(); 4 | -------------------------------------------------------------------------------- /examples/4-sequelize-seeders/seed.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register'); 2 | 3 | require('./umzug').seeder.runAsCLI(); 4 | -------------------------------------------------------------------------------- /examples/4-sequelize-seeders/migrate.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register'); 2 | 3 | require('./umzug').migrator.runAsCLI(); 4 | -------------------------------------------------------------------------------- /examples/5-custom-template/migrate.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register'); 2 | 3 | require('./umzug').migrator.runAsCLI(); 4 | -------------------------------------------------------------------------------- /examples/1-sequelize-typescript/migrate.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register'); 2 | 3 | require('./umzug').migrator.runAsCLI(); 4 | -------------------------------------------------------------------------------- /examples/3-raw-sql/migrate.js: -------------------------------------------------------------------------------- 1 | require('ts-node/register/transpile-only'); 2 | 3 | require('./umzug').migrator.runAsCLI(); 4 | -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["."], 4 | "compilerOptions": { 5 | "noEmit": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './umzug' 2 | export * from './storage' 3 | export * from './file-locker' 4 | export * from './types' 5 | export * from './cli' 6 | -------------------------------------------------------------------------------- /examples/7-bundling-codegen/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "noEmit": true 5 | }, 6 | "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /src/storage/index.ts: -------------------------------------------------------------------------------- 1 | // codegen:start {preset: barrel} 2 | export * from './contract' 3 | export * from './json' 4 | export * from './memory' 5 | export * from './mongodb' 6 | export * from './sequelize' 7 | // codegen:end 8 | -------------------------------------------------------------------------------- /tsconfig.lib.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "include": [ 4 | "src" 5 | ], 6 | "compilerOptions": { 7 | "lib": ["es2021"], 8 | "outDir": "lib", 9 | "noEmit": false, 10 | "moduleResolution": "Node16" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import {defineConfig} from 'vitest/config' 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'node', 7 | testTimeout: 10_000, 8 | coverage: { 9 | include: ['src/**'], 10 | }, 11 | }, 12 | }) 13 | -------------------------------------------------------------------------------- /examples/readme.md: -------------------------------------------------------------------------------- 1 | This folder contains several minimal umzug setups. You can try them out by cloning this repo, running `npm install`, then `cd`ing into the folders. Or just browse them on GitHub and copy-paste what you need. Each has a short readme with a set of shell commands showing a sample usage. 2 | -------------------------------------------------------------------------------- /examples/6-events/readme.md: -------------------------------------------------------------------------------- 1 | This example demonstrates how to use umzug events. In this example, an internal service is shut down before migrations running (hopefully, you wouldn't actually need to do this - it's just a demo of how you _could_ use the events emitted by the umzug instance). 2 | 3 | ```bash 4 | node migrate up 5 | ``` -------------------------------------------------------------------------------- /examples/7-bundling-codegen/eslint.config.js: -------------------------------------------------------------------------------- 1 | const tseslint = require('typescript-eslint') 2 | const codegen = require('eslint-plugin-codegen') 3 | 4 | module.exports = [ 5 | tseslint.configs.base, 6 | {plugins: {codegen}}, 7 | {rules: {'codegen/codegen': 'error'}}, 8 | {files: ['migrations/*.ts']}, 9 | {ignores: ['dist/**']}, 10 | ] 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/* 2 | lib 3 | umzug.json 4 | .vscode 5 | coverage 6 | test/generated 7 | *.tgz 8 | examples/*/db.sqlite 9 | # to make the examples realistic, they `import {} from 'umzug'`. So there's a passthrough script to the compiled library in examples/node_modules/umzug/index.js 10 | !examples/node_modules/umzug/* 11 | examples/*/node_modules/* 12 | *ignoreme* 13 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = [ 2 | ...require('eslint-plugin-mmkal').recommendedFlatConfigs, 3 | {ignores: ['lib/**', 'examples/**', 'test/generated/**']}, // 4 | { 5 | rules: { 6 | // todo[>=4.0.0] drop lower node version support and remove these 7 | 'unicorn/prefer-string-replace-all': 'off', 8 | 'unicorn/prefer-at': 'off', 9 | }, 10 | }, 11 | ] 12 | -------------------------------------------------------------------------------- /examples/7-bundling-codegen/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "umzug-bundling-example", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "build": "tsup umzug.ts --external pg-hstore", 6 | "lint": "eslint ." 7 | }, 8 | "devDependencies": { 9 | "eslint": "8.57.0", 10 | "eslint-plugin-codegen": "0.28.0", 11 | "tsup": "8.0.2", 12 | "typescript-eslint": "7.3.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/5-custom-template/readme.md: -------------------------------------------------------------------------------- 1 | This example shows how to use the filesystem to get a custom project-specific template, since the template included with umzug is pretty basic. 2 | 3 | The implementation simply reads the template file from a predefined folder, but you could make the logic more complex if you wanted. 4 | 5 | Usage: 6 | 7 | ```bash 8 | node migrate create --name new-migration.ts 9 | ``` 10 | -------------------------------------------------------------------------------- /examples/0-vanilla/migrate.js: -------------------------------------------------------------------------------- 1 | const { Umzug, JSONStorage } = require('umzug'); 2 | 3 | exports.migrator = new Umzug({ 4 | migrations: { 5 | glob: 'migrations/*.js', 6 | }, 7 | context: { directory: __dirname + '/ignoreme' }, 8 | storage: new JSONStorage({ path: __dirname + '/ignoreme/storage.json' }), 9 | logger: console, 10 | }); 11 | 12 | if (require.main === module) { 13 | exports.migrator.runAsCLI(); 14 | } 15 | -------------------------------------------------------------------------------- /src/storage/memory.ts: -------------------------------------------------------------------------------- 1 | import type {UmzugStorage} from './contract' 2 | 3 | export const memoryStorage = (): UmzugStorage => { 4 | let executed: string[] = [] 5 | return { 6 | async logMigration({name}) { 7 | executed.push(name) 8 | }, 9 | async unlogMigration({name}) { 10 | executed = executed.filter(n => n !== name) 11 | }, 12 | executed: async () => [...executed], 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/post-release.yml: -------------------------------------------------------------------------------- 1 | name: Post-release 2 | on: 3 | release: 4 | types: 5 | - published 6 | - edited 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: apexskier/github-release-commenter@v1 12 | with: 13 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 14 | comment-template: Released in {release_link}. 15 | label-template: released 16 | -------------------------------------------------------------------------------- /.github/workflows/pkg.pr.new.yml: -------------------------------------------------------------------------------- 1 | name: pkg.pr.new 2 | on: 3 | pull_request: {} 4 | push: 5 | branches: [main, deps, pkg.pr.new] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | - run: npm install -g corepack@0.31.0 # todo: delete if https://github.com/nodejs/corepack/issues/612 is resolved 13 | - run: corepack enable 14 | - run: pnpm install 15 | - run: pnpm build 16 | - run: pnpm pkg-pr-new publish 17 | -------------------------------------------------------------------------------- /examples/0.5-vanilla-esm/migrate.mjs: -------------------------------------------------------------------------------- 1 | import { Umzug, JSONStorage } from 'umzug'; 2 | import { fileURLToPath } from 'url' 3 | 4 | const __dirname = fileURLToPath(new URL('.', import.meta.url)).replace(/\/$/, '') 5 | 6 | export const migrator = new Umzug({ 7 | migrations: { 8 | glob: 'migrations/*.*js', 9 | }, 10 | context: { directory: __dirname + '/ignoreme' }, 11 | storage: new JSONStorage({ path: __dirname + '/ignoreme/storage.json' }), 12 | logger: console, 13 | }); 14 | 15 | await migrator.runAsCLI(); 16 | -------------------------------------------------------------------------------- /examples/2-es-modules/migrations/2020.11.24T18.31.34.roles-table.cjs: -------------------------------------------------------------------------------- 1 | exports.up = async ({ context: { sequelize, DataTypes } }) => { 2 | await sequelize.getQueryInterface().createTable('roles', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | allowNull: false, 6 | primaryKey: true, 7 | }, 8 | name: { 9 | type: DataTypes.STRING, 10 | allowNull: false, 11 | }, 12 | }); 13 | }; 14 | 15 | exports.down = async ({ context: { sequelize } }) => { 16 | await sequelize.getQueryInterface().dropTable('roles'); 17 | }; 18 | -------------------------------------------------------------------------------- /examples/2-es-modules/migrations/2020.11.24T16.52.04.users-table.mjs: -------------------------------------------------------------------------------- 1 | export const up = async ({ context: { sequelize, DataTypes } }) => { 2 | await sequelize.getQueryInterface().createTable('users', { 3 | id: { 4 | type: DataTypes.INTEGER, 5 | allowNull: false, 6 | primaryKey: true, 7 | }, 8 | name: { 9 | type: DataTypes.STRING, 10 | allowNull: false, 11 | }, 12 | }); 13 | }; 14 | 15 | export const down = async ({ context: { sequelize } }) => { 16 | await sequelize.getQueryInterface().dropTable('users'); 17 | }; 18 | -------------------------------------------------------------------------------- /examples/4-sequelize-seeders/seeders/2020.11.24T18.46.19.sample-users.ts: -------------------------------------------------------------------------------- 1 | import type { Seeder } from '../umzug'; 2 | 3 | const seedUsers = [ 4 | { id: 1, name: 'Alice' }, 5 | { id: 2, name: 'Bob' }, 6 | ]; 7 | 8 | export const up: Seeder = async ({ context: sequelize }) => { 9 | await sequelize.getQueryInterface().bulkInsert('users', seedUsers); 10 | }; 11 | 12 | export const down: Seeder = async ({ context: sequelize }) => { 13 | await sequelize.getQueryInterface().bulkDelete('users', { id: seedUsers.map(u => u.id) }); 14 | }; 15 | -------------------------------------------------------------------------------- /examples/5-custom-template/template/sample-migration.ts: -------------------------------------------------------------------------------- 1 | import type { Migration } from '../umzug'; 2 | import { DataTypes } from 'sequelize'; 3 | 4 | // you can put some team-specific imports/code here to be included in every migration 5 | 6 | export const up: Migration = async ({ context: sequelize }) => { 7 | await sequelize.query(`raise fail('up migration not implemented')`); 8 | }; 9 | 10 | export const down: Migration = async ({ context: sequelize }) => { 11 | await sequelize.query(`raise fail('down migration not implemented')`); 12 | }; 13 | -------------------------------------------------------------------------------- /examples/1-sequelize-typescript/umzug.ts: -------------------------------------------------------------------------------- 1 | import { Umzug, SequelizeStorage } from 'umzug'; 2 | import { Sequelize } from 'sequelize'; 3 | 4 | const sequelize = new Sequelize({ 5 | dialect: 'sqlite', 6 | storage: './db.sqlite', 7 | }); 8 | 9 | export const migrator = new Umzug({ 10 | migrations: { 11 | glob: ['migrations/*.ts', { cwd: __dirname }], 12 | }, 13 | context: sequelize, 14 | storage: new SequelizeStorage({ 15 | sequelize, 16 | }), 17 | logger: console, 18 | }); 19 | 20 | export type Migration = typeof migrator._types.migration; 21 | -------------------------------------------------------------------------------- /test/__snapshots__/3-raw-sql.snap: -------------------------------------------------------------------------------- 1 | `node migrate up` output: 2 | 3 | Executing (default): create table if not exists my_migrations_table(name text) 4 | Executing (default): select name from my_migrations_table 5 | { event: 'migrating', name: '<>.users.sql' } 6 | Executing (default): create table users(id int, name text); 7 | Executing (default): insert into my_migrations_table(name) values ($1) 8 | { 9 | event: 'migrated', 10 | name: '<>.users.sql', 11 | durationSeconds: ??? 12 | } 13 | { event: 'up', message: 'applied 1 migrations.' } -------------------------------------------------------------------------------- /examples/3-raw-sql/readme.md: -------------------------------------------------------------------------------- 1 | This example demonstrates how to set up umzug with a raw sql client. 2 | 3 | Note: this is really just a toy example to show how you can use pre-existing sql queries. If you want a raw sql migrator for postgres, see [@slonik/migrator](https://npmjs.com/package/@slonik/migrator). It uses umzug in a similar way, with [slonik](https://npmjs.com/package/slonik) as the underlying client, and it adds transactions, locking, supports typescript/javascript alongside sql, and some additional safety checks. 4 | 5 | ```bash 6 | node migrate up 7 | ``` 8 | -------------------------------------------------------------------------------- /examples/0-vanilla/migrations/2023.11.03T16.52.04.users-table.js: -------------------------------------------------------------------------------- 1 | const { promises: fs } = require('fs'); 2 | 3 | /** @type {typeof import('../migrate').migrator['_types']['migration']} */ 4 | exports.up = async ({ context }) => { 5 | await fs.mkdir(context.directory, { recursive: true }); 6 | await fs.writeFile(context.directory + '/users.json', JSON.stringify([], null, 2)); 7 | }; 8 | 9 | /** @type {typeof import('../migrate').migrator['_types']['migration']} */ 10 | exports.down = async ({ context }) => { 11 | await fs.unlink(context.directory + '/users.json'); 12 | }; 13 | -------------------------------------------------------------------------------- /examples/0.5-vanilla-esm/migrations/2023.11.03T16.52.04.users-table.mjs: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'fs'; 2 | 3 | /** @type {typeof import('../migrate.mjs').migrator['_types']['migration']} */ 4 | export const up = async ({ context }) => { 5 | await fs.mkdir(context.directory, { recursive: true }); 6 | await fs.writeFile(context.directory + '/users.json', JSON.stringify([], null, 2)); 7 | }; 8 | 9 | /** @type {typeof import('../migrate.mjs').migrator['_types']['migration']} */ 10 | export const down = async ({ context }) => { 11 | await fs.unlink(context.directory + '/users.json'); 12 | }; 13 | -------------------------------------------------------------------------------- /codegen.js: -------------------------------------------------------------------------------- 1 | const {Umzug} = require('.') 2 | const stripAnsi = require('strip-ansi') 3 | const {UmzugCLI} = require('./lib/cli') 4 | 5 | /** @type import('eslint-plugin-codegen').Preset<{ action?: string }> */ 6 | exports.cliHelp = ({options: {action}}) => { 7 | const cli = new UmzugCLI(new Umzug({migrations: [], logger: undefined})) 8 | const helpable = action ? cli.tryGetAction(action) : cli 9 | 10 | return [ 11 | '```', 12 | stripAnsi(helpable.renderHelpText()) 13 | .trim() 14 | // for some reason the last `-h` is on its own line 15 | .replace(/\n-h$/, '-h'), 16 | '```', 17 | ].join('\n') 18 | } 19 | -------------------------------------------------------------------------------- /examples/6-events/migrations/2020.11.24T16.52.04.users-table.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize'; 2 | import type { Migration } from '../umzug'; 3 | 4 | export const up: Migration = async ({ context: sequelize }) => { 5 | await sequelize.getQueryInterface().createTable('users', { 6 | id: { 7 | type: DataTypes.INTEGER, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | name: { 12 | type: DataTypes.STRING, 13 | allowNull: false, 14 | }, 15 | }); 16 | }; 17 | 18 | export const down: Migration = async ({ context: sequelize }) => { 19 | await sequelize.getQueryInterface().dropTable('users'); 20 | }; 21 | -------------------------------------------------------------------------------- /examples/5-custom-template/migrations/2020.11.24T16.52.04.users-table.ts: -------------------------------------------------------------------------------- 1 | import type { Migration } from '../umzug'; 2 | import { DataTypes } from 'sequelize'; 3 | 4 | export const up: Migration = async ({ context: sequelize }) => { 5 | await sequelize.getQueryInterface().createTable('users', { 6 | id: { 7 | type: DataTypes.INTEGER, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | name: { 12 | type: DataTypes.STRING, 13 | allowNull: false, 14 | }, 15 | }); 16 | }; 17 | 18 | export const down: Migration = async ({ context: sequelize }) => { 19 | await sequelize.getQueryInterface().dropTable('users'); 20 | }; 21 | -------------------------------------------------------------------------------- /examples/0-vanilla/readme.md: -------------------------------------------------------------------------------- 1 | This example shows the simplest possible, node-only setup for Umzug. No typescript, no database, no dependencies. 2 | 3 | Note: 4 | - The `context` for the migrations just contains a (gitignored) directory. 5 | - The example migration just writes an empty file to the directory 6 | 7 | ```bash 8 | node migrate --help # show CLI help 9 | 10 | node migrate up # apply migrations 11 | node migrate down # revert the last migration 12 | node migrate create --name new-migration.js # create a new migration file 13 | 14 | node migrate up # apply migrations again 15 | node migrate down --to 0 # revert all migrations 16 | ``` 17 | -------------------------------------------------------------------------------- /examples/1-sequelize-typescript/migrations/2020.11.24T16.52.04.users-table.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize'; 2 | import type { Migration } from '../umzug'; 3 | 4 | export const up: Migration = async ({ context: sequelize }) => { 5 | await sequelize.getQueryInterface().createTable('users', { 6 | id: { 7 | type: DataTypes.INTEGER, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | name: { 12 | type: DataTypes.STRING, 13 | allowNull: false, 14 | }, 15 | }); 16 | }; 17 | 18 | export const down: Migration = async ({ context: sequelize }) => { 19 | await sequelize.getQueryInterface().dropTable('users'); 20 | }; 21 | -------------------------------------------------------------------------------- /examples/4-sequelize-seeders/migrations/2020.11.24T16.52.04.users-table.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize'; 2 | import type { Migration } from '../umzug'; 3 | 4 | export const up: Migration = async ({ context: sequelize }) => { 5 | await sequelize.getQueryInterface().createTable('users', { 6 | id: { 7 | type: DataTypes.INTEGER, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | name: { 12 | type: DataTypes.STRING, 13 | allowNull: false, 14 | }, 15 | }); 16 | }; 17 | 18 | export const down: Migration = async ({ context: sequelize }) => { 19 | await sequelize.getQueryInterface().dropTable('users'); 20 | }; 21 | -------------------------------------------------------------------------------- /examples/7-bundling-codegen/migrations/2020.12.09T19.24.31.users-table.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize'; 2 | import type { Migration } from '../umzug'; 3 | 4 | export const up: Migration = async ({ context: sequelize }) => { 5 | await sequelize.getQueryInterface().createTable('users', { 6 | id: { 7 | type: DataTypes.INTEGER, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | name: { 12 | type: DataTypes.STRING, 13 | allowNull: false, 14 | }, 15 | }); 16 | }; 17 | 18 | export const down: Migration = async ({ context: sequelize }) => { 19 | await sequelize.getQueryInterface().dropTable('users'); 20 | }; 21 | -------------------------------------------------------------------------------- /examples/7-bundling-codegen/migrations/2020.12.09T19.25.09.roles-table.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize'; 2 | import type { Migration } from '../umzug'; 3 | 4 | export const up: Migration = async ({ context: sequelize }) => { 5 | await sequelize.getQueryInterface().createTable('roles', { 6 | id: { 7 | type: DataTypes.INTEGER, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | name: { 12 | type: DataTypes.STRING, 13 | allowNull: false, 14 | }, 15 | }); 16 | }; 17 | 18 | export const down: Migration = async ({ context: sequelize }) => { 19 | await sequelize.getQueryInterface().dropTable('roles'); 20 | }; 21 | -------------------------------------------------------------------------------- /test/__snapshots__/5-custom-template.snap: -------------------------------------------------------------------------------- 1 | `node migrate create --name new-migration.ts` output: 2 | 3 | { 4 | event: 'created', 5 | path: 'migrations/<>.new-migration.ts' 6 | } 7 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 8 | Executing (default): CREATE TABLE IF NOT EXISTS `SequelizeMeta` (`name` VARCHAR(255) NOT NULL UNIQUE PRIMARY KEY); 9 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 10 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 11 | Executing (default): SELECT `name` FROM `SequelizeMeta` AS `SequelizeMeta` ORDER BY `SequelizeMeta`.`name` ASC; -------------------------------------------------------------------------------- /examples/0.5-vanilla-esm/readme.md: -------------------------------------------------------------------------------- 1 | This example shows the simplest possible, node-only setup for Umzug. No typescript, no database, no dependencies. 2 | 3 | Note: 4 | - The `context` for the migrations just contains a (gitignored) directory. 5 | - The example migration just writes an empty file to the directory 6 | 7 | ```bash 8 | node migrate.mjs --help # show CLI help 9 | 10 | node migrate.mjs up # apply migrations 11 | node migrate.mjs down # revert the last migration 12 | node migrate.mjs create --name new-migration.mjs # create a new migration file 13 | 14 | node migrate.mjs up # apply migrations again 15 | node migrate.mjs down --to 0 # revert all migrations 16 | ``` 17 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: sequelize 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /examples/2-es-modules/umzug.mjs: -------------------------------------------------------------------------------- 1 | import { Umzug, SequelizeStorage } from 'umzug'; 2 | import { Sequelize, DataTypes } from 'sequelize'; 3 | import * as path from 'path'; 4 | import os from 'os'; 5 | 6 | const sequelize = new Sequelize({ 7 | dialect: 'sqlite', 8 | storage: './db.sqlite', 9 | logging: process.env.SEQUELIZE_LOG === 'true', 10 | }); 11 | 12 | export const migrator = new Umzug({ 13 | migrations: { 14 | glob: ['migrations/*.{js,cjs,mjs}', { cwd: path.dirname(import.meta.url.replace(os.platform() === 'win32' ? 'file:///' : 'file://', '')) }], 15 | }, 16 | context: { sequelize, DataTypes }, 17 | storage: new SequelizeStorage({ 18 | sequelize, 19 | }), 20 | logger: console, 21 | }); 22 | 23 | migrator.runAsCLI(); 24 | -------------------------------------------------------------------------------- /examples/4-sequelize-seeders/readme.md: -------------------------------------------------------------------------------- 1 | This example shows how to use umzug for seeding data into a database. 2 | 3 | There's nothing very special about it. It's just two separate umzug instances created - one for migrations, and one for seeders, along with `seed.js` and `migrate.js` scripts to run them. 4 | 5 | Usage example: 6 | 7 | ```bash 8 | # show help for each script 9 | node migrate --help 10 | node seed --help 11 | 12 | node seed up || echo failed # will fail, since tables haven't been created yet 13 | 14 | node migrate up # creates tables 15 | node seed up # inserts seed data 16 | 17 | node seed down --to 0 # removes all seed data 18 | 19 | node seed create --name new-seed-data.ts # create a placeholder migration file for inserting more seed data. 20 | ``` 21 | -------------------------------------------------------------------------------- /examples/4-sequelize-seeders/seeders/2020.11.24T19.01.37.sample-user-roles.ts: -------------------------------------------------------------------------------- 1 | import type { Seeder } from '../umzug'; 2 | 3 | const seedData = { 4 | roles: [{ id: 1, name: 'admin' }], 5 | user_roles: [{ user_id: 1, role_id: 1 }], 6 | }; 7 | 8 | export const up: Seeder = async ({ context: sequelize }) => { 9 | await sequelize.getQueryInterface().bulkInsert('roles', seedData.roles); 10 | await sequelize.getQueryInterface().bulkInsert('user_roles', seedData.user_roles); 11 | }; 12 | 13 | export const down: Seeder = async ({ context: sequelize }) => { 14 | await sequelize.getQueryInterface().bulkDelete('user_roles', { user_id: seedData.user_roles.map(u => u.user_id) }); 15 | await sequelize.getQueryInterface().bulkDelete('roles', { id: seedData.roles.map(r => r.id) }); 16 | }; 17 | -------------------------------------------------------------------------------- /examples/2-es-modules/readme.md: -------------------------------------------------------------------------------- 1 | This example shows how ECMAScript modules can be used with umzug. See the `migrations.resolve` option passed to the Umzug constructor. 2 | 3 | Note that `.mjs` migrations are resolved using `import(...)` and others are resolved using `require(...)`. If you're using a setup like this one, it's recommended to use either the `.cjs` or `.mjs` extensions rather than `.js` to make sure there's no ambiguity. 4 | 5 | Also note that you may not need to use `createRequire` like this example does, depending on which dependencies you're using. 6 | 7 | Usage example: 8 | 9 | ```bash 10 | node umzug.mjs --help 11 | 12 | node umzug.mjs up 13 | node umzug.mjs down 14 | 15 | node umzug.mjs create --name new-migration-1.cjs 16 | node umzug.mjs create --name new-migration-2.mjs 17 | ``` 18 | -------------------------------------------------------------------------------- /examples/7-bundling-codegen/umzug.ts: -------------------------------------------------------------------------------- 1 | import type { MigrationFn } from 'umzug'; 2 | import { Umzug, SequelizeStorage } from 'umzug'; 3 | import { Sequelize } from 'sequelize'; 4 | import { migrations } from './migrations/barrel'; 5 | 6 | const sequelize = new Sequelize({ 7 | dialect: 'sqlite', 8 | storage: './db.sqlite', 9 | logging: false, 10 | }); 11 | 12 | export const migrator = new Umzug({ 13 | migrations: Object.entries(migrations).map(([path, migration]) => { 14 | path += '.ts'; 15 | const name = path.replace('./migrations/', ''); 16 | return { name, path, ...migration }; 17 | }), 18 | context: sequelize, 19 | storage: new SequelizeStorage({ 20 | sequelize, 21 | }), 22 | logger: console, 23 | }); 24 | 25 | export type Migration = MigrationFn; 26 | 27 | migrator.runAsCLI(); 28 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "allowJs": true, 6 | "target": "ES2019", 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "lib": ["ES2021"], 10 | "resolveJsonModule": true, 11 | "declaration": true, 12 | "sourceMap": true, 13 | "pretty": true, 14 | "newLine": "lf", 15 | "strictNullChecks": true, 16 | "noImplicitAny": true, 17 | "noImplicitReturns": true, 18 | "noUnusedLocals": false, 19 | "noUnusedParameters": false, 20 | "noFallthroughCasesInSwitch": true, 21 | "noEmitOnError": true, 22 | "skipLibCheck": true, 23 | "esModuleInterop": true 24 | }, 25 | "include": [ 26 | "src", 27 | "test", 28 | "examples", 29 | "*.md", 30 | "*.ts", 31 | "*.js", 32 | ".*.js" 33 | ], 34 | "exclude": [ 35 | "test/generated" 36 | ] 37 | } 38 | -------------------------------------------------------------------------------- /test/__snapshots__/7-bundling-codegen.snap: -------------------------------------------------------------------------------- 1 | `npm install` output: 2 | 3 | ... 4 | 5 | `npm run lint -- --fix` output: 6 | 7 | ... 8 | 9 | `npm run build` output: 10 | 11 | ... 12 | 13 | `node dist/umzug up` output: 14 | 15 | { event: 'migrating', name: './<>.users-table.ts' } 16 | { 17 | event: 'migrated', 18 | name: './<>.users-table.ts', 19 | durationSeconds: ??? 20 | } 21 | { event: 'migrating', name: './<>.roles-table.ts' } 22 | { 23 | event: 'migrated', 24 | name: './<>.roles-table.ts', 25 | durationSeconds: ??? 26 | } 27 | { event: 'up', message: 'applied 2 migrations.' } 28 | 29 | `node dist/umzug create --name new-migration.ts --skip-verify` output: 30 | 31 | { event: 'created', path: '<>.new-migration.ts' } 32 | 33 | `npm run lint -- --fix` output: 34 | 35 | ... -------------------------------------------------------------------------------- /examples/5-custom-template/umzug.ts: -------------------------------------------------------------------------------- 1 | import { Umzug, SequelizeStorage } from 'umzug'; 2 | import { Sequelize } from 'sequelize'; 3 | import fs = require('fs'); 4 | import path = require('path'); 5 | 6 | const sequelize = new Sequelize({ 7 | dialect: 'sqlite', 8 | storage: './db.sqlite', 9 | }); 10 | 11 | export const migrator = new Umzug({ 12 | migrations: { 13 | glob: ['migrations/*.ts', { cwd: __dirname }], 14 | }, 15 | context: sequelize, 16 | storage: new SequelizeStorage({ 17 | sequelize, 18 | }), 19 | logger: console, 20 | create: { 21 | folder: 'migrations', 22 | template: filepath => [ 23 | // read template from filesystem 24 | [filepath, fs.readFileSync(path.join(__dirname, 'template/sample-migration.ts')).toString()], 25 | ], 26 | }, 27 | }); 28 | 29 | export type Migration = typeof migrator._types.migration; 30 | -------------------------------------------------------------------------------- /examples/7-bundling-codegen/migrations/barrel.ts: -------------------------------------------------------------------------------- 1 | // The content in this file is autogenerated by eslint. So any IDE with an eslint extension will automatically 2 | // sync it, or a lint task such as `yarn lint --fix`. 3 | // Ideally there should be a CI job that runs `yarn lint` - and will fail if it ever does get out of date. 4 | // See https://npmjs.com/package/eslint-plugin-codegen for more details. 5 | 6 | // codegen:start {preset: barrel, include: './*.ts', import: star, export: {name: migrations, keys: path}} 7 | import * as _20201209T192431UsersTable from './2020.12.09T19.24.31.users-table' 8 | import * as _20201209T192509RolesTable from './2020.12.09T19.25.09.roles-table' 9 | 10 | export const migrations = { 11 | "./2020.12.09T19.24.31.users-table": _20201209T192431UsersTable, 12 | "./2020.12.09T19.25.09.roles-table": _20201209T192509RolesTable 13 | } 14 | // codegen:end 15 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Report a problem with umzug 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | 25 | -------------------------------------------------------------------------------- /examples/4-sequelize-seeders/umzug.ts: -------------------------------------------------------------------------------- 1 | import { Umzug, SequelizeStorage } from 'umzug'; 2 | import { Sequelize } from 'sequelize'; 3 | 4 | const sequelize = new Sequelize({ 5 | dialect: 'sqlite', 6 | storage: './db.sqlite', 7 | }); 8 | 9 | export const migrator = new Umzug({ 10 | migrations: { 11 | glob: ['migrations/*.ts', { cwd: __dirname }], 12 | }, 13 | context: sequelize, 14 | storage: new SequelizeStorage({ 15 | sequelize, 16 | modelName: 'migration_meta', 17 | }), 18 | logger: console, 19 | }); 20 | 21 | export type Migration = typeof migrator._types.migration; 22 | 23 | export const seeder = new Umzug({ 24 | migrations: { 25 | glob: ['seeders/*.ts', { cwd: __dirname }], 26 | }, 27 | context: sequelize, 28 | storage: new SequelizeStorage({ 29 | sequelize, 30 | modelName: 'seeder_meta', 31 | }), 32 | logger: console, 33 | }); 34 | 35 | export type Seeder = typeof seeder._types.migration; 36 | -------------------------------------------------------------------------------- /examples/6-events/umzug.ts: -------------------------------------------------------------------------------- 1 | import { Umzug, SequelizeStorage } from 'umzug'; 2 | import { Sequelize } from 'sequelize'; 3 | 4 | const sequelize = new Sequelize({ 5 | dialect: 'sqlite', 6 | storage: './db.sqlite', 7 | }); 8 | 9 | export const migrator = new Umzug({ 10 | migrations: { 11 | glob: ['migrations/*.ts', { cwd: __dirname }], 12 | }, 13 | context: sequelize, 14 | storage: new SequelizeStorage({ sequelize }), 15 | logger: console, 16 | }); 17 | 18 | const fakeApi = { 19 | async shutdownInternalService() { 20 | console.log('shutting down...'); 21 | }, 22 | async restartInternalService() { 23 | console.log('restarting!'); 24 | }, 25 | }; 26 | 27 | migrator.on('beforeCommand', async () => { 28 | await fakeApi.shutdownInternalService(); 29 | }); 30 | 31 | migrator.on('afterCommand', async () => { 32 | await fakeApi.restartInternalService(); 33 | }); 34 | 35 | export type Migration = typeof migrator._types.migration; 36 | -------------------------------------------------------------------------------- /src/templates.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable unicorn/template-indent */ 2 | // templates for migration file creation 3 | 4 | export const js = ` 5 | /** @type {import('umzug').MigrationFn} */ 6 | exports.up = async params => {}; 7 | 8 | /** @type {import('umzug').MigrationFn} */ 9 | exports.down = async params => {}; 10 | `.trimStart() 11 | 12 | export const ts = ` 13 | import type { MigrationFn } from 'umzug'; 14 | 15 | export const up: MigrationFn = async params => {}; 16 | export const down: MigrationFn = async params => {}; 17 | `.trimStart() 18 | 19 | export const mjs = ` 20 | /** @type {import('umzug').MigrationFn} */ 21 | export const up = async params => {}; 22 | 23 | /** @type {import('umzug').MigrationFn} */ 24 | export const down = async params => {}; 25 | `.trimStart() 26 | 27 | export const sqlUp = ` 28 | -- up migration 29 | `.trimStart() 30 | 31 | export const sqlDown = ` 32 | -- down migration 33 | `.trimStart() 34 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | ":semanticCommits" 6 | ], 7 | "schedule": [ 8 | "before 3am on Monday" 9 | ], 10 | "prConcurrentLimit": 4, 11 | "dependencyDashboard": true, 12 | "dependencyDashboardAutoclose": true, 13 | "packageRules": [ 14 | { 15 | "depTypeList": [ 16 | "devDependencies" 17 | ], 18 | "groupName": "devDependencies", 19 | "excludePackageNames": [ 20 | "sequelize", 21 | "strip-ansi" 22 | ], 23 | "excludePackagePatterns": [ 24 | "eslint" 25 | ], 26 | "automerge": true 27 | }, 28 | { 29 | "depTypeList": [ 30 | "devDependencies" 31 | ], 32 | "groupName": "lint", 33 | "matchPackagePatterns": [ 34 | "eslint", 35 | "prettier" 36 | ], 37 | "automerge": true 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /examples/1-sequelize-typescript/readme.md: -------------------------------------------------------------------------------- 1 | This example shows how migrations can be written in typescript and run with the help of `ts-node`. 2 | 3 | Note: 4 | - The entrypoint, `migrate.js`, calls `require('ts-node/register')` before requiring the `umzug.ts` module. This enables loading of typescript modules directly, and avoids the complexity of having a separate compilation target folder. 5 | - `umzug.ts` exports a migration type with `export type Migration = typeof migrator._types.migration;`. This allows typescript migration files to get strongly typed parameters by importing it. See also the [custom template example](../5.custom-template) to see how to setup a template to include this automatically in every new migration. 6 | 7 | ```bash 8 | node migrate --help # show CLI help 9 | 10 | node migrate up # apply migrations 11 | node migrate down # revert the last migration 12 | node migrate down --to 0 # revert all migrations 13 | node migrate up --step 2 # run only two migrations 14 | 15 | node migrate create --name new-migration.ts # create a new migration file 16 | ``` 17 | -------------------------------------------------------------------------------- /src/storage/contract.ts: -------------------------------------------------------------------------------- 1 | import type {MigrationParams} from '../types' 2 | 3 | export type UmzugStorage = { 4 | /** 5 | * Logs migration to be considered as executed. 6 | */ 7 | logMigration: (params: MigrationParams) => Promise 8 | 9 | /** 10 | * Unlogs migration (makes it to be considered as pending). 11 | */ 12 | unlogMigration: (params: MigrationParams) => Promise 13 | 14 | /** 15 | * Gets list of executed migrations. 16 | */ 17 | executed: (meta: Pick, 'context'>) => Promise 18 | } 19 | 20 | export function isUmzugStorage(arg: Partial): arg is UmzugStorage { 21 | return ( 22 | arg && 23 | typeof arg.logMigration === 'function' && 24 | typeof arg.unlogMigration === 'function' && 25 | typeof arg.executed === 'function' 26 | ) 27 | } 28 | 29 | export const verifyUmzugStorage = (arg: Partial): UmzugStorage => { 30 | if (!isUmzugStorage(arg)) { 31 | throw new Error(`Invalid umzug storage`) 32 | } 33 | 34 | return arg 35 | } 36 | -------------------------------------------------------------------------------- /examples/4-sequelize-seeders/migrations/2020.11.24T18.54.32.roles.ts: -------------------------------------------------------------------------------- 1 | import { DataTypes } from 'sequelize'; 2 | import type { Migration } from '../umzug'; 3 | 4 | export const up: Migration = async ({ context: sequelize }) => { 5 | await sequelize.getQueryInterface().createTable('roles', { 6 | id: { 7 | type: DataTypes.INTEGER, 8 | allowNull: false, 9 | primaryKey: true, 10 | }, 11 | name: { 12 | type: DataTypes.STRING, 13 | allowNull: false, 14 | }, 15 | }); 16 | 17 | await sequelize.getQueryInterface().createTable('user_roles', { 18 | user_id: { 19 | type: DataTypes.INTEGER, 20 | allowNull: false, 21 | primaryKey: true, 22 | references: { 23 | model: 'users', 24 | key: 'id', 25 | }, 26 | }, 27 | role_id: { 28 | type: DataTypes.INTEGER, 29 | allowNull: false, 30 | primaryKey: true, 31 | references: { 32 | model: 'roles', 33 | key: 'id', 34 | }, 35 | }, 36 | }); 37 | }; 38 | 39 | export const down: Migration = async ({ context: sequelize }) => { 40 | await sequelize.getQueryInterface().dropTable('users'); 41 | }; 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014-2017 Sequelize contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/__snapshots__/2-es-modules.snap: -------------------------------------------------------------------------------- 1 | `node umzug.mjs --help` output: 2 | 3 | ... 4 | 5 | `node umzug.mjs up` output: 6 | 7 | { event: 'migrating', name: '<>.users-table.mjs' } 8 | { 9 | event: 'migrated', 10 | name: '<>.users-table.mjs', 11 | durationSeconds: ??? 12 | } 13 | { event: 'migrating', name: '<>.roles-table.cjs' } 14 | { 15 | event: 'migrated', 16 | name: '<>.roles-table.cjs', 17 | durationSeconds: ??? 18 | } 19 | { event: 'up', message: 'applied 2 migrations.' } 20 | 21 | `node umzug.mjs down` output: 22 | 23 | { event: 'reverting', name: '<>.roles-table.cjs' } 24 | { 25 | event: 'reverted', 26 | name: '<>.roles-table.cjs', 27 | durationSeconds: ??? 28 | } 29 | { event: 'down', message: 'reverted 1 migrations.' } 30 | 31 | `node umzug.mjs create --name new-migration-1.cjs` output: 32 | 33 | { 34 | event: 'created', 35 | path: '<>/examples/2-es-modules/migrations/<>.new-migration-1.cjs' 36 | } 37 | 38 | `node umzug.mjs create --name new-migration-2.mjs` output: 39 | 40 | { 41 | event: 'created', 42 | path: '<>/examples/2-es-modules/migrations/<>.new-migration-2.mjs' 43 | } -------------------------------------------------------------------------------- /test/__snapshots__/6-events.snap: -------------------------------------------------------------------------------- 1 | `node migrate up` output: 2 | 3 | shutting down... 4 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 5 | Executing (default): CREATE TABLE IF NOT EXISTS `SequelizeMeta` (`name` VARCHAR(255) NOT NULL UNIQUE PRIMARY KEY); 6 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 7 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 8 | Executing (default): SELECT `name` FROM `SequelizeMeta` AS `SequelizeMeta` ORDER BY `SequelizeMeta`.`name` ASC; 9 | { event: 'migrating', name: '<>.users-table.ts' } 10 | Executing (default): CREATE TABLE IF NOT EXISTS `users` (`id` INTEGER PRIMARY KEY, `name` VARCHAR(255) NOT NULL); 11 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 12 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 13 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 14 | Executing (default): INSERT INTO `SequelizeMeta` (`name`) VALUES ($1); 15 | { 16 | event: 'migrated', 17 | name: '<>.users-table.ts', 18 | durationSeconds: ??? 19 | } 20 | restarting! 21 | { event: 'up', message: 'applied 1 migrations.' } -------------------------------------------------------------------------------- /examples/7-bundling-codegen/readme.md: -------------------------------------------------------------------------------- 1 | This example shows how Umzug can be used with a bundler like tsup, webpack, parcel, turbopack, microbundle, ncc, bun, esbuild, swc, etc. This scenario isn't really what Umzug was designed for, so it depends on the help of codegen tool. 2 | 3 | Since we're going to be bundling this package, (maybe with the purpose of running migrations in another environment), we can't rely on "globbing" the filesystem. Here, [eslint-plugin-codegen](https://npmjs.com/package/eslint-plugin-codegen) is used to glob for the files when the linter runs, and barrel them into an object (see [barrel.ts](./barrel.ts)). That object can then be passed to the Umzug constructor directly as a list of migrations (see [umzug.ts](./umzug.ts)). 4 | 5 | When a new migration file is added, the linter can ensure it is added to the barrel by running `eslint . --fix`. 6 | 7 | To try out this example, which uses `tsup`, first install dependencies, then bundle and run the migrator: 8 | 9 | ```bash 10 | npm install 11 | npm run lint -- --fix # makes sure barrel is up to date 12 | npm run build 13 | 14 | node dist/umzug up # apply migrations 15 | 16 | node dist/umzug create --name new-migration.ts --skip-verify # create a new migration file 17 | npm run lint -- --fix # makes sure barrel is up to date 18 | ``` 19 | 20 | Since the codegen lint plugin just creates a simple JavaScript object using regular imports, the same technique can be used with any other bundling library (e.g. webpack, pkg etc.). 21 | -------------------------------------------------------------------------------- /test/lock.test.ts: -------------------------------------------------------------------------------- 1 | import {fsSyncer} from 'fs-syncer' 2 | import pEvent from 'p-event' 3 | import * as path from 'path' 4 | import {test, describe, expect} from 'vitest' 5 | import {JSONStorage, FileLocker, Umzug} from '../src' 6 | 7 | const names = (migrations: Array<{name: string}>) => migrations.map(m => m.name) 8 | const delay = async (ms: number) => new Promise(r => setTimeout(r, ms)) 9 | 10 | describe('locks', () => { 11 | const syncer = fsSyncer(path.join(__dirname, 'generated/lock/json'), {}) 12 | syncer.sync() 13 | 14 | test('file lock', async () => { 15 | const umzug = new Umzug({ 16 | migrations: [1, 2].map(n => ({ 17 | name: `m${n}`, 18 | up: async () => delay(100), 19 | })), 20 | storage: new JSONStorage({path: path.join(syncer.baseDir, 'storage.json')}), 21 | logger: undefined, 22 | }) 23 | 24 | FileLocker.attach(umzug, {path: path.join(syncer.baseDir, 'storage.json.lock')}) 25 | 26 | expect(syncer.read()).toEqual({}) 27 | 28 | const promise1 = umzug.up() 29 | await pEvent(umzug as pEvent.Emitter, 'migrating') 30 | const promise2 = umzug.up() 31 | 32 | await expect(promise2).rejects.toThrow(/Can't acquire lock. (.*)storage.json.lock exists/) 33 | await expect(promise1.then(names)).resolves.toEqual(['m1', 'm2']) 34 | 35 | expect(names(await umzug.executed())).toEqual(['m1', 'm2']) 36 | expect(syncer.read()).toEqual({ 37 | 'storage.json': JSON.stringify(['m1', 'm2'], null, 2), 38 | }) 39 | }) 40 | }) 41 | -------------------------------------------------------------------------------- /test/storage/memory.test.ts: -------------------------------------------------------------------------------- 1 | import {expectTypeOf} from 'expect-type' 2 | import {describe, test, expect} from 'vitest' 3 | import {UmzugStorage, memoryStorage} from '../../src' 4 | 5 | describe('memoryStorage', () => { 6 | test('type', () => { 7 | expectTypeOf(memoryStorage).returns.toMatchTypeOf() 8 | // no additional properties: 9 | expectTypeOf(memoryStorage).returns.toEqualTypeOf() 10 | }) 11 | 12 | test('executed returns empty array when no migrations are logged', async () => { 13 | const storage = memoryStorage() 14 | expect(await storage.executed({context: {}})).toEqual([]) 15 | }) 16 | 17 | test('logMigration, executed and unlogMigration', async () => { 18 | const storage = memoryStorage() 19 | 20 | await storage.logMigration({name: 'm1', context: {}}) 21 | expect(await storage.executed({context: {}})).toEqual(['m1']) 22 | 23 | await storage.logMigration({name: 'm1', context: {}}) 24 | await storage.logMigration({name: 'm2', context: {}}) 25 | expect(await storage.executed({context: {}})).toEqual(['m1', 'm1', 'm2']) 26 | 27 | await storage.unlogMigration({name: 'm1', context: {}}) 28 | expect(await storage.executed({context: {}})).toEqual(['m2']) 29 | 30 | await storage.unlogMigration({name: 'm2', context: {}}) 31 | expect(await storage.executed({context: {}})).toEqual([]) 32 | }) 33 | 34 | test(`executed isn't affected by side-effects`, async () => { 35 | const storage = memoryStorage() 36 | 37 | const executed = await storage.executed({context: {}}) 38 | executed.push('abc') 39 | 40 | expect(await storage.executed({context: {}})).toEqual([]) 41 | }) 42 | }) 43 | -------------------------------------------------------------------------------- /test/__snapshots__/0-vanilla.snap: -------------------------------------------------------------------------------- 1 | `node migrate --help` output: 2 | 3 | ... 4 | 5 | `node migrate up` output: 6 | 7 | { event: 'migrating', name: '<>.users-table.js' } 8 | { 9 | event: 'migrated', 10 | name: '<>.users-table.js', 11 | durationSeconds: ??? 12 | } 13 | { event: 'up', message: 'applied 1 migrations.' } 14 | 15 | `node migrate down` output: 16 | 17 | { event: 'reverting', name: '<>.users-table.js' } 18 | { 19 | event: 'reverted', 20 | name: '<>.users-table.js', 21 | durationSeconds: ??? 22 | } 23 | { event: 'down', message: 'reverted 1 migrations.' } 24 | 25 | `node migrate create --name new-migration.js` output: 26 | 27 | { 28 | event: 'created', 29 | path: '<>/examples/0-vanilla/migrations/<>.new-migration.js' 30 | } 31 | 32 | `node migrate up` output: 33 | 34 | { event: 'migrating', name: '<>.users-table.js' } 35 | { 36 | event: 'migrated', 37 | name: '<>.users-table.js', 38 | durationSeconds: ??? 39 | } 40 | { event: 'migrating', name: '<>.new-migration.js' } 41 | { 42 | event: 'migrated', 43 | name: '<>.new-migration.js', 44 | durationSeconds: ??? 45 | } 46 | { event: 'up', message: 'applied 2 migrations.' } 47 | 48 | `node migrate down --to 0` output: 49 | 50 | { event: 'reverting', name: '<>.new-migration.js' } 51 | { 52 | event: 'reverted', 53 | name: '<>.new-migration.js', 54 | durationSeconds: ??? 55 | } 56 | { event: 'reverting', name: '<>.users-table.js' } 57 | { 58 | event: 'reverted', 59 | name: '<>.users-table.js', 60 | durationSeconds: ??? 61 | } 62 | { event: 'down', message: 'reverted 2 migrations.' } -------------------------------------------------------------------------------- /test/__snapshots__/0.5-vanilla-esm.snap: -------------------------------------------------------------------------------- 1 | `node migrate.mjs --help` output: 2 | 3 | ... 4 | 5 | `node migrate.mjs up` output: 6 | 7 | { event: 'migrating', name: '<>.users-table.mjs' } 8 | { 9 | event: 'migrated', 10 | name: '<>.users-table.mjs', 11 | durationSeconds: ??? 12 | } 13 | { event: 'up', message: 'applied 1 migrations.' } 14 | 15 | `node migrate.mjs down` output: 16 | 17 | { event: 'reverting', name: '<>.users-table.mjs' } 18 | { 19 | event: 'reverted', 20 | name: '<>.users-table.mjs', 21 | durationSeconds: ??? 22 | } 23 | { event: 'down', message: 'reverted 1 migrations.' } 24 | 25 | `node migrate.mjs create --name new-migration.mjs` output: 26 | 27 | { 28 | event: 'created', 29 | path: '<>/examples/0.5-vanilla-esm/migrations/<>.new-migration.mjs' 30 | } 31 | 32 | `node migrate.mjs up` output: 33 | 34 | { event: 'migrating', name: '<>.users-table.mjs' } 35 | { 36 | event: 'migrated', 37 | name: '<>.users-table.mjs', 38 | durationSeconds: ??? 39 | } 40 | { event: 'migrating', name: '<>.new-migration.mjs' } 41 | { 42 | event: 'migrated', 43 | name: '<>.new-migration.mjs', 44 | durationSeconds: ??? 45 | } 46 | { event: 'up', message: 'applied 2 migrations.' } 47 | 48 | `node migrate.mjs down --to 0` output: 49 | 50 | { event: 'reverting', name: '<>.new-migration.mjs' } 51 | { 52 | event: 'reverted', 53 | name: '<>.new-migration.mjs', 54 | durationSeconds: ??? 55 | } 56 | { event: 'reverting', name: '<>.users-table.mjs' } 57 | { 58 | event: 'reverted', 59 | name: '<>.users-table.mjs', 60 | durationSeconds: ??? 61 | } 62 | { event: 'down', message: 'reverted 2 migrations.' } -------------------------------------------------------------------------------- /examples/3-raw-sql/umzug.ts: -------------------------------------------------------------------------------- 1 | import { Umzug } from 'umzug'; 2 | import { Sequelize } from 'sequelize'; 3 | import path = require('path'); 4 | import fs = require('fs'); 5 | 6 | const getRawSqlClient = () => { 7 | // this implementation happens to use sequelize, but you may want to use a specialised sql client 8 | const sequelize = new Sequelize({ 9 | dialect: 'sqlite', 10 | storage: './db.sqlite', 11 | }); 12 | 13 | return { 14 | query: async (sql: string, values?: unknown[]) => sequelize.query(sql, { bind: values }), 15 | }; 16 | }; 17 | 18 | export const migrator = new Umzug({ 19 | migrations: { 20 | glob: ['migrations/*.sql', { cwd: __dirname }], 21 | resolve(params) { 22 | const downPath = path.join(path.dirname(params.path!), 'down', path.basename(params.path!)); 23 | return { 24 | name: params.name, 25 | path: params.path, 26 | up: async () => params.context.query(fs.readFileSync(params.path!).toString()), 27 | down: async () => params.context.query(fs.readFileSync(downPath).toString()), 28 | }; 29 | }, 30 | }, 31 | context: getRawSqlClient(), 32 | storage: { 33 | async executed({ context: client }) { 34 | await client.query(`create table if not exists my_migrations_table(name text)`); 35 | const [results] = await client.query(`select name from my_migrations_table`); 36 | return results.map((r: { name: string }) => r.name); 37 | }, 38 | async logMigration({ name, context: client }) { 39 | await client.query(`insert into my_migrations_table(name) values ($1)`, [name]); 40 | }, 41 | async unlogMigration({ name, context: client }) { 42 | await client.query(`delete from my_migrations_table where name = $1`, [name]); 43 | }, 44 | }, 45 | logger: console, 46 | create: { 47 | folder: 'migrations', 48 | }, 49 | }); 50 | 51 | export type Migration = typeof migrator._types.migration; 52 | -------------------------------------------------------------------------------- /src/storage/json.ts: -------------------------------------------------------------------------------- 1 | import {promises as fs} from 'fs' 2 | import * as path from 'path' 3 | import type {UmzugStorage} from './contract' 4 | 5 | const filesystem = { 6 | /** reads a file as a string or returns null if file doesn't exist */ 7 | async readAsync(filepath: string) { 8 | return fs.readFile(filepath).then( 9 | c => c.toString(), 10 | () => null, 11 | ) 12 | }, 13 | /** writes a string as file contents, creating its parent directory if necessary */ 14 | async writeAsync(filepath: string, content: string) { 15 | await fs.mkdir(path.dirname(filepath), {recursive: true}) 16 | await fs.writeFile(filepath, content) 17 | }, 18 | } 19 | 20 | export type JSONStorageConstructorOptions = { 21 | /** 22 | Path to JSON file where the log is stored. 23 | 24 | @default './umzug.json' 25 | */ 26 | readonly path?: string 27 | } 28 | 29 | export class JSONStorage implements UmzugStorage { 30 | public readonly path: string 31 | 32 | constructor(options?: JSONStorageConstructorOptions) { 33 | this.path = options?.path ?? path.join(process.cwd(), 'umzug.json') 34 | } 35 | 36 | async logMigration({name: migrationName}: {name: string}): Promise { 37 | const loggedMigrations = await this.executed() 38 | loggedMigrations.push(migrationName) 39 | 40 | await filesystem.writeAsync(this.path, JSON.stringify(loggedMigrations, null, 2)) 41 | } 42 | 43 | async unlogMigration({name: migrationName}: {name: string}): Promise { 44 | const loggedMigrations = await this.executed() 45 | const updatedMigrations = loggedMigrations.filter(name => name !== migrationName) 46 | 47 | await filesystem.writeAsync(this.path, JSON.stringify(updatedMigrations, null, 2)) 48 | } 49 | 50 | async executed(): Promise { 51 | const content = await filesystem.readAsync(this.path) 52 | return content ? (JSON.parse(content) as string[]) : [] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/storage/mongodb.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 3 | import type {UmzugStorage} from './contract' 4 | 5 | type AnyObject = Record 6 | 7 | export type MongoDBConnectionOptions = { 8 | /** 9 | A connection to target database established with MongoDB Driver 10 | */ 11 | readonly connection: AnyObject 12 | 13 | /** 14 | The name of the migration collection in MongoDB 15 | 16 | @default 'migrations' 17 | */ 18 | readonly collectionName?: string 19 | } 20 | 21 | export type MongoDBCollectionOptions = { 22 | /** 23 | A reference to a MongoDB Driver collection 24 | */ 25 | readonly collection: AnyObject 26 | } 27 | 28 | export type MongoDBStorageConstructorOptions = MongoDBConnectionOptions | MongoDBCollectionOptions 29 | 30 | function isMongoDBCollectionOptions(arg: any): arg is MongoDBCollectionOptions { 31 | return Boolean(arg.collection) 32 | } 33 | 34 | export class MongoDBStorage implements UmzugStorage { 35 | public readonly collection: AnyObject 36 | public readonly connection: any // TODO remove this 37 | public readonly collectionName: string // TODO remove this 38 | 39 | constructor(options: MongoDBStorageConstructorOptions) { 40 | if (!options || (!(options as any).collection && !(options as any).connection)) { 41 | throw new Error('MongoDB Connection or Collection required') 42 | } 43 | 44 | this.collection = isMongoDBCollectionOptions(options) 45 | ? options.collection 46 | : options.connection.collection(options.collectionName ?? 'migrations') 47 | 48 | this.connection = (options as any).connection // TODO remove this 49 | this.collectionName = (options as any).collectionName ?? 'migrations' // TODO remove this 50 | } 51 | 52 | async logMigration({name: migrationName}: {name: string}): Promise { 53 | await this.collection.insertOne({migrationName}) 54 | } 55 | 56 | async unlogMigration({name: migrationName}: {name: string}): Promise { 57 | await this.collection.deleteOne({migrationName}) 58 | } 59 | 60 | async executed(): Promise { 61 | type Record = {migrationName: string} 62 | const records: Record[] = await this.collection.find({}).sort({migrationName: 1}).toArray() 63 | return records.map(r => r.migrationName) 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "umzug", 3 | "version": "3.8.2", 4 | "description": "Framework-agnostic migration tool for Node", 5 | "keywords": [ 6 | "migrate", 7 | "migration", 8 | "migrations", 9 | "sequelize", 10 | "database" 11 | ], 12 | "main": "lib/index.js", 13 | "files": [ 14 | "lib" 15 | ], 16 | "packageManager": "pnpm@8.10.2", 17 | "dependencies": { 18 | "@rushstack/ts-command-line": "^4.12.2", 19 | "emittery": "^0.13.0", 20 | "pony-cause": "^2.1.4", 21 | "tinyglobby": "^0.2.13", 22 | "type-fest": "^4.0.0" 23 | }, 24 | "devDependencies": { 25 | "@types/lodash": "4.17.0", 26 | "@types/node": "20.12.7", 27 | "@types/uuid": "8.3.4", 28 | "@types/verror": "1.10.10", 29 | "@vitest/coverage-v8": "3.2.4", 30 | "del": "^5.0.0", 31 | "eslint": "8.57.0", 32 | "eslint-plugin-mmkal": "0.5.1", 33 | "execa": "^5.1.1", 34 | "expect-type": "0.19.0", 35 | "fs-syncer": "0.5.3", 36 | "lodash": "4.17.21", 37 | "np": "10.0.5", 38 | "p-event": "^4.0.0", 39 | "pkg-pr-new": "0.0.20", 40 | "sequelize": "6.37.3", 41 | "source-map-support": "0.5.21", 42 | "sqlite3": "5.1.7", 43 | "strip-ansi": "6.0.1", 44 | "ts-node": "10.9.2", 45 | "typescript": "4.9.5", 46 | "uuid": "9.0.1", 47 | "verror": "1.10.1", 48 | "vitest": "3.2.4" 49 | }, 50 | "scripts": { 51 | "clean": "rm -rf lib", 52 | "compile": "tsc -p tsconfig.lib.json", 53 | "build": "pnpm clean && pnpm compile", 54 | "eslint": "eslint . --max-warnings 0", 55 | "lint": "pnpm type-check && pnpm eslint", 56 | "prepare": "pnpm build", 57 | "pretest": "rm -rf test/generated", 58 | "test": "vitest run", 59 | "type-check": "tsc -p ." 60 | }, 61 | "repository": { 62 | "type": "git", 63 | "url": "https://github.com/sequelize/umzug.git" 64 | }, 65 | "author": "Sascha Depold ", 66 | "contributors": [ 67 | { 68 | "name": "Misha Kaletsky", 69 | "email": "mmkal@kaletsky.com" 70 | }, 71 | { 72 | "name": "Jukka Hyytiälä", 73 | "email": "hyytiala.jukka@gmail.com" 74 | }, 75 | { 76 | "name": "Pascal Pflaum", 77 | "email": "mail@pascalpflaum.de" 78 | }, 79 | { 80 | "name": "Pedro Augusto de Paula Barbosa", 81 | "email": "papb1996@gmail.com" 82 | } 83 | ], 84 | "license": "MIT", 85 | "bugs": { 86 | "url": "https://github.com/sequelize/umzug/issues" 87 | }, 88 | "homepage": "https://github.com/sequelize/umzug", 89 | "engines": { 90 | "node": ">=12" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/examples.test.ts: -------------------------------------------------------------------------------- 1 | import execa from 'execa' 2 | import * as fs from 'fs' 3 | import * as path from 'path' 4 | import stripAnsi from 'strip-ansi' 5 | import {test, expect, beforeAll} from 'vitest' 6 | 7 | beforeAll(async () => { 8 | await execa('npm', ['run', 'compile']) 9 | }) 10 | 11 | const examplesDir = path.join(__dirname, '../examples') 12 | const examples = fs 13 | .readdirSync(examplesDir) 14 | .filter(ex => /^\d/.exec(ex) && fs.existsSync(path.join(examplesDir, ex, 'readme.md'))) 15 | 16 | /** get rid of any untracked files, including newly-created migrations and the sqlite db file, so that each run is from scratch and has the same output (give or take timings etc.) */ 17 | const cleanup = (cwd: string) => { 18 | execa.sync('git', ['diff', '--exit-code', '.'], {cwd}) 19 | execa.sync( 20 | 'sh', 21 | ['-c', 'git checkout migrations && git clean migrations seeders db.sqlite ignoreme *.new-migration.* -fx'], 22 | {cwd}, 23 | ) 24 | } 25 | 26 | // Convert path to use forward slashes and normalize it 27 | const normalizePath = (str: string) => { 28 | return str.replace(/\\+/g, '/') // Convert backslashes to forward slashes 29 | } 30 | 31 | examples.forEach(ex => { 32 | test(`example ${ex}`, async () => { 33 | const dir = path.join(examplesDir, ex) 34 | const readmeFile = fs.readdirSync(dir).find(f => f.toLowerCase() === 'readme.md') 35 | const readme = fs.readFileSync(path.join(dir, readmeFile!)).toString() 36 | const bash = readme.split('```bash')[1].split('```')[0].trim() 37 | 38 | cleanup(dir) 39 | 40 | const cwd = path.resolve(process.cwd()) // Get absolute path 41 | const normalizedCwd = normalizePath(cwd) // Normalize cwd path 42 | 43 | const stdout = bash 44 | .split('\n') 45 | .map(line => line.split('#')[0].trim()) 46 | .filter(Boolean) 47 | .flatMap(cmd => { 48 | try { 49 | let output = execa.sync('sh', ['-c', `${cmd} 2>&1`], {cwd: dir}).stdout 50 | output = stripAnsi(output) 51 | output = cmd.startsWith('npm') || cmd.endsWith('--help') ? '...' : output // npm commands and `--help` are formatted inconsistently and aren't v relevant 52 | output = normalizePath(output) 53 | output = output.split(normalizedCwd).join('<>') // cwd varies by machine 54 | output = output.replaceAll(/durationSeconds: .*/g, 'durationSeconds: ???') // migrations durations vary by a few milliseconds 55 | output = output.replaceAll(/\d{4}.\d{2}.\d{2}T\d{2}.\d{2}.\d{2}/g, '<>') // the river of time flows only in one direction 56 | return [`\`${cmd}\` output:`, output] 57 | } catch (err: unknown) { 58 | throw new Error(`Processing command "${cmd}" in ${dir} failed:\n${String(err)}`) 59 | } 60 | }) 61 | .join('\n\n') 62 | 63 | await expect(stdout).toMatchFileSnapshot(`__snapshots__/${ex}.snap`) 64 | 65 | cleanup(dir) 66 | }) 67 | }) 68 | -------------------------------------------------------------------------------- /src/file-locker.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs' 2 | import * as path from 'path' 3 | import type {Umzug} from './umzug' 4 | 5 | export type FileLockerOptions = { 6 | path: string 7 | fs?: typeof fs 8 | } 9 | 10 | /** 11 | * Simple locker using the filesystem. Only one lock can be held per file. An error will be thrown if the 12 | * lock file already exists. 13 | * 14 | * @example 15 | * const umzug = new Umzug({ ... }) 16 | * FileLocker.attach(umzug, { path: 'path/to/lockfile' }) 17 | * 18 | * @docs 19 | * To wait for the lock to be free instead of throwing, you could extend it (the below example uses `setInterval`, 20 | * but depending on your use-case, you may want to use a library with retry/backoff): 21 | * 22 | * @example 23 | * class WaitingFileLocker extends FileLocker { 24 | * async getLock() { 25 | * return new Promise(resolve => setInterval( 26 | * () => super.getLock().then(resolve).catch(), 27 | * 500, 28 | * ) 29 | * } 30 | * } 31 | * 32 | * const locker = new WaitingFileLocker({ path: 'path/to/lockfile' }) 33 | * locker.attachTo(umzug) 34 | */ 35 | export class FileLocker { 36 | private readonly lockFile: string 37 | private readonly fs: typeof fs 38 | 39 | constructor(params: FileLockerOptions) { 40 | this.lockFile = params.path 41 | this.fs = params.fs ?? fs 42 | } 43 | 44 | /** Attach `beforeAll` and `afterAll` events to an umzug instance which use the specified filepath */ 45 | static attach(umzug: Umzug, params: FileLockerOptions): void { 46 | const locker = new FileLocker(params) 47 | locker.attachTo(umzug) 48 | } 49 | 50 | /** Attach lock handlers to `beforeCommand` and `afterCommand` events on an umzug instance */ 51 | attachTo(umzug: Umzug): void { 52 | umzug.on('beforeCommand', async () => this.getLock()) 53 | umzug.on('afterCommand', async () => this.releaseLock()) 54 | } 55 | 56 | private async readFile(filepath: string): Promise { 57 | return this.fs.promises.readFile(filepath).then( 58 | buf => buf.toString(), 59 | () => undefined, 60 | ) 61 | } 62 | 63 | private async writeFile(filepath: string, content: string): Promise { 64 | await this.fs.promises.mkdir(path.dirname(filepath), {recursive: true}) 65 | await this.fs.promises.writeFile(filepath, content) 66 | } 67 | 68 | private async removeFile(filepath: string): Promise { 69 | await this.fs.promises.unlink(filepath) 70 | } 71 | 72 | async getLock(): Promise { 73 | const existing = await this.readFile(this.lockFile) 74 | if (existing) { 75 | throw new Error(`Can't acquire lock. ${this.lockFile} exists`) 76 | } 77 | 78 | await this.writeFile(this.lockFile, 'lock') 79 | } 80 | 81 | async releaseLock(): Promise { 82 | const existing = await this.readFile(this.lockFile) 83 | if (!existing) { 84 | throw new Error(`Nothing to unlock`) 85 | } 86 | 87 | await this.removeFile(this.lockFile) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | run: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - run: npm install -g corepack@0.31.0 # todo: delete if https://github.com/nodejs/corepack/issues/612 is resolved 10 | - run: corepack enable 11 | - run: pnpm install 12 | - run: pnpm lint 13 | - run: pnpm test -- --coverage 14 | - name: Coverage 15 | uses: codecov/codecov-action@v3 16 | run_windows: 17 | runs-on: windows-latest 18 | steps: 19 | - uses: actions/checkout@v4 20 | - run: npm install -g corepack@0.31.0 --force # todo: delete if https://github.com/nodejs/corepack/issues/612 is resolved 21 | - run: corepack enable 22 | - run: pnpm install 23 | - run: pnpm test 24 | create_tgz: 25 | runs-on: ubuntu-latest 26 | steps: 27 | - uses: actions/checkout@v4 28 | - run: npm install -g corepack@0.31.0 # todo: delete if https://github.com/nodejs/corepack/issues/612 is resolved 29 | - run: corepack enable 30 | - run: pnpm install 31 | - run: pnpm build 32 | - run: npm pack 33 | - name: rename tgz 34 | run: mv $(ls | grep .tgz) umzug.tgz 35 | - uses: actions/upload-artifact@v4 36 | with: 37 | name: tarball 38 | path: umzug.tgz 39 | test_tgz: 40 | runs-on: ubuntu-latest 41 | needs: [create_tgz] 42 | strategy: 43 | matrix: 44 | node: [20, 18, 16, 14, 12] 45 | steps: 46 | - uses: actions/setup-node@v4 47 | with: 48 | node-version: ${{ matrix.node }} 49 | - uses: actions/checkout@v4 50 | - uses: actions/download-artifact@v4 51 | with: 52 | name: tarball 53 | - run: ls 54 | - name: remove local node_modules 55 | run: rm -rf examples/node_modules 56 | - name: install tgz 57 | working-directory: examples/0-vanilla 58 | run: | 59 | npm init -y 60 | npm install ../../umzug.tgz 61 | - name: run vanilla example 62 | working-directory: examples/0-vanilla 63 | run: | 64 | node migrate up 65 | node migrate down 66 | node migrate create --name new-migration.js 67 | node migrate up 68 | - name: run vanilla esm example 69 | if: matrix.node != 12 70 | working-directory: examples/0.5-vanilla-esm 71 | run: | 72 | npm init -y 73 | sed -i 's|"name"|"type": "module",\n "name"|g' package.json 74 | npm install ../../umzug.tgz 75 | cat package.json 76 | 77 | node migrate.mjs up 78 | node migrate.mjs down 79 | node migrate.mjs create --name new-migration-1.mjs 80 | node migrate.mjs create --name new-migration-2.js 81 | node migrate.mjs up 82 | 83 | cd migrations 84 | cat $(ls . | grep new-migration-1) 85 | cat $(ls . | grep new-migration-2) 86 | 87 | # hard to test this with vitest transpiling stuff for us, so make sure .mjs and .js have same content 88 | cmp $(ls . | grep new-migration-1) $(ls . | grep new-migration-2) 89 | - run: ls -R 90 | -------------------------------------------------------------------------------- /test/storage/json.test.ts: -------------------------------------------------------------------------------- 1 | import {expectTypeOf} from 'expect-type' 2 | import {fsSyncer} from 'fs-syncer' 3 | import * as path from 'path' 4 | import {describe, test, expect, beforeEach} from 'vitest' 5 | import {JSONStorage, UmzugStorage} from '../../src' 6 | 7 | describe('JSONStorage', () => { 8 | describe('constructor', () => { 9 | test('type', () => { 10 | expectTypeOf(JSONStorage).toBeConstructibleWith() 11 | expectTypeOf(JSONStorage).toBeConstructibleWith({}) 12 | expectTypeOf(JSONStorage).toBeConstructibleWith({path: 'abc'}) 13 | 14 | expectTypeOf(JSONStorage).instance.toMatchTypeOf() 15 | expectTypeOf(JSONStorage).instance.toHaveProperty('path').toBeString() 16 | }) 17 | 18 | test('default storage path', () => { 19 | const storage = new JSONStorage() 20 | expect(storage.path).toEqual(path.join(process.cwd(), 'umzug.json')) 21 | }) 22 | }) 23 | 24 | const json = (migrations: string[]) => JSON.stringify(migrations, null, 2) 25 | 26 | describe('logMigration', () => { 27 | const syncer = fsSyncer(path.join(__dirname, '../generated/JSONStorage/logMigration'), {}) 28 | beforeEach(syncer.sync) // Wipes out the directory 29 | 30 | const storage = new JSONStorage({path: path.join(syncer.baseDir, 'umzug.json')}) 31 | 32 | test('adds entry', async () => { 33 | await storage.logMigration({name: 'm1.txt'}) 34 | 35 | expect(syncer.read()).toEqual({ 36 | 'umzug.json': json(['m1.txt']), 37 | }) 38 | }) 39 | test(`doesn't dedupe`, async () => { 40 | await storage.logMigration({name: 'm1.txt'}) 41 | await storage.logMigration({name: 'm1.txt'}) 42 | 43 | expect(syncer.read()).toEqual({ 44 | 'umzug.json': json(['m1.txt', 'm1.txt']), 45 | }) 46 | }) 47 | }) 48 | 49 | describe('unlogMigration', () => { 50 | const syncer = fsSyncer(path.join(__dirname, '../generated/JSONStorage/unlogMigration'), { 51 | 'umzug.json': `["m1.txt"]`, 52 | }) 53 | beforeEach(syncer.sync) // Wipes out the directory 54 | const storage = new JSONStorage({path: path.join(syncer.baseDir, 'umzug.json')}) 55 | 56 | test('removes entry', async () => { 57 | await storage.unlogMigration({name: 'm1.txt'}) 58 | expect(syncer.read()).toEqual({ 59 | 'umzug.json': '[]', 60 | }) 61 | }) 62 | 63 | test('does nothing when unlogging non-existent migration', async () => { 64 | await storage.unlogMigration({name: 'does-not-exist.txt'}) 65 | 66 | expect(syncer.read()).toEqual({ 67 | 'umzug.json': json(['m1.txt']), 68 | }) 69 | }) 70 | }) 71 | 72 | describe('executed', () => { 73 | const syncer = fsSyncer(path.join(__dirname, '../generated/JSONStorage/executed'), {}) 74 | beforeEach(syncer.sync) // Wipes out the directory 75 | 76 | const storage = new JSONStorage({path: path.join(syncer.baseDir, 'umzug.json')}) 77 | 78 | test('returns empty array when no migrations are logged', async () => { 79 | expect(await storage.executed()).toEqual([]) 80 | }) 81 | 82 | test('returns logged migration', async () => { 83 | await storage.logMigration({name: 'm1.txt'}) 84 | expect(await storage.executed()).toEqual(['m1.txt']) 85 | }) 86 | }) 87 | }) 88 | -------------------------------------------------------------------------------- /test/storage/mongodb.test.ts: -------------------------------------------------------------------------------- 1 | import {expectTypeOf} from 'expect-type' 2 | import {describe, test, expect, beforeEach, vi as jest} from 'vitest' 3 | import {MongoDBStorage, UmzugStorage} from '../../src' 4 | 5 | describe('MongoDBStorage', () => { 6 | const mockCollection = { 7 | insertOne: jest.fn(), 8 | deleteOne: jest.fn(), 9 | find: jest.fn().mockReturnValue({ 10 | sort: jest.fn().mockReturnValue({ 11 | toArray: jest.fn().mockResolvedValue([{migrationName: 'fake'}]), 12 | }), 13 | }), 14 | } 15 | 16 | beforeEach(() => { 17 | jest.clearAllMocks() 18 | }) 19 | 20 | describe('constructor', () => { 21 | test('should fail when collection is not set', () => { 22 | expect(() => new MongoDBStorage({} as any)).toThrowErrorMatchingInlineSnapshot( 23 | `[Error: MongoDB Connection or Collection required]`, 24 | ) 25 | }) 26 | 27 | test('receives collection', () => { 28 | const storage = new MongoDBStorage({collection: mockCollection}) 29 | expect(storage.collection).toEqual(mockCollection) 30 | }) 31 | 32 | test('type', () => { 33 | expectTypeOf(MongoDBStorage).toBeConstructibleWith({collection: mockCollection}) 34 | expectTypeOf(MongoDBStorage).toBeConstructibleWith({connection: {}, collectionName: 'test'}) 35 | 36 | expectTypeOf(MongoDBStorage).instance.toMatchTypeOf() 37 | expectTypeOf(MongoDBStorage).instance.toHaveProperty('collection').toBeObject() 38 | }) 39 | 40 | describe('connection (deprecated - a collection instance should be passed in instead)', () => { 41 | const mockConnection = {collection: jest.fn().mockReturnValue(mockCollection)} 42 | 43 | test('receives connection', () => { 44 | const storage = new MongoDBStorage({connection: mockConnection}) 45 | expect(storage.connection).toBe(mockConnection) 46 | expect(storage.collection).toBe(mockCollection) 47 | expect(mockConnection.collection).toHaveBeenCalledTimes(1) 48 | expect(mockConnection.collection).toHaveBeenCalledWith('migrations') 49 | }) 50 | 51 | test('receives collectionName', () => { 52 | const storage = new MongoDBStorage({ 53 | collection: null as any, 54 | connection: mockConnection, 55 | collectionName: 'TEST', 56 | }) 57 | expect(storage.collectionName).toEqual('TEST') 58 | expect(storage.connection.collection).toHaveBeenCalledWith('TEST') 59 | }) 60 | 61 | test('default for collectionName', () => { 62 | const storage = new MongoDBStorage({ 63 | collection: null as any, 64 | connection: mockConnection, 65 | }) 66 | expect(storage.collectionName).toEqual('migrations') 67 | expect(storage.connection.collection).toHaveBeenCalledWith('migrations') 68 | }) 69 | }) 70 | }) 71 | 72 | describe('logMigration', () => { 73 | test('adds entry to storage', async () => { 74 | const storage = new MongoDBStorage({collection: mockCollection}) 75 | await storage.logMigration({name: 'm1.txt'}) 76 | expect(mockCollection.insertOne).toHaveBeenCalledTimes(1) 77 | expect(mockCollection.insertOne).toHaveBeenCalledWith({ 78 | migrationName: 'm1.txt', 79 | }) 80 | }) 81 | }) 82 | 83 | describe('unlogMigration', () => { 84 | test('adds entry to storage', async () => { 85 | const storage = new MongoDBStorage({collection: mockCollection}) 86 | await storage.unlogMigration({name: 'm1.txt'}) 87 | expect(mockCollection.deleteOne).toHaveBeenCalledTimes(1) 88 | expect(mockCollection.deleteOne).toHaveBeenCalledWith({ 89 | migrationName: 'm1.txt', 90 | }) 91 | }) 92 | }) 93 | 94 | describe('executed', () => { 95 | test('returns', async () => { 96 | const storage = new MongoDBStorage({collection: mockCollection}) 97 | const mockToArray = mockCollection.find().sort().toArray 98 | mockToArray.mockReturnValue([{migrationName: 'm1.txt'}]) 99 | expect(await storage.executed()).toEqual(['m1.txt']) 100 | }) 101 | }) 102 | }) 103 | -------------------------------------------------------------------------------- /test/__snapshots__/1-sequelize-typescript.snap: -------------------------------------------------------------------------------- 1 | `node migrate --help` output: 2 | 3 | ... 4 | 5 | `node migrate up` output: 6 | 7 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 8 | Executing (default): CREATE TABLE IF NOT EXISTS `SequelizeMeta` (`name` VARCHAR(255) NOT NULL UNIQUE PRIMARY KEY); 9 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 10 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 11 | Executing (default): SELECT `name` FROM `SequelizeMeta` AS `SequelizeMeta` ORDER BY `SequelizeMeta`.`name` ASC; 12 | { event: 'migrating', name: '<>.users-table.ts' } 13 | Executing (default): CREATE TABLE IF NOT EXISTS `users` (`id` INTEGER PRIMARY KEY, `name` VARCHAR(255) NOT NULL); 14 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 15 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 16 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 17 | Executing (default): INSERT INTO `SequelizeMeta` (`name`) VALUES ($1); 18 | { 19 | event: 'migrated', 20 | name: '<>.users-table.ts', 21 | durationSeconds: ??? 22 | } 23 | { event: 'up', message: 'applied 1 migrations.' } 24 | 25 | `node migrate down` output: 26 | 27 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 28 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 29 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 30 | Executing (default): SELECT `name` FROM `SequelizeMeta` AS `SequelizeMeta` ORDER BY `SequelizeMeta`.`name` ASC; 31 | { event: 'reverting', name: '<>.users-table.ts' } 32 | Executing (default): DROP TABLE IF EXISTS `users`; 33 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 34 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 35 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 36 | Executing (default): DELETE FROM `SequelizeMeta` WHERE `name` = '<>.users-table.ts' 37 | { 38 | event: 'reverted', 39 | name: '<>.users-table.ts', 40 | durationSeconds: ??? 41 | } 42 | { event: 'down', message: 'reverted 1 migrations.' } 43 | 44 | `node migrate down --to 0` output: 45 | 46 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 47 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 48 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 49 | Executing (default): SELECT `name` FROM `SequelizeMeta` AS `SequelizeMeta` ORDER BY `SequelizeMeta`.`name` ASC; 50 | { event: 'down', message: 'reverted 0 migrations.' } 51 | 52 | `node migrate up --step 2` output: 53 | 54 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 55 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 56 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 57 | Executing (default): SELECT `name` FROM `SequelizeMeta` AS `SequelizeMeta` ORDER BY `SequelizeMeta`.`name` ASC; 58 | { event: 'migrating', name: '<>.users-table.ts' } 59 | Executing (default): CREATE TABLE IF NOT EXISTS `users` (`id` INTEGER PRIMARY KEY, `name` VARCHAR(255) NOT NULL); 60 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 61 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 62 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 63 | Executing (default): INSERT INTO `SequelizeMeta` (`name`) VALUES ($1); 64 | { 65 | event: 'migrated', 66 | name: '<>.users-table.ts', 67 | durationSeconds: ??? 68 | } 69 | { event: 'up', message: 'applied 1 migrations.' } 70 | 71 | `node migrate create --name new-migration.ts` output: 72 | 73 | { 74 | event: 'created', 75 | path: '<>/examples/1-sequelize-typescript/migrations/<>.new-migration.ts' 76 | } 77 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='SequelizeMeta'; 78 | Executing (default): PRAGMA INDEX_LIST(`SequelizeMeta`) 79 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_SequelizeMeta_1`) 80 | Executing (default): SELECT `name` FROM `SequelizeMeta` AS `SequelizeMeta` ORDER BY `SequelizeMeta`.`name` ASC; -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project until v2.3.0 were documented in this file. 4 | 5 | For the change logs for versions above v2.3.0, please refer to the GitHub releases section. 6 | 7 | ## v2.3.0 - 2020-03-22 8 | 9 | ### Added 10 | - `migrationsList` helper to easily build a valid list of migrations 11 | [#199](https://github.com/sequelize/umzug/pull/199) 12 | 13 | ### Changed 14 | - Documentation updates 15 | [#198](https://github.com/sequelize/umzug/pull/198) 16 | - Updated dependencies 17 | [#203](https://github.com/sequelize/umzug/pull/203) 18 | - Configure babel to not rely on babel-runtime 19 | [#202](https://github.com/sequelize/umzug/pull/202) 20 | - Skip logging non-migration files 21 | [#190](https://github.com/sequelize/umzug/pull/190) 22 | 23 | ## v2.2.0 - 2018-11-18 24 | 25 | ### Added 26 | - feat: support passing an array of Migrations 27 | [#164](https://github.com/sequelize/umzug/pull/164) 28 | 29 | ### Changed 30 | - Doc fixes 31 | [#155](https://github.com/sequelize/umzug/pull/155) 32 | - Add support for coffeescript 2 33 | [#157](https://github.com/sequelize/umzug/pull/157) 34 | - throw error if a migration method doesn't return a thenable 35 | [#158](https://github.com/sequelize/umzug/pull/158) 36 | - Update README.md with respect to MongoDBStorage 37 | [#165](https://github.com/sequelize/umzug/pull/165) 38 | - fix multiple jsdoc lines related to MongoDBStorage 39 | [#174](https://github.com/sequelize/umzug/pull/174) 40 | - clarify up/down migrations "to" option 41 | [#176](https://github.com/sequelize/umzug/pull/176) 42 | - Test isolation by using different sqlite databases in each testsuite 43 | [#180](https://github.com/sequelize/umzug/pull/180) 44 | 45 | 46 | ## v2.1.0 - 2017-10-23 47 | ### Added 48 | - Ability to traverse sub directories 49 | [#80](https://github.com/sequelize/umzug/pull/80) 50 | 51 | ## v2.0.0 - 2017-05-10 52 | ### Added 53 | - Warn about ignored files in migrations directory 54 | [#108](https://github.com/sequelize/umzug/pull/108) 55 | - Support ES6 default export in migrations 56 | [#132](https://github.com/sequelize/umzug/pull/132) 57 | - Support custom storage instances 58 | [#133](https://github.com/sequelize/umzug/pull/133) 59 | 60 | ### Changed 61 | - Use ES6 classes instead of redefine classes 62 | [#130](https://github.com/sequelize/umzug/pull/130) 63 | - Pass only storage options to Storage constructor 64 | [#137](https://github.com/sequelize/umzug/pull/137) 65 | (Old format is still supported but **deprecated**.) 66 | 67 | ### Breaking changes 68 | - Migration.migration(), Migration.up(), and Migration.down() returns Promise 69 | instead of Bluebird [#132](https://github.com/sequelize/umzug/pull/132) 70 | 71 | ### Deprecations 72 | - Pass only storage options to Storage constructor 73 | [#137](https://github.com/sequelize/umzug/pull/137) 74 | 75 | ## v1.12.0 - 2017-04-21 76 | ### Added 77 | - Option `timestamps` to Sequelize storage [#99](https://github.com/sequelize/umzug/pull/99) 78 | 79 | ### Fixed 80 | - Reject migration if umzug can't find the migration method [#115](https://github.com/sequelize/umzug/pull/115) 81 | 82 | ## v1.11.0 - 2016-04-29 83 | ### Added 84 | - Events `migrating`, `migrated`, `reverting`, and `reverted` #76 85 | - Official support to all major Sequelize versions #73 86 | - Official support to Node.js v0.12, io.js v1-v3, and Node.js v4-v5 #73 87 | 88 | ### Fixed 89 | - Compatibility issues with Sequelize >= 3.15.1 #67 90 | 91 | ## v1.10.0 - 2016-04-17 92 | ### Added 93 | - Option `from` to `up` and `down` methods #72 94 | 95 | ### Fixed 96 | - Configurable `up` and `down` methods #70 97 | 98 | ## v1.9.1 - 2016-03-14 99 | ### Fixed 100 | - Call of `down` with empty object 101 | 102 | ## v1.9.0 - 2016-02-09 103 | ### Changed 104 | - Set charset for SequelizeMeta table to `utf8` 105 | 106 | ## v1.8.1 - 2016-02-09 107 | ### Added 108 | - Print details in error cases 109 | 110 | ### Changed 111 | - The `options` input object is not modified anymore 112 | - Updated lodash to 4.3.0 113 | 114 | ## v1.8.0 - 2016-01-05 115 | ### Added 116 | - The `none` storage 117 | 118 | ## v1.7.2 - 2015-12-27 119 | ### Fixed 120 | - Migrations on utf8mb4 databases 121 | 122 | ## v1.7.1 - 2015-12-03 123 | ### Changed 124 | - Ensure existence of migration specified by `to` parameter 125 | 126 | ## v1.7.0 - 2015-11-21 127 | ### Added 128 | - Option to define the database schema 129 | 130 | ### Changed 131 | - Sort table entries when reading currently executed migrations 132 | 133 | ## 1.6.0 134 | ### Changed 135 | - Don't resolve the sequelize library anymore but use the instance's constructor 136 | 137 | ## 1.5.0 138 | ### Added 139 | - ActiveRecord like logging 140 | 141 | ## 1.4.0 142 | ### Added 143 | - Builds for all versions of sequelize 144 | 145 | ### Changed 146 | - Project is now compatible with all versions of sequelize 147 | 148 | ## 1.3.1 149 | ### Changed 150 | - Update lodash to 3.0 151 | 152 | ## 1.3.0 153 | ### Added 154 | - Possibility to define the column type of the sequelize meta table 155 | -------------------------------------------------------------------------------- /test/sequelize.test.ts: -------------------------------------------------------------------------------- 1 | import {fsSyncer} from 'fs-syncer' 2 | import * as _path from 'path' 3 | import {Sequelize, QueryInterface} from 'sequelize' 4 | import {describe, test, expect, beforeAll} from 'vitest' 5 | import {SequelizeStorage} from '../src' 6 | import {Umzug} from '../src/umzug' 7 | 8 | describe('recommended usage', () => { 9 | const baseDir = _path.join(__dirname, 'generated/sequelize/integration/recommended-usage') 10 | 11 | const syncer = fsSyncer(_path.join(baseDir, 'migrations'), { 12 | '00_initial.js': ` 13 | const { DataTypes } = require('sequelize'); 14 | 15 | async function up({context: queryInterface}) { 16 | await queryInterface.createTable('users', { 17 | id: { 18 | type: DataTypes.INTEGER, 19 | allowNull: false, 20 | primaryKey: true 21 | }, 22 | name: { 23 | type: DataTypes.STRING, 24 | allowNull: false 25 | } 26 | }); 27 | } 28 | 29 | async function down({context: queryInterface}) { 30 | await queryInterface.dropTable('users'); 31 | } 32 | 33 | module.exports = { up, down }; 34 | `, 35 | }) 36 | 37 | const sequelize = new Sequelize({ 38 | dialect: 'sqlite', 39 | storage: _path.join(baseDir, 'db.sqlite'), 40 | logging: false, 41 | }) 42 | 43 | beforeAll(async () => { 44 | syncer.sync() 45 | await sequelize.query('drop table if exists users') 46 | }) 47 | 48 | test('sequelize recommended usage integration test', async () => { 49 | const context = sequelize.getQueryInterface() 50 | const umzug = new Umzug({ 51 | migrations: { 52 | glob: ['migrations/*.js', {cwd: baseDir}], 53 | }, 54 | context, 55 | storage: new SequelizeStorage({sequelize}), 56 | logger: undefined, 57 | }) 58 | 59 | const tableCount = async () => { 60 | const sql = ` 61 | SELECT count(*) as count 62 | FROM sqlite_master 63 | WHERE type='table' 64 | AND name='users' 65 | ` 66 | return context.sequelize.query(sql).then(([results]: any[]) => results[0]) 67 | } 68 | 69 | expect(await tableCount()).toEqual({count: 0}) 70 | 71 | await umzug.up() 72 | 73 | expect(await tableCount()).toEqual({count: 1}) 74 | 75 | await umzug.down() 76 | 77 | expect(await tableCount()).toEqual({count: 0}) 78 | }) 79 | }) 80 | 81 | describe('v2 back compat', () => { 82 | const baseDir = _path.join(__dirname, 'generated/sequelize/integration/back-compat') 83 | 84 | const syncer = fsSyncer(_path.join(baseDir, 'migrations'), { 85 | '00_initial.js': ` 86 | const { DataTypes } = require('sequelize'); 87 | 88 | async function up(queryInterface) { 89 | await queryInterface.createTable('users', { 90 | id: { 91 | type: DataTypes.INTEGER, 92 | allowNull: false, 93 | primaryKey: true 94 | }, 95 | name: { 96 | type: DataTypes.STRING, 97 | allowNull: false 98 | } 99 | }); 100 | } 101 | 102 | async function down(queryInterface) { 103 | await queryInterface.dropTable('users'); 104 | } 105 | 106 | module.exports = { up, down }; 107 | `, 108 | }) 109 | 110 | const sequelize = new Sequelize({ 111 | dialect: 'sqlite', 112 | storage: _path.join(baseDir, 'db.sqlite'), 113 | logging: false, 114 | }) 115 | 116 | beforeAll(async () => { 117 | syncer.sync() 118 | await sequelize.query('drop table if exists users') 119 | }) 120 | 121 | test('sequelize integration test', async () => { 122 | const queryInterface = sequelize.getQueryInterface() 123 | const umzug = new Umzug({ 124 | migrations: { 125 | glob: ['migrations/*.js', {cwd: baseDir}], 126 | resolve({name, path, context}) { 127 | // umzug v2.x received context directly - this resolve function supports migrations written for v2 128 | type MigrationFnV2 = (qi: QueryInterface) => Promise 129 | // eslint-disable-next-line @typescript-eslint/no-var-requires 130 | const migration: {up: MigrationFnV2; down: MigrationFnV2} = require(path!) 131 | 132 | return {name, up: async () => migration.up(context), down: async () => migration.down(context)} 133 | }, 134 | }, 135 | context: queryInterface, 136 | storage: new SequelizeStorage({sequelize}), 137 | logger: undefined, 138 | }) 139 | 140 | const tableCount = async () => { 141 | const sql = ` 142 | SELECT count(*) as count 143 | FROM sqlite_master 144 | WHERE type='table' 145 | AND name='users' 146 | ` 147 | return queryInterface.sequelize.query(sql).then(([results]: any[]) => results[0]) 148 | } 149 | 150 | expect(await tableCount()).toEqual({count: 0}) 151 | 152 | await umzug.up() 153 | 154 | expect(await tableCount()).toEqual({count: 1}) 155 | 156 | await umzug.down() 157 | 158 | expect(await tableCount()).toEqual({count: 0}) 159 | }) 160 | }) 161 | -------------------------------------------------------------------------------- /test/__snapshots__/4-sequelize-seeders.snap: -------------------------------------------------------------------------------- 1 | `node migrate --help` output: 2 | 3 | ... 4 | 5 | `node seed --help` output: 6 | 7 | ... 8 | 9 | `node seed up || echo failed` output: 10 | 11 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='seeder_meta'; 12 | Executing (default): CREATE TABLE IF NOT EXISTS `seeder_meta` (`name` VARCHAR(255) NOT NULL UNIQUE PRIMARY KEY); 13 | Executing (default): PRAGMA INDEX_LIST(`seeder_meta`) 14 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_seeder_meta_1`) 15 | Executing (default): SELECT `name` FROM `seeder_meta` AS `seeder_meta` ORDER BY `seeder_meta`.`name` ASC; 16 | { event: 'migrating', name: '<>.sample-users.ts' } 17 | Executing (default): INSERT INTO `users` (`id`,`name`) VALUES (1,'Alice'),(2,'Bob'); 18 | failed 19 | 20 | `node migrate up` output: 21 | 22 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='migration_meta'; 23 | Executing (default): CREATE TABLE IF NOT EXISTS `migration_meta` (`name` VARCHAR(255) NOT NULL UNIQUE PRIMARY KEY); 24 | Executing (default): PRAGMA INDEX_LIST(`migration_meta`) 25 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_migration_meta_1`) 26 | Executing (default): SELECT `name` FROM `migration_meta` AS `migration_meta` ORDER BY `migration_meta`.`name` ASC; 27 | { event: 'migrating', name: '<>.users-table.ts' } 28 | Executing (default): CREATE TABLE IF NOT EXISTS `users` (`id` INTEGER PRIMARY KEY, `name` VARCHAR(255) NOT NULL); 29 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='migration_meta'; 30 | Executing (default): PRAGMA INDEX_LIST(`migration_meta`) 31 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_migration_meta_1`) 32 | Executing (default): INSERT INTO `migration_meta` (`name`) VALUES ($1); 33 | { 34 | event: 'migrated', 35 | name: '<>.users-table.ts', 36 | durationSeconds: ??? 37 | } 38 | { event: 'migrating', name: '<>.roles.ts' } 39 | Executing (default): CREATE TABLE IF NOT EXISTS `roles` (`id` INTEGER PRIMARY KEY, `name` VARCHAR(255) NOT NULL); 40 | Executing (default): CREATE TABLE IF NOT EXISTS `user_roles` (`user_id` INTEGER NOT NULL REFERENCES `users` (`id`), `role_id` INTEGER NOT NULL REFERENCES `roles` (`id`), PRIMARY KEY (`user_id`, `role_id`)); 41 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='migration_meta'; 42 | Executing (default): PRAGMA INDEX_LIST(`migration_meta`) 43 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_migration_meta_1`) 44 | Executing (default): INSERT INTO `migration_meta` (`name`) VALUES ($1); 45 | { 46 | event: 'migrated', 47 | name: '<>.roles.ts', 48 | durationSeconds: ??? 49 | } 50 | { event: 'up', message: 'applied 2 migrations.' } 51 | 52 | `node seed up` output: 53 | 54 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='seeder_meta'; 55 | Executing (default): PRAGMA INDEX_LIST(`seeder_meta`) 56 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_seeder_meta_1`) 57 | Executing (default): SELECT `name` FROM `seeder_meta` AS `seeder_meta` ORDER BY `seeder_meta`.`name` ASC; 58 | { event: 'migrating', name: '<>.sample-users.ts' } 59 | Executing (default): INSERT INTO `users` (`id`,`name`) VALUES (1,'Alice'),(2,'Bob'); 60 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='seeder_meta'; 61 | Executing (default): PRAGMA INDEX_LIST(`seeder_meta`) 62 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_seeder_meta_1`) 63 | Executing (default): INSERT INTO `seeder_meta` (`name`) VALUES ($1); 64 | { 65 | event: 'migrated', 66 | name: '<>.sample-users.ts', 67 | durationSeconds: ??? 68 | } 69 | { 70 | event: 'migrating', 71 | name: '<>.sample-user-roles.ts' 72 | } 73 | Executing (default): INSERT INTO `roles` (`id`,`name`) VALUES (1,'admin'); 74 | Executing (default): INSERT INTO `user_roles` (`user_id`,`role_id`) VALUES (1,1); 75 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='seeder_meta'; 76 | Executing (default): PRAGMA INDEX_LIST(`seeder_meta`) 77 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_seeder_meta_1`) 78 | Executing (default): INSERT INTO `seeder_meta` (`name`) VALUES ($1); 79 | { 80 | event: 'migrated', 81 | name: '<>.sample-user-roles.ts', 82 | durationSeconds: ??? 83 | } 84 | { event: 'up', message: 'applied 2 migrations.' } 85 | 86 | `node seed down --to 0` output: 87 | 88 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='seeder_meta'; 89 | Executing (default): PRAGMA INDEX_LIST(`seeder_meta`) 90 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_seeder_meta_1`) 91 | Executing (default): SELECT `name` FROM `seeder_meta` AS `seeder_meta` ORDER BY `seeder_meta`.`name` ASC; 92 | { 93 | event: 'reverting', 94 | name: '<>.sample-user-roles.ts' 95 | } 96 | Executing (default): DELETE FROM `user_roles` WHERE `user_id` IN (1) 97 | Executing (default): DELETE FROM `roles` WHERE `id` IN (1) 98 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='seeder_meta'; 99 | Executing (default): PRAGMA INDEX_LIST(`seeder_meta`) 100 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_seeder_meta_1`) 101 | Executing (default): DELETE FROM `seeder_meta` WHERE `name` = '<>.sample-user-roles.ts' 102 | { 103 | event: 'reverted', 104 | name: '<>.sample-user-roles.ts', 105 | durationSeconds: ??? 106 | } 107 | { event: 'reverting', name: '<>.sample-users.ts' } 108 | Executing (default): DELETE FROM `users` WHERE `id` IN (1, 2) 109 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='seeder_meta'; 110 | Executing (default): PRAGMA INDEX_LIST(`seeder_meta`) 111 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_seeder_meta_1`) 112 | Executing (default): DELETE FROM `seeder_meta` WHERE `name` = '<>.sample-users.ts' 113 | { 114 | event: 'reverted', 115 | name: '<>.sample-users.ts', 116 | durationSeconds: ??? 117 | } 118 | { event: 'down', message: 'reverted 2 migrations.' } 119 | 120 | `node seed create --name new-seed-data.ts` output: 121 | 122 | { 123 | event: 'created', 124 | path: '<>/examples/4-sequelize-seeders/seeders/<>.new-seed-data.ts' 125 | } 126 | Executing (default): SELECT name FROM sqlite_master WHERE type='table' AND name='seeder_meta'; 127 | Executing (default): PRAGMA INDEX_LIST(`seeder_meta`) 128 | Executing (default): PRAGMA INDEX_INFO(`sqlite_autoindex_seeder_meta_1`) 129 | Executing (default): SELECT `name` FROM `seeder_meta` AS `seeder_meta` ORDER BY `seeder_meta`.`name` ASC; -------------------------------------------------------------------------------- /src/storage/sequelize.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 3 | import type {SetRequired} from 'type-fest' 4 | import type {UmzugStorage} from './contract' 5 | 6 | type ModelTempInterface = {} & ModelClass & Record 7 | 8 | /** 9 | * Minimal structure of a sequelize model, defined here to avoid a hard dependency. 10 | * The type expected is `import { Model } from 'sequelize'` 11 | */ 12 | export type ModelClass = { 13 | tableName: string 14 | sequelize?: SequelizeType 15 | getTableName(): string 16 | sync(): Promise 17 | findAll(options?: {}): Promise 18 | create(options: {}): Promise 19 | destroy(options: {}): Promise 20 | } 21 | 22 | /** 23 | * Minimal structure of a sequelize model, defined here to avoid a hard dependency. 24 | * The type expected is `import { Sequelize } from 'sequelize'` 25 | */ 26 | export type SequelizeType = { 27 | getQueryInterface(): any 28 | isDefined(modelName: string): boolean 29 | model(modelName: string): any 30 | define(modelName: string, columns: {}, options: {}): {} 31 | dialect?: { 32 | name?: string 33 | } 34 | } 35 | 36 | const DIALECTS_WITH_CHARSET_AND_COLLATE = new Set(['mysql', 'mariadb']) 37 | 38 | type ModelClassType = ModelClass & (new (values?: object, options?: any) => ModelTempInterface) 39 | 40 | type _SequelizeStorageConstructorOptions = { 41 | /** 42 | The configured instance of Sequelize. If omitted, it is inferred from the `model` option. 43 | */ 44 | readonly sequelize?: SequelizeType 45 | 46 | /** 47 | The model representing the SequelizeMeta table. Must have a column that matches the `columnName` option. If omitted, it is created automatically. 48 | */ 49 | readonly model?: any 50 | 51 | /** 52 | The name of the model. 53 | 54 | @default 'SequelizeMeta' 55 | */ 56 | readonly modelName?: string 57 | 58 | /** 59 | The name of the table. If omitted, defaults to the model name. 60 | */ 61 | readonly tableName?: string 62 | 63 | /** 64 | Name of the schema under which the table is to be created. 65 | 66 | @default undefined 67 | */ 68 | readonly schema?: any 69 | 70 | /** 71 | Name of the table column holding the executed migration names. 72 | 73 | @default 'name' 74 | */ 75 | readonly columnName?: string 76 | 77 | /** 78 | The type of the column holding the executed migration names. 79 | 80 | For `utf8mb4` charsets under InnoDB, you may need to set this to less than 190 81 | 82 | @default Sequelize.DataTypes.STRING 83 | */ 84 | readonly columnType?: any 85 | 86 | /** 87 | Option to add timestamps to the table 88 | 89 | @default false 90 | */ 91 | readonly timestamps?: boolean 92 | } 93 | 94 | export type SequelizeStorageConstructorOptions = 95 | | SetRequired<_SequelizeStorageConstructorOptions, 'sequelize'> 96 | | SetRequired<_SequelizeStorageConstructorOptions, 'model'> 97 | 98 | export class SequelizeStorage implements UmzugStorage { 99 | public readonly sequelize: SequelizeType 100 | public readonly columnType: string 101 | public readonly columnName: string 102 | public readonly timestamps: boolean 103 | public readonly modelName: string 104 | public readonly tableName?: string 105 | public readonly schema: any 106 | public readonly model: ModelClassType 107 | 108 | /** 109 | Constructs Sequelize based storage. Migrations will be stored in a SequelizeMeta table using the given instance of Sequelize. 110 | 111 | If a model is given, it will be used directly as the model for the SequelizeMeta table. Otherwise, it will be created automatically according to the given options. 112 | 113 | If the table does not exist it will be created automatically upon the logging of the first migration. 114 | */ 115 | constructor(options: SequelizeStorageConstructorOptions) { 116 | if (!options || (!options.model && !options.sequelize)) { 117 | throw new Error('One of "sequelize" or "model" storage option is required') 118 | } 119 | 120 | this.sequelize = options.sequelize ?? options.model.sequelize 121 | this.columnType = options.columnType ?? (this.sequelize.constructor as any).DataTypes.STRING 122 | this.columnName = options.columnName ?? 'name' 123 | this.timestamps = options.timestamps ?? false 124 | this.modelName = options.modelName ?? 'SequelizeMeta' 125 | this.tableName = options.tableName 126 | this.schema = options.schema 127 | this.model = options.model ?? this.getModel() 128 | } 129 | 130 | getModel(): ModelClassType { 131 | if (this.sequelize.isDefined(this.modelName)) { 132 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 133 | return this.sequelize.model(this.modelName) 134 | } 135 | 136 | const dialectName = this.sequelize.dialect?.name 137 | const hasCharsetAndCollate = dialectName && DIALECTS_WITH_CHARSET_AND_COLLATE.has(dialectName) 138 | 139 | return this.sequelize.define( 140 | this.modelName, 141 | { 142 | [this.columnName]: { 143 | type: this.columnType, 144 | allowNull: false, 145 | unique: true, 146 | primaryKey: true, 147 | autoIncrement: false, 148 | }, 149 | }, 150 | { 151 | tableName: this.tableName, 152 | schema: this.schema, 153 | timestamps: this.timestamps, 154 | charset: hasCharsetAndCollate ? 'utf8' : undefined, 155 | collate: hasCharsetAndCollate ? 'utf8_unicode_ci' : undefined, 156 | }, 157 | ) as ModelClassType 158 | } 159 | 160 | protected async syncModel() { 161 | await this.model.sync() 162 | } 163 | 164 | async logMigration({name: migrationName}: {name: string}): Promise { 165 | await this.syncModel() 166 | await this.model.create({ 167 | [this.columnName]: migrationName, 168 | }) 169 | } 170 | 171 | async unlogMigration({name: migrationName}: {name: string}): Promise { 172 | await this.syncModel() 173 | await this.model.destroy({ 174 | where: { 175 | [this.columnName]: migrationName, 176 | }, 177 | }) 178 | } 179 | 180 | async executed(): Promise { 181 | await this.syncModel() 182 | const migrations: any[] = await this.model.findAll({order: [[this.columnName, 'ASC']]}) 183 | return migrations.map(migration => { 184 | const name = migration[this.columnName] 185 | if (typeof name !== 'string') { 186 | throw new TypeError(`Unexpected migration name type: expected string, got ${typeof name}`) 187 | } 188 | 189 | return name 190 | }) 191 | } 192 | 193 | // TODO remove this 194 | _model(): ModelClassType { 195 | return this.model 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type * as typeFest from 'type-fest' 2 | import type {UmzugStorage} from './storage' 3 | 4 | /** 5 | * Create a type that has mutually exclusive keys. 6 | * Wrapper for @see `import('type-fest').MergeExclusive` that works for three types 7 | */ 8 | export type MergeExclusive = typeFest.MergeExclusive> 9 | 10 | export type Promisable = T | PromiseLike 11 | 12 | export type LogFn = (message: Record) => void 13 | 14 | /** Constructor options for the Umzug class */ 15 | export type UmzugOptions> = { 16 | /** The migrations that the Umzug instance should perform */ 17 | migrations: InputMigrations 18 | /** A logging function. Pass `console` to use stdout, or pass in your own logger. Pass `undefined` explicitly to disable logging. */ 19 | logger: Record<'info' | 'warn' | 'error' | 'debug', LogFn> | undefined 20 | /** The storage implementation. By default, `JSONStorage` will be used */ 21 | storage?: UmzugStorage 22 | /** An optional context object, which will be passed to each migration function, if defined */ 23 | context?: Ctx | (() => Promise | Ctx) 24 | /** Options for file creation */ 25 | create?: { 26 | /** 27 | * A function for generating placeholder migration files. Specify to make sure files generated via CLI or using `.create` follow team conventions. 28 | * Should return an array of [filepath, content] pairs. Usually, only one pair is needed, but to put `down` migrations in a separate 29 | * file, more than one can be returned. 30 | */ 31 | template?: (filepath: string) => Promisable> 32 | /** 33 | * The default folder that new migration files should be generated in. If this is not specified, the new migration file will be created 34 | * in the same folder as the last existing migration. The value here can be overriden by passing `folder` when calling `create`. 35 | */ 36 | folder?: string 37 | } 38 | } 39 | 40 | /** Serializeable metadata for a migration. The structure returned by the external-facing `pending()` and `executed()` methods. */ 41 | export type MigrationMeta = { 42 | /** Name - this is used as the migration unique identifier within storage */ 43 | name: string 44 | /** An optional filepath for the migration. Note: this may be undefined, since not all migrations correspond to files on the filesystem */ 45 | path?: string 46 | } 47 | 48 | export type MigrationParams = { 49 | name: string 50 | path?: string 51 | context: T 52 | } 53 | 54 | /** A callable function for applying or reverting a migration */ 55 | export type MigrationFn = (params: MigrationParams) => Promise 56 | 57 | /** 58 | * A runnable migration. Represents a migration object with an `up` function which can be called directly, with no arguments, and an optional `down` function to revert it. 59 | */ 60 | export type RunnableMigration = { 61 | /** The effect of applying the migration */ 62 | up: MigrationFn 63 | /** The effect of reverting the migration */ 64 | down?: MigrationFn 65 | } & MigrationMeta 66 | 67 | /** Glob instructions for migration files */ 68 | export type GlobInputMigrations = { 69 | /** 70 | * A glob string for migration files. Can also be in the format `[path/to/migrations/*.js', {cwd: 'some/base/dir', ignore: '**ignoreme.js' }]` 71 | * See https://npmjs.com/package/glob for more details on the glob format - this package is used internally. 72 | */ 73 | glob: string | [string, {cwd?: string; ignore?: string | string[]}] 74 | /** Will be supplied to every migration function. Can be a database client, for example */ 75 | /** A function which takes a migration name, path and context, and returns an object with `up` and `down` functions. */ 76 | resolve?: Resolver 77 | } 78 | 79 | /** 80 | * Allowable inputs for migrations. Can be either glob instructions for migration files, a list of runnable migrations, or a 81 | * function which receives a context and returns a list of migrations. 82 | */ 83 | export type InputMigrations = 84 | | GlobInputMigrations 85 | | Array> 86 | | ((context: T) => Promisable>) 87 | 88 | /** A function which takes a migration name, path and context, and returns an object with `up` and `down` functions. */ 89 | export type Resolver = (params: MigrationParams) => RunnableMigration 90 | 91 | export const RerunBehavior = { 92 | /** Hard error if an up migration that has already been run, or a down migration that hasn't, is encountered */ 93 | THROW: 'THROW', 94 | /** Silently skip up migrations that have already been run, or down migrations that haven't */ 95 | SKIP: 'SKIP', 96 | /** Re-run up migrations that have already been run, or down migrations that haven't */ 97 | ALLOW: 'ALLOW', 98 | } as const 99 | 100 | // eslint-disable-next-line @typescript-eslint/no-redeclare 101 | export type RerunBehavior = keyof typeof RerunBehavior 102 | 103 | export type MigrateUpOptions = MergeExclusive< 104 | { 105 | /** If specified, migrations up to and including this name will be run. Otherwise, all pending migrations will be run */ 106 | to?: string 107 | }, 108 | { 109 | /** Only run this many migrations. If not specified, all pending migrations will be run */ 110 | step: number 111 | }, 112 | { 113 | /** If specified, only the migrations with these names migrations will be run. An error will be thrown if any of the names are not found in the list of available migrations */ 114 | migrations: string[] 115 | 116 | /** What to do if a migration that has already been run is explicitly specified. Default is `THROW`. */ 117 | rerun?: RerunBehavior 118 | } 119 | > 120 | 121 | export type MigrateDownOptions = MergeExclusive< 122 | { 123 | /** If specified, migrations down to and including this name will be revert. Otherwise, only the last executed will be reverted */ 124 | to?: string | 0 125 | }, 126 | { 127 | /** Revert this many migrations. If not specified, only the most recent migration will be reverted */ 128 | step: number 129 | }, 130 | { 131 | /** 132 | * If specified, only the migrations with these names migrations will be reverted. An error will be thrown if any of the names are not found in the list of executed migrations. 133 | * Note, migrations will be run in the order specified. 134 | */ 135 | migrations: string[] 136 | 137 | /** What to do if a migration that has not been run is explicitly specified. Default is `THROW`. */ 138 | rerun?: RerunBehavior 139 | } 140 | > 141 | 142 | /** Map of eventName -> eventData type, where the keys are the string events that are emitted by an umzug instances, and the values are the payload emitted with the corresponding event. */ 143 | export type UmzugEvents = { 144 | migrating: MigrationParams 145 | migrated: MigrationParams 146 | reverting: MigrationParams 147 | reverted: MigrationParams 148 | beforeCommand: {command: string; context: Ctx} 149 | afterCommand: {command: string; context: Ctx} 150 | } 151 | -------------------------------------------------------------------------------- /src/cli.ts: -------------------------------------------------------------------------------- 1 | import * as cli from '@rushstack/ts-command-line' 2 | import type {MigrateDownOptions, MigrateUpOptions} from './types' 3 | import type {Umzug} from './umzug' 4 | 5 | export class UpAction extends cli.CommandLineAction { 6 | private _params: ReturnType 7 | 8 | constructor(protected umzug: Umzug) { 9 | super({ 10 | actionName: 'up', 11 | summary: 'Applies pending migrations', 12 | documentation: 'Performs all migrations. See --help for more options', 13 | }) 14 | } 15 | 16 | private static _defineParameters(action: UpAction) { 17 | return { 18 | to: action.defineStringParameter({ 19 | parameterLongName: '--to', 20 | argumentName: 'NAME', 21 | description: `All migrations up to and including this one should be applied`, 22 | }), 23 | step: action.defineIntegerParameter({ 24 | parameterLongName: '--step', 25 | argumentName: 'COUNT', 26 | description: `Apply this many migrations. If not specified, all will be applied.`, 27 | }), 28 | name: action.defineStringListParameter({ 29 | parameterLongName: '--name', 30 | argumentName: 'MIGRATION', 31 | description: `Explicity declare migration name(s) to be applied. Only these migrations will be applied.`, 32 | }), 33 | rerun: action.defineChoiceParameter({ 34 | parameterLongName: '--rerun', 35 | description: `Specify what action should be taken when a migration that has already been applied is passed to --name.`, 36 | alternatives: ['THROW', 'SKIP', 'ALLOW'], 37 | defaultValue: 'THROW', 38 | }), 39 | } 40 | } 41 | 42 | onDefineParameters(): void { 43 | this._params = UpAction._defineParameters(this) 44 | } 45 | 46 | async onExecute(): Promise { 47 | const { 48 | to: {value: to}, 49 | step: {value: step}, 50 | name: {values: nameArray}, 51 | rerun: {value: rerun}, 52 | } = this._params 53 | 54 | // string list parameters are always defined. When they're empty it means nothing was passed. 55 | const migrations = nameArray.length > 0 ? nameArray : undefined 56 | 57 | if (to && migrations) { 58 | throw new Error(`Can't specify 'to' and 'name' together`) 59 | } 60 | 61 | if (to && typeof step === 'number') { 62 | throw new Error(`Can't specify 'to' and 'step' together`) 63 | } 64 | 65 | if (typeof step === 'number' && migrations) { 66 | throw new Error(`Can't specify 'step' and 'name' together`) 67 | } 68 | 69 | if (rerun !== 'THROW' && !migrations) { 70 | throw new Error(`Can't specify 'rerun' without 'name'`) 71 | } 72 | 73 | const result = await this.umzug.up({to, step, migrations, rerun} as MigrateUpOptions) 74 | 75 | this.umzug.options.logger?.info({event: this.actionName, message: `applied ${result.length} migrations.`}) 76 | } 77 | } 78 | 79 | export class DownAction extends cli.CommandLineAction { 80 | private _params: ReturnType 81 | 82 | constructor(protected umzug: Umzug) { 83 | super({ 84 | actionName: 'down', 85 | summary: 'Revert migrations', 86 | documentation: 87 | 'Undoes previously-applied migrations. By default, undoes the most recent migration only. Use --help for more options. Useful in development to start from a clean slate. Use with care in production!', 88 | }) 89 | } 90 | 91 | private static _defineParameters(action: DownAction) { 92 | return { 93 | to: action.defineStringParameter({ 94 | parameterLongName: '--to', 95 | argumentName: 'NAME', 96 | description: `All migrations up to and including this one should be reverted. Pass '0' to revert all.`, 97 | }), 98 | step: action.defineIntegerParameter({ 99 | parameterLongName: '--step', 100 | argumentName: 'COUNT', 101 | description: `Revert this many migrations. If not specified, only the most recent migration will be reverted.`, 102 | }), 103 | name: action.defineStringListParameter({ 104 | parameterLongName: '--name', 105 | argumentName: 'MIGRATION', 106 | description: `Explicity declare migration name(s) to be reverted. Only these migrations will be reverted.`, 107 | }), 108 | // todo: come up with a better word for this 109 | rerun: action.defineChoiceParameter({ 110 | parameterLongName: '--rerun', 111 | description: `Specify what action should be taken when a migration that has already been applied is passed to --name.`, 112 | alternatives: ['THROW', 'SKIP', 'ALLOW'], 113 | defaultValue: 'THROW', 114 | }), 115 | } 116 | } 117 | 118 | onDefineParameters(): void { 119 | this._params = DownAction._defineParameters(this) 120 | } 121 | 122 | async onExecute(): Promise { 123 | const { 124 | to: {value: to}, 125 | step: {value: step}, 126 | name: {values: nameArray}, 127 | rerun: {value: rerun}, 128 | } = this._params 129 | 130 | // string list parameters are always defined. When they're empty it means nothing was passed. 131 | const migrations = nameArray.length > 0 ? nameArray : undefined 132 | 133 | if (to && migrations) { 134 | throw new Error(`Can't specify 'to' and 'name' together`) 135 | } 136 | 137 | if (to && typeof step === 'number') { 138 | throw new Error(`Can't specify 'to' and 'step' together`) 139 | } 140 | 141 | if (typeof step === 'number' && migrations) { 142 | throw new Error(`Can't specify 'step' and 'name' together`) 143 | } 144 | 145 | if (rerun !== 'THROW' && !migrations) { 146 | throw new Error(`Can't specify 'rerun' without 'name'`) 147 | } 148 | 149 | const result = await this.umzug.down({ 150 | to: to === '0' ? 0 : to, 151 | step, 152 | migrations, 153 | rerun, 154 | } as MigrateDownOptions) 155 | 156 | this.umzug.options.logger?.info({event: this.actionName, message: `reverted ${result.length} migrations.`}) 157 | } 158 | } 159 | 160 | export class ListAction extends cli.CommandLineAction { 161 | private _params: ReturnType 162 | 163 | constructor( 164 | private readonly action: 'pending' | 'executed', 165 | private readonly umzug: Umzug, 166 | ) { 167 | super({ 168 | actionName: action, 169 | summary: `Lists ${action} migrations`, 170 | documentation: `Prints migrations returned by \`umzug.${action}()\`. By default, prints migration names one per line.`, 171 | }) 172 | } 173 | 174 | private static _defineParameters(action: cli.CommandLineAction) { 175 | return { 176 | json: action.defineFlagParameter({ 177 | parameterLongName: '--json', 178 | description: 179 | `Print ${action.actionName} migrations in a json format including names and paths. This allows piping output to tools like jq. ` + 180 | `Without this flag, the migration names will be printed one per line.`, 181 | }), 182 | } 183 | } 184 | 185 | onDefineParameters(): void { 186 | this._params = ListAction._defineParameters(this) 187 | } 188 | 189 | async onExecute(): Promise { 190 | const migrations = await this.umzug[this.action]() 191 | const formatted = this._params.json.value 192 | ? JSON.stringify(migrations, null, 2) 193 | : migrations.map(m => m.name).join('\n') 194 | // eslint-disable-next-line no-console 195 | console.log(formatted) 196 | } 197 | } 198 | 199 | export class CreateAction extends cli.CommandLineAction { 200 | private _params: ReturnType 201 | 202 | constructor(readonly umzug: Umzug) { 203 | super({ 204 | actionName: 'create', 205 | summary: 'Create a migration file', 206 | documentation: 207 | 'Generates a placeholder migration file using a timestamp as a prefix. By default, mimics the last existing migration, or guesses where to generate the file if no migration exists yet.', 208 | }) 209 | } 210 | 211 | private static _defineParameters(action: cli.CommandLineAction) { 212 | return { 213 | name: action.defineStringParameter({ 214 | parameterLongName: '--name', 215 | argumentName: 'NAME', 216 | description: `The name of the migration file. e.g. my-migration.js, my-migration.ts or my-migration.sql. Note - a prefix will be added to this name, usually based on a timestamp. See --prefix`, 217 | required: true, 218 | }), 219 | prefix: action.defineChoiceParameter({ 220 | parameterLongName: '--prefix', 221 | description: 222 | 'The prefix format for generated files. TIMESTAMP uses a second-resolution timestamp, DATE uses a day-resolution timestamp, and NONE removes the prefix completely', 223 | alternatives: ['TIMESTAMP', 'DATE', 'NONE'], 224 | defaultValue: 'TIMESTAMP', 225 | }), 226 | folder: action.defineStringParameter({ 227 | parameterLongName: '--folder', 228 | argumentName: 'PATH', 229 | description: `Path on the filesystem where the file should be created. The new migration will be created as a sibling of the last existing one if this is omitted.`, 230 | }), 231 | allowExtension: action.defineStringListParameter({ 232 | parameterLongName: '--allow-extension', 233 | argumentName: 'EXTENSION', 234 | environmentVariable: 'UMZUG_ALLOW_EXTENSION', 235 | description: `Allowable extension for created files. By default .js, .ts and .sql files can be created. To create txt file migrations, for example, you could use '--name my-migration.txt --allow-extension .txt'`, 236 | }), 237 | skipVerify: action.defineFlagParameter({ 238 | parameterLongName: '--skip-verify', 239 | description: 240 | `By default, the generated file will be checked after creation to make sure it is detected as a pending migration. This catches problems like creation in the wrong folder, or invalid naming conventions. ` + 241 | `This flag bypasses that verification step.`, 242 | }), 243 | allowConfusingOrdering: action.defineFlagParameter({ 244 | parameterLongName: '--allow-confusing-ordering', 245 | description: 246 | `By default, an error will be thrown if you try to create a migration that will run before a migration that already exists. ` + 247 | `This catches errors which can cause problems if you change file naming conventions. ` + 248 | `If you use a custom ordering system, you can disable this behavior, but it's strongly recommended that you don't! ` + 249 | `If you're unsure, just ignore this option.`, 250 | }), 251 | } 252 | } 253 | 254 | onDefineParameters(): void { 255 | this._params = CreateAction._defineParameters(this) 256 | } 257 | 258 | async onExecute(): Promise { 259 | await this.umzug 260 | .create({ 261 | name: this._params.name.value, 262 | prefix: this._params.prefix.value, 263 | folder: this._params.folder.value, 264 | allowExtension: 265 | this._params.allowExtension.values.length > 0 ? this._params.allowExtension.values[0] : undefined, 266 | allowConfusingOrdering: this._params.allowConfusingOrdering.value, 267 | skipVerify: this._params.skipVerify.value, 268 | }) 269 | .catch((e: Error) => { 270 | Object.entries(this._params) 271 | .filter(entry => entry[0] !== 'name') 272 | .forEach(([name, param]) => { 273 | // replace `skipVerify` in error messages with `--skip-verify`, etc. 274 | e.message = e.message?.split(name).join(param.longName) 275 | }) 276 | throw e 277 | }) 278 | } 279 | } 280 | 281 | export type CommandLineParserOptions = { 282 | toolFileName?: string 283 | toolDescription?: string 284 | } 285 | 286 | export class UmzugCLI extends cli.CommandLineParser { 287 | constructor( 288 | readonly umzug: Umzug, 289 | commandLineParserOptions: CommandLineParserOptions = {}, 290 | ) { 291 | super({ 292 | toolFilename: commandLineParserOptions.toolFileName ?? '