From 8340d447bd9795ec05d290dde4210e6d73425cfa Mon Sep 17 00:00:00 2001 From: Leo Farias Date: Mon, 26 Oct 2026 21:56:13 -0400 Subject: [PATCH] fix(ack): preserve lazy discriminated codec outputs --- CHANGELOG.md | 3 +- packages/ack/CHANGELOG.md | 1 + .../schemas/discriminated_object_schema.dart | 5 + packages/ack/lib/src/schemas/lazy_schema.dart | 16 +++ ...discriminated_runtime_validation_test.dart | 99 ++++++++++++++++++- 5 files changed, 119 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66b11820..fce147ea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,8 @@ See [Conventional Commits](https://conventionalcommits.org) for commit guideline field collide with the generated additional-properties member. * Preserve round trips for discriminated map codecs with strict output schemas, including values used as another codec's output, without narrowing permissive - intermediate output policies. + intermediate output policies. Lazy outputs retain deferred resolution and + recursion limits. * Accept contextual Dart member names and deeply snapshot JSON-owned generated fields without changing opaque codec values. Preserve effective nullable overrides when inferring explicit JSON boundaries. diff --git a/packages/ack/CHANGELOG.md b/packages/ack/CHANGELOG.md index 73345f92..734c676c 100644 --- a/packages/ack/CHANGELOG.md +++ b/packages/ack/CHANGELOG.md @@ -5,6 +5,7 @@ * Accept union-owned discriminators in strict map-backed codec outputs while preserving declared discriminator rules and rejecting unrelated extra keys. Already-permissive intermediate outputs retain their policies and values. + Lazy outputs preserve round trips, deferred resolution and recursion limits. * Expose the shared `deepUnmodifiableCopy` helper for generated immutable constructors. It snapshots nested collections without validating data or changing opaque noncollection objects. diff --git a/packages/ack/lib/src/schemas/discriminated_object_schema.dart b/packages/ack/lib/src/schemas/discriminated_object_schema.dart index 0973455e..ae9140a9 100644 --- a/packages/ack/lib/src/schemas/discriminated_object_schema.dart +++ b/packages/ack/lib/src/schemas/discriminated_object_schema.dart @@ -295,6 +295,11 @@ final class DiscriminatedObjectSchema optionalDiscriminator: true, ); } + if (schema is LazySchema) { + return schema._mapTarget( + (target) => _withRuntimeDiscriminator(target, discriminatorValue), + ); + } if (schema is CodecSchema) { final input = _withRuntimeDiscriminator( schema.inputSchema, diff --git a/packages/ack/lib/src/schemas/lazy_schema.dart b/packages/ack/lib/src/schemas/lazy_schema.dart index 594aee9a..4fb42adf 100644 --- a/packages/ack/lib/src/schemas/lazy_schema.dart +++ b/packages/ack/lib/src/schemas/lazy_schema.dart @@ -61,6 +61,22 @@ final class LazySchema @internal AckSchema get target => _target; + // Preserve deferred resolution, generic types and recursion identity when + // adapting a lazy codec output for a union-owned discriminator. + LazySchema _mapTarget( + AnyAckSchema Function(AnyAckSchema) transform, + ) => LazySchema._copy( + name, + () => transform(_target) as AckSchema, + _recursionToken, + maxDepth: maxDepth, + isNullable: isNullable, + isOptional: isOptional, + description: description, + constraints: constraints, + refinements: refinements, + ); + /// Whether the lazy reference's own null policy narrows its target. @internal bool get excludesTargetNull => !acceptsNull && _target.acceptsNull; diff --git a/packages/ack/test/schemas/discriminated_runtime_validation_test.dart b/packages/ack/test/schemas/discriminated_runtime_validation_test.dart index 040bc2a8..c3940284 100644 --- a/packages/ack/test/schemas/discriminated_runtime_validation_test.dart +++ b/packages/ack/test/schemas/discriminated_runtime_validation_test.dart @@ -228,16 +228,107 @@ void main() { expect(union.encode(union.parse(wire)!), wire); }); - for (final unionOutput in [false, true]) { - test('preserves permissive intermediate codec outputs ' - '${unionOutput ? 'inside anyOf' : 'directly'}', () { + test('round-trips lazy object outputs without eagerly resolving them', () { + var builds = 0; + var refinements = 0; + final output = + Ack.lazy('CatOutput', () { + builds++; + return Ack.object({'name': Ack.string()}); + }).refine((map) { + refinements++; + return map['name'] == 'Mittens'; + }); + final union = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec( + decode: (map) => {'name': map['name']}, + encode: (map) => {'name': map['name']}, + output: output, + ), + }, + ); + union.effectiveBranch('cat'); + expect(builds, 0); + + final wire = {'kind': 'cat', 'name': 'Mittens'}; + final parsed = union.parse(wire)!; + expect(parsed, wire); + expect(union.encode(parsed), wire); + final codec = _codecWithOutput(union, parsed); + expect(codec.parse('wire'), same(parsed)); + expect(codec.encode(parsed), 'wire'); + expect(builds, 1); + expect(refinements, 4); + expect(union.safeEncode({...wire, 'extra': true}).isFail, isTrue); + expect(_codecWithOutput(output, wire).safeParse('wire').isFail, isTrue); + }); + + test('retains recursion limits in lazy codec outputs', () { + late final LazySchema output; + output = Ack.lazy( + 'RecursiveOutput', + () => output, + maxDepth: 2, + ); + final union = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec( + decode: (map) => {'name': map['name']}, + encode: (map) => {'name': map['name']}, + output: output, + ), + }, + ); + final result = union.safeParse({'kind': 'cat', 'name': 'Mittens'}); + expect(result.isFail, isTrue); + expect( + result.getError().toString(), + contains('Maximum recursion depth (2)'), + ); + }); + + test('preserves a lazy output codec boundary type without converting', () { + final output = Ack.lazy( + 'CatOutput', + () => Ack.string().codec( + decode: (_) => throw StateError('Output decoder must not run'), + encode: (_) => throw StateError('Output encoder must not run'), + output: Ack.object({'name': Ack.string()}), + ), + ); + final union = Ack.discriminated( + discriminatorKey: 'kind', + schemas: { + 'cat': Ack.object({'name': Ack.string()}).codec( + decode: (map) => {'name': map['name']}, + encode: (map) => {'name': map['name']}, + output: output, + ), + }, + ); + final wire = {'kind': 'cat', 'name': 'Mittens'}; + expect(union.encode(union.parse(wire)!), wire); + }); + + for (final outputShape in ['directly', 'inside anyOf', 'inside lazy']) { + test('preserves permissive intermediate codec outputs $outputShape', () { final output = Ack.object({ 'name': Ack.string(), }).passthrough().refine((map) => map['kind'] == 'internal'); final inner = Ack.object({'name': Ack.string()}).codec( decode: (map) => {'name': map['name'], 'kind': 'internal'}, encode: (value) => {'name': (value as JsonMap)['name']}, - output: unionOutput ? Ack.anyOf([output]) : output, + output: switch (outputShape) { + 'inside anyOf' => Ack.anyOf([output]), + 'inside lazy' => Ack.lazy( + 'CatOutput', + () => output, + ), + _ => output, + }, ); final union = Ack.discriminated( discriminatorKey: 'kind',