├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── custom.md │ └── feature_request.md └── PULL_REQUEST_TEMPLATE.md ├── .gitignore ├── .travis.yml ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── bin └── migrate-mongo.js ├── eslint.config.js ├── lib ├── actions │ ├── create.js │ ├── down.js │ ├── init.js │ ├── status.js │ └── up.js ├── env │ ├── config.js │ ├── database.js │ └── migrationsDir.js ├── migrate-mongo.js └── utils │ ├── date.js │ ├── has-callback.js │ ├── lock.js │ └── module-loader.js ├── migrate-mongo-logo.png ├── package-lock.json ├── package.json ├── samples ├── commonjs │ ├── migrate-mongo-config.js │ └── migration.js └── esm │ ├── migrate-mongo-config.js │ └── migration.js └── test ├── actions ├── create.test.js ├── down.test.js ├── init.test.js ├── status.test.js └── up.test.js ├── env ├── config.test.js ├── database.test.js └── migrationsDir.test.js └── utils └── has-callback.test.js /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | 5 | --- 6 | 7 | **Describe the bug** 8 | A clear and concise description of what the bug is. 9 | 10 | **To Reproduce** 11 | Steps to reproduce the behavior: 12 | 13 | **Expected behavior** 14 | A clear and concise description of what you expected to happen. 15 | 16 | **Additional context** 17 | Add any other context about the problem here. 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/custom.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Custom issue template 3 | about: Describe this issue template's purpose here. 4 | 5 | --- 6 | 7 | 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | 5 | --- 6 | 7 | **Is your feature request related to a problem? Please describe.** 8 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 9 | 10 | **Describe the solution you'd like** 11 | A clear and concise description of what you want to happen. 12 | 13 | **Describe alternatives you've considered** 14 | A clear and concise description of any alternative solutions or features you've considered. 15 | 16 | **Additional context** 17 | Add any other context or screenshots about the feature request here. 18 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 7 | 8 | ##### Checklist 9 | 10 | 11 | - [ ] `npm test` passes and has 100% coverage 12 | - [ ] README.md is updated 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directory 2 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 3 | node_modules 4 | 5 | # Webstorm files 6 | .idea 7 | 8 | # Istanbul test coverage 9 | coverage 10 | .nyc_output -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "10" 4 | - "12" 5 | - "14" 6 | script: "npm run-script test-coverage" 7 | after_script: "cat ./coverage/lcov.info | ./node_modules/coveralls/bin/coveralls.js" 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) 6 | 7 | ## [12.1.3] - 2025-02-03 8 | - Remove accidentally added npm dependency (https://github.com/seppevs/migrate-mongo/pull/460) 9 | - Fix snyk issues 10 | 11 | ## [12.1.2] - 2025-01-29 12 | - Update ESM config with soft lock settings 13 | 14 | ## [12.1.1] - 2025-01-29 15 | - Fix issue when no soft lock is defined in config file 16 | 17 | ## [12.1.0] - 2025-01-29 18 | - Add soft lock feature (https://github.com/seppevs/migrate-mongo/pull/262) 19 | 20 | ## [12.0.1] - 2025-01-29 21 | - Fix "No url defined in config file" when using ESM (https://github.com/seppevs/migrate-mongo/issues/458) 22 | 23 | ## [12.0.0] - 2025-01-29 24 | - Fix "No url defined in config file" when using ESM ([`f086575`](https://github.com/seppevs/migrate-mongo/commit/f086575f6ec55411dca4f2cf9d24a19cb7c41696)) 25 | - fix: Bump deprecated eslint from 7.31.0 to 9.15.0 ([`63c37fa`](https://github.com/seppevs/migrate-mongo/commit/63c37fa0231ffbc192477d1449fcad7b478b5f42)) 26 | - Replace Lodash with Smaller Modular Packages ([`b08b924`](https://github.com/seppevs/migrate-mongo/commit/b08b924c35aea0ab5a1d0266703017f12a383344)) 27 | - add type annotations in sample migrations ([`55f8c0b`](https://github.com/seppevs/migrate-mongo/commit/55f8c0badeedde1ae533cb77a912c623606c8d45)) 28 | - Update package.json to support Mongo 7 ([`f33228f`](https://github.com/seppevs/migrate-mongo/commit/f33228f58d5c02402b355cbbbf0579051691f05b)) 29 | - feat: remove date-fns dep (https://github.com/seppevs/migrate-mongo/pull/436/) 30 | - Update README.md site with dependencies seems to be down (https://github.com/seppevs/migrate-mongo/pull/444) 31 | - Fix up/down command console output when logger replaces it (https://github.com/seppevs/migrate-mongo/pull/365) 32 | - typo: mistake with async function signature in README.md (https://github.com/seppevs/migrate-mongo/pull/333) 33 | - Enable rollback of all scripts from same migration ([`f67f6d4`](https://github.com/seppevs/migrate-mongo/commit/f67f6d43540773161ba913dd09c14ebf44e61594)) 34 | 35 | ## [11.0.0] - 2023-09-25 36 | - Upgrade mongodb to version 6 ([`1f020ab`](https://github.com/seppevs/migrate-mongo/commit/1f020ab8a3fafef826eb8c68e844ed94f3d9666e)) 37 | - docs: CHANGELOG ([`47740cb`](https://github.com/seppevs/migrate-mongo/commit/47740cb81bd8108631eaf2fe6b3fd4b4ba2aec92)) 38 | - docs: No autolink ([`f2c13c5`](https://github.com/seppevs/migrate-mongo/commit/f2c13c508cf60cdc790a1552fc24784e79c4ead9)) 39 | - typo: function async > async function ([`282cd6e`](https://github.com/seppevs/migrate-mongo/commit/282cd6e1527f02a282bbadc29ee61aa0c67bc0b4)) 40 | - fix: exit from process after create command is done ([`addeabf`](https://github.com/seppevs/migrate-mongo/commit/addeabf1c781752771f923370d83f5edfc1a335f)) 41 | - docs: added '{session}' options to Transactions API samples ([`47fa544`](https://github.com/seppevs/migrate-mongo/commit/47fa544a1c249e473135df06f6befa1b6a3caaaf)) 42 | 43 | ## [10.0.0] - 2023-04-11 44 | 45 | - Change dependencies of `mongodb@^4.4.1` to peerDependencies of `mongodb@^4.4.1||^5.0.0` ([`e4d9446`](https://github.com/seppevs/migrate-mongo/commit/e4d944680db7222482ce55340eaddf15c02c234d)) 46 | 47 | ## [9.0.0] - 2022-03-31 48 | 49 | - Add ESM support ([`633235e`](https://github.com/seppevs/migrate-mongo/commit/633235eecad3aa852d75809d5a150ddfb9a3a3b9), [`4b9a955`](https://github.com/seppevs/migrate-mongo/commit/4b9a955b291734c3f8971327423343d4d90311d1)) 50 | - Upgrade dependencies from `mongodb@^4.0.1` to `mongodb@^4.4.1` ([`b5d4dc5`](https://github.com/seppevs/migrate-mongo/commit/b5d4dc514062ef15525806bb58d5ff16ffac5173)) 51 | 52 | ## [8.2.3] - 2021-07-28 53 | 54 | - Upgrade dependencies from `mongodb@^3.6.4` to `mongodb@^4.0.1` ([`499fc8d`](https://github.com/seppevs/migrate-mongo/commit/499fc8dc823f0d8e794e2edb15767832144ef7f2)) 55 | 56 | ## [8.2.2] - 2021-03-05 57 | 58 | - Upgrade dependencies ([`4876e5b`](https://github.com/seppevs/migrate-mongo/commit/4876e5b5530f10055f2cd05da796fce78b3cc289)) 59 | 60 | ## [8.2.1] - 2021-03-05 61 | 62 | - Add custom file extension to sample file ([`3b79e11`](https://github.com/seppevs/migrate-mongo/commit/3b79e11b5ebf89123267601f2711e6f29ebe93de)) 63 | 64 | ## [8.1.5] - 2021-03-05 65 | 66 | - Add support for file hash tracking ([`20a8884`](https://github.com/seppevs/migrate-mongo/commit/20a8884e60ad09968093b7d0b09e22689d01ef2f)) 67 | 68 | ## [8.1.4] - 2020-10-19 69 | 70 | - Fix `mocha` issue ([`eeea64a`](https://github.com/seppevs/migrate-mongo/commit/eeea64a4ca98aa86d4b90b0a8e79ce54ab9f8719)) 71 | 72 | ## [8.1.3] - 2020-10-19 73 | 74 | - Upgrade dependencies ([`4385b78`](https://github.com/seppevs/migrate-mongo/commit/4385b78e4a536084ca9ef4b5b20b2aae02051f13)) 75 | 76 | ## [8.1.2] - 2020-09-21 77 | 78 | - Upgrade dependencies from `mongodb@3.5.9` to `mongodb@^3.6.2` ([`862fde0`](https://github.com/seppevs/migrate-mongo/commit/862fde035e4ecc3353f8d7e0f6aeafcef9ef01b1)) 79 | 80 | ## [8.1.0] - 2020-07-21 81 | 82 | - Add API to set config ([`f842ba1`](https://github.com/seppevs/migrate-mongo/commit/f842ba1c0db15e34860d115c0d945bffb0659b35)) 83 | - Change `databaseName` on connection URI to be optional ([`84494dd`](https://github.com/seppevs/migrate-mongo/commit/84494dd483f39bdcfe0d1377ed9348c751ec65a3)) 84 | 85 | ## [8.0.0] - 2020-07-20 86 | 87 | - Deprecate node v8 ([`af0eaf2`](https://github.com/seppevs/migrate-mongo/commit/af0eaf2d4c2d29b8a2bf7ce250c0d48d6d70307e)) 88 | 89 | ## [7.2.2] - 2020-07-20 90 | 91 | - Upgrade dependencies from `mongodb@3.5.6` to `mongodb@3.5.9` ([`53e7e63`](https://github.com/seppevs/migrate-mongo/commit/53e7e630dc6fc817b9fe45b85b5d4dff060aaaf4)) 92 | 93 | ## [7.2.1] - 2020-04-28 94 | 95 | - Downgrade dependencies from `fs-extra@9.0.0` to `fs-extra@8.1.0` to support node v8 ([`e066ef0`](https://github.com/seppevs/migrate-mongo/commit/e066ef0b25e133d438aa902d75922c618279f655)) 96 | 97 | ## [7.2.0] - 2020-04-24 98 | 99 | - Add configurable migration file extension ([`ecc48fb`](https://github.com/seppevs/migrate-mongo/commit/ecc48fbb29ff3cf76aaaa23c8973ec1eae8b83f9)) 100 | - Upgrade dependencies from `mongodb@3.5.3` to `mongodb@3.5.6` ([`eab9104`](https://github.com/seppevs/migrate-mongo/commit/eab910496b30241ad0348ccbef4225cb4492380b)) 101 | 102 | ## [7.1.0] - 2020-02-23 103 | 104 | - Upgrade dependencies from `mongodb@3.3.3` to `mongodb@3.5.3` ([`85d29be`](https://github.com/seppevs/migrate-mongo/commit/85d29be1026cdb9e6ec226b99da2947a1aa5c147)) 105 | 106 | ## [7.0.0] - 2019-11-03 107 | 108 | - Add `client` argument to access MongoDB transaction API ([`d52c418`](https://github.com/seppevs/migrate-mongo/commit/d52c4180b9b5532185daea21ca9f5759a41e8974)) 109 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at sebastian@vansande.org. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | (The MIT License) 2 | 3 | Copyright (c) 2016 Sebastian Van Sande 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | 'Software'), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 19 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 20 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 21 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 22 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | migrate-mongo database migration tool for Node.js 3 | 4 | [![Coverage Status](https://coveralls.io/repos/github/seppevs/migrate-mongo/badge.svg?branch=master)](https://coveralls.io/r/seppevs/migrate-mongo) [![NPM](https://img.shields.io/npm/v/migrate-mongo.svg?style=flat)](https://www.npmjs.org/package/migrate-mongo) [![Downloads](https://img.shields.io/npm/dm/migrate-mongo.svg?style=flat)](https://www.npmjs.org/package/migrate-mongo) [![Known Vulnerabilities](https://snyk.io/test/github/seppevs/migrate-mongo/badge.svg)](https://snyk.io/test/github/seppevs/migrate-mongo) 5 | 6 | migrate-mongo is a database migration tool for MongoDB running in Node.js 7 | 8 |

