Skip to content

Commit f5b393d

Browse files
authored
Merge pull request #686 from objectstack-ai/copilot/fix-downstream-project-issue
2 parents 6c90890 + 0f5a2ad commit f5b393d

File tree

3 files changed

+228
-1
lines changed

3 files changed

+228
-1
lines changed

packages/spec/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ 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';
87+
export { normalizeMetadataCollection, normalizeStackInput, normalizePluginMetadata, MAP_SUPPORTED_FIELDS, METADATA_ALIASES } from './shared/metadata-collection.zod';
8888
export type { MetadataCollectionInput, MapSupportedField } from './shared/metadata-collection.zod';
8989

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

packages/spec/src/shared/metadata-collection.test.ts

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { describe, it, expect } from 'vitest';
44
import {
55
normalizeMetadataCollection,
66
normalizeStackInput,
7+
normalizePluginMetadata,
78
MAP_SUPPORTED_FIELDS,
9+
METADATA_ALIASES,
810
} from './metadata-collection.zod';
911

1012
describe('normalizeMetadataCollection', () => {
@@ -260,3 +262,144 @@ describe('MAP_SUPPORTED_FIELDS', () => {
260262
expect(MAP_SUPPORTED_FIELDS).not.toContain('devPlugins');
261263
});
262264
});
265+
266+
describe('METADATA_ALIASES', () => {
267+
it('should map triggers to hooks', () => {
268+
expect(METADATA_ALIASES.triggers).toBe('hooks');
269+
});
270+
});
271+
272+
describe('normalizePluginMetadata', () => {
273+
describe('map → array conversion', () => {
274+
it('should convert map-formatted actions to an array', () => {
275+
const result = normalizePluginMetadata({
276+
actions: {
277+
lead_convert: { type: 'custom', label: 'Convert Lead' },
278+
},
279+
});
280+
expect(result.actions).toEqual([
281+
{ name: 'lead_convert', type: 'custom', label: 'Convert Lead' },
282+
]);
283+
});
284+
285+
it('should convert map-formatted workflows to an array', () => {
286+
const result = normalizePluginMetadata({
287+
workflows: {
288+
auto_assign: { objectName: 'lead', label: 'Auto Assign' },
289+
},
290+
});
291+
expect(result.workflows).toEqual([
292+
{ name: 'auto_assign', objectName: 'lead', label: 'Auto Assign' },
293+
]);
294+
});
295+
296+
it('should leave array-formatted collections unchanged', () => {
297+
const actions = [{ name: 'convert', type: 'custom' }];
298+
const result = normalizePluginMetadata({ actions });
299+
expect(result.actions).toBe(actions);
300+
});
301+
302+
it('should handle multiple map-formatted collections at once', () => {
303+
const result = normalizePluginMetadata({
304+
actions: { a: { label: 'A' } },
305+
flows: { f: { label: 'F' } },
306+
hooks: { h: { object: 'lead' } },
307+
});
308+
expect(result.actions).toEqual([{ name: 'a', label: 'A' }]);
309+
expect(result.flows).toEqual([{ name: 'f', label: 'F' }]);
310+
expect(result.hooks).toEqual([{ name: 'h', object: 'lead' }]);
311+
});
312+
});
313+
314+
describe('alias resolution (triggers → hooks)', () => {
315+
it('should rename triggers to hooks', () => {
316+
const result = normalizePluginMetadata({
317+
triggers: {
318+
lead_scoring: { object: 'lead', event: 'afterInsert' },
319+
},
320+
});
321+
expect(result.hooks).toEqual([
322+
{ name: 'lead_scoring', object: 'lead', event: 'afterInsert' },
323+
]);
324+
expect(result.triggers).toBeUndefined();
325+
});
326+
327+
it('should merge triggers into existing hooks (array)', () => {
328+
const result = normalizePluginMetadata({
329+
hooks: [{ name: 'existing_hook', object: 'account' }],
330+
triggers: {
331+
new_trigger: { object: 'lead', event: 'afterInsert' },
332+
},
333+
});
334+
expect(result.hooks).toEqual([
335+
{ name: 'existing_hook', object: 'account' },
336+
{ name: 'new_trigger', object: 'lead', event: 'afterInsert' },
337+
]);
338+
expect(result.triggers).toBeUndefined();
339+
});
340+
341+
it('should merge triggers into existing hooks (map)', () => {
342+
const result = normalizePluginMetadata({
343+
hooks: { existing_hook: { object: 'account' } },
344+
triggers: { new_trigger: { object: 'lead' } },
345+
});
346+
expect(result.hooks).toEqual([
347+
{ name: 'existing_hook', object: 'account' },
348+
{ name: 'new_trigger', object: 'lead' },
349+
]);
350+
expect(result.triggers).toBeUndefined();
351+
});
352+
});
353+
354+
describe('recursive nested plugin normalization', () => {
355+
it('should recursively normalize nested plugins', () => {
356+
const result = normalizePluginMetadata({
357+
actions: { a1: { label: 'Root Action' } },
358+
plugins: [
359+
{
360+
actions: { nested_action: { label: 'Nested' } },
361+
triggers: { nested_trigger: { object: 'contact' } },
362+
},
363+
'string-plugin-ref', // string refs should pass through
364+
],
365+
});
366+
367+
expect(result.actions).toEqual([{ name: 'a1', label: 'Root Action' }]);
368+
expect(result.plugins).toHaveLength(2);
369+
370+
const nestedPlugin = result.plugins[0] as Record<string, unknown>;
371+
expect(nestedPlugin.actions).toEqual([{ name: 'nested_action', label: 'Nested' }]);
372+
expect(nestedPlugin.hooks).toEqual([{ name: 'nested_trigger', object: 'contact' }]);
373+
expect(nestedPlugin.triggers).toBeUndefined();
374+
375+
expect(result.plugins[1]).toBe('string-plugin-ref');
376+
});
377+
});
378+
379+
describe('pass-through / edge cases', () => {
380+
it('should not modify non-metadata fields', () => {
381+
const result = normalizePluginMetadata({
382+
manifest: { name: 'test', version: '1.0.0' },
383+
actions: { a: { label: 'A' } },
384+
});
385+
expect(result.manifest).toEqual({ name: 'test', version: '1.0.0' });
386+
});
387+
388+
it('should handle empty input', () => {
389+
expect(normalizePluginMetadata({})).toEqual({});
390+
});
391+
392+
it('should handle input with no metadata collections', () => {
393+
const input = { manifest: { name: 'test' }, i18n: { defaultLocale: 'en' } };
394+
const result = normalizePluginMetadata(input);
395+
expect(result.manifest).toBe(input.manifest);
396+
expect(result.i18n).toBe(input.i18n);
397+
});
398+
399+
it('should handle undefined metadata fields', () => {
400+
const result = normalizePluginMetadata({ actions: undefined, hooks: undefined });
401+
expect(result.actions).toBeUndefined();
402+
expect(result.hooks).toBeUndefined();
403+
});
404+
});
405+
});

