From 931ffce6bc653b7395ad52224b3c9c3e312d1045 Mon Sep 17 00:00:00 2001 From: Rafael Ramalho Date: Mon, 9 Sep 2019 07:36:09 +0100 Subject: [PATCH] Add support for migrations (#83) Co-authored-by: Sindre Sorhus --- index.d.ts | 34 ++++++++++- index.js | 114 +++++++++++++++++++++++++++++++++-- package.json | 2 + readme.md | 44 ++++++++++++-- test.js | 163 +++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 348 insertions(+), 9 deletions(-) diff --git a/index.d.ts b/index.d.ts index 9310d0d..a94b204 100644 --- a/index.d.ts +++ b/index.d.ts @@ -57,10 +57,42 @@ declare namespace Conf { readonly configName?: string; /** - You only need to specify this if you don't have a `package.json` file in your project. Default: The name field in the `package.json` closest to where `conf` is imported. + You only need to specify this if you don't have a package.json file in your project or if it doesn't have a name defined within it. Default: The name field in the `package.json` closest to where `conf` is imported. */ readonly projectName?: string; + /** + You only need to specify this if you don't have a package.json file in your project or if it doesn't have a version defined within it. Default: The name field in the `package.json` closest to where `conf` is imported. + */ + readonly projectVersion?: string; + + /* + You can use migrations to perform operations to the store whenever a version is switched. + + The `migrations` object should be consisted of a key-value pair of `version`: `handler`. + + The `projectVersion` option should be specified in order for the migrations to be run. + + @example + ``` + const store = new Conf({ + migrations: { + '0.0.1': store => { + store.set('debug phase', true); + }, + '1.0.0': store => { + store.delete('debug phase'); + store.set('phase', '1.0'); + }, + '1.0.2': store => { + store.set('phase', '>1.0'); + } + } + }); + ``` + */ + readonly migrations?: {[key: string]: JSONSchema}; + /** __You most likely don't need this. Please don't use it unless you really have to.__ diff --git a/index.js b/index.js index 3da9cff..d06609b 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,8 @@ const pkgUp = require('pkg-up'); const envPaths = require('env-paths'); const writeFileAtomic = require('write-file-atomic'); const Ajv = require('ajv'); +const semver = require('semver'); +const onetime = require('onetime'); const plainObject = () => Object.create(null); const encryptionAlgorithm = 'aes-256-cbc'; @@ -33,6 +35,9 @@ const checkValueType = (key, value) => { } }; +const INTERNAL_KEY = '__internal__'; +const MIGRATION_KEY = `${INTERNAL_KEY}.migrations.version`; + class Conf { constructor(options) { options = { @@ -46,12 +51,18 @@ class Conf { ...options }; + const getPackageData = onetime(() => { + const packagePath = pkgUp.sync(parentDir); + // Can't use `require` because of Webpack being annoying: + // https://github.com/webpack/webpack/issues/196 + const packageData = packagePath && JSON.parse(fs.readFileSync(packagePath, 'utf8')); + + return packageData || {}; + }); + if (!options.cwd) { if (!options.projectName) { - const pkgPath = pkgUp.sync(parentDir); - // Can't use `require` because of Webpack being annoying: - // https://github.com/webpack/webpack/issues/196 - options.projectName = pkgPath && JSON.parse(fs.readFileSync(pkgPath, 'utf8')).name; + options.projectName = getPackageData().name; } if (!options.projectName) { @@ -97,6 +108,18 @@ class Conf { } catch (_) { this.store = store; } + + if (options.migrations) { + if (!options.projectVersion) { + options.projectVersion = getPackageData().version; + } + + if (!options.projectVersion) { + throw new Error('Project version could not be inferred. Please specify the `projectVersion` option.'); + } + + this._migrate(options.migrations, options.projectVersion); + } } _validate(data) { @@ -112,6 +135,85 @@ class Conf { } } + _migrate(migrations, versionToMigrate) { + let previousMigratedVersion = this._get(MIGRATION_KEY, '0.0.0'); + + const newerVersions = Object.keys(migrations) + .filter(candidateVersion => this._shouldPerformMigration(candidateVersion, previousMigratedVersion, versionToMigrate)) + .sort(semver.compare); + + let storeBackup = {...this.store}; + + for (const version of newerVersions) { + try { + const migration = migrations[version]; + migration(this); + + this._set(MIGRATION_KEY, version); + + previousMigratedVersion = version; + storeBackup = {...this.store}; + } catch (error) { + this.store = storeBackup; + + throw new Error( + `Something went wrong during the migration! Changes applied to the store until this failed migration will be restored. ${error}` + ); + } + } + + if (!semver.eq(previousMigratedVersion, versionToMigrate)) { + this._set(MIGRATION_KEY, versionToMigrate); + } + } + + _containsReservedKey(key) { + if (typeof key === 'object') { + const firstKey = Object.keys(key)[0]; + + if (firstKey === INTERNAL_KEY) { + return true; + } + } + + if (typeof key !== 'string') { + return false; + } + + if (this._options.accessPropertiesByDotNotation) { + if (key.startsWith(`${INTERNAL_KEY}.`)) { + return true; + } + + return false; + } + + return false; + } + + _shouldPerformMigration(candidateVersion, previousMigratedVersion, versionToMigrate) { + if (semver.lte(candidateVersion, previousMigratedVersion)) { + return false; + } + + if (semver.gt(candidateVersion, versionToMigrate)) { + return false; + } + + return true; + } + + _get(key, defaultValue) { + return dotProp.get(this.store, key, defaultValue); + } + + _set(key, value) { + const {store} = this; + dotProp.set(store, key, value); + + this.store = store; + } + get(key, defaultValue) { if (this._options.accessPropertiesByDotNotation) { return dotProp.get(this.store, key, defaultValue); @@ -129,6 +231,10 @@ class Conf { throw new TypeError('Use `delete()` to clear values'); } + if (this._containsReservedKey(key)) { + throw new TypeError(`Please don't use the ${INTERNAL_KEY} key, as it's used to manage this module internal operations.`); + } + const {store} = this; const set = (key, value) => { diff --git a/package.json b/package.json index fc94ca6..2c9cb93 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,9 @@ "env-paths": "^2.2.0", "json-schema-typed": "^7.0.0", "make-dir": "^3.0.0", + "onetime": "^5.1.0", "pkg-up": "^3.0.1", + "semver": "^6.2.0", "write-file-atomic": "^3.0.0" }, "devDependencies": { diff --git a/readme.md b/readme.md index abac394..666bf13 100644 --- a/readme.md +++ b/readme.md @@ -48,11 +48,11 @@ Returns a new instance. ### options -Type: `Object` +Type: `object` #### defaults -Type: `Object` +Type: `object` Default values for the config items. @@ -60,7 +60,7 @@ Default values for the config items. #### schema -Type: `Object` +Type: `object` [JSON Schema](https://json-schema.org) to validate your config data. @@ -97,6 +97,35 @@ config.set('foo', '1'); **Note:** The `default` value will be overwritten by the `defaults` option if set. +### migrations + +Type: `object` + +You can use migrations to perform operations to the store whenever a version is upgraded. + +The `migrations` object should be consisted of a key-value pair of `version`: `handler`. + +**Note**: The [`projectVersion`](#projectversion) option should be specified in order for the migrations to be run. + +Example: + +```js +const store = new Conf({ + migrations: { + '0.0.1': store => { + store.set('debug phase', true); + }, + '1.0.0': store => { + store.delete('debug phase'); + store.set('phase', '1.0'); + }, + '1.0.2': store => { + store.set('phase', '>1.0'); + } + } +}); +``` + #### configName Type: `string`
@@ -111,7 +140,14 @@ Useful if you need multiple config files for your app or module. For example, di Type: `string`
Default: The `name` field in the package.json closest to where `conf` is imported. -You only need to specify this if you don't have a package.json file in your project. +You only need to specify this if you don't have a package.json file in your project or if it doesn't have a name defined within it. + +#### projectVersion + +Type: `string`
+Default: The `version` field in the package.json closest to where `conf` is imported. + +You only need to specify this if you don't have a package.json file in your project or if it doesn't have a version defined within it. #### cwd diff --git a/test.js b/test.js index cd24af7..036269c 100644 --- a/test.js +++ b/test.js @@ -716,3 +716,166 @@ test('.delete() - without dot notation', t => { configWithoutDotNotation.delete('foo.bar.baz'); t.deepEqual(configWithoutDotNotation.get('foo.bar.zoo'), {awesome: 'redpanda'}); }); + +test('migrations - should save the project version as the initial migrated version', t => { + const cwd = tempy.directory(); + + const conf = new Conf({cwd, projectVersion: '0.0.2', migrations: {}}); + + t.is(conf._get('__internal__.migrations.version'), '0.0.2'); +}); + +test('migrations - should save the project version when a migration occurs', t => { + const cwd = tempy.directory(); + + const migrations = { + '0.0.3': store => { + store.set('foo', 'cool stuff'); + } + }; + + const conf = new Conf({cwd, projectVersion: '0.0.2', migrations}); + + t.is(conf._get('__internal__.migrations.version'), '0.0.2'); + + const conf2 = new Conf({cwd, projectVersion: '0.0.4', migrations}); + + t.is(conf2._get('__internal__.migrations.version'), '0.0.4'); + t.is(conf2.get('foo'), 'cool stuff'); +}); + +test('migrations - should NOT run the migration when the version doesn\'t change', t => { + const cwd = tempy.directory(); + + const migrations = { + '1.0.0': store => { + store.set('foo', 'cool stuff'); + } + }; + + const conf = new Conf({cwd, projectVersion: '0.0.2', migrations}); + t.is(conf._get('__internal__.migrations.version'), '0.0.2'); + t.false(conf.has('foo')); + + const conf2 = new Conf({cwd, projectVersion: '0.0.2', migrations}); + + t.is(conf2._get('__internal__.migrations.version'), '0.0.2'); + t.false(conf2.has('foo')); +}); + +test('migrations - should run the migration when the version changes', t => { + const cwd = tempy.directory(); + + const migrations = { + '1.0.0': store => { + store.set('foo', 'cool stuff'); + } + }; + + const conf = new Conf({cwd, projectVersion: '0.0.2', migrations}); + t.is(conf._get('__internal__.migrations.version'), '0.0.2'); + t.false(conf.has('foo')); + + const conf2 = new Conf({cwd, projectVersion: '1.1.0', migrations}); + + t.is(conf2._get('__internal__.migrations.version'), '1.1.0'); + t.true(conf2.has('foo')); + t.is(conf2.get('foo'), 'cool stuff'); +}); + +test('migrations - should infer the applicationVersion from the package.json when it isn\'t specified', t => { + const cwd = tempy.directory(); + + const conf = new Conf({cwd, migrations: { + '2000.0.0': store => { + store.set('foo', 'bar'); + } + }}); + + t.false(conf.has('foo')); + t.is(conf._get('__internal__.migrations.version'), require('./package.json').version); +}); + +test('migrations - should NOT throw an error when project version is unspecified but there are no migrations', t => { + const cwd = tempy.directory(); + + t.notThrows(() => { + const conf = new Conf({cwd}); + conf.clear(); + }); +}); + +test('migrations - should not create the previous migration key if the migrations aren\'t needed', t => { + const cwd = tempy.directory(); + + const conf = new Conf({cwd}); + t.false(conf.has('__internal__.migrations.version')); +}); + +test('migrations error handling - should rollback changes if a migration failed', t => { + const cwd = tempy.directory(); + + const failingMigrations = { + '1.0.0': store => { + store.set('foo', 'initial update'); + }, + '1.0.1': store => { + store.set('foo', 'updated before crash'); + + throw new Error('throw the migration and rollback'); + + // eslint-disable-next-line no-unreachable + store.set('foo', 'can you reach here?'); + } + }; + + const passingMigrations = { + '1.0.0': store => { + store.set('foo', 'initial update'); + } + }; + + let conf = new Conf({cwd, projectVersion: '1.0.0', migrations: passingMigrations}); + + t.throws(() => { + conf = new Conf({cwd, projectVersion: '1.0.2', migrations: failingMigrations}); + }, /throw the migration and rollback/); + + t.is(conf._get('__internal__.migrations.version'), '1.0.0'); + t.true(conf.has('foo')); + t.is(conf.get('foo'), 'initial update'); +}); + +test('__internal__ keys - should not be accessible by the user', t => { + const cwd = tempy.directory(); + + const conf = new Conf({cwd}); + + t.throws(() => { + conf.set('__internal__.you-shall', 'not-pass'); + }, /Please don't use the __internal__ key/); +}); + +test('__internal__ keys - should not be accessible by the user even without dot notation', t => { + const cwd = tempy.directory(); + + const conf = new Conf({cwd, accessPropertiesByDotNotation: false}); + + t.throws(() => { + conf.set({ + __internal__: { + 'you-shall': 'not-pass' + } + }); + }, /Please don't use the __internal__ key/); +}); + +test('__internal__ keys - should only match specific "__internal__" entry', t => { + const cwd = tempy.directory(); + + const conf = new Conf({cwd}); + + t.notThrows(() => { + conf.set('__internal__foo.you-shall', 'not-pass'); + }); +});