Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions .changeset/16075-merge-config-object-refused.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
'@objectstack/spec': minor
---

feat(spec)!: `composeStacks` `objectConflict: 'merge'` refuses a fixed-shape config object both objects declare with different values (#16075)

<!-- 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. -->

**BREAKING** accept-set narrowing on `composeStacks({ objectConflict: 'merge' })`
— shipped as `minor` under the repo's launch-window convention for breaking
changes. Maintainer ruling on #16075 (ruling record 5563452716, director
decision batch #61, option 1, verbatim 「同意」): the #14848 refusal extends to
fixed-shape config objects.

**What changed.** #14848 made `'merge'` refuse every object-level
**collection** two stacks declare differently, and left everything else on
later-wins. "Everything else" included eight **fixed-shape config objects** on
`ObjectSchema` — `userActions`, `external`, `tenancy`, `access`, `lifecycle`,
`enable`, `publicSharing`, `protection`. Measured on `main` @ `44ce049a8`
before this change, each of the eight composed to the LATER object's
declaration wholesale, with nothing said: `enable: { trackHistory: true }`
beside `enable: { apiEnabled: true }` lost `trackHistory`, and an add-on
package's `access: { default: 'public' }` switched a core package's
`access: { default: 'private' }` off — the posture downgrade `composeStacks`
already refuses at the top level for `api` / `server`.

Now, when both objects declare one of them with different values,
`composeStacks` throws the refusal it throws for a collection — same code
(`STACK_COMPOSE_COLLECTION_CONFLICT`), same `status: 422`, same three-line
shape — naming the object, the key and both stacks by manifest id:

```
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).
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.
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.
```

The config-object half of the refusal set is **derived from `ObjectSchema`'s
shape**, like the collection half — every key whose declared type, through
optional/default wrappers, a `lazy` or a `pipe`'s authored side, is a plain
object and not a collection — so a config object added to the object schema
joins the refusal without an edit to the composer. The collection refusal's
message now lists both kinds; its first and last lines are unchanged.

**What did not change.**

- `fields` keeps its documented shallow merge (later fields win, earlier
fields kept).
- **Identical** declarations on both sides pass through and are carried once
— the reading `'merge'` already gives an identical collection. Because the
strict parse fills a config object's member defaults, "identical" is judged
on the parsed objects: `enable: { apiEnabled: true }` and
`enable: { apiEnabled: true, trackHistory: false }` are the same declaration.
- A config object only the earlier object declares is kept; a later object
that does not declare it (or declares it `undefined`) leaves it in place.
- A **scalar** the later object declares (`label`, `sharingModel`, …) still
replaces the earlier one. So does a key whose type is a **union** admitting
an object beside a non-object form — `systemFields` (`false` or an options
object) and `titleFormat` (a template string or an expression object): a
union is not a fixed shape, and the ruling covers the fixed-shape keys only.
- The default `'error'` and `'override'` are untouched, message for message.

**Who is affected.** Measured on `origin/main` @ `44ce049a8`: **zero**
non-test call sites in `packages/**`, `examples/**`, `apps/**` pass
`objectConflict` at all — the one non-test `composeStacks` call
(`examples/app-multi-package`) passes `{ manifest: 'preserve' }` and takes the
default `'error'`. An external author who opted into `'merge'` and relied on
the later package's config object winning silently now gets the refusal above;
the fix is the one it names.

Clause-②: yes
5 changes: 3 additions & 2 deletions packages/spec/src/compose-stacks-collection-pipe-arm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
/**
* [#19150] `declaresCollection` reads a `pipe` on the side the AUTHOR writes.
*
* The walker behind `objectCollectionKeys()` — the key set
* The walker behind `objectUnmergeableKeys()` (named `objectCollectionKeys()`
* until #16075 added config objects to it) — the key set
* `objectConflict: 'merge'` refuses to combine (#14848) — read only `def.in`
* on its `pipe` arm. `z.preprocess(fn, schema)` puts a transform STAGE in `in`
* and the real, validated schema in `out`, the opposite of `a.transform(fn)`,
Expand Down Expand Up @@ -63,7 +64,7 @@ const NAMES = vi.hoisted(() => ({
}));

// The probe keys ride on `ObjectSchema.shape` because that shape is the ONLY
// input `objectCollectionKeys()` reads. Nothing else in the module graph is
// input `objectUnmergeableKeys()` reads. Nothing else in the module graph is
// replaced: the factory spreads the real module and the real shape.
vi.mock('./data/object.zod', async (importOriginal) => {
const actual = await importOriginal<typeof import('./data/object.zod')>();
Expand Down
66 changes: 56 additions & 10 deletions packages/spec/src/compose-stacks-merge-collection-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,19 @@
* `[archive]`). Maintainer ruling (2026-09-04, option 4): refuse, in the shape
* `'error'` uses, naming the object, the colliding collection and both
* package ids; `fields` keeps its shallow merge; identical declarations pass
* (as `composeSingleValue` passes identical top-level values); scalars and
* fixed-shape config objects stay on later-wins.
* (as `composeSingleValue` passes identical top-level values); scalars stay on
* later-wins. Fixed-shape config objects stayed on later-wins too until
* #16075 (ruling 5563452716, option 1) extended this same refusal to them —
* pinned in `compose-stacks-merge-config-object-refusal.test.ts`; the message
* below enumerates both kinds.
*
* The refusal set is DERIVED from `ObjectSchema`'s shape (every array- or
* record-typed key but `fields`), so the last block pins it against the shape
* in BOTH directions with an independent walk: every collection key refuses,
* every other key composes. The literal list beside it is the reviewer's copy
* — a new collection key on the object schema joins the refusal without an
* edit to `stack.zod.ts`, and shows up here as a one-line diff.
* record-typed key but `fields`, plus every fixed-shape config object), so the
* last block pins it against the shape in BOTH directions with an independent
* walk: every collection or config-object key refuses, every other key
* composes. The literal list beside it is the reviewer's copy — a new
* collection key on the object schema joins the refusal without an edit to
* `stack.zod.ts`, and shows up here as a one-line diff.
*/
import { describe, it, expect } from 'vitest';
import { composeStacks, defineStack, type ObjectStackDefinition } from './stack.zod';
Expand Down Expand Up @@ -67,12 +71,29 @@ const COLLECTION_KEYS_IN_SHAPE_ORDER = [
'actions',
] as const;

/**
* The fixed-shape config objects the same refusal covers since #16075, in
* shape order — the second list the refusal prints. Pinned against the shape
* in `compose-stacks-merge-config-object-refusal.test.ts`.
*/
const CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER = [
'userActions',
'external',
'tenancy',
'access',
'lifecycle',
'enable',
'publicSharing',
'protection',
] as const;

const REFUSED = (key: string, holder: string, later: string) =>
`composeStacks conflict: object 'shared' is defined in multiple stacks and its '${key}' is declared with ` +
`different values by ${holder} and ${later}.`;
const WHY = (holder: string) =>
"objectConflict: 'merge' shallow-merges 'fields' only. Any other object-level collection " +
`(${COLLECTION_KEYS_IN_SHAPE_ORDER.join(', ')}) is not merged: the later declaration would replace the ` +
`(${COLLECTION_KEYS_IN_SHAPE_ORDER.join(', ')}) is not merged, and neither is a fixed-shape config ` +
`object (${CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER.join(', ')}): the later declaration would replace the ` +
`earlier one wholesale, silently dropping every entry ${holder} wrote.`;
const FIX = (key: string) =>
`Fix: declare '${key}' on 'shared' in exactly one of the two stacks, make the two declarations identical, ` +
Expand Down Expand Up @@ -236,13 +257,38 @@ describe('the refusal set is derived from ObjectSchema.shape — pinned in both
return false;
}

/**
* [#16075] Independent walk for the second kind: a fixed-shape config
* object — `object` once wrappers are stripped, through lazy/pipe, and
* deliberately NOT into a union (a union admitting a non-object form is not
* a fixed shape). Asked only of a key that is not a collection.
*/
function isConfigObject(schema: unknown, depth = 0): boolean {
if (depth > 8) return false;
const def = (schema as { _zod?: { def?: Record<string, unknown> } })._zod?.def;
const type = def?.type as string | undefined;
if (!type) return false;
if (type === 'object') return true;
if (['optional', 'nullable', 'default', 'prefault', 'readonly', 'nonoptional', 'catch'].includes(type)) return isConfigObject(def!.innerType, depth + 1);
if (type === 'lazy') return isConfigObject((def!.getter as () => unknown)(), depth + 1);
if (type === 'pipe') return isConfigObject(def!.in, depth + 1) || isConfigObject(def!.out, depth + 1);
return false;
}

const shapeKeys = Object.keys(ObjectSchema.shape);
const derived = shapeKeys.filter((k) => k !== 'fields' && isCollection((ObjectSchema.shape as Record<string, unknown>)[k]));
const derivedConfigObjects = shapeKeys.filter(
(k) => k !== 'fields' && !derived.includes(k) && isConfigObject((ObjectSchema.shape as Record<string, unknown>)[k]),
);

it('the literal list equals the shape walk, in shape order (a new collection key on the object schema lands here as a one-line diff)', () => {
expect(derived).toEqual([...COLLECTION_KEYS_IN_SHAPE_ORDER]);
});

it('the config-object literal list equals the shape walk, in shape order (#16075)', () => {
expect(derivedConfigObjects).toEqual([...CONFIG_OBJECT_KEYS_IN_SHAPE_ORDER]);
});

it("'fields' is a record on the shape and is the one collection excluded by rule", () => {
expect(isCollection((ObjectSchema.shape as Record<string, unknown>).fields)).toBe(true);
expect(derived).not.toContain('fields');
Expand All @@ -257,13 +303,13 @@ describe('the refusal set is derived from ObjectSchema.shape — pinned in both
};

it.each(shapeKeys.filter((k) => k !== 'name' && k !== 'fields'))(
"'%s': refused under 'merge' iff the shape declares it as a collection",
"'%s': refused under 'merge' iff the shape declares it as a collection or a fixed-shape config object",
(key) => {
const [left, right] = valuesFor(key);
const a = defineStack({ manifest: mf('com.example.a'), objects: [obj('shared', { [key]: left })] }, { strict: false });
const b = defineStack({ manifest: mf('com.example.b'), objects: [obj('shared', { [key]: right })] }, { strict: false });
const msg = refusal(() => composeStacks([a, b], { objectConflict: 'merge' }));
if (derived.includes(key)) {
if (derived.includes(key) || derivedConfigObjects.includes(key)) {
expect(msg).toContain(REFUSED(key, A0, B1));
} else {
expect(msg).toBeNull();
Expand Down
Loading
Loading