Skip to content

Enable sync validation. - #1029

Merged
polina-c merged 5 commits into
flutter:mainfrom
polina-c:sync-validation
Sep 1, 2026
Merged

Enable sync validation.#1029
polina-c merged 5 commits into
flutter:mainfrom
polina-c:sync-validation

Conversation

@polina-c

@polina-c polina-c commented Aug 31, 2026

Copy link
Copy Markdown
Collaborator

Contributes to a2ui-project/a2ui#2356, a2ui-project/a2ui#2373.

Addresses: a2ui-project/a2ui#2439 (comment).

Add synchronous validation to json_schema_builder

Motivation

Schema.validate is asynchronous, but validation is not. Across lib/ there
are 34 awaits and only two of them are real I/O, both in schema_cache.dart:
fetching the target of a remote $ref over file: or http(s):. Everything
else is contagion up the recursion — the tree walk itself is pure computation
over a schema and a value.

That cost is paid by callers who never fetch anything. The A2UI Dart SDK
(a2ui_core) pre-inlines every $ref and documents that it never fetches a
schema over the network, yet the inherited Future forces its
MessageProcessor.processMessages to return Future<void>, diverging from the
TypeScript SDK's synchronous processMessages(messages): void
(a2ui-project/a2ui#2439 (comment)).

The split

Resolution is asynchronous; validation is not. This PR separates the two rather
than duplicating the ~1,170-line traversal.

  • The traversal is now synchronous. validateSchemaSync,
    validateSubSchemaSync, validateObjectSync, validateListSync,
    validateTypeSpecificKeywordsSync, resolveRefSync and
    resolveDynamicRefSync are the same code as before with the async/await
    removed. References resolve from the in-memory SchemaRegistry only. If the
    walk reaches a reference whose target is not registered, it throws the new
    SchemaResolutionRequiredException naming that target — it does not skip the
    subschema, which would silently treat it as unconstrained and turn a missing
    fetch into a passing validation.

  • The asynchronous API is a thin wrapper. validate and each of the
    previously asynchronous helpers now call the synchronous core through one
    small retry loop (_resolvingRemoteRefs): run it, and if it asks for a schema,
    fetch that schema into the registry and run it again. Each retry resolves one
    more remote reference; a schema with no remote references runs the core
    exactly once. Deriving the URI to fetch from the core itself, rather than from
    a separate pre-scan, is what keeps base-URI and $id resolution semantics from
    drifting between the two paths — there is only one implementation of them.

  • validateSync is the new public entry point, mirroring validate's
    signature and results. Its dartdoc states the precondition (every reference
    resolves from the schema itself or from a registry the caller populated,
    including any $schema meta-schema) and what happens when it is violated.

Supporting changes: SchemaRegistry gains resolveSync (registry-only
resolution) and fetch (fetch and register), with resolve now expressed in
terms of fetch. ValidationContext records the outcome of the fetches made for
one validation, so a fetch that failed is not repeated within that validation and
is not remembered beyond it — a later validate retries it exactly as it did
before. SchemaFetchException and SchemaResolutionRequiredException are now
exported.

Compatibility

No behavior change for existing async callers. validate keeps its signature and
its results, and every previously asynchronous member still exists with its
asynchronous signature, so this is additive: nothing that compiled before stops
compiling.

The one observable difference is the number of HTTP requests inside a single
validate call: a remote target that fails to fetch is now attempted once per
validation instead of once per reference to it. The resulting errors are
identical.

Trade-off

A cold registry costs one extra traversal per distinct remote reference, since
each retry restarts the walk. Validation is pure CPU over the data, the count is
bounded by the number of remote references, and the registry stays warm
afterwards, so subsequent validations are single-pass. The alternative — a
separate pre-scan that predicts which URIs will be needed — would duplicate the
base-URI and $id resolution rules and drift from them.

Testing

  • test/test_suite_test.dart (the official JSON Schema Test Suite, draft
    2020-12) passes unchanged, and now runs every one of its 1261 cases through
    the synchronous path as well, asserting the two paths produce identical error
    strings. A tearDownAll assertion fails the suite if any case stops being
    covered synchronously, so the coverage cannot erode silently.
  • test/sync_validation_test.dart covers the split directly, in four parts:
    the synchronous path (agreement with validate, strictFormat, local $defs
    references, registry-backed references, a registry warmed by validate, and
    the failure modes — an unregistered $ref, an unregistered $schema
    meta-schema, and a reference whose fetch previously failed); the asynchronous
    wrappers, each called directly; the reference resolution failures
    ($dynamicRef, meta-schema, and a fetch that yields no schema); and
    SchemaRegistry.resolve/resolveSync.
  • dart analyze clean, dart format applied, full package suite green (1317
    tests). packages/genui's validation tests pass against the change.
  • Package line coverage goes from the 79.09% baseline to 81.93%;
    coverage_baseline.yaml is updated to the new high-water mark.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces synchronous validation capabilities to the json_schema_builder package. It refactors the validation core to be synchronous (validateSync), while keeping the existing asynchronous validate method as a thin wrapper that automatically fetches remote references as needed. It also introduces SchemaRegistry.resolveSync, SchemaRegistry.fetch, and a new SchemaResolutionRequiredException thrown when a synchronous validation encounters an unresolved remote reference. Comprehensive tests have been added to verify the correctness and equivalence of the synchronous validation path. There are no review comments, so I have no additional feedback to provide.

@polina-c
polina-c requested a review from gspencergoog August 31, 2026 22:17

@gspencergoog gspencergoog left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I like that this splits up the concerns, but when can you ever run validateSync without first running a validate or fetch? It seems like you need to always do an async resolve step before you can validate synchronously anyhow.

while (true) {
try {
return body();
} on SchemaResolutionRequiredException catch (e) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Isn't this the anti-pattern of using exceptions for control flow? Wouldn't it be better to return some sort of union or result type that allows branching instead?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

I do not see how it is possible. Added comment to explain in detail.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Why couldn't you have a prefetch that you run before doing validateSync? I.e. do something like this:

Future<T> _withRemoteRefs<T>(
  ValidationContext context,
  Schema schema,
  T Function() body,
) async {
  // 1. Discover all missing remote references in the schema AST
  //    and fetch them in parallel with Future.wait.
  await context.schemaRegistry.prefetchDependencies(schema, baseUri: context.sourceUri);
  // 2. Run the synchronous validation once.
  return body();
}

Or, even simpler, the async validate method can call it directly:

Future<List<ValidationError>> validate(
  Object? data, {
  SchemaRegistry? schemaRegistry,
  ...
}) async {
  final registry = schemaRegistry ?? SchemaRegistry(...);
  
  // Fetch any missing transitive references in parallel:
  await registry.prefetchDependencies(this, baseUri: sourceUri);
  // Run synchronous validation directly:
  return validateSync(data, schemaRegistry: registry, ...);
}

This way validateSync stays the same, but the exception is a real exception that happens if you call validateSync without all of the dependencies, but now they can be fetched in parallel instead of serially, and we don't need to use an exception for flow control. In the current setup, each time the exception is thrown, it starts the resolution over again.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Thank you for amazing suggestion! Applied.

return _getSchemaFromFragment(uri, _schemas[uriWithoutFragment]!);
final Schema? schema = _schemas[uriWithoutFragment];
if (schema == null) {
throw SchemaResolutionRequiredException(uriWithoutFragment);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just a comment: I was hoping that we could check and see if the URI was a file URI and then resolve it sync here, but I guess if we're going to be using this on the web ever, then that will mean that dart:io isn't available, so sync file reading also wouldn't be.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

true

@polina-c

polina-c commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator Author

I like that this splits up the concerns, but when can you ever run validateSync without first running a validate or fetch? It seems like you need to always do an async resolve step before you can validate synchronously anyhow.

Nope. validateSync should be invoked if caller knows for sure no I/O is needed - all references should be resolved in compile time. Makes sense?

@polina-c
polina-c enabled auto-merge (squash) September 1, 2026 19:39
Comment on lines +69 to +70
For example, in Dart, keep
the `_` prefix and annotate with `@visibleForTesting`.

@gspencergoog gspencergoog Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I don't think this will work, actually. Symbols starting with _ are private to the module. Marking them with @visibleForTesting won't export them to the test. You can only mark public functions as @visibleForTesting.

But I agree with the sentiment of keeping things private. For the skill, I'd word it like this:

Make every code element as private as it can be. If tests need access, design the API for testability with
dependency injection, abstraction, role interfaces, ports and adaptors, or other factoring improvements.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Oh, yes, this is standard source of confusion for me. Will send correction.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Correction: #1031

@polina-c
polina-c merged commit 029421e into flutter:main Sep 1, 2026
46 checks passed
@polina-c
polina-c deleted the sync-validation branch September 1, 2026 20:10
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.

2 participants