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');
+ });
+});