Repository navigation
fix(metadata): honour sortKeys in the typescript/javascript serializer - #19901
Conversation
TypeScriptSerializer wraps a JSON body (export const metadata = {...})
and never read options.sortKeys, unlike JSONSerializer and YAMLSerializer,
which already honour it. typescript is FilesystemLoader.save()'s default
format, so a caller asking for deterministic key order silently got
unsorted keys.
Both the public serialize() and the package-internal
serializeTypeScriptForMetadataType() share one renderModule() code path,
so one fix covers both, and covers the javascript format too (same class,
same path).
Extracted JSONSerializer's recursive key sort into a shared,
package-internal sort-object-keys.ts so the typescript format reuses the
exact same sort instead of a second implementation. JSONSerializer keeps
its private sortObjectKeys() as a thin delegate so the class's declared
shape (and the package's emitted .d.ts) is unchanged.
Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr
📓 Docs Drift Check3 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 16 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin a0931ee2dc5b77139b0ede7dec2318297b87e4f2 && git checkout a0931ee2dc5b77139b0ede7dec2318297b87e4f2
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 029d8a47109c63986a27f53581ec6c980b8e2f14 67015feb5f1107858566df986bb8eaeb7b3420fe && git checkout -B drift-repro 029d8a47109c63986a27f53581ec6c980b8e2f14 && git merge --no-ff 67015feb5f1107858566df986bb8eaeb7b3420fe
node scripts/docs-audit/affected-docs.mjs --json 029d8a47109c63986a27f53581ec6c980b8e2f14 |
Contract reviewServed-tier: Head = PR head; merge-base ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
Fixes #19872
What
sortKeys— aMetadataSaveOptionsfield already honoured by thejsonandyamlformats — is now honoured by thetypescript/javascriptformats too.A1 — reproduced;
javascriptis affected the same way and fixed the same waySerializing an object with
sortKeys: true: thejsonkeys came out sorted, thetypescriptkeys did not (typescript-serializer.tsnever readoptions.sortKeys).javascriptis the sameTypeScriptSerializerclass, routed through the samerenderModule()— confirmed it silently ignoredsortKeystoo, and one fix (inrenderModule()) covers both formats. Pinned separately for each.A2 —
JSONSerializer's sort is deep (recursive), matched exactlyJSONSerializer.sortObjectKeys()recurses into nested objects; arrays keep element order, only object keys sort. Extracted verbatim intopackages/metadata/src/serializers/sort-object-keys.ts(package-internal, published from no@objectstack/metadataexportsentry) soTypeScriptSerializer'srenderModule()reuses the exact same sort — no second implementation.JSONSerializerkeeps its privatesortObjectKeys()method, now a thin delegate to the shared function (see.d.tsnote below for why it's kept rather than inlined away).A3 — byte-identity when
sortKeysis off/absent, pinned forobjectthrough the loader pathsortKeysabsent orfalseis byte-identical toorigin/main, for every format, including throughFilesystemLoader.save()→serializeTypeScriptForMetadataType()..d.tsdiff (file-surface constraint honoured)Built
@objectstack/metadataonorigin/main(dabf8d795e) and on this branch's head; diffed everyexportsentry's emitted.d.ts(.,./node,./migrations,./errors,./view-container). Four of five identical byte-for-byte.index.d.ts(andnode.d.ts, which re-exports it — also checked, identical) differs by exactly one added JSDoc comment block onJSONSerializer's already-existing privatesortObjectKeysmember — no declaration line changed, nothing added or removed from the class's shape. That private method's body now delegates to the shared helper instead of holding its own copy of the sort; it's kept (not deleted) specifically so the emitted.d.tsstays byte-identical apart from the comment.Tests
New
describe('sortKeys (#19872)')inpackages/metadata/src/serializers/serializers.test.ts:sortKeys: truethrough the publicserialize(),serializeTypeScriptForMetadataType(), and thejavascriptformat — each asserted byte-identical toJSONSerializer's own sorted output wrapped in the module text, using a fixture unsorted at both the top level and a nested level (proves the sort is deep, not top-level-only).sortKeysabsent/false: byte-identical toorigin/main, both direct calls and throughFilesystemLoader.save().serialize/serializeTypeScriptForMetadataType→deserialize) withsortKeys: true, forviewandobject.FilesystemLoader.save()honouringsortKeys: trueforobject, through the loader path (A3).Ablation (fix committed first): reverted only the two implementation files to
origin/main's version and removed the new shared-sort file, ran the new pins — 4 of 7 went red (everysortKeys: truepin), 3 stayed green (the byte-identity-when-off pins and the order-insensitive round trip) — exactly the expected split. Restored withgit checkout HEAD --, verifiedgit diff HEADempty andgit hash-objectmatchesHEAD:<path>for all three touched/added files.README
packages/metadata/README.md'sTypeScriptSerializerparagraph is silent on whichSerializeOptionsit honours — no false claim aboutsortKeysto correct — so left unedited, per the file surface's own condition ("only if it states which options the format honours").Gates
Re-derived on the final commit with
node scripts/pm/dispatch-gates.mjs --commands; every listed command run with its exit code captured before any pipe (full list in the report). The knowncheck:issue-citationsgap (the derived family runs only--self-test) is closed by also running the live verdict,node scripts/check-issue-citations.mjs, by hand.Clause-②: no
Acceptance notes
No out-of-scope findings.
Generated by Claude Code