diff --git a/src/migrate-async.spec.ts b/src/migrate-async.spec.ts index 6170635..3b0a447 100644 --- a/src/migrate-async.spec.ts +++ b/src/migrate-async.spec.ts @@ -291,3 +291,66 @@ test("migrateAsync: null state handling", async (t) => { t.deepEqual(result, { _version: 1, initialized: true }); }); + +test("migrateAsync: schema validation error does not mutate up() return value or input state", async (t) => { + const upReturned = { name: "ab" }; // will fail .min(5) + + const migrations = [ + { + version: 1, + schema: z.object({ name: z.string().min(5) }), + up: async () => upReturned, + }, + ] as const; + + const originalInput = { name: "start" }; + + t.false("_version" in upReturned); + t.false("_version" in originalInput); + + const error = await t.throwsAsync( + () => + migrateAsync({ + state: originalInput, + migrations, + }), + { instanceOf: ValidationError }, + ); + + t.is(error?.version, 1); + + t.false( + "_version" in upReturned, + "up() return value must not be mutated with version key on validation failure (async)", + ); + t.false( + "_version" in originalInput, + "input state must not be mutated when a migration fails validation (async)", + ); +}); + +test("migrateAsync: schema validation error does not mutate when up() returns same ref", async (t) => { + const originalState = { name: "start" }; + + const migrations = [ + { + version: 1, + schema: z.object({ name: z.string().min(5), extra: z.boolean() }), + up: async (state: Record) => { + (state as Record).mutatedField = true; + return state; + }, + }, + ] as const; + + t.false("_version" in originalState); + + await t.throwsAsync(() => migrateAsync({ state: originalState, migrations }), { + instanceOf: ValidationError, + }); + + t.false( + "_version" in originalState, + "original state must not receive version key on validation failure even if up returned same ref (async)", + ); +}); diff --git a/src/migrate.spec.ts b/src/migrate.spec.ts index c254cd4..55f9b67 100644 --- a/src/migrate.spec.ts +++ b/src/migrate.spec.ts @@ -318,3 +318,72 @@ test("complex schema transformations", (t) => { users: [{ id: 1, name: "Alice", createdAt: "2024-01-01" }], }); }); + +test("schema validation error does not mutate up() return value or input state", (t) => { + const upReturned = { name: "ab" }; // will fail .min(5) + + const migrations = [ + { + version: 1, + schema: z.object({ name: z.string().min(5) }), + up: () => upReturned, + }, + ] as const; + + const originalInput = { name: "start" }; + + t.false("_version" in upReturned); + t.false("_version" in originalInput); + + const error = t.throws( + () => + migrate({ + state: originalInput, + migrations, + }), + { instanceOf: ValidationError }, + ); + + t.is(error?.version, 1); + + // Critical: no version key should have been written before/during failed validation + t.false( + "_version" in upReturned, + "up() return value must not be mutated with version key on validation failure", + ); + t.false( + "_version" in originalInput, + "input state must not be mutated when a later migration fails validation", + ); + t.is((upReturned as Record)._version, undefined); + t.is((originalInput as Record)._version, undefined); +}); + +test("schema validation error does not mutate when up() returns same ref as currentState", (t) => { + const originalState = { name: "start" }; + + const migrations = [ + { + version: 1, + schema: z.object({ name: z.string().min(5), extra: z.boolean() }), // missing extra -> fail + up: (state: Record) => { + // simulate a mutating up that returns same reference + (state as Record).mutatedField = true; + return state; + }, + }, + ] as const; + + t.false("_version" in originalState); + + t.throws(() => migrate({ state: originalState, migrations }), { + instanceOf: ValidationError, + }); + + t.false( + "_version" in originalState, + "original state must not be dirtied with version key when up() returned same ref and validation failed", + ); + t.is((originalState as Record)._version, undefined); + t.true((originalState as Record).mutatedField); // other mutations from up() itself are user's responsibility +}); diff --git a/src/migrate.ts b/src/migrate.ts index 810340d..4990319 100644 --- a/src/migrate.ts +++ b/src/migrate.ts @@ -87,9 +87,23 @@ export function migrate< throw new MigrationError(migration.version, error); } - // Add version to result + // Prepare a value for validation by injecting the version key *without* + // mutating the object returned by `up()` (or any shared reference such as + // a previous currentState). The schemaWithVersion requires the literal + // version, so we must provide it for validation. Using a shallow copy + // ensures that on ValidationError the caller's objects remain untouched, + // and the version key is only present on successfully validated outputs. + let toValidate: unknown = result; if (result !== null && typeof result === "object") { - (result as Record)[key] = migration.version; + if (Array.isArray(result)) { + toValidate = [...result]; + (toValidate as Record)[key] = migration.version; + } else { + toValidate = { + ...(result as Record), + [key]: migration.version, + }; + } } // Validate against schema with version field @@ -97,7 +111,7 @@ export function migrate< z.object({ [key]: z.literal(migration.version) }), ); - const parseResult = schemaWithVersion.safeParse(result); + const parseResult = schemaWithVersion.safeParse(toValidate); if (!parseResult.success) { throw new ValidationError(migration.version, parseResult.error.issues); } @@ -195,9 +209,23 @@ export async function migrateAsync< throw new MigrationError(migration.version, error); } - // Add version to result + // Prepare a value for validation by injecting the version key *without* + // mutating the object returned by `up()` (or any shared reference such as + // a previous currentState). The schemaWithVersion requires the literal + // version, so we must provide it for validation. Using a shallow copy + // ensures that on ValidationError the caller's objects remain untouched, + // and the version key is only present on successfully validated outputs. + let toValidate: unknown = result; if (result !== null && typeof result === "object") { - (result as Record)[key] = migration.version; + if (Array.isArray(result)) { + toValidate = [...result]; + (toValidate as Record)[key] = migration.version; + } else { + toValidate = { + ...(result as Record), + [key]: migration.version, + }; + } } // Validate against schema with version field @@ -205,7 +233,7 @@ export async function migrateAsync< z.object({ [key]: z.literal(migration.version) }), ); - const parseResult = schemaWithVersion.safeParse(result); + const parseResult = schemaWithVersion.safeParse(toValidate); if (!parseResult.success) { throw new ValidationError(migration.version, parseResult.error.issues); }