Skip to content

Commit 3b8c724

Browse files
fix(objectql, metadata-protocol, runtime): a package's export carries the objectExtensions it was installed with, and the owner's export folds no other package's extension (#29948)
<!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBNTkQJpyt5hDhp8ZSurFMMX)_ Fixes #29872 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么**:通过 install-local 安装(或代码包里声明)的扩展包,导出时现在会带上它自己的 `objectExtensions`;被扩展对象的拥有者(基础包)导出时,不再把任何其他包的扩展字段并进自己的对象(此前只排除了它自己的 `.custom` 配对包)。`/meta/object`、控制台和数据接口读到的对象不变,仍是合并后的。 - **为什么改**:整部署导出恢复到新环境后,每个包都是 install-local 装进来的;它们再次导出时丢掉扩展字段且不报错,第二跳就静默丢数据(卡片复现 E12)。ADR-0146 D8 第 3 条:包导出只带自己的东西,字段只出现一次。 - **风险与回滚**:只影响包导出读的 per-package 层与导出拼装;registry 新增一个只读方法和一个可选参数,已发布的 `withoutExtensionsOf` 语义不变。回滚即 revert 本 PR。 - **先看**:`packages/runtime/src/domains/packages.ts`(`registeredExtensionsOf`,按目标去重)、`packages/objectql/src/registry.ts`(`getObjectExtensionsOf` 与 `isLeftOutExtension`)、`packages/metadata-protocol/src/protocol-meta-list-reads.ts`(per-package 层改传 `onlyExtensionsOf`)。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/packages-export-base-showcase-boot.integration.test.ts -t 29872`,预期 2 passed:扩展包导出带 `objectExtensions`,基础包导出与安装扩展包之前逐字节相同,第二跳再导出不变。 - **需要你做的**:无,席位落地。 ## What changes **The defect (measured on `origin/main` 21510bb).** `assemblePackageManifest` built a package's `objectExtensions` from the `object_extension` items of the protocol's per-package layer. install-local (and a code package) registers a manifest's `objectExtensions` straight into the registry as `'extend'` contributors (`ObjectQL.registerApp`) and files no item, so such a package exported no extension. Its fields were folded into the OWNER's export instead (the per-package layer left out only the owner's `.custom` pair since #29930). A deployment restored from an export therefore lost every extension field on its next export, and a `.custom` pair installed from its export no longer re-exported its extension. **Half 1: the extender's export carries its extensions, however they were installed.** - `SchemaRegistry.getObjectExtensionsOf(packageId)` (new, `@objectstack/objectql`): every `extend` contribution the package makes, read back as the `objectExtensions` entry it was registered from, the inverse of `registerObjectExtension`. `extend` is the key it is filed under; `priority` is written only when it is not the default `200` (the value an entry without one parses to), so a second hop exports the same bytes as the first; `name` and every envelope key (`_packageId`, `_provenance`, ...) are left out. - `assemblePackageManifest` (`@objectstack/runtime`) appends those entries (`registeredExtensionsOf`) to the per-package layer's `object_extension` items, skipping any target an item already names: a published extension keeps exporting as it is stored (#28574, #29903 pins unchanged). A registry double without the method adds nothing, as before. **Half 2: the owner's export carries its object without ANY other package's extension** (the seat's carrier note 6605292329). - `listItems` / `getAllObjects` / `foldObjectExtendersOnto` take an `onlyExtensionsOf` option: only that package's own `extend` contributions fold, every other package's is left out. Both options live in one `ObjectExtensionFoldOptions` and one predicate (`isLeftOutExtension`). - The per-package layer (`readFlattenedMetaItems`, `layer: 'per-package'`) passes `onlyExtensionsOf` naming the listed package to both the registry list and the overlay-row fold, in place of `withoutExtensionsOf` naming its pair. - `withoutExtensionsOf` is kept with its meaning: it shipped in the published `@objectstack/objectql@18.3.0` tarball (5 hits in `dist/index.js`, measured with `npm pack`), so removing it would be a narrowing of a published surface. No in-repo caller passes it any more; its unit pin stays. Served and effective reads are untouched: neither option is passed outside the per-package layer, and the controls in every new pin read `GET /meta/object/NAME` and `GET /meta/object` with every extension folded in. ## Tests - `packages/objectql/src/registry-extension-field-patches.test.ts`: the #29903 describe now pins `onlyExtensionsOf` (owner listed with neither the pair's nor a third package's extension; an extender folds its own only; an un-extended object is the cached object; byte-equal to a registry with no extender; the stored-body fold) and keeps a `withoutExtensionsOf` case. A new describe pins `getObjectExtensionsOf`: a manifest entry through `ObjectQL.registerApp` comes back as written and parses under the strict `ObjectExtensionSchema`; a stored extension through `registerObjectExtension` comes back with its own priority and re-registers on a fresh registry to a byte-equal served object. - `packages/runtime/src/domains/packages-export-base-showcase-boot.integration.test.ts` (the shared showcase-boot harness; new top-level describe): an extender registered the way install-local registers it exports its entry; the base's export is byte-identical to its export before the extender was installed, with the pair's extension published beside it, and after a restart; the pair exports its own item only; an extender installed from its own export re-exports the same bytes (the second hop). - `packages/qa/dogfood/test/package-export-bundle-round-trip.dogfood.test.ts`: the first describe asserts the install-local extender's `objectExtensions` by name and in full on the source bundle AND the target bundle, and that the base's object carries the extender's field on neither; the third describe asserts the pair installed on the target from its entry re-exports its extension. The same-inventory pin stays and is green. ## Acceptance notes - **The inventory pin alone could not see the defect**, and it still cannot see half 1 alone: with half 1 ablated and half 2 in place, the inventory test stayed green (both hops lose the field identically) while the new #29872 dogfood assertions went red. That is why the halves ship together and why the new assertions read the field by name. - **Dedupe is by the item's `extend` spelling against the registry key.** A stored row spelling a short name that the registry files under a legacy folded key (#28858) would export twice; the save door refuses that spelling (#28834), so no stored row carries it today. Noted, not filed. - **The pending `.changeset/29903-base-export-without-pair-extension.md`** still says an extension from any other installed package "is still folded into the base's object in the base's export, as before", which this PR makes false. I first corrected it, and `check-empty-changeset` refused (it guards another PR's pending note); I restored it byte-for-byte from the merge base instead, and this PR's changeset carries a "Supersedes, in the same release" paragraph naming that sentence. The seat may still prefer to correct the #29903 note on the release side. - A code package's export (not in the whole-deployment bundle, which skips `source: 'code'` rows) now also carries its manifest `objectExtensions`, and a platform-owned object's export no longer folds in plugin extensions. Both follow from the same rule. ## Verification <details><summary>Ablations (each: mutation through scripts/ablation-replace.mjs, anchor 1 → 0, blob restored == HEAD, `git diff HEAD` empty)</summary> - **A, half 1** (`packages.ts`: drop the `registeredExtensionsOf` append) against the runtime integration file `-t 29872`: 2 failed. `the extender's export carries its manifest entry: expected undefined to deeply equal [ { extend: 'showcase_task', …(1) } ]`, and the second-hop test the same. Restored. - **A on dogfood** (runtime resolves from src there): the round-trip file 2 failed / 11 passed: `source: com.dogfood.exportext: expected { extends: [], objectCarries: false } to deeply equal { …(2) }` and `[#29872] target: the pair installed from its entry re-exports its extension: expected undefined to deeply equal [ { …(3) } ]`. Restored. - **B, half 2** (`protocol-meta-list-reads.ts`: `onlyExtensionsOf` never set; marker `ablated-29872-b`; metadata-protocol rebuilt, `ablation-dist-preflight` found the marker in `dist`) against the runtime integration file `-t '29872|29903'`: 2 failed / 1 passed. `expected [ 'organization_id', …(11) ] to not include 'acme_billing_code'` and the #29903 `the base's object with the extension live` equality. Restored, rebuilt, preflight `--absent` exit 0. - **C, registry** (`isLeftOutExtension`'s `onlyExtensionsOf` clause → `false`) against `registry-extension-field-patches.test.ts -t 29872`: 4 failed / 4 passed (the four `onlyExtensionsOf` cases). Restored. </details> <details><summary>Local runs (code head 2e498fb; final head 7b1145a changes only this PR's changeset)</summary> - Build: `pnpm --filter '@objectstack/dogfood^...' build` (closure), then objectql + metadata-protocol + runtime rebuilt: VERDICT command-exit 0. - `pnpm --filter @objectstack/objectql test`: 547 files / 10827 tests passed. - `pnpm --filter @objectstack/metadata-protocol test`: 311 passed, 4 skipped files / 22320 passed, 26 skipped tests. - `pnpm --filter @objectstack/runtime test`: 538 files / 8599 passed, 19 skipped tests. - `typecheck` for objectql, metadata-protocol, runtime (incl. each `check:test-typecheck`): exit 0. - Dogfood `package-export-bundle-round-trip.dogfood.test.ts`: 13/13. - eslint `--no-inline-config` over the 8 changed source/test files: exit 0 (the repo-wide `pnpm lint` is CI's Lint & Repo Gates). - `dispatch-gates --commands` (67 families), each run and its exit recorded: 67 exit 0 after three reruns. `check-plugin-teardown-shape --self-test` first exited 3 (shallow clone; its pinned fixture commit fetched, then 48 cases pass); `check:dual-build-cjs-loads` first exited 3 (seven unbuilt dists: client-react, embedder-openai, knowledge-memory, knowledge-ragflow, service-cluster-redis, service-knowledge, studio; built, then exit 0); `check-empty-changeset` first exited 1 on my edit of the #29903 note (see Acceptance notes; restored, then exit 0). The changeset gates (`check-empty-changeset`, `check-changeset-no-major`, `check-adr-0087-registration`) were rerun on 7b1145a: exit 0. `dispatch-gates --ran`: 67 derived, 67 run, 0 NOT-MEASURED, 0 UNRUN. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01WGufzebqeaeGkaTWGnN9bm <!-- signed: domain:server#6 · os-support-ai · session_01WGufzebqeaeGkaTWGnN9bm --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 42e1211 commit 3b8c724

