onetable-cli
Advanced tools
Comparing version 1.2.4 to 1.3.0
204
dist/cli.js
@@ -7,3 +7,3 @@ #!/usr/bin/env node | ||
Migrations: | ||
onetable [all, down, generate, list, outstanding, repeat, reset, status, up, N.N.N] | ||
onetable [all, down, generate, list, named, outstanding, repeat, reset, status, up, N.N.N, NAMED] | ||
@@ -15,8 +15,6 @@ Reads migrate.json: | ||
}, | ||
delimiter: ':', | ||
dir: './migrations-directory', | ||
hidden: false, | ||
name: 'table-name', | ||
nulls: false, | ||
typeField: 'type', | ||
onetable: { | ||
name: 'table-name', | ||
}, | ||
aws: {accessKeyId, secretAccessKey, region}, | ||
@@ -37,9 +35,12 @@ arn: 'lambda-arn' | ||
import SenseLogs from 'senselogs'; | ||
// import SenseLogs from '../../senselogs/dist/mjs/index.js' | ||
const MigrationTemplate = ` | ||
import Schema from 'your-onetable-schema' | ||
export default { | ||
version: 'VERSION', | ||
schema: Schema, | ||
description: 'Purpose of this migration', | ||
async up(db, migrate, params) { | ||
if (!params.dry) { | ||
// db.log.info('Running upgrade') | ||
// await db.create('Model', {}) | ||
@@ -54,8 +55,2 @@ } | ||
}`; | ||
const Types = { | ||
String: 'string', | ||
Number: 'number', | ||
Boolean: 'boolean', | ||
String: 'string', | ||
}; | ||
const Usage = ` | ||
@@ -71,8 +66,11 @@ onetable usage: | ||
onetable outstanding # List migrations yet to be applied | ||
onetable named # List available named migrations | ||
onetable repeat # Repeat the last migration | ||
onetable reset # Reset the database with latest migration | ||
onetable reset # Reset the database to the latest schema | ||
onetable status # Show most recently applied migration | ||
onetable up # Apply the next migration | ||
onetable NAME # Apply a named migration | ||
Options: | ||
--arn # Lambda ARN for migration controller | ||
--aws-access-key # AWS access key | ||
@@ -87,9 +85,9 @@ --aws-region # AWS service region | ||
--dry # Dry-run, pass params.dry to migrations. | ||
--endpoint http://host:port # Database endpoint | ||
--endpoint http://host:port # Database endpoint for local use. | ||
--force # Force action without confirmation | ||
--profile prod|qa|dev|... # Select configuration profile | ||
--quiet # Run as quietly as possible | ||
--table TableName # DynamoDB table name | ||
--version # Emit version number | ||
`; | ||
const LATEST_VERSION = 'latest'; | ||
class CLI { | ||
@@ -104,2 +102,3 @@ usage() { | ||
this.aws = {}; | ||
this.table = null; | ||
} | ||
@@ -123,2 +122,6 @@ async init() { | ||
} | ||
onetable.name = this.table || onetable.name; | ||
if (!onetable.name) { | ||
error('Missing DynamoDB table name'); | ||
} | ||
let location; | ||
@@ -151,3 +154,3 @@ if (config.arn) { | ||
this.migrate = new Migrate(onetable, { | ||
// migrations: config.migrations, | ||
migrations: config.migrations, | ||
dir: config.dir, | ||
@@ -178,6 +181,6 @@ profile: config.profile, | ||
if (cmd == 'all') { | ||
await this.move(); | ||
await this.run(); | ||
} | ||
else if (cmd == 'reset') { | ||
await this.move(LATEST_VERSION); | ||
await this.run("reset"); | ||
} | ||
@@ -193,5 +196,8 @@ else if (cmd == 'status') { | ||
} | ||
else if (args.length) { | ||
await this.move(cmd); | ||
else if (cmd == 'named') { | ||
await this.named(); | ||
} | ||
else if (cmd) { | ||
await this.run(cmd); | ||
} | ||
else { | ||
@@ -208,3 +214,12 @@ this.usage(); | ||
let version = versions.length ? versions.pop() : await this.migrate.getCurrentVersion(); | ||
version = Semver.inc(version, this.bump); | ||
if (Semver.valid(this.bump)) { | ||
version = this.bump; | ||
} | ||
else { | ||
let newVersion = Semver.inc(version, this.bump); | ||
if (!Semver.valid(newVersion)) { | ||
this.error(`Cannot bump version ${version} via ${this.bump}`); | ||
} | ||
version = newVersion; | ||
} | ||
let dir = Path.resolve(this.config.dir || '.'); | ||
@@ -225,3 +240,3 @@ let path = `${dir}/${version}.js`; | ||
async list() { | ||
let pastMigrations = await this.migrate.findPastMigrations(); | ||
let pastMigrations = await this.migrate.getPastMigrations(); | ||
if (this.quiet) { | ||
@@ -237,7 +252,7 @@ for (let m of pastMigrations) { | ||
else { | ||
print('Date Version Description'); | ||
print('Date Version Description'); | ||
} | ||
for (let m of pastMigrations) { | ||
let date = Dates.format(m.time, 'HH:MM:ss mmm d, yyyy'); | ||
print(`${date} ${m.version.padStart(7)} ${m.description}`); | ||
let date = Dates.format(m.date, 'HH:MM:ss mmm d, yyyy'); | ||
print(`${date} ${m.version.padStart(20)} ${m.description}`); | ||
} | ||
@@ -257,7 +272,17 @@ } | ||
} | ||
async named() { | ||
let list = await this.migrate.getNamedMigrations(); | ||
if (list.length == 0) { | ||
print('none'); | ||
} | ||
else { | ||
for (let migration of list) { | ||
print(`${migration}`); | ||
} | ||
} | ||
} | ||
/* | ||
Move to the target version | ||
*/ | ||
async move(target) { | ||
let direction; | ||
async run(target) { | ||
let outstanding = await this.migrate.getOutstandingVersions(); | ||
@@ -273,12 +298,14 @@ if (!target) { | ||
} | ||
let pastMigrations = await this.migrate.findPastMigrations(); | ||
let current = pastMigrations.length ? pastMigrations[pastMigrations.length - 1].version : '0.0.0'; | ||
let pastMigrations = await this.migrate.getPastMigrations(); | ||
let pastVersions = pastMigrations.filter(m => Semver.valid(m.version)); | ||
let current = await this.migrate.getCurrentVersion(); | ||
let versions = []; | ||
if (target == 'latest') { | ||
direction = 0; | ||
let cmd; | ||
if (target == 'latest' || target == 'reset') { | ||
cmd = 'reset'; | ||
pastMigrations = []; | ||
versions = [LATEST_VERSION]; | ||
versions = ["reset"]; | ||
} | ||
else if (target == 'repeat') { | ||
direction = 2; | ||
cmd = 'repeat'; | ||
let version = pastMigrations.reverse().slice(0).map(m => m.version).shift(); | ||
@@ -290,3 +317,3 @@ if (version) { | ||
else if (target == 'up') { | ||
direction = 1; | ||
cmd = 'up'; | ||
if (outstanding.length == 0) { | ||
@@ -299,4 +326,4 @@ print(`All migrations applied`); | ||
else if (target == 'down') { | ||
direction = -1; | ||
let version = pastMigrations.slice(0).reverse().map(m => m.version).shift(); | ||
cmd = 'down'; | ||
let version = pastVersions.slice(0).reverse().map(m => m.version).shift(); | ||
if (version) { | ||
@@ -306,20 +333,27 @@ versions = [version]; | ||
} | ||
else if (Semver.compare(target, current) < 0) { | ||
direction = -1; | ||
if (target != '0.0.0' && !pastMigrations.find(m => m.version == target)) { | ||
error(`Cannot find target migration ${target} in applied migrations`); | ||
else if (Semver.valid(target)) { | ||
if (Semver.compare(target, current) < 0) { | ||
cmd = 'down'; | ||
if (target != '0.0.0' && !pastVersions.find(p => p == target)) { | ||
error(`Cannot find target migration ${target} in applied migrations`); | ||
} | ||
versions = pastVersions.reverse().filter(v => Semver.compare(v, target) > 0); | ||
} | ||
versions = pastMigrations.reverse().map(m => m.version).filter(v => Semver.compare(v, target) > 0); | ||
else { | ||
cmd = 'up'; | ||
if (Semver.compare(target, current) <= 0) { | ||
print('Migration already applied'); | ||
return; | ||
} | ||
if (!outstanding.find(v => v == target)) { | ||
error(`Cannot find migration ${target} in outstanding migrations: ${outstanding.join(', ')}`); | ||
} | ||
versions = outstanding.filter(v => Semver.compare(v, current) >= 0); | ||
versions = versions.filter(v => Semver.compare(v, target) <= 0); | ||
} | ||
} | ||
else { | ||
direction = 1; | ||
if (Semver.compare(target, current) <= 0) { | ||
print('Migration already applied'); | ||
return; | ||
} | ||
if (!outstanding.find(v => v == target)) { | ||
error(`Cannot find migration ${target} in outstanding migrations: ${outstanding}`); | ||
} | ||
versions = outstanding.filter(v => Semver.compare(v, current) >= 0); | ||
versions = versions.filter(v => Semver.compare(v, target) <= 0); | ||
// Named version | ||
versions = [target]; | ||
cmd = target; | ||
} | ||
@@ -331,7 +365,12 @@ if (versions.length == 0) { | ||
try { | ||
await this.confirm(versions, direction); | ||
await this.confirm(cmd, versions); | ||
for (let version of versions) { | ||
let verb = ['Downgrade from', 'Reset to', 'Upgrade to', 'Repeat'][direction + 1]; | ||
let migration = await this.migrate.apply(direction, version, { dry: this.dry }); | ||
print(`${verb} "${migration.version} - ${migration.description}"`); | ||
let migration = await this.migrate.apply(cmd, version, { dry: this.dry }); | ||
let verb = { | ||
'down': 'Downgrade database from', | ||
'reset': 'Reset database with', | ||
'up': 'Upgrade database to', | ||
'repeat': 'Repeat migration' | ||
}[cmd] || 'Run named migration'; | ||
print(`${verb} "${version} - ${migration.description}"`); | ||
} | ||
@@ -346,9 +385,9 @@ current = await this.migrate.getCurrentVersion(); | ||
} | ||
async confirm(versions, direction) { | ||
async confirm(cmd, versions) { | ||
if (this.force) { | ||
return; | ||
} | ||
let action = ['downgrade', 'reset', 'upgrade', 'repeat'][direction + 1]; | ||
cmd = { 'up': 'upgrade', 'down': 'downgrade' }[cmd] || cmd; | ||
let noun = versions.length > 1 ? 'changes' : 'change'; | ||
let fromto = action == 'downgrade' ? 'from' : 'to'; | ||
let fromto = cmd == 'downgrade' ? 'from' : 'to'; | ||
let target = versions[versions.length - 1]; | ||
@@ -358,4 +397,4 @@ if (this.config.profile == 'prod') { | ||
} | ||
print(`Confirm ${versions.length} "${action}" ${noun} ${fromto} version "${target}" for database "${this.config.onetable.name}" using profile "${this.config.profile}".`); | ||
print(`\nMigrations to ${direction < 0 ? 'revert' : 'apply'}:`); | ||
print(`Confirm ${versions.length} "${cmd}" ${noun} ${fromto} version "${target}" for database "${this.config.onetable.name}" using profile "${this.config.profile}".`); | ||
print(`\nMigrations to ${cmd == 'downgrade' ? 'revert' : 'apply'}:`); | ||
for (let version of versions) { | ||
@@ -390,3 +429,6 @@ print(`${version}`); | ||
let arg = argv[i]; | ||
if (arg == '--aws-access-key') { | ||
if (arg == '--arn') { | ||
this.arn = argv[++i]; | ||
} | ||
else if (arg == '--aws-access-key') { | ||
this.aws.accessKeyId = argv[++i]; | ||
@@ -434,2 +476,5 @@ } | ||
} | ||
else if (arg == '--table') { | ||
this.table = argv[++i]; | ||
} | ||
else if (arg == '--verbose' || arg == '-v') { | ||
@@ -441,3 +486,3 @@ this.verbosity = true; | ||
} | ||
else if (arg[0] == '-' || arg.indexOf('-') >= 0) { | ||
else if (arg[0] == '-') { | ||
this.usage(); | ||
@@ -457,5 +502,9 @@ } | ||
async getConfig() { | ||
let migrateConfig = this.migrateConfig || 'migrate.json'; | ||
let migrateConfig = this.migrateConfig || 'migrate.json5'; | ||
if (!Fs.existsSync(migrateConfig)) { | ||
error(`Cannot locate ${migrateConfig}`); | ||
// LEGACY | ||
migrateConfig = 'migrate.json'; | ||
if (!Fs.existsSync(migrateConfig)) { | ||
error(`Cannot locate migrate.json5`); | ||
} | ||
} | ||
@@ -520,7 +569,7 @@ let index, profile; | ||
} | ||
async apply(direction, version, params = {}) { | ||
return await this.invoke('apply', { direction, version, params }); | ||
async apply(action, version, params = {}) { | ||
return await this.invoke('apply', { action, version, params }); | ||
} | ||
async findPastMigrations() { | ||
return await this.invoke('findPastMigrations'); | ||
async getPastMigrations() { | ||
return await this.invoke('getPastMigrations'); | ||
} | ||
@@ -533,8 +582,11 @@ async getCurrentVersion() { | ||
} | ||
async invoke(action, args) { | ||
let params = { action, config: this.config }; | ||
async getNamedMigrations(limit = Number.MAX_SAFE_INTEGER) { | ||
return await this.invoke('getNamedMigrations'); | ||
} | ||
async invoke(cmd, args) { | ||
let params = { cmd, config: this.config }; | ||
if (args) { | ||
params.args = args; | ||
} | ||
this.debug(`Invoke migrate proxy`, { action, args, arn: this.arn }); | ||
this.debug(`Invoke migrate proxy`, { cmd, args, arn: this.arn }); | ||
let payload = JSON.stringify(params, null, 2); | ||
@@ -548,8 +600,8 @@ let result = await this.lambda.invoke({ | ||
if (result.StatusCode != 200) { | ||
error(`Cannot invoke ${action}: bad status code ${result.StatusCode}`); | ||
error(`Cannot invoke ${cmd}: bad status code ${result.StatusCode}`); | ||
} | ||
else if (result && result.Payload) { | ||
result = JSON.parse(result.Payload); | ||
if (result.errorMessage) { | ||
error(`Cannot invoke ${action}: ${result.errorMessage}`); | ||
if (result.error) { | ||
error(`Cannot invoke ${cmd}: ${result.error}`); | ||
} | ||
@@ -561,3 +613,3 @@ else { | ||
else { | ||
error(`Cannot invoke ${action}: no result`); | ||
error(`Cannot invoke ${cmd}: no result`); | ||
} | ||
@@ -564,0 +616,0 @@ this.debug(`Migrate proxy results`, { args, result }); |
{ | ||
"name": "onetable-cli", | ||
"version": "1.2.4", | ||
"version": "1.3.0", | ||
"type": "module", | ||
@@ -36,13 +36,13 @@ "description": "DynamoDB OneTable CLI", | ||
"devDependencies": { | ||
"@types/jest": "^28.1.6", | ||
"@types/node": "^18.6.5", | ||
"@types/jest": "^29.5.4", | ||
"@types/node": "^20.6.0", | ||
"coveralls": "^3.1.1", | ||
"eslint": "^8.21.0", | ||
"jest": "^28.1.3", | ||
"ts-jest": "^28.0.7", | ||
"typescript": "^4.7.4" | ||
"eslint": "^8.49.0", | ||
"jest": "^29.6.4", | ||
"ts-jest": "^29.1.1", | ||
"typescript": "^5.2.2" | ||
}, | ||
"dependencies": { | ||
"aws-sdk": "^2.1190.0", | ||
"dynamodb-onetable": "^2.4", | ||
"aws-sdk": "^2.1455.0", | ||
"dynamodb-onetable": "^2.7", | ||
"js-blend": "./src/paks/js-blend", | ||
@@ -53,6 +53,6 @@ "js-clone": "./src/paks/js-clone", | ||
"js-file": "./src/paks/js-file", | ||
"json5": "^2.2.1", | ||
"onetable-migrate": "^1.1.6", | ||
"json5": "^2.2.3", | ||
"onetable-migrate": "^1.2.0", | ||
"readline": "^1.3.0", | ||
"semver": "^7.3.7", | ||
"semver": "^7.5.4", | ||
"senselogs": "^1" | ||
@@ -59,0 +59,0 @@ }, |
111
README.md
@@ -12,3 +12,3 @@  | ||
The CLI is ideal for development teams to initialize and reset database contents and for production use to control and sequence step-wise database upgrades and downgrades. It is a vital tool to successfully evolve your Single-Table DynamoDB patterns. | ||
The CLI is ideal for development teams to initialize and reset database contents and for production use to control and sequence step-wise database upgrades, downgrades and maintenance tasks. It is a vital tool to successfully evolve your Single-Table DynamoDB patterns. | ||
@@ -21,5 +21,7 @@ The OneTable CLI was used in production by the [SenseDeep Developer Studio](https://www.sensedeep.com/) for all DynamoDB access for a year before it was published as an NPM module. | ||
* Mutates database schema and contents via discrete, reversible migrations. | ||
* Migrate upwards, downwards, to specific versions. | ||
* Migrate upwards, downwards, and to specific versions. | ||
* Automated, ordered sequencing of migrations in both directions. | ||
* Operates on local databases, remote databases via AWS credentials and via a Lambda proxy. | ||
* Named migrations for database maintenance, auditing and other tasks. | ||
* Operates on local databases and remote databases. | ||
* Use AWS credentials or profiles. | ||
* Add and remove seed data in any migration. | ||
@@ -34,4 +36,2 @@ * Quick reset of DynamoDB databases for development. | ||
NOTE: this package requires NPM version 7.0 or later. The version 6.x of NPM that comes with Node v14 will not work as it does not support local packages. | ||
```sh | ||
@@ -60,3 +60,3 @@ npm i onetable-cli -g | ||
Then create a `migrate.json` with your DynamoDB OneTable configuration. We use JSON5 so you can use Javascript object literal syntax. | ||
Then create a `migrate.json5` with your DynamoDB OneTable configuration. We use JSON5 so you can use Javascript object literal syntax. | ||
@@ -68,26 +68,26 @@ ```javascript | ||
// Other onetable configuration parameters. | ||
} | ||
partial: true, | ||
}, | ||
dir: './migrations' | ||
} | ||
``` | ||
Set the `name` property to the name of your DynamoDB table. | ||
Set the `name` property to the name of your DynamoDB table and set the `dir` property to point to the directory containing the migrations. | ||
If you need to have your migrations in a different directory, you can set the migrate.json `dir` property to point to the directory containing the migrations themselves. | ||
You pass your OneTable configuration via the `onetable` collection. Ensure your `crypto`, `nulls` and `typeField` settings match your deployed code. If you have these set to non-default settings in your code, add them to your migrate.json5 `onetable` map to match. | ||
You pass your OneTable configuration via the `onetable` collection. Ensure your `crypto`, `delimiter`, `nulls` and `typeField` settings match your deployed code. If you have these set to non-default settings in your code, add them to your migrate.json `onetable` map to match. | ||
**Generate a stub migration** | ||
Generate a stub migration | ||
Migrations are Javascript files that export the methods `up` and `down` to apply the migration and a `description` property. The migration must nominate a version and provide the OneTable schema that applies for the table data at this version level. | ||
```sh | ||
cd ./migrations | ||
onetable generate migration | ||
``` | ||
This will create a `0.0.1.js` migration that contains the following. Edit the `up` and `down` methods and description to suit. | ||
This will create a `0.0.1.js` migration that contains an `up` method to upgrade the database and a `down` method to downgrade to the previous version. Customize the `up` and `down` methods and description to suit. | ||
The `db` property is the OneTable `Table` instance. This `migrate` property is an instance of the CLI Migrate class. | ||
For example: | ||
```javascript | ||
import Schema from 'your-onetable-schema', | ||
export default { | ||
@@ -99,3 +99,3 @@ version: '0.0.1', | ||
if (!params.dry) { | ||
await db.create('Model', {}) | ||
// Code here to upgrade the database | ||
} else { | ||
@@ -107,3 +107,3 @@ console.log('Dry run: create "Model"') | ||
if (!params.dry) { | ||
await db.remove('Model', {}) | ||
// Code here to downgrade the database to the prior version | ||
} else { | ||
@@ -116,4 +116,6 @@ console.log('Dry run: remove "Model"') | ||
### Examples | ||
The `db` property is the OneTable `Table` instance. This `migrate` property is an instance of the CLI Migrate class. | ||
### OneTable Comamnds | ||
Apply the next migration. | ||
@@ -143,2 +145,9 @@ | ||
Run a specific named migration. | ||
```sh | ||
onetable cleanup-orphans | ||
onetable reset | ||
``` | ||
Apply all outstanding migrations. | ||
@@ -168,3 +177,3 @@ | ||
Reset the database to the latest migration. This should reset the database and apply the `latest.js` migration. The purpose of the `latest` migration is to have one migration that can quickly create a new database with the latest schema without having to apply all historical migrations. | ||
Reset the database to the latest version. If you provide a `reset.js` migration, this migrations should reset the database to a known good state. The purpose of the `reset` migration is to have one migration that can quickly initialize a database with the latest data and schema without having to apply all historical migrations. | ||
@@ -179,6 +188,9 @@ ```sh | ||
onetable --bump 2.4.3 generate | ||
# or generate with a bumped minor version number | ||
onetable --bump minor generate | ||
``` | ||
Do a dry run for a migration and not execute. This will set params.dry to true when invoking the up/down. | ||
It is up to the up/down routines to implement the dry run functionality if that support is desired. | ||
Do a dry run for a migration and not execute. This will set params.dry to true when invoking the up/down migration function. It is up to the up/down routines to implement the dry run functionality if that support is desired. During a dry run, the database migration table will not be updated nor will the current version and schema. | ||
@@ -195,4 +207,4 @@ ```sh | ||
--aws-secret-key # AWS secret key | ||
--bump [major,minor,patch] # Version digit to bump in generation | ||
--config ./migrate.json # Migration configuration | ||
--bump [VERSION|major|minor|patch] # Version to generate or digit to bump | ||
--config ./migrate.json5 # Migration configuration file | ||
--crypto cipher:password # Crypto to use for encrypted attributes | ||
@@ -205,6 +217,7 @@ --dir directory # Change to directory to execute | ||
--quiet # Run as quietly as possible | ||
--table TableName # Set the DynamoDB table name | ||
--version # Emit version number | ||
``` | ||
### Accessing AWS | ||
### Authenticating with DynamoDB | ||
@@ -214,14 +227,13 @@ You can configure access to your DynamoDB table in your AWS account several ways: | ||
* via command line options | ||
* via the migrate.json | ||
* via the migrate.json5 | ||
* via environment variables | ||
* via proxy | ||
Via command line option: | ||
``` | ||
```shell | ||
onetable --aws-access-key key --aws-secret-key secret --aws-region us-east-1 | ||
``` | ||
Via migrate.json | ||
``` | ||
Via migrate.json5: | ||
```javascript | ||
{ | ||
@@ -238,3 +250,3 @@ aws: { | ||
``` | ||
```bash | ||
export AWS_ACCESS_KEY_ID=your-access-key | ||
@@ -246,3 +258,3 @@ export AWS_SECRET_ACCESS_KEY=your-secret-key | ||
You can also use: | ||
``` | ||
```bash | ||
export AWS_PROFILE=aws-profile-name | ||
@@ -252,5 +264,5 @@ export AWS_REGION=us-east-1 | ||
To access a local DynamoDB database, set the migrate.json `aws.endpoint` property to point to the listening endpoint. | ||
To access a local DynamoDB database, set the migrate.json5 `aws.endpoint` property to point to the listening endpoint. | ||
``` | ||
```javascript | ||
{ | ||
@@ -263,6 +275,5 @@ aws: { | ||
To communicate with a Lambda hosting the [OneTable Migrate Library](), set the `arn` field to the ARN of your Lambda function. | ||
Then define your AWS credentials as described above to grant access for the CLI to your Lambda. | ||
To communicate with a Lambda hosting the [OneTable Migrate Library](), set the `arn` field to the ARN of your Lambda function. Then define your AWS credentials as described above to grant access for the CLI to your Lambda. | ||
``` | ||
```javascript | ||
{ | ||
@@ -280,21 +291,23 @@ arn: 'arn:aws:lambda:us-east-1:123456789012:function:migrate-prod-invoke' | ||
When deployed, configure migrations by setting the CLI migrate.json `arn` property to the ARN of your migration Lambda that hosts the Migration Library. | ||
When deployed, configure migrations by setting the CLI migrate.json5 `arn` property to the ARN of your migration Lambda that hosts the Migration Library. | ||
### Latest Migration | ||
### Reset Migration | ||
You can create a special `latest` migration that is used for the `migrate reset` command which is is a quick way to get a development database up to the current version. | ||
You can create a special named `reset` migration that is used for the `onetable reset` command which is is a quick way to get a development database up to the current version. | ||
The latest migration should remove all data from the database and then initialize the database equivalent to applying all migrations. | ||
The `reset` migration should remove all data from the database and then initialize the database as required. | ||
When creating your `latest.js` migration, be very careful when removing all items from the database. We typically protect this with a test against the deployment profile to ensure you never do this on a production database. | ||
When creating your `reset.js` migration, be very careful when removing all items from the database. We typically protect this with a test against the deployment profile to ensure you never do this on a production database. | ||
Sample latest.js migration: | ||
Sample reset.js migration: | ||
```javascript | ||
import Schema from 'your-onetable-schema.js' | ||
export default { | ||
version: '0.0.1', | ||
description: 'Database reset to latest version', | ||
description: 'Database reset', | ||
schema: Schema, | ||
async up(db, migrate, params) { | ||
// Careful not to remove all items on a production database! | ||
if (migrate.params.profile == 'dev') { | ||
@@ -311,2 +324,3 @@ await removeAllItems(db) | ||
} | ||
async function removeAllItems(db) { | ||
@@ -324,22 +338,23 @@ do { | ||
You can use profiles in your `migrate.json` to have specific configuration for different build profiles. | ||
You can use profiles in your `migrate.json5` to have specific configuration for different build profiles. | ||
Profiles are implemented by copying the properties from the relevant `profile.NAME` collection to the top level. For example: | ||
Here is a sample migrate.json with profiles: | ||
Here is a sample migrate.json5 with profiles: | ||
```javascript | ||
{ | ||
onetable: { | ||
name: 'sensedb', | ||
partial: true, | ||
}, | ||
profiles: { | ||
dev: { | ||
dir: './migrations', | ||
name: 'sensedb', | ||
endpoint: 'http://localhost:8000' | ||
}, | ||
qa: { | ||
name: 'sensedb', | ||
arn: 'arn:aws:lambda:us-east-1:xxxx:function:migrate-qa-invoke' | ||
}, | ||
prod: { | ||
name: 'sensedb', | ||
arn: 'arn:aws:lambda:us-east-1:xxxx:function:migrate-prod-invoke' | ||
@@ -346,0 +361,0 @@ } |
205
src/cli.js
@@ -7,3 +7,3 @@ #!/usr/bin/env node | ||
Migrations: | ||
onetable [all, down, generate, list, outstanding, repeat, reset, status, up, N.N.N] | ||
onetable [all, down, generate, list, named, outstanding, repeat, reset, status, up, N.N.N, NAMED] | ||
@@ -15,8 +15,6 @@ Reads migrate.json: | ||
}, | ||
delimiter: ':', | ||
dir: './migrations-directory', | ||
hidden: false, | ||
name: 'table-name', | ||
nulls: false, | ||
typeField: 'type', | ||
onetable: { | ||
name: 'table-name', | ||
}, | ||
aws: {accessKeyId, secretAccessKey, region}, | ||
@@ -42,10 +40,12 @@ arn: 'lambda-arn' | ||
// import SenseLogs from '../../senselogs/dist/mjs/index.js' | ||
const MigrationTemplate = ` | ||
import Schema from 'your-onetable-schema' | ||
const MigrationTemplate = ` | ||
export default { | ||
version: 'VERSION', | ||
schema: Schema, | ||
description: 'Purpose of this migration', | ||
async up(db, migrate, params) { | ||
if (!params.dry) { | ||
// db.log.info('Running upgrade') | ||
// await db.create('Model', {}) | ||
@@ -61,9 +61,2 @@ } | ||
const Types = { | ||
String: 'string', | ||
Number: 'number', | ||
Boolean: 'boolean', | ||
String: 'string', | ||
} | ||
const Usage = ` | ||
@@ -79,8 +72,11 @@ onetable usage: | ||
onetable outstanding # List migrations yet to be applied | ||
onetable named # List available named migrations | ||
onetable repeat # Repeat the last migration | ||
onetable reset # Reset the database with latest migration | ||
onetable reset # Reset the database to the latest schema | ||
onetable status # Show most recently applied migration | ||
onetable up # Apply the next migration | ||
onetable NAME # Apply a named migration | ||
Options: | ||
--arn # Lambda ARN for migration controller | ||
--aws-access-key # AWS access key | ||
@@ -95,11 +91,10 @@ --aws-region # AWS service region | ||
--dry # Dry-run, pass params.dry to migrations. | ||
--endpoint http://host:port # Database endpoint | ||
--endpoint http://host:port # Database endpoint for local use. | ||
--force # Force action without confirmation | ||
--profile prod|qa|dev|... # Select configuration profile | ||
--quiet # Run as quietly as possible | ||
--table TableName # DynamoDB table name | ||
--version # Emit version number | ||
` | ||
const LATEST_VERSION = 'latest' | ||
class CLI { | ||
@@ -115,2 +110,3 @@ usage() { | ||
this.aws = {} | ||
this.table = null | ||
} | ||
@@ -137,2 +133,6 @@ | ||
} | ||
onetable.name = this.table || onetable.name | ||
if (!onetable.name) { | ||
error('Missing DynamoDB table name') | ||
} | ||
@@ -165,3 +165,3 @@ let location | ||
this.migrate = new Migrate(onetable, { | ||
// migrations: config.migrations, | ||
migrations: config.migrations, | ||
dir: config.dir, | ||
@@ -192,5 +192,5 @@ profile: config.profile, | ||
if (cmd == 'all') { | ||
await this.move() | ||
await this.run() | ||
} else if (cmd == 'reset') { | ||
await this.move(LATEST_VERSION) | ||
await this.run("reset") | ||
} else if (cmd == 'status') { | ||
@@ -202,4 +202,6 @@ await this.status() | ||
await this.outstanding() | ||
} else if (args.length) { | ||
await this.move(cmd) | ||
} else if (cmd == 'named') { | ||
await this.named() | ||
} else if (cmd) { | ||
await this.run(cmd) | ||
} else { | ||
@@ -216,3 +218,11 @@ this.usage() | ||
let version = versions.length ? versions.pop() : await this.migrate.getCurrentVersion() | ||
version = Semver.inc(version, this.bump) | ||
if (Semver.valid(this.bump)) { | ||
version = this.bump | ||
} else { | ||
let newVersion = Semver.inc(version, this.bump) | ||
if (!Semver.valid(newVersion)) { | ||
this.error(`Cannot bump version ${version} via ${this.bump}`) | ||
} | ||
version = newVersion | ||
} | ||
let dir = Path.resolve(this.config.dir || '.') | ||
@@ -234,3 +244,3 @@ let path = `${dir}/${version}.js` | ||
async list() { | ||
let pastMigrations = await this.migrate.findPastMigrations() | ||
let pastMigrations = await this.migrate.getPastMigrations() | ||
if (this.quiet) { | ||
@@ -244,7 +254,7 @@ for (let m of pastMigrations) { | ||
} else { | ||
print('Date Version Description') | ||
print('Date Version Description') | ||
} | ||
for (let m of pastMigrations) { | ||
let date = Dates.format(m.time, 'HH:MM:ss mmm d, yyyy') | ||
print(`${date} ${m.version.padStart(7)} ${m.description}`) | ||
let date = Dates.format(m.date, 'HH:MM:ss mmm d, yyyy') | ||
print(`${date} ${m.version.padStart(20)} ${m.description}`) | ||
} | ||
@@ -265,7 +275,17 @@ } | ||
async named() { | ||
let list = await this.migrate.getNamedMigrations() | ||
if (list.length == 0) { | ||
print('none') | ||
} else { | ||
for (let migration of list) { | ||
print(`${migration}`) | ||
} | ||
} | ||
} | ||
/* | ||
Move to the target version | ||
*/ | ||
async move(target) { | ||
let direction | ||
async run(target) { | ||
let outstanding = await this.migrate.getOutstandingVersions() | ||
@@ -281,13 +301,15 @@ | ||
} | ||
let pastMigrations = await this.migrate.findPastMigrations() | ||
let current = pastMigrations.length ? pastMigrations[pastMigrations.length - 1].version : '0.0.0' | ||
let pastMigrations = await this.migrate.getPastMigrations() | ||
let pastVersions = pastMigrations.filter(m => Semver.valid(m.version)) | ||
let current = await this.migrate.getCurrentVersion() | ||
let versions = [] | ||
let cmd | ||
if (target == 'latest') { | ||
direction = 0 | ||
if (target == 'latest' || target == 'reset') { | ||
cmd = 'reset' | ||
pastMigrations = [] | ||
versions = [LATEST_VERSION] | ||
versions = ["reset"] | ||
} else if (target == 'repeat') { | ||
direction = 2 | ||
cmd = 'repeat' | ||
let version = pastMigrations.reverse().slice(0).map(m => m.version).shift() | ||
@@ -299,3 +321,3 @@ if (version) { | ||
} else if (target == 'up') { | ||
direction = 1 | ||
cmd = 'up' | ||
if (outstanding.length == 0) { | ||
@@ -308,4 +330,4 @@ print(`All migrations applied`) | ||
} else if (target == 'down') { | ||
direction = -1 | ||
let version = pastMigrations.slice(0).reverse().map(m => m.version).shift() | ||
cmd = 'down' | ||
let version = pastVersions.slice(0).reverse().map(m => m.version).shift() | ||
if (version) { | ||
@@ -315,20 +337,26 @@ versions = [version] | ||
} else if (Semver.compare(target, current) < 0) { | ||
direction = -1 | ||
if (target != '0.0.0' && !pastMigrations.find(m => m.version == target)) { | ||
error(`Cannot find target migration ${target} in applied migrations`) | ||
} else if (Semver.valid(target)) { | ||
if (Semver.compare(target, current) < 0) { | ||
cmd = 'down' | ||
if (target != '0.0.0' && !pastVersions.find(p => p == target)) { | ||
error(`Cannot find target migration ${target} in applied migrations`) | ||
} | ||
versions = pastVersions.reverse().filter(v => Semver.compare(v, target) > 0) | ||
} else { | ||
cmd = 'up' | ||
if (Semver.compare(target, current) <= 0) { | ||
print('Migration already applied') | ||
return | ||
} | ||
if (!outstanding.find(v => v == target)) { | ||
error(`Cannot find migration ${target} in outstanding migrations: ${outstanding.join(', ')}`) | ||
} | ||
versions = outstanding.filter(v => Semver.compare(v, current) >= 0) | ||
versions = versions.filter(v => Semver.compare(v, target) <= 0) | ||
} | ||
versions = pastMigrations.reverse().map(m => m.version).filter(v => Semver.compare(v, target) > 0) | ||
} else { | ||
direction = 1 | ||
if (Semver.compare(target, current) <= 0) { | ||
print('Migration already applied') | ||
return | ||
} | ||
if (!outstanding.find(v => v == target)) { | ||
error(`Cannot find migration ${target} in outstanding migrations: ${outstanding}`) | ||
} | ||
versions = outstanding.filter(v => Semver.compare(v, current) >= 0) | ||
versions = versions.filter(v => Semver.compare(v, target) <= 0) | ||
// Named version | ||
versions = [target] | ||
cmd = target | ||
} | ||
@@ -340,7 +368,12 @@ if (versions.length == 0) { | ||
try { | ||
await this.confirm(versions, direction) | ||
await this.confirm(cmd, versions) | ||
for (let version of versions) { | ||
let verb = ['Downgrade from', 'Reset to', 'Upgrade to', 'Repeat'][direction + 1] | ||
let migration = await this.migrate.apply(direction, version, {dry: this.dry}) | ||
print(`${verb} "${migration.version} - ${migration.description}"`) | ||
let migration = await this.migrate.apply(cmd, version, {dry: this.dry}) | ||
let verb = { | ||
'down': 'Downgrade database from', | ||
'reset': 'Reset database with', | ||
'up': 'Upgrade database to', | ||
'repeat': 'Repeat migration' | ||
}[cmd] || 'Run named migration' | ||
print(`${verb} "${version} - ${migration.description}"`) | ||
} | ||
@@ -356,9 +389,9 @@ current = await this.migrate.getCurrentVersion() | ||
async confirm(versions, direction) { | ||
async confirm(cmd, versions) { | ||
if (this.force) { | ||
return | ||
} | ||
let action = ['downgrade', 'reset', 'upgrade', 'repeat'][direction + 1] | ||
cmd = { 'up': 'upgrade', 'down': 'downgrade'}[cmd] || cmd | ||
let noun = versions.length > 1 ? 'changes' : 'change' | ||
let fromto = action == 'downgrade' ? 'from' : 'to' | ||
let fromto = cmd == 'downgrade' ? 'from' : 'to' | ||
let target = versions[versions.length - 1] | ||
@@ -368,4 +401,4 @@ if (this.config.profile == 'prod') { | ||
} | ||
print(`Confirm ${versions.length} "${action}" ${noun} ${fromto} version "${target}" for database "${this.config.onetable.name}" using profile "${this.config.profile}".`) | ||
print(`\nMigrations to ${direction < 0 ? 'revert' : 'apply'}:`) | ||
print(`Confirm ${versions.length} "${cmd}" ${noun} ${fromto} version "${target}" for database "${this.config.onetable.name}" using profile "${this.config.profile}".`) | ||
print(`\nMigrations to ${cmd == 'downgrade' ? 'revert' : 'apply'}:`) | ||
for (let version of versions) { | ||
@@ -402,3 +435,5 @@ print(`${version}`) | ||
let arg = argv[i] | ||
if (arg == '--aws-access-key') { | ||
if (arg == '--arn') { | ||
this.arn = argv[++i] | ||
} else if (arg == '--aws-access-key') { | ||
this.aws.accessKeyId = argv[++i] | ||
@@ -432,2 +467,4 @@ } else if (arg == '--aws-secret-key') { | ||
this.quiet = true | ||
} else if (arg == '--table') { | ||
this.table = argv[++i] | ||
} else if (arg == '--verbose' || arg == '-v') { | ||
@@ -437,3 +474,3 @@ this.verbosity = true | ||
await this.printVersion() | ||
} else if (arg[0] == '-' || arg.indexOf('-') >= 0) { | ||
} else if (arg[0] == '-') { | ||
this.usage() | ||
@@ -453,5 +490,9 @@ } else { | ||
async getConfig() { | ||
let migrateConfig = this.migrateConfig || 'migrate.json' | ||
let migrateConfig = this.migrateConfig || 'migrate.json5' | ||
if (!Fs.existsSync(migrateConfig)) { | ||
error(`Cannot locate ${migrateConfig}`) | ||
// LEGACY | ||
migrateConfig = 'migrate.json' | ||
if (!Fs.existsSync(migrateConfig)) { | ||
error(`Cannot locate migrate.json5`) | ||
} | ||
} | ||
@@ -522,8 +563,8 @@ let index, profile | ||
async apply(direction, version, params = {}) { | ||
return await this.invoke('apply', {direction, version, params}) | ||
async apply(action, version, params = {}) { | ||
return await this.invoke('apply', {action, version, params}) | ||
} | ||
async findPastMigrations() { | ||
return await this.invoke('findPastMigrations') | ||
async getPastMigrations() { | ||
return await this.invoke('getPastMigrations') | ||
} | ||
@@ -539,8 +580,12 @@ | ||
async invoke(action, args) { | ||
let params = {action, config: this.config} | ||
async getNamedMigrations(limit = Number.MAX_SAFE_INTEGER) { | ||
return await this.invoke('getNamedMigrations') | ||
} | ||
async invoke(cmd, args) { | ||
let params = {cmd, config: this.config} | ||
if (args) { | ||
params.args = args | ||
} | ||
this.debug(`Invoke migrate proxy`, {action, args, arn: this.arn}) | ||
this.debug(`Invoke migrate proxy`, {cmd, args, arn: this.arn}) | ||
@@ -557,8 +602,8 @@ let payload = JSON.stringify(params, null, 2) | ||
if (result.StatusCode != 200) { | ||
error(`Cannot invoke ${action}: bad status code ${result.StatusCode}`) | ||
error(`Cannot invoke ${cmd}: bad status code ${result.StatusCode}`) | ||
} else if (result && result.Payload) { | ||
result = JSON.parse(result.Payload) | ||
if (result.errorMessage) { | ||
error(`Cannot invoke ${action}: ${result.errorMessage}`) | ||
if (result.error) { | ||
error(`Cannot invoke ${cmd}: ${result.error}`) | ||
} else { | ||
@@ -568,3 +613,3 @@ result = result.body | ||
} else { | ||
error(`Cannot invoke ${action}: no result`) | ||
error(`Cannot invoke ${cmd}: no result`) | ||
} | ||
@@ -571,0 +616,0 @@ this.debug(`Migrate proxy results`, {args, result}) |
83112
1697
369
Updatedaws-sdk@^2.1455.0
Updateddynamodb-onetable@^2.7
Updatedjson5@^2.2.3
Updatedonetable-migrate@^1.2.0
Updatedsemver@^7.5.4