packages/spec/src/shared/metadata-collection.zod.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,87 @@ export function normalizeStackInput<T extends Record<string, unknown>>(input: T)
169169
}
170170
return result;
171171
}
172+
173+
/**
174+
* Mapping of legacy / alternative field names to their canonical names
175+
* in `ObjectStackDefinitionSchema`.
176+
*
177+
* Plugins may use legacy names (e.g., `triggers` instead of `hooks`).
178+
* This map lets `normalizePluginMetadata()` rewrite them automatically.
179+
*/
180+
export const METADATA_ALIASES: Record<string, MapSupportedField> = {
181+
triggers: 'hooks',
182+
};
183+
184+
/**
185+
* Normalize plugin metadata so it matches the canonical format expected by the runtime.
186+
*
187+
* This handles two issues that commonly arise when loading third-party plugin metadata:
188+
*
189+
* 1. **Map → Array conversion** — plugins often define metadata as maps
190+
* (e.g., `actions: { convert_lead: { ... } }`), but the runtime expects arrays.
191+
* Every key listed in {@link MAP_SUPPORTED_FIELDS} is normalized via
192+
* {@link normalizeMetadataCollection}.
193+
*
194+
* 2. **Field aliasing** — plugins may use legacy or alternative field names
195+
* (e.g., `triggers` instead of `hooks`). {@link METADATA_ALIASES} maps them
196+
* to their canonical counterparts.
197+
*
198+
* 3. **Recursive normalization** — if the plugin itself contains nested `plugins`,
199+
* each nested plugin is normalized recursively.
200+
*
201+
* @param metadata - Raw plugin metadata object
202+
* @returns A new object with all collections normalized to arrays, aliases resolved,
203+
* and nested plugins recursively normalized
204+
*
205+
* @example
206+
* ```ts
207+
* const raw = {
208+
* actions: { lead_convert: { type: 'custom', label: 'Convert' } },
209+
* triggers: { lead_scoring: { object: 'lead', event: 'afterInsert' } },
210+
* };
211+
* const normalized = normalizePluginMetadata(raw);
212+
* // normalized.actions → [{ name: 'lead_convert', type: 'custom', label: 'Convert' }]
213+
* // normalized.hooks → [{ name: 'lead_scoring', object: 'lead', event: 'afterInsert' }]
214+
* // normalized.triggers → removed (merged into hooks)
215+
* ```
216+
*/
217+
export function normalizePluginMetadata<T extends Record<string, unknown>>(metadata: T): T {
218+
const result = { ...metadata };
219+
220+
// 1. Resolve aliases (e.g. triggers → hooks), merging with any existing canonical values
221+
for (const [alias, canonical] of Object.entries(METADATA_ALIASES)) {
222+
if (alias in result) {
223+
const aliasValue = normalizeMetadataCollection(result[alias]);
224+
const canonicalValue = normalizeMetadataCollection(result[canonical]);
225+
226+
// Merge: canonical array wins; alias values are appended
227+
if (Array.isArray(aliasValue)) {
228+
(result as Record<string, unknown>)[canonical] = Array.isArray(canonicalValue)
229+
? [...canonicalValue, ...aliasValue]
230+
: aliasValue;
231+
}
232+
233+
delete (result as Record<string, unknown>)[alias];
234+
}
235+
}
236+
237+
// 2. Normalize map-formatted collections → arrays
238+
for (const field of MAP_SUPPORTED_FIELDS) {
239+
if (field in result) {
240+
(result as Record<string, unknown>)[field] = normalizeMetadataCollection(result[field]);
241+
}
242+
}
243+
244+
// 3. Recursively normalize nested plugins
245+
if (Array.isArray(result.plugins)) {
246+
(result as Record<string, unknown>).plugins = result.plugins.map((p: unknown) => {
247+
if (p && typeof p === 'object' && !Array.isArray(p)) {
248+
return normalizePluginMetadata(p as Record<string, unknown>);
249+
}
250+
return p;
251+
});
252+
}
253+
254+
return result;
255+
}

0 commit comments

Comments
 (0)