Skip to content

feat: redesign changelog validation and modernize the Node 24 toolchain#4

Merged
tbinna merged 1 commit intomainfrom
v2
Apr 8, 2026
Merged

feat: redesign changelog validation and modernize the Node 24 toolchain#4
tbinna merged 1 commit intomainfrom
v2

Conversation

@tbinna
Copy link
Copy Markdown
Member

@tbinna tbinna commented Apr 8, 2026

Introduce validated changelog creation, strict stored-value parsing, and initializeFromUnversioned for bootstrapping older records. Update the test suite, documentation, and build tooling to match the new migration flow and move the package to the modern TypeScript, ESLint, and Jest stack.

BREAKING CHANGE: evolve now expects a validated changelog and rejects missing, invalid, and future _version values by default; createChangelog/validateChangelog and initializeFromUnversioned replace the previous looser behavior, and the package now requires Node 24.

Summary by CodeRabbit

  • New Features

    • Changelog validation and creation helpers; optional initializer for unversioned data during migration
  • Bug Fixes

    • Expanded error variants and clearer failure reporting for migration/validation flows
  • Documentation

    • README updated with improved migration examples and Node.js requirement set to 24.x+
  • Chores

    • Tooling upgrades (Node/toolchain, TypeScript, Jest) and CI workflow updates; ESLint configuration migrated to the newer flat format
  • Tests

    • Expanded test coverage for changelog validation, parsing, initialization, and evolve scenarios

@tbinna
Copy link
Copy Markdown
Member Author

tbinna commented Apr 8, 2026

coderabbit

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 8, 2026

@tbinna Sure! I'll kick off a review of the PR now.

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 8, 2026

📝 Walkthrough

Walkthrough

Modernizes tooling and upgrades Node/dev-dependencies, replaces legacy ESLint config with a flat eslint.config.mjs, and adds changelog validation, stored-value parsing/initialization hooks, expanded evolution control flow, new error variants, and extensive test updates.

Changes

