Skip to content

fix: stabilize field order in Ash.Expr.determine_map_type/1#2679

Merged
zachdaniel merged 1 commit into
ash-project:mainfrom
barnabasJ:fix/deterministic-map-literal-field-order
Apr 17, 2026
Merged

fix: stabilize field order in Ash.Expr.determine_map_type/1#2679
zachdaniel merged 1 commit into
ash-project:mainfrom
barnabasJ:fix/deterministic-map-literal-field-order

Conversation

@barnabasJ
Copy link
Copy Markdown
Contributor

Summary

A literal map expression inside an :auto-typed calculation — e.g.

calculate :card, :auto, expr(%{title: title, score: score, active: active})

currently produces a {Ash.Type.Map, fields: [...]} whose field order is non-deterministic across compiles.

Ash.Expr.determine_map_type/1 receives the map and iterates it with Enum.reduce_while. For small Erlang flatmaps that dispatches to :maps.to_list/1, which walks by internal atom term order — i.e. atom-table index order — which varies between BEAM loads (warm _build/dev vs clean _build/test). The resulting fields: keyword list is stable within a single compile but can reshuffle between compiles.

Impact

Any consumer that renders ordered output from calc.constraints[:fields] sees flaky results. Concrete case: ash-project/ash_typescript#67 — generated TypeScript interfaces whose field order changes between cold and warm builds, polluting snapshot diffs.

Fix

One line: sort the accumulated fields by Atom.to_string(key) before returning. Atom.to_string/1 is deterministic across BEAM loads (atom names are part of the atom's identity), unlike term ordering. Replaces the Enum.reverse/1 that was there purely to counter the reduce-into-head pattern — sorting is end-to-end deterministic and doesn't need the reverse.

Contract

The guarantee is deterministic, not alphabetical. Consumers must not depend on the specific ordering — only on stability across compiles. Today nothing downstream can rely on a specific order (there isn't one), so the behavior change is observable only as "the flaky thing stopped being flaky."

Test plan

  • mix test test/type/auto_type_test.exs — 67 tests, 0 failures
  • New assertion on :ordered_card calc with source keys z, a, m expects [:a, :m, :z] output
  • mix format --check-formatted clean

Iterating a literal map expr (e.g. `expr(%{a: id, b: name})`) produces
a `{Ash.Type.Map, fields: [...]}` whose field order depends on Erlang
map iteration, which for small flatmaps is driven by internal atom
term ordering and varies between BEAM loads. Downstream consumers
that render ordered output (e.g. ash_typescript codegen) see flaky,
shuffled results across compiles.

Sort the resulting fields by atom name before returning so the output
is stable across compiles. The contract is only "deterministic", not
"alphabetical forever" — consumers should not rely on the specific
order, only that it doesn't change.
@zachdaniel zachdaniel merged commit 9efb8a0 into ash-project:main Apr 17, 2026
40 of 45 checks passed
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.

3 participants