Skip to content

Commit d1ca874

Browse files
fix(metadata): honour sortKeys in the typescript/javascript serializer (#19901)
Fixes #19872 ## What `sortKeys` — a `MetadataSaveOptions` field already honoured by the `json` and `yaml` formats — is now honoured by the `typescript` / `javascript` formats too. ## A1 — reproduced; `javascript` is affected the same way and fixed the same way Serializing an object with `sortKeys: true`: the `json` keys came out sorted, the `typescript` keys did not (`typescript-serializer.ts` never read `options.sortKeys`). `javascript` is the *same* `TypeScriptSerializer` class, routed through the same `renderModule()` — confirmed it silently ignored `sortKeys` too, and one fix (in `renderModule()`) covers both formats. Pinned separately for each. ## A2 — `JSONSerializer`'s sort is deep (recursive), matched exactly `JSONSerializer.sortObjectKeys()` recurses into nested objects; arrays keep element order, only object keys sort. Extracted verbatim into `packages/metadata/src/serializers/sort-object-keys.ts` (package-internal, published from no `@objectstack/metadata` `exports` entry) so `TypeScriptSerializer`'s `renderModule()` reuses the *exact same* sort — no second implementation. `JSONSerializer` keeps its private `sortObjectKeys()` method, now a thin delegate to the shared function (see `.d.ts` note below for why it's kept rather than inlined away). ## A3 — byte-identity when `sortKeys` is off/absent, pinned for `object` through the loader path `sortKeys` absent or `false` is byte-identical to `origin/main`, for every format, including through `FilesystemLoader.save()` → `serializeTypeScriptForMetadataType()`. ## `.d.ts` diff (file-surface constraint honoured) Built `@objectstack/metadata` on `origin/main` (`dabf8d795e`) and on this branch's head; diffed every `exports` entry's emitted `.d.ts` (`.`, `./node`, `./migrations`, `./errors`, `./view-container`). Four of five identical byte-for-byte. `index.d.ts` (and `node.d.ts`, which re-exports it — also checked, identical) differs by exactly one added JSDoc comment block on `JSONSerializer`'s already-existing private `sortObjectKeys` member — 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.ts` stays byte-identical apart from the comment. ## Tests New `describe('sortKeys (#19872)')` in `packages/metadata/src/serializers/serializers.test.ts`: - `sortKeys: true` through the public `serialize()`, `serializeTypeScriptForMetadataType()`, and the `javascript` format — each asserted byte-identical to `JSONSerializer`'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). - `sortKeys` absent/false: byte-identical to `origin/main`, both direct calls and through `FilesystemLoader.save()`. - Round trip (`serialize`/`serializeTypeScriptForMetadataType` → `deserialize`) with `sortKeys: true`, for `view` and `object`. - `FilesystemLoader.save()` honouring `sortKeys: true` for `object`, 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 (every `sortKeys: true` pin), 3 stayed green (the byte-identity-when-off pins and the order-insensitive round trip) — exactly the expected split. Restored with `git checkout HEAD --`, verified `git diff HEAD` empty and `git hash-object` matches `HEAD:<path>` for all three touched/added files. ## README `packages/metadata/README.md`'s `TypeScriptSerializer` paragraph is silent on which `SerializeOptions` it honours — no false claim about `sortKeys` to 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 known `check:issue-citations` gap (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](https://claude.ai/code/session_01TEhopqrWQYBycZzyJHpAZr)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 90ff10a commit d1ca874

5 files changed

Lines changed: 159 additions & 18 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
"@objectstack/metadata": patch
3+
---
4+
5+
`sortKeys` — a declared `MetadataSaveOptions` field, already honoured by the `json` and `yaml`
6+
formats — is now honoured by the `typescript` / `javascript` formats too (#19872).
7+
8+
`TypeScriptSerializer` wraps a JSON body in `export const metadata = { … };`. Both its public
9+
`serialize()` and the package-internal `serializeTypeScriptForMetadataType()` (the one
10+
`FilesystemLoader.save()` calls for the built-in `typescript` serializer, `save()`'s default
11+
format) share one `renderModule()` code path, and neither read `options.sortKeys` — so a caller
12+
saving metadata to the filesystem with `sortKeys: true` got unsorted keys in the default format,
13+
silently. `javascript` shares the same `TypeScriptSerializer` class and the same path, so it was
14+
affected too and is fixed the same way.
15+
16+
The body is now sorted with the exact recursive (deep) key sort `JSONSerializer` already applies
17+
— extracted into one shared, package-internal helper (`sort-object-keys.ts`, not published from
18+
any `@objectstack/metadata` `exports` entry) so there is one sort implementation, not two.
19+
`sortKeys` absent or `false` is unchanged: byte-identical output to before this fix, for every
20+
format, including through `FilesystemLoader.save()`.
21+
22+
No public API changes — `SerializeOptions` already declared `sortKeys`; this closes the gap
23+
between the declaration and the `typescript`/`javascript` formats' enforcement of it.

‎packages/metadata/src/serializers/json-serializer.ts‎

Lines changed: 8 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import type { z } from 'zod';
1010
import type { MetadataFormat } from '@objectstack/spec/system';
1111
import type { MetadataSerializer, SerializeOptions } from './serializer-interface.js';
12+
import { sortObjectKeys } from './sort-object-keys.js';
1213

1314
export class JSONSerializer implements MetadataSerializer {
1415
serialize<T>(item: T, options?: SerializeOptions): string {
@@ -51,23 +52,14 @@ export class JSONSerializer implements MetadataSerializer {
5152

5253
/**
5354
* Recursively sort object keys
55+
*
56+
* #19872: the shared implementation moved to `./sort-object-keys.js`, so
57+
* `TypeScriptSerializer` can reuse the exact same sort instead of a second
58+
* one — this method is now a thin delegate, kept (rather than inlined at
59+
* the call site) so this class's declared shape, private members included,
60+
* is unchanged in the package's emitted `.d.ts`.
5461
*/
5562
private sortObjectKeys(obj: any): any {
56-
if (obj === null || typeof obj !== 'object') {
57-
return obj;
58-
}
59-
60-
if (Array.isArray(obj)) {
61-
return obj.map(item => this.sortObjectKeys(item));
62-
}
63-
64-
const sorted: Record<string, any> = {};
65-
const keys = Object.keys(obj).sort();
66-
67-
for (const key of keys) {
68-
sorted[key] = this.sortObjectKeys(obj[key]);
69-
}
70-
71-
return sorted;
63+
return sortObjectKeys(obj);
7264
}
7365
}

‎packages/metadata/src/serializers/serializers.test.ts‎

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,5 +206,96 @@ describe('Serializers', () => {
206206
const js = new TypeScriptSerializer('javascript');
207207
expect(js.getExtension()).toBe('.js');
208208
});
209+
210+
// #19872: `sortKeys` was declared (`MetadataSaveOptionsSchema`), honoured
211+
// by `json`/`yaml`, and silently ignored by `typescript` (the default
212+
// format `FilesystemLoader.save()` routes to) — and by `javascript`, the
213+
// same class on the same `renderModule()` code path.
214+
describe('sortKeys (#19872)', () => {
215+
// Deliberately unsorted at BOTH levels, so a top-level-only sort would
216+
// pass while a deep one is the only one this can't fake: the nested
217+
// `fields` object's own keys are unsorted too.
218+
const unsortedObject = {
219+
name: 'account',
220+
label: 'Account',
221+
fields: {
222+
zeta_field: { type: 'text', label: 'Zeta' },
223+
alpha_field: { type: 'text', label: 'Alpha' },
224+
},
225+
};
226+
const jsonSerializer = new JSONSerializer();
227+
228+
it('the public serialize() sorts the JSON body deep, byte-identical to JSONSerializer\'s own sort — reused, not reimplemented', () => {
229+
const sortedBody = jsonSerializer.serialize(unsortedObject, { sortKeys: true });
230+
expect(serializer.serialize(unsortedObject, { sortKeys: true })).toBe(
231+
`export const metadata = ${sortedBody};\n\nexport default metadata;\n`
232+
);
233+
});
234+
235+
it('serializeTypeScriptForMetadataType() sorts the JSON body deep too, annotated', () => {
236+
const sortedBody = jsonSerializer.serialize(unsortedObject, { sortKeys: true });
237+
expect(serializeTypeScriptForMetadataType(unsortedObject, 'object', { sortKeys: true })).toBe(
238+
`import type { ServiceObject } from '@objectstack/spec/data';\n\n` +
239+
`export const metadata: ServiceObject = ${sortedBody};\n\n` +
240+
`export default metadata;\n`
241+
);
242+
});
243+
244+
it('the javascript format honours it too — A1: same TypeScriptSerializer class, same renderModule() path', () => {
245+
const sortedBody = jsonSerializer.serialize(unsortedObject, { sortKeys: true });
246+
expect(new TypeScriptSerializer('javascript').serialize(unsortedObject, { sortKeys: true })).toBe(
247+
`export const metadata = ${sortedBody};\n\nexport default metadata;\n`
248+
);
249+
});
250+
251+
it('sortKeys absent or false: byte-identical to origin/main, through both serialize() and serializeTypeScriptForMetadataType()', () => {
252+
expect(serializer.serialize(object)).toBe(plain(object));
253+
expect(serializer.serialize(object, { sortKeys: false })).toBe(plain(object));
254+
expect(serializeTypeScriptForMetadataType(object, 'object')).toBe(annotatedObject);
255+
expect(serializeTypeScriptForMetadataType(object, 'object', { sortKeys: false })).toBe(annotatedObject);
256+
});
257+
258+
it('round-trips a view and an object with sortKeys: true, through both paths', () => {
259+
for (const [metadataType, item] of [['view', view], ['object', object]] as const) {
260+
expect(
261+
serializer.deserialize(serializeTypeScriptForMetadataType(item, metadataType, { sortKeys: true }))
262+
).toEqual(item);
263+
expect(serializer.deserialize(serializer.serialize(item, { sortKeys: true }))).toEqual(item);
264+
}
265+
});
266+
267+
it('FilesystemLoader.save() honours sortKeys: true for object, through the loader path (A3)', async () => {
268+
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-sortkeys-on-'));
269+
try {
270+
const manager = new NodeMetadataManager({ rootDir, watch: false });
271+
await manager.save('object', 'account', unsortedObject, { sortKeys: true });
272+
const objectFile = await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8');
273+
const sortedBody = jsonSerializer.serialize(unsortedObject, { sortKeys: true });
274+
expect(objectFile).toBe(
275+
`import type { ServiceObject } from '@objectstack/spec/data';\n\n` +
276+
`export const metadata: ServiceObject = ${sortedBody};\n\n` +
277+
`export default metadata;\n`
278+
);
279+
} finally {
280+
await fs.rm(rootDir, { recursive: true, force: true });
281+
}
282+
});
283+
284+
it('FilesystemLoader.save() with sortKeys absent or explicitly false is byte-identical to origin/main, for object, through the loader path (A3)', async () => {
285+
const rootDir = await fs.mkdtemp(path.join(os.tmpdir(), 'objectstack-ts-serializer-sortkeys-off-'));
286+
try {
287+
const manager = new NodeMetadataManager({ rootDir, watch: false });
288+
await manager.save('object', 'account', object);
289+
const noOptionFile = await fs.readFile(path.join(rootDir, 'object', 'account.ts'), 'utf-8');
290+
expect(noOptionFile).toBe(annotatedObject);
291+
292+
await manager.save('object', 'account2', object, { sortKeys: false });
293+
const explicitFalseFile = await fs.readFile(path.join(rootDir, 'object', 'account2.ts'), 'utf-8');
294+
expect(explicitFalseFile).toBe(annotatedObject);
295+
} finally {
296+
await fs.rm(rootDir, { recursive: true, force: true });
297+
}
298+
});
299+
});
209300
});
210301
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The ONE `sortKeys` sort every serializer that honours it as a plain-JSON
5+
* key sort (not a format-native one, like `js-yaml`'s own `sortKeys` option)
6+
* shares — `JSONSerializer` and the `typescript`/`javascript` module body
7+
* `TypeScriptSerializer` wraps. Recursive: every nested object's keys sort
8+
* too, not just the top level. Arrays keep their element order; only object
9+
* keys sort. Not published from any `@objectstack/metadata` `exports` entry
10+
* — an internal helper two in-package serializers share, never a second
11+
* implementation of the same sort.
12+
*/
13+
export function sortObjectKeys(obj: any): any {
14+
if (obj === null || typeof obj !== 'object') {
15+
return obj;
16+
}
17+
18+
if (Array.isArray(obj)) {
19+
return obj.map(item => sortObjectKeys(item));
20+
}
21+
22+
const sorted: Record<string, any> = {};
23+
const keys = Object.keys(obj).sort();
24+
25+
for (const key of keys) {
26+
sorted[key] = sortObjectKeys(obj[key]);
27+
}
28+
29+
return sorted;
30+
}

‎packages/metadata/src/serializers/typescript-serializer.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import type { z } from 'zod';
1010
import type { MetadataFormat } from '@objectstack/spec/system';
1111
import type { MetadataSerializer, SerializeOptions } from './serializer-interface.js';
12+
import { sortObjectKeys } from './sort-object-keys.js';
1213

1314
/**
1415
* The spec type a `typescript`-format file's `metadata` constant is annotated
@@ -79,9 +80,13 @@ function renderModule(
7980
options: SerializeOptions | undefined,
8081
annotation: readonly [typeName: string, subpath: string] | undefined,
8182
): string {
82-
const { prettify = true, indent = 2 } = options || {};
83+
const { prettify = true, indent = 2, sortKeys = false } = options || {};
8384

84-
const jsonStr = JSON.stringify(item, null, prettify ? indent : 0);
85+
// Same sort `JSONSerializer` applies (recursive, ⛔ not a second
86+
// implementation) — the JSON body this format wraps is otherwise the one
87+
// place `sortKeys: true` silently produced unsorted keys (#19872).
88+
const body = sortKeys ? sortObjectKeys(item) : item;
89+
const jsonStr = JSON.stringify(body, null, prettify ? indent : 0);
8590

8691
if (annotation) {
8792
const [typeName, subpath] = annotation;

0 commit comments

Comments
 (0)