9 files changed

Lines changed: 375 additions & 73 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@objectstack/objectql': patch
3+
'@objectstack/metadata-protocol': patch
4+
'@objectstack/runtime': patch
5+
---
6+
7+
fix(objectql, metadata-protocol, runtime): a package's export carries the `objectExtensions` it was installed with, and the extended object's owner's export does not
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
A package installed through `POST /api/v1/marketplace/install-local` (what `os package install` sends, and how every entry of a whole-deployment export is restored) that extends another package's object exported without its `objectExtensions`: `GET /api/v1/packages/<id>/export` answered the package's other items and no extension, while the extension's fields were folded into the owner's export instead, as if the owner had shipped them. So a deployment restored from an export lost those fields on its next export, with no error, and a `.custom` pair installed from its export no longer exported its extension.
13+
14+
- **The extending package's export** (and its entry in `GET /api/v1/packages/export`) now carries every extension it contributes as `objectExtensions`, however it was installed: an extension published through `/api/v1/meta/object_extension` (as before) or a manifest's `objectExtensions` entry (install-local, or a code package). A published extension is exported as it is stored; a manifest entry as it was registered, with `priority` written only when it is not the default `200`.
15+
- **The extended object's owner's export** now carries the object as the owner declares it, without the fields, field patches or labels of ANY other package's extension. Until now only its own `.custom` pair's extension was left out; an extension from any other installed package was folded in.
16+
- **Every served read is unchanged**: `GET /api/v1/meta/object/<name>`, the object list, the console and the data API keep serving the object with every extension folded in.
17+
18+
`SchemaRegistry` gains `getObjectExtensionsOf(packageId)`, which reads a package's `extend` contributions back as the `objectExtensions` entries they were registered from (the inverse of `registerObjectExtension`). `listItems`, `getAllObjects` and `foldObjectExtendersOnto` take an `onlyExtensionsOf` option beside `withoutExtensionsOf`: each object is resolved with only that package's `extend` contributions folded in. The metadata protocol's per-package layer (the package export) now passes `onlyExtensionsOf`, naming the listed package, and no longer passes `withoutExtensionsOf`, which keeps its meaning for any other caller.
19+
20+
Supersedes, in the same release: the `.custom` pair note for #29903 says an extension contributed by any other installed package is still folded into the base's object in the base's export. After this change it is not: the base's export leaves out every other package's extension, and that package's own export carries it.