Cohort / File(s) Summary
ESLint config
/.eslintrc.json, eslint.config.mjs, tsconfig.eslint.json
Removed legacy .eslintrc.json; added flat eslint.config.mjs with JS/TypeScript/Jest rules, type-checked parser settings, and Prettier disables; updated tsconfig eslint comment.
CI / Release workflows
.github/workflows/ci.yml, .github/workflows/release.yml
Bumped GH Action versions (checkout→v4, setup-node→v4), set Node to 24, enabled npm caching, and adjusted test flag passing (npm run test -- ...).
Package & build
package.json, tsconfig.json, CONTRIBUTING.md, jest.config.js
Raised engines.node to >=24; upgraded TypeScript, Jest, ESLint and related deps; simplified lint scripts; changed tsconfig target/lib to ES2022; removed Jest setup file reference; made Node requirement explicit in CONTRIBUTING.
Core API surface
src/api/Changeset.ts, src/api/EvolutionError.ts
Added exported types: StoredValue, StoredValueV0, InitializeFromUnversioned, ValidChangelog (branded); made Changelog readonly; introduced multiple new EvolutionError variants.
Evolution logic
src/core/Evolutions.ts
Added validateChangelog/createChangelog, changed evolve signature to accept ValidChangelog and unknown, added EvolveOptions with optional initializer, implemented parse/initialization flow, deterministic version checks, and narrowed application of changesets; updated JSON-patch usage and error handling.
Tests & fixtures
test/core/Evolutions.spec.ts, test/core/__fixtures__/configuration/*
Removed @relmify/jest-fp-ts; replaced matcher helpers with explicit fp-ts Either assertions; added tests for changelog validation, parseStoredValue, initialization paths, unsupported future versions, and immutability; fixtures now use createChangelog() with Either unwrapping.
Documentation
README.md
Rewrote examples to use createChangelog() with Either handling, clarified _version encoding/decoding, documented InitializeFromUnversioned hook, and replaced "Limitations" with "Assumptions".

Sequence Diagram(s)

sequenceDiagram
    participant Caller
    participant Evolve as evolve()
    participant Validator as validateChangelog()
    participant Parser as parseStoredValueWithInitialization()
    participant Initializer as initializeFromUnversioned?
    participant Migrator as applyChangesets()

    Caller->>Evolve: call with (validChangelog, options?, unknownTarget)
    Evolve->>Validator: assume ValidChangelog (pre-validated)
    Evolve->>Parser: parseStoredValueWithInitialization(unknownTarget)
    alt target has _version
        Parser-->>Evolve: Right(StoredValue)
    else missing _version and initializer provided
        Parser->>Initializer: initializeFromUnversioned(input)
        Initializer-->>Parser: Either<Error, StoredValueV0>
        alt initializer succeeds
            Parser-->>Evolve: Right(StoredValueV0)
        else fails
            Parser-->>Evolve: Left(INITIALIZE_FROM_UNVERSIONED_FAILED_ERROR)
        end
    else missing _version and no initializer
        Parser-->>Evolve: Left(MISSING_VERSION_ERROR)
    end
    Evolve->>Evolve: check stored version ≤ latest
    alt stored version > latest
        Evolve-->>Caller: Left(UNSUPPORTED_FUTURE_VERSION_ERROR)
    else apply needed changesets
        Evolve->>Migrator: applyChangesets(from storedVersion+1 to latest)
        Migrator-->>Evolve: Right(updated StoredValue)
        Evolve-->>Caller: Right(StoredValue with latest _version)
    end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Poem

🐰 The linting burrow got a brand new map,
Node twenty-four now powers my nap,
Changelogs checked in order, neat and sure,
Unversioned seedlings get a gentle cure,
Hops and patches—evolutions pure! 🌿

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main objectives of the PR: changelog validation redesign and Node 24 toolchain modernization.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch v2

Warning

Review ran into problems

🔥 Problems

Timed out fetching pipeline failures after 30000ms


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@tbinna
Copy link
Copy Markdown
Member Author

tbinna commented Apr 8, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 8, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (3)
.github/workflows/release.yml (1)

27-27: ⚠️ Potential issue | 🟡 Minor

Verify --ci flag is passed to Jest correctly.

The --ci and --maxWorkers flags are passed to npm run, not directly to Jest. npm does not forward these flags to the underlying script unless you use -- separator: npm run test -- --ci --maxWorkers=2.

Proposed fix
-          npm run test --ci --maxWorkers=2
+          npm run test -- --ci --maxWorkers=2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/release.yml at line 27, The workflow is incorrectly
passing Jest flags to npm instead of to the test script; update the release
job's test step to forward flags by adding the `--` separator so the command
becomes `npm run test -- --ci --maxWorkers=2`, ensuring `--ci` and
`--maxWorkers` are passed to Jest (change the line that currently reads `npm run
test --ci --maxWorkers=2`).
.github/workflows/ci.yml (1)

40-40: ⚠️ Potential issue | 🟡 Minor

Same issue: flags not forwarded to Jest.

Proposed fix
-      - run: npm run test --ci --coverage --maxWorkers=2
+      - run: npm run test -- --ci --coverage --maxWorkers=2
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.github/workflows/ci.yml at line 40, The CI job is invoking "npm run test
--ci --coverage --maxWorkers=2" which does not forward those flags to Jest;
update the run step to forward arguments by inserting "--" so it becomes "npm
run test -- --ci --coverage --maxWorkers=2" (or alternatively call Jest
directly, e.g., "npx jest --ci --coverage --maxWorkers=2") to ensure the --ci,
--coverage and --maxWorkers flags reach the test runner.
src/core/Evolutions.ts (1)

49-82: ⚠️ Potential issue | 🟠 Major

Add runtime validation that changesets produce JSON objects before treating them as StoredValue.

Both applyPatch(...).newDocument and update(t, cs.spec) can return primitives or non-object values. When this occurs, the subsequent {...result, _version: latest} spread silently fabricates an object instead of rejecting the invalid state. This bypasses the stored-value invariant.

Use isRecord(result) in an E.chain to validate the result is an object before treating it as a StoredValue. Apply this to both the JSON_PATCH_CHANGESET and IMMUTABILITY_HELPER_CHANGESET cases.

💡 Suggested hardening
             case "JSON_PATCH_CHANGESET":
-                return E.tryCatch(
-                    () => applyPatch(t, cs.patch, true, false).newDocument,
-                    (e) =>
-                        e instanceof JsonPatchError
-                            ? {
-                                  errorCode: "JSON_PATCH_EVOLUTION_ERROR",
-                                  message: `Failed to apply JSON patch changeset with version ${cs._version}: ${e.message}`,
-                                  error: e,
-                              }
-                            : {
-                                  errorCode: "UNEXPECTED_EVOLUTION_ERROR",
-                                  message: `Unexpected JSON patch error: ${String(
-                                      e,
-                                  )}`,
-                              },
-                );
+                return pipe(
+                    E.tryCatch(
+                        () => applyPatch(t, cs.patch, true, false).newDocument,
+                        (e) =>
+                            e instanceof JsonPatchError
+                                ? {
+                                      errorCode: "JSON_PATCH_EVOLUTION_ERROR",
+                                      message: `Failed to apply JSON patch changeset with version ${cs._version}: ${e.message}`,
+                                      error: e,
+                                  }
+                                : {
+                                      errorCode: "UNEXPECTED_EVOLUTION_ERROR",
+                                      message: `Unexpected JSON patch error: ${String(
+                                          e,
+                                      )}`,
+                                  },
+                    ),
+                    E.chain((result) =>
+                        isRecord(result)
+                            ? E.right(result as StoredValue)
+                            : E.left({
+                                  errorCode: "INVALID_STORED_VALUE_ERROR",
+                                  message: `Changeset version ${cs._version} must produce a JSON object.`,
+                              }),
+                    ),
+                );
             case "IMMUTABILITY_HELPER_CHANGESET":
-                return E.tryCatch(
-                    () => update(t, cs.spec) as StoredValue,
-                    (e) =>
-                        e instanceof Error
-                            ? {
-                                  errorCode:
-                                      "IMMUTABILITY_HELPER_EVOLUTION_ERROR",
-                                  message: `Failed to apply Immutability helper changeset with version ${cs._version}: ${e.message}`,
-                                  error: e,
-                              }
-                            : {
-                                  errorCode: "UNEXPECTED_EVOLUTION_ERROR",
-                                  message: `Unexpected immutability-helper error: ${String(
-                                      e,
-                                  )}`,
-                              },
-                );
+                return pipe(
+                    E.tryCatch(
+                        () => update(t, cs.spec),
+                        (e) =>
+                            e instanceof Error
+                                ? {
+                                      errorCode:
+                                          "IMMUTABILITY_HELPER_EVOLUTION_ERROR",
+                                      message: `Failed to apply Immutability helper changeset with version ${cs._version}: ${e.message}`,
+                                      error: e,
+                                  }
+                                : {
+                                      errorCode: "UNEXPECTED_EVOLUTION_ERROR",
+                                      message: `Unexpected immutability-helper error: ${String(
+                                          e,
+                                      )}`,
+                                  },
+                    ),
+                    E.chain((result) =>
+                        isRecord(result)
+                            ? E.right(result as StoredValue)
+                            : E.left({
+                                  errorCode: "INVALID_STORED_VALUE_ERROR",
+                                  message: `Changeset version ${cs._version} must produce a JSON object.`,
+                              }),
+                    ),
+                );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/Evolutions.ts` around lines 49 - 82, The JSON_PATCH_CHANGESET and
IMMUTABILITY_HELPER_CHANGESET branches currently return raw results from
E.tryCatch (applyPatch(...).newDocument and update(t, cs.spec)) which may be
primitives; add an E.chain after each E.tryCatch to validate the result with
isRecord(result) and reject with a suitable error object if it is not an object,
otherwise cast/return it as StoredValue; update the branches that reference
applyPatch(...).newDocument and update(t, cs.spec) to use
E.tryCatch(...).chain(result => isRecord(result) ? E.right(result as
StoredValue) : E.left({ errorCode: "INVALID_STORED_VALUE", message: `Changeset
version ${cs._version} did not produce an object`, original: result })); ensure
this validation is applied for both JSON_PATCH_CHANGESET and
IMMUTABILITY_HELPER_CHANGESET.
🧹 Nitpick comments (2)
eslint.config.mjs (1)

17-46: Consider using typescript-eslint's flat config helpers.

The manual rule composition accessing tsPlugin.configs["eslint-recommended"].overrides?.[0]?.rules is fragile as it relies on the internal structure. The typescript-eslint package v8+ (currently installed as v8.43.0) exports flat config helpers that provide a more robust alternative while maintaining type-aware linting via projectService. The optional chaining provides a safe fallback, so the current setup works, but the refactoring would simplify the configuration.

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

In `@eslint.config.mjs` around lines 17 - 46, Replace the fragile manual rule
composition that references
tsPlugin.configs["eslint-recommended"].overrides?.[0]?.rules with
typescript-eslint's flat config helper getESLintFlatConfigForTypeScript
(exported from the package) to produce the TypeScript-aware ruleset; call
getESLintFlatConfigForTypeScript with the same parserOptions (project:
"./tsconfig.eslint.json", tsconfigRootDir: import.meta.dirname) and then merge
its returned config into your exported array/object alongside your files filter,
languageOptions (parser: tsParser), plugins, and custom rules like "no-console"
and "@typescript-eslint/no-empty-object-type"; this removes the brittle
tsPlugin.configs access and keeps the same type-checked linting via
projectService.
src/core/Evolutions.ts (1)

177-177: Freeze the sorted changelog before branding it.

ValidChangelog is readonly only at the type level today; in plain JS the returned array can still be mutated after validation and break the invariant that evolve() trusts. Freezing the sorted copy would make the runtime contract match the API.

💡 Suggested hardening
-    return E.right((sorted as unknown) as ValidChangelog);
+    return E.right(
+        Object.freeze(sorted) as unknown as ValidChangelog,
+    );
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/core/Evolutions.ts` at line 177, The returned sorted changelog is only
readonly at the TS type level but remains mutable at runtime; before branding
and returning it as a ValidChangelog in the function that currently returns
E.right((sorted as unknown) as ValidChangelog), freeze the runtime structure
(e.g., call Object.freeze on the sorted array and, if entries are plain objects,
freeze each entry as well) so evolve() and other consumers cannot mutate it;
update the return to brand and return the frozen object (use the existing sorted
variable and the ValidChangelog cast).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In @.github/workflows/ci.yml:
- Line 23: The npm run invocation is not forwarding flags to the underlying Jest
script; update the CI job command that currently reads the run step invoking
"npm run test --ci --coverage --maxWorkers=2" to include the "--" separator so
the flags are passed through to Jest (i.e., change the run command to use "npm
run test -- --ci --coverage --maxWorkers=2").

In `@src/core/Evolutions.ts`:
- Around line 123-130: The call to initializeFromUnversioned(input) can throw
synchronously and escape the Either flow; wrap the invocation in an Either-safe
try/catch (e.g., fp-ts E.tryCatch or an explicit try/catch that returns a Left)
so thrown exceptions become a Left and then map that Left to the same shape used
by the existing mapLeft block; update the pipe around
initializeFromUnversioned(input) (the call used before E.mapLeft and
E.chain(ensureInitializedValue)) to use the try-catch wrapper so all thrown
errors are converted to an initializeError and handled via the existing
INITIALIZE_FROM_UNVERSIONED_FAILED_ERROR mapping.

---

Outside diff comments:
In @.github/workflows/ci.yml:
- Line 40: The CI job is invoking "npm run test --ci --coverage --maxWorkers=2"
which does not forward those flags to Jest; update the run step to forward
arguments by inserting "--" so it becomes "npm run test -- --ci --coverage
--maxWorkers=2" (or alternatively call Jest directly, e.g., "npx jest --ci
--coverage --maxWorkers=2") to ensure the --ci, --coverage and --maxWorkers
flags reach the test runner.

In @.github/workflows/release.yml:
- Line 27: The workflow is incorrectly passing Jest flags to npm instead of to
the test script; update the release job's test step to forward flags by adding
the `--` separator so the command becomes `npm run test -- --ci --maxWorkers=2`,
ensuring `--ci` and `--maxWorkers` are passed to Jest (change the line that
currently reads `npm run test --ci --maxWorkers=2`).

In `@src/core/Evolutions.ts`:
- Around line 49-82: The JSON_PATCH_CHANGESET and IMMUTABILITY_HELPER_CHANGESET
branches currently return raw results from E.tryCatch
(applyPatch(...).newDocument and update(t, cs.spec)) which may be primitives;
add an E.chain after each E.tryCatch to validate the result with
isRecord(result) and reject with a suitable error object if it is not an object,
otherwise cast/return it as StoredValue; update the branches that reference
applyPatch(...).newDocument and update(t, cs.spec) to use
E.tryCatch(...).chain(result => isRecord(result) ? E.right(result as
StoredValue) : E.left({ errorCode: "INVALID_STORED_VALUE", message: `Changeset
version ${cs._version} did not produce an object`, original: result })); ensure
this validation is applied for both JSON_PATCH_CHANGESET and
IMMUTABILITY_HELPER_CHANGESET.

---

Nitpick comments:
In `@eslint.config.mjs`:
- Around line 17-46: Replace the fragile manual rule composition that references
tsPlugin.configs["eslint-recommended"].overrides?.[0]?.rules with
typescript-eslint's flat config helper getESLintFlatConfigForTypeScript
(exported from the package) to produce the TypeScript-aware ruleset; call
getESLintFlatConfigForTypeScript with the same parserOptions (project:
"./tsconfig.eslint.json", tsconfigRootDir: import.meta.dirname) and then merge
its returned config into your exported array/object alongside your files filter,
languageOptions (parser: tsParser), plugins, and custom rules like "no-console"
and "@typescript-eslint/no-empty-object-type"; this removes the brittle
tsPlugin.configs access and keeps the same type-checked linting via
projectService.

In `@src/core/Evolutions.ts`:
- Line 177: The returned sorted changelog is only readonly at the TS type level
but remains mutable at runtime; before branding and returning it as a
ValidChangelog in the function that currently returns E.right((sorted as
unknown) as ValidChangelog), freeze the runtime structure (e.g., call
Object.freeze on the sorted array and, if entries are plain objects, freeze each
entry as well) so evolve() and other consumers cannot mutate it; update the
return to brand and return the frozen object (use the existing sorted variable
and the ValidChangelog cast).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d6f583c9-4efb-4d9e-83a0-b44c86c49d33

📥 Commits

Reviewing files that changed from the base of the PR and between f9d21f3 and 28eef72.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (17)
  • .eslintrc.json
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • CONTRIBUTING.md
  • README.md
  • eslint.config.mjs
  • jest.config.js
  • package.json
  • src/api/Changeset.ts
  • src/api/EvolutionError.ts
  • src/core/Evolutions.ts
  • test/core/Evolutions.spec.ts
  • test/core/__fixtures__/configuration/v0.ts
  • test/core/__fixtures__/configuration/v1.ts
  • test/core/__fixtures__/configuration/v2.ts
  • tsconfig.eslint.json
  • tsconfig.json
💤 Files with no reviewable changes (2)
  • jest.config.js
  • .eslintrc.json

Introduce validated changelog creation, strict stored-value parsing, and initializeFromUnversioned for bootstrapping older records. Update the test suite, documentation, and build tooling to match the new migration flow and move the package to the modern TypeScript, ESLint, and Jest stack.

BREAKING CHANGE: evolve now expects a validated changelog and rejects missing, invalid, and future _version values by default; createChangelog/validateChangelog and initializeFromUnversioned replace the previous looser behavior, and the package now requires Node 24.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
src/core/Evolutions.ts (1)

138-145: ⚠️ Potential issue | 🟠 Major

Keep initializeFromUnversioned inside the Either boundary.

initializeFromUnversioned(input) is still invoked directly. If user code throws synchronously, parseStoredValueWithInitialization() throws instead of returning INITIALIZE_FROM_UNVERSIONED_FAILED_ERROR, so evolve() no longer honors its Either contract. Wrap the callback in a try/catch that converts thrown errors into that Left before the existing mapping and validation flow.

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

In `@src/core/Evolutions.ts` around lines 138 - 145, The code currently calls
initializeFromUnversioned(input) directly so any synchronous throw escapes the
Either flow; wrap that call in a try/catch (or use fp-ts/Either.tryCatch) to
convert thrown exceptions into a Left with the same shape used later (errorCode:
"INITIALIZE_FROM_UNVERSIONED_FAILED_ERROR", message: "Failed to initialize an
unversioned value.", error: the thrown error) and then pass that Either into the
existing pipe so E.mapLeft and E.chain(ensureInitializedValue) operate inside
the Either boundary (i.e., replace the direct initializeFromUnversioned(input)
invocation with a try/catch-produced Either before the rest of the pipe).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@README.md`:
- Around line 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).

In `@src/core/Evolutions.ts`:
- Around line 57-98: The switch on cs.type inside applyChangeset (the branch
handling "JSON_PATCH_CHANGESET" and "IMMUTABILITY_HELPER_CHANGESET") is not
exhaustive and can fall through for unknown cs.type values; add a default branch
that returns a Left-like E.left containing an error object with errorCode
"INVALID_CHANGELOG_ERROR" (and a helpful message referencing cs._version /
cs.type) so the function preserves the Either contract instead of returning
undefined—ensure the default mirrors the shape used by other errors and plays
nicely with ensureStoredValueResult and subsequent E.map calls.

---

Duplicate comments:
In `@src/core/Evolutions.ts`:
- Around line 138-145: The code currently calls initializeFromUnversioned(input)
directly so any synchronous throw escapes the Either flow; wrap that call in a
try/catch (or use fp-ts/Either.tryCatch) to convert thrown exceptions into a
Left with the same shape used later (errorCode:
"INITIALIZE_FROM_UNVERSIONED_FAILED_ERROR", message: "Failed to initialize an
unversioned value.", error: the thrown error) and then pass that Either into the
existing pipe so E.mapLeft and E.chain(ensureInitializedValue) operate inside
the Either boundary (i.e., replace the direct initializeFromUnversioned(input)
invocation with a try/catch-produced Either before the rest of the pipe).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: d6e69945-3a50-41cb-8e69-925864d69fae

📥 Commits

Reviewing files that changed from the base of the PR and between 28eef72 and e109d25.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (17)
  • .eslintrc.json
  • .github/workflows/ci.yml
  • .github/workflows/release.yml
  • CONTRIBUTING.md
  • README.md
  • eslint.config.mjs
  • jest.config.js
  • package.json
  • src/api/Changeset.ts
  • src/api/EvolutionError.ts
  • src/core/Evolutions.ts
  • test/core/Evolutions.spec.ts
  • test/core/__fixtures__/configuration/v0.ts
  • test/core/__fixtures__/configuration/v1.ts
  • test/core/__fixtures__/configuration/v2.ts
  • tsconfig.eslint.json
  • tsconfig.json
💤 Files with no reviewable changes (2)
  • jest.config.js
  • .eslintrc.json
✅ Files skipped from review due to trivial changes (4)
  • tsconfig.json
  • tsconfig.eslint.json
  • CONTRIBUTING.md
  • src/api/EvolutionError.ts
🚧 Files skipped from review as they are similar to previous changes (7)
  • .github/workflows/release.yml
  • test/core/fixtures/configuration/v1.ts
  • eslint.config.mjs
  • src/api/Changeset.ts
  • test/core/fixtures/configuration/v2.ts
  • .github/workflows/ci.yml
  • test/core/Evolutions.spec.ts

Comment on lines 79 to 83
pipe(
{_version: 0, defaultFields: ["field1", "field2"]},
evolve(changelog),
E.map(codec.decode),
E.chain(codec.decode),
);
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).

Comment on lines 57 to 98
switch (cs.type) {
case "JSON_PATCH_CHANGESET":
return E.tryCatch(
() => applyPatch(t, cs.patch, true).newDocument,
(e) =>
e instanceof JsonPatchError
? {
errorCode: "JSON_PATCH_EVOLUTION_ERROR",
message: `Failed to apply JSON patch changeset with version ${cs._version}: ${e.message}`,
error: e,
}
: {
errorCode: "UNEXPECTED_EVOLUTION_ERROR",
message: `Unexpected JSON patch error: ${e}`,
},
return pipe(
E.tryCatch(
() => applyPatch(t, cs.patch, true, false).newDocument,
(e) =>
e instanceof JsonPatchError
? {
errorCode: "JSON_PATCH_EVOLUTION_ERROR" as const,
message: `Failed to apply JSON patch changeset with version ${cs._version}: ${e.message}`,
error: e,
}
: {
errorCode: "UNEXPECTED_EVOLUTION_ERROR" as const,
message: `Unexpected JSON patch error: ${String(
e,
)}`,
},
),
E.chain(ensureStoredValueResult(cs._version)),
);
case "IMMUTABILITY_HELPER_CHANGESET":
return E.tryCatch(
() => update(t, cs.spec),
(e) =>
e instanceof Error
? {
errorCode:
"IMMUTABILITY_HELPER_EVOLUTION_ERROR",
message: `Failed to apply Immutability helper changeset with version ${cs._version}: ${e.message}`,
error: e,
}
: {
errorCode: "UNEXPECTED_EVOLUTION_ERROR",
message: `Unexpected immutability-helper error: ${e}`,
},
return pipe(
E.tryCatch(
() => update(t, cs.spec) as unknown,
(e) =>
e instanceof Error
? {
errorCode: "IMMUTABILITY_HELPER_EVOLUTION_ERROR" as const,
message: `Failed to apply Immutability helper changeset with version ${cs._version}: ${e.message}`,
error: e,
}
: {
errorCode: "UNEXPECTED_EVOLUTION_ERROR" as const,
message: `Unexpected immutability-helper error: ${String(
e,
)}`,
},
),
E.chain(ensureStoredValueResult(cs._version)),
);
}
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 | 🟠 Major

Return a Left for unsupported changeset types.

This switch is only exhaustive for typed callers. Because validateChangelog() currently brands based on version checks alone, a malformed changelog from JS or an unsafe cast can still reach this branch; in that case applyChangeset() falls off the end and the next E.map throws on undefined instead of preserving the Either contract. Add a default branch that returns INVALID_CHANGELOG_ERROR.

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

In `@src/core/Evolutions.ts` around lines 57 - 98, The switch on cs.type inside
applyChangeset (the branch handling "JSON_PATCH_CHANGESET" and
"IMMUTABILITY_HELPER_CHANGESET") is not exhaustive and can fall through for
unknown cs.type values; add a default branch that returns a Left-like E.left
containing an error object with errorCode "INVALID_CHANGELOG_ERROR" (and a
helpful message referencing cs._version / cs.type) so the function preserves the
Either contract instead of returning undefined—ensure the default mirrors the
shape used by other errors and plays nicely with ensureStoredValueResult and
subsequent E.map calls.

@tbinna tbinna merged commit 9765236 into main Apr 8, 2026
5 checks passed
@github-actions
Copy link
Copy Markdown

github-actions bot commented Apr 8, 2026

🎉 This PR is included in version 2.0.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant