Skip to content

Commit 8196881

Browse files
feat(runtime): the per-base customizations read names the package behind each field and flow another package contributes (#28749)
domain:server#3 · os-project-manager · session_01FEkQ5kE5BrPUPNMMyv6oCH <!-- claude-projects-attribution: {"github_login":"os-project-manager"} --> _Requested by **Leehom** · [project thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XQB8PvuLXjvzVKRUBkB8969)_ Part of #28722 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — the member is on a REST answer that no published skill teaches, and no metadata author writes it ## 维护者速读 - **改了什么**:每个已安装基础包的「定制读取」(`GET /packages/:id/customizations`) 多返回一张 `contributions` 清单:别的包给这个基础包的对象加的字段,以及别的包挂在这些对象上的流程,每一条都写明是哪个包贡献的。基础包自己的东西和它的 `.custom` 配对包不在里面。 - **为什么改**:客户在 Studio 打开 HotCRM 时,要能看出哪些字段、流程是 HotCRM 自带的,哪些是别的包加进来的(灰显并写明来源,ADR-0029 D7 / ADR-0146 D3)。以前没有任何读取接口说明来源,前端也不许自己猜。 - **风险与回滚**:只加字段,不改已有清单和数字;Studio 现有的「已定制 N 项」计数不受影响。spec 的应答声明是严格的,所以 ui 里手写的几个测试样例补了这个新成员。回滚 = revert 本 PR。导航项(Wanted 3)不在本 PR,等 #27928 的裁决。 - **先看**:① `packages/spec/src/api/package-lifecycle.zod.ts` 新增的 `contributions` 与 `counts.contributions`(契约);② `packages/runtime/src/domains/packages.ts` 的 `readBaseContributions`(排除基础包、配对包和本环境自己写的流程的三处判断);③ `packages/runtime/src/domains/packages-customizations.test.ts` 新增的 #28722 一组测试。 - **两分钟验证**:`pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-customizations.test.ts`,预期 17 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes - **Contract** — `packages/spec/src/api/package-lifecycle.zod.ts`: `GetPackageCustomizationsResponseSchema` gains `contributions`, an array of strict entries `{ kind: 'field' | 'flow', object, name, packageId }`, and `counts.contributions`. Strict like the rest of the answer, and required: the door always serves it. - **Where it lives: a member of the existing read, not a new route.** Measured choice between the N-15 answer and one new read: - The N-15 read is already the one per-base read Studio fetches for a base's surface (`packageCustomizations-io.ts`), with the read gate, the closed query set, the pair-id refusal and the 404 wording this answer also needs. A new `GET /packages/:id/contributions` would duplicate all four and add a route-ledger row, a dispatcher mount, a typed client method and the dogfood route census pins. - The cost of the member is that hand-built answers in tests must carry it. Four fixtures do (one runtime mount test, three ui tests); they are updated here. - The existing lists keep their meaning: `extensions` stays the pair's members only, and Studio's "customized" count (overrides + extensions) is unchanged. - **Reader** — `packages/runtime/src/domains/packages.ts#readBaseContributions`, called by `readPackageCustomizations`. Two sources, each the registry that already records the contributor: - **field**: the object registry's `extend` contributors of each object the base owns (`getObjectOwner`), the same walk the `extensions` list makes for the pair. Each contributor's `definition` is its extension body as declared, so its `fields` keys are the fields it adds and its `packageId` is the package that declared them. - **flow**: `registry.listItems('flow')` (which already drops a disabled package's flows), kept when the `start` node's `config.objectName` is a base-owned object and the body is a code package's (`isCodeArtifactBody`: not the `sys_metadata` sentinel and not a tenant-authored overlay). The package is the stamp the registration put on the flow. - Excluded: the base and its `.custom` pair (the pair's members stay in `extensions`). One entry per item and package, so two packages adding a field of one name are two entries. Ordered by object, kind, name and package. - **Hypotheses in the order, measured:** - `getObjectContributors` already knows the extenders: holds. Read here exactly as N-15 reads it. - Stamping the source at fold time (`mergeObjectDefinitions`): not taken. The fold is shared by every object read and the write door, and #28565 edits it. Reading the declared `extend` bodies needs no change to the served object. - A flow's owning package is on its registry entry: holds (`_packageId`, stamped by `registerItem`). The trigger object is the `start` node's `config.objectName`. - `packages/runtime/src/route-ledger.ts`: the row's note names the new list (`check-route-ledger-census` green). - Regenerated: `content/docs/references/api/package-lifecycle.mdx` (`gen:docs`) and `docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md` (`gen:strictness-ledger`), the two artifacts `check:generated` named stale, and `packages/spec/authorable-surface/api.json` (the spec build writes it: one line, `api/GetPackageCustomizationsResponse:contributions`). No export was added or removed (`check:api-surface` unchanged). - `.changeset/28722-customizations-contributions.md`: `minor` on `@objectstack/spec` and `@objectstack/runtime`. The kind is `added`: the new member moves the spec's authorable-surface shard (`packages/spec/authorable-surface/api.json`), and a kind takes at least `minor` on every package the diff moves (AGENTS.md Post-Task Checklist step 3). The ui fixture edits are test files, which no package ships. ## Tests | Test | Head | Base / ablation | |---|---|---| | `packages/runtime/src/domains/packages-customizations.test.ts` (17) | 17/17 | `packages.ts` at base `4bf48a8c`: 7 red (the 4 new tests and the 3 whole-answer equalities) | | ablation: pair exclusion removed | | 4 red | | ablation: base exclusion removed | | 2 red | | ablation: `isCodeArtifactBody` guard removed (environment flows listed) | | 1 red | | ablation: base-owned trigger object check removed | | 3 red | The new tests (#28722 describe block) use the card's fixture: a base with its own flow on its object, the pair extending that object and carrying a flow, and a third code package registered through `ObjectQL.registerApp` (the real manifest path) with an `objectExtensions` field and two flows, one on the base's object and one on another package's object. - The read names the third package for its field and its flow, and names neither the base nor the pair; the answer parses through the declared schema. - Two packages adding a field of the same name are two entries, each with its own package. - A flow authored in the environment (the `sys_metadata` sentinel, and a tenant-authored overlay wearing a package id) is not listed. - Another base's read lists only what is contributed to its own objects. Each ablation ran through `scripts/ablation-replace.mjs` (anchor hit, run, restore), and each restore was proven: blob equal to HEAD and `git diff HEAD` empty. The runtime test imports `packages.ts` from `src`, so no rebuild was needed between legs. Suites: | Suite | Result | |---|---| | `pnpm --filter @objectstack/runtime test` | 504 files, 8203 passed, 19 skipped, exit 0 | | ui `BuilderLanding.customizationCounts-28566`, `customizationMarks`, `metadataAreaHost.customizations-28568` | 3 files, 22/22, exit 0 | | `pnpm --filter @objectstack/runtime typecheck`, `pnpm --filter @objectstack/spec typecheck` | exit 0 | | `pnpm build` (whole repo) | exit 0 | ## Acceptance notes - **Wanted 3 (navigation entries)** is not here. It is the same question as #27928, which waits on the maintainer's ruling, so this PR is `Part of #28722`. - **D5, the ui reader.** The served key is pinned by the server test above. The greyed, attributed drawing belongs to N-19 (#28572), which is in flight on the ui lane and is not on `main`. Drawing it here would edit the same Studio surfaces that PR is changing, so the reader is N-19's (or its follow-up card's) to wire, now that a served source exists for fields and flows. - The ui package's own `type-check` needs every `ui/` package built; it was not run locally. CI's `Type Check UI` covers it. - Not stamped at fold time, so the folded object read still carries no per-field source. The contributions list is the served source for that question. ## Verification All readings at head `b4a36765` unless named. Head `7edbf44b` changes only the changeset (kind `added`, `minor`); `check-adr-0087-registration --base origin/main` and `check-empty-changeset --base origin/main` exit 0 there. `pnpm --filter @objectstack/runtime exec vitest run src/domains/packages-customizations.test.ts`: 17/17. With `packages.ts` restored to base `4bf48a8c`: 7 red. <details><summary>Derived gates: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack`, re-derived at `b4a36765`: the same 109 commands as at `274f09f9`, where the loop ran. - `--ran` verdict: "109 derived famil(ies) accounted for — 109 run, 0 NOT-MEASURED", exit 0. - First pass at `274f09f9`: 105 exit 0, and four that did not: - `check:doc-authoring` exit 1: a real finding. The route-ledger note carried the tracker id. It is removed in `b4a36765`, and the gate is green there. - `check-plugin-teardown-shape --self-test` exit 3: shallow clone. After fetching its pinned fixture commit, 48 cases pass. - `check:skill-examples` and `check:dual-build-cjs-loads` exit 3 (PREREQUISITE NOT MET: no built output). After a whole-repo `pnpm build` (exit 0), both exit 0. - Re-run at `b4a36765` after the note edit: `check:doc-authoring`, `check:route-envelope`, `check:issue-citations`, `check:nul-bytes`, `check:published-files`, `check:type-check-coverage`, `check:durability-log-level` and the four above, all exit 0. - `node scripts/check-route-ledger-census.mjs`: exit 0 (115 rows). - `pnpm --filter @objectstack/spec check:generated --fix`: two artifacts were stale (docs, strictness ledger). Both were regenerated and re-checked green. - eslint `--no-inline-config` over the 5 changed `packages/` TS files: exit 0. ui eslint over the 3 changed test files: exit 0. </details> <details><summary>ui fixtures were needed</summary> `BuilderLanding.customizationCounts-28566.test.tsx` restored to base: 3 of 6 red against the new schema, because its served answer lacks the now-required `contributions`. With the fixture carrying it: 6/6 green. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01FEkQ5ogKLgaK3fAZF4kmeF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d9c4cdc commit 8196881

13 files changed

Lines changed: 257 additions & 19 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
feat(runtime): `GET /packages/:id/customizations` names the package behind each field and flow another package contributes to the base
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
Studio draws the items another installed package brings to a base greyed, naming their source (ADR-0029 D7 as amended by ADR-0146 D3). Until now no served read said which package contributed what. The per-base customization read now answers a fourth list, `contributions`, with its count:
12+
13+
- **What is listed.** Every field another package's object extension (`objectExtensions`) adds to an object the base owns, and every flow such a package ships whose trigger object (the `start` node's `config.objectName`) the base owns. One entry per item: `{ kind: 'field' | 'flow', object, name, packageId }`.
14+
- **What is not.** The base's own items, and its `<base>.custom` pair's (the pair's members stay in `extensions`). A flow authored in this environment is the environment's, not a package's. A flow of a disabled package is not served, so it is not listed.
15+
- **The stamp is on the item.** Two packages adding a field of the same name are two entries, each naming its package; a client joins nothing by name.
16+
- `GetPackageCustomizationsResponseSchema` declares the member and `counts.contributions`, strict as the rest of the answer. A client parsing the answer through the schema needs this version of `@objectstack/spec`.
17+
18+
Navigation entries contributed to the base's app are not in this list yet.

‎content/docs/references/api/package-lifecycle.mdx‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -163,7 +163,7 @@ Per-package customization read request: the base package id, no query parameters
163163

164164
## GetPackageCustomizationsResponse
165165

166-
Per-package customization read: overrides, extensions and switched-off items of one installed base.
166+
Per-package customization read: overrides, extensions and switched-off items of one installed base, and the fields and flows other packages contribute to it.
167167

168168
### Properties
169169

@@ -173,7 +173,8 @@ Per-package customization read: overrides, extensions and switched-off items of
173173
| **overrides** | `{ type: string; name: string; member?: string; basedOn?: string; … }[]` | ✅ | Every ACTIVE item of the base's paired `<base>.custom` package that declares `overrides.package` = the base and that the effective read serves over the base's item of the same name: the rows the served-override predicate admits (a declaration from any other package, and an undeclared pair item, are not listed), minus the stored rows every read declines (a shipped flow name). Ordered by type, name and member. |
174174
| **extensions** | `{ object: string; kind: Enum<'field' \| 'validation' \| 'index'>; name?: string; fields?: string[] }[]` | ✅ | Every field, validation rule and index the paired `<base>.custom` package declares in an object extension (`objectExtensions`) of an object the base owns, one entry per member, as the extension declares it. Ordered by object, kind and name. |
175175
| **switchedOff** | `{ type: string; name: string }[]` | ✅ | Every activation-ledger row (`sys_metadata_activation`) that records the base as its package and `active: false`: the base's items this deployment switched off. Ordered by type and name. |
176-
| **counts** | `{ overrides: integer; extensions: integer; switchedOff: integer }` | ✅ | Each list's length, derived from the lists in this same answer. |
176+
| **contributions** | `{ kind: Enum<'field' \| 'flow'>; object: string; name: string; packageId: string }[]` | ✅ | Every field and flow another installed package contributes to the base's objects, one entry per item and contributing package: the fields of every object extension of an object the base owns, from any package but the base and its pair, and every flow registered under such a package whose trigger object the base owns (a flow of a disabled package is not served, so not listed). Ordered by object, kind, name and package. |
177+
| **counts** | `{ overrides: integer; extensions: integer; switchedOff: integer; contributions: integer }` | ✅ | Each list's length, derived from the lists in this same answer. |
177178

178179
### Nested Shape: `GetPackageCustomizationsResponse.overrides[number]`
179180

@@ -201,13 +202,23 @@ Per-package customization read: overrides, extensions and switched-off items of
201202
| **type** | `string` | ✅ | The ledger row's metadata type (`flow`, `action`, `position`, …). |
202203
| **name** | `string` | ✅ | The switched-off item's name. |
203204

205+
### Nested Shape: `GetPackageCustomizationsResponse.contributions[number]`
206+
207+
| Property | Type | Required | Description |
208+
| :--- | :--- | :--- | :--- |
209+
| **kind** | `Enum<'field' \| 'flow'>` | ✅ | What is contributed: `field`, a field another package's object extension (`objectExtensions`) adds to one of the base's objects; `flow`, a flow another package ships whose trigger object (its `start` node's `config.objectName`) is one of the base's objects. |
210+
| **object** | `string` | ✅ | The base-owned object the item is on: the extended object, or the flow's trigger object. |
211+
| **name** | `string` | ✅ | The item's name: the field name as the extension declares it, or the flow's name. |
212+
| **packageId** | `string` | ✅ | The package that contributes the item: the extension's package, or the package the flow is registered under. Never the base and never its `<base>.custom` pair. |
213+
204214
### Nested Shape: `GetPackageCustomizationsResponse.counts`
205215

206216
| Property | Type | Required | Description |
207217
| :--- | :--- | :--- | :--- |
208218
| **overrides** | `integer` | ✅ | `overrides.length`. |
209219
| **extensions** | `integer` | ✅ | `extensions.length`: members, not objects. |
210220
| **switchedOff** | `integer` | ✅ | `switchedOff.length`. |
221+
| **contributions** | `integer` | ✅ | `contributions.length`: items, not packages. |
211222

212223

213224
---

‎docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
1919

2020
| Dir | Sites |
2121
|---|---|
22-
| `api/` | 477 |
22+
| `api/` | 478 |

‎packages/runtime/src/dispatcher-plugin.package-customizations-mount.test.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,8 @@ describe('GET /api/v1/packages/:id/customizations — mounted', () => {
100100
overrides: [OVERRIDE],
101101
extensions: [],
102102
switchedOff: [],
103-
counts: { overrides: 1, extensions: 0, switchedOff: 0 },
103+
contributions: [],
104+
counts: { overrides: 1, extensions: 0, switchedOff: 0, contributions: 0 },
104105
});
105106
const parsed = GetPackageCustomizationsResponseSchema.safeParse(res.body.data);
106107
expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true);

‎packages/runtime/src/domains/packages-customizations.test.ts‎

Lines changed: 95 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -339,7 +339,8 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an
339339
overrides: [{ type: 'view', name: VIEW, basedOn: '1.0.0', version: expect.any(Number) }],
340340
extensions: [{ object: 'showcase_task', kind: 'field', name: 'region__c' }],
341341
switchedOff: [{ type: 'flow', name: 'showcase_task_reminder' }],
342-
counts: { overrides: 1, extensions: 1, switchedOff: 1 },
342+
contributions: [],
343+
counts: { overrides: 1, extensions: 1, switchedOff: 1, contributions: 0 },
343344
});
344345
const parsed = GetPackageCustomizationsResponseSchema.safeParse(data);
345346
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
@@ -442,7 +443,8 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an
442443
overrides: [],
443444
extensions: [],
444445
switchedOff: [],
445-
counts: { overrides: 0, extensions: 0, switchedOff: 0 },
446+
contributions: [],
447+
counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: 0 },
446448
});
447449
expect(engine.registry.getAllPackages().length).toBe(before);
448450
expect(engine.registry.getPackage(PAIR)).toBeFalsy();
@@ -488,3 +490,94 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an
488490
expect(r.response?.body?.error?.message).toContain('includeDrafts');
489491
});
490492
});
493+
494+
const THIRD = 'com.example.acme-sync';
495+
const FOURTH = 'com.example.partner';
496+
497+
/** A flow triggered by a record change on `object`. */
498+
const flowOn = (name: string, object: string) => ({
499+
name,
500+
label: name,
501+
type: 'record_change',
502+
status: 'active',
503+
nodes: [
504+
{ id: 'start', type: 'start', label: 'Start', config: { objectName: object, triggerType: 'record-after-update' } },
505+
{ id: 'end', type: 'end', label: 'End' },
506+
],
507+
edges: [{ id: 'e1', source: 'start', target: 'end' }],
508+
});
509+
510+
/** A code package registered as the engine registers every manifest: an object extension and flows. */
511+
const registerContributor = (engine: ObjectQL, id: string, fields: readonly string[], flows: ReadonlyArray<ReturnType<typeof flowOn>>) =>
512+
engine.registerApp({
513+
id,
514+
name: id.split('.').join('-'),
515+
version: '1.0.0',
516+
objectExtensions: [{
517+
extend: 'showcase_task',
518+
fields: Object.fromEntries(fields.map((f) => [f, { name: f, label: f, type: 'text' }])),
519+
}],
520+
flows: [...flows],
521+
});
522+
523+
/**
524+
* The card's fixture (#28722): the base ships a flow on its object, the pair
525+
* extends that object and carries a flow on it, and a third code package
526+
* extends the same object with a field and ships a flow on it — plus a flow
527+
* on an object the base does not own.
528+
*/
529+
async function contributedFixture() {
530+
const booted = await boot();
531+
const { engine, protocol } = booted;
532+
engine.registry.registerItem('flow', flowOn('showcase_task_escalate', 'showcase_task'), 'name', BASE);
533+
await install(protocol, PAIR);
534+
extend(engine, PAIR, 'showcase_task', { fields: { region__c: { name: 'region__c', label: 'Region', type: 'text' } } });
535+
engine.registry.registerItem('flow', flowOn('showcase_task_region_notify', 'showcase_task'), 'name', PAIR);
536+
registerContributor(engine, THIRD, ['acme_sync_id'], [flowOn('acme_task_sync', 'showcase_task'), flowOn('acme_note_sync', 'other_note')]);
537+
return booted;
538+
}
539+
540+
describe('[#28722] GET /packages/:id/customizations — the fields and flows other packages contribute to a base', () => {
541+
it('names the third package for its extension field and its flow, and names neither the base nor the pair', async () => {
542+
const { dispatcher } = await contributedFixture();
543+
const r = await customizations(dispatcher, BASE);
544+
expect(r.response?.status).toBe(200);
545+
const data = r.response?.body?.data;
546+
expect(data.contributions).toEqual([
547+
{ kind: 'field', object: 'showcase_task', name: 'acme_sync_id', packageId: THIRD },
548+
{ kind: 'flow', object: 'showcase_task', name: 'acme_task_sync', packageId: THIRD },
549+
]);
550+
expect(data.counts.contributions).toBe(2);
551+
// The pair's member stays where N-15 serves it, and is not a contribution.
552+
expect(data.extensions).toEqual([{ object: 'showcase_task', kind: 'field', name: 'region__c' }]);
553+
const parsed = GetPackageCustomizationsResponseSchema.safeParse(data);
554+
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
555+
});
556+
557+
it('stamps each item with its own package: two packages adding a field of one name are two entries', async () => {
558+
const { dispatcher, engine } = await contributedFixture();
559+
registerContributor(engine, FOURTH, ['acme_sync_id'], []);
560+
const { contributions } = (await customizations(dispatcher, BASE)).response?.body?.data;
561+
expect(contributions.filter((c: any) => c.kind === 'field')).toEqual([
562+
{ kind: 'field', object: 'showcase_task', name: 'acme_sync_id', packageId: THIRD },
563+
{ kind: 'field', object: 'showcase_task', name: 'acme_sync_id', packageId: FOURTH },
564+
]);
565+
});
566+
567+
it('a flow authored in this environment is not a package\'s contribution', async () => {
568+
const { dispatcher, engine } = await contributedFixture();
569+
// The bare slot every package-less registration takes: the sentinel
570+
// stamp, and a tenant-authored overlay wearing a real package's id.
571+
engine.registry.registerItem('flow', { ...flowOn('showcase_task_local', 'showcase_task'), _packageId: 'sys_metadata' } as any);
572+
engine.registry.registerItem('flow', { ...flowOn('showcase_task_org', 'showcase_task'), _packageId: THIRD, _provenance: 'org' } as any);
573+
const { contributions } = (await customizations(dispatcher, BASE)).response?.body?.data;
574+
expect(contributions.map((c: any) => `${c.kind}:${c.name}`)).toEqual(['field:acme_sync_id', 'flow:acme_task_sync']);
575+
});
576+
577+
it('another base\'s read lists what is contributed to its own objects only', async () => {
578+
const { dispatcher } = await contributedFixture();
579+
const data = (await customizations(dispatcher, OTHER)).response?.body?.data;
580+
expect(data.contributions).toEqual([{ kind: 'flow', object: 'other_note', name: 'acme_note_sync', packageId: THIRD }]);
581+
expect(data.counts.contributions).toBe(1);
582+
});
583+
});

‎packages/runtime/src/domains/packages.ts‎

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import {
3232
// (`packages-capability-gate.test.ts`, beside this file), so it names
3333
// `OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES` — same value it read before,
3434
// no re-ruling of the package cohort as a side effect of #7020.
35-
import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core';
35+
import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES, isCodeArtifactBody } from '@objectstack/metadata-core';
3636
// [#28411, ADR-0146 D8 item 4] The per-package customization read's three
3737
// sources, each read from its owner: the pair's id and a pair's base
3838
// (`@objectstack/spec/kernel`, the one spelling of the suffix), and the
@@ -2527,7 +2527,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
25272527
// GET /packages/:id/customizations → [#28411, ADR-0146 D8 item 4] what
25282528
// this deployment changed on an installed base: its `.custom` pair's
25292529
// served overrides, the members the pair adds to the base's objects,
2530-
// and the base's items switched off in the activation ledger. See
2530+
// the base's items switched off in the activation ledger, and the
2531+
// fields and flows other packages contribute to it (#28722). See
25312532
// {@link readPackageCustomizations}.
25322533
if (parts.length === 2 && parts[1] === 'customizations' && m === 'GET') {
25332534
// The read authority of `GET /packages/:id`, the same gate, asked
@@ -3093,7 +3094,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
30933094
*/
30943095
/**
30953096
* [#28411, ADR-0146 D8 item 4, D3] The answer of `GET /packages/:id/customizations`
3096-
* for the installed base `baseId`: three lists, each read from the one owner
3097+
* for the installed base `baseId`: four lists, each read from the one owner
30973098
* of its fact, and their lengths. Served rows and ledger rows only; ⛔ no diff
30983099
* against the base and no `basedOn` verdict (ADR-0146 D2, D6).
30993100
*
@@ -3111,6 +3112,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
31113112
* ({@link listPackageMetadataActivations}). A composition with no ledger
31123113
* object has switched nothing off, which is the ledger's own reading of
31133114
* absence (ADR-0126 §4: no row means active).
3115+
* - **contributions** — [#28722, ADR-0029 D7 / ADR-0146 D3] what OTHER packages
3116+
* bring to the base's surface, each item stamped with its package: see
3117+
* {@link readBaseContributions}.
31143118
*
31153119
* No per-organization axis (ADR-0131 D6): every source is environment-level,
31163120
* so every caller the read gate admits gets the same answer. A base with no
@@ -3168,15 +3172,91 @@ async function readPackageCustomizations(
31683172
.map((row) => ({ type: row.metadataType, name: row.name }))
31693173
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));
31703174

3175+
const contributions = readBaseContributions(baseId, pair.ok ? pair.value : undefined, registry);
3176+
31713177
return {
31723178
packageId: baseId,
31733179
overrides,
31743180
extensions,
31753181
switchedOff,
3176-
counts: { overrides: overrides.length, extensions: extensions.length, switchedOff: switchedOff.length },
3182+
contributions,
3183+
counts: {
3184+
overrides: overrides.length,
3185+
extensions: extensions.length,
3186+
switchedOff: switchedOff.length,
3187+
contributions: contributions.length,
3188+
},
31773189
};
31783190
}
31793191

3192+
/**
3193+
* [#28722, ADR-0029 D7 as amended by ADR-0146 D3] The `contributions` of
3194+
* `GET /packages/:id/customizations`: every field and flow a package other than
3195+
* the base `baseId` and its pair `pairId` contributes to an object the base
3196+
* OWNS (`getObjectOwner`), each entry naming that package. Read from the two
3197+
* registries that already record the contributor, never inferred:
3198+
*
3199+
* - **field** — the object registry's `extend` contributors of the object, as
3200+
* the customizations' `extensions` reads the pair's: each contributor's
3201+
* `definition` is its extension body as declared, so its `fields` keys ARE
3202+
* the fields it adds, and its `packageId` is the package that declared it.
3203+
* One entry per contributor and field, so two packages adding a field of
3204+
* one name are two entries.
3205+
* - **flow** — the metadata registry's flows (`listItems('flow')`, which
3206+
* already leaves a disabled package's out), whose `start` node's
3207+
* `config.objectName` is a base-owned object, attributed to the package
3208+
* stamp the registration put on it when that stamp is a code package's
3209+
* ({@link isCodeArtifactBody}: not the package-less `sys_metadata`
3210+
* sentinel, not a tenant-authored overlay). A flow authored in this
3211+
* environment is the environment's, not a package's, and is not listed.
3212+
*
3213+
* Ordered by object, kind, name and package.
3214+
*/
3215+
function readBaseContributions(
3216+
baseId: string,
3217+
pairId: string | undefined,
3218+
registry: any,
3219+
): GetPackageCustomizationsResponse['contributions'] {
3220+
type Contribution = GetPackageCustomizationsResponse['contributions'][number];
3221+
const contributions: Contribution[] = [];
3222+
if (typeof registry?.getAllObjects !== 'function') return contributions;
3223+
const isContributor = (packageId: unknown): packageId is string =>
3224+
typeof packageId === 'string' && packageId !== '' && packageId !== baseId && packageId !== pairId;
3225+
3226+
const owned = new Set<string>();
3227+
for (const object of registry.getAllObjects(baseId) as Array<{ name?: unknown }>) {
3228+
const name = object?.name;
3229+
if (typeof name === 'string' && registry.getObjectOwner(name)?.packageId === baseId) owned.add(name);
3230+
}
3231+
3232+
for (const name of owned) {
3233+
for (const contributor of registry.getObjectContributors(name) as Array<{ packageId?: unknown; ownership?: unknown; definition?: any }>) {
3234+
if (contributor.ownership !== 'extend' || !isContributor(contributor.packageId)) continue;
3235+
for (const field of Object.keys(contributor.definition?.fields ?? {})) {
3236+
contributions.push({ kind: 'field', object: name, name: field, packageId: contributor.packageId });
3237+
}
3238+
}
3239+
}
3240+
3241+
if (owned.size > 0 && typeof registry.listItems === 'function') {
3242+
for (const flow of registry.listItems('flow') as Array<{ name?: unknown; nodes?: unknown; _packageId?: unknown }>) {
3243+
if (!isCodeArtifactBody(flow) || !isContributor(flow._packageId) || typeof flow.name !== 'string') continue;
3244+
const start = Array.isArray(flow.nodes)
3245+
? flow.nodes.find((node: any) => node?.type === 'start')
3246+
: undefined;
3247+
const object = start?.config?.objectName;
3248+
if (typeof object !== 'string' || !owned.has(object)) continue;
3249+
contributions.push({ kind: 'flow', object, name: flow.name, packageId: flow._packageId });
3250+
}
3251+
}
3252+
3253+
const kindOrder = { field: 0, flow: 1 } as const;
3254+
return contributions.sort((a, b) => a.object.localeCompare(b.object)
3255+
|| kindOrder[a.kind] - kindOrder[b.kind]
3256+
|| a.name.localeCompare(b.name)
3257+
|| a.packageId.localeCompare(b.packageId));
3258+
}
3259+
31803260
async function assemblePackageManifest(
31813261
deps: DomainHandlerDeps,
31823262
packageId: string,

0 commit comments

Comments
 (0)