Skip to content

Commit 30c7db9

Browse files
committed
feat: enhance metadata collection handling with support for map format and add tests
1 parent 6d74414 commit 30c7db9

7 files changed

Lines changed: 728 additions & 15 deletions

File tree

packages/spec/src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export {
7171
ObjectOSCapabilitiesSchema
7272
} from './stack.zod';
7373

74-
export type { DefineStackOptions } from './stack.zod';
74+
export type { DefineStackOptions, ObjectStackDefinitionInput } from './stack.zod';
7575

7676
export * from './stack.zod';
7777

@@ -84,6 +84,8 @@ export { defineAgent } from './ai/agent.zod';
8484
// DX Validation Utilities (re-exported for convenience)
8585
export { objectStackErrorMap, formatZodError, formatZodIssue, safeParsePretty } from './shared/error-map.zod';
8686
export { suggestFieldType, findClosestMatches, formatSuggestion } from './shared/suggestions.zod';
87+
export { normalizeMetadataCollection, normalizeStackInput, MAP_SUPPORTED_FIELDS } from './shared/metadata-collection.zod';
88+
export type { MetadataCollectionInput, MapSupportedField } from './shared/metadata-collection.zod';
8789

8890
export { type PluginContext } from './kernel/plugin.zod';
8991

packages/spec/src/shared/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ export * from './metadata-types.zod';
1313
export * from './branded-types.zod';
1414
export * from './suggestions.zod';
1515
export * from './error-map.zod';
16+
export * from './metadata-collection.zod';
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import {
5+
normalizeMetadataCollection,
6+
normalizeStackInput,
7+
MAP_SUPPORTED_FIELDS,
8+
} from './metadata-collection.zod';
9+
10+
describe('normalizeMetadataCollection', () => {
11+
describe('pass-through (no-op) cases', () => {
12+
it('should return null as-is', () => {
13+
expect(normalizeMetadataCollection(null)).toBeNull();
14+
});
15+
16+
it('should return undefined as-is', () => {
17+
expect(normalizeMetadataCollection(undefined)).toBeUndefined();
18+
});
19+
20+
it('should return an array as-is', () => {
21+
const arr = [{ name: 'task', fields: {} }];
22+
expect(normalizeMetadataCollection(arr)).toBe(arr); // same reference
23+
});
24+
25+
it('should return an empty array as-is', () => {
26+
const arr: unknown[] = [];
27+
expect(normalizeMetadataCollection(arr)).toBe(arr);
28+
});
29+
});
30+
31+
describe('map → array conversion', () => {
32+
it('should convert a map to an array with key as name', () => {
33+
const input = {
34+
account: { label: 'Account' },
35+
contact: { label: 'Contact' },
36+
};
37+
const result = normalizeMetadataCollection(input);
38+
expect(result).toEqual([
39+
{ name: 'account', label: 'Account' },
40+
{ name: 'contact', label: 'Contact' },
41+
]);
42+
});
43+
44+
it('should convert an empty map to an empty array', () => {
45+
expect(normalizeMetadataCollection({})).toEqual([]);
46+
});
47+
48+
it('should convert a single-entry map', () => {
49+
const result = normalizeMetadataCollection({
50+
task: { fields: { title: { type: 'text' } } },
51+
});
52+
expect(result).toEqual([
53+
{ name: 'task', fields: { title: { type: 'text' } } },
54+
]);
55+
});
56+
});
57+
58+
describe('name field precedence', () => {
59+
it('should use existing name from value (value takes precedence over key)', () => {
60+
const result = normalizeMetadataCollection({
61+
my_key: { name: 'actual_name', label: 'Test' },
62+
});
63+
expect(result).toEqual([
64+
{ name: 'actual_name', label: 'Test' },
65+
]);
66+
});
67+
68+
it('should inject key only when name is missing', () => {
69+
const result = normalizeMetadataCollection({
70+
task: { label: 'Task' },
71+
});
72+
expect(result).toEqual([
73+
{ name: 'task', label: 'Task' },
74+
]);
75+
});
76+
77+
it('should inject key when name is undefined', () => {
78+
const result = normalizeMetadataCollection({
79+
task: { name: undefined, label: 'Task' },
80+
});
81+
expect(result).toEqual([
82+
{ name: 'task', label: 'Task' },
83+
]);
84+
});
85+
86+
it('should not overwrite an empty string name', () => {
87+
// Empty string is a "present" value — it's the user's choice (Zod will validate it)
88+
const result = normalizeMetadataCollection({
89+
task: { name: '', label: 'Task' },
90+
});
91+
expect(result).toEqual([
92+
{ name: '', label: 'Task' },
93+
]);
94+
});
95+
});
96+
97+
describe('custom keyField', () => {
98+
it('should inject key into a custom field', () => {
99+
const result = normalizeMetadataCollection(
100+
{ contact: { fields: {} } },
101+
'extend',
102+
);
103+
expect(result).toEqual([
104+
{ extend: 'contact', fields: {} },
105+
]);
106+
});
107+
108+
it('should not overwrite existing custom field', () => {
109+
const result = normalizeMetadataCollection(
110+
{ my_key: { extend: 'existing', fields: {} } },
111+
'extend',
112+
);
113+
expect(result).toEqual([
114+
{ extend: 'existing', fields: {} },
115+
]);
116+
});
117+
});
118+
119+
describe('edge cases', () => {
120+
it('should handle non-object map values gracefully', () => {
121+
// Unusual case — let Zod handle the error downstream
122+
const result = normalizeMetadataCollection({
123+
bad: 'not an object',
124+
});
125+
expect(result).toEqual(['not an object']);
126+
});
127+
128+
it('should preserve nested objects without modification', () => {
129+
const result = normalizeMetadataCollection({
130+
task: {
131+
label: 'Task',
132+
fields: {
133+
title: { type: 'text', label: 'Title' },
134+
status: { type: 'select', label: 'Status' },
135+
},
136+
},
137+
});
138+
expect(result).toEqual([
139+
{
140+
name: 'task',
141+
label: 'Task',
142+
fields: {
143+
title: { type: 'text', label: 'Title' },
144+
status: { type: 'select', label: 'Status' },
145+
},
146+
},
147+
]);
148+
});
149+
150+
it('should preserve order of map entries', () => {
151+
const result = normalizeMetadataCollection({
152+
alpha: { label: 'Alpha' },
153+
beta: { label: 'Beta' },
154+
gamma: { label: 'Gamma' },
155+
}) as Array<{ name: string }>;
156+
expect(result.map((r) => r.name)).toEqual(['alpha', 'beta', 'gamma']);
157+
});
158+
});
159+
});
160+
161+
describe('normalizeStackInput', () => {
162+
it('should normalize map-formatted metadata fields to arrays', () => {
163+
const input = {
164+
manifest: { id: 'test', name: 'test', version: '1.0.0', type: 'app' },
165+
objects: {
166+
task: { fields: { title: { type: 'text' } } },
167+
},
168+
apps: {
169+
project_manager: { label: 'PM' },
170+
},
171+
};
172+
173+
const result = normalizeStackInput(input);
174+
175+
expect(result.manifest).toBe(input.manifest); // non-metadata fields unchanged
176+
expect(result.objects).toEqual([
177+
{ name: 'task', fields: { title: { type: 'text' } } },
178+
]);
179+
expect(result.apps).toEqual([
180+
{ name: 'project_manager', label: 'PM' },
181+
]);
182+
});
183+
184+
it('should leave array-formatted fields unchanged', () => {
185+
const input = {
186+
objects: [{ name: 'task', fields: {} }],
187+
apps: [{ name: 'sales', label: 'Sales' }],
188+
};
189+
190+
const result = normalizeStackInput(input);
191+
expect(result.objects).toBe(input.objects); // same reference
192+
expect(result.apps).toBe(input.apps);
193+
});
194+
195+
it('should handle mixed array and map formats', () => {
196+
const input = {
197+
objects: { task: { fields: { title: { type: 'text' } } } }, // map
198+
apps: [{ name: 'sales', label: 'Sales' }], // array
199+
views: [{ list: { type: 'grid' } }], // not in MAP_SUPPORTED_FIELDS if ViewSchema doesn't have name
200+
};
201+
202+
const result = normalizeStackInput(input);
203+
expect(Array.isArray(result.objects)).toBe(true);
204+
expect(result.apps).toBe(input.apps);
205+
expect(result.views).toBe(input.views);
206+
});
207+
208+
it('should not modify fields not in MAP_SUPPORTED_FIELDS', () => {
209+
const input = {
210+
manifest: { id: 'test' },
211+
i18n: { defaultLocale: 'en' },
212+
plugins: ['@objectstack/plugin-dev'],
213+
views: [{ list: { type: 'grid' } }],
214+
};
215+
216+
const result = normalizeStackInput(input);
217+
expect(result.manifest).toBe(input.manifest);
218+
expect(result.i18n).toBe(input.i18n);
219+
expect(result.plugins).toBe(input.plugins);
220+
expect(result.views).toBe(input.views);
221+
});
222+
223+
it('should handle an empty input', () => {
224+
expect(normalizeStackInput({})).toEqual({});
225+
});
226+
227+
it('should handle undefined metadata fields', () => {
228+
const input = { objects: undefined, apps: undefined };
229+
const result = normalizeStackInput(input);
230+
expect(result.objects).toBeUndefined();
231+
expect(result.apps).toBeUndefined();
232+
});
233+
});
234+
235+
describe('MAP_SUPPORTED_FIELDS', () => {
236+
it('should contain only fields that exist on ObjectStackDefinitionSchema', () => {
237+
// Sanity check — these are the fields we expect to support map format
238+
expect(MAP_SUPPORTED_FIELDS).toContain('objects');
239+
expect(MAP_SUPPORTED_FIELDS).toContain('apps');
240+
expect(MAP_SUPPORTED_FIELDS).toContain('pages');
241+
expect(MAP_SUPPORTED_FIELDS).toContain('dashboards');
242+
expect(MAP_SUPPORTED_FIELDS).toContain('reports');
243+
expect(MAP_SUPPORTED_FIELDS).toContain('actions');
244+
expect(MAP_SUPPORTED_FIELDS).toContain('themes');
245+
expect(MAP_SUPPORTED_FIELDS).toContain('workflows');
246+
expect(MAP_SUPPORTED_FIELDS).toContain('approvals');
247+
expect(MAP_SUPPORTED_FIELDS).toContain('flows');
248+
expect(MAP_SUPPORTED_FIELDS).toContain('roles');
249+
expect(MAP_SUPPORTED_FIELDS).toContain('permissions');
250+
expect(MAP_SUPPORTED_FIELDS).toContain('datasources');
251+
expect(MAP_SUPPORTED_FIELDS).toContain('connectors');
252+
});
253+
254+
it('should NOT contain fields without a name identifier', () => {
255+
expect(MAP_SUPPORTED_FIELDS).not.toContain('views');
256+
expect(MAP_SUPPORTED_FIELDS).not.toContain('objectExtensions');
257+
expect(MAP_SUPPORTED_FIELDS).not.toContain('data');
258+
expect(MAP_SUPPORTED_FIELDS).not.toContain('translations');
259+
expect(MAP_SUPPORTED_FIELDS).not.toContain('plugins');
260+
expect(MAP_SUPPORTED_FIELDS).not.toContain('devPlugins');
261+
});
262+
});

0 commit comments

Comments
 (0)