‎packages/metadata-protocol/src/protocol-meta-list-reads.ts‎

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -89,19 +89,19 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
8989
// and exports every package, enabled or not); the effective and the
9090
// execution reads hide them, at the registry and after the merges below.
9191
const keepDisabledPackages = layer === 'per-package';
92-
// [#29903, ADR-0146 D8 item 3] …and it serves a package's objects as the
93-
// package declares them: its `.custom` pair's extension of one is the
94-
// pair's item, exported as the pair's `objectExtensions` entry, never
95-
// folded into the base's object for the base's export (a field and its
96-
// patches would travel twice, the second time as the base's). Every
97-
// other contributor folds as on the effective read, and the effective
98-
// and execution reads keep the pair's extension in the object they serve.
99-
const pairOfLayer = layer === 'per-package' && packageId ? customizationPackageId(packageId) : undefined;
100-
const withoutExtensionsOf = pairOfLayer?.ok ? pairOfLayer.value : undefined;
92+
// [#29903, #29872, ADR-0146 D8 item 3] …and it serves a package's objects
93+
// as the package declares them: another package's extension of one (the
94+
// `.custom` pair's, or any other installed package's) is that package's,
95+
// exported as its own `objectExtensions` entry, never folded into the
96+
// owner's object for the owner's export (a field and its patches would
97+
// travel twice, the second time as the owner's). Only the package's own
98+
// extensions fold, and the effective and execution reads keep every
99+
// extension in the object they serve.
100+
const onlyExtensionsOf = layer === 'per-package' && packageId ? packageId : undefined;
101101
const listRegistry = (type: string): unknown[] => (keepDisabledPackages
102102
? host.engine.registry.listItems(type, packageId, {
103103
includeDisabledPackages: true,
104-
...(withoutExtensionsOf !== undefined ? { withoutExtensionsOf } : {}),
104+
...(onlyExtensionsOf !== undefined ? { onlyExtensionsOf } : {}),
105105
})
106106
: host.engine.registry.listItems(type, packageId));
107107
// ── [ADR-0131 D6] The environment → code read ──────────────────────
@@ -263,10 +263,10 @@ export async function readFlattenedMetaItems(host: Pick<ProtocolHost, 'artifactL
263263
// object disagree the moment a row exists. Both routes were
264264
// wrong here together, which is why #7556's byName===listed
265265
// pin stayed green through this defect.
266-
// [#29903] …without the package's pair's extension on the
267-
// per-package layer, as its registry items are listed.
266+
// [#29903, #29872] …with only the package's own extensions on
267+
// the per-package layer, as its registry items are listed.
268268
return host.foldObjectExtendersFromRegistry(
269-
request.type, (data as { name?: unknown } | null)?.name, data, withoutExtensionsOf,
269+
request.type, (data as { name?: unknown } | null)?.name, data, onlyExtensionsOf,
270270
);
271271
}, unseated, { servedOverride });
272272

