|
| 1 | +--- |
| 2 | +'@objectstack/spec': minor |
| 3 | +--- |
| 4 | + |
| 5 | +feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses a fixed-shape config object both objects declare with different values (#16075) |
| 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 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, one step past the #14848 narrowing that answered the same question the same way: two stacks whose same-name objects both declare a fixed-shape config object (`enable`, `access`, `protection`, ...) with different values are now refused under `'merge'` where they used to compose with the earlier declaration silently replaced. The refusal text names the object, the key and both stacks and carries its own fix, no stored metadata row or authored file changes shape, and the repository measures zero non-test call sites passing `objectConflict` at all, so there is no document 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 on #16075 (ruling record 5563452716, director |
| 12 | +decision batch #61, option 1, verbatim 「同意」): the #14848 refusal extends to |
| 13 | +fixed-shape config objects. |
| 14 | + |
| 15 | +**What changed.** #14848 made `'merge'` refuse every object-level |
| 16 | +**collection** two stacks declare differently, and left everything else on |
| 17 | +later-wins. "Everything else" included eight **fixed-shape config objects** on |
| 18 | +`ObjectSchema` — `userActions`, `external`, `tenancy`, `access`, `lifecycle`, |
| 19 | +`enable`, `publicSharing`, `protection`. Measured on `main` @ `44ce049a8` |
| 20 | +before this change, each of the eight composed to the LATER object's |
| 21 | +declaration wholesale, with nothing said: `enable: { trackHistory: true }` |
| 22 | +beside `enable: { apiEnabled: true }` lost `trackHistory`, and an add-on |
| 23 | +package's `access: { default: 'public' }` switched a core package's |
| 24 | +`access: { default: 'private' }` off — the posture downgrade `composeStacks` |
| 25 | +already refuses at the top level for `api` / `server`. |
| 26 | + |
| 27 | +Now, when both objects declare one of them with different values, |
| 28 | +`composeStacks` throws the refusal it throws for a collection — same code |
| 29 | +(`STACK_COMPOSE_COLLECTION_CONFLICT`), same `status: 422`, same three-line |
| 30 | +shape — naming the object, the key and both stacks by manifest id: |
| 31 | + |
| 32 | +``` |
| 33 | +composeStacks conflict: object 'shared' is defined in multiple stacks and its 'access' is declared with different values by 'com.example.a' (stack #0) and 'com.example.b' (stack #1). |
| 34 | +objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection (indexes, fieldGroups, requiredPermissions, validations, activityMilestones, highlightFields, listViews, searchableFields, actions) is not merged, and neither is a fixed-shape config object (userActions, external, tenancy, access, lifecycle, enable, publicSharing, protection): the later declaration would replace the earlier one wholesale, silently dropping every member 'com.example.a' (stack #0) set. |
| 35 | +Fix: declare 'access' 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. |
| 36 | +``` |
| 37 | + |
| 38 | +The config-object half of the refusal set is **derived from `ObjectSchema`'s |
| 39 | +shape**, like the collection half — every key whose declared type, through |
| 40 | +optional/default wrappers, a `lazy` or a `pipe`'s authored side, is a plain |
| 41 | +object and not a collection — so a config object added to the object schema |
| 42 | +joins the refusal without an edit to the composer. The collection refusal's |
| 43 | +message now lists both kinds; its first and last lines are unchanged. |
| 44 | + |
| 45 | +**What did not change.** |
| 46 | + |
| 47 | +- `fields` keeps its documented shallow merge (later fields win, earlier |
| 48 | + fields kept). |
| 49 | +- **Identical** declarations on both sides pass through and are carried once |
| 50 | + — the reading `'merge'` already gives an identical collection. Because the |
| 51 | + strict parse fills a config object's member defaults, "identical" is judged |
| 52 | + on the parsed objects: `enable: { apiEnabled: true }` and |
| 53 | + `enable: { apiEnabled: true, trackHistory: false }` are the same declaration. |
| 54 | +- A config object only the earlier object declares is kept; a later object |
| 55 | + that does not declare it (or declares it `undefined`) leaves it in place. |
| 56 | +- A **scalar** the later object declares (`label`, `sharingModel`, …) still |
| 57 | + replaces the earlier one. So does a key whose type is a **union** admitting |
| 58 | + an object beside a non-object form — `systemFields` (`false` or an options |
| 59 | + object) and `titleFormat` (a template string or an expression object): a |
| 60 | + union is not a fixed shape, and the ruling covers the fixed-shape keys only. |
| 61 | +- The default `'error'` and `'override'` are untouched, message for message. |
| 62 | + |
| 63 | +**Who is affected.** Measured on `origin/main` @ `44ce049a8`: **zero** |
| 64 | +non-test call sites in `packages/**`, `examples/**`, `apps/**` pass |
| 65 | +`objectConflict` at all — the one non-test `composeStacks` call |
| 66 | +(`examples/app-multi-package`) passes `{ manifest: 'preserve' }` and takes the |
| 67 | +default `'error'`. An external author who opted into `'merge'` and relied on |
| 68 | +the later package's config object winning silently now gets the refusal above; |
| 69 | +the fix is the one it names. |
| 70 | + |
| 71 | +Clause-②: yes |
0 commit comments