Skip to content

feat(stjm): add per-target undiscriminated source migration support#26

Merged
egil merged 5 commits intomainfrom
egil/migration-fallback
May 5, 2026
Merged

feat(stjm): add per-target undiscriminated source migration support#26
egil merged 5 commits intomainfrom
egil/migration-fallback

Conversation

@egil
Copy link
Copy Markdown
Owner

@egil egil commented May 5, 2026

Why

When adopting Egil.SystemTextJson.Migration on an existing type, stored JSON typically has no $type discriminator. Today, discriminator-less object payloads are deserialized directly as the target type, which makes it hard to incrementally introduce a renamed/restructured target with a migration from the prior shape.

Approach

Adds an opt-in JsonMigratableAttribute.UndiscriminatedSourceType that lets a target declare a single source CLR type to assume when an object payload has no recognized discriminator. The converter then runs the matching migrator (static IMigrateFrom<> on the target, or an external IMigrate<,> registration). Behavior without the attribute is unchanged.

public record CustomerNameV0(string FirstName, string LastName);

[JsonMigratable(
    TypeDiscriminator = "customer-name-v1",
    UndiscriminatedSourceType = typeof(CustomerNameV0))]
public record CustomerNameV1(string Name) : IMigrateFrom<CustomerNameV0, CustomerNameV1>
{
    public static bool TryMigrateFrom(CustomerNameV0 source, out CustomerNameV1 result)
    {
        result = new CustomerNameV1($"{source.FirstName} {source.LastName}");
        return true;
    }
}

Internals:

  • TypeMetadata carries the configured undiscriminated source type.
  • JsonMigratableConverterFactory resolves the matching MigratorReference once per converter and throws a clear InvalidOperationException at setup time if no migrator matches.
  • JsonMigratableConverter.Inspect consults the resolved migrator at the empty-object branch and after the source-discriminator scan, before the dictionary-payload fallback.

Performance

Added BenchmarkDotNet coverage for the new undiscriminated source migration route with a plain System.Text.Json manual migration baseline. Refreshed the generated docs/perf reports and README performance summary from those results. Ran the full perf suite on this machine in both directions: changed branch first, then a detached main worktree baseline.

Common benchmark rows compared: 72. New changed-branch benchmark rows: 12. No allocation deltas were observed across common rows. The only common-row slowdowns were small/noisy: +5.2% on a plain STJ serialize baseline at tag count 2, and +0.6% to +0.9% on existing external migration rows; all other common rows were equal or faster in this run.

New undiscriminated source migration benchmark results:

  • Reflection: 555.0 ns / 1,032 B at 2 tags; 1,554.2 ns / 2,992 B at 32 tags; 9,609.7 ns / 17,400 B at 256 tags.
  • SourceGen: 587.8 ns / 1,008 B at 2 tags; 1,847.2 ns / 2,968 B at 32 tags; 8,540.4 ns / 17,376 B at 256 tags.

Notes for reviewers

  • Discriminator matches still take priority. The opt-in only activates when no discriminator is recognized, so existing payloads that include $type (or a known source discriminator) keep their current routing.
  • Only one source type is supported per target by design, to keep the choice deterministic. A test on UndiscriminatedStaticTarget declares two IMigrateFrom<> impls and verifies the configured one wins.
  • Misconfiguration (no migrator for the declared source) fails at converter creation with both type names in the message, rather than silently passing through.
  • Docs (README, recipes) and the LegacyPayloads sample are updated with the new pattern; mdsnippets refreshed.
  • Validation: 184 library tests, 44 sample tests, perf project Release build, and full BenchmarkDotNet suite on changed branch plus main baseline.

@egil egil force-pushed the egil/migration-fallback branch from 48dbd7c to bfe7cca Compare May 5, 2026 13:40
@egil egil changed the title Add per-target undiscriminated source migration support feat(migration): add per-target undiscriminated source migration support May 5, 2026
Introduces JsonMigratableAttribute.UndiscriminatedSourceType so a target type
can opt in to treating discriminator-less object payloads as a specific source
type and run the matching migrator. Without this opt-in, behavior is unchanged:
discriminator-less objects deserialize directly as the target type.

This enables incremental adoption of migration on existing types where stored
JSON has no $type discriminator.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@egil egil changed the title feat(migration): add per-target undiscriminated source migration support feat(stjm): add per-target undiscriminated source migration support May 5, 2026
@egil egil force-pushed the egil/migration-fallback branch from bfe7cca to d026587 Compare May 5, 2026 13:44
egil and others added 4 commits May 5, 2026 13:47
…s guidance [skip notes]

Introduces a repo-wide AGENTS.md that documents the Conventional Commit scopes
for every project (stjm, oes, os, ot, stp), the cross-cutting scopes (ci, build,
docs), and the [skip notes] marker used to exclude irrelevant commits from
generated package release notes. Per-project AGENTS.md files continue to govern
project-specific build/test conventions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds BenchmarkDotNet coverage for the per-target undiscriminated source
migration path with a plain System.Text.Json manual migration baseline. This
keeps the new code path visible in future performance comparisons without
adding a package-consumer release note entry.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…arks [skip notes]

Copies the latest BenchmarkDotNet report output into docs/perf and updates the
README performance summary to include the undiscriminated source migration
scenario. The summary generator now keeps that scenario in future refreshes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Documents how to refresh benchmark-backed performance docs after running the
BenchmarkDotNet suite, and makes the update script consume reports from either
the perf project artifact folder or the project-root artifact folder.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@egil egil merged commit a2d9df7 into main May 5, 2026
17 checks passed
@egil egil deleted the egil/migration-fallback branch May 5, 2026 15:37
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.

1 participant