├── .editorconfig ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── LICENSE ├── README.md ├── package-lock.json ├── package.json ├── packages └── immigration │ ├── .gitignore │ ├── README.md │ ├── package.json │ ├── src │ ├── bin.ts │ ├── index.spec.ts │ └── index.ts │ ├── test │ ├── migrations │ │ ├── 1_test.js │ │ └── 2_test.js │ └── out │ │ └── .gitignore │ ├── tsconfig.build.json │ └── tsconfig.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_size = 2 7 | indent_style = space 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | - push 4 | - pull_request 5 | jobs: 6 | test: 7 | name: Node.js ${{ matrix.node-version }} 8 | runs-on: macos-latest 9 | strategy: 10 | matrix: 11 | node-version: 12 | - "12" 13 | - "*" 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-node@v1 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | - run: npm install -g npm@7 20 | - name: Hack around NPM issue with workspaces + `prepare` (https://github.com/npm/cli/issues/2900) 21 | run: mkdir packages/immigration/dist && touch packages/immigration/dist/bin.js 22 | - run: npm ci 23 | - run: npm test 24 | - uses: codecov/codecov-action@v1 25 | with: 26 | name: Node.js ${{ matrix.node-version }} 27 | files: ./packages/immigration/coverage/lcov.info 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | coverage/ 3 | .DS_Store 4 | npm-debug.log 5 | dist/ 6 | *.tsbuildinfo 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Blake Embrey 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Immigration 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![NPM downloads][downloads-image]][downloads-url] 5 | [![Build status][build-image]][build-url] 6 | [![Build coverage][coverage-image]][coverage-url] 7 | 8 | > Simple, no-frills migration utility. 9 | 10 | ## Installation 11 | 12 | ```sh 13 | npm install -g immigration 14 | ``` 15 | 16 | ## Packages 17 | 18 | - [Immigration](packages/immigration) 19 | 20 | ## License 21 | 22 | Apache 2.0 23 | 24 | [npm-image]: https://img.shields.io/npm/v/immigration 25 | [npm-url]: https://npmjs.org/package/immigration 26 | [downloads-image]: https://img.shields.io/npm/dm/immigration 27 | [downloads-url]: https://npmjs.org/package/immigration 28 | [build-image]: https://img.shields.io/github/workflow/status/blakeembrey/node-immigration/CI/main 29 | [build-url]: https://github.com/blakeembrey/node-immigration/actions/workflows/ci.yml?query=branch%3Amain 30 | [coverage-image]: https://img.shields.io/codecov/c/gh/blakeembrey/node-immigration 31 | [coverage-url]: https://codecov.io/gh/blakeembrey/node-immigration 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immigration", 3 | "private": true, 4 | "scripts": { 5 | "format": "npm run format --workspaces", 6 | "prepare": "ts-scripts install && npm run prepare --workspaces", 7 | "specs": "npm run specs --workspaces", 8 | "test": "npm run test --workspaces" 9 | }, 10 | "devDependencies": { 11 | "@borderless/ts-scripts": "^0.4.1", 12 | "@types/jest": "^26.0.20", 13 | "@types/node": "^14.14.22", 14 | "typescript": "^4.1.3" 15 | }, 16 | "workspaces": [ 17 | "./packages/*" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /packages/immigration/.gitignore: -------------------------------------------------------------------------------- 1 | .migrate.json 2 | -------------------------------------------------------------------------------- /packages/immigration/README.md: -------------------------------------------------------------------------------- 1 | # Immigration 2 | 3 | [![NPM version][npm-image]][npm-url] 4 | [![NPM downloads][downloads-image]][downloads-url] 5 | [![Build status][build-image]][build-url] 6 | [![Build coverage][coverage-image]][coverage-url] 7 | 8 | > Simple, no-frills migration utility. 9 | 10 | ## Installation 11 | 12 | ```sh 13 | npm install -g immigration 14 | ``` 15 | 16 | ## Usage 17 | 18 | From `immigration --help`: 19 | 20 | ``` 21 | immigration [options] [command] 22 | 23 | Options: 24 | --store [plugin] Loads a plugin for state storage (default: "fs") 25 | --directory [dir] Directory to read migrations from 26 | --extension [ext] Specify the default extension to support 27 | 28 | Commands: 29 | up Run up migration scripts 30 | down Run down migration scripts 31 | create Create a new migration file 32 | list List available migrations 33 | history List the run migrations 34 | force Force a migration to be valid 35 | remove Remove a migration 36 | ``` 37 | 38 | Migration files should export two functions: `up` and `down`. These functions can return a promise for asynchronous actions. 39 | 40 | ## License 41 | 42 | Apache 2.0 43 | 44 | [npm-image]: https://img.shields.io/npm/v/immigration 45 | [npm-url]: https://npmjs.org/package/immigration 46 | [downloads-image]: https://img.shields.io/npm/dm/immigration 47 | [downloads-url]: https://npmjs.org/package/immigration 48 | [build-image]: https://img.shields.io/github/workflow/status/blakeembrey/node-immigration/CI/main 49 | [build-url]: https://github.com/blakeembrey/node-immigration/actions/workflows/ci.yml?query=branch%3Amain 50 | [coverage-image]: https://img.shields.io/codecov/c/gh/blakeembrey/node-immigration 51 | [coverage-url]: https://codecov.io/gh/blakeembrey/node-immigration 52 | -------------------------------------------------------------------------------- /packages/immigration/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "immigration", 3 | "version": "2.3.0", 4 | "publishConfig": { 5 | "access": "public" 6 | }, 7 | "description": "Simple, no-frills migration utility", 8 | "license": "Apache-2.0", 9 | "repository": { 10 | "type": "git", 11 | "url": "git://github.com/blakeembrey/node-immigration.git" 12 | }, 13 | "author": { 14 | "name": "Blake Embrey", 15 | "email": "hello@blakeembrey.com", 16 | "url": "http://blakeembrey.me" 17 | }, 18 | "homepage": "https://github.com/blakeembrey/node-immigration", 19 | "bugs": { 20 | "url": "https://github.com/blakeembrey/node-immigration/issues" 21 | }, 22 | "main": "dist/index.js", 23 | "bin": { 24 | "immigration": "dist/bin.js" 25 | }, 26 | "scripts": { 27 | "format": "ts-scripts format", 28 | "lint": "ts-scripts lint", 29 | "prepare": "ts-scripts build", 30 | "specs": "ts-scripts specs", 31 | "test": "ts-scripts test" 32 | }, 33 | "files": [ 34 | "dist/" 35 | ], 36 | "keywords": [ 37 | "migrate", 38 | "migration", 39 | "database", 40 | "deploy", 41 | "update", 42 | "script" 43 | ], 44 | "dependencies": { 45 | "@servie/events": "^3.0.0", 46 | "arg": "^5.0.0", 47 | "chalk": "^4.1.0", 48 | "iterative": "^1.9.2", 49 | "make-error-cause": "^2.3.0", 50 | "ms": "^2.0.0", 51 | "pad-left": "^2.0.0", 52 | "performance-now": "^2.0.0", 53 | "touch": "^3.0.0" 54 | }, 55 | "devDependencies": { 56 | "@types/ms": "^0.7.31", 57 | "@types/pad-left": "^2.1.0" 58 | }, 59 | "ts-scripts": { 60 | "project": [ 61 | "tsconfig.build.json" 62 | ] 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /packages/immigration/src/bin.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import arg from "arg"; 4 | import ms from "ms"; 5 | import chalk from "chalk"; 6 | import { relative } from "path"; 7 | import { Migrate, Storage, ImmigrationError, FS_STORAGE } from "./index"; 8 | 9 | const HELP_SPEC = { 10 | "--help": Boolean, 11 | }; 12 | 13 | const RUN_ARG_SPEC = { 14 | ...HELP_SPEC, 15 | "--directory": String, 16 | "--extension": String, 17 | "--store": String, 18 | "-d": "--directory", 19 | "-e": "--extension", 20 | "-s": "--store", 21 | }; 22 | 23 | const LIST_ARG_SPEC = { 24 | ...HELP_SPEC, 25 | "--count": Number, 26 | "--reverse": Boolean, 27 | "--gte": String, 28 | "--lte": String, 29 | "-c": "--count", 30 | }; 31 | 32 | const MIGRATE_ARG_SPEC = { 33 | ...HELP_SPEC, 34 | "--dry-run": Boolean, 35 | "--to": String, 36 | "--all": Boolean, 37 | "--check": Number, 38 | "--wait": String, 39 | "-d": "--dry-run", 40 | }; 41 | 42 | const FAIL_ICON = chalk.red`⨯`; 43 | const SUCCESS_ICON = chalk.green`✔`; 44 | const NEXT_ICON = chalk.dim`➜`; 45 | 46 | /** Log text output with an icon, skip the icon in non-TTY environments. */ 47 | const format = (icon: string, text: string) => { 48 | if (chalk.supportsColor) return `${icon} ${text}`; 49 | return text; 50 | }; 51 | 52 | /** 53 | * Run the CLI script. 54 | */ 55 | async function run(argv: string[]): Promise { 56 | const { 57 | _, 58 | "--help": help, 59 | "--store": store, 60 | "--directory": directory = "migrations", 61 | "--extension": extension, 62 | } = arg(RUN_ARG_SPEC, { 63 | argv, 64 | stopAtPositional: true, 65 | }); 66 | 67 | const [commandName, ...args] = _; 68 | 69 | if (help || !commandName) { 70 | return console.log(` 71 | immigration [options] [command] 72 | 73 | Options: 74 | --store [plugin] Loads a plugin for state storage (default: "fs") 75 | --directory [dir] Directory to read migrations from 76 | --extension [ext] Specify the default extension to support 77 | 78 | Commands: 79 | up Run up migration scripts 80 | down Run down migration scripts 81 | create Create a new migration file 82 | list List available migrations 83 | history List the run migrations 84 | force Force a migration to be valid 85 | remove Remove a migration 86 | `); 87 | } 88 | 89 | // eslint-disable-next-line @typescript-eslint/no-var-requires 90 | const storage = store ? (require(store) as Storage) : FS_STORAGE; 91 | const migrate = new Migrate({ directory, storage, extension }); 92 | 93 | migrate.on("skip", (name) => { 94 | console.log(format(chalk.dim`-`, `Skipped: ${name}`)); 95 | }); 96 | 97 | migrate.on("plan", (name) => { 98 | console.log(format(chalk.cyan`-`, `Planned: ${name}`)); 99 | }); 100 | 101 | migrate.on("start", (name) => { 102 | console.log(format(chalk.yellow`○`, `Applying: ${name}`)); 103 | }); 104 | 105 | migrate.on("end", (name, success, duration) => { 106 | const iconText = success ? SUCCESS_ICON : FAIL_ICON; 107 | const statusText = success ? "success" : "failed"; 108 | const durationText = chalk.magenta(ms(duration)); 109 | console.log( 110 | format(iconText, `Done: ${name} (${statusText}) ${durationText}`) 111 | ); 112 | }); 113 | 114 | migrate.on("wait", (count, duration, maxWait) => { 115 | console.log( 116 | format(chalk.yellow`…`, `Waiting: ${ms(duration)} / ${ms(maxWait)}`) 117 | ); 118 | }); 119 | 120 | // Run the migration up/down. 121 | async function migration(direction: "up" | "down", argv: string[]) { 122 | const { 123 | "--help": help, 124 | "--dry-run": dryRun, 125 | "--to": to, 126 | "--all": all, 127 | "--check": check, 128 | "--wait": wait, 129 | } = arg(MIGRATE_ARG_SPEC, { argv }); 130 | 131 | if (help) { 132 | return console.log(` 133 | immigration ${direction} [options] 134 | 135 | Options: 136 | --to [string] The migration to end on (${ 137 | direction === "up" ? "inclusive" : "exclusive" 138 | }) 139 | --all Run all the migrations without specifying \`--to\` 140 | --dry-run Only preview the migrations, do not run them 141 | --check [int] The number of past migrations to validate exist before proceeding 142 | --wait Maximum duration to wait for lock to be acquired 143 | `); 144 | } 145 | 146 | const maxWait = wait ? ms(wait) : undefined; 147 | const migrations = await migrate.migrate(direction, { 148 | dryRun, 149 | to, 150 | all, 151 | check, 152 | maxWait, 153 | }); 154 | 155 | if (migrations.length) { 156 | console.log(format(SUCCESS_ICON, "Migrations finished")); 157 | } else { 158 | console.log(format(chalk.yellow`…`, "No migrations required")); 159 | } 160 | } 161 | 162 | // Get the migration history. 163 | async function history(argv: string[]) { 164 | const { 165 | "--help": help, 166 | "--count": count, 167 | "--gte": gte, 168 | "--lte": lte, 169 | "--reverse": reverse, 170 | } = arg(LIST_ARG_SPEC, { argv }); 171 | 172 | if (help) { 173 | return console.log(` 174 | immigration history [options] 175 | 176 | Lists historically executed migrations and their timestamps. 177 | 178 | Options: 179 | --count [int] The number of migrations to list 180 | --gte [string] The first migration to start from 181 | --lte [string] The final migration to end on 182 | --reverse Reverse the order of the migrations 183 | `); 184 | } 185 | 186 | const migrations = migrate.history({ count, reverse, gte, lte }); 187 | 188 | for await (const migration of migrations) { 189 | console.log( 190 | [ 191 | migration.name, 192 | chalk.bold(migration.valid ? "VALID" : "INVALID"), 193 | chalk.magenta(migration.date.toLocaleString()), 194 | ].join(" ") 195 | ); 196 | } 197 | } 198 | 199 | // Force the migration state. 200 | async function force(argv: string[]) { 201 | const { 202 | _: [name], 203 | "--help": help, 204 | } = arg(HELP_SPEC, { argv }); 205 | 206 | if (help) { 207 | return console.log(` 208 | immigration force [name] 209 | 210 | Forces the migration to be marked as valid in state. 211 | `); 212 | } 213 | 214 | if (!name) throw new ImmigrationError("No migration name to update"); 215 | 216 | await migrate.update(name, true); 217 | console.log(format(SUCCESS_ICON, "Migration forced to be valid")); 218 | } 219 | 220 | // Remove a migration status. 221 | async function remove(argv: string[]) { 222 | const { 223 | _: [name], 224 | "--help": help, 225 | } = arg(HELP_SPEC, { argv }); 226 | 227 | if (help) { 228 | return console.log(` 229 | immigration remove [name] 230 | 231 | Removes a migration from state. 232 | `); 233 | } 234 | 235 | if (!name) throw new ImmigrationError("No migration name to remove"); 236 | 237 | const removed = await migrate.remove(name); 238 | if (!removed) return console.log(format(FAIL_ICON, "Migration not found")); 239 | return console.log(format(SUCCESS_ICON, "Migration removed")); 240 | } 241 | 242 | // Print lock state. 243 | const printLockState = (isLocked: boolean) => { 244 | const icon = isLocked ? "🔒" : "🔓"; 245 | const state = chalk.bold(isLocked ? "LOCKED" : "UNLOCKED"); 246 | console.log(format(icon, `Migration state: ${state}`)); 247 | return process.exit(isLocked ? 1 : 0); 248 | }; 249 | 250 | // Remove the current migration lock. 251 | async function unlock() { 252 | const { "--help": help } = arg(HELP_SPEC, { argv }); 253 | 254 | if (help) { 255 | return console.log(` 256 | immigration unlock 257 | 258 | Force the migration state to be unlocked. 259 | `); 260 | } 261 | 262 | await migrate.unlock(); 263 | return printLockState(false); 264 | } 265 | 266 | // Check if the migration is locked. 267 | async function locked() { 268 | const { "--help": help } = arg(HELP_SPEC, { argv }); 269 | 270 | if (help) { 271 | return console.log(` 272 | immigration locked 273 | 274 | Print whether the migration state is locked and exit 0 when unlocked. 275 | `); 276 | } 277 | 278 | const isLocked = await migrate.isLocked(); 279 | return printLockState(isLocked); 280 | } 281 | 282 | // List available migrations. 283 | async function list(argv: string[]) { 284 | const { 285 | "--help": help, 286 | "--count": count, 287 | "--gte": gte, 288 | "--lte": lte, 289 | "--reverse": reverse, 290 | } = arg(LIST_ARG_SPEC, { argv }); 291 | 292 | if (help) { 293 | return console.log(` 294 | immigration list [options] 295 | 296 | Lists migration files available locally. 297 | 298 | Options: 299 | --count [int] The number of migrations to list 300 | --gte [string] The first migration to start from 301 | --lte [string] The final migration to end on 302 | --reverse Reverse the order of the migrations 303 | `); 304 | } 305 | 306 | const files = migrate.list({ count, gte, lte, reverse }); 307 | 308 | for await (const file of files) console.log(file); 309 | } 310 | 311 | // Create a new migration file. 312 | async function create(argv: string[]) { 313 | const { "--help": help, _: title } = arg(HELP_SPEC, { argv }); 314 | 315 | if (help) { 316 | return console.log(` 317 | immigration create [name] 318 | 319 | Creates a new migration file prefixed with UTC timestamp. 320 | `); 321 | } 322 | 323 | const path = await migrate.create(title.join(" ")); 324 | const filename = relative(process.cwd(), path); 325 | console.log(format(SUCCESS_ICON, `File created: ${filename}`)); 326 | } 327 | 328 | const commands = new Map void>([ 329 | ["create", create], 330 | ["list", list], 331 | ["history", history], 332 | ["force", force], 333 | ["remove", remove], 334 | ["unlock", unlock], 335 | ["locked", locked], 336 | ["up", (argv) => migration("up", argv)], 337 | ["down", (argv) => migration("down", argv)], 338 | ]); 339 | 340 | const command = commands.get(commandName); 341 | 342 | if (!command) { 343 | throw new ImmigrationError(`Invalid command: ${commandName}`); 344 | } 345 | 346 | return command(args); 347 | } 348 | 349 | // Remember to force process termination after `run`. 350 | run(process.argv.slice(2)).then( 351 | () => process.exit(0), 352 | (error) => { 353 | if (error instanceof ImmigrationError) { 354 | console.error(format(FAIL_ICON, error.message)); 355 | if (error.path) { 356 | console.error(`File: ${relative(process.cwd(), error.path)}`); 357 | } 358 | if (error.cause) { 359 | console.error(format(NEXT_ICON, `Caused by: ${error.cause}`)); 360 | } 361 | } else { 362 | console.error(error); 363 | } 364 | 365 | process.exit(1); 366 | } 367 | ); 368 | -------------------------------------------------------------------------------- /packages/immigration/src/index.spec.ts: -------------------------------------------------------------------------------- 1 | import { join } from "path"; 2 | import { promises as fs } from "fs"; 3 | import { FS_STORAGE, Migrate } from "./index"; 4 | import { list } from "iterative/dist/async"; 5 | 6 | const MIGRATION_DIRECTORY = join(__dirname, "../test/migrations"); 7 | const OUT_DIRECTORY = join(__dirname, "../test/out"); 8 | 9 | const getFiles = async () => { 10 | const files = await fs.readdir(OUT_DIRECTORY); 11 | return files.sort().filter((x) => x !== ".gitignore"); 12 | }; 13 | 14 | const migrate = new Migrate({ 15 | directory: MIGRATION_DIRECTORY, 16 | storage: FS_STORAGE, 17 | }); 18 | 19 | it("should not be locked", async () => { 20 | const isLocked = await migrate.isLocked(); 21 | expect(isLocked).toEqual(false); 22 | }); 23 | 24 | describe("list", () => { 25 | it("should list all files", async () => { 26 | const files = migrate.list({}); 27 | expect(await list(files)).toEqual(["1_test.js", "2_test.js"]); 28 | }); 29 | 30 | it("should limit by count", async () => { 31 | const files = migrate.list({ count: 1 }); 32 | expect(await list(files)).toEqual(["1_test.js"]); 33 | }); 34 | 35 | it("should limit by count in reverse", async () => { 36 | const files = migrate.list({ count: 1, reverse: true }); 37 | expect(await list(files)).toEqual(["2_test.js"]); 38 | }); 39 | }); 40 | 41 | describe("with no migrations", () => { 42 | beforeEach(async () => { 43 | const files = await getFiles(); 44 | await Promise.all( 45 | files.map((file) => fs.unlink(join(OUT_DIRECTORY, file))) 46 | ); 47 | 48 | try { 49 | await fs.unlink(join(process.cwd(), ".migrate.json")); 50 | } catch {} 51 | }); 52 | 53 | it("should migrate up", async () => { 54 | const migrations = await migrate.migrate("up", { all: true }); 55 | expect(migrations).toEqual(["1_test.js", "2_test.js"]); 56 | 57 | expect(await getFiles()).toEqual(["1", "2"]); 58 | }); 59 | 60 | it("should have empty history", async () => { 61 | const history = migrate.history({}); 62 | expect(await list(history)).toEqual([]); 63 | }); 64 | 65 | it("should return empty state", async () => { 66 | const entry = await migrate.show("1_test.js"); 67 | expect(entry).toBe(undefined); 68 | }); 69 | 70 | describe("with existing migrations", () => { 71 | beforeEach(async () => { 72 | await migrate.migrate("up", { all: true }); 73 | }); 74 | 75 | describe("history", () => { 76 | it("should have history", async () => { 77 | const history = migrate.history({}); 78 | const items = await list(history); 79 | expect(items.map((x) => x.name)).toEqual(["1_test.js", "2_test.js"]); 80 | }); 81 | 82 | it("should reverse history", async () => { 83 | const history = migrate.history({ reverse: true }); 84 | const items = await list(history); 85 | expect(items.map((x) => x.name)).toEqual(["2_test.js", "1_test.js"]); 86 | }); 87 | 88 | it("should limit history", async () => { 89 | const history = migrate.history({ count: 1 }); 90 | const items = await list(history); 91 | expect(items.map((x) => x.name)).toEqual(["1_test.js"]); 92 | }); 93 | 94 | it("should limit history in reverse", async () => { 95 | const history = migrate.history({ count: 1, reverse: true }); 96 | const items = await list(history); 97 | expect(items.map((x) => x.name)).toEqual(["2_test.js"]); 98 | }); 99 | }); 100 | 101 | it("should show state", async () => { 102 | const entry = await migrate.show("1_test.js"); 103 | expect(entry?.name).toBe("1_test.js"); 104 | expect(entry?.valid).toBe(true); 105 | expect(entry?.date).toBeInstanceOf(Date); 106 | }); 107 | 108 | it("should migrate down all", async () => { 109 | const migrations = await migrate.migrate("down", { all: true }); 110 | expect(migrations).toEqual(["2_test.js", "1_test.js"]); 111 | 112 | expect(await getFiles()).toEqual([]); 113 | }); 114 | 115 | it("should migrate down to specific file", async () => { 116 | const migrations = await migrate.migrate("down", { to: "1_test.js" }); 117 | expect(migrations).toEqual(["2_test.js"]); 118 | 119 | expect(await getFiles()).toEqual(["1"]); 120 | }); 121 | 122 | it("should only migrate down once", async () => { 123 | const migrations = await migrate.migrate("down", { to: "1_test.js" }); 124 | expect(migrations).toEqual(["2_test.js"]); 125 | 126 | const migrations2 = await migrate.migrate("down", { to: "1_test.js" }); 127 | expect(migrations2).toEqual([]); 128 | 129 | expect(await getFiles()).toEqual(["1"]); 130 | }); 131 | }); 132 | }); 133 | -------------------------------------------------------------------------------- /packages/immigration/src/index.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from "fs"; 2 | import now from "performance-now"; 3 | import { resolve, join, extname } from "path"; 4 | import { BaseError } from "make-error-cause"; 5 | import pad from "pad-left"; 6 | import { 7 | iter, 8 | next, 9 | chain, 10 | iterable, 11 | zipLongest, 12 | list, 13 | } from "iterative/dist/async"; 14 | import { Emitter } from "@servie/events"; 15 | 16 | /** 17 | * Errors caused during migration. 18 | */ 19 | export class ImmigrationError extends BaseError { 20 | constructor(msg: string, cause?: Error, public path?: string) { 21 | super(msg, cause); 22 | } 23 | } 24 | 25 | /** 26 | * Create a "retry lock" error. 27 | */ 28 | export class LockRetryError extends BaseError { 29 | constructor(cause?: Error) { 30 | super("Failed to acquire migration lock", cause); 31 | } 32 | } 33 | 34 | /** 35 | * Wrap an error as "safe", skips marking as invalid in storage. 36 | */ 37 | export class SafeMigrationError extends BaseError {} 38 | 39 | /** 40 | * Lock acquisition options. 41 | */ 42 | export interface AcquireOptions { 43 | maxWait?: number; 44 | retryWait?: number; 45 | } 46 | 47 | /** 48 | * List options. 49 | */ 50 | export interface ListOptions { 51 | /** The number of migrations to list. */ 52 | count?: number; 53 | /** Reverses the sort direction. */ 54 | reverse?: boolean; 55 | /** The first migration to include. */ 56 | gte?: string; 57 | /** The last migration to include. */ 58 | lte?: string; 59 | } 60 | 61 | /** 62 | * Migrate options. 63 | */ 64 | export interface MigrateOptions extends AcquireOptions { 65 | /** Run the migration with doing a real migration. */ 66 | dryRun?: boolean; 67 | /** 68 | * Historical migrations to validate before running, default `20`. 69 | * 70 | * Set to `1` to consider only the "latest" state. 71 | */ 72 | check?: number; 73 | /** The migration name to migrate to. */ 74 | to?: string; 75 | /** Migrate all the way to the last version available. */ 76 | all?: boolean; 77 | } 78 | 79 | /** 80 | * What an execution is stored as. 81 | */ 82 | export interface Execution { 83 | name: string; 84 | valid: boolean; 85 | date: Date; 86 | } 87 | 88 | /** 89 | * Wrap type to allow promise or synchronous value. 90 | */ 91 | export type OrPromise = T | Promise; 92 | 93 | /** 94 | * Options passed to plugins. 95 | */ 96 | export interface PluginOptions { 97 | cwd: string; 98 | } 99 | 100 | /** 101 | * The plugin only needs to export a single `create` function. 102 | */ 103 | export interface Storage { 104 | create(options: PluginOptions): OrPromise; 105 | } 106 | 107 | /** 108 | * Expose the required methods for migration. 109 | */ 110 | export interface Store { 111 | lock: () => OrPromise; 112 | unlock: () => OrPromise; 113 | isLocked: () => OrPromise; 114 | history: ( 115 | options: ListOptions 116 | ) => AsyncIterable | Iterable; 117 | show: (name: string) => OrPromise; 118 | update: (name: string, dirty: boolean, date: Date) => OrPromise; 119 | remove: (name: string) => OrPromise; 120 | } 121 | 122 | /** 123 | * Initialization options. 124 | */ 125 | export interface InitializeOptions { 126 | storage: Storage; 127 | directory: string; 128 | cwd?: string; 129 | extension?: string; 130 | } 131 | 132 | /** 133 | * No lock needed. 134 | */ 135 | const NO_ATTEMPT = Symbol("NO_ATTEMPT"); 136 | 137 | /** 138 | * Valid migration events. 139 | */ 140 | export interface Events { 141 | skip: [name: string]; 142 | start: [name: string]; 143 | end: [name: string, success: boolean, duration: number]; 144 | plan: [name: string]; 145 | wait: [attempt: number, duration: number, maxWait: number]; 146 | } 147 | 148 | /** 149 | * Migrate class. 150 | */ 151 | export class Migrate extends Emitter { 152 | cwd: string; 153 | directory: string; 154 | extension: string; 155 | storage: Storage; 156 | _store?: Promise; 157 | 158 | constructor(options: InitializeOptions) { 159 | super(); 160 | 161 | this.storage = options.storage; 162 | this.directory = resolve(options.directory); 163 | this.cwd = options.cwd ?? process.cwd(); 164 | this.extension = options.extension ?? ".js"; 165 | } 166 | 167 | async getStore(): Promise { 168 | if (!this._store) { 169 | this._store = Promise.resolve(this.storage.create({ cwd: this.cwd })); 170 | } 171 | return this._store; 172 | } 173 | 174 | async create(title: string): Promise { 175 | const date = new Date(); 176 | const prefix = 177 | String(date.getUTCFullYear()) + 178 | pad(String(date.getUTCMonth() + 1), 2, "0") + 179 | pad(String(date.getUTCDate()), 2, "0") + 180 | pad(String(date.getUTCHours()), 2, "0") + 181 | pad(String(date.getUTCMinutes()), 2, "0") + 182 | pad(String(date.getUTCSeconds()), 2, "0"); 183 | const label = title.replace(/\s+/g, "_").toLowerCase(); 184 | const suffix = label ? `_${label}` : ""; 185 | const path = join(this.directory, `${prefix}${suffix}${this.extension}`); 186 | 187 | await fs.open(path, "w").then((file) => file.close()); 188 | 189 | return path; 190 | } 191 | 192 | async show(name: string): Promise { 193 | const storage = await this.getStore(); 194 | return storage.show(name); 195 | } 196 | 197 | async update(name: string, valid: boolean, date?: Date): Promise { 198 | const storage = await this.getStore(); 199 | return storage.update(name, valid, date ?? new Date()); 200 | } 201 | 202 | async remove(filename: string): Promise { 203 | const storage = await this.getStore(); 204 | return storage.remove(filename); 205 | } 206 | 207 | async lock(): Promise { 208 | const storage = await this.getStore(); 209 | return storage.lock(); 210 | } 211 | 212 | async unlock(): Promise { 213 | const storage = await this.getStore(); 214 | return storage.unlock(); 215 | } 216 | 217 | async isLocked(): Promise { 218 | const storage = await this.getStore(); 219 | return storage.isLocked() ?? false; 220 | } 221 | 222 | async *history(options: ListOptions): AsyncIterable { 223 | const storage = await this.getStore(); 224 | yield* storage.history(options) ?? []; 225 | } 226 | 227 | async migrate( 228 | direction: "up" | "down", 229 | options: MigrateOptions 230 | ): Promise { 231 | const { to = "", all = false, check = 50, dryRun = false } = options; 232 | 233 | if (all === !!to) { 234 | throw new ImmigrationError("Either `to` or `all` must be specified"); 235 | } 236 | 237 | if (check < 1) { 238 | throw new ImmigrationError("Migration `check` should not be less than 1"); 239 | } 240 | 241 | // Run a migration. 242 | const exec = async (name: string) => { 243 | const path = join(this.directory, name); 244 | // eslint-disable-next-line @typescript-eslint/no-var-requires 245 | const module = require(path); 246 | const fn = module[direction]; 247 | 248 | // Skip missing up/down methods. 249 | if (fn === undefined) { 250 | this.emit("skip", name); 251 | return; 252 | } 253 | 254 | // Fail when the migration is invalid. 255 | if (typeof fn !== "function") { 256 | throw new ImmigrationError( 257 | `Migration ${direction} is not a function: ${name}`, 258 | undefined, 259 | path 260 | ); 261 | } 262 | 263 | this.emit("start", name); 264 | const start = now(); 265 | 266 | try { 267 | await fn(); 268 | 269 | this.emit("end", name, true, now() - start); 270 | 271 | if (direction === "up") { 272 | await this.update(name, true); 273 | } else { 274 | await this.remove(name); 275 | } 276 | } catch (error) { 277 | this.emit("end", name, false, now() - start); 278 | 279 | // Allow `SafeMigrationError` to simply undo the status. 280 | if (error instanceof SafeMigrationError) { 281 | throw new ImmigrationError(error.message, error.cause, path); 282 | } 283 | 284 | await this.update(name, false); 285 | 286 | throw new ImmigrationError( 287 | `Migration ${direction} failed: ${name}. Please fix the migration and update the state before trying again`, 288 | error, 289 | path 290 | ); 291 | } 292 | }; 293 | 294 | // Attempt to run the migrations. 295 | const migrated = await this.acquire( 296 | async (files: string[]) => { 297 | await files.reduce>( 298 | (p, file) => p.then(() => exec(file)), 299 | Promise.resolve() 300 | ); 301 | 302 | return files; 303 | }, 304 | async () => { 305 | // Load historical migrations to check. 306 | const history = iter( 307 | this.history({ 308 | count: check, 309 | gte: direction === "down" ? to : undefined, 310 | reverse: true, 311 | }) 312 | ); 313 | 314 | // Grab the latest execution to compare with file system. 315 | const latest = await next(history, undefined); 316 | 317 | // Verify the latest migration states are correct before continuing. 318 | if (latest) { 319 | const allHistory = chain([latest], iterable(history)); 320 | 321 | const files = this.list({ 322 | count: check, 323 | gte: direction === "down" ? to : undefined, 324 | lte: latest.name, 325 | reverse: true, 326 | }); 327 | 328 | for await (const [execution, file] of zipLongest(allHistory, files)) { 329 | if (execution === undefined || (file ?? "") > execution.name) { 330 | throw new ImmigrationError( 331 | `The migration (${JSON.stringify(file)}) has not been run yet` 332 | ); 333 | } 334 | 335 | if (file === undefined || file < execution.name) { 336 | throw new ImmigrationError( 337 | `The migration (${JSON.stringify( 338 | execution.name 339 | )}) cannot be found` 340 | ); 341 | } 342 | 343 | if (!execution.valid) { 344 | throw new ImmigrationError( 345 | `Migration (${JSON.stringify( 346 | execution.name 347 | )}) is in an invalid state` 348 | ); 349 | } 350 | } 351 | } 352 | 353 | const files = await list( 354 | direction === "down" 355 | ? this.list({ gte: to, lte: latest?.name, reverse: true }) 356 | : this.list({ gte: latest?.name, lte: to, reverse: false }) 357 | ); 358 | 359 | if (direction === "up") { 360 | // Exclude the latest run migration on `up`. 361 | if (files[0] === latest?.name) files.shift(); 362 | } else { 363 | // Exclude the last migration on `down`. 364 | if (files[files.length - 1] === to) files.pop(); 365 | } 366 | 367 | // Skip the migration attempt when there are no files. 368 | if (files.length === 0) return NO_ATTEMPT; 369 | 370 | if (dryRun) { 371 | for (const file of files) this.emit("plan", file); 372 | return NO_ATTEMPT; 373 | } 374 | 375 | return files; 376 | }, 377 | options 378 | ); 379 | 380 | return migrated ?? []; 381 | } 382 | 383 | async acquire( 384 | fn: (arg: V) => Promise, 385 | shouldTry: () => Promise, 386 | options: AcquireOptions 387 | ): Promise { 388 | // Default waits for 10 minutes and retries every 500ms. 389 | const maxWait = options.maxWait ?? 600_000; 390 | const retryWait = options.retryWait ?? 500; 391 | const start = now(); 392 | 393 | const attempt = async (count: number) => { 394 | const arg = await shouldTry(); 395 | if (arg === NO_ATTEMPT) return undefined; 396 | 397 | try { 398 | await this.lock(); 399 | } catch (error) { 400 | return new Promise((resolve, reject) => { 401 | const duration = now() - start; 402 | 403 | // Allow lock retries. This is useful as we will re-attempt which 404 | // may no longer require any migrations to lock to run. 405 | if (error instanceof LockRetryError && duration < maxWait) { 406 | this.emit("wait", count + 1, duration, maxWait); 407 | setTimeout(() => resolve(attempt(count + 1)), retryWait); 408 | return; 409 | } 410 | 411 | return reject(error); 412 | }); 413 | } 414 | 415 | try { 416 | return await fn(arg); 417 | } finally { 418 | await this.unlock(); 419 | } 420 | }; 421 | 422 | return attempt(0); 423 | } 424 | 425 | async *list(options: ListOptions): AsyncIterable { 426 | const { gte, lte, reverse } = options; 427 | let files = await fs.readdir(this.directory); 428 | 429 | files = files 430 | .filter((filename) => extname(filename) === this.extension) 431 | .sort(); 432 | 433 | const startIndex = gte ? files.indexOf(gte) : 0; 434 | const endIndex = lte ? files.indexOf(lte) : files.length - 1; 435 | 436 | if (startIndex === -1) { 437 | const name = JSON.stringify(gte); 438 | throw new ImmigrationError( 439 | `Migration (${name}) does not exist in migrations` 440 | ); 441 | } 442 | 443 | if (endIndex === -1) { 444 | const name = JSON.stringify(lte); 445 | throw new ImmigrationError( 446 | `Migration (${name}) does not exist in migrations` 447 | ); 448 | } 449 | 450 | files = files.slice(startIndex, endIndex + 1); 451 | 452 | // Invert order when reverse flag is passed. 453 | if (reverse) files.reverse(); 454 | 455 | // Limit results to requested `count`. 456 | if (options.count) files = files.slice(0, options.count); 457 | 458 | yield* files; 459 | } 460 | } 461 | 462 | /** 463 | * JSON entry for each migration `name`. 464 | */ 465 | interface FsStoreEntry { 466 | valid: boolean; 467 | date: string; 468 | } 469 | 470 | /** 471 | * Format of the JSON storage file. 472 | */ 473 | type FsStoreJson = Partial>; 474 | 475 | /** 476 | * Filesystem store. 477 | */ 478 | export const FS_STORAGE = { 479 | create(options: PluginOptions): Store { 480 | const path = join(options.cwd, ".migrate.json"); 481 | const lockfile = `${path}.lock`; 482 | let pending = Promise.resolve(); 483 | 484 | function toExecution(name: string, value: FsStoreEntry): Execution { 485 | return { 486 | name: name, 487 | valid: value.valid, 488 | date: new Date(value.date), 489 | }; 490 | } 491 | 492 | function updateStore(fn: (file: FsStoreJson) => FsStoreJson) { 493 | pending = pending.then(async () => { 494 | const contents = await read(); 495 | const update = fn(contents); 496 | await fs.writeFile(path, JSON.stringify(update, null, 2)); 497 | }); 498 | 499 | return pending; 500 | } 501 | 502 | async function read(): Promise { 503 | try { 504 | const text = await fs.readFile(path, "utf8"); 505 | return JSON.parse(text); 506 | } catch { 507 | return {}; 508 | } 509 | } 510 | 511 | async function update(name: string, valid: boolean, date: Date) { 512 | return updateStore((contents) => { 513 | contents[name] = { valid, date: date.toISOString() }; 514 | return contents; 515 | }); 516 | } 517 | 518 | async function remove(name: string) { 519 | let exists = false; 520 | await updateStore((contents) => { 521 | if (contents.hasOwnProperty(name)) { 522 | exists = true; 523 | delete contents[name]; 524 | } 525 | return contents; 526 | }); 527 | return exists; 528 | } 529 | 530 | async function show(name: string) { 531 | const contents = await read(); 532 | if (!contents.hasOwnProperty(name)) return; 533 | return toExecution(name, contents[name]!); 534 | } 535 | 536 | function lock() { 537 | return fs.open(lockfile, `wx`).then( 538 | (fd) => fd.close(), 539 | (err) => { 540 | if (err.code === "EEXIST") { 541 | throw new LockRetryError(err); 542 | } 543 | 544 | return Promise.reject(err); 545 | } 546 | ); 547 | } 548 | 549 | function unlock() { 550 | return fs.unlink(lockfile).catch(() => undefined); 551 | } 552 | 553 | function isLocked() { 554 | return fs.stat(lockfile).then( 555 | () => true, 556 | (err) => { 557 | if (err.code === "ENOENT") { 558 | return false; 559 | } 560 | 561 | return Promise.reject(err); 562 | } 563 | ); 564 | } 565 | 566 | async function* history(options: ListOptions) { 567 | const { gte, lte, reverse, count } = options; 568 | const contents = await read(); 569 | 570 | const history = Object.keys(contents) 571 | .sort() 572 | .filter((key) => { 573 | if (gte !== undefined && key < gte) return false; 574 | if (lte !== undefined && key > lte) return false; 575 | return true; 576 | }) 577 | .map((key) => toExecution(key, contents[key]!)); 578 | 579 | if (reverse) history.reverse(); 580 | 581 | yield* history.slice(0, count ?? history.length); 582 | } 583 | 584 | return { history, lock, isLocked, unlock, show, update, remove }; 585 | }, 586 | }; 587 | -------------------------------------------------------------------------------- /packages/immigration/test/migrations/1_test.js: -------------------------------------------------------------------------------- 1 | const { promises: fs } = require("fs"); 2 | const { join } = require("path"); 3 | 4 | var PATH = join(__dirname, "../out/1"); 5 | 6 | exports.up = function (done) { 7 | return fs.writeFile(PATH, "success", done); 8 | }; 9 | 10 | exports.down = function (done) { 11 | return fs.unlink(PATH, done); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/immigration/test/migrations/2_test.js: -------------------------------------------------------------------------------- 1 | const { promises: fs } = require("fs"); 2 | const { join } = require("path"); 3 | 4 | var PATH = join(__dirname, "../out/2"); 5 | 6 | exports.up = function (done) { 7 | return fs.writeFile(PATH, "success", done); 8 | }; 9 | 10 | exports.down = function (done) { 11 | return fs.unlink(PATH, done); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/immigration/test/out/.gitignore: -------------------------------------------------------------------------------- 1 | * 2 | !.gitignore 3 | -------------------------------------------------------------------------------- /packages/immigration/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "types": ["node"] 5 | }, 6 | "exclude": ["**/*.spec.ts"] 7 | } 8 | -------------------------------------------------------------------------------- /packages/immigration/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "rootDir": "src" 6 | }, 7 | "include": ["src/**/*"] 8 | } 9 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@borderless/ts-scripts/configs/tsconfig.json", 3 | "compilerOptions": { 4 | "composite": true, 5 | "module": "CommonJS", 6 | "types": ["node", "jest"], 7 | "baseUrl": "./packages", 8 | "paths": { 9 | "*": ["./*/src/index.ts"] 10 | } 11 | }, 12 | "include": [] 13 | } 14 | --------------------------------------------------------------------------------