diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index b7374aaf..c91e42ef 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -1,5 +1,14 @@ ## Unreleased +### Breaking + +* Preserve programmer `Error` objects and their original stack traces when + codec decoders or one-way transforms throw. Nested schemas and `anyOf` + no longer disguise those defects as validation failures or try a fallback + branch. Recoverable `Exception`s still become `SchemaTransformError` + failures. Decoder callbacks should throw an `Exception`, such as + `FormatException`, to report invalid input rather than a programmer defect. + ### Added * `Ack.fromJsonSchema()` now supports `multipleOf` for `number` and `integer` diff --git a/packages/ack/lib/src/schemas/codec_schema.dart b/packages/ack/lib/src/schemas/codec_schema.dart index 29e548d1..4a83b31d 100644 --- a/packages/ack/lib/src/schemas/codec_schema.dart +++ b/packages/ack/lib/src/schemas/codec_schema.dart @@ -109,6 +109,9 @@ final class CodecSchema try { runtime = _decoder(intermediate); } catch (e, st) { + if (e is Error) { + Error.throwWithStackTrace(e, st); + } return SchemaResult.fail( SchemaTransformError( message: 'Codec decode failed: ${e.toString()}', diff --git a/packages/ack/test/codecs/codec_error_contract_test.dart b/packages/ack/test/codecs/codec_error_contract_test.dart new file mode 100644 index 00000000..8b561ebc --- /dev/null +++ b/packages/ack/test/codecs/codec_error_contract_test.dart @@ -0,0 +1,111 @@ +import 'package:ack/ack.dart'; +import 'package:test/test.dart'; + +void main() { + group('codec decoder error contract', () { + test('parse and safeParse propagate the original programmer error', () { + final error = StateError('decoder defect'); + final schema = Ack.string().codec( + decode: (_) => throw error, + encode: (value) => value.toString(), + ); + + expect(() => schema.safeParse('7'), throwsA(same(error))); + expect(() => schema.parse('7'), throwsA(same(error))); + }); + + test('nested objects, lists, and maps preserve decoder errors', () { + final error = StateError('nested decoder defect'); + final schema = Ack.string().codec( + decode: (_) => throw error, + encode: (value) => value.toString(), + ); + + expect( + () => Ack.object({'value': schema}).safeParse({'value': '7'}), + throwsA(same(error)), + ); + expect(() => Ack.list(schema).safeParse(['7']), throwsA(same(error))); + expect( + () => Ack.map(schema).safeParse({'value': '7'}), + throwsA(same(error)), + ); + }); + + test('one-way transforms preserve programmer errors too', () { + final error = StateError('transform defect'); + final schema = Ack.string().transform((_) => throw error); + + expect(() => schema.safeParse('7'), throwsA(same(error))); + }); + + test('anyOf does not hide a decoder error with a later branch', () { + final error = StateError('first branch defect'); + var fallbackCalled = false; + final broken = Ack.string().codec( + decode: (_) => throw error, + encode: (value) => value.toString(), + ); + final fallback = Ack.string().refine((_) { + fallbackCalled = true; + return true; + }); + + expect( + () => Ack.anyOf([broken, fallback]).safeParse('7'), + throwsA(same(error)), + ); + expect(fallbackCalled, isFalse); + }); + + test('recoverable decoder exceptions remain transform failures', () { + const exception = FormatException('not an integer'); + final schema = Ack.string().codec( + decode: (_) => throw exception, + encode: (value) => value.toString(), + ); + + final result = schema.safeParse('bad'); + + expect(result.isFail, isTrue); + expect( + result.getError(), + isA().having( + (error) => error.cause, + 'cause', + same(exception), + ), + ); + }); + + test('anyOf can still recover from a decoder exception', () { + final schema = Ack.string().codec( + decode: (_) => throw const FormatException('not an integer'), + encode: (value) => value.toString(), + ); + + expect(Ack.anyOf([schema, Ack.string()]).parse('text'), 'text'); + }); + + test('decoder errors retain their original stack trace', () { + final error = StateError('decoder defect'); + final originalStack = StackTrace.fromString('original decoder stack'); + final schema = Ack.string().codec( + decode: (_) => Error.throwWithStackTrace(error, originalStack), + encode: (value) => value.toString(), + ); + Object? caughtError; + StackTrace? caughtStack; + + try { + schema.safeParse('7'); + } catch (error, stackTrace) { + caughtError = error; + caughtStack = stackTrace; + } + + expect(caughtError, same(error)); + expect(caughtStack.toString(), originalStack.toString()); + }); + }); +} diff --git a/packages/ack/test/schemas/encode_error_propagation_test.dart b/packages/ack/test/schemas/encode_error_propagation_test.dart index bda62fbe..280a5fca 100644 --- a/packages/ack/test/schemas/encode_error_propagation_test.dart +++ b/packages/ack/test/schemas/encode_error_propagation_test.dart @@ -283,17 +283,16 @@ void main() { }); }); - group('decoder policy is unchanged', () { - test('a decoder Error still becomes a SchemaTransformError', () { + group('decoder policy matches encoder policy', () { + test('a decoder Error propagates unchanged', () { + final error = StateError('decode boom'); final schema = Ack.string().codec( - decode: (_) => throw StateError('decode boom'), + decode: (_) => throw error, encode: (value) => value.toString(), ); - final result = schema.safeParse('1'); - - expect(result.isFail, isTrue); - expect(result.getError(), isA()); + expect(() => schema.safeParse('1'), throwsA(same(error))); + expect(() => schema.parse('1'), throwsA(same(error))); }); }); } diff --git a/packages/ack/test/schemas/extensions/transform_extension_test.dart b/packages/ack/test/schemas/extensions/transform_extension_test.dart index 0371b6d3..926951df 100644 --- a/packages/ack/test/schemas/extensions/transform_extension_test.dart +++ b/packages/ack/test/schemas/extensions/transform_extension_test.dart @@ -49,16 +49,14 @@ void main() { ); test( - 'should error when transformer returns null for non-nullable output', + 'should propagate TypeError when a non-nullable transform returns null', () { final schema = Ack.string().transform((val) { return (null as dynamic); }); - final result = schema.safeParse('value'); - - expect(result.isFail, isTrue); - expect(result.getError(), isA()); + expect(() => schema.safeParse('value'), throwsA(isA())); + expect(() => schema.parse('value'), throwsA(isA())); }, ); diff --git a/packages/ack/test/typed_codecs_characterization_test.dart b/packages/ack/test/typed_codecs_characterization_test.dart index cf45c85f..61a18792 100644 --- a/packages/ack/test/typed_codecs_characterization_test.dart +++ b/packages/ack/test/typed_codecs_characterization_test.dart @@ -281,7 +281,7 @@ void main() { test('decoder exceptions use codec decode wording', () { final transformSchema = Ack.string().transform( - (_) => throw StateError('transform decoder failed'), + (_) => throw const FormatException('transform decoder failed'), ); final transformResult = transformSchema.safeParse('value'); @@ -291,7 +291,7 @@ void main() { expect(transformError.message, startsWith('Codec decode failed:')); final codecSchema = Ack.string().codec( - decode: (_) => throw StateError('codec decoder failed'), + decode: (_) => throw const FormatException('codec decoder failed'), encode: (value) => value.toString(), ); final codecResult = codecSchema.safeParse('value');