Skip to content

Commit 581d294

Browse files
fix(objectql): the object fold names a field two extensions declare, and which definition it serves (#28876)
<!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1X4oSWnFYFtpNR7L96S4qiN1)_ Fixes #28835 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:对象合并时,两个扩展在 `fields` 里声明了同名字段,或某个扩展在 `fields` 里重新声明了所有者自己的字段,注册表现在会在日志里记一行:对象、字段、按合并顺序列出的所有声明包,以及最终生效的是哪个包的定义。同一注册表只记一次。 - **为什么改**:合并按 `priority` 升序、同优先级按注册先后,后者直接覆盖前者,之前没有任何提示。#28811 开放 `object_extension` 条目后,Studio 也能走到这条路径。 - **风险与回滚**:只加一行 `warn`,生效的定义一个字节不变,不拒绝任何东西,保存、发布、`os validate` 都没动。仓库里出货的包和示例没有一处这样冲突。回滚即还原本 PR。 - **先看**:`packages/objectql/src/registry.ts` 的 `reportRedeclaredFields`,以及 `foldExtendersOntoDefinition` 循环里新增的 `folded.push` 一行。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/registry-extension-redeclared-field.test.ts`,预期 10/10。 - **需要你做的**:无,席位落地。 ## What changes - **One warning at the fold.** `foldExtendersOntoDefinition` records each extension's `fields` as the merge took them (after the audit and `queue` governance), in fold order. After the loop, `reportRedeclaredFields` finds every field name declared by more than one contributor: two extensions, or an extension and the owner (a name in `ownFieldNamesOf(owner)`, the set the field-patch judge already uses). For each, it writes one `console.warn` naming the object, the field, every declaring package in fold order (the owner first when it declares the field), and the package whose definition is served, which is the last one the fold applies. - **Once per registry.** Keyed `object|field|packages` in a per-registry set, beside `fieldPatchMissesWarned`. Repeated reads (`getObject`, `foldObjectExtendersOnto`, the code layer) do not repeat it; a second registry says it again. - **Judged from the contributors, not the running body.** A base that already carries the extensions' fields (a folded body handed back to `foldObjectExtendersOnto`, a stored overlay row) is not read as a redeclaration. - **Not counted.** `fieldPatches` (not a declaration; a patch that misses is already named). A `.custom` pair's `__c` field and a dependent's plain field never share a name, so they never meet here; the `__c` judge is untouched. - **Unchanged.** The merged definition, byte for byte, and every door: no change to save, publish, restore, `os validate` or the install door. - **Docs.** `content/docs/data-modeling/object-extensions.mdx` said "nothing warns when they do"; it now says what the log names, and that the one registered last wins at equal priority. ## Why The PR #28811 contract review (record `6565271420`, should-fix 2) found that two extenders of one object declaring the same field name resolve last-writer-wins with no refusal and no report. The seat's route: report at the fold, keep today's winner, refuse nothing. ## Tests `packages/objectql/src/registry-extension-redeclared-field.test.ts`, 10 cases. Warning cases (each asserts the served definition first, then the line): - two dependents both add `region` at priority 200: the one registered last is served, one line names both and the served one; - the same by priority: the 300 registered first is served and named; - an extension redeclaring the owner's `industry`: one line names the owner, the extension and the served one; - the owner and two extensions on one field: one line naming all three in fold order; - two `.custom` pairs both adding `region__c`; - a second registry names it again. Controls (nothing named): one extension adding its own field; a pair's `region__c` beside a dependent's `region`; `fieldPatches` beside an added field; a folded body handed back to the fold. Every case reads `getObject`, `foldObjectExtendersOnto` and `getArtifactItem` three times with the cache dropped between. <details><summary>Red first and ablation (head ce7c7fa)</summary> - **Red first.** `registry.ts` checked out from main `7e60dc23`: `Tests 6 failed | 4 passed (10)`. All six fail on the line count (`expected [] to have a length of 1 but got +0`); the served-definition assertions before them pass, so the winner is the same on main. Restore: `git checkout HEAD -- packages/objectql/src/registry.ts`, `git diff HEAD` empty, porcelain empty. - **Ablation** (`scripts/ablation-replace.mjs`, the `this.reportRedeclaredFields(...)` call replaced by `void 0`), run over this file and `registry-extension-field-patches.test.ts`: `Tests 6 failed | 21 passed (27)`, the same six warning cases; the four controls and #28809's 17 stay green. `ok restored: blob == HEAD (c47d8f1) and git diff HEAD is empty`, marker count 0. - The test imports `./registry.js` from `src/`, so no `dist/` leg is involved. </details> <details><summary>Suites, typecheck, lint, gates (head ce7c7fa)</summary> After merging main (with #28836) and a whole-repo `pnpm build` (VERDICT command-exit 0): - objectql full suite: `Test Files 524 passed (524)`, `Tests 10036 passed (10036)`. - objectql `pnpm typecheck`: exit 0, `check:test-typecheck: OK` (39 files / 233 errors / 64 signatures held, unchanged). - `eslint --no-inline-config` on the two touched source files: exit 0. - Tests that spy on `console.warn` beside an `extend` registration in other packages: rest `rest-exposure-gate-one-object-read` 37/37, runtime `package-uninstall-store-refusal.integration` 5/5, cloud-connection `marketplace-install-local-field-patch-targets` 8/8. - `dispatch-gates.mjs --commands`: 92 derived, 92 run, all exit 0. `--ran`: 92 derived, 92 run, 0 NOT-MEASURED. - `check-nul-bytes.mjs`: exit 0. </details> ## Acceptance notes - **Measured: no shipped collision.** `packages/**`, `examples/**` and HotCRM hold two `objectExtensions` entries: `examples/app-showcase` extends its own `showcase_account` (priority 210, `loyalty_tier`, `linkedin_url`, `csat_score`, none declared by the owner), and `plugin-auth` extends `sys_sso_provider` (`oidc_client_secret`, not declared in `platform-objects`). Different objects, so no fold sees two declarers. HotCRM declares none. `packages/qa/downstream-contract` (private) has one fixture extension of `dc_account`, alone. - **Boot probe.** Showcase and plugin-auth registered into one ObjectQL from `dist/`, every one of 54 objects read through `getObject` and `foldObjectExtendersOnto`: 0 lines. Positive control (a third extension redeclaring `showcase_account.name` and `linkedin_url`): 2 lines, each naming the served package. Script: project files `pm-orders/server/evidence/28835/boot-probe.mts`. - **Existing tests now print the line twice, both true redeclarations:** `registry-object-overlay-layer.test.ts` (a package extending its own object with `name`, the overlay counter-factual) and an audit-governance test (an extender declaring `created_at`, served with the platform's `readonly`). Neither asserts on warnings. - **Merging #28836.** After main took #28836, its option-patch tests filter warnings on the substring `fieldPatches`, and their takeover cases redeclare `industry`, so my line's first wording ("through `fieldPatches`") matched their filter and failed 2 of their cases. The line now says "patch an owner field's display properties instead of redeclaring it", and both files pass 27/27. - **Overlap.** Open PRs re-read before opening: none of #28850, #28860, #28862, #28873, #28875 touches `objectql/src/registry.ts` or the docs page. Main is 4 commits ahead (#28853, #28857 and two others); `git merge-tree` is clean and none touches these files. - **Changeset.** `patch` for `@objectstack/objectql`, with `Clause-②: no` and `Metadata-protocol: none` at line start: one log line, no accept set or served shape moves. ## Verification ``` pnpm --filter @objectstack/objectql exec vitest run src/registry-extension-redeclared-field.test.ts ``` Expect `Tests 10 passed (10)`. ## Human Minimum - [x] 1. No `packages/spec` change. - [x] 2. New names follow their kind's casing. - [x] 3. No formatter ran; eslint on touched files exits 0 (`pnpm lint` is CI's). - [x] 4. One issue, one branch, one pull request. - [x] 5. Changeset present; both declaration lines filled in here and in it. - [x] 6. `packages/spec` not touched. - [x] 7. No hand edits to references, changelogs or release notes. - [ ] 8. English throughout, except the 维护者速读 block the fleet's PR contract requires. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5nbGTknnd7qupVJNmN --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent bdb19ca commit 581d294