‎packages/metadata-protocol/src/protocol-object-extender-fold.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export function foldObjectExtendersFromRegistry(
4646
type: string,
4747
name: unknown,
4848
body: unknown,
49-
withoutExtensionsOf?: string,
49+
onlyExtensionsOf?: string,
5050
): unknown {
5151
const singular = PLURAL_TO_SINGULAR[type] ?? type;
5252
if (singular !== 'object') return body;
@@ -60,11 +60,12 @@ export function foldObjectExtendersFromRegistry(
6060
// cannot fold answers exactly as it did before.
6161
if (!registry || typeof registry.foldObjectExtendersOnto !== 'function') return body;
6262
try {
63-
// [#29903] `withoutExtensionsOf`: that package's `extend` contributions
64-
// are left out of the fold (the per-package layer names a base's pair).
65-
return withoutExtensionsOf === undefined
63+
// [#29903, #29872] `onlyExtensionsOf`: every other package's `extend`
64+
// contributions are left out of the fold (the per-package layer names
65+
// the listed package).
66+
return onlyExtensionsOf === undefined
6667
? registry.foldObjectExtendersOnto(name, body)
67-
: registry.foldObjectExtendersOnto(name, body, { withoutExtensionsOf });
68+
: registry.foldObjectExtendersOnto(name, body, { onlyExtensionsOf });
6869
} catch {
6970
// The fold is a read over in-memory contributors; a failure here
7071
// must not turn a served schema into a 5xx.

‎packages/metadata-protocol/src/protocol.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3526,13 +3526,13 @@ export class ObjectStackProtocolImplementation implements
35263526
* (see {@link SchemaRegistry.foldObjectExtendersOnto}), so applying it twice
35273527
* would duplicate both.
35283528
*/
3529-
private foldObjectExtendersFromRegistry(type: string, name: unknown, body: unknown, withoutExtensionsOf?: string): unknown {
3529+
private foldObjectExtendersFromRegistry(type: string, name: unknown, body: unknown, onlyExtensionsOf?: string): unknown {
35303530
// [#25467 cut 30] The body moved to `protocol-object-extender-fold.ts` (see `ObjectExtenderFoldHost`).
35313531
const readEngine = () => this.engine;
35323532
const host: ThisParameterType<typeof foldObjectExtendersFromRegistry> = {
35333533
get engine() { return readEngine(); },
35343534
};
3535-
return foldObjectExtendersFromRegistry.call(host, type, name, body, withoutExtensionsOf);
3535+
return foldObjectExtendersFromRegistry.call(host, type, name, body, onlyExtensionsOf);
35363536
}
35373537

35383538
/**

‎packages/objectql/src/registry-extension-field-patches.test.ts‎

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import { describe, it, expect, vi, afterEach } from 'vitest';
2121
import { SchemaRegistry } from './registry.js';
2222
import { ObjectQL } from './engine.js';
2323
import { fieldPatchTargetRefusals } from '@objectstack/metadata-core';
24+
import { ObjectExtensionSchema } from '@objectstack/spec/data';
2425

2526
const OWNER_PKG = 'com.vendor.crm';
2627
const EXT_PKG = 'com.acme.crm-display';
@@ -406,7 +407,7 @@ describe('ADR-0146 D10 — the manifest loop carries fieldPatches onto the exten
406407
});
407408
});
408409

409-
describe('#29903 — a read can leave one package\'s extensions out of the fold', () => {
410+
describe('#29903, #29872 — a read can fold only one package\'s own extensions', () => {
410411
const PAIR_PKG = `${OWNER_PKG}.custom`;
411412
/** The vendor's object, a third package's patch, and the pair's field plus its patch. */
412413
function extended(): SchemaRegistry {
@@ -424,31 +425,80 @@ describe('#29903 — a read can leave one package\'s extensions out of the fold'
424425
industry: body?.fields?.industry?.label,
425426
name: body?.fields?.name?.label,
426427
});
427-
const listed = (reg: SchemaRegistry, options?: { withoutExtensionsOf?: string }) =>
428-
(reg.listItems<any>('object', OWNER_PKG, options)).find((o) => o?.name === OBJ);
428+
const listed = (reg: SchemaRegistry, packageId: string, options?: { withoutExtensionsOf?: string; onlyExtensionsOf?: string }) =>
429+
(reg.listItems<any>('object', packageId, options)).find((o) => o?.name === OBJ);
429430

430-
it('the listed object without the pair\'s extension is the object as no pair had extended it', () => {
431+
it('[#29903] `withoutExtensionsOf` leaves that one package\'s extension out, and folds every other', () => {
431432
const reg = extended();
432-
expect(shape(listed(reg, { withoutExtensionsOf: PAIR_PKG }))).toEqual({ region: false, industry: 'Industry', name: 'Account name' });
433-
expect(listed(reg, { withoutExtensionsOf: PAIR_PKG })?._packageId).toBe(OWNER_PKG);
434-
// CONTROL: every other read of the object still folds the pair in.
435-
expect(shape(listed(reg))).toEqual({ region: true, industry: 'Sector (customized)', name: 'Account name' });
436-
expect(shape(reg.getObject(OBJ))).toEqual({ region: true, industry: 'Sector (customized)', name: 'Account name' });
433+
expect(shape(listed(reg, OWNER_PKG, { withoutExtensionsOf: PAIR_PKG }))).toEqual({ region: false, industry: 'Industry', name: 'Account name' });
434+
expect(listed(reg, OWNER_PKG, { withoutExtensionsOf: PAIR_PKG })?._packageId).toBe(OWNER_PKG);
435+
expect(shape(reg.foldObjectExtendersOnto(OBJ, vendorAccount(), { withoutExtensionsOf: PAIR_PKG })))
436+
.toEqual({ region: false, industry: 'Industry', name: 'Account name' });
437437
// A package that extends nothing changes nothing: the cached merged object.
438-
expect(listed(reg, { withoutExtensionsOf: 'com.nobody' })).toBe(reg.getObject(OBJ));
438+
expect(listed(reg, OWNER_PKG, { withoutExtensionsOf: 'com.nobody' })).toBe(reg.getObject(OBJ));
439+
});
440+
441+
it('the owner\'s listed object with only its own extensions is the object as no package had extended it', () => {
442+
const reg = extended();
443+
// Neither the pair's extension nor the third package's is folded in.
444+
expect(shape(listed(reg, OWNER_PKG, { onlyExtensionsOf: OWNER_PKG }))).toEqual({ region: false, industry: 'Industry', name: 'Name' });
445+
expect(listed(reg, OWNER_PKG, { onlyExtensionsOf: OWNER_PKG })?._packageId).toBe(OWNER_PKG);
446+
// CONTROL: every other read of the object still folds both in.
447+
expect(shape(listed(reg, OWNER_PKG))).toEqual({ region: true, industry: 'Sector (customized)', name: 'Account name' });
448+
expect(shape(reg.getObject(OBJ))).toEqual({ region: true, industry: 'Sector (customized)', name: 'Account name' });
449+
});
450+
451+
it('an extender\'s listed object folds that extender\'s own extension only', () => {
452+
expect(shape(listed(extended(), EXT_PKG, { onlyExtensionsOf: EXT_PKG }))).toEqual({ region: false, industry: 'Industry', name: 'Account name' });
439453
});
440454

441-
it('leaving the pair out is the answer a registry with no pair gives, byte for byte', () => {
455+
it('an object no other package extends is the cached merged object', () => {
442456
const reg = withVendor();
443-
extend(reg, { name: { label: 'Account name' } });
444-
expect(JSON.stringify(listed(extended(), { withoutExtensionsOf: PAIR_PKG }))).toBe(JSON.stringify(listed(reg)));
457+
expect(listed(reg, OWNER_PKG, { onlyExtensionsOf: OWNER_PKG })).toBe(reg.getObject(OBJ));
458+
});
459+
460+
it('leaving every other package out is the answer a registry with no extender gives, byte for byte', () => {
461+
expect(JSON.stringify(listed(extended(), OWNER_PKG, { onlyExtensionsOf: OWNER_PKG })))
462+
.toBe(JSON.stringify(listed(withVendor(), OWNER_PKG)));
445463
});
446464

447-
it('the fold onto a stored body leaves the pair\'s extension out the same way', () => {
465+
it('the fold onto a stored body leaves the other packages\' extensions out the same way', () => {
448466
const reg = extended();
449-
expect(shape(reg.foldObjectExtendersOnto(OBJ, vendorAccount(), { withoutExtensionsOf: PAIR_PKG })))
450-
.toEqual({ region: false, industry: 'Industry', name: 'Account name' });
467+
expect(shape(reg.foldObjectExtendersOnto(OBJ, vendorAccount(), { onlyExtensionsOf: OWNER_PKG })))
468+
.toEqual({ region: false, industry: 'Industry', name: 'Name' });
469+
expect(shape(reg.foldObjectExtendersOnto(OBJ, vendorAccount(), { onlyExtensionsOf: PAIR_PKG })))
470+
.toEqual({ region: true, industry: 'Sector (customized)', name: 'Name' });
451471
expect(shape(reg.foldObjectExtendersOnto(OBJ, vendorAccount())))
452472
.toEqual({ region: true, industry: 'Sector (customized)', name: 'Account name' });
453473
});
454474
});
475+
476+
describe('#29872 — getObjectExtensionsOf reads a package\'s extensions back as the entries it registered from', () => {
477+
const MANIFEST_EXTENSION = { extend: OBJ, fields: { acme_code: { type: 'text', label: 'Billing code' } } };
478+
const STORED_EXTENSION = { extend: OBJ, fieldPatches: { industry: { label: 'Sector' } }, priority: 250 };
479+
480+
it('a manifest `objectExtensions` entry (registerApp, no item) comes back as written, its default priority omitted', () => {
481+
const ql = new ObjectQL();
482+
(ql.registry as SchemaRegistry).logLevel = 'silent';
483+
ql.registerApp({ id: OWNER_PKG, name: 'CRM', version: '1.0.0', objects: [vendorAccount()] });
484+
ql.registerApp({ id: EXT_PKG, name: 'CRM ext', version: '1.0.0', objectExtensions: [{ ...MANIFEST_EXTENSION }] });
485+
486+
const entries = (ql.registry as SchemaRegistry).getObjectExtensionsOf(EXT_PKG);
487+
expect(entries).toEqual([MANIFEST_EXTENSION]);
488+
expect(ObjectExtensionSchema.safeParse(entries[0]).success).toBe(true);
489+
// The owner contributes no extension, and a package that extends nothing reads none.
490+
expect((ql.registry as SchemaRegistry).getObjectExtensionsOf(OWNER_PKG)).toEqual([]);
491+
expect((ql.registry as SchemaRegistry).getObjectExtensionsOf('com.nobody')).toEqual([]);
492+
});
493+
494+
it('a stored item\'s extension (registerObjectExtension) comes back with its own priority, and re-registers to the same object', () => {
495+
const reg = withVendor();
496+
reg.registerObjectExtension({ ...STORED_EXTENSION }, EXT_PKG);
497+
const entries = reg.getObjectExtensionsOf(EXT_PKG);
498+
expect(entries).toEqual([STORED_EXTENSION]);
499+
// The inverse of registerObjectExtension: a fresh registry fed the entries serves the same object.
500+
const fresh = withVendor();
501+
for (const entry of entries) fresh.registerObjectExtension(entry, EXT_PKG);
502+
expect(JSON.stringify(fresh.getObject(OBJ))).toBe(JSON.stringify(reg.getObject(OBJ)));
503+
});
504+
});

0 commit comments

Comments
 (0)