9 | 10 | ## Installation 11 | ````bash 12 | $ npm install -g migrate-mongo 13 | ```` 14 | 15 | ## CLI Usage 16 | ```` 17 | $ migrate-mongo 18 | Usage: migrate-mongo [options] [command] 19 | 20 | 21 | Commands: 22 | 23 | init initialize a new migration project 24 | create [description] create a new database migration with the provided description 25 | up [options] run all unapplied database migrations 26 | down [options] undo the last applied database migration 27 | status [options] print the changelog of the database 28 | 29 | Options: 30 | 31 | -h, --help output usage information 32 | -V, --version output the version number 33 | ```` 34 | 35 | ## Basic Usage 36 | ### Initialize a new project 37 | Make sure you have [Node.js](https://nodejs.org/en/) 10 (or higher) installed. 38 | 39 | Create a directory where you want to store your migrations for your mongo database (eg. 'albums' here) and cd into it 40 | ````bash 41 | $ mkdir albums-migrations 42 | $ cd albums-migrations 43 | ```` 44 | 45 | Initialize a new migrate-mongo project 46 | ````bash 47 | $ migrate-mongo init 48 | Initialization successful. Please edit the generated migrate-mongo-config.js file 49 | ```` 50 | 51 | The above command did two things: 52 | 1. create a sample 'migrate-mongo-config.js' file and 53 | 2. create a 'migrations' directory 54 | 55 | Edit the migrate-mongo-config.js file. An object or promise can be returned. Make sure you change the mongodb url: 56 | ````javascript 57 | // In this file you can configure migrate-mongo 58 | 59 | module.exports = { 60 | mongodb: { 61 | // TODO Change (or review) the url to your MongoDB: 62 | url: "mongodb://localhost:27017", 63 | 64 | // TODO Change this to your database name: 65 | databaseName: "YOURDATABASENAME", 66 | 67 | options: { 68 | useNewUrlParser: true // removes a deprecation warning when connecting 69 | // connectTimeoutMS: 3600000, // increase connection timeout to 1 hour 70 | // socketTimeoutMS: 3600000, // increase socket timeout to 1 hour 71 | } 72 | }, 73 | 74 | // The migrations dir, can be an relative or absolute path. Only edit this when really necessary. 75 | migrationsDir: "migrations", 76 | 77 | // The mongodb collection where the applied changes are stored. Only edit this when really necessary. 78 | changelogCollectionName: "changelog", 79 | 80 | // The file extension to create migrations and search for in migration dir 81 | migrationFileExtension: ".js", 82 | 83 | // Enable the algorithm to create a checksum of the file contents and use that in the comparison to determin 84 | // if the file should be run. Requires that scripts are coded to be run multiple times. 85 | useFileHash: false 86 | 87 | // The mongodb collection where the lock will be created. 88 | lockCollectionName: "changelog_lock", 89 | 90 | // The value in seconds for the TTL index that will be used for the lock. Value of 0 will disable the feature. 91 | lockTtl: 0 92 | }; 93 | ```` 94 | 95 | Alternatively, you can also encode your database name in the url (and leave out the `databaseName` property): 96 | ```` 97 | url: "mongodb://localhost:27017/YOURDATABASE", 98 | ```` 99 | 100 | ### Creating a new migration script 101 | To create a new database migration script, just run the ````migrate-mongo create [description]```` command. 102 | 103 | For example: 104 | ````bash 105 | $ migrate-mongo create blacklist_the_beatles 106 | Created: migrations/20160608155948-blacklist_the_beatles.js 107 | ```` 108 | 109 | A new migration file is created in the 'migrations' directory: 110 | ````javascript 111 | module.exports = { 112 | /** 113 | * @param db {import('mongodb').Db} 114 | * @param client {import('mongodb').MongoClient} 115 | * @returns {Promise} 116 | */ 117 | up(db, client) { 118 | // TODO write your migration here. Return a Promise (and/or use async & await). 119 | // See https://github.com/seppevs/migrate-mongo/#creating-a-new-migration-script 120 | // Example: 121 | // return db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: true}}); 122 | }, 123 | 124 | /** 125 | * @param db {import('mongodb').Db} 126 | * @param client {import('mongodb').MongoClient} 127 | * @returns {Promise} 128 | */ 129 | down(db, client) { 130 | // TODO write the statements to rollback your migration (if possible) 131 | // Example: 132 | // return db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: false}}); 133 | } 134 | }; 135 | ```` 136 | 137 | Edit this content so it actually performs changes to your database. Don't forget to write the down part as well. 138 | The ````db```` object contains [the official MongoDB db object](https://www.npmjs.com/package/mongodb) 139 | The ````client```` object is a [MongoClient](https://mongodb.github.io/node-mongodb-native/3.3/api/MongoClient.html) instance (which you can omit if you don't use it). 140 | 141 | There are 3 options to implement the `up` and `down` functions of your migration: 142 | 1. Return a Promises 143 | 2. Use async-await 144 | 3. Call a callback (DEPRECATED!) 145 | 146 | Always make sure the implementation matches the function signature: 147 | * `function up(db, client) { /* */ }` should return `Promise` 148 | * `async function up(db, client) { /* */ }` should contain `await` keyword(s) and return `Promise` 149 | * `function up(db, client, next) { /* */ }` should callback `next` 150 | 151 | #### Example 1: Return a Promise 152 | ````javascript 153 | module.exports = { 154 | up(db) { 155 | return db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: true}}); 156 | }, 157 | 158 | down(db) { 159 | return db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: false}}); 160 | } 161 | }; 162 | ```` 163 | 164 | #### Example 2: Use async & await 165 | Async & await is especially useful if you want to perform multiple operations against your MongoDB in one migration. 166 | 167 | ````javascript 168 | module.exports = { 169 | async up(db) { 170 | await db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: true}}); 171 | await db.collection('albums').updateOne({artist: 'The Doors'}, {$set: {stars: 5}}); 172 | }, 173 | 174 | async down(db) { 175 | await db.collection('albums').updateOne({artist: 'The Doors'}, {$set: {stars: 0}}); 176 | await db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: false}}); 177 | }, 178 | }; 179 | ```` 180 | 181 | #### Example 3: Call a callback (deprecated) 182 | Callbacks are supported for backwards compatibility. 183 | New migration scripts should be written using Promises and/or async & await. It's easier to read and write. 184 | 185 | ````javascript 186 | module.exports = { 187 | up(db, callback) { 188 | return db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: true}}, callback); 189 | }, 190 | 191 | down(db, callback) { 192 | return db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: false}}, callback); 193 | } 194 | }; 195 | ```` 196 | 197 | #### Overriding the sample migration 198 | To override the content of the sample migration that will be created by the `create` command, 199 | create a file **`sample-migration.js`** in the migrations directory. 200 | 201 | ### Checking the status of the migrations 202 | At any time, you can check which migrations are applied (or not) 203 | 204 | ````bash 205 | $ migrate-mongo status 206 | ┌─────────────────────────────────────────┬────────────┐ 207 | │ Filename │ Applied At │ 208 | ├─────────────────────────────────────────┼────────────┤ 209 | │ 20160608155948-blacklist_the_beatles.js │ PENDING │ 210 | └─────────────────────────────────────────┴────────────┘ 211 | 212 | ```` 213 | 214 | 215 | ### Migrate up 216 | This command will apply all pending migrations 217 | ````bash 218 | $ migrate-mongo up 219 | MIGRATED UP: 20160608155948-blacklist_the_beatles.js 220 | ```` 221 | 222 | If an an error occurred, it will stop and won't continue with the rest of the pending migrations 223 | 224 | If we check the status again, we can see the last migration was successfully applied: 225 | ````bash 226 | $ migrate-mongo status 227 | ┌─────────────────────────────────────────┬──────────────────────────┐ 228 | │ Filename │ Applied At │ 229 | ├─────────────────────────────────────────┼──────────────────────────┤ 230 | │ 20160608155948-blacklist_the_beatles.js │ 2016-06-08T20:13:30.415Z │ 231 | └─────────────────────────────────────────┴──────────────────────────┘ 232 | ```` 233 | 234 | ### Migrate down 235 | With this command, migrate-mongo will revert (only) the last applied migration 236 | 237 | ````bash 238 | $ migrate-mongo down 239 | MIGRATED DOWN: 20160608155948-blacklist_the_beatles.js 240 | ```` 241 | 242 | If we check the status again, we see that the reverted migration is pending again: 243 | ````bash 244 | $ migrate-mongo status 245 | ┌─────────────────────────────────────────┬────────────┐ 246 | │ Filename │ Applied At │ 247 | ├─────────────────────────────────────────┼────────────┤ 248 | │ 20160608155948-blacklist_the_beatles.js │ PENDING │ 249 | └─────────────────────────────────────────┴────────────┘ 250 | ```` 251 | 252 | #### Migrate down all scripts from a same migration 253 | With the flag -b (--block), migrate-mongo will revert all scripts of the last migration. 254 | ````bash 255 | $ migrate-mongo down -b 256 | ```` 257 | 258 | ## Advanced Features 259 | 260 | ### Using a custom config file 261 | All actions (except ```init```) accept an optional ````-f```` or ````--file```` option to specify a path to a custom config file. 262 | By default, migrate-mongo will look for a ````migrate-mongo-config.js```` config file in of the current directory. 263 | 264 | #### Example: 265 | 266 | ````bash 267 | $ migrate-mongo status -f '~/configs/albums-migrations.js' 268 | ┌─────────────────────────────────────────┬────────────┐ 269 | │ Filename │ Applied At │ 270 | ├─────────────────────────────────────────┼────────────┤ 271 | │ 20160608155948-blacklist_the_beatles.js │ PENDING │ 272 | └─────────────────────────────────────────┴────────────┘ 273 | 274 | ```` 275 | 276 | ### Using npm packages in your migration scripts 277 | You can use use Node.js modules (or require other modules) in your migration scripts. 278 | It's even possible to use npm modules, just provide a `package.json` file in the root of your migration project: 279 | 280 | ````bash 281 | $ cd albums-migrations 282 | $ npm init --yes 283 | ```` 284 | 285 | Now you have a package.json file, and you can install your favorite npm modules that might help you in your migration scripts. 286 | For example, one of the very useful [promise-fun](https://github.com/sindresorhus/promise-fun) npm modules. 287 | 288 | 289 | ### Using ESM (ECMAScript Modules) instead of CommonJS 290 | Since migrate-mongo 7.0.0, it's possible to use ESM instead of CommonJS. 291 | 292 | #### Using ESM when initializing a new project 293 | Pass the `-m esm` option to the `init` action: 294 | ````bash 295 | $ migrate-mongo init -m esm 296 | ```` 297 | 298 | It's also required to have package.json file in the root of your project with `"type": "module"`. 299 | Create a new package.json file: 300 | ````bash 301 | $ npm init --yes 302 | ```` 303 | 304 | Then edit this package.json file, and add: 305 | ````bash 306 | "type": "module" 307 | ```` 308 | 309 | When you create migration files with `migrate-mongo create`, they will be prepared for you in ESM style. 310 | 311 | Please note that CommonJS is still the default module loading system. 312 | 313 | ### Using MongoDB's Transactions API 314 | You can make use of the [MongoDB Transaction API](https://docs.mongodb.com/manual/core/transactions/) in your migration scripts. 315 | 316 | Note: this requires both: 317 | - MongoDB 4.0 or higher 318 | - migrate-mongo 7.0.0 or higher 319 | 320 | migrate-mongo will call your migration `up` and `down` function with a second argument: `client`. 321 | This `client` argument is an [MongoClient](https://mongodb.github.io/node-mongodb-native/3.3/api/MongoClient.html) instance, it gives you access to the `startSession` function. 322 | 323 | Example: 324 | 325 | ````javascript 326 | module.exports = { 327 | async up(db, client) { 328 | const session = client.startSession(); 329 | try { 330 | await session.withTransaction(async () => { 331 | await db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: true}}, {session}); 332 | await db.collection('albums').updateOne({artist: 'The Doors'}, {$set: {stars: 5}}, {session}); 333 | }); 334 | } finally { 335 | await session.endSession(); 336 | } 337 | }, 338 | 339 | async down(db, client) { 340 | const session = client.startSession(); 341 | try { 342 | await session.withTransaction(async () => { 343 | await db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: false}}, {session}); 344 | await db.collection('albums').updateOne({artist: 'The Doors'}, {$set: {stars: 0}}, {session}); 345 | }); 346 | } finally { 347 | await session.endSession(); 348 | } 349 | }, 350 | }; 351 | ```` 352 | 353 | ### Using a file hash algorithm to enable re-running updated files 354 | There are use cases where it may make sense to not treat scripts as immutable items. An example would be a simple collection with lookup values where you just can wipe and recreate the entire collection all at the same time. 355 | 356 | ```javascript 357 | useFileHash: true 358 | ``` 359 | 360 | Set this config value to will enable tracking a hash of the file contents and will run a file with the same name again as long as the file contents have changes. Setting this flag changes the behavior for every script and if this is enabled each script needs to be written in a manner where it can be re-run safefly. A script of the same name and hash will not be executed again, only if the hash changes. 361 | 362 | Now the status will also include the file hash in the output 363 | 364 | ```bash 365 | ┌────────────────────────────────────────┬──────────────────────────────────────────────────────────────────┬──────────────────────────┐ 366 | │ Filename │ Hash │ Applied At │ 367 | ├────────────────────────────────────────┼──────────────────────────────────────────────────────────────────┼──────────────────────────┤ 368 | │ 20160608155948-blacklist_the_beatles.js│ 7625a0220d552dbeb42e26fdab61d8c7ef54ac3a052254588c267e42e9fa876d │ 2021-03-04T15:40:22.732Z │ 369 | └────────────────────────────────────────┴──────────────────────────────────────────────────────────────────┴──────────────────────────┘ 370 | 371 | ``` 372 | 373 | ### Version 374 | To know which version of migrate-mongo you're running, just pass the `version` option: 375 | 376 | ````bash 377 | $ migrate-mongo version 378 | ```` 379 | 380 | ## API Usage 381 | 382 | ```javascript 383 | const { 384 | init, 385 | create, 386 | database, 387 | config, 388 | up, 389 | down, 390 | status 391 | } = require('migrate-mongo'); 392 | ``` 393 | 394 | ### `init() → Promise` 395 | 396 | Initialize a new migrate-mongo project 397 | ```javascript 398 | await init(); 399 | ``` 400 | 401 | The above command did two things: 402 | 1. create a sample `migrate-mongo-config.js` file and 403 | 2. create a `migrations` directory 404 | 405 | Edit the `migrate-mongo-config.js` file. Make sure you change the mongodb url. 406 | 407 | ### `create(description) → Promise` 408 | 409 | For example: 410 | ```javascript 411 | const fileName = await create('blacklist_the_beatles'); 412 | console.log('Created:', fileName); 413 | ``` 414 | 415 | A new migration file is created in the `migrations` directory. 416 | 417 | ### `database.connect() → Promise<{db: MongoDb, client: MongoClient}>` 418 | 419 | Connect to a mongo database using the connection settings from the `migrate-mongo-config.js` file. 420 | 421 | ```javascript 422 | const { db, client } = await database.connect(); 423 | ``` 424 | 425 | ### `config.read() → Promise` 426 | 427 | Read connection settings from the `migrate-mongo-config.js` file. 428 | 429 | ```javascript 430 | const mongoConnectionSettings = await config.read(); 431 | ``` 432 | 433 | ### `config.set(yourConfigObject)` 434 | 435 | Tell migrate-mongo NOT to use the `migrate-mongo-config.js` file, but instead use the config object passed as the first argument of this function. 436 | When using this feature, please do this at the very beginning of your program. 437 | 438 | Example: 439 | ```javascript 440 | const { config, up } = require('../lib/migrate-mongo'); 441 | 442 | const myConfig = { 443 | mongodb: { 444 | url: "mongodb://localhost:27017/mydatabase", 445 | options: { useNewUrlParser: true } 446 | }, 447 | migrationsDir: "migrations", 448 | changelogCollectionName: "changelog", 449 | migrationFileExtension: ".js" 450 | }; 451 | 452 | config.set(myConfig); 453 | 454 | // then, use the API as you normally would, eg: 455 | await up(); 456 | ``` 457 | 458 | ### `up(MongoDb, MongoClient) → Promise>` 459 | 460 | Apply all pending migrations 461 | 462 | ```javascript 463 | const { db, client } = await database.connect(); 464 | const migrated = await up(db, client); 465 | migrated.forEach(fileName => console.log('Migrated:', fileName)); 466 | ``` 467 | 468 | If an an error occurred, the promise will reject and won't continue with the rest of the pending migrations. 469 | 470 | ### `down(MongoDb, MongoClient) → Promise>` 471 | 472 | Revert (only) the last applied migration 473 | 474 | ```javascript 475 | const { db, client } = await database.connect(); 476 | const migratedDown = await down(db, client); 477 | migratedDown.forEach(fileName => console.log('Migrated Down:', fileName)); 478 | ``` 479 | 480 | ### `status(MongoDb) → Promise>` 481 | 482 | Check which migrations are applied (or not. 483 | 484 | ```javascript 485 | const { db } = await database.connect(); 486 | const migrationStatus = await status(db); 487 | migrationStatus.forEach(({ fileName, appliedAt }) => console.log(fileName, ':', appliedAt)); 488 | ``` 489 | 490 | ### `client.close() → Promise` 491 | Close the database connection 492 | 493 | ```javascript 494 | const { db, client } = await database.connect(); 495 | await client.close(); 496 | ``` 497 | -------------------------------------------------------------------------------- /bin/migrate-mongo.js: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env node 2 | 3 | const program = require("commander"); 4 | const _isEmpty = require("lodash.isempty"); 5 | const _values = require("lodash.values"); 6 | const Table = require("cli-table3"); 7 | const migrateMongo = require("../lib/migrate-mongo"); 8 | const pkgjson = require("../package.json"); 9 | 10 | function printMigrated(migrated = []) { 11 | migrated.forEach(migratedItem => { 12 | console.log(`MIGRATED UP: ${migratedItem}`); 13 | }); 14 | } 15 | 16 | function handleError(err) { 17 | console.error(`ERROR: ${err.message}`, err.stack); 18 | process.exit(1); 19 | } 20 | 21 | function printStatusTable(statusItems) { 22 | return migrateMongo.config.read().then(config => { 23 | const useFileHash = config.useFileHash === true; 24 | const table = new Table({ head: useFileHash ? ["Filename", "Hash", "Applied At", "Migration block"] : ["Filename", "Applied At", "Migration block"]}); 25 | statusItems.forEach(item => table.push(_values(item))); 26 | console.log(table.toString()); 27 | }) 28 | 29 | } 30 | 31 | program.version(pkgjson.version); 32 | 33 | program 34 | .command("init") 35 | .description("initialize a new migration project") 36 | .option("-m --module ", "module loading system (commonjs (DEFAULT) or esm)") 37 | .action(options => { 38 | global.options = options; 39 | migrateMongo 40 | .init() 41 | .then(() => 42 | console.log( 43 | `Initialization successful. Please edit the generated \`${migrateMongo.config.getConfigFilename()}\` file` 44 | ) 45 | ) 46 | .catch(err => handleError(err)) 47 | }); 48 | 49 | program 50 | .command("create [description]") 51 | .description("create a new database migration with the provided description") 52 | .option("-f --file ", "use a custom config file") 53 | .action((description, options) => { 54 | global.options = options; 55 | migrateMongo 56 | .create(description) 57 | .then(fileName => 58 | migrateMongo.config.read().then(config => { 59 | console.log(`Created: ${config.migrationsDir}/${fileName}`); 60 | }) 61 | ) 62 | .then(() => { 63 | process.exit(0); 64 | }) 65 | .catch(err => handleError(err)); 66 | }); 67 | 68 | program 69 | .command("up") 70 | .description("run all pending database migrations") 71 | .option("-f --file ", "use a custom config file") 72 | .action(options => { 73 | global.options = options; 74 | migrateMongo.database 75 | .connect() 76 | .then(({db, client}) => migrateMongo.up(db, client)) 77 | .then(migrated => { 78 | printMigrated(migrated); 79 | }) 80 | .then(() => { 81 | process.exit(0); 82 | }) 83 | .catch(err => { 84 | handleError(err); 85 | printMigrated(err.migrated); 86 | }); 87 | }); 88 | 89 | program 90 | .command("down") 91 | .description("undo the last applied database migration") 92 | .option("-f --file ", "use a custom config file") 93 | .option("-b --block", "rollback all scripts from the same migration block") 94 | .action(options => { 95 | global.options = options; 96 | migrateMongo.database 97 | .connect() 98 | .then(({db, client}) => migrateMongo.down(db, client)) 99 | .then(migrated => { 100 | migrated.forEach(migratedItem => { 101 | console.log(`MIGRATED DOWN: ${migratedItem}`); 102 | }); 103 | }) 104 | .then(() => { 105 | process.exit(0); 106 | }) 107 | .catch(err => { 108 | handleError(err); 109 | }); 110 | }); 111 | 112 | program 113 | .command("status") 114 | .description("print the changelog of the database") 115 | .option("-f --file ", "use a custom config file") 116 | .action(options => { 117 | global.options = options; 118 | migrateMongo.database 119 | .connect() 120 | .then(({db, client}) => migrateMongo.status(db, client)) 121 | .then(statusItems => printStatusTable(statusItems)) 122 | .then(() => { 123 | process.exit(0); 124 | }) 125 | .catch(err => { 126 | handleError(err); 127 | }); 128 | }); 129 | 130 | program.parse(process.argv); 131 | 132 | if (_isEmpty(program.rawArgs)) { 133 | program.outputHelp(); 134 | } 135 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | const mochaPlugin = require("eslint-plugin-mocha"); 2 | 3 | module.exports = [mochaPlugin.configs.flat.recommended]; 4 | -------------------------------------------------------------------------------- /lib/actions/create.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs-extra"); 2 | const path = require("path"); 3 | const date = require("../utils/date"); 4 | const migrationsDir = require("../env/migrationsDir"); 5 | const config = require("../env/config"); 6 | 7 | module.exports = async description => { 8 | if (!description) { 9 | throw new Error("Missing parameter: description"); 10 | } 11 | await migrationsDir.shouldExist(); 12 | const migrationsDirPath = await migrationsDir.resolve(); 13 | const migrationExtension = await migrationsDir.resolveMigrationFileExtension(); 14 | 15 | // Check if there is a 'sample-migration.js' file in migrations dir - if there is, use that 16 | let source; 17 | if (await migrationsDir.doesSampleMigrationExist()) { 18 | source = await migrationsDir.resolveSampleMigrationPath(); 19 | } else { 20 | const configContent = await config.read(); 21 | source = path.join(__dirname, `../../samples/${configContent.moduleSystem}/migration.js`); 22 | } 23 | 24 | const filename = `${date.nowAsString()}-${description 25 | .split(" ") 26 | .join("_")}${migrationExtension}`; 27 | const destination = path.join(migrationsDirPath, filename); 28 | await fs.copy(source, destination); 29 | return filename; 30 | }; 31 | -------------------------------------------------------------------------------- /lib/actions/down.js: -------------------------------------------------------------------------------- 1 | const pEachSeries = require("p-each-series"); 2 | const _last = require("lodash.last"); 3 | const _get = require("lodash.get"); 4 | const { promisify } = require("util"); 5 | const fnArgs = require("fn-args"); 6 | 7 | const status = require("./status"); 8 | const config = require("../env/config"); 9 | const migrationsDir = require("../env/migrationsDir"); 10 | const hasCallback = require("../utils/has-callback"); 11 | const lock = require("../utils/lock"); 12 | 13 | module.exports = async (db, client) => { 14 | const isBlockRollback = _get(global.options, "block"); 15 | const downgraded = []; 16 | const statusItems = await status(db); 17 | const appliedItems = statusItems.filter(item => item.appliedAt !== "PENDING"); 18 | const lastAppliedItem = _last(appliedItems); 19 | 20 | let itemsToRollback = []; 21 | 22 | if (isBlockRollback && lastAppliedItem.migrationBlock) { 23 | itemsToRollback = appliedItems.filter(item => item.migrationBlock === lastAppliedItem.migrationBlock).reverse(); 24 | } else { 25 | itemsToRollback = [lastAppliedItem]; 26 | } 27 | 28 | const rollbackItem = async item => { 29 | if (item) { 30 | try { 31 | const migration = await migrationsDir.loadMigration(item.fileName); 32 | const down = hasCallback(migration.down) ? promisify(migration.down) : migration.down; 33 | 34 | if (hasCallback(migration.down) && fnArgs(migration.down).length < 3) { 35 | // support old callback-based migrations prior to migrate-mongo 7.x.x 36 | await down(db); 37 | } else { 38 | await down(db, client); 39 | } 40 | 41 | } catch (err) { 42 | throw new Error( 43 | `Could not migrate down ${item.fileName}: ${err.message}` 44 | ); 45 | } 46 | const { changelogCollectionName } = await config.read(); 47 | const changelogCollection = db.collection(changelogCollectionName); 48 | try { 49 | await changelogCollection.deleteOne({ fileName: item.fileName }); 50 | downgraded.push(item.fileName); 51 | } catch (err) { 52 | throw new Error(`Could not update changelog: ${err.message}`); 53 | } 54 | } 55 | } 56 | 57 | if (await lock.exist(db)) { 58 | throw new Error("Could not migrate down, a lock is in place."); 59 | } 60 | try { 61 | await lock.activate(db); 62 | } catch(err) { 63 | throw new Error(`Could not create a lock: ${err.message}`); 64 | } 65 | 66 | try { 67 | await pEachSeries(itemsToRollback, rollbackItem); 68 | } catch (err) { 69 | await lock.clear(db); 70 | throw err; 71 | } 72 | 73 | await lock.clear(db); 74 | return downgraded; 75 | }; 76 | -------------------------------------------------------------------------------- /lib/actions/init.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs-extra"); 2 | const path = require("path"); 3 | 4 | const migrationsDir = require("../env/migrationsDir"); 5 | const config = require("../env/config"); 6 | 7 | function copySampleConfigFile() { 8 | const moduleSystem = global.options.module === 'esm' ? 'esm' : 'commonjs'; 9 | const source = path.join(__dirname, `../../samples/${moduleSystem}/migrate-mongo-config.js`); 10 | const destination = path.join( 11 | process.cwd(), 12 | config.DEFAULT_CONFIG_FILE_NAME 13 | ); 14 | return fs.copy(source, destination); 15 | } 16 | 17 | function createMigrationsDirectory() { 18 | return fs.mkdirs(path.join(process.cwd(), "migrations")); 19 | } 20 | 21 | module.exports = async () => { 22 | await migrationsDir.shouldNotExist(); 23 | await config.shouldNotExist(); 24 | await copySampleConfigFile(); 25 | return createMigrationsDirectory(); 26 | }; 27 | -------------------------------------------------------------------------------- /lib/actions/status.js: -------------------------------------------------------------------------------- 1 | const _find = require("lodash.find"); 2 | const migrationsDir = require("../env/migrationsDir"); 3 | const config = require("../env/config"); 4 | 5 | module.exports = async db => { 6 | await migrationsDir.shouldExist(); 7 | await config.shouldExist(); 8 | const fileNames = await migrationsDir.getFileNames(); 9 | 10 | const { changelogCollectionName, useFileHash } = await config.read(); 11 | const changelogCollection = db.collection(changelogCollectionName); 12 | const changelog = await changelogCollection.find({}).toArray(); 13 | 14 | 15 | const useFileHashTest = useFileHash === true; 16 | const statusTable = await Promise.all(fileNames.map(async (fileName) => { 17 | let fileHash; 18 | let findTest = { fileName }; 19 | if (useFileHashTest) { 20 | fileHash = await migrationsDir.loadFileHash(fileName); 21 | findTest = { fileName, fileHash }; 22 | } 23 | const itemInLog = _find(changelog, findTest); 24 | const appliedAt = itemInLog ? itemInLog.appliedAt.toJSON() : "PENDING"; 25 | const migrationBlock = itemInLog ? itemInLog.migrationBlock : undefined; 26 | return useFileHash ? { fileName, fileHash, appliedAt, migrationBlock } : { fileName, appliedAt, migrationBlock }; 27 | })); 28 | 29 | return statusTable; 30 | }; 31 | -------------------------------------------------------------------------------- /lib/actions/up.js: -------------------------------------------------------------------------------- 1 | const _filter = require("lodash.filter"); 2 | const pEachSeries = require("p-each-series"); 3 | const { promisify } = require("util"); 4 | const fnArgs = require("fn-args"); 5 | 6 | const status = require("./status"); 7 | const config = require("../env/config"); 8 | const migrationsDir = require("../env/migrationsDir"); 9 | const hasCallback = require("../utils/has-callback"); 10 | const lock = require("../utils/lock"); 11 | 12 | module.exports = async (db, client) => { 13 | const statusItems = await status(db); 14 | const pendingItems = _filter(statusItems, { appliedAt: "PENDING" }); 15 | const migrated = []; 16 | const migrationBlock = Date.now(); 17 | 18 | if (await lock.exist(db)) { 19 | throw new Error("Could not migrate up, a lock is in place."); 20 | } 21 | 22 | try { 23 | await lock.activate(db); 24 | } catch(err) { 25 | throw new Error(`Could not create a lock: ${err.message}`); 26 | } 27 | 28 | const migrateItem = async item => { 29 | try { 30 | const migration = await migrationsDir.loadMigration(item.fileName); 31 | const up = hasCallback(migration.up) ? promisify(migration.up) : migration.up; 32 | 33 | if (hasCallback(migration.up) && fnArgs(migration.up).length < 3) { 34 | // support old callback-based migrations prior to migrate-mongo 7.x.x 35 | await up(db); 36 | } else { 37 | await up(db, client); 38 | } 39 | 40 | } catch (err) { 41 | const error = new Error( 42 | `Could not migrate up ${item.fileName}: ${err.message}` 43 | ); 44 | error.stack = err.stack; 45 | error.migrated = migrated; 46 | await lock.clear(db); 47 | throw error; 48 | } 49 | 50 | const { changelogCollectionName, useFileHash } = await config.read(); 51 | const changelogCollection = db.collection(changelogCollectionName); 52 | 53 | const { fileName, fileHash } = item; 54 | const appliedAt = new Date(); 55 | 56 | try { 57 | await changelogCollection.insertOne(useFileHash === true ? { fileName, fileHash, appliedAt, migrationBlock } : { fileName, appliedAt, migrationBlock }); 58 | } catch (err) { 59 | throw new Error(`Could not update changelog: ${err.message}`); 60 | } 61 | migrated.push(item.fileName); 62 | }; 63 | 64 | await pEachSeries(pendingItems, migrateItem); 65 | await lock.clear(db); 66 | return migrated; 67 | }; 68 | -------------------------------------------------------------------------------- /lib/env/config.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs-extra"); 2 | const path = require("path"); 3 | const url = require("url"); 4 | const _get = require("lodash.get"); 5 | const moduleLoader = require('../utils/module-loader'); 6 | 7 | const DEFAULT_CONFIG_FILE_NAME = "migrate-mongo-config.js"; 8 | 9 | let customConfigContent = null; 10 | 11 | function getConfigPath() { 12 | const fileOptionValue = _get(global.options, "file"); 13 | if (!fileOptionValue) { 14 | return path.join(process.cwd(), DEFAULT_CONFIG_FILE_NAME); 15 | } 16 | 17 | if (path.isAbsolute(fileOptionValue)) { 18 | return fileOptionValue; 19 | } 20 | return path.join(process.cwd(), fileOptionValue); 21 | } 22 | 23 | function getModuleExports(module) { 24 | // If ESM module format need to return default export 25 | return module.default ? module.default : module; 26 | } 27 | 28 | module.exports = { 29 | DEFAULT_CONFIG_FILE_NAME, 30 | 31 | set(configContent) { 32 | customConfigContent = configContent 33 | }, 34 | 35 | async shouldExist() { 36 | if (!customConfigContent) { 37 | const configPath = getConfigPath(); 38 | try { 39 | await fs.stat(configPath); 40 | } catch (err) { 41 | throw new Error(`config file does not exist: ${configPath}`); 42 | } 43 | } 44 | }, 45 | 46 | async shouldNotExist() { 47 | if (!customConfigContent) { 48 | const configPath = getConfigPath(); 49 | const error = new Error(`config file already exists: ${configPath}`); 50 | try { 51 | await fs.stat(configPath); 52 | throw error; 53 | } catch (err) { 54 | if (err.code !== "ENOENT") { 55 | throw error; 56 | } 57 | } 58 | } 59 | }, 60 | 61 | getConfigFilename() { 62 | return path.basename(getConfigPath()); 63 | }, 64 | 65 | async read() { 66 | if (customConfigContent) { 67 | return customConfigContent; 68 | } 69 | const configPath = getConfigPath(); 70 | try { 71 | const result = await moduleLoader.require(configPath); 72 | return getModuleExports(result); 73 | } catch (e) { 74 | if (e.code === 'ERR_REQUIRE_ESM') { 75 | const loadedImport = await moduleLoader.import(url.pathToFileURL(configPath)); 76 | return getModuleExports(loadedImport); 77 | } 78 | throw e; 79 | } 80 | } 81 | }; 82 | -------------------------------------------------------------------------------- /lib/env/database.js: -------------------------------------------------------------------------------- 1 | const { MongoClient } = require("mongodb"); 2 | const _get = require("lodash.get"); 3 | const config = require("./config"); 4 | 5 | module.exports = { 6 | async connect() { 7 | const configContent = await config.read(); 8 | const url = _get(configContent, "mongodb.url"); 9 | const databaseName = _get(configContent, "mongodb.databaseName"); 10 | const options = _get(configContent, "mongodb.options"); 11 | 12 | if (!url) { 13 | throw new Error("No `url` defined in config file!"); 14 | } 15 | 16 | const client = await MongoClient.connect( 17 | url, 18 | options 19 | ); 20 | 21 | const db = client.db(databaseName); 22 | db.close = client.close; 23 | return { 24 | client, 25 | db, 26 | }; 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /lib/env/migrationsDir.js: -------------------------------------------------------------------------------- 1 | const fs = require("fs-extra"); 2 | const path = require("path"); 3 | const url = require("url"); 4 | const crypto = require("crypto"); 5 | const config = require("./config"); 6 | const moduleLoader = require('../utils/module-loader'); 7 | 8 | const DEFAULT_MIGRATIONS_DIR_NAME = "migrations"; 9 | const DEFAULT_MIGRATION_EXT = ".js"; 10 | 11 | async function resolveMigrationsDirPath() { 12 | let migrationsDir; 13 | try { 14 | const configContent = await config.read(); 15 | migrationsDir = configContent.migrationsDir; // eslint-disable-line 16 | // if config file doesn't have migrationsDir key, assume default 'migrations' dir 17 | if (!migrationsDir) { 18 | migrationsDir = DEFAULT_MIGRATIONS_DIR_NAME; 19 | } 20 | } catch (err) { 21 | // config file could not be read, assume default 'migrations' dir 22 | migrationsDir = DEFAULT_MIGRATIONS_DIR_NAME; 23 | } 24 | 25 | if (path.isAbsolute(migrationsDir)) { 26 | return migrationsDir; 27 | } 28 | return path.join(process.cwd(), migrationsDir); 29 | } 30 | 31 | async function resolveMigrationFileExtension() { 32 | let migrationFileExtension; 33 | try { 34 | const configContent = await config.read(); 35 | migrationFileExtension = configContent.migrationFileExtension || DEFAULT_MIGRATION_EXT; 36 | } catch (err) { 37 | // config file could not be read, assume default extension 38 | migrationFileExtension = DEFAULT_MIGRATION_EXT; 39 | } 40 | 41 | if (migrationFileExtension && !migrationFileExtension.startsWith('.')) { 42 | throw new Error('migrationFileExtension must start with dot'); 43 | } 44 | 45 | return migrationFileExtension; 46 | } 47 | 48 | async function resolveSampleMigrationFileName() { 49 | const migrationFileExtention = await resolveMigrationFileExtension(); 50 | return `sample-migration${migrationFileExtention}`; 51 | } 52 | 53 | async function resolveSampleMigrationPath() { 54 | const migrationsDir = await resolveMigrationsDirPath(); 55 | const sampleMigrationSampleFileName = await resolveSampleMigrationFileName(); 56 | return path.join(migrationsDir, sampleMigrationSampleFileName); 57 | } 58 | 59 | module.exports = { 60 | resolve: resolveMigrationsDirPath, 61 | resolveSampleMigrationPath, 62 | resolveMigrationFileExtension, 63 | 64 | async shouldExist() { 65 | const migrationsDir = await resolveMigrationsDirPath(); 66 | try { 67 | await fs.stat(migrationsDir); 68 | } catch (err) { 69 | throw new Error(`migrations directory does not exist: ${migrationsDir}`); 70 | } 71 | }, 72 | 73 | async shouldNotExist() { 74 | const migrationsDir = await resolveMigrationsDirPath(); 75 | const error = new Error( 76 | `migrations directory already exists: ${migrationsDir}` 77 | ); 78 | 79 | try { 80 | await fs.stat(migrationsDir); 81 | throw error; 82 | } catch (err) { 83 | if (err.code !== "ENOENT") { 84 | throw error; 85 | } 86 | } 87 | }, 88 | 89 | async getFileNames() { 90 | const migrationsDir = await resolveMigrationsDirPath(); 91 | const migrationExt = await resolveMigrationFileExtension(); 92 | const files = await fs.readdir(migrationsDir); 93 | const sampleMigrationFileName = await resolveSampleMigrationFileName(); 94 | return files.filter(file => path.extname(file) === migrationExt && path.basename(file) !== sampleMigrationFileName).sort(); 95 | }, 96 | 97 | async loadMigration(fileName) { 98 | const migrationsDir = await resolveMigrationsDirPath(); 99 | const migrationPath = path.join(migrationsDir, fileName); 100 | 101 | try { 102 | const result = moduleLoader.require(migrationPath); 103 | return getModuleExports(result); 104 | } catch (e) { 105 | if (e.code === 'ERR_REQUIRE_ESM') { 106 | const loadedImport = moduleLoader.import(url.pathToFileURL(migrationPath)); 107 | return getModuleExports(loadedImport); 108 | } 109 | throw e; 110 | } 111 | }, 112 | 113 | async loadFileHash(fileName) { 114 | const migrationsDir = await resolveMigrationsDirPath(); 115 | const filePath = path.join(migrationsDir, fileName) 116 | const hash = crypto.createHash('sha256'); 117 | const input = await fs.readFile(filePath); 118 | hash.update(input); 119 | return hash.digest('hex'); 120 | }, 121 | 122 | async doesSampleMigrationExist() { 123 | const samplePath = await resolveSampleMigrationPath(); 124 | try { 125 | await fs.stat(samplePath); 126 | return true; 127 | } catch (err) { 128 | return false; 129 | } 130 | }, 131 | }; 132 | 133 | function getModuleExports(module) { 134 | // If ESM module format need to return default export 135 | return module.default ? module.default : module; 136 | } 137 | 138 | -------------------------------------------------------------------------------- /lib/migrate-mongo.js: -------------------------------------------------------------------------------- 1 | const init = require("./actions/init"); 2 | const create = require("./actions/create"); 3 | const up = require("./actions/up"); 4 | const down = require("./actions/down"); 5 | const status = require("./actions/status"); 6 | const database = require("./env/database"); 7 | const config = require("./env/config"); 8 | 9 | module.exports = { 10 | init, 11 | create, 12 | up, 13 | down, 14 | status, 15 | database, 16 | config 17 | }; 18 | -------------------------------------------------------------------------------- /lib/utils/date.js: -------------------------------------------------------------------------------- 1 | const { format } = require("date-fns"); 2 | 3 | const now = (dateString = Date.now()) => { 4 | const date = new Date(dateString); 5 | return new Date( 6 | date.getUTCFullYear(), 7 | date.getUTCMonth(), 8 | date.getUTCDate(), 9 | date.getUTCHours(), 10 | date.getUTCMinutes(), 11 | date.getUTCSeconds(), 12 | date.getUTCMilliseconds() 13 | ); 14 | }; 15 | 16 | const nowAsString = () => format(now(), "yyyyMMddHHmmss"); 17 | 18 | module.exports = { 19 | now, 20 | nowAsString 21 | }; 22 | -------------------------------------------------------------------------------- /lib/utils/has-callback.js: -------------------------------------------------------------------------------- 1 | const fnArgs = require('fn-args'); 2 | const _last = require('lodash.last'); 3 | 4 | module.exports = (func) => { 5 | 6 | const argNames = fnArgs(func); 7 | const lastArgName = _last(argNames); 8 | 9 | return [ 10 | 'callback', 11 | 'callback_', 12 | 'cb', 13 | 'cb_', 14 | 'next', 15 | 'next_', 16 | 'done', 17 | 'done_' 18 | ].includes(lastArgName); 19 | 20 | }; 21 | -------------------------------------------------------------------------------- /lib/utils/lock.js: -------------------------------------------------------------------------------- 1 | const config = require('../env/config'); 2 | 3 | async function getLockCollection(db) { 4 | const { lockCollectionName, lockTtl } = await config.read(); 5 | if (!lockCollectionName || lockTtl <= 0) { 6 | return null; 7 | } 8 | 9 | const lockCollection = db.collection(lockCollectionName); 10 | lockCollection.createIndex({ createdAt: 1 }, { expireAfterSeconds: lockTtl }); 11 | return lockCollection; 12 | } 13 | 14 | async function exist(db) { 15 | const lockCollection = await getLockCollection(db); 16 | if (!lockCollection) { 17 | return false; 18 | } 19 | const foundLocks = await lockCollection.find({}).toArray(); 20 | 21 | return foundLocks.length > 0; 22 | } 23 | 24 | async function activate(db) { 25 | const lockCollection = await getLockCollection(db); 26 | if (lockCollection) { 27 | await lockCollection.insertOne({ createdAt: new Date() }); 28 | } 29 | } 30 | 31 | async function clear(db) { 32 | const lockCollection = await getLockCollection(db); 33 | if (lockCollection) { 34 | await lockCollection.deleteMany({}); 35 | } 36 | } 37 | 38 | module.exports = { 39 | exist, 40 | activate, 41 | clear, 42 | } 43 | -------------------------------------------------------------------------------- /lib/utils/module-loader.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | require(requirePath) { 3 | return require(requirePath); // eslint-disable-line 4 | }, 5 | 6 | /* istanbul ignore next */ 7 | import(importPath) { 8 | return import(importPath); // eslint-disable-line 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /migrate-mongo-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/seppevs/migrate-mongo/5a40ba25fc53ba920d97e038dfc7d4eeba3ca3d6/migrate-mongo-logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "migrate-mongo", 3 | "version": "12.1.3", 4 | "description": "A database migration tool for MongoDB in Node", 5 | "main": "lib/migrate-mongo.js", 6 | "bin": { 7 | "migrate-mongo": "bin/migrate-mongo.js" 8 | }, 9 | "scripts": { 10 | "test": "nyc --reporter=html --reporter=text mocha --recursive", 11 | "test-coverage": "nyc --reporter=text-lcov mocha --recursive | coveralls", 12 | "lint": "eslint lib/ test/" 13 | }, 14 | "author": "Sebastian Van Sande", 15 | "license": "MIT", 16 | "keywords": [ 17 | "migrate mongo mongodb migrations database" 18 | ], 19 | "repository": { 20 | "type": "git", 21 | "url": "https://github.com/seppevs/migrate-mongo.git" 22 | }, 23 | "engines": { 24 | "node": ">=8" 25 | }, 26 | "preferGlobal": true, 27 | "dependencies": { 28 | "cli-table3": "^0.6.1", 29 | "commander": "^9.1.0", 30 | "date-fns": "^2.28.0", 31 | "fn-args": "^5.0.0", 32 | "fs-extra": "^10.0.1", 33 | "lodash.filter": "^4.6.0", 34 | "lodash.find": "^4.6.0", 35 | "lodash.get": "^4.4.2", 36 | "lodash.isempty": "^4.4.0", 37 | "lodash.last": "^3.0.0", 38 | "lodash.values": "^4.3.0", 39 | "p-each-series": "^2.2.0" 40 | }, 41 | "peerDependencies": { 42 | "mongodb": "^4.4.1 || ^5.0.0 || ^6.0.0 || ^7.0.0" 43 | }, 44 | "devDependencies": { 45 | "chai": "^4.3.6", 46 | "coveralls": "^3.1.1", 47 | "eslint": "^9.15.0", 48 | "eslint-config-prettier": "^8.5.0", 49 | "eslint-plugin-mocha": "^10.5.0", 50 | "mocha": "^9.2.2", 51 | "nyc": "^15.1.0", 52 | "proxyquire": "^2.1.3", 53 | "sinon": "^13.0.1" 54 | }, 55 | "eslintConfig": { 56 | "extends": [ 57 | "prettier" 58 | ], 59 | "parserOptions": { 60 | "ecmaVersion": 2018 61 | } 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /samples/commonjs/migrate-mongo-config.js: -------------------------------------------------------------------------------- 1 | // In this file you can configure migrate-mongo 2 | 3 | const config = { 4 | mongodb: { 5 | // TODO Change (or review) the url to your MongoDB: 6 | url: "mongodb://localhost:27017", 7 | 8 | // TODO Change this to your database name: 9 | databaseName: "YOURDATABASENAME", 10 | 11 | options: { 12 | useNewUrlParser: true, // removes a deprecation warning when connecting 13 | useUnifiedTopology: true, // removes a deprecating warning when connecting 14 | // connectTimeoutMS: 3600000, // increase connection timeout to 1 hour 15 | // socketTimeoutMS: 3600000, // increase socket timeout to 1 hour 16 | } 17 | }, 18 | 19 | // The migrations dir, can be an relative or absolute path. Only edit this when really necessary. 20 | migrationsDir: "migrations", 21 | 22 | // The mongodb collection where the applied changes are stored. Only edit this when really necessary. 23 | changelogCollectionName: "changelog", 24 | 25 | // The mongodb collection where the lock will be created. 26 | lockCollectionName: "changelog_lock", 27 | 28 | // The value in seconds for the TTL index that will be used for the lock. Value of 0 will disable the feature. 29 | lockTtl: 0, 30 | 31 | // The file extension to create migrations and search for in migration dir 32 | migrationFileExtension: ".js", 33 | 34 | // Enable the algorithm to create a checksum of the file contents and use that in the comparison to determine 35 | // if the file should be run. Requires that scripts are coded to be run multiple times. 36 | useFileHash: false, 37 | 38 | // Don't change this, unless you know what you're doing 39 | moduleSystem: 'commonjs', 40 | }; 41 | 42 | module.exports = config; 43 | -------------------------------------------------------------------------------- /samples/commonjs/migration.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | /** 3 | * @param db {import('mongodb').Db} 4 | * @param client {import('mongodb').MongoClient} 5 | * @returns {Promise} 6 | */ 7 | async up(db, client) { 8 | // TODO write your migration here. 9 | // See https://github.com/seppevs/migrate-mongo/#creating-a-new-migration-script 10 | // Example: 11 | // await db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: true}}); 12 | }, 13 | 14 | /** 15 | * @param db {import('mongodb').Db} 16 | * @param client {import('mongodb').MongoClient} 17 | * @returns {Promise} 18 | */ 19 | async down(db, client) { 20 | // TODO write the statements to rollback your migration (if possible) 21 | // Example: 22 | // await db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: false}}); 23 | } 24 | }; 25 | -------------------------------------------------------------------------------- /samples/esm/migrate-mongo-config.js: -------------------------------------------------------------------------------- 1 | // In this file you can configure migrate-mongo 2 | 3 | const config = { 4 | mongodb: { 5 | // TODO Change (or review) the url to your MongoDB: 6 | url: "mongodb://localhost:27017", 7 | 8 | // TODO Change this to your database name: 9 | databaseName: "YOURDATABASENAME", 10 | 11 | options: { 12 | useNewUrlParser: true, // removes a deprecation warning when connecting 13 | useUnifiedTopology: true, // removes a deprecating warning when connecting 14 | // connectTimeoutMS: 3600000, // increase connection timeout to 1 hour 15 | // socketTimeoutMS: 3600000, // increase socket timeout to 1 hour 16 | } 17 | }, 18 | 19 | // The migrations dir, can be an relative or absolute path. Only edit this when really necessary. 20 | migrationsDir: "migrations", 21 | 22 | // The mongodb collection where the applied changes are stored. Only edit this when really necessary. 23 | changelogCollectionName: "changelog", 24 | 25 | // The mongodb collection where the lock will be created. 26 | lockCollectionName: "changelog_lock", 27 | 28 | // The value in seconds for the TTL index that will be used for the lock. Value of 0 will disable the feature. 29 | lockTtl: 0, 30 | 31 | // The file extension to create migrations and search for in migration dir 32 | migrationFileExtension: ".js", 33 | 34 | // Enable the algorithm to create a checksum of the file contents and use that in the comparison to determin 35 | // if the file should be run. Requires that scripts are coded to be run multiple times. 36 | useFileHash: false, 37 | 38 | // Don't change this, unless you know what you're doing 39 | moduleSystem: 'esm', 40 | }; 41 | 42 | export default config; -------------------------------------------------------------------------------- /samples/esm/migration.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @param db {import('mongodb').Db} 3 | * @param client {import('mongodb').MongoClient} 4 | * @returns {Promise} 5 | */ 6 | export const up = async (db, client) => { 7 | // TODO write your migration here. 8 | // See https://github.com/seppevs/migrate-mongo/#creating-a-new-migration-script 9 | // Example: 10 | // await db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: true}}); 11 | }; 12 | 13 | /** 14 | * @param db {import('mongodb').Db} 15 | * @param client {import('mongodb').MongoClient} 16 | * @returns {Promise} 17 | */ 18 | export const down = async (db, client) => { 19 | // TODO write the statements to rollback your migration (if possible) 20 | // Example: 21 | // await db.collection('albums').updateOne({artist: 'The Beatles'}, {$set: {blacklisted: false}}); 22 | }; 23 | -------------------------------------------------------------------------------- /test/actions/create.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const sinon = require("sinon"); 3 | const path = require("path"); 4 | 5 | const proxyquire = require("proxyquire"); 6 | 7 | describe("create", () => { 8 | let create; 9 | let migrationsDir; 10 | let config; 11 | let fs; 12 | 13 | function mockMigrationsDir() { 14 | return { 15 | shouldExist: sinon.stub().returns(Promise.resolve()), 16 | resolveMigrationFileExtension: sinon.stub().returns('.js'), 17 | doesSampleMigrationExist: sinon.stub().returns(Promise.resolve(false)) 18 | }; 19 | } 20 | 21 | function mockConfig() { 22 | return { 23 | shouldExist: sinon.stub().returns(Promise.resolve()), 24 | read: sinon.stub().returns(Promise.resolve({ 25 | moduleSystem: 'commonjs', 26 | })) 27 | }; 28 | } 29 | 30 | function mockFs() { 31 | return { 32 | copy: sinon.stub().returns(Promise.resolve()) 33 | }; 34 | } 35 | 36 | beforeEach(() => { 37 | migrationsDir = mockMigrationsDir(); 38 | config = mockConfig(); 39 | fs = mockFs(); 40 | create = proxyquire("../../lib/actions/create", { 41 | "../env/migrationsDir": migrationsDir, 42 | "../env/config": config, 43 | "fs-extra": fs 44 | }); 45 | }); 46 | 47 | it("should yield an error when called without a description", async () => { 48 | try { 49 | await create(null); 50 | expect.fail("Error was not thrown"); 51 | } catch (err) { 52 | expect(err.message).to.equal("Missing parameter: description"); 53 | } 54 | }); 55 | 56 | it("should check that the migrations directory exists", async () => { 57 | await create("my_description"); 58 | expect(migrationsDir.shouldExist.called).to.equal(true); 59 | }); 60 | 61 | it("should yield an error when the migrations directory does not exist", async () => { 62 | migrationsDir.shouldExist.returns( 63 | Promise.reject(new Error("migrations directory does not exist")) 64 | ); 65 | try { 66 | await create("my_description"); 67 | expect.fail("Error was not thrown"); 68 | } catch (err) { 69 | expect(err.message).to.equal("migrations directory does not exist"); 70 | } 71 | }); 72 | 73 | it("should not be necessary to have an config present", async () => { 74 | await create("my_description"); 75 | expect(config.shouldExist.called).to.equal(false); 76 | }); 77 | 78 | it("should create a new migration file and yield the filename", async () => { 79 | const clock = sinon.useFakeTimers( 80 | new Date("2016-06-09T08:07:00.077Z").getTime() 81 | ); 82 | const filename = await create("my_description"); 83 | expect(fs.copy.called).to.equal(true); 84 | expect(fs.copy.getCall(0).args[0]).to.equal( 85 | path.join(__dirname, "../../samples/commonjs/migration.js") 86 | ); 87 | expect(fs.copy.getCall(0).args[1]).to.equal( 88 | path.join(process.cwd(), "migrations", "20160609080700-my_description.js") 89 | ); 90 | expect(filename).to.equal("20160609080700-my_description.js"); 91 | clock.restore(); 92 | }); 93 | 94 | it("should create a new migration file and yield the filename with custom extension", async () => { 95 | const clock = sinon.useFakeTimers( 96 | new Date("2016-06-09T08:07:00.077Z").getTime() 97 | ); 98 | migrationsDir.resolveMigrationFileExtension.returns('.ts'); 99 | const filename = await create("my_description"); 100 | expect(fs.copy.called).to.equal(true); 101 | expect(fs.copy.getCall(0).args[0]).to.equal( 102 | path.join(__dirname, "../../samples/commonjs/migration.js") 103 | ); 104 | expect(fs.copy.getCall(0).args[1]).to.equal( 105 | path.join(process.cwd(), "migrations", "20160609080700-my_description.ts") 106 | ); 107 | expect(filename).to.equal("20160609080700-my_description.ts"); 108 | clock.restore(); 109 | }); 110 | 111 | it("should replace spaces in the description with underscores", async () => { 112 | const clock = sinon.useFakeTimers( 113 | new Date("2016-06-09T08:07:00.077Z").getTime() 114 | ); 115 | await create("this description contains spaces"); 116 | expect(fs.copy.called).to.equal(true); 117 | expect(fs.copy.getCall(0).args[0]).to.equal( 118 | path.join(__dirname, "../../samples/commonjs/migration.js") 119 | ); 120 | expect(fs.copy.getCall(0).args[1]).to.equal( 121 | path.join( 122 | process.cwd(), 123 | "migrations", 124 | "20160609080700-this_description_contains_spaces.js" 125 | ) 126 | ); 127 | clock.restore(); 128 | }); 129 | 130 | it("should yield errors that occurred when copying the file", async () => { 131 | fs.copy.returns(Promise.reject(new Error("Copy failed"))); 132 | try { 133 | await create("my_description"); 134 | expect.fail("Error was not thrown"); 135 | } catch (err) { 136 | expect(err.message).to.equal("Copy failed"); 137 | } 138 | }); 139 | 140 | it("should use the sample migration file if it exists", async () => { 141 | const clock = sinon.useFakeTimers( 142 | new Date("2016-06-09T08:07:00.077Z").getTime() 143 | ); 144 | migrationsDir.doesSampleMigrationExist.returns(true); 145 | const filename = await create("my_description"); 146 | expect(migrationsDir.doesSampleMigrationExist.called).to.equal(true); 147 | expect(fs.copy.called).to.equal(true); 148 | expect(fs.copy.getCall(0).args[0]).to.equal( 149 | path.join(process.cwd(), "migrations", "sample-migration.js") 150 | ); 151 | expect(fs.copy.getCall(0).args[1]).to.equal( 152 | path.join(process.cwd(), "migrations", "20160609080700-my_description.js") 153 | ); 154 | expect(filename).to.equal("20160609080700-my_description.js"); 155 | clock.restore(); 156 | }); 157 | }); 158 | -------------------------------------------------------------------------------- /test/actions/down.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const sinon = require("sinon"); 3 | 4 | const proxyquire = require("proxyquire"); 5 | 6 | describe("down", () => { 7 | let down; 8 | let status; 9 | let config; 10 | let lock; 11 | let migrationsDir; 12 | let db; 13 | let client; 14 | let migration; 15 | let changelogCollection; 16 | let changelogLockCollection; 17 | 18 | function mockStatus() { 19 | return sinon.stub().returns( 20 | Promise.resolve([ 21 | { 22 | fileName: "20160609113224-first_migration.js", 23 | appliedAt: new Date(), 24 | }, 25 | { 26 | fileName: "20160609113224-second_migration.js", 27 | appliedAt: new Date(), 28 | migrationBlock: 1 29 | }, 30 | { 31 | fileName: "20160609113225-last_migration.js", 32 | appliedAt: new Date(), 33 | migrationBlock: 1 34 | } 35 | ]) 36 | ); 37 | } 38 | 39 | function mockConfig() { 40 | return { 41 | shouldExist: sinon.stub().returns(Promise.resolve()), 42 | read: sinon.stub().returns({ 43 | changelogCollectionName: "changelog", 44 | lockCollectionName: "changelog_lock", 45 | lockTtl: 10 46 | }) 47 | }; 48 | } 49 | 50 | function mockMigrationsDir() { 51 | return { 52 | loadMigration: sinon.stub().returns(Promise.resolve(migration)) 53 | }; 54 | } 55 | 56 | function mockDb() { 57 | const mock = {}; 58 | mock.collection = sinon.stub(); 59 | mock.collection.withArgs("changelog").returns(changelogCollection); 60 | mock.collection.withArgs("changelog_lock").returns(changelogLockCollection); 61 | return mock; 62 | } 63 | 64 | function mockClient() { 65 | return { the: 'client' }; 66 | } 67 | 68 | function mockMigration() { 69 | const theMigration = { 70 | down: sinon.stub() 71 | }; 72 | theMigration.down.returns(Promise.resolve()); 73 | return theMigration; 74 | } 75 | 76 | function mockChangelogCollection() { 77 | return { 78 | deleteOne: sinon.stub().returns(Promise.resolve()) 79 | }; 80 | } 81 | 82 | function mockChangelogLockCollection() { 83 | const findStub = { 84 | toArray: () => { 85 | return []; 86 | } 87 | } 88 | 89 | return { 90 | insertOne: sinon.stub().returns(Promise.resolve()), 91 | createIndex: sinon.stub().returns(Promise.resolve()), 92 | find: sinon.stub().returns(findStub), 93 | deleteMany: sinon.stub().returns(Promise.resolve()), 94 | } 95 | } 96 | 97 | function loadDownWithInjectedMocks() { 98 | return proxyquire("../../lib/actions/down", { 99 | "./status": status, 100 | "../env/config": config, 101 | "../env/migrationsDir": migrationsDir, 102 | "../utils/lock": lock, 103 | }); 104 | } 105 | 106 | function loadLockWithInjectedMocks() { 107 | return proxyquire("../../lib/utils/lock", { 108 | "../env/config": config 109 | }); 110 | } 111 | 112 | beforeEach(() => { 113 | migration = mockMigration(); 114 | changelogCollection = mockChangelogCollection(); 115 | changelogLockCollection = mockChangelogLockCollection(); 116 | 117 | status = mockStatus(); 118 | config = mockConfig(); 119 | migrationsDir = mockMigrationsDir(); 120 | db = mockDb(); 121 | client = mockClient(); 122 | 123 | lock = loadLockWithInjectedMocks(); 124 | down = loadDownWithInjectedMocks(); 125 | }); 126 | 127 | it("should fetch the status", async () => { 128 | await down(db); 129 | expect(status.called).to.equal(true); 130 | }); 131 | 132 | it("should yield empty list when nothing to downgrade", async () => { 133 | status.returns( 134 | Promise.resolve([ 135 | { fileName: "20160609113224-some_migration.js", appliedAt: "PENDING" } 136 | ]) 137 | ); 138 | const migrated = await down(db); 139 | expect(migrated).to.deep.equal([]); 140 | }); 141 | 142 | it("should load the last applied migration", async () => { 143 | await down(db); 144 | expect(migrationsDir.loadMigration.getCall(0).args[0]).to.equal( 145 | "20160609113225-last_migration.js" 146 | ); 147 | }); 148 | 149 | it("should downgrade the last applied migration", async () => { 150 | await down(db); 151 | expect(migration.down.called).to.equal(true); 152 | }); 153 | 154 | it("should be able to downgrade callback based migration that has both the `db` and `client` arguments", async () => { 155 | migration = { 156 | down(theDb, theClient, callback) { 157 | return callback(); 158 | } 159 | }; 160 | migrationsDir = mockMigrationsDir(); 161 | down = loadDownWithInjectedMocks(); 162 | await down(db, client); 163 | }); 164 | 165 | it("should be able to downgrade callback based migration that has only the `db` argument", async () => { 166 | migration = { 167 | down(theDb, callback) { 168 | return callback(); 169 | } 170 | }; 171 | migrationsDir = mockMigrationsDir(); 172 | down = loadDownWithInjectedMocks(); 173 | await down(db); 174 | }); 175 | 176 | /* eslint no-unused-vars: "off" */ 177 | it("should allow downgrade to return promise", async () => { 178 | migrationsDir = mockMigrationsDir(); 179 | down = loadDownWithInjectedMocks(); 180 | await down(db); 181 | expect(migration.down.called).to.equal(true); 182 | }); 183 | 184 | it("should yield an error when an error occurred during the downgrade", async () => { 185 | migration.down.returns(Promise.reject(new Error("Invalid syntax"))); 186 | try { 187 | await down(db); 188 | expect.fail("Error was not thrown"); 189 | } catch (err) { 190 | expect(err.message).to.equal( 191 | "Could not migrate down 20160609113225-last_migration.js: Invalid syntax" 192 | ); 193 | } 194 | }); 195 | 196 | it("should remove the entry of the downgraded migration from the changelog collection", async () => { 197 | await down(db); 198 | expect(changelogCollection.deleteOne.called).to.equal(true); 199 | expect(changelogCollection.deleteOne.callCount).to.equal(1); 200 | }); 201 | 202 | it("should yield errors that occurred when deleting from the changelog collection", async () => { 203 | changelogCollection.deleteOne.returns( 204 | Promise.reject(new Error("Could not delete")) 205 | ); 206 | try { 207 | await down(db); 208 | } catch (err) { 209 | expect(err.message).to.equal( 210 | "Could not update changelog: Could not delete" 211 | ); 212 | } 213 | }); 214 | 215 | it("should yield a list of downgraded items", async () => { 216 | const items = await down(db); 217 | expect(items).to.deep.equal(["20160609113225-last_migration.js"]); 218 | }); 219 | 220 | it("should rollback last migrations scripts of a same migration block", async () => { 221 | global.options = { block: true }; 222 | const items = await down(db); 223 | expect(items).to.deep.equal(["20160609113225-last_migration.js", "20160609113224-second_migration.js"]); 224 | }); 225 | 226 | it("should lock if feature is enabled", async() => { 227 | await down(db); 228 | expect(changelogLockCollection.createIndex.called).to.equal(true); 229 | expect(changelogLockCollection.find.called).to.equal(true); 230 | expect(changelogLockCollection.insertOne.called).to.equal(true); 231 | expect(changelogLockCollection.deleteMany.called).to.equal(true); 232 | }); 233 | 234 | it("should ignore lock if feature is disabled", async() => { 235 | config.read = sinon.stub().returns({ 236 | changelogCollectionName: "changelog", 237 | lockCollectionName: "changelog_lock", 238 | lockTtl: 0 239 | }); 240 | const findStub = { 241 | toArray: () => { 242 | return [{ createdAt: new Date() }]; 243 | } 244 | } 245 | changelogLockCollection.find.returns(findStub); 246 | 247 | await down(db); 248 | expect(changelogLockCollection.createIndex.called).to.equal(false); 249 | expect(changelogLockCollection.find.called).to.equal(false); 250 | }); 251 | 252 | it("should yield an error when unable to create a lock", async() => { 253 | changelogLockCollection.insertOne.returns(Promise.reject(new Error("Kernel panic"))); 254 | 255 | try { 256 | await down(db); 257 | expect.fail("Error was not thrown"); 258 | } catch (err) { 259 | expect(err.message).to.deep.equal( 260 | "Could not create a lock: Kernel panic" 261 | ); 262 | } 263 | }); 264 | 265 | it("should yield an error when changelog is locked", async() => { 266 | const findStub = { 267 | toArray: () => { 268 | return [{ createdAt: new Date() }]; 269 | } 270 | } 271 | changelogLockCollection.find.returns(findStub); 272 | 273 | try { 274 | await down(db); 275 | expect.fail("Error was not thrown"); 276 | } catch (err) { 277 | expect(err.message).to.deep.equal( 278 | "Could not migrate down, a lock is in place." 279 | ); 280 | } 281 | }); 282 | }); 283 | -------------------------------------------------------------------------------- /test/actions/init.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const sinon = require("sinon"); 3 | const path = require("path"); 4 | const proxyquire = require("proxyquire"); 5 | 6 | describe("init", () => { 7 | let init; 8 | let migrationsDir; 9 | let config; 10 | let fs; 11 | 12 | function mockMigrationsDir() { 13 | return { 14 | shouldNotExist: sinon.stub().returns(Promise.resolve()) 15 | }; 16 | } 17 | 18 | function mockConfig() { 19 | return { 20 | shouldNotExist: sinon.stub().returns(Promise.resolve()) 21 | }; 22 | } 23 | 24 | function mockFs() { 25 | return { 26 | copy: sinon.stub().returns(Promise.resolve()), 27 | mkdirs: sinon.stub().returns(Promise.resolve()) 28 | }; 29 | } 30 | 31 | beforeEach(() => { 32 | global.options = { module: 'commonjs' }; 33 | migrationsDir = mockMigrationsDir(); 34 | config = mockConfig(); 35 | fs = mockFs(); 36 | init = proxyquire("../../lib/actions/init", { 37 | "../env/migrationsDir": migrationsDir, 38 | "../env/config": config, 39 | "fs-extra": fs 40 | }); 41 | }); 42 | 43 | it("should check if the migrations directory already exists", async () => { 44 | await init(); 45 | expect(migrationsDir.shouldNotExist.called).to.equal(true); 46 | }); 47 | 48 | it("should not continue and yield an error if the migrations directory already exists", async () => { 49 | migrationsDir.shouldNotExist.returns( 50 | Promise.reject(new Error("Dir exists")) 51 | ); 52 | try { 53 | await init(); 54 | } catch (err) { 55 | expect(err.message).to.equal("Dir exists"); 56 | expect(fs.copy.called).to.equal(false); 57 | expect(fs.mkdirs.called).to.equal(false); 58 | } 59 | }); 60 | 61 | it("should check if the config file already exists", async () => { 62 | await init(); 63 | expect(config.shouldNotExist.called).to.equal(true); 64 | }); 65 | 66 | it("should not continue and yield an error if the config file already exists", async () => { 67 | config.shouldNotExist.returns( 68 | Promise.resolve(new Error("Config exists")) 69 | ); 70 | try { 71 | await init(); 72 | } catch (err) { 73 | expect(err.message).to.equal("Config exists"); 74 | expect(fs.copy.called).to.equal(false); 75 | expect(fs.mkdirs.called).to.equal(false); 76 | } 77 | }); 78 | 79 | it("should copy the sample config file to the current working directory", async () => { 80 | await init(); 81 | expect(fs.copy.called).to.equal(true); 82 | expect(fs.copy.callCount).to.equal(1); 83 | 84 | const source = fs.copy.getCall(0).args[0]; 85 | expect(source).to.equal( 86 | path.join(__dirname, "../../samples/commonjs/migrate-mongo-config.js") 87 | ); 88 | 89 | const destination = fs.copy.getCall(0).args[1]; 90 | expect(destination).to.equal( 91 | path.join(process.cwd(), "migrate-mongo-config.js") 92 | ); 93 | }); 94 | 95 | it("should copy the sample config file to the current working directory", async () => { 96 | global.options.module = 'esm'; 97 | await init(); 98 | expect(fs.copy.called).to.equal(true); 99 | expect(fs.copy.callCount).to.equal(1); 100 | 101 | const source = fs.copy.getCall(0).args[0]; 102 | expect(source).to.equal( 103 | path.join(__dirname, "../../samples/esm/migrate-mongo-config.js") 104 | ); 105 | 106 | const destination = fs.copy.getCall(0).args[1]; 107 | expect(destination).to.equal( 108 | path.join(process.cwd(), "migrate-mongo-config.js") 109 | ); 110 | }); 111 | 112 | 113 | it("should yield errors that occurred when copying the sample config", async () => { 114 | fs.copy.returns(Promise.reject(new Error("No space left on device"))); 115 | try { 116 | await init(); 117 | expect.fail("Error was not thrown"); 118 | } catch (err) { 119 | expect(err.message).to.equal("No space left on device"); 120 | } 121 | }); 122 | 123 | it("should create a migrations directory in the current working directory", async () => { 124 | await init(); 125 | 126 | expect(fs.mkdirs.called).to.equal(true); 127 | expect(fs.mkdirs.callCount).to.equal(1); 128 | expect(fs.mkdirs.getCall(0).args[0]).to.deep.equal( 129 | path.join(process.cwd(), "migrations") 130 | ); 131 | }); 132 | 133 | it("should yield errors that occurred when creating the migrations directory", async () => { 134 | fs.mkdirs.returns(Promise.reject(new Error("I cannot do that"))); 135 | try { 136 | await init(); 137 | } catch (err) { 138 | expect(err.message).to.equal("I cannot do that"); 139 | } 140 | }); 141 | }); 142 | -------------------------------------------------------------------------------- /test/actions/status.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const sinon = require("sinon"); 3 | 4 | const proxyquire = require("proxyquire"); 5 | 6 | describe("status", () => { 7 | let status; 8 | let migrationsDir; 9 | let config; 10 | let fs; 11 | let db; 12 | let changelogCollection; 13 | 14 | function mockMigrationsDir() { 15 | return { 16 | shouldExist: sinon.stub().returns(Promise.resolve()), 17 | getFileNames: sinon 18 | .stub() 19 | .returns( 20 | Promise.resolve([ 21 | "20160509113224-first_migration.js", 22 | "20160512091701-second_migration.js", 23 | "20160513155321-third_migration.js" 24 | ]) 25 | ), 26 | loadFileHash: sinon 27 | .stub() 28 | .callsFake((fileName) => { 29 | switch (fileName) { 30 | case "20160509113224-first_migration.js": 31 | return Promise.resolve("0f295f21f63c66dc78d8dc091ce3c8bab8c56d8b74fb35a0c99f6d9953e37d1a"); 32 | case "20160512091701-second_migration.js": 33 | return Promise.resolve("18b4d9c95a8678ae3a6dd3ae5b8961737a6c3dd65e3e655a5f5718d97a0bff70"); 34 | case "20160513155321-third_migration.js": 35 | return Promise.resolve("1f9eb3b5eb70b2fb5b83fa0c660d859082f0bb615e835d29943d26fb0d352022"); 36 | default: 37 | return Promise.resolve(); 38 | } 39 | }), 40 | }; 41 | } 42 | 43 | function mockConfig() { 44 | return { 45 | shouldExist: sinon.stub().returns(Promise.resolve()), 46 | read: sinon.stub().returns({ 47 | changelogCollectionName: "changelog" 48 | }) 49 | }; 50 | } 51 | 52 | function mockFs() { 53 | return { 54 | copy: sinon.stub().returns(Promise.resolve()), 55 | readFile: sinon.stub().returns(Promise.resolve("some file content")) 56 | }; 57 | } 58 | 59 | function mockDb() { 60 | const mock = {}; 61 | mock.collection = sinon.stub(); 62 | mock.collection.withArgs("changelog").returns(changelogCollection); 63 | return mock; 64 | } 65 | 66 | function mockChangelogCollection() { 67 | return { 68 | deleteOne: sinon.stub().returns(Promise.resolve()), 69 | find: sinon.stub().returns({ 70 | toArray: sinon.stub().returns( 71 | Promise.resolve([ 72 | { 73 | fileName: "20160509113224-first_migration.js", 74 | appliedAt: new Date("2016-06-03T20:10:12.123Z") 75 | }, 76 | { 77 | fileName: "20160512091701-second_migration.js", 78 | appliedAt: new Date("2016-06-09T20:10:12.123Z") 79 | } 80 | ]) 81 | ) 82 | }) 83 | }; 84 | } 85 | 86 | function enabledFileHash(configContent) { 87 | configContent.read.returns({ 88 | changelogCollectionName: "changelog", 89 | useFileHash: true 90 | }) 91 | } 92 | 93 | function addHashToChangeLog(changelog) { 94 | changelog.find.returns({ 95 | toArray: sinon.stub().returns( 96 | Promise.resolve([ 97 | { 98 | fileName: "20160509113224-first_migration.js", 99 | fileHash: "0f295f21f63c66dc78d8dc091ce3c8bab8c56d8b74fb35a0c99f6d9953e37d1a", 100 | appliedAt: new Date("2016-06-03T20:10:12.123Z") 101 | }, 102 | { 103 | fileName: "20160512091701-second_migration.js", 104 | fileHash: "18b4d9c95a8678ae3a6dd3ae5b8961737a6c3dd65e3e655a5f5718d97a0bff70", 105 | appliedAt: new Date("2016-06-09T20:10:12.123Z") 106 | } 107 | ]) 108 | ) 109 | }) 110 | } 111 | 112 | beforeEach(() => { 113 | changelogCollection = mockChangelogCollection(); 114 | 115 | migrationsDir = mockMigrationsDir(); 116 | config = mockConfig(); 117 | fs = mockFs(); 118 | db = mockDb(); 119 | status = proxyquire("../../lib/actions/status", { 120 | "../env/migrationsDir": migrationsDir, 121 | "../env/config": config, 122 | "fs-extra": fs 123 | }); 124 | }); 125 | 126 | it("should check that the migrations directory exists", async () => { 127 | await status(db); 128 | expect(migrationsDir.shouldExist.called).to.equal(true); 129 | }); 130 | 131 | it("should yield an error when the migrations directory does not exist", async () => { 132 | migrationsDir.shouldExist.returns( 133 | Promise.reject(new Error("migrations directory does not exist")) 134 | ); 135 | try { 136 | await status(db); 137 | expect.fail("Error was not thrown"); 138 | } catch (err) { 139 | expect(err.message).to.equal("migrations directory does not exist"); 140 | } 141 | }); 142 | 143 | it("should check that the config file exists", async () => { 144 | await status(db); 145 | expect(config.shouldExist.called).to.equal(true); 146 | }); 147 | 148 | it("should yield an error when config file does not exist", async () => { 149 | config.shouldExist.returns( 150 | Promise.reject(new Error("config file does not exist")) 151 | ); 152 | try { 153 | await status(db); 154 | expect.fail("Error was not thrown"); 155 | } catch (err) { 156 | expect(err.message).to.equal("config file does not exist"); 157 | } 158 | }); 159 | 160 | it("should get the list of files in the migrations directory", async () => { 161 | await status(db); 162 | expect(migrationsDir.getFileNames.called).to.equal(true); 163 | }); 164 | 165 | it("should yield errors that occurred when getting the list of files in the migrations directory", async () => { 166 | migrationsDir.getFileNames.returns( 167 | Promise.reject(new Error("File system unavailable")) 168 | ); 169 | try { 170 | await status(db); 171 | expect.fail("Error was not thrown"); 172 | } catch (err) { 173 | expect(err.message).to.equal("File system unavailable"); 174 | } 175 | }); 176 | 177 | it("should fetch the content of the changelog collection", async () => { 178 | await status(db); 179 | expect(changelogCollection.find.called).to.equal(true); 180 | expect(changelogCollection.find({}).toArray.called).to.equal(true); 181 | }); 182 | 183 | it("should yield errors that occurred when fetching the changelog collection", async () => { 184 | changelogCollection 185 | .find({}) 186 | .toArray.returns( 187 | Promise.reject(new Error("Cannot read from the database")) 188 | ); 189 | try { 190 | await status(db); 191 | expect.fail("Error was not thrown"); 192 | } catch (err) { 193 | expect(err.message).to.equal("Cannot read from the database"); 194 | } 195 | }); 196 | 197 | it("should yield an array that indicates the status of the migrations in the directory", async () => { 198 | const statusItems = await status(db); 199 | expect(statusItems).to.deep.equal([ 200 | { 201 | appliedAt: "2016-06-03T20:10:12.123Z", 202 | fileName: "20160509113224-first_migration.js", 203 | migrationBlock: undefined 204 | }, 205 | { 206 | appliedAt: "2016-06-09T20:10:12.123Z", 207 | fileName: "20160512091701-second_migration.js", 208 | migrationBlock: undefined 209 | }, 210 | { 211 | appliedAt: "PENDING", 212 | fileName: "20160513155321-third_migration.js", 213 | migrationBlock: undefined 214 | } 215 | ]); 216 | }); 217 | 218 | it("it should mark all scripts as pending when enabling for the first time", async () => { 219 | enabledFileHash(config); 220 | const statusItems = await status(db); 221 | expect(statusItems).to.deep.equal([ 222 | { 223 | appliedAt: "PENDING", 224 | fileName: "20160509113224-first_migration.js", 225 | fileHash: "0f295f21f63c66dc78d8dc091ce3c8bab8c56d8b74fb35a0c99f6d9953e37d1a", 226 | migrationBlock: undefined 227 | }, 228 | { 229 | appliedAt: "PENDING", 230 | fileName: "20160512091701-second_migration.js", 231 | fileHash: "18b4d9c95a8678ae3a6dd3ae5b8961737a6c3dd65e3e655a5f5718d97a0bff70", 232 | migrationBlock: undefined 233 | }, 234 | { 235 | appliedAt: "PENDING", 236 | fileName: "20160513155321-third_migration.js", 237 | fileHash: "1f9eb3b5eb70b2fb5b83fa0c660d859082f0bb615e835d29943d26fb0d352022", 238 | migrationBlock: undefined 239 | } 240 | ]); 241 | }); 242 | 243 | it("it should mark new scripts as pending with a file hash", async () => { 244 | enabledFileHash(config); 245 | addHashToChangeLog(changelogCollection); 246 | const statusItems = await status(db); 247 | expect(statusItems).to.deep.equal([ 248 | { 249 | appliedAt: "2016-06-03T20:10:12.123Z", 250 | fileName: "20160509113224-first_migration.js", 251 | fileHash: "0f295f21f63c66dc78d8dc091ce3c8bab8c56d8b74fb35a0c99f6d9953e37d1a", 252 | migrationBlock: undefined 253 | }, 254 | { 255 | appliedAt: "2016-06-09T20:10:12.123Z", 256 | fileName: "20160512091701-second_migration.js", 257 | fileHash: "18b4d9c95a8678ae3a6dd3ae5b8961737a6c3dd65e3e655a5f5718d97a0bff70", 258 | migrationBlock: undefined 259 | }, 260 | { 261 | appliedAt: "PENDING", 262 | fileName: "20160513155321-third_migration.js", 263 | fileHash: "1f9eb3b5eb70b2fb5b83fa0c660d859082f0bb615e835d29943d26fb0d352022", 264 | migrationBlock: undefined 265 | } 266 | ]); 267 | }); 268 | 269 | it("it should mark changed scripts with pending", async () => { 270 | enabledFileHash(config); 271 | addHashToChangeLog(changelogCollection); 272 | migrationsDir.loadFileHash.callsFake((fileName) => { 273 | switch (fileName) { 274 | case "20160509113224-first_migration.js": 275 | return Promise.resolve("0f295f21f63c66dc78d8dc091ce3c8bab8c56d8b74fb35a0c99f6d9953e37d1a"); 276 | case "20160512091701-second_migration.js": 277 | return Promise.resolve("18b4d9c95a8678ae3a6dd3ae5b8961737a6c3dd65e3e655a5f5718d97a0bff71"); 278 | case "20160513155321-third_migration.js": 279 | return Promise.resolve("1f9eb3b5eb70b2fb5b83fa0c660d859082f0bb615e835d29943d26fb0d352022"); 280 | default: 281 | return Promise.resolve(); 282 | } 283 | }) 284 | 285 | const statusItems = await status(db); 286 | expect(statusItems).to.deep.equal([ 287 | { 288 | appliedAt: "2016-06-03T20:10:12.123Z", 289 | fileName: "20160509113224-first_migration.js", 290 | fileHash: "0f295f21f63c66dc78d8dc091ce3c8bab8c56d8b74fb35a0c99f6d9953e37d1a", 291 | migrationBlock: undefined 292 | }, 293 | { 294 | appliedAt: "PENDING", 295 | fileName: "20160512091701-second_migration.js", 296 | fileHash: "18b4d9c95a8678ae3a6dd3ae5b8961737a6c3dd65e3e655a5f5718d97a0bff71", // this hash is different 297 | migrationBlock: undefined 298 | }, 299 | { 300 | appliedAt: "PENDING", 301 | fileName: "20160513155321-third_migration.js", 302 | fileHash: "1f9eb3b5eb70b2fb5b83fa0c660d859082f0bb615e835d29943d26fb0d352022", 303 | migrationBlock: undefined 304 | } 305 | ]); 306 | }); 307 | }); 308 | -------------------------------------------------------------------------------- /test/actions/up.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const sinon = require("sinon"); 3 | 4 | const proxyquire = require("proxyquire"); 5 | 6 | describe("up", () => { 7 | let up; 8 | let status; 9 | let config; 10 | let lock; 11 | let migrationsDir; 12 | let db; 13 | let client; 14 | 15 | let firstPendingMigration; 16 | let secondPendingMigration; 17 | let changelogCollection; 18 | let changelogLockCollection; 19 | 20 | function mockStatus() { 21 | return sinon.stub().returns( 22 | Promise.resolve([ 23 | { 24 | fileName: "20160605123224-first_applied_migration.js", 25 | appliedAt: new Date() 26 | }, 27 | { 28 | fileName: "20160606093207-second_applied_migration.js", 29 | appliedAt: new Date() 30 | }, 31 | { 32 | fileName: "20160607173840-first_pending_migration.js", 33 | appliedAt: "PENDING" 34 | }, 35 | { 36 | fileName: "20160608060209-second_pending_migration.js", 37 | appliedAt: "PENDING" 38 | } 39 | ]) 40 | ); 41 | } 42 | 43 | function mockConfig() { 44 | return { 45 | shouldExist: sinon.stub().returns(Promise.resolve()), 46 | read: sinon.stub().returns({ 47 | changelogCollectionName: "changelog", 48 | lockCollectionName: "changelog_lock", 49 | lockTtl: 10 50 | }) 51 | }; 52 | } 53 | 54 | function mockMigrationsDir() { 55 | const mock = {}; 56 | mock.loadMigration = sinon.stub(); 57 | mock.loadMigration 58 | .withArgs("20160607173840-first_pending_migration.js") 59 | .returns(Promise.resolve(firstPendingMigration)); 60 | mock.loadMigration 61 | .withArgs("20160608060209-second_pending_migration.js") 62 | .returns(Promise.resolve(secondPendingMigration)); 63 | return mock; 64 | } 65 | 66 | function mockDb() { 67 | const mock = {}; 68 | mock.collection = sinon.stub(); 69 | mock.collection.withArgs("changelog").returns(changelogCollection); 70 | mock.collection.withArgs("changelog_lock").returns(changelogLockCollection); 71 | return mock; 72 | } 73 | 74 | function mockClient() { 75 | return { the: 'client' }; 76 | } 77 | 78 | function mockMigration() { 79 | const migration = { 80 | up: sinon.stub() 81 | }; 82 | migration.up.returns(Promise.resolve()); 83 | return migration; 84 | } 85 | 86 | function mockChangelogCollection() { 87 | return { 88 | insertOne: sinon.stub().returns(Promise.resolve()) 89 | }; 90 | } 91 | 92 | function mockChangelogLockCollection() { 93 | const findStub = { 94 | toArray: () => { 95 | return []; 96 | } 97 | } 98 | 99 | return { 100 | insertOne: sinon.stub().returns(Promise.resolve()), 101 | createIndex: sinon.stub().returns(Promise.resolve()), 102 | find: sinon.stub().returns(findStub), 103 | deleteMany: sinon.stub().returns(Promise.resolve()), 104 | } 105 | } 106 | 107 | function loadUpWithInjectedMocks() { 108 | return proxyquire("../../lib/actions/up", { 109 | "./status": status, 110 | "../env/config": config, 111 | "../env/migrationsDir": migrationsDir, 112 | "../utils/lock": lock 113 | }); 114 | } 115 | 116 | function loadLockWithInjectedMocks() { 117 | return proxyquire("../../lib/utils/lock", { 118 | "../env/config": config 119 | }); 120 | } 121 | 122 | beforeEach(() => { 123 | firstPendingMigration = mockMigration(); 124 | secondPendingMigration = mockMigration(); 125 | changelogCollection = mockChangelogCollection(); 126 | changelogLockCollection = mockChangelogLockCollection(); 127 | 128 | status = mockStatus(); 129 | config = mockConfig(); 130 | migrationsDir = mockMigrationsDir(); 131 | db = mockDb(); 132 | client = mockClient(); 133 | 134 | lock = loadLockWithInjectedMocks(); 135 | up = loadUpWithInjectedMocks(); 136 | }); 137 | 138 | it("should fetch the status", async () => { 139 | await up(db); 140 | expect(status.called).to.equal(true); 141 | }); 142 | 143 | it("should load all the pending migrations", async () => { 144 | await up(db); 145 | expect(migrationsDir.loadMigration.called).to.equal(true); 146 | expect(migrationsDir.loadMigration.callCount).to.equal(2); 147 | expect(migrationsDir.loadMigration.getCall(0).args[0]).to.equal( 148 | "20160607173840-first_pending_migration.js" 149 | ); 150 | expect(migrationsDir.loadMigration.getCall(1).args[0]).to.equal( 151 | "20160608060209-second_pending_migration.js" 152 | ); 153 | }); 154 | 155 | it("should upgrade all pending migrations in ascending order", async () => { 156 | await up(db); 157 | expect(firstPendingMigration.up.called).to.equal(true); 158 | expect(secondPendingMigration.up.called).to.equal(true); 159 | sinon.assert.callOrder(firstPendingMigration.up, secondPendingMigration.up); 160 | }); 161 | 162 | it("should be able to upgrade callback based migration that has both the `db` and `client` args", async () => { 163 | firstPendingMigration = { 164 | up(theDb, theClient, callback) { 165 | return callback(); 166 | } 167 | }; 168 | migrationsDir = mockMigrationsDir(); 169 | up = loadUpWithInjectedMocks(); 170 | await up(db, client); 171 | }); 172 | 173 | it("should be able to upgrade callback based migration that has only the `db` arg", async () => { 174 | firstPendingMigration = { 175 | up(theDb, callback) { 176 | return callback(); 177 | } 178 | }; 179 | migrationsDir = mockMigrationsDir(); 180 | up = loadUpWithInjectedMocks(); 181 | await up(db, client); 182 | }); 183 | 184 | it("should populate the changelog with info about the upgraded migrations", async () => { 185 | const clock = sinon.useFakeTimers( 186 | new Date("2016-06-09T08:07:00.077Z").getTime() 187 | ); 188 | await up(db); 189 | 190 | expect(changelogCollection.insertOne.called).to.equal(true); 191 | expect(changelogCollection.insertOne.callCount).to.equal(2); 192 | expect(changelogCollection.insertOne.getCall(0).args[0]).to.deep.equal({ 193 | appliedAt: new Date("2016-06-09T08:07:00.077Z"), 194 | fileName: "20160607173840-first_pending_migration.js", 195 | migrationBlock: 1465459620077 196 | }); 197 | clock.restore(); 198 | }); 199 | 200 | it("should populate the changelog with info about the upgraded migrations (using file hash)", async () => { 201 | config.read = sinon.stub().returns({ 202 | changelogCollectionName: "changelog", 203 | lockCollectionName: "changelog_lock", 204 | lockTtl: 0, 205 | useFileHash: true, 206 | }); 207 | const findStub = { 208 | toArray: () => { 209 | return [{ createdAt: new Date() }]; 210 | } 211 | } 212 | changelogLockCollection.find.returns(findStub); 213 | 214 | const clock = sinon.useFakeTimers( 215 | new Date("2016-06-09T08:07:00.077Z").getTime() 216 | ); 217 | await up(db); 218 | 219 | expect(changelogCollection.insertOne.called).to.equal(true); 220 | expect(changelogCollection.insertOne.callCount).to.equal(2); 221 | expect(changelogCollection.insertOne.getCall(0).args[0]).to.deep.equal({ 222 | appliedAt: new Date("2016-06-09T08:07:00.077Z"), 223 | "fileHash": undefined, 224 | fileName: "20160607173840-first_pending_migration.js", 225 | migrationBlock: 1465459620077 226 | }); 227 | clock.restore(); 228 | }); 229 | 230 | it("should yield a list of upgraded migration file names", async () => { 231 | const upgradedFileNames = await up(db); 232 | expect(upgradedFileNames).to.deep.equal([ 233 | "20160607173840-first_pending_migration.js", 234 | "20160608060209-second_pending_migration.js" 235 | ]); 236 | }); 237 | 238 | it("should stop migrating when an error occurred and yield the error", async () => { 239 | secondPendingMigration.up.returns(Promise.reject(new Error("Nope"))); 240 | try { 241 | await up(db); 242 | expect.fail("Error was not thrown"); 243 | } catch (err) { 244 | expect(err.message).to.deep.equal( 245 | "Could not migrate up 20160608060209-second_pending_migration.js: Nope" 246 | ); 247 | } 248 | }); 249 | 250 | it("should yield an error + items already migrated when unable to update the changelog", async () => { 251 | changelogCollection.insertOne 252 | .onSecondCall() 253 | .returns(Promise.reject(new Error("Kernel panic"))); 254 | try { 255 | await up(db); 256 | expect.fail("Error was not thrown"); 257 | } catch (err) { 258 | expect(err.message).to.deep.equal( 259 | "Could not update changelog: Kernel panic" 260 | ); 261 | } 262 | }); 263 | 264 | it("should lock if feature is enabled", async() => { 265 | await up(db); 266 | expect(changelogLockCollection.createIndex.called).to.equal(true); 267 | expect(changelogLockCollection.find.called).to.equal(true); 268 | expect(changelogLockCollection.insertOne.called).to.equal(true); 269 | expect(changelogLockCollection.deleteMany.called).to.equal(true); 270 | }); 271 | 272 | it("should ignore lock if feature is disabled", async() => { 273 | config.read = sinon.stub().returns({ 274 | changelogCollectionName: "changelog", 275 | lockCollectionName: "changelog_lock", 276 | lockTtl: 0 277 | }); 278 | const findStub = { 279 | toArray: () => { 280 | return [{ createdAt: new Date() }]; 281 | } 282 | } 283 | changelogLockCollection.find.returns(findStub); 284 | 285 | await up(db); 286 | expect(changelogLockCollection.createIndex.called).to.equal(false); 287 | expect(changelogLockCollection.find.called).to.equal(false); 288 | expect(changelogLockCollection.insertOne.called).to.equal(false); 289 | expect(changelogLockCollection.deleteMany.called).to.equal(false); 290 | }); 291 | 292 | it("should yield an error when unable to create a lock", async() => { 293 | changelogLockCollection.insertOne.returns(Promise.reject(new Error("Kernel panic"))); 294 | 295 | try { 296 | await up(db); 297 | expect.fail("Error was not thrown"); 298 | } catch (err) { 299 | expect(err.message).to.deep.equal( 300 | "Could not create a lock: Kernel panic" 301 | ); 302 | } 303 | }); 304 | 305 | it("should yield an error when changelog is locked", async() => { 306 | const findStub = { 307 | toArray: () => { 308 | return [{ createdAt: new Date() }]; 309 | } 310 | } 311 | changelogLockCollection.find.returns(findStub); 312 | 313 | try { 314 | await up(db); 315 | expect.fail("Error was not thrown"); 316 | } catch (err) { 317 | expect(err.message).to.deep.equal( 318 | "Could not migrate up, a lock is in place." 319 | ); 320 | } 321 | }); 322 | }); 323 | -------------------------------------------------------------------------------- /test/env/config.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const sinon = require("sinon"); 3 | const proxyquire = require("proxyquire"); 4 | 5 | const path = require("path"); 6 | 7 | describe("config", () => { 8 | let config; // module under test 9 | let fs; // mocked dependencies 10 | let moduleLoader; 11 | 12 | function mockFs() { 13 | return { 14 | stat: sinon.stub() 15 | }; 16 | } 17 | 18 | function mockModuleLoader() { 19 | return { 20 | import: sinon.stub(), 21 | }; 22 | } 23 | 24 | beforeEach(() => { 25 | fs = mockFs(); 26 | moduleLoader = mockModuleLoader(); 27 | config = proxyquire("../../lib/env/config", { 28 | "fs-extra": fs, 29 | "../utils/module-loader": moduleLoader 30 | }); 31 | }); 32 | 33 | describe("shouldExist()", () => { 34 | 35 | it('should not yield an error when the config was set manually', async () => { 36 | fs.stat.rejects(); 37 | config.set({ my: 'config'}) 38 | await config.shouldExist(); 39 | }); 40 | 41 | it("should not yield an error if the config exists", async () => { 42 | fs.stat.returns(Promise.resolve()); 43 | await config.shouldExist(); 44 | }); 45 | 46 | it("should yield an error if the config does not exist", async () => { 47 | const configPath = path.join(process.cwd(), "migrate-mongo-config.js"); 48 | fs.stat.returns(Promise.reject(new Error("It does not exist"))); 49 | try { 50 | await config.shouldExist(); 51 | expect.fail("Error was not thrown"); 52 | } catch (err) { 53 | expect(err.message).to.equal( 54 | `config file does not exist: ${configPath}` 55 | ); 56 | } 57 | }); 58 | }); 59 | 60 | describe("shouldNotExist()", () => { 61 | 62 | it('should not yield an error when the config was set manually', async () => { 63 | fs.stat.rejects(); 64 | config.set({ my: 'config'}) 65 | await config.shouldNotExist(); 66 | }); 67 | 68 | it("should not yield an error if the config does not exist", async () => { 69 | const error = new Error("File does not exist"); 70 | error.code = "ENOENT"; 71 | fs.stat.returns(Promise.reject(error)); 72 | await config.shouldNotExist(); 73 | }); 74 | 75 | it("should yield an error if the config exists", async () => { 76 | const configPath = path.join(process.cwd(), "migrate-mongo-config.js"); 77 | fs.stat.returns(Promise.resolve()); 78 | try { 79 | await config.shouldNotExist(); 80 | expect.fail("Error was not thrown"); 81 | } catch (err) { 82 | expect(err.message).to.equal( 83 | `config file already exists: ${configPath}` 84 | ); 85 | } 86 | }); 87 | }); 88 | 89 | describe("getConfigFilename()", () => { 90 | it("should return the config file name", () => { 91 | expect(config.getConfigFilename()).to.equal( 92 | "migrate-mongo-config.js" 93 | ); 94 | }); 95 | }); 96 | 97 | describe("read()", () => { 98 | 99 | it('should resolve with the custom config content when config content was set manually', async () => { 100 | const expected = { my: 'custom-config'}; 101 | config.set(expected); 102 | const actual = await config.read(); 103 | expect(actual).to.deep.equal(expected); 104 | }); 105 | 106 | it("should attempt to read the config file", async () => { 107 | const configPath = path.join(process.cwd(), "migrate-mongo-config.js"); 108 | try { 109 | await config.read(); 110 | expect.fail("Error was not thrown"); 111 | } catch (err) { 112 | expect(err.message).to.have.string(`Cannot find module '${configPath}'`); 113 | } 114 | }); 115 | 116 | it("should be possible to read a custom, absolute config file path", async () => { 117 | global.options = { file: "/some/absolute/path/to/a-config-file.js" }; 118 | try { 119 | await config.read(); 120 | expect.fail("Error was not thrown"); 121 | } catch (err) { 122 | expect(err.message).to.have.string(`Cannot find module '${global.options.file}'`); 123 | } 124 | }); 125 | 126 | it("should be possible to read a custom, relative config file path", async () => { 127 | global.options = { file: "./a/relative/path/to/a-config-file.js" }; 128 | const configPath = path.join(process.cwd(), global.options.file); 129 | try { 130 | await config.read(); 131 | expect.fail("Error was not thrown"); 132 | } catch (err) { 133 | expect(err.message).to.have.string(`Cannot find module '${configPath}'`); 134 | } 135 | }); 136 | 137 | it("should fall back to using 'import' if Node requires the use of ESM", async () => { 138 | const error = new Error('ESM required'); 139 | error.code = 'ERR_REQUIRE_ESM'; 140 | moduleLoader.require = sinon.stub().throws(error); 141 | moduleLoader.import.returns({}); 142 | await config.read(); 143 | expect(moduleLoader.import.called).to.equal(true); 144 | }); 145 | 146 | it("should handle ESM modules with default export", async () => { 147 | const expectedConfig = { 148 | mongodb: { 149 | url: 'mongodb://localhost:27017', 150 | databaseName: 'test' 151 | } 152 | }; 153 | 154 | moduleLoader.require = sinon.stub().resolves({ 155 | default: expectedConfig 156 | }); 157 | 158 | const actual = await config.read(); 159 | expect(actual).to.deep.equal(expectedConfig); 160 | }); 161 | 162 | it("should handle regular CommonJS modules", async () => { 163 | const expectedConfig = { 164 | mongodb: { 165 | url: 'mongodb://localhost:27017', 166 | databaseName: 'test' 167 | } 168 | }; 169 | 170 | moduleLoader.require = sinon.stub().resolves(expectedConfig); 171 | 172 | const actual = await config.read(); 173 | expect(actual).to.deep.equal(expectedConfig); 174 | }); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /test/env/database.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const sinon = require("sinon"); 3 | const proxyquire = require("proxyquire"); 4 | 5 | describe("database", () => { 6 | let configObj; 7 | let database; 8 | let config; 9 | let mongodb; 10 | let client; 11 | 12 | function createConfigObj() { 13 | return { 14 | mongodb: { 15 | url: "mongodb://someserver:27017", 16 | databaseName: "testDb", 17 | options: { 18 | connectTimeoutMS: 3600000, // 1 hour 19 | socketTimeoutMS: 3600000 // 1 hour 20 | } 21 | } 22 | }; 23 | } 24 | 25 | function mockClient() { 26 | return { 27 | db: sinon.stub().returns({ the: "db" }), 28 | close: "theCloseFnFromMongoClient" 29 | }; 30 | } 31 | 32 | function mockConfig() { 33 | return { 34 | read: sinon.stub().returns(configObj) 35 | }; 36 | } 37 | 38 | function mockMongodb() { 39 | return { 40 | MongoClient: { 41 | connect: sinon.stub().returns(Promise.resolve(client)) 42 | } 43 | }; 44 | } 45 | 46 | beforeEach(() => { 47 | configObj = createConfigObj(); 48 | client = mockClient(); 49 | config = mockConfig(); 50 | mongodb = mockMongodb(); 51 | 52 | database = proxyquire("../../lib/env/database", { 53 | "./config": config, 54 | mongodb 55 | }); 56 | }); 57 | 58 | describe("connect()", () => { 59 | it("should connect MongoClient to the configured mongodb url with the configured options", async () => { 60 | const result = await database.connect(); 61 | expect(mongodb.MongoClient.connect.called).to.equal(true); 62 | expect(mongodb.MongoClient.connect.getCall(0).args[0]).to.equal( 63 | "mongodb://someserver:27017" 64 | ); 65 | 66 | expect(mongodb.MongoClient.connect.getCall(0).args[1]).to.deep.equal({ 67 | connectTimeoutMS: 3600000, // 1 hour 68 | socketTimeoutMS: 3600000 // 1 hour 69 | }); 70 | 71 | expect(client.db.getCall(0).args[0]).to.equal("testDb"); 72 | expect(result.db).to.deep.equal({ 73 | the: "db", 74 | close: "theCloseFnFromMongoClient" 75 | }); 76 | expect(result.client).to.deep.equal(client); 77 | }); 78 | 79 | it("should yield an error when no url is defined in the config file", async () => { 80 | delete configObj.mongodb.url; 81 | try { 82 | await database.connect(); 83 | expect.fail("Error was not thrown"); 84 | } catch (err) { 85 | expect(err.message).to.equal("No `url` defined in config file!"); 86 | } 87 | }); 88 | 89 | it("should yield an error when unable to connect", async () => { 90 | mongodb.MongoClient.connect.returns( 91 | Promise.reject(new Error("Unable to connect")) 92 | ); 93 | try { 94 | await database.connect(); 95 | } catch (err) { 96 | expect(err.message).to.equal("Unable to connect"); 97 | } 98 | }); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /test/env/migrationsDir.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | const sinon = require("sinon"); 3 | const proxyquire = require("proxyquire"); 4 | 5 | const path = require("path"); 6 | 7 | describe("migrationsDir", () => { 8 | let migrationsDir; 9 | let fs; 10 | let config; 11 | let moduleLoader; 12 | 13 | function mockFs() { 14 | return { 15 | stat: sinon.stub(), 16 | readdir: sinon.stub(), 17 | readFile: sinon.stub() 18 | }; 19 | } 20 | 21 | function mockConfig() { 22 | return { 23 | read: sinon.stub().returns({ 24 | migrationsDir: "migrations", 25 | migrationFileExtension: ".js" 26 | }) 27 | }; 28 | } 29 | 30 | function mockModuleLoader() { 31 | return { 32 | import: sinon.stub(), 33 | }; 34 | } 35 | 36 | beforeEach(() => { 37 | fs = mockFs(); 38 | config = mockConfig(); 39 | moduleLoader = mockModuleLoader(); 40 | migrationsDir = proxyquire("../../lib/env/migrationsDir", { 41 | "fs-extra": fs, 42 | "./config": config, 43 | "../utils/module-loader": moduleLoader 44 | }); 45 | }); 46 | 47 | describe("resolve()", () => { 48 | it("should use the configured relative migrations dir when a config file is available", async () => { 49 | config.read.returns({ 50 | migrationsDir: "custom-migrations-dir" 51 | }); 52 | expect(await migrationsDir.resolve()).to.equal( 53 | path.join(process.cwd(), "custom-migrations-dir") 54 | ); 55 | }); 56 | 57 | it("should use the configured absolute migrations dir when a config file is available", async () => { 58 | config.read.returns({ 59 | migrationsDir: "/absolute/path/to/my/custom-migrations-dir" 60 | }); 61 | expect(await migrationsDir.resolve()).to.equal( 62 | "/absolute/path/to/my/custom-migrations-dir" 63 | ); 64 | }); 65 | 66 | it("should use the default migrations directory when no migrationsDir is specified in the config file", async () => { 67 | config.read.returns({}); 68 | expect(await migrationsDir.resolve()).to.equal( 69 | path.join(process.cwd(), "migrations") 70 | ); 71 | }); 72 | 73 | it("should use the default migrations directory when unable to read the config file", async () => { 74 | config.read.throws(new Error("Cannot read config file")); 75 | expect(await migrationsDir.resolve()).to.equal( 76 | path.join(process.cwd(), "migrations") 77 | ); 78 | }); 79 | }); 80 | 81 | describe("shouldExist()", () => { 82 | it("should not reject with an error if the migrations dir exists", async () => { 83 | fs.stat.returns(Promise.resolve()); 84 | await migrationsDir.shouldExist(); 85 | }); 86 | 87 | it("should yield an error if the migrations dir does not exist", async () => { 88 | const migrationsPath = path.join(process.cwd(), "migrations"); 89 | fs.stat.returns(Promise.reject(new Error("It does not exist"))); 90 | try { 91 | await migrationsDir.shouldExist(); 92 | expect.fail("Error was not thrown"); 93 | } catch (err) { 94 | expect(err.message).to.equal( 95 | `migrations directory does not exist: ${migrationsPath}` 96 | ); 97 | } 98 | }); 99 | }); 100 | 101 | describe("shouldNotExist()", () => { 102 | it("should not yield an error if the migrations dir does not exist", async () => { 103 | const error = new Error("File does not exist"); 104 | error.code = "ENOENT"; 105 | fs.stat.returns(Promise.reject(error)); 106 | await migrationsDir.shouldNotExist(); 107 | }); 108 | 109 | it("should yield an error if the migrations dir exists", async () => { 110 | const migrationsPath = path.join(process.cwd(), "migrations"); 111 | fs.stat.returns(Promise.resolve()); 112 | try { 113 | await migrationsDir.shouldNotExist(); 114 | expect.fail("Error was not thrown"); 115 | } catch (err) { 116 | expect(err.message).to.equal( 117 | `migrations directory already exists: ${migrationsPath}` 118 | ); 119 | } 120 | }); 121 | }); 122 | 123 | describe("getFileNames()", () => { 124 | it("should read the directory and yield the result", async () => { 125 | fs.readdir.returns(Promise.resolve(["file1.js", "file2.js"])); 126 | const files = await migrationsDir.getFileNames(); 127 | expect(files).to.deep.equal(["file1.js", "file2.js"]); 128 | }); 129 | 130 | it("should list only files with configured extension", async () => { 131 | config.read.returns({ 132 | migrationFileExtension: ".ts" 133 | }); 134 | fs.readdir.returns(Promise.resolve(["file1.ts", "file2.ts", "file1.js", "file2.js", ".keep"])); 135 | const files = await migrationsDir.getFileNames(); 136 | expect(files).to.deep.equal(["file1.ts", "file2.ts"]); 137 | }); 138 | 139 | it("should yield errors that occurred while reading the dir", async () => { 140 | fs.readdir.returns(Promise.reject(new Error("Could not read"))); 141 | try { 142 | await migrationsDir.getFileNames(); 143 | expect.fail("Error was not thrown"); 144 | } catch (err) { 145 | expect(err.message).to.equal("Could not read"); 146 | } 147 | }); 148 | 149 | it("should be sorted in alphabetical order", async () => { 150 | fs.readdir.returns(Promise.resolve([ 151 | "20201014172343-test.js", 152 | "20201014172356-test3.js", 153 | "20201014172354-test2.js", 154 | "20201014172345-test1.js" 155 | ])); 156 | const files = await migrationsDir.getFileNames(); 157 | expect(files).to.deep.equal([ 158 | "20201014172343-test.js", 159 | "20201014172345-test1.js", 160 | "20201014172354-test2.js", 161 | "20201014172356-test3.js" 162 | ]); 163 | }); 164 | }); 165 | 166 | describe("loadMigration()", () => { 167 | it("should attempt to load the fileName in the migrations directory", async () => { 168 | const pathToMigration = path.join( 169 | process.cwd(), 170 | "migrations", 171 | "someFile.js" 172 | ); 173 | try { 174 | await migrationsDir.loadMigration("someFile.js"); 175 | expect.fail("Error was not thrown"); 176 | } catch (err) { 177 | expect(err.message).to.have.string(`Cannot find module '${pathToMigration}'`); 178 | } 179 | }); 180 | 181 | it("should use CommonJS default", async () => { 182 | moduleLoader.require = sinon.stub().returns({ up: sinon.stub(), down: sinon.stub() }); 183 | await migrationsDir.loadMigration("someFile.js"); 184 | expect(moduleLoader.require.called).to.equal(true); 185 | expect(moduleLoader.import.called).to.equal(false); 186 | }); 187 | 188 | it("should fall back to using 'import' if Node requires the use of ESM (default export)", async () => { 189 | const error = new Error('ESM required'); 190 | error.code = 'ERR_REQUIRE_ESM'; 191 | moduleLoader.require = sinon.stub().throws(error); 192 | moduleLoader.import = sinon.stub().returns({ default: () => sinon.stub() }); 193 | await migrationsDir.loadMigration("someFile.js"); 194 | expect(moduleLoader.import.called).to.equal(true); 195 | }); 196 | 197 | it("should fall back to using 'import' if Node requires the use of ESM (no default export)", async () => { 198 | const error = new Error('ESM required'); 199 | error.code = 'ERR_REQUIRE_ESM'; 200 | moduleLoader.require = sinon.stub().throws(error); 201 | moduleLoader.import = sinon.stub().returns({ up: sinon.stub(), down: sinon.stub() }); 202 | await migrationsDir.loadMigration("someFile.js"); 203 | expect(moduleLoader.import.called).to.equal(true); 204 | }); 205 | }); 206 | 207 | describe("resolveMigrationFileExtension()", () => { 208 | it("should provide the value if specified", async () => { 209 | config.read.returns({ 210 | migrationFileExtension: ".ts" 211 | }); 212 | const ext = await migrationsDir.resolveMigrationFileExtension(); 213 | expect(ext).to.equal(".ts"); 214 | }); 215 | it("should error if the extension does not start with dot", async () => { 216 | config.read.returns({ 217 | migrationFileExtension: "js" 218 | }); 219 | try { 220 | await migrationsDir.resolveMigrationFileExtension(); 221 | expect.fail("Error was not thrown"); 222 | } catch(err) { 223 | expect(err.message).to.equal("migrationFileExtension must start with dot"); 224 | } 225 | }); 226 | it("should use the default if not specified", async() => { 227 | config.read.returns({ 228 | migrationFileExtension: undefined 229 | }); 230 | const ext = await migrationsDir.resolveMigrationFileExtension(); 231 | expect(ext).to.equal(".js"); 232 | }); 233 | it("should use the default if config file not found", async() => { 234 | config.read.throws(); 235 | const ext = await migrationsDir.resolveMigrationFileExtension(); 236 | expect(ext).to.equal(".js"); 237 | }); 238 | }); 239 | 240 | describe("doesSampleMigrationExist()", () => { 241 | it("should return true if sample migration exists", async () => { 242 | fs.stat.returns(Promise.resolve()); 243 | const result = await migrationsDir.doesSampleMigrationExist(); 244 | expect(result).to.equal(true); 245 | }); 246 | 247 | it("should return false if sample migration doesn't exists", async () => { 248 | fs.stat.returns(Promise.reject(new Error("It does not exist"))); 249 | const result = await migrationsDir.doesSampleMigrationExist(); 250 | expect(result).to.equal(false); 251 | }); 252 | }); 253 | 254 | describe("loadFileHash()", () => { 255 | it("should return a hash based on the file contents", async () => { 256 | fs.readFile.returns(Promise.resolve("some string to hash")); 257 | const result = await migrationsDir.loadFileHash('somefile.js'); 258 | expect(result).to.equal("ea83a45637a9af470a994d2c9722273ef07d47aec0660a1d10afe6e9586801ac"); 259 | }) 260 | }) 261 | }); 262 | -------------------------------------------------------------------------------- /test/utils/has-callback.test.js: -------------------------------------------------------------------------------- 1 | const { expect } = require("chai"); 2 | 3 | const hasCallback = require('../../lib/utils/has-callback'); 4 | 5 | describe('has-callback', () => { 6 | 7 | it('should return true when last argument is called `callback`', () => { 8 | expect(hasCallback((db, callback) => { 9 | return callback(); 10 | })).to.equal(true); 11 | }); 12 | 13 | it('should return true when last argument is called `callback_`', () => { 14 | expect(hasCallback((db, callback_) => { 15 | return callback_(); 16 | })).to.equal(true); 17 | }); 18 | 19 | it('should return true when last argument is called `cb`', () => { 20 | expect(hasCallback((db, cb) => { 21 | return cb(); 22 | })).to.equal(true); 23 | }); 24 | 25 | it('should return true when last argument is called `cb_`', () => { 26 | expect(hasCallback((db, cb_) => { 27 | return cb_(); 28 | })).to.equal(true); 29 | }); 30 | 31 | it('should return true when last argument is called `next`', () => { 32 | expect(hasCallback((db, next) => { 33 | return next(); 34 | })).to.equal(true); 35 | }); 36 | 37 | it('should return true when last argument is called `next_`', () => { 38 | expect(hasCallback((db, next_) => { 39 | return next_(); 40 | })).to.equal(true); 41 | }); 42 | 43 | it('should return true when last argument is called `done`', () => { 44 | expect(hasCallback((db, done) => { 45 | return done(); 46 | })).to.equal(true); 47 | }); 48 | 49 | it('should return true when last argument is called `done_`', () => { 50 | expect(hasCallback((db, done_) => { 51 | return done_(); 52 | })).to.equal(true); 53 | }); 54 | 55 | }); 56 | --------------------------------------------------------------------------------