Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 0 additions & 30 deletions .eslintrc.json

This file was deleted.

18 changes: 10 additions & 8 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,31 +10,33 @@ jobs:
runs-on: ubuntu-latest
if: ${{ github.event_name != 'pull_request' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
name: Checkout [${{ github.ref_name }}]
with:
fetch-depth: 0
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: '16'
node-version: '24'
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npm run test --ci --coverage --maxWorkers=2
- run: npm run test -- --ci --coverage --maxWorkers=2
pr:
runs-on: ubuntu-latest
if: ${{ github.event_name == 'pull_request' }}
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{github.event.pull_request.head.repo.full_name}}
fetch-depth: 0
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
with:
node-version: '16'
node-version: '24'
cache: 'npm'
- run: npm ci
- run: npx commitlint --from ${{ github.event.pull_request.base.sha }} --to ${{ github.event.pull_request.head.sha }} --verbose
- run: npm run build
- run: npm run test --ci --coverage --maxWorkers=2
- run: npm run test -- --ci --coverage --maxWorkers=2
- run: npm run lint
- run: npm run prettier
9 changes: 5 additions & 4 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,22 @@ jobs:
name: Release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v4
name: Checkout [main]
with:
fetch-depth: 0
- uses: actions/setup-node@v3
- uses: actions/setup-node@v4
name: Setup Node.js
with:
node-version: 'lts/*'
node-version: '24'
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Build and test
run: |
rm -rf dist
npm run build
npm run test --ci --maxWorkers=2
npm run test -- --ci --maxWorkers=2
- name: Release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ Before you can build and test, you must install and configure the following prod

* [Git](https://git-scm.com/)

* [Node.js](https://nodejs.org), (version specified in the engines field of [`package.json`](./package.json)) which is used to run tests, and generate distributable files.
* [Node.js](https://nodejs.org) 24.x (or newer, as specified in the engines field of [`package.json`](./package.json)) which is used to run tests and generate distributable files.

## Getting the sources

Expand Down
119 changes: 84 additions & 35 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,27 @@ $ npm add @toolsplus/json-evolutions

### Example

Let's assume our app stores a configuration object and wants to evolve old values on read while always writing the latest version.

#### Version 0

Let's assume our app starts off with the following configuration record. Note, that the changelog is empty in the initial version (version 0) of the data.
Let's start with the initial version of the stored data. At version `0`, the changelog is empty because there are no migrations to apply yet.

We also define an [io-ts](https://github.com/gcanti/io-ts) `codec` that uses the `versioned` combinator included in this library. The `versioned` combinator injects the latest version value when data is encoded and drops the injected version value when data is decoded using the `io-ts` library. You neither are required to use `io-ts` nor the `versioned` combinator to use this library.
We also define an [io-ts](https://github.com/gcanti/io-ts) codec using the `versioned` combinator. The `versioned` combinator injects the latest `_version` when encoding and expects your strict `io-ts` codec to strip `_version` again when decoding. Using `io-ts` is optional, but it is a convenient way to keep the version marker as a storage concern instead of leaking it into the rest of the app.

```typescript
import * as E from "fp-ts/Either";
import * as t from "io-ts";
import {
createChangelog,
latestVersion,
versioned,
VersionedJsonObject,
versioned,
} from "@toolsplus/json-evolutions";

export const changelog = [];
export const changelog = E.getOrElseW((error) => {
throw new Error(error.message);
})(createChangelog());

export interface Configuration {
defaultFields: string[];
Expand All @@ -49,62 +55,69 @@ export const codec: t.Type<Configuration, VersionedJsonObject> = versioned(
t.strict({
defaultFields: t.array(t.string),
}),
latestVersion(changelog), // 0 as long as the changelog is empty
latestVersion(changelog),
);
```

Configuration records can now be written using
Configuration records can now be written using:

```typescript
codec.encode({defaultFields: ["field1", "field2"]})
codec.encode({defaultFields: ["field1", "field2"]});

// {_version: 0, defaultFields: ["field1", "field2"]}
```

Because our codec used the io-ts `versioned` combinator the latest version tag is included automatically into the written JSON record.
Because the codec uses `versioned`, the latest `_version` is injected automatically when encoding.

To read a previously stored configuration value we first use `evolve`. This will find the `_version` tag in the JSON record and decided which changesets need to be applied to the given data. In this case, there are no changesets so `evolve` will not do anything. Next, the data is passed to our io-ts `decode` function which will validate the given data and drop the `_version` tag (this is storage concern - code anywhere further upstream in our app should not know about it). Again, the decode step and using io-ts is optional.
To read a stored configuration value, first pass it through `evolve`. With an empty changelog there is nothing to migrate, so the value is returned unchanged. After that, decode it with the `io-ts` codec to validate the structure and drop `_version`.

```typescript
import * as E from "fp-ts/Either";
import {pipe} from "fp-ts/function";
import {evolve} from "@toolsplus/json-evolutions";

pipe(
{_version: 0, defaultFields: ["field1", "field2"]},
evolve(changelog),
E.map(codec.decode),
E.chain(codec.decode),
);
Comment on lines 79 to 83
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

In fp-ts 2.16.11 and io-ts 2.2.22, what are the TypeScript signatures of Either.chain, Either.chainW, and Either.flatMap? When combining Either<EvolutionError, A> with codec.decode from io-ts (Either<t.Errors, B>), which combinator preserves both left error types?

💡 Result:

fp-ts Either (v2.16.11) signatures

// Either.chain (alias of flatMap)
export declare const chain:
  <E, A, B>(f: (a: A) => Either<E, B>) =>
  (ma: Either<E, A>) => Either<E, B>

[1]

// Either.chainW (alias of flatMap; widens/merges Left types)
export declare const chainW:
  <E2, A, B>(f: (a: A) => Either<E2, B>) =>
  <E1>(ma: Either<E1, A>) => Either<E2 | E1, B>

[2]

// Either.flatMap (data-last + data-first overloads; merges Left types)
export declare const flatMap: {
  <A, E2, B>(f: (a: A) => Either<E2, B>):
    <E1>(ma: Either<E1, A>) => Either<E2 | E1, B>

  <E1, A, E2, B>(ma: Either<E1, A>, f: (a: A) => Either<E2, B>):
    Either<E1 | E2, B>
}

[3]

io-ts decode (v2.2.22) result type

decode: (i: I) => Either<Errors, A> // where Errors = t.Errors

[4]

Which combinator preserves both left error types?

If you have:

  • Either<EvolutionError, A> and
  • codec.decode(...) : Either<t.Errors, B>

…the combinator that preserves both Left error types is Either.chainW (equivalently, Either.flatMap), yielding:

Either<EvolutionError | t.Errors, B>.

Using plain Either.chain forces the same E on both sides, so it won’t keep EvolutionError and t.Errors as a union. [1][2][3]

Sources:
[1] (gcanti.github.io)
[2] (gcanti.github.io)
[3] (gcanti.github.io)
[4] (jsdocs.io)

Citations:


🏁 Script executed:

# Check README.md exists and examine the specific lines mentioned
file_lines=$(wc -l < README.md)
echo "README.md has $file_lines lines"

# Show lines 79-83
echo "=== Lines 79-83 ==="
sed -n '79,83p' README.md

# Show line 88
echo "=== Line 88 ==="
sed -n '88p' README.md

# Show lines 151-155
echo "=== Lines 151-155 ==="
sed -n '151,155p' README.md

# Search for all occurrences of E.chain in README
echo "=== All E.chain occurrences ==="
grep -n "E\.chain" README.md || echo "No E.chain found"

Repository: toolsplus/json-evolutions

Length of output: 866


Replace E.chain(codec.decode) with E.chainW(codec.decode) or E.flatMap(codec.decode) in code examples.

The examples use E.chain(codec.decode) where evolve() returns Either<EvolutionError, A> and codec.decode returns Either<t.Errors, B>. In fp-ts 2.16.11, Either.chain requires the left type parameter E to be identical on both sides, so this won't type-check. Use Either.chainW or Either.flatMap instead, which merge the left types into a union: Either<EvolutionError | t.Errors, B>.

Also applies to: lines 88, 151-155

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@README.md` around lines 79 - 83, The examples call E.chain(codec.decode)
where evolve(...) returns Either<EvolutionError, A> and codec.decode returns
Either<t.Errors, B>, causing a type mismatch; update the examples (the pipe
using evolve and codec.decode and other occurrences noted) to use
E.chainW(codec.decode) or E.flatMap(codec.decode) instead of E.chain so the left
error types are merged into Either<EvolutionError | t.Errors, B>; replace each
E.chain(codec.decode) instance (including the pipe snippet and the occurrences
around the listed lines) with E.chainW(codec.decode) or E.flatMap(codec.decode).


// {defaultFields: ["field1", "field2"]}
// Right({defaultFields: ["field1", "field2"]})
```

The example above is simplified for readability. The error types of `evolve` and `codec.decode` would probably have to adjusted to be compatible.
The important detail here is that `evolve` returns an `Either`, and `codec.decode` also returns an `Either`, so `E.chain(codec.decode)` keeps the two validation steps in the same error pipeline.

#### Version 1

When the configuration evolves we define one or more changesets that describe how to migrate configuration values with version 0 to version 1 (the now latest version). This library supports changesets written as[ JSON Patch instructions](http://jsonpatch.com/) or as an [immutability-helper](https://github.com/kolodny/immutability-helper) spec.
Now let's evolve the schema by adding a new `isEnabled` field. To do that, define a validated changelog containing a version `1` changeset. Changelog versions must be sequential and start at `1`, and `createChangelog` checks that rule up front.

```typescript
import * as E from "fp-ts/Either";
import * as t from "io-ts";
import {
createChangelog,
jsonPatchChangeset,
latestVersion,
VersionedJsonObject,
versioned,
jsonPatchChangeset,
VersionedJsonObject
} from "@toolsplus/json-evolutions";

const addIsEnabledField: JsonPatchChangeset = jsonPatchChangeset({
_version: 1,
patch: [
{
op: "add",
path: "/isEnabled",
value: true,
},
],
});

export const changelog: Changelog = [addIsEnabledField];
export const changelog = E.getOrElseW((error) => {
throw new Error(error.message);
})(
createChangelog(
jsonPatchChangeset({
_version: 1,
patch: [
{
op: "add",
path: "/isEnabled",
value: true,
},
],
}),
),
);

export interface Configuration {
defaultFields: string[];
Expand All @@ -120,38 +133,74 @@ export const codec: t.Type<Configuration, VersionedJsonObject> = versioned(
);
```

Reading and writing values works just as before. However, this time when writing a value version 1 will be injected:
Writing values still happens through `versioned`, which now injects version `1`:

```typescript
codec.encode({defaultFields: ["field1", "field2"], isEnabled: false})
codec.encode({defaultFields: ["field1", "field2"], isEnabled: false});

// {_version: 1, defaultFields: ["field1", "field2"], isEnabled: false}
```

To read a previously stored version 0 configuration value we again call `evolve`. It will find the `_version` tag in the JSON record and find that there is one changeset to be applied to migrate the given data to the latest version. The `isEnabled` property with the default value `true` will be added as described in the version 1 changelog. The `decode` step will work just as before.
Reading a previously stored version `0` value now goes through a strict migration boundary. `evolve` will validate the stored value, determine that version `1` is still pending, apply the configured changeset, and return the migrated shape. The codec decode step then validates the business shape and strips `_version`.

```typescript
import * as E from "fp-ts/Either";
import {pipe} from "fp-ts/function";
import {evolve} from "@toolsplus/json-evolutions";

pipe(
{_version: 0, defaultFields: ["field1", "field2"]},
evolve(changelog),
E.map(codec.decode),
E.chain(codec.decode),
);

// Right({defaultFields: ["field1", "field2"], isEnabled: true})
```

The example above is simplified for readability. The error types of `evolve` and `codec.decode` would probably have to adjusted to be compatible.
### Initializing older unversioned values

If your storage contains historic values from before `_version` existed, you can opt in to `initializeFromUnversioned`. The hook is only called when `_version` is missing and must return a valid version `0` stored value.

```typescript
import * as E from "fp-ts/Either";
import {evolve, InitializeFromUnversioned} from "@toolsplus/json-evolutions";

const initializeFromUnversioned: InitializeFromUnversioned = (input) => {
if (
typeof input !== "object" ||
input === null ||
Array.isArray(input) ||
!("defaultFields" in input)
) {
return E.left({
errorCode: "INVALID_STORED_VALUE_ERROR",
message: "Cannot initialize value.",
});
}

return E.right({
...(input as Record<string, unknown>),
_version: 0,
});
};

evolve(changelog, {initializeFromUnversioned})({
defaultFields: ["field1", "field2"],
});
```

In other words, `initializeFromUnversioned` is a one-time bridge from pre-versioned data into the normal versioned migration flow. Once the hook has returned a valid version `0` value, the regular changelog semantics apply.

### Rules

To make sure the concepts implemented in this library work as intended follow these rules when you code your evolutions:
To make sure the concepts implemented in this library work as intended, follow these rules when you code your evolutions:

* Existing changesets **must** never be changed after they have been shipped to production.
* New changesets **must** always have a sequentially increasing version number.
* Call `createChangelog` or `validateChangelog` once at startup and reuse the validated result.

### Limitations
### Assumptions

It is assumed that the JSON data is always a JSON object. Any JSON values other than JSON objects are not supported.
* Stored values must be JSON objects.
* `initializeFromUnversioned` is disabled by default and should only be used for known pre-versioning records.
* The library validates stored values and changelogs eagerly and returns an error instead of silently accepting unsupported shapes.
51 changes: 51 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import js from "@eslint/js";
import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import tsPlugin from "@typescript-eslint/eslint-plugin";
import eslintConfigPrettier from "eslint-config-prettier";

export default [
{
ignores: [
"build/**",
"coverage/**",
"node_modules/**",
"test-results/**",
],
},
js.configs.recommended,
...tsPlugin.configs["flat/recommended"].map((config) => ({
...config,
files: ["src/**/*.ts", "test/**/*.ts"],
})),
...tsPlugin.configs["flat/recommended-type-checked"].map((config) => ({
...config,
files: ["src/**/*.ts", "test/**/*.ts"],
languageOptions: {
...config.languageOptions,
parser: tsParser,
parserOptions: {
...config.languageOptions?.parserOptions,
project: "./tsconfig.eslint.json",
tsconfigRootDir: import.meta.dirname,
},
globals: {
...globals.node,
...globals.jest,
},
},
})),
{
files: ["src/**/*.ts", "test/**/*.ts"],
rules: {
"no-console": "error",
"@typescript-eslint/no-empty-object-type": [
"error",
{
allowInterfaces: "with-single-extends",
},
],
},
},
eslintConfigPrettier,
];
1 change: 0 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
setupFilesAfterEnv: ["@relmify/jest-fp-ts"],
collectCoverageFrom: ["src/**/*", "!src/**/*.spec.*"],
coverageDirectory: "coverage",
reporters: [
Expand Down
Loading
Loading