diff --git a/ROADMAP.md b/ROADMAP.md index e55fd14e4c..5544a1ea16 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -143,6 +143,7 @@ The following renames are planned for packages that implement core service contr - [x] **Studio Protocol** — Plugin extension schemas - [x] **Contracts** — 25 service interfaces with full method signatures - [x] **Stack Definition** — `defineStack()`, `defineView()`, `defineApp()`, `defineFlow()`, `defineAgent()` helpers +- [x] **Stack Composition** — `composeStacks()` for declarative multi-stack merging with conflict resolution (`error`/`override`/`merge`) - [x] **Error Map** — Custom Zod error messages with `objectStackErrorMap` - [x] **DX Utilities** — `safeParsePretty()`, `formatZodError()`, `suggestFieldType()` diff --git a/packages/spec/src/compose-stacks.test.ts b/packages/spec/src/compose-stacks.test.ts new file mode 100644 index 0000000000..da045e1522 --- /dev/null +++ b/packages/spec/src/compose-stacks.test.ts @@ -0,0 +1,489 @@ +import { describe, it, expect } from 'vitest'; +import { + composeStacks, + ComposeStacksOptionsSchema, + ConflictStrategySchema, + defineStack, + type ObjectStackDefinition, + type ComposeStacksOptions, + type ConflictStrategy, +} from './stack.zod'; + +// ─── Helper factories ────────────────────────────────────────────── + +function makeStack(overrides: Partial = {}): ObjectStackDefinition { + return defineStack({ ...overrides }, { strict: false }); +} + +const manifest1 = { + id: 'com.example.crm', + name: 'crm', + version: '1.0.0', + type: 'app' as const, +}; + +const manifest2 = { + id: 'com.example.todo', + name: 'todo', + version: '2.0.0', + type: 'app' as const, +}; + +// ─── Schema tests ────────────────────────────────────────────────── + +describe('ConflictStrategySchema', () => { + it('should accept valid strategies', () => { + expect(ConflictStrategySchema.parse('error')).toBe('error'); + expect(ConflictStrategySchema.parse('override')).toBe('override'); + expect(ConflictStrategySchema.parse('merge')).toBe('merge'); + }); + + it('should reject invalid strategies', () => { + expect(() => ConflictStrategySchema.parse('invalid')).toThrow(); + }); +}); + +describe('ComposeStacksOptionsSchema', () => { + it('should apply defaults', () => { + const result = ComposeStacksOptionsSchema.parse({}); + expect(result.objectConflict).toBe('error'); + expect(result.manifest).toBe('last'); + expect(result.namespace).toBeUndefined(); + }); + + it('should accept all valid option combinations', () => { + const opts = ComposeStacksOptionsSchema.parse({ + objectConflict: 'merge', + manifest: 'first', + namespace: 'crm', + }); + expect(opts.objectConflict).toBe('merge'); + expect(opts.manifest).toBe('first'); + expect(opts.namespace).toBe('crm'); + }); + + it('should accept numeric manifest index', () => { + const opts = ComposeStacksOptionsSchema.parse({ manifest: 0 }); + expect(opts.manifest).toBe(0); + }); + + it('should reject negative manifest index', () => { + expect(() => ComposeStacksOptionsSchema.parse({ manifest: -1 })).toThrow(); + }); +}); + +// ─── composeStacks core ──────────────────────────────────────────── + +describe('composeStacks', () => { + it('should return empty stack for empty input', () => { + const result = composeStacks([]); + expect(result).toEqual({}); + }); + + it('should return the single stack unchanged', () => { + const stack = makeStack({ manifest: manifest1 }); + const result = composeStacks([stack]); + expect(result).toBe(stack); + }); + + it('should concatenate apps from multiple stacks', () => { + const crm = makeStack({ + apps: [{ name: 'sales', label: 'Sales', objects: ['account'] }], + }); + const todo = makeStack({ + apps: [{ name: 'tasks', label: 'Tasks', objects: ['task'] }], + }); + + const result = composeStacks([crm, todo]); + expect(result.apps).toHaveLength(2); + expect(result.apps![0].name).toBe('sales'); + expect(result.apps![1].name).toBe('tasks'); + }); + + it('should concatenate views from multiple stacks', () => { + const s1 = makeStack({ + views: [{ list: { type: 'grid', columns: ['title'] } }], + }); + const s2 = makeStack({ + views: [{ list: { type: 'kanban', columns: ['status'] } }], + }); + + const result = composeStacks([s1, s2]); + expect(result.views).toHaveLength(2); + }); + + it('should concatenate dashboards from multiple stacks', () => { + const s1 = makeStack({ + dashboards: [{ name: 'crm_overview', label: 'CRM Overview', widgets: [] }], + }); + const s2 = makeStack({ + dashboards: [{ name: 'todo_stats', label: 'Todo Stats', widgets: [] }], + }); + + const result = composeStacks([s1, s2]); + expect(result.dashboards).toHaveLength(2); + }); + + it('should concatenate reports from multiple stacks', () => { + const s1 = makeStack({ + reports: [{ name: 'sales_report', label: 'Sales', object: 'account', type: 'tabular' }], + }); + const s2 = makeStack({ + reports: [{ name: 'task_report', label: 'Tasks', object: 'task', type: 'tabular' }], + }); + + const result = composeStacks([s1, s2]); + expect(result.reports).toHaveLength(2); + }); + + it('should concatenate pages from multiple stacks', () => { + const s1 = makeStack({ + pages: [{ + name: 'home', + label: 'Home', + type: 'app', + route: '/home', + regions: [{ name: 'main', components: [{ type: 'page:section', properties: {} }] }], + }], + }); + const s2 = makeStack({ + pages: [{ + name: 'settings', + label: 'Settings', + type: 'app', + route: '/settings', + regions: [{ name: 'main', components: [{ type: 'page:section', properties: {} }] }], + }], + }); + + const result = composeStacks([s1, s2]); + expect(result.pages).toHaveLength(2); + }); + + it('should concatenate data (seed datasets) from multiple stacks', () => { + const s1 = makeStack({ + data: [{ object: 'account', records: [{ name: 'Acme' }] }], + }); + const s2 = makeStack({ + data: [{ object: 'task', records: [{ subject: 'Test' }] }], + }); + + const result = composeStacks([s1, s2]); + expect(result.data).toHaveLength(2); + }); + + it('should concatenate plugins and devPlugins', () => { + const s1 = makeStack({ plugins: ['plugin-a'], devPlugins: ['dev-a'] }); + const s2 = makeStack({ plugins: ['plugin-b'], devPlugins: ['dev-b'] }); + + const result = composeStacks([s1, s2]); + expect(result.plugins).toEqual(['plugin-a', 'plugin-b']); + expect(result.devPlugins).toEqual(['dev-a', 'dev-b']); + }); + + it('should skip undefined array fields (no empty arrays in output)', () => { + const s1 = makeStack({ objects: [{ name: 'task', fields: { title: { type: 'text' } } }] }); + const s2 = makeStack({ objects: [{ name: 'project', fields: { name: { type: 'text' } } }] }); + + const result = composeStacks([s1, s2]); + expect(result.apps).toBeUndefined(); + expect(result.views).toBeUndefined(); + expect(result.dashboards).toBeUndefined(); + }); + + it('should compose three or more stacks', () => { + const s1 = makeStack({ + apps: [{ name: 'app1', label: 'App 1' }], + objects: [{ name: 'obj_a', fields: { f: { type: 'text' } } }], + }); + const s2 = makeStack({ + apps: [{ name: 'app2', label: 'App 2' }], + objects: [{ name: 'obj_b', fields: { f: { type: 'text' } } }], + }); + const s3 = makeStack({ + apps: [{ name: 'app3', label: 'App 3' }], + objects: [{ name: 'obj_c', fields: { f: { type: 'text' } } }], + }); + + const result = composeStacks([s1, s2, s3]); + expect(result.apps).toHaveLength(3); + expect(result.objects).toHaveLength(3); + }); +}); + +// ─── Manifest strategy ───────────────────────────────────────────── + +describe('composeStacks - manifest strategy', () => { + it('should use last manifest by default', () => { + const s1 = makeStack({ manifest: manifest1 }); + const s2 = makeStack({ manifest: manifest2 }); + + const result = composeStacks([s1, s2]); + expect(result.manifest?.name).toBe('todo'); + }); + + it('should use first manifest when specified', () => { + const s1 = makeStack({ manifest: manifest1 }); + const s2 = makeStack({ manifest: manifest2 }); + + const result = composeStacks([s1, s2], { manifest: 'first' }); + expect(result.manifest?.name).toBe('crm'); + }); + + it('should use manifest at specific index', () => { + const s1 = makeStack({ manifest: manifest1 }); + const s2 = makeStack({ manifest: manifest2 }); + + const result = composeStacks([s1, s2], { manifest: 0 }); + expect(result.manifest?.name).toBe('crm'); + + const result2 = composeStacks([s1, s2], { manifest: 1 }); + expect(result2.manifest?.name).toBe('todo'); + }); + + it('should skip stacks without manifest for first/last', () => { + const s1 = makeStack({}); + const s2 = makeStack({ manifest: manifest2 }); + + const result = composeStacks([s1, s2], { manifest: 'first' }); + expect(result.manifest?.name).toBe('todo'); + }); + + it('should return undefined manifest when no stacks have one', () => { + const s1 = makeStack({}); + const s2 = makeStack({}); + + const result = composeStacks([s1, s2]); + expect(result.manifest).toBeUndefined(); + }); +}); + +// ─── i18n handling ────────────────────────────────────────────────── + +describe('composeStacks - i18n', () => { + it('should use last i18n config (last-wins)', () => { + const s1 = makeStack({ i18n: { defaultLocale: 'en', supportedLocales: ['en'] } }); + const s2 = makeStack({ i18n: { defaultLocale: 'zh', supportedLocales: ['zh', 'en'] } }); + + const result = composeStacks([s1, s2]); + expect(result.i18n?.defaultLocale).toBe('zh'); + }); + + it('should skip stacks without i18n', () => { + const s1 = makeStack({ i18n: { defaultLocale: 'en', supportedLocales: ['en'] } }); + const s2 = makeStack({}); + + const result = composeStacks([s1, s2]); + expect(result.i18n?.defaultLocale).toBe('en'); + }); +}); + +// ─── Object conflict strategy ─────────────────────────────────────── + +describe('composeStacks - objectConflict: error (default)', () => { + it('should merge non-overlapping objects from multiple stacks', () => { + const s1 = makeStack({ + objects: [{ name: 'account', fields: { name: { type: 'text' } } }], + }); + const s2 = makeStack({ + objects: [{ name: 'task', fields: { title: { type: 'text' } } }], + }); + + const result = composeStacks([s1, s2]); + expect(result.objects).toHaveLength(2); + expect(result.objects![0].name).toBe('account'); + expect(result.objects![1].name).toBe('task'); + }); + + it('should throw on duplicate object names by default', () => { + const s1 = makeStack({ + objects: [{ name: 'account', fields: { name: { type: 'text' } } }], + }); + const s2 = makeStack({ + objects: [{ name: 'account', fields: { email: { type: 'email' } } }], + }); + + expect(() => composeStacks([s1, s2])).toThrow("object 'account' is defined in multiple stacks"); + }); + + it('should throw on duplicate objects explicitly with error strategy', () => { + const s1 = makeStack({ + objects: [{ name: 'contact', fields: { name: { type: 'text' } } }], + }); + const s2 = makeStack({ + objects: [{ name: 'contact', fields: { phone: { type: 'phone' } } }], + }); + + expect(() => composeStacks([s1, s2], { objectConflict: 'error' })).toThrow('contact'); + }); +}); + +describe('composeStacks - objectConflict: override', () => { + it('should override duplicate objects (last-wins)', () => { + const s1 = makeStack({ + objects: [{ + name: 'account', + label: 'Account v1', + fields: { name: { type: 'text' }, old_field: { type: 'text' } }, + }], + }); + const s2 = makeStack({ + objects: [{ + name: 'account', + label: 'Account v2', + fields: { name: { type: 'text' }, new_field: { type: 'number' } }, + }], + }); + + const result = composeStacks([s1, s2], { objectConflict: 'override' }); + expect(result.objects).toHaveLength(1); + expect(result.objects![0].label).toBe('Account v2'); + expect(result.objects![0].fields).toHaveProperty('new_field'); + // old_field should NOT be present (full override) + expect(result.objects![0].fields).not.toHaveProperty('old_field'); + }); + + it('should keep non-overlapping objects alongside overridden ones', () => { + const s1 = makeStack({ + objects: [ + { name: 'account', fields: { name: { type: 'text' } } }, + { name: 'task', fields: { title: { type: 'text' } } }, + ], + }); + const s2 = makeStack({ + objects: [{ name: 'account', fields: { email: { type: 'email' } } }], + }); + + const result = composeStacks([s1, s2], { objectConflict: 'override' }); + expect(result.objects).toHaveLength(2); + expect(result.objects![0].name).toBe('account'); + expect(result.objects![0].fields).toHaveProperty('email'); + expect(result.objects![1].name).toBe('task'); + }); +}); + +describe('composeStacks - objectConflict: merge', () => { + it('should shallow-merge duplicate objects (later fields win)', () => { + const s1 = makeStack({ + objects: [{ + name: 'account', + label: 'Account v1', + fields: { name: { type: 'text' }, industry: { type: 'text' } }, + }], + }); + const s2 = makeStack({ + objects: [{ + name: 'account', + label: 'Account v2', + fields: { email: { type: 'email' } }, + }], + }); + + const result = composeStacks([s1, s2], { objectConflict: 'merge' }); + expect(result.objects).toHaveLength(1); + expect(result.objects![0].label).toBe('Account v2'); + // Fields from both stacks should be present + expect(result.objects![0].fields).toHaveProperty('name'); + expect(result.objects![0].fields).toHaveProperty('industry'); + expect(result.objects![0].fields).toHaveProperty('email'); + }); + + it('should let later field definitions win on field-level conflicts', () => { + const s1 = makeStack({ + objects: [{ + name: 'account', + fields: { status: { type: 'text' } }, + }], + }); + const s2 = makeStack({ + objects: [{ + name: 'account', + fields: { status: { type: 'select', options: [{ label: 'Active', value: 'active' }] } }, + }], + }); + + const result = composeStacks([s1, s2], { objectConflict: 'merge' }); + expect(result.objects![0].fields.status.type).toBe('select'); + }); + + it('should merge three stacks with overlapping objects', () => { + const s1 = makeStack({ + objects: [{ name: 'account', fields: { name: { type: 'text' } } }], + }); + const s2 = makeStack({ + objects: [{ name: 'account', fields: { email: { type: 'email' } } }], + }); + const s3 = makeStack({ + objects: [{ name: 'account', fields: { phone: { type: 'phone' } } }], + }); + + const result = composeStacks([s1, s2, s3], { objectConflict: 'merge' }); + expect(result.objects).toHaveLength(1); + expect(result.objects![0].fields).toHaveProperty('name'); + expect(result.objects![0].fields).toHaveProperty('email'); + expect(result.objects![0].fields).toHaveProperty('phone'); + }); +}); + +// ─── Type safety checks ──────────────────────────────────────────── + +describe('composeStacks - type safety', () => { + it('return type should be ObjectStackDefinition', () => { + const stack: ObjectStackDefinition = composeStacks([]); + expect(stack).toBeDefined(); + }); + + it('options type should accept ComposeStacksOptions', () => { + const opts: ComposeStacksOptions = { + objectConflict: 'merge', + manifest: 'first', + namespace: 'test', + }; + // Should not throw + const result = composeStacks([], opts); + expect(result).toBeDefined(); + }); + + it('ConflictStrategy type should match enum values', () => { + const strategies: ConflictStrategy[] = ['error', 'override', 'merge']; + for (const s of strategies) { + expect(ConflictStrategySchema.parse(s)).toBe(s); + } + }); +}); + +// ─── Integration with defineStack ─────────────────────────────────── + +describe('composeStacks + defineStack integration', () => { + it('should compose outputs of defineStack', () => { + const crm = defineStack({ + manifest: manifest1, + objects: { account: { fields: { name: { type: 'text' } } } }, + apps: { sales: { label: 'Sales', objects: ['account'] } }, + }, { strict: false }); + + const todo = defineStack({ + manifest: manifest2, + objects: { task: { fields: { title: { type: 'text' } } } }, + apps: { todo_app: { label: 'Todo', objects: ['task'] } }, + }, { strict: false }); + + const combined = composeStacks([crm, todo]); + expect(combined.objects).toHaveLength(2); + expect(combined.apps).toHaveLength(2); + expect(combined.manifest?.name).toBe('todo'); // last-wins default + }); + + it('composed result should be valid for defineStack re-validation', () => { + const s1 = defineStack({ + objects: [{ name: 'task', fields: { title: { type: 'text' } } }], + }); + const s2 = defineStack({ + objects: [{ name: 'project', fields: { name: { type: 'text' } } }], + }); + + const combined = composeStacks([s1, s2]); + // Re-validate the composed result through defineStack + expect(() => defineStack(combined)).not.toThrow(); + }); +}); diff --git a/packages/spec/src/index.ts b/packages/spec/src/index.ts index 0ffecaceb7..1daa3dd252 100644 --- a/packages/spec/src/index.ts +++ b/packages/spec/src/index.ts @@ -63,6 +63,9 @@ export * as Studio from './studio'; export { defineStack, + composeStacks, + ComposeStacksOptionsSchema, + ConflictStrategySchema, ObjectStackDefinitionSchema, ObjectStackSchema, ObjectStackCapabilitiesSchema, @@ -71,7 +74,7 @@ export { ObjectOSCapabilitiesSchema } from './stack.zod'; -export type { DefineStackOptions, ObjectStackDefinitionInput } from './stack.zod'; +export type { DefineStackOptions, ComposeStacksOptions, ConflictStrategy, ObjectStackDefinitionInput } from './stack.zod'; export * from './stack.zod'; diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index 25d4469084..8e441a283c 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -404,6 +404,227 @@ export function defineStack( } +// ─── composeStacks ────────────────────────────────────────────────── + +/** + * Strategy for resolving conflicts when multiple stacks define the same named item. + * + * - `'error'` — Throw an error when a duplicate name is detected (default). + * - `'override'` — Last stack wins; later definitions replace earlier ones. + * - `'merge'` — Shallow-merge items with the same name (later fields win). + */ +export const ConflictStrategySchema = z.enum(['error', 'override', 'merge']); +export type ConflictStrategy = z.infer; + +/** + * Options for {@link composeStacks}. + */ +export const ComposeStacksOptionsSchema = z.object({ + /** + * How to handle same-name objects across stacks. + * @default 'error' + */ + objectConflict: ConflictStrategySchema.default('error'), + + /** + * Which manifest to keep when multiple stacks provide one. + * - `'first'` — Use the first manifest found. + * - `'last'` — Use the last manifest found (default). + * - A number — Use the manifest from the stack at the given index. + * @default 'last' + */ + manifest: z.union([z.enum(['first', 'last']), z.number().int().min(0)]).default('last'), + + /** + * Optional namespace prefix (reserved for Phase 2 — Marketplace isolation). + * When set, object names from this composition are prefixed for isolation. + */ + namespace: z.string().optional(), +}); + +export type ComposeStacksOptions = z.input; + +/** + * All array fields on `ObjectStackDefinition` that are simply concatenated. + * @internal + */ +const CONCAT_ARRAY_FIELDS = [ + 'datasources', + 'translations', + 'objectExtensions', + 'apps', + 'views', + 'pages', + 'dashboards', + 'reports', + 'actions', + 'themes', + 'workflows', + 'approvals', + 'flows', + 'roles', + 'permissions', + 'sharingRules', + 'policies', + 'apis', + 'webhooks', + 'agents', + 'ragPipelines', + 'hooks', + 'mappings', + 'analyticsCubes', + 'connectors', + 'data', + 'plugins', + 'devPlugins', +] as const satisfies readonly (keyof ObjectStackDefinition)[]; + +/** + * Merge objects from multiple stacks according to the chosen conflict strategy. + * @internal + */ +function mergeObjects( + stacks: ObjectStackDefinition[], + strategy: ConflictStrategy, +): ObjectStackDefinition['objects'] { + type Obj = NonNullable[number]; + const map = new Map(); + const result: Obj[] = []; + + for (const stack of stacks) { + if (!stack.objects) continue; + for (const obj of stack.objects) { + const existing = map.get(obj.name); + if (!existing) { + map.set(obj.name, obj); + result.push(obj); + continue; + } + + switch (strategy) { + case 'error': + throw new Error( + `composeStacks conflict: object '${obj.name}' is defined in multiple stacks. ` + + `Use { objectConflict: 'override' } or { objectConflict: 'merge' } to resolve.`, + ); + case 'override': { + // Replace in-place in the result array + const idx = result.indexOf(existing); + result[idx] = obj; + map.set(obj.name, obj); + break; + } + case 'merge': { + const merged = { ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } } as Obj; + const idx = result.indexOf(existing); + result[idx] = merged; + map.set(obj.name, merged); + break; + } + } + } + } + + return result.length > 0 ? result : undefined; +} + +/** + * Select the manifest to use from multiple stacks. + * @internal + */ +function selectManifest( + stacks: ObjectStackDefinition[], + strategy: 'first' | 'last' | number, +): ObjectStackDefinition['manifest'] { + if (typeof strategy === 'number') { + return stacks[strategy]?.manifest; + } + if (strategy === 'first') { + for (const s of stacks) { + if (s.manifest) return s.manifest; + } + return undefined; + } + // 'last' (default) + for (let i = stacks.length - 1; i >= 0; i--) { + if (stacks[i].manifest) return stacks[i].manifest; + } + return undefined; +} + +/** + * Declaratively compose multiple stack definitions into a single unified stack. + * + * This eliminates the manual `...spread` merging pattern when combining + * multiple applications (e.g., CRM + Todo + BI) into a single project. + * + * **Array fields** (apps, views, dashboards, etc.) are concatenated in order. + * **Objects** are merged according to the `objectConflict` strategy. + * **Manifest** is selected based on the `manifest` option. + * + * @param stacks - Stack definitions to compose (order matters for conflict resolution) + * @param options - Composition options (conflict strategy, manifest selection, etc.) + * @returns A single merged `ObjectStackDefinition` + * + * @example + * ```ts + * import { composeStacks, defineStack } from '@objectstack/spec'; + * + * const crm = defineStack({ ... }); + * const todo = defineStack({ ... }); + * + * // Simple composition — throws on duplicate objects + * const combined = composeStacks([crm, todo]); + * + * // Override strategy — later stacks win + * const combined = composeStacks([crm, todo], { objectConflict: 'override' }); + * + * // Merge strategy — fields from later stacks are shallow-merged + * const combined = composeStacks([crm, todo], { objectConflict: 'merge' }); + * ``` + */ +export function composeStacks( + stacks: ObjectStackDefinition[], + options?: ComposeStacksOptions, +): ObjectStackDefinition { + if (stacks.length === 0) return {} as ObjectStackDefinition; + if (stacks.length === 1) return stacks[0]; + + const opts = ComposeStacksOptionsSchema.parse(options ?? {}); + + const composed: Record = {}; + + // 1. Manifest — pick based on strategy + composed.manifest = selectManifest(stacks, opts.manifest); + + // 2. i18n — last-wins (single object, not array) + for (let i = stacks.length - 1; i >= 0; i--) { + if (stacks[i].i18n) { + composed.i18n = stacks[i].i18n; + break; + } + } + + // 3. Objects — use conflict strategy + const objects = mergeObjects(stacks, opts.objectConflict); + if (objects) { + composed.objects = objects; + } + + // 4. All other array fields — simple concatenation + for (const field of CONCAT_ARRAY_FIELDS) { + const arrays = stacks + .map((s) => (s as Record)[field]) + .filter((v): v is unknown[] => Array.isArray(v)); + if (arrays.length > 0) { + composed[field] = arrays.flat(); + } + } + + return composed as ObjectStackDefinition; +} + + /** * 2. RUNTIME CAPABILITIES PROTOCOL (Dynamic) * ----------------------------------------------------------------------