Skip to content

Commit 5e1b9b0

Browse files
fix(objectql): an object extension whose extend folds to a legacy ns__name key is filed under that key, not a second owner-less entry (#28858) (#28887)
domain:server#1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC Fixes #28858 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:对象扩展的 `extend` 写成短名,而 `getObject` 把它折叠到旧式 `ns__name` 键时,注册表把这条扩展登记到折叠后的那个键上,字段落到真正的对象,不再在旁边开一个没有所有者的第二个对象条目;同一写法每个包在日志里 `warn` 一次,并给出应写的键。 - **为什么改**:#28853 让 `object_extension` 保存口拒绝这种写法,但清单的 `objectExtensions` 循环和启动时回灌存储行仍照原样登记,会多出一个幽灵条目,扩展的字段也到不了任何读取。 - **风险与回滚**:低。只影响 `extend` 折叠到别的键的扩展;本仓库和 hotcrm 中这样的写法为 0。写对键、或对象恰好以该名字注册时行为不变。启动时不拒绝。回滚即还原本 PR。 - **先看**:`packages/objectql/src/registry.ts` 的 `registerObject` 中 `extend` 分支新增的两行,以及 `extensionTargetKey`。 - **两分钟验证**:`pnpm --filter @objectstack/objectql exec vitest run src/registry-extension-legacy-key-fold.test.ts`,预期 4/4。 - **需要你做的**:无,席位落地。 ## What changes `registerObject`'s `extend` arm is the one place both entries reach: the manifest `objectExtensions` loop (`engine.ts`) and a stored `object_extension` row (`registerObjectExtension`). Right after the extend refusals, it now files the contribution under `extensionTargetKey(extend)`. That is `extend` itself when an entry is registered under it or nothing is registered that it folds to. It is `getObject`'s fold (`resolveObjectKey`) only when the fold names a different key. The folded spelling is named once at `warn`, keyed by spelling, resolved key and package (deduped like `reportFieldPatchMisses`), with the key to write. `registerObjectExtension` answers the resolved key, and the row delete's `withdrawObjectExtension` already resolves the same way. There are no refusals at boot, no change to #28853's item gate, and `computeFQN` / `parseFQN` are untouched. There is no `packages/spec/**`, no new export, gate or error code. **Premise, measured (step 1).** The count is 0 everywhere: - in this repo (main 7e60dc2), the three non-test `extend` values are each a declared object's exact name, and no shipped object name contains `__`; - in hotcrm (a598256), there are 0 extensions and 0 such names. The case is still reachable by authoring: the spec dist's `ObjectSchema` accepts `crm__account` and `survey__c`, and a stored object row from the FQN era reaches it too. The seat kept the card on that ground. **Clause-② measured: no.** No accept set moves and nothing is exported. For a package with the folded spelling (0 measured), the extension's fields now reach the object they name, where on main they reached no read. **Residual.** A legacy owner that registers after the extension is not yet there to fold to, so that extension is filed as written, as on main. ## Tests <details><summary>Red first, ablation, controls</summary> - `objectql` `registry-extension-legacy-key-fold.test.ts` (4 cases, from source): - manifest entry `extend: 'case'` beside the owner `crm__case`: the field lands on `crm__case`, no `case` contributors, one object listed, one warn across a re-read and a re-register; - `registerObjectExtension` answers `crm__case`, warns once, and withdrawing by the short name removes it; - controls: the registered key registers with no warn; an object registered exactly as `case` beside `crm__case` keeps its extension, with no warn. - Red on main 45efba2: 2 failed | 2 passed. - `runtime` `meta-object-extension-item-door-fit.test.ts`, new case: a stored row respelled at rest to `extend: 'case'`, then `loadMetaFromDb`. The field lands on `crm__case`, no `case` entry, one warn. - Red on main (objectql rebuilt from base with `pnpm --filter`, head-only string count 0 in dist): 1 failed | 5 passed, with a `case` contributor present. On head (rebuilt, count 2): 6/6. - #28853's item-gate pins stay green in the same file. - Ablation: the resolution line deleted through `ablation-replace.mjs` gave both objectql pins red (2 failed | 2 passed). Restore: `ok restored: blob == HEAD`, `git diff HEAD` empty. </details> <details><summary>Suites, typecheck, lint, gates</summary> Head `6db3a09f`, under the verify lock: - objectql: 524 files, 10030 tests passed; - metadata-protocol: 297 files passed, 4 skipped; 22093 tests passed, 26 skipped. `pnpm typecheck` in full (tsc plus check:test-typecheck) exits 0 for objectql and runtime, and both ledgers hold. eslint `--no-inline-config` on the 3 touched files: exit 0. Gates: `dispatch-gates.mjs --commands` derived 63, all run, every exit 0. `--ran`: 63 derived, 63 run, 0 NOT-MEASURED. Dogfood battery: not owed (no `!`, no narrowing arm). </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FEkQ5oTRCUNQ4EA3YV84sZ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b15b634 commit 5e1b9b0

4 files changed

Lines changed: 211 additions & 1 deletion

File tree

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/objectql': patch
3+
---
4+
5+
fix(objectql): an object extension whose `extend` folds to a legacy `ns__name` key is filed under that key, and never opens a second, owner-less object entry (#28858)
6+
7+
Clause-②: no — no accept set moves and nothing is exported; an extension that already registered now lands on the object it names. Measured reach: no package or example in this repository or in hotcrm spells an `extend` that folds.
8+
Metadata-protocol: none
9+
10+
`getObject` reads a short name as a legacy `<ns>__<name>` key when that is the only object it can mean (an object a package still ships as `crm__case`, which `ObjectSchema` accepts). The registry used to file an extension under the name it is written with. So a manifest `objectExtensions` entry or a stored `object_extension` row with `extend: 'case'` opened a second entry, `case`, with no owner, beside `crm__case`. Its fields reached no read of the object, and nothing checked the entry.
11+
12+
- **Now.** Every entry that registers an extension (the manifest loop and the stored row's hydration, both through `registerObject`) files it under the key `getObject` reads its `extend` as. The fields land on `crm__case`, and no `case` entry opens. The spelling is named once per spelling and package at `warn`, with the key to write.
13+
- **Never a boot refusal.** The manifest loop and the hydration run while packages are still registering. The `object_extension` item door keeps refusing the folded spelling at save (#28834).
14+
- **Unchanged.** An `extend` spelled with the registered key, or naming an object registered under that exact name, registers as before, with no line. `computeFQN` and `parseFQN` are untouched. A legacy owner that registers after the extension is not yet there to fold to, so that extension is filed as written, as before.
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#28858, ADR-0146 D10] An `extend` that `getObject` folds to a legacy
5+
* `<ns>__<name>` key never opens a second object entry.
6+
*
7+
* A vendor package still ships its Case under the legacy key `crm__case`
8+
* (the published `ObjectSchema` accepts the name), so `getObject('case')`
9+
* folds to it. A second package extends it as `extend: 'case'`. The
10+
* extension is filed under the key the object is registered under, its field
11+
* lands on the vendor's object, no owner-less `case` entry opens beside it,
12+
* and the spelling is named once at `warn`. Never a boot refusal (AGENTS.md
13+
* *Startup registry reads*). The `object_extension` item door still refuses
14+
* the folded spelling at save (#28853, pinned in `@objectstack/runtime`).
15+
*/
16+
17+
import { describe, it, expect, vi, afterEach } from 'vitest';
18+
import { SchemaRegistry } from './registry.js';
19+
import { ObjectQL } from './engine.js';
20+
21+
const VENDOR = 'com.vendor.crm';
22+
const EXT = 'com.acme.case-region';
23+
const LEGACY_KEY = 'crm__case';
24+
const SHORT = 'case';
25+
26+
const vendorCase = () => ({
27+
name: LEGACY_KEY,
28+
label: 'Case',
29+
fields: { subject: { name: 'subject', type: 'text', label: 'Subject' } },
30+
});
31+
32+
const region = { region: { name: 'region', type: 'text', label: 'Region' } };
33+
34+
function registry(): SchemaRegistry {
35+
const reg = new SchemaRegistry({ multiTenant: false, searchCompanion: false } as never);
36+
reg.logLevel = 'silent';
37+
return reg;
38+
}
39+
40+
const spellingLines = (warn: { mock: { calls: unknown[][] } }): string[] =>
41+
warn.mock.calls.map((c) => String(c[0])).filter((l) => l.includes(`'${LEGACY_KEY}'`) && l.includes(EXT));
42+
43+
afterEach(() => vi.restoreAllMocks());
44+
45+
describe('#28858 — an extension whose `extend` folds to a legacy key lands on the object it folds to', () => {
46+
it('a manifest objectExtensions entry: the field is on the vendor\'s object, no second entry opens, named once', () => {
47+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
48+
const ql = new ObjectQL();
49+
(ql.registry as SchemaRegistry).logLevel = 'silent';
50+
ql.registerApp({ id: VENDOR, name: 'CRM', version: '1.0.0', objects: [vendorCase()] });
51+
ql.registerApp({
52+
id: EXT, name: 'Case region', version: '1.0.0',
53+
objectExtensions: [{ extend: SHORT, fields: region }],
54+
});
55+
56+
const reg = ql.registry as SchemaRegistry;
57+
expect(Object.keys((reg.getObject(LEGACY_KEY) as any).fields)).toEqual(expect.arrayContaining(['subject', 'region']));
58+
expect(reg.getObjectContributors(SHORT)).toEqual([]);
59+
expect(reg.getObjectContributors(LEGACY_KEY).map((c) => [c.packageId, c.ownership]))
60+
.toEqual([[VENDOR, 'own'], [EXT, 'extend']]);
61+
expect(reg.getAllObjects().map((o) => o.name).filter((n) => n === SHORT || n === LEGACY_KEY)).toEqual([LEGACY_KEY]);
62+
// The short name still reads the one object, with the field.
63+
expect(Object.keys((reg.getObject(SHORT) as any).fields)).toContain('region');
64+
65+
// Re-reading and re-registering name it no more than once.
66+
reg.invalidateAll();
67+
reg.getObject(LEGACY_KEY);
68+
ql.registerApp({ id: EXT, name: 'Case region', version: '1.0.0', objectExtensions: [{ extend: SHORT, fields: region }] });
69+
const lines = spellingLines(warn);
70+
expect(lines).toHaveLength(1);
71+
expect(lines[0]).toContain(`'${SHORT}'`);
72+
});
73+
74+
it('registerObjectExtension (the stored row\'s verb) files under the resolved key and answers it', () => {
75+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
76+
const reg = registry();
77+
reg.registerObject(vendorCase() as any, VENDOR, undefined, 'own');
78+
79+
const filed = reg.registerObjectExtension({ extend: SHORT, fields: region, _provenance: 'org' }, EXT);
80+
81+
expect(filed).toBe(LEGACY_KEY);
82+
expect(reg.getObjectContributors(SHORT)).toEqual([]);
83+
expect(Object.keys((reg.getObject(LEGACY_KEY) as any).fields)).toContain('region');
84+
expect(spellingLines(warn)).toHaveLength(1);
85+
// The withdrawal the row delete makes reaches the same entry.
86+
expect(reg.withdrawObjectExtension(SHORT, EXT)).toBe(true);
87+
expect(Object.keys((reg.getObject(LEGACY_KEY) as any).fields)).not.toContain('region');
88+
});
89+
90+
it('control: an extension spelled with the registered key registers as before, with no warn', () => {
91+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
92+
const reg = registry();
93+
reg.registerObject(vendorCase() as any, VENDOR, undefined, 'own');
94+
95+
expect(reg.registerObjectExtension({ extend: LEGACY_KEY, fields: region }, EXT)).toBe(LEGACY_KEY);
96+
expect(Object.keys((reg.getObject(LEGACY_KEY) as any).fields)).toContain('region');
97+
expect(reg.getObjectContributors(SHORT)).toEqual([]);
98+
expect(spellingLines(warn)).toEqual([]);
99+
});
100+
101+
it('control: an object registered under the exact name keeps its extensions, even beside a legacy key that shares the short name', () => {
102+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
103+
const reg = registry();
104+
reg.registerObject(vendorCase() as any, VENDOR, undefined, 'own');
105+
reg.registerObject({ name: SHORT, label: 'Support case', fields: { title: { name: 'title', type: 'text', label: 'Title' } } } as any, 'com.other.support', undefined, 'own');
106+
107+
expect(reg.registerObjectExtension({ extend: SHORT, fields: region }, EXT)).toBe(SHORT);
108+
expect(reg.getObjectContributors(SHORT).map((c) => c.ownership)).toEqual(['own', 'extend']);
109+
expect(Object.keys((reg.getObject(LEGACY_KEY) as any).fields)).not.toContain('region');
110+
expect(spellingLines(warn)).toEqual([]);
111+
});
112+
});

‎packages/objectql/src/registry.ts‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4288,6 +4288,12 @@ export class SchemaRegistry {
42884288
if (ownership === 'extend') {
42894289
const extendRefusal = extendContributionRefusal(schema, packageId);
42904290
if (extendRefusal) throw extendRefusal;
4291+
// [#28858] …filed under the key the object it extends is registered
4292+
// under — see {@link extensionTargetKey}. Every entry that registers an
4293+
// extension reaches this line: the manifest `objectExtensions` loop and
4294+
// a stored `object_extension` row ({@link registerObjectExtension}).
4295+
const target = this.extensionTargetKey(String(schema.name ?? ''), packageId);
4296+
if (target !== schema.name) schema = { ...schema, name: target };
42914297
}
42924298
// [ADR-0144 D6] A `summary` roll-up that reads a `record`-mode amount —
42934299
// `sum` / `avg` / `min` / `max` of a child field whose rows each carry
@@ -6259,6 +6265,46 @@ export class SchemaRegistry {
62596265
);
62606266
}
62616267

6268+
/**
6269+
* [#28858] Spellings of an `extend` already named, keyed
6270+
* `spelled|resolved|package`. Per registry, for the reason
6271+
* {@link shadowedExtensionQueueWarned} gives.
6272+
*/
6273+
private extensionSpellingWarned = new Set<string>();
6274+
6275+
/**
6276+
* [#28858, ADR-0146 D10] The key an extension of `name` is filed under: the
6277+
* key {@link getObject} reads `name` as. That is `name` itself whenever an
6278+
* entry is registered under it, or nothing is registered that `name` folds
6279+
* to. It differs only for a short name `getObject` folds to a legacy
6280+
* `<ns>__<name>` key ({@link resolveObjectKey}); filing such an extension
6281+
* under the short name would open a second, owner-less entry beside the
6282+
* object it meant, which nothing serves and nothing checks.
6283+
*
6284+
* The folded spelling is named once at `warn`, with the key to write. ⛔
6285+
* Never a refusal: the manifest loop and the boot hydration run while
6286+
* packages are still registering (AGENTS.md *Startup registry reads*). The
6287+
* `object_extension` item door refuses the same spelling at save (#28834),
6288+
* where the author is present to fix it. A legacy owner that registers
6289+
* AFTER the extension is not yet there to fold to, so that extension is
6290+
* filed under its own spelling, as before.
6291+
*/
6292+
private extensionTargetKey(name: string, packageId: string | undefined): string {
6293+
if (name === '' || this.objectContributors.has(name)) return name;
6294+
const resolved = this.resolveObjectKey(name);
6295+
if (resolved === undefined || resolved === name) return name;
6296+
const key = `${name}|${resolved}|${packageId ?? ''}`;
6297+
if (!this.extensionSpellingWarned.has(key)) {
6298+
this.extensionSpellingWarned.add(key);
6299+
console.warn(
6300+
`[Registry] An extension${packageId ? ` from package "${packageId}"` : ''} names its object '${name}', ` +
6301+
`which is registered as '${resolved}': the extension is filed under '${resolved}', the object it ` +
6302+
`reads as. Write \`extend: '${resolved}'\` to name the object by the key it is registered under.`,
6303+
);
6304+
}
6305+
return resolved;
6306+
}
6307+
62626308
/**
62636309
* [#28574] Withdraw ONE package's `'extend'` contribution to an object — the
62646310
* delete of that package's `object_extension` item. The owner, the overlay

‎packages/runtime/src/domains/meta-object-extension-item-door-fit.test.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
* sentence and that no row was left.
3030
*/
3131

32-
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
32+
import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest';
3333
import { mkdtempSync, rmSync } from 'node:fs';
3434
import { tmpdir } from 'node:os';
3535
import { join } from 'node:path';
@@ -199,3 +199,41 @@ describe('an object_extension item names its target by the key it is registered
199199
expect(engine.registry.getObjectContributors(LEGACY_SHORT)).toEqual([]);
200200
});
201201
});
202+
203+
describe('a stored object_extension row whose extend folds to a legacy key lands on that object at the boot hydration (#28858)', () => {
204+
it('the field is on the legacy-keyed object, no second entry opens, and the spelling is named once', async () => {
205+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
206+
try {
207+
const { engine, p } = await boot();
208+
// A row stored under the short spelling: the item door refuses
209+
// that spelling now (#28834), so it is a row written before it.
210+
// Stored as the registered key, then respelled at rest.
211+
await p.saveMetaItem({ type: 'object_extension', name: LEGACY_KEY, packageId: PAIR, item: region(LEGACY_KEY), mode: 'draft' });
212+
await p.publishMetaItem({ type: 'object_extension', name: LEGACY_KEY, packageId: PAIR });
213+
const stored: any[] = ((await engine.find('sys_metadata', { where: { type: 'object_extension' }, context: { isSystem: true } })) ?? [])
214+
.filter((r: any) => r.package_id === PAIR);
215+
expect(stored).toHaveLength(1);
216+
const body = typeof stored[0].metadata === 'string' ? JSON.parse(stored[0].metadata) : stored[0].metadata;
217+
await engine.update('sys_metadata', {
218+
id: stored[0].id,
219+
name: LEGACY_SHORT,
220+
metadata: JSON.stringify({ ...body, extend: LEGACY_SHORT }),
221+
}, { context: { isSystem: true } });
222+
// A fresh process: the registry holds no contribution of the pair.
223+
engine.registry.withdrawObjectExtension(LEGACY_KEY, PAIR);
224+
expect(engine.registry.getObjectContributors(LEGACY_KEY).map((c: any) => c.packageId)).toEqual([BASE]);
225+
226+
const result = await p.loadMetaFromDb();
227+
expect(result.errors).toBe(0);
228+
expect(engine.registry.getObjectContributors(LEGACY_SHORT)).toEqual([]);
229+
expect(engine.registry.getObjectContributors(LEGACY_KEY).map((c: any) => [c.packageId, c.ownership]))
230+
.toEqual([[BASE, 'own'], [PAIR, 'extend']]);
231+
expect(Object.keys((engine.registry.getObject(LEGACY_KEY) as any)?.fields ?? {})).toEqual(expect.arrayContaining(['region__c', 'subject']));
232+
const lines = warn.mock.calls.map((c) => String(c[0]))
233+
.filter((l) => l.includes(`'${LEGACY_SHORT}'`) && l.includes(`'${LEGACY_KEY}'`) && l.includes(PAIR));
234+
expect(lines).toHaveLength(1);
235+
} finally {
236+
warn.mockRestore();
237+
}
238+
});
239+
});

0 commit comments

Comments
 (0)