|
| 1 | +--- |
| 2 | +"@objectstack/spec": minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses object pairs whose object-level collections cannot be merged (#14848) |
| 6 | + |
| 7 | +<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable is renamed, retired or re-typed: every object key, every `composeStacks` option and the `ConflictStrategySchema` enum (`'error' | 'override' | 'merge'`) parse exactly as before, so `objectstack migrate meta` has nothing to rewrite. What narrows is the ACCEPT SET of one option value at composition time — two stacks whose same-name objects both declare an object-level collection with different values are now refused under `'merge'` where they used to compose with the earlier stack's entries silently dropped. The refusal text carries the whole prescription (declare the collection in one stack, make the declarations identical, or use `'override'`), and the repository measures zero non-test call sites passing `objectConflict` at all (see below), so there is no stored artifact and no authored file for a migration to act on. --> |
| 8 | + |
| 9 | +**BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })` |
| 10 | +— shipped as `minor` under the repo's launch-window convention for breaking |
| 11 | +changes. Maintainer ruling 2026-09-04 on #14848 (director decision batch #38 |
| 12 | +item 5, verbatim 「同意」): option 4, `'merge'` **refuses** what it cannot merge |
| 13 | +instead of dropping it. |
| 14 | + |
| 15 | +**What changed.** `'merge'` was implemented as |
| 16 | +`{ ...existing, ...obj, fields: { ...existing.fields, ...obj.fields } }`: |
| 17 | +`fields` was the only key merged, and every other key the later object carried |
| 18 | +— `actions`, `indexes`, `listViews`, `validations`, … — replaced the earlier |
| 19 | +package's value wholesale, with nothing at compose, build or boot saying so. |
| 20 | +Two packages each embedding an action on one shared object composed to the |
| 21 | +later package's array alone; the earlier package's action was gone. |
| 22 | + |
| 23 | +Now, when both objects declare an object-level **collection** other than |
| 24 | +`fields` with different values, `composeStacks` throws — the refusal shape |
| 25 | +`'error'` uses — naming the object, the colliding collection and both stacks |
| 26 | +by manifest id: |
| 27 | + |
| 28 | +``` |
| 29 | +composeStacks conflict: object 'shared' is defined in multiple stacks and its 'actions' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1). |
| 30 | +objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged: the later declaration would replace the earlier one wholesale, silently dropping every entry 'com.example.a' (stack #0) wrote. |
| 31 | +Fix: declare 'actions' on 'shared' in exactly one of the two stacks, make the two declarations identical, or use { objectConflict: 'override' } to hand the whole object to the later stack. |
| 32 | +``` |
| 33 | + |
| 34 | +The refusal set is **derived from `ObjectSchema`'s shape** — every key whose |
| 35 | +declared type is an array or a record (through optional/default wrappers and |
| 36 | +into a union's members), except `fields` — not hand-listed, so a collection key |
| 37 | +added to the object schema joins the refusal without an edit to the composer. |
| 38 | +Today that set is `actions`, `activityMilestones`, `fieldGroups`, |
| 39 | +`highlightFields`, `indexes`, `listViews`, `requiredPermissions`, |
| 40 | +`searchableFields`, `validations`. |
| 41 | + |
| 42 | +**What did not change.** |
| 43 | + |
| 44 | +- `fields` keeps its documented shallow merge (later fields win, earlier |
| 45 | + fields kept). |
| 46 | +- **Identical** declarations on both sides pass through and are carried once |
| 47 | + — the same reading `composeStacks` already gives identical top-level values |
| 48 | + — so two built stacks that each bind one standalone action to the same |
| 49 | + object (identical copies) still reach the cross-stack action-key check |
| 50 | + (#14662) and are refused there, by name, as before. |
| 51 | +- A scalar or fixed-shape config object the later object declares (`label`, |
| 52 | + `sharingModel`, `enable`, `access`, …) still replaces the earlier one: the |
| 53 | + ruling narrows collections only, and the docblock now says so. |
| 54 | +- The default `'error'` and `'override'` are untouched, message for message. |
| 55 | +- An explicit `undefined` on the later object is read as no declaration — it |
| 56 | + neither counts as a differing value nor erases what the earlier stack |
| 57 | + declared (the bare spread used to let it). |
| 58 | + |
| 59 | +**Who is affected.** Measured on `origin/main` @ `53cbad9f7`: **zero** non-test |
| 60 | +call sites in `packages/**`, `examples/**`, `apps/**` pass `objectConflict` at |
| 61 | +all — every real caller takes the default `'error'`. An external author who |
| 62 | +opted into `'merge'` and relied on the later package's collection winning |
| 63 | +silently now gets the refusal above; the fix is the one it names. |
| 64 | + |
| 65 | +The `ConflictStrategySchema` docblock for `'merge'` states the rule. |
0 commit comments