4 files changed

Lines changed: 301 additions & 4 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): the object fold names, once, a field that two contributors declare in `fields`, and which definition it serves (#28835)
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
The registry fold every object read serves (`getObject`, the by-name and layered reads through `foldObjectExtendersOnto`, the code layer) merges each extension's `fields` by spreading them. So when two extensions of one object both declare a field name, or an extension's `fields` redeclares a field the owner declares, the last contribution the fold applies is served: extensions fold in ascending `priority`, and at equal priority the one registered last wins. Nothing said so. This is reachable from two vendor dependents, from two `.custom` pairs where one base depends on the other, and, since the `object_extension` item door, from Studio.
11+
12+
- **Now.** The first time a fold sees such a field, it writes one `console.warn` line per object, field and set of declaring packages. The line names the object, the field, every declaring package in fold order (the owner first when it declares the field), and the package whose definition is served. Repeated reads in one registry do not repeat it; a second registry says it again. It sits beside the existing line for a field patch that misses its target, in the same style.
13+
- **Unchanged.** The served definition, byte for byte: the same winner as before. No door refuses anything new, and `os validate` and the save and publish doors are not touched.
14+
- **Not counted.** `fieldPatches` entries (they are patches, not declarations, and a patch that misses is already named). A `.custom` pair's `__c` field never shares a name with a dependent's plain field, so the two never meet here.
15+
- **Docs.** `content/docs/data-modeling/object-extensions.mdx` no longer says nothing warns.
16+
- **Measured.** No shipped package or example declares the same field from two extensions, or redeclares an owner field in an extension's `fields`: the two shipped `objectExtensions` entries (`examples/app-showcase` on `showcase_account`, `plugin-auth` on `sys_sso_provider`) each add fields the owner does not declare, and HotCRM declares none.

‎content/docs/data-modeling/object-extensions.mdx‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,12 @@ it does not walk `objectExtensions`, and it could not — the target's name belo
158158
its owner, not to you.
159159

160160
Nothing therefore stops two packages from contributing the same field name to the same
161-
object, and nothing warns when they do: the merge is last-writer-wins by `priority`, so
162-
one of the two silently disappears. Name defensively — prefix added fields with
163-
something specific to your package when the name is at all generic.
161+
object: the merge is last-writer-wins by `priority`, so one of the two definitions is
162+
not served. The same goes for an extension whose `fields` redeclares a field the owner
163+
declares. Neither is refused; at boot the registry names each such field once in the log,
164+
with every package that declares it and the one whose definition is served. Name
165+
defensively — prefix added fields with something specific to your package when the
166+
name is at all generic.
164167

165168
---
166169

@@ -172,7 +175,7 @@ something specific to your package when the name is at all generic.
172175
last-writer-wins, validations and indexes accumulate.
173176

174177
So a **higher `priority` is applied later and wins** a conflict. Two extensions with the
175-
same priority are folded in registration order, which is not a guarantee to build on —
178+
same priority are folded in registration order (the one registered last wins), which is not a guarantee to build on —
176179
give competing extensions distinct priorities.
177180

178181
Merging is idempotent: re-registering a package (a metadata rebuild, a dev-server
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28835] A field name that two contributors of one object both declare in
5+
* `fields` is named once at the fold, with the definition it serves.
6+
*
7+
* `mergeObjectDefinitions` spreads `fields`, so when two extensions of one
8+
* object both add `x`, or an extension's `fields` redeclares a field the owner
9+
* declares, the last contribution the fold applies wins: ascending `priority`,
10+
* and at equal priority the one registered last. That stays the answer (the
11+
* served definition is not changed here); the fold now says so, once per
12+
* object, field and set of packages, in the style of the field-patch miss
13+
* report. `fieldPatches` is not a declaration, and a `.custom` pair's `__c`
14+
* field never shares a name with a dependent's plain one.
15+
*/
16+
17+
import { describe, it, expect, vi, afterEach } from 'vitest';
18+
import { SchemaRegistry } from './registry.js';
19+
20+
const OWNER_PKG = 'com.vendor.crm';
21+
const DEP_A = 'com.acme.crm-territory';
22+
const DEP_B = 'com.globex.crm-regions';
23+
const OBJ = 'crm_account';
24+
25+
function vendorAccount(): any {
26+
return {
27+
name: OBJ,
28+
label: 'Account',
29+
fields: {
30+
name: { name: 'name', type: 'text', label: 'Name' },
31+
industry: {
32+
name: 'industry',
33+
type: 'select',
34+
label: 'Industry',
35+
options: [
36+
{ value: 'tech', label: 'Tech' },
37+
{ value: 'retail', label: 'Retail' },
38+
],
39+
},
40+
},
41+
};
42+
}
43+
44+
function withVendor(): SchemaRegistry {
45+
const reg = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
46+
reg.logLevel = 'silent';
47+
reg.registerObject(vendorAccount(), OWNER_PKG, undefined, 'own');
48+
return reg;
49+
}
50+
51+
function extendWith(reg: SchemaRegistry, pkg: string, body: Record<string, unknown>, priority = 200): void {
52+
reg.registerObject({ name: OBJ, ...body } as any, pkg, undefined, 'extend', priority);
53+
}
54+
55+
const regionOf = (pkg: string) => ({ name: 'region', type: 'text', label: `Region (${pkg})` });
56+
57+
/** The fold's lines about a field declared more than once. */
58+
function redeclaredLines(warn: { mock: { calls: unknown[][] } }): string[] {
59+
return warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes('is declared in `fields` by'));
60+
}
61+
62+
/** Every read the fold serves, three times over, with the cache dropped between. */
63+
function readEverywhere(reg: SchemaRegistry): void {
64+
for (let i = 0; i < 3; i += 1) {
65+
reg.invalidateAll();
66+
reg.getObject(OBJ);
67+
reg.foldObjectExtendersOnto(OBJ, vendorAccount());
68+
reg.getArtifactItem('object', OBJ);
69+
}
70+
}
71+
72+
afterEach(() => {
73+
vi.restoreAllMocks();
74+
});
75+
76+
describe('#28835 — a field two contributors declare in `fields` is named once at the fold, with the definition served', () => {
77+
it('two dependents both add `region` at equal priority: the one registered last is served, and one line names both and the served one', () => {
78+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
79+
const reg = withVendor();
80+
extendWith(reg, DEP_A, { fields: { region: regionOf(DEP_A) } });
81+
extendWith(reg, DEP_B, { fields: { region: regionOf(DEP_B) } });
82+
83+
const served = reg.getObject(OBJ) as any;
84+
expect(served.fields.region).toEqual(regionOf(DEP_B));
85+
readEverywhere(reg);
86+
87+
const lines = redeclaredLines(warn);
88+
expect(lines).toHaveLength(1);
89+
expect(lines[0]).toContain(`Field "region" of object "${OBJ}"`);
90+
expect(lines[0]).toContain(`extensions from "${DEP_A}", "${DEP_B}"`);
91+
expect(lines[0]).toContain(`the definition served is the one from "${DEP_B}"`);
92+
expect(lines[0]).not.toContain(OWNER_PKG);
93+
});
94+
95+
it('…by priority, not registration order: the higher-priority dependent registered first is the one served and named', () => {
96+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
97+
const reg = withVendor();
98+
extendWith(reg, DEP_A, { fields: { region: regionOf(DEP_A) } }, 300);
99+
extendWith(reg, DEP_B, { fields: { region: regionOf(DEP_B) } }, 200);
100+
101+
expect((reg.getObject(OBJ) as any).fields.region).toEqual(regionOf(DEP_A));
102+
const lines = redeclaredLines(warn);
103+
expect(lines).toHaveLength(1);
104+
expect(lines[0]).toContain(`extensions from "${DEP_B}", "${DEP_A}"`);
105+
expect(lines[0]).toContain(`the definition served is the one from "${DEP_A}"`);
106+
});
107+
108+
it('an extension redeclaring a field the owner declares: the extension\'s definition is served, and one line names the owner, the extension and the served one', () => {
109+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
110+
const reg = withVendor();
111+
const takeover = { name: 'industry', type: 'text', label: 'Industry (free text)' };
112+
extendWith(reg, DEP_A, { fields: { industry: takeover } });
113+
114+
expect((reg.getObject(OBJ) as any).fields.industry).toEqual(takeover);
115+
readEverywhere(reg);
116+
117+
const lines = redeclaredLines(warn);
118+
expect(lines).toHaveLength(1);
119+
expect(lines[0]).toContain(`Field "industry" of object "${OBJ}"`);
120+
expect(lines[0]).toContain(`the owner "${OWNER_PKG}" and by extensions from "${DEP_A}"`);
121+
expect(lines[0]).toContain(`the definition served is the one from "${DEP_A}"`);
122+
});
123+
124+
it('the owner and two extensions on one field: one line naming all three in fold order', () => {
125+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
126+
const reg = withVendor();
127+
extendWith(reg, DEP_A, { fields: { industry: { name: 'industry', type: 'text', label: 'A' } } });
128+
extendWith(reg, DEP_B, { fields: { industry: { name: 'industry', type: 'text', label: 'B' } } });
129+
130+
expect((reg.getObject(OBJ) as any).fields.industry.label).toBe('B');
131+
const lines = redeclaredLines(warn);
132+
expect(lines).toHaveLength(1);
133+
expect(lines[0]).toContain(`the owner "${OWNER_PKG}" and by extensions from "${DEP_A}", "${DEP_B}"`);
134+
expect(lines[0]).toContain(`the definition served is the one from "${DEP_B}"`);
135+
});
136+
137+
it('two `.custom` pairs (one base depending on the other) both adding `region__c`: named like any two extensions', () => {
138+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
139+
const reg = withVendor();
140+
extendWith(reg, `${OWNER_PKG}.custom`, { fields: { region__c: { name: 'region__c', type: 'text', label: 'One' } } });
141+
extendWith(reg, `${DEP_A}.custom`, { fields: { region__c: { name: 'region__c', type: 'text', label: 'Two' } } });
142+
143+
expect((reg.getObject(OBJ) as any).fields.region__c.label).toBe('Two');
144+
const lines = redeclaredLines(warn);
145+
expect(lines).toHaveLength(1);
146+
expect(lines[0]).toContain(`the definition served is the one from "${DEP_A}.custom"`);
147+
});
148+
149+
it('a second registry names the same collision again', () => {
150+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
151+
for (let i = 0; i < 2; i += 1) {
152+
const reg = withVendor();
153+
extendWith(reg, DEP_A, { fields: { region: regionOf(DEP_A) } });
154+
extendWith(reg, DEP_B, { fields: { region: regionOf(DEP_B) } });
155+
readEverywhere(reg);
156+
}
157+
expect(redeclaredLines(warn)).toHaveLength(2);
158+
});
159+
160+
describe('controls: nothing is named', () => {
161+
it('one extension adding a field of its own', () => {
162+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
163+
const reg = withVendor();
164+
extendWith(reg, DEP_A, { fields: { region: regionOf(DEP_A) } });
165+
readEverywhere(reg);
166+
expect((reg.getObject(OBJ) as any).fields.region).toEqual(regionOf(DEP_A));
167+
expect(redeclaredLines(warn)).toEqual([]);
168+
});
169+
170+
it('a pair\'s `__c` field beside a dependent\'s plain field of the same stem', () => {
171+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
172+
const reg = withVendor();
173+
extendWith(reg, `${OWNER_PKG}.custom`, { fields: { region__c: { name: 'region__c', type: 'text', label: 'Mine' } } });
174+
extendWith(reg, DEP_A, { fields: { region: regionOf(DEP_A) } });
175+
readEverywhere(reg);
176+
const served = reg.getObject(OBJ) as any;
177+
expect(served.fields.region__c.label).toBe('Mine');
178+
expect(served.fields.region).toEqual(regionOf(DEP_A));
179+
expect(redeclaredLines(warn)).toEqual([]);
180+
});
181+
182+
it('a `fieldPatches` entry of an owner field, beside an extension adding a field', () => {
183+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
184+
const reg = withVendor();
185+
extendWith(reg, DEP_A, { fieldPatches: { industry: { label: 'Sector' } } });
186+
extendWith(reg, DEP_B, { fields: { region: regionOf(DEP_B) }, fieldPatches: { industry: { label: 'Market' } } });
187+
readEverywhere(reg);
188+
expect((reg.getObject(OBJ) as any).fields.industry.label).toBe('Market');
189+
expect(redeclaredLines(warn)).toEqual([]);
190+
});
191+
192+
it('a base that already carries the extensions\' fields (a folded body handed back to the fold)', () => {
193+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
194+
const reg = withVendor();
195+
extendWith(reg, DEP_A, { fields: { region: regionOf(DEP_A) } });
196+
const folded = reg.getObject(OBJ) as any;
197+
reg.foldObjectExtendersOnto(OBJ, structuredClone(folded));
198+
reg.foldObjectExtendersOnto(OBJ, structuredClone(folded));
199+
expect(redeclaredLines(warn)).toEqual([]);
200+
});
201+
});
202+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -513,6 +513,9 @@ type ObjectFieldPatches = NonNullable<ObjectExtensionParsed['fieldPatches']>;
513513
/** An `extend` contributor's body: a partial object that may carry {@link ObjectFieldPatches}. */
514514
type ObjectExtensionBody = Partial<ServiceObject> & { fieldPatches?: ObjectFieldPatches };
515515

516+
/** [#28835] One extension's `fields` as the fold merged them, with the package that declared them. */
517+
type FoldedExtensionFields = { packageId: string | undefined; fields: unknown };
518+
516519
const isPlainRecord = (v: unknown): v is Record<string, unknown> =>
517520
v !== null && typeof v === 'object' && !Array.isArray(v);
518521

@@ -4627,6 +4630,8 @@ export class SchemaRegistry {
46274630
let merged = tenantRules
46284631
? this.withPackagedOwnerRules(contributors, baseDefinition, tenantRules)
46294632
: { ...baseDefinition };
4633+
// [#28835] Each extension's `fields` as the merge took them, in fold order.
4634+
const folded: FoldedExtensionFields[] = [];
46304635
for (const contrib of contributors) {
46314636
if (contrib.ownership === 'extend') {
46324637
let extension = governAudit ? applyAuditFieldGovernance(contrib.definition) : contrib.definition;
@@ -4664,8 +4669,10 @@ export class SchemaRegistry {
46644669
)
46654670
: [];
46664671
this.reportFieldPatchMisses([...misses, ...residual], contrib.packageId);
4672+
folded.push({ packageId: contrib.packageId, fields: extension.fields });
46674673
}
46684674
}
4675+
this.reportRedeclaredFields(String(merged.name ?? baseDefinition.name ?? ''), contributors, ownFields, folded);
46694676
if (tenantRules) this.reportChangedRuleCopies(contributors, baseDefinition);
46704677
// A picklist-bound field is served with its list's options resolved onto
46714678
// it — here, in the fold every object read and the write door share, so
@@ -4680,6 +4687,75 @@ export class SchemaRegistry {
46804687
*/
46814688
private fieldPatchMissesWarned = new Set<string>();
46824689

4690+
/**
4691+
* [#28835] Field names already named as declared by more than one
4692+
* contributor, keyed `object|field|packages` (the packages in fold order).
4693+
* Per registry, for the reason {@link shadowedExtensionQueueWarned} gives.
4694+
*/
4695+
private redeclaredFieldsWarned = new Set<string>();
4696+
4697+
/**
4698+
* [#28835] Name, once, each field name of the object that more than one
4699+
* contributor declares in `fields`: two extensions both adding it, or an
4700+
* extension redeclaring a field the owner declares (one of `ownFields`). The
4701+
* merge spreads `fields`, so the definition served is the last one the fold
4702+
* applied: contributions fold in ascending `priority`, and at equal priority
4703+
* in the order they registered, so the one registered last. The line names
4704+
* the object, the field, every declaring package in that order, and the one
4705+
* whose definition is served.
4706+
*
4707+
* Judged from the contributors' own declarations (`folded`, as the merge
4708+
* took them), never from the running body, so a base that already carries
4709+
* the extensions' fields (a folded body handed to
4710+
* {@link foldObjectExtendersOnto}, a stored overlay row) is not read as a
4711+
* redeclaration. `fieldPatches` is not a declaration and is not counted
4712+
* ({@link reportFieldPatchMisses} names a patch that misses). A `.custom`
4713+
* pair's `__c` field and a dependent's plain one never share a name.
4714+
*
4715+
* ⛔ Never a refusal, and the served definition is unchanged: the same
4716+
* reasons as {@link reportFieldPatchMisses} (the fold runs on every read
4717+
* while a boot is still registering packages). At `warn`: one package's
4718+
* definition of the field is not served.
4719+
*/
4720+
private reportRedeclaredFields(
4721+
object: string,
4722+
contributors: readonly ObjectContributor[],
4723+
ownFields: ReadonlySet<string> | undefined,
4724+
folded: readonly FoldedExtensionFields[],
4725+
): void {
4726+
if (folded.length === 0) return;
4727+
const owner = contributors.find((c) => c.ownership === 'own');
4728+
const declarers = new Map<string, Array<{ packageId: string | undefined; owner: boolean }>>();
4729+
for (const { packageId, fields } of folded) {
4730+
if (!isPlainRecord(fields)) continue;
4731+
for (const name of Object.keys(fields)) {
4732+
let list = declarers.get(name);
4733+
if (!list) {
4734+
list = owner && ownFields?.has(name) ? [{ packageId: owner.packageId, owner: true }] : [];
4735+
declarers.set(name, list);
4736+
}
4737+
list.push({ packageId, owner: false });
4738+
}
4739+
}
4740+
const named = (packageId: string | undefined) => (packageId ? `"${packageId}"` : '(no package)');
4741+
for (const [field, list] of declarers) {
4742+
if (list.length < 2) continue;
4743+
const key = `${object}|${field}|${list.map((d) => d.packageId ?? '').join(',')}`;
4744+
if (this.redeclaredFieldsWarned.has(key)) continue;
4745+
this.redeclaredFieldsWarned.add(key);
4746+
const extenders = list.filter((d) => !d.owner).map((d) => named(d.packageId));
4747+
const declaredBy = list[0]!.owner
4748+
? `the owner ${named(list[0]!.packageId)} and by extensions from ${extenders.join(', ')}`
4749+
: `extensions from ${extenders.join(', ')}`;
4750+
console.warn(
4751+
`[Registry] Field "${field}" of object "${object}" is declared in \`fields\` by ${declaredBy}; ` +
4752+
`the definition served is the one from ${named(list[list.length - 1]!.packageId)}, the last the fold applies ` +
4753+
'(ascending `priority`; at equal priority, the one registered last). Rename the field in one extension, ' +
4754+
'or patch an owner field\'s display properties instead of redeclaring it.',
4755+
);
4756+
}
4757+
}
4758+
46834759
/**
46844760
* [ADR-0146 D10, second rule] Name, once, each patch the fold did not apply
46854761
* because the judge missed it: a field that is not one of the owner's own

0 commit comments

Comments
 (0)