Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/28796-contributions-one-stamp.md
Original file line number Diff line number Diff line change
@@ -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.
27 changes: 27 additions & 0 deletions packages/runtime/src/domains/packages-customizations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
34 changes: 24 additions & 10 deletions packages/runtime/src/domains/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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<string>();
for (const object of registry.getAllObjects(baseId) as Array<{ name?: unknown }>) {
Expand All @@ -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 });
}
}

Expand Down
13 changes: 9 additions & 4 deletions packages/spec/src/api/package-lifecycle.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).'),
Expand Down
Loading