feat: add composeStacks() for declarative multi-stack composition#721
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Implements the core composeStacks() function that eliminates manual spread-merging when combining multiple stack definitions (CRM + Todo + BI). - ConflictStrategySchema: 'error' | 'override' | 'merge' - ComposeStacksOptionsSchema: objectConflict, manifest strategy, namespace - Array fields (apps, views, dashboards, etc.) are concatenated - Objects support configurable conflict resolution - Manifest selection via 'first', 'last', or index - Exported from package root for convenience - 37 new tests covering all strategies and edge cases Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
composeStacks() for declarative multi-stack composition
There was a problem hiding this comment.
Pull request overview
Adds a new composeStacks() helper to @objectstack/spec to declaratively combine multiple defineStack() outputs into a single ObjectStackDefinition, reducing manual spread-merging across stack fields.
Changes:
- Introduces
composeStacks(stacks, options?)with conflict strategy + manifest selection options and supporting schemas/types. - Exports the new API/schemas/types from the package root.
- Adds a dedicated Vitest suite for
composeStacksand updates the roadmap checklist.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| packages/spec/src/stack.zod.ts | Adds composeStacks() implementation, conflict/option schemas, and merge helpers. |
| packages/spec/src/index.ts | Re-exports composeStacks and related schemas/types from the root entrypoint. |
| packages/spec/src/compose-stacks.test.ts | Adds tests for schema defaults, merge strategies, manifest selection, and integration with defineStack. |
| ROADMAP.md | Marks “Stack Composition” as completed. |
| * Optional namespace prefix (reserved for Phase 2 — Marketplace isolation). | ||
| * When set, object names from this composition are prefixed for isolation. |
There was a problem hiding this comment.
namespace is exposed in ComposeStacksOptionsSchema and its JSDoc says object names are prefixed for isolation when set, but composeStacks() never reads opts.namespace, so the option is currently a no-op and the docs are misleading. Either implement the namespacing behavior (and update all references like apps.objects/report.object/data.object, etc.) or clarify in the schema/JSDoc that this is reserved for Phase 2 and currently unused (and consider omitting it from the schema until implemented).
| * Optional namespace prefix (reserved for Phase 2 — Marketplace isolation). | |
| * When set, object names from this composition are prefixed for isolation. | |
| * Optional namespace prefix (reserved for Phase 2 — Marketplace / tenant isolation). | |
| * | |
| * NOTE: As of the current spec version, this option is **declarative only** and may be | |
| * ignored by runtime composition helpers such as `composeStacks()`. It is preserved in | |
| * the schema for forward-compatibility and tooling, but does not yet trigger automatic | |
| * object-name prefixing or other isolation effects. |
| if (stacks.length === 0) return {} as ObjectStackDefinition; | ||
| if (stacks.length === 1) return stacks[0]; | ||
|
|
||
| const opts = ComposeStacksOptionsSchema.parse(options ?? {}); | ||
|
|
There was a problem hiding this comment.
composeStacks() returns early for stacks.length === 0 / === 1 before parsing options, so invalid options (e.g. { manifest: -1 }) won't be rejected and callers get inconsistent behavior depending on stack count. Consider parsing/validating options before the early returns (or at least when options is provided) to keep runtime validation consistent.
| if (stacks.length === 0) return {} as ObjectStackDefinition; | |
| if (stacks.length === 1) return stacks[0]; | |
| const opts = ComposeStacksOptionsSchema.parse(options ?? {}); | |
| const opts = ComposeStacksOptionsSchema.parse(options ?? {}); | |
| if (stacks.length === 0) return {} as ObjectStackDefinition; | |
| if (stacks.length === 1) return stacks[0]; |
| 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); |
There was a problem hiding this comment.
mergeObjects() uses result.indexOf(existing) on every duplicate to locate the item to replace, which makes conflict resolution O(n^2) in the worst case (many objects with many duplicates across stacks). Consider tracking the index alongside the object in the map (e.g., Map<string, { obj, idx }>), or maintaining a separate name -> index map, to make overrides/merges O(1).
Combining multiple stacks (CRM + Todo + BI) requires manually spread-merging every array field (
objects,apps,dashboards,reports,pages,data, etc.), which is verbose, error-prone, and scales poorly toward marketplace scenarios.New API:
composeStacks(stacks, options?)objectConflict:'error'(default) — throws on duplicate names'override'— last-wins replacement'merge'— shallow-merge fields from later stacks'first'/'last'(default) / numeric index selectionnamespaceoption reserved as Phase 2 extension point for marketplace isolationSchemas
ConflictStrategySchema—z.enum(['error', 'override', 'merge'])ComposeStacksOptionsSchema— typed options with defaultsUsage
37 new tests covering all strategies, edge cases, and
defineStackround-trip validation.Original prompt
✨ Let Copilot coding agent set things up for you — coding agent works faster and does higher quality work when set up for your repo.