diff --git a/.changeset/28796-contributions-one-stamp.md b/.changeset/28796-contributions-one-stamp.md new file mode 100644 index 00000000000..c89aed5da06 --- /dev/null +++ b/.changeset/28796-contributions-one-stamp.md @@ -0,0 +1,13 @@ +--- +'@objectstack/runtime': patch +'@objectstack/spec': patch +--- + +fix(runtime): the customizations read's `contributions` attributes a field and a flow by the same stamp rule + +Clause-②: no +Metadata-protocol: none + +`GET /packages/:id/customizations` now applies one rule to both kinds of contribution. A field from another package's object extension and a flow that package ships are listed only when their package stamp is a code package's: not the package-less `sys_metadata` sentinel, not a tenant-authored body, and not the base or its `.custom` pair. Before this, flows were judged that way and fields only by the package id. Nothing in an installed composition reads differently, so who is listed and the shape of the answer do not change. + +The `GetPackageCustomizationsResponseSchema` docblock now names exactly what `contributions` lists, and where the pair's items are instead: its overrides in `overrides`, its extension members in `extensions`, and no other pair flow in any list. diff --git a/packages/runtime/src/domains/packages-customizations.test.ts b/packages/runtime/src/domains/packages-customizations.test.ts index beb069626f4..e342fee301e 100644 --- a/packages/runtime/src/domains/packages-customizations.test.ts +++ b/packages/runtime/src/domains/packages-customizations.test.ts @@ -574,6 +574,33 @@ describe('[#28722] GET /packages/:id/customizations — the fields and flows oth expect(contributions.map((c: any) => `${c.kind}:${c.name}`)).toEqual(['field:acme_sync_id', 'flow:acme_task_sync']); }); + it('reads a field\'s stamp and a flow\'s stamp the same way: each stamp lists both kinds or neither', async () => { + const { dispatcher, engine } = await boot(); + // One field extension and one flow per stamp, on the base's object. + // A code package's stamp is a contribution; the package-less + // `sys_metadata` sentinel and a tenant-authored body wearing a real + // package's id are the environment's, whichever kind carries them. + const stamps = [ + { tag: 'code', packageId: THIRD, body: {} }, + { tag: 'sentinel', packageId: 'sys_metadata', body: {} }, + { tag: 'tenant', packageId: FOURTH, body: { _provenance: 'org' } }, + ] as const; + for (const { tag, packageId, body } of stamps) { + extend(engine, packageId, 'showcase_task', { ...body, fields: { [`${tag}_field`]: { name: `${tag}_field`, label: tag, type: 'text' } } }); + engine.registry.registerItem('flow', { ...flowOn(`${tag}_flow`, 'showcase_task'), _packageId: packageId, ...body } as any); + } + const { contributions } = (await customizations(dispatcher, BASE)).response?.body?.data; + const listedTags = (kind: string) => contributions + .filter((c: any) => c.kind === kind) + .map((c: any) => c.name.replace(/_(field|flow)$/, '')) + .sort(); + expect(listedTags('field')).toEqual(listedTags('flow')); + expect(contributions).toEqual([ + { kind: 'field', object: 'showcase_task', name: 'code_field', packageId: THIRD }, + { kind: 'flow', object: 'showcase_task', name: 'code_flow', packageId: THIRD }, + ]); + }); + it('another base\'s read lists what is contributed to its own objects only', async () => { const { dispatcher } = await contributedFixture(); const data = (await customizations(dispatcher, OTHER)).response?.body?.data; diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 05d05a1e276..5488c8893cf 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -3205,10 +3205,15 @@ async function readPackageCustomizations( * - **flow** — the metadata registry's flows (`listItems('flow')`, which * already leaves a disabled package's out), whose `start` node's * `config.objectName` is a base-owned object, attributed to the package - * stamp the registration put on it when that stamp is a code package's - * ({@link isCodeArtifactBody}: not the package-less `sys_metadata` - * sentinel, not a tenant-authored overlay). A flow authored in this - * environment is the environment's, not a package's, and is not listed. + * stamp the registration put on it (its `_packageId`). + * + * Both kinds ask ONE question of their stamp, {@link codePackageOf}: is it a + * code package's ({@link isCodeArtifactBody}: not the package-less + * `sys_metadata` sentinel, not a tenant-authored body), and neither the base + * nor its pair? Only where the stamp is read differs: the contributor's + * `packageId` beside its `definition`, or the flow's own `_packageId`. An item + * authored in this environment is the environment's, not a package's, and is + * not listed, whichever kind it is. * * Ordered by object, kind, name and package. */ @@ -3220,8 +3225,14 @@ function readBaseContributions( type Contribution = GetPackageCustomizationsResponse['contributions'][number]; const contributions: Contribution[] = []; if (typeof registry?.getAllObjects !== 'function') return contributions; - const isContributor = (packageId: unknown): packageId is string => - typeof packageId === 'string' && packageId !== '' && packageId !== baseId && packageId !== pairId; + // The one stamp reading both kinds share: the package `packageId` names + // when `body` is a code package's item and the package is neither the base + // nor its pair; `undefined` otherwise. + const codePackageOf = (packageId: unknown, body: unknown): string | undefined => { + if (typeof packageId !== 'string' || packageId === baseId || packageId === pairId) return undefined; + const stamped = { _packageId: packageId, _provenance: (body as { _provenance?: unknown } | null | undefined)?._provenance }; + return isCodeArtifactBody(stamped) ? packageId : undefined; + }; const owned = new Set(); for (const object of registry.getAllObjects(baseId) as Array<{ name?: unknown }>) { @@ -3231,22 +3242,25 @@ function readBaseContributions( for (const name of owned) { for (const contributor of registry.getObjectContributors(name) as Array<{ packageId?: unknown; ownership?: unknown; definition?: any }>) { - if (contributor.ownership !== 'extend' || !isContributor(contributor.packageId)) continue; + if (contributor.ownership !== 'extend') continue; + const packageId = codePackageOf(contributor.packageId, contributor.definition); + if (packageId === undefined) continue; for (const field of Object.keys(contributor.definition?.fields ?? {})) { - contributions.push({ kind: 'field', object: name, name: field, packageId: contributor.packageId }); + contributions.push({ kind: 'field', object: name, name: field, packageId }); } } } if (owned.size > 0 && typeof registry.listItems === 'function') { for (const flow of registry.listItems('flow') as Array<{ name?: unknown; nodes?: unknown; _packageId?: unknown }>) { - if (!isCodeArtifactBody(flow) || !isContributor(flow._packageId) || typeof flow.name !== 'string') continue; + const packageId = codePackageOf(flow._packageId, flow); + if (packageId === undefined || typeof flow.name !== 'string') continue; const start = Array.isArray(flow.nodes) ? flow.nodes.find((node: any) => node?.type === 'start') : undefined; const object = start?.config?.objectName; if (typeof object !== 'string' || !owned.has(object)) continue; - contributions.push({ kind: 'flow', object, name: flow.name, packageId: flow._packageId }); + contributions.push({ kind: 'flow', object, name: flow.name, packageId }); } } diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index 83aface01e9..bae0d604e0f 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -350,10 +350,15 @@ export const GetPackageCustomizationsRequestSchema = lazySchema(() => z.object({ * [#28722, ADR-0029 D7 as amended by ADR-0146 D3] Beside the pair's changes, * the same read names what OTHER installed packages bring to X's surface, so * Studio can draw those items greyed with their source: `contributions`, one - * entry per item, each stamped with the package that contributes it. The - * stamp is on the item, so two packages adding a field of the same name are - * two entries and nothing is joined by name on the client. X's own items and - * the pair's (listed above) are never contributions. + * entry per item, each stamped with the package that contributes it. What is + * listed is exactly two kinds: the fields another package's object extension + * adds to an object X owns, and the flows another package ships whose trigger + * object X owns. The stamp is on the item, so two packages adding a field of + * the same name are two entries and nothing is joined by name on the client. + * Neither X's own items nor its pair's are contributions: a pair item that + * overrides one of X's (a flow included) is in `overrides`, the pair's + * extension members are in `extensions`, and any other pair flow is in no + * list of this answer. */ export const GetPackageCustomizationsResponseSchema = lazySchema(() => z.object({ packageId: z.string().describe('The base package the customizations are of (the request\'s id).'),