Skip to content

Commit

Permalink
Add support for migrations (#83)
Browse files Browse the repository at this point in the history
Co-authored-by: Sindre Sorhus <[email protected]>
  • Loading branch information
rafaelramalho19 and sindresorhus committed Sep 9, 2019
1 parent ef63b11 commit 931ffce
Show file tree
Hide file tree
Showing 5 changed files with 348 additions and 9 deletions.
34 changes: 33 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.__
Expand Down
114 changes: 110 additions & 4 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -33,6 +35,9 @@ const checkValueType = (key, value) => {
}
};

const INTERNAL_KEY = '__internal__';
const MIGRATION_KEY = `${INTERNAL_KEY}.migrations.version`;

class Conf {
constructor(options) {
options = {
Expand All @@ -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) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
Expand All @@ -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) => {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
44 changes: 40 additions & 4 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,19 @@ Returns a new instance.

### options

Type: `Object`
Type: `object`

#### defaults

Type: `Object`
Type: `object`

Default values for the config items.

**Note:** The values in `defaults` will overwrite the `default` key in the `schema` option.

#### schema

Type: `Object`
Type: `object`

[JSON Schema](https://json-schema.org) to validate your config data.

Expand Down Expand Up @@ -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`<br>
Expand All @@ -111,7 +140,14 @@ Useful if you need multiple config files for your app or module. For example, di
Type: `string`<br>
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`<br>
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

Expand Down
Loading

0 comments on commit 931ffce

Please sign in to comment.