Skip to content

fix: avoid mutating caller/up-return objects with version key before validation#27

Open
ig-imanish wants to merge 1 commit into
Nano-Collective:mainfrom
ig-imanish:fix/version-key-mutation
Open

fix: avoid mutating caller/up-return objects with version key before validation#27
ig-imanish wants to merge 1 commit into
Nano-Collective:mainfrom
ig-imanish:fix/version-key-mutation

Conversation

@ig-imanish

Copy link
Copy Markdown

Description

Problem

The version key (_version or custom key) was being assigned directly to the object returned by up() before running safeParse on the augmented schema:

// Before (problematic)

result = migration.up(currentState);

if (result !== null && typeof result === "object") {

  (result as Record<string, unknown>)[key] = migration.version;   // <-- mutation here

}

const parseResult = schemaWithVersion.safeParse(result);

if (!parseResult.success) {

  throw new ValidationError(...);

}

This caused two issues:

  1. On ValidationError: the object produced by up() (or the original state when a mutating up did return state) would have the version key set even though the migration "failed".

  2. On the success path: the exact return value of up() was mutated as a side-effect (even though the final result came from Zod's parseResult.data).

This affects both migrate() and migrateAsync().

Solution

Instead of mutating the user's object, we now create a shallow copy only for the validation input:

  • For plain objects: { ...result, [key]: migration.version }

  • For arrays (defensive): [...result] + set the property on the copy

We then call safeParse(toValidate). The successfully migrated state still comes from parseResult.data (which already contains the version thanks to the .and(z.object({ [key]: literal })) schema).

Result:

  • No side effects on objects returned from up()

  • No side effects on the caller's original state

  • Even when up() returns the same reference as the previous state, that reference stays clean on error

Changes

  • src/migrate.ts — core fix in both migrate() and migrateAsync() + explanatory comments

  • src/migrate.spec.ts — 2 new regression tests

  • src/migrate-async.spec.ts — 2 new regression tests

Testing

  • Reproduced the original bug before the fix (objects were being dirtied)

  • After the fix: dedicated verification + the 4 new tests pass

  • Full test run: 44 tests passed

  • npm run test:types, test:lint, test:format — all clean

  • Coverage report remains strong

Related

Closes #26


Reviewer note: The shallow copy is minimal and only exists to satisfy the internal version literal requirement of the validation schema. This is the cleanest way to avoid mutation while keeping the existing public API and behavior unchanged for successful migrations.

@ig-imanish ig-imanish requested a review from mrspence as a code owner June 4, 2026 09:33
@will-lamerton

will-lamerton commented Jun 9, 2026

Copy link
Copy Markdown
Member

Hey @ig-imanish - thanks a lot for opening this PR :) I'm requesting a review again from @mrspence as he is the primary maintainer!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Version key mutation before schema validation can dirty caller objects on ValidationError (and success path)

2 participants