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
18 changes: 18 additions & 0 deletions .changeset/28722-customizations-contributions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/spec': minor
'@objectstack/runtime': minor
---

feat(runtime): `GET /packages/:id/customizations` names the package behind each field and flow another package contributes to the base

Clause-②: yes (widening)
Metadata-protocol: added

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:

- **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 }`.
- **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.
- **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.
- `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`.

Navigation entries contributed to the base's app are not in this list yet.
15 changes: 13 additions & 2 deletions content/docs/references/api/package-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ Per-package customization read request: the base package id, no query parameters

## GetPackageCustomizationsResponse

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

### Properties

Expand All @@ -173,7 +173,8 @@ Per-package customization read: overrides, extensions and switched-off items of
| **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. |
| **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. |
| **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. |
| **counts** | `{ overrides: integer; extensions: integer; switchedOff: integer }` | ✅ | Each list's length, derived from the lists in this same answer. |
| **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. |
| **counts** | `{ overrides: integer; extensions: integer; switchedOff: integer; contributions: integer }` | ✅ | Each list's length, derived from the lists in this same answer. |

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

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

### Nested Shape: `GetPackageCustomizationsResponse.contributions[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |
| **object** | `string` | ✅ | The base-owned object the item is on: the extended object, or the flow's trigger object. |
| **name** | `string` | ✅ | The item's name: the field name as the extension declares it, or the flow's name. |
| **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. |

### Nested Shape: `GetPackageCustomizationsResponse.counts`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **overrides** | `integer` | ✅ | `overrides.length`. |
| **extensions** | `integer` | ✅ | `extensions.length`: members, not objects. |
| **switchedOff** | `integer` | ✅ | `switchedOff.length`. |
| **contributions** | `integer` | ✅ | `contributions.length`: items, not packages. |


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `api/` | 477 |
| `api/` | 478 |
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ describe('GET /api/v1/packages/:id/customizations — mounted', () => {
overrides: [OVERRIDE],
extensions: [],
switchedOff: [],
counts: { overrides: 1, extensions: 0, switchedOff: 0 },
contributions: [],
counts: { overrides: 1, extensions: 0, switchedOff: 0, contributions: 0 },
});
const parsed = GetPackageCustomizationsResponseSchema.safeParse(res.body.data);
expect(parsed.success, JSON.stringify(parsed.success ? null : parsed.error.issues)).toBe(true);
Expand Down
97 changes: 95 additions & 2 deletions packages/runtime/src/domains/packages-customizations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -339,7 +339,8 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an
overrides: [{ type: 'view', name: VIEW, basedOn: '1.0.0', version: expect.any(Number) }],
extensions: [{ object: 'showcase_task', kind: 'field', name: 'region__c' }],
switchedOff: [{ type: 'flow', name: 'showcase_task_reminder' }],
counts: { overrides: 1, extensions: 1, switchedOff: 1 },
contributions: [],
counts: { overrides: 1, extensions: 1, switchedOff: 1, contributions: 0 },
});
const parsed = GetPackageCustomizationsResponseSchema.safeParse(data);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
Expand Down Expand Up @@ -442,7 +443,8 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an
overrides: [],
extensions: [],
switchedOff: [],
counts: { overrides: 0, extensions: 0, switchedOff: 0 },
contributions: [],
counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: 0 },
});
expect(engine.registry.getAllPackages().length).toBe(before);
expect(engine.registry.getPackage(PAIR)).toBeFalsy();
Expand Down Expand Up @@ -488,3 +490,94 @@ describe('[#28411] GET /packages/:id/customizations — overrides, extensions an
expect(r.response?.body?.error?.message).toContain('includeDrafts');
});
});

const THIRD = 'com.example.acme-sync';
const FOURTH = 'com.example.partner';

/** A flow triggered by a record change on `object`. */
const flowOn = (name: string, object: string) => ({
name,
label: name,
type: 'record_change',
status: 'active',
nodes: [
{ id: 'start', type: 'start', label: 'Start', config: { objectName: object, triggerType: 'record-after-update' } },
{ id: 'end', type: 'end', label: 'End' },
],
edges: [{ id: 'e1', source: 'start', target: 'end' }],
});

/** A code package registered as the engine registers every manifest: an object extension and flows. */
const registerContributor = (engine: ObjectQL, id: string, fields: readonly string[], flows: ReadonlyArray<ReturnType<typeof flowOn>>) =>
engine.registerApp({
id,
name: id.split('.').join('-'),
version: '1.0.0',
objectExtensions: [{
extend: 'showcase_task',
fields: Object.fromEntries(fields.map((f) => [f, { name: f, label: f, type: 'text' }])),
}],
flows: [...flows],
});

/**
* The card's fixture (#28722): the base ships a flow on its object, the pair
* extends that object and carries a flow on it, and a third code package
* extends the same object with a field and ships a flow on it — plus a flow
* on an object the base does not own.
*/
async function contributedFixture() {
const booted = await boot();
const { engine, protocol } = booted;
engine.registry.registerItem('flow', flowOn('showcase_task_escalate', 'showcase_task'), 'name', BASE);
await install(protocol, PAIR);
extend(engine, PAIR, 'showcase_task', { fields: { region__c: { name: 'region__c', label: 'Region', type: 'text' } } });
engine.registry.registerItem('flow', flowOn('showcase_task_region_notify', 'showcase_task'), 'name', PAIR);
registerContributor(engine, THIRD, ['acme_sync_id'], [flowOn('acme_task_sync', 'showcase_task'), flowOn('acme_note_sync', 'other_note')]);
return booted;
}

describe('[#28722] GET /packages/:id/customizations — the fields and flows other packages contribute to a base', () => {
it('names the third package for its extension field and its flow, and names neither the base nor the pair', async () => {
const { dispatcher } = await contributedFixture();
const r = await customizations(dispatcher, BASE);
expect(r.response?.status).toBe(200);
const data = r.response?.body?.data;
expect(data.contributions).toEqual([
{ kind: 'field', object: 'showcase_task', name: 'acme_sync_id', packageId: THIRD },
{ kind: 'flow', object: 'showcase_task', name: 'acme_task_sync', packageId: THIRD },
]);
expect(data.counts.contributions).toBe(2);
// The pair's member stays where N-15 serves it, and is not a contribution.
expect(data.extensions).toEqual([{ object: 'showcase_task', kind: 'field', name: 'region__c' }]);
const parsed = GetPackageCustomizationsResponseSchema.safeParse(data);
expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true);
});

it('stamps each item with its own package: two packages adding a field of one name are two entries', async () => {
const { dispatcher, engine } = await contributedFixture();
registerContributor(engine, FOURTH, ['acme_sync_id'], []);
const { contributions } = (await customizations(dispatcher, BASE)).response?.body?.data;
expect(contributions.filter((c: any) => c.kind === 'field')).toEqual([
{ kind: 'field', object: 'showcase_task', name: 'acme_sync_id', packageId: THIRD },
{ kind: 'field', object: 'showcase_task', name: 'acme_sync_id', packageId: FOURTH },
]);
});

it('a flow authored in this environment is not a package\'s contribution', async () => {
const { dispatcher, engine } = await contributedFixture();
// The bare slot every package-less registration takes: the sentinel
// stamp, and a tenant-authored overlay wearing a real package's id.
engine.registry.registerItem('flow', { ...flowOn('showcase_task_local', 'showcase_task'), _packageId: 'sys_metadata' } as any);
engine.registry.registerItem('flow', { ...flowOn('showcase_task_org', 'showcase_task'), _packageId: THIRD, _provenance: 'org' } as any);
const { contributions } = (await customizations(dispatcher, BASE)).response?.body?.data;
expect(contributions.map((c: any) => `${c.kind}:${c.name}`)).toEqual(['field:acme_sync_id', 'flow:acme_task_sync']);
});

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;
expect(data.contributions).toEqual([{ kind: 'flow', object: 'other_note', name: 'acme_note_sync', packageId: THIRD }]);
expect(data.counts.contributions).toBe(1);
});
});
88 changes: 84 additions & 4 deletions packages/runtime/src/domains/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import {
// (`packages-capability-gate.test.ts`, beside this file), so it names
// `OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES` — same value it read before,
// no re-ruling of the package cohort as a side effect of #7020.
import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core';
import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES, isCodeArtifactBody } from '@objectstack/metadata-core';
// [#28411, ADR-0146 D8 item 4] The per-package customization read's three
// sources, each read from its owner: the pair's id and a pair's base
// (`@objectstack/spec/kernel`, the one spelling of the suffix), and the
Expand Down Expand Up @@ -2527,7 +2527,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
// GET /packages/:id/customizations → [#28411, ADR-0146 D8 item 4] what
// this deployment changed on an installed base: its `.custom` pair's
// served overrides, the members the pair adds to the base's objects,
// and the base's items switched off in the activation ledger. See
// the base's items switched off in the activation ledger, and the
// fields and flows other packages contribute to it (#28722). See
// {@link readPackageCustomizations}.
if (parts.length === 2 && parts[1] === 'customizations' && m === 'GET') {
// The read authority of `GET /packages/:id`, the same gate, asked
Expand Down Expand Up @@ -3093,7 +3094,7 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
*/
/**
* [#28411, ADR-0146 D8 item 4, D3] The answer of `GET /packages/:id/customizations`
* for the installed base `baseId`: three lists, each read from the one owner
* for the installed base `baseId`: four lists, each read from the one owner
* of its fact, and their lengths. Served rows and ledger rows only; ⛔ no diff
* against the base and no `basedOn` verdict (ADR-0146 D2, D6).
*
Expand All @@ -3111,6 +3112,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin
* ({@link listPackageMetadataActivations}). A composition with no ledger
* object has switched nothing off, which is the ledger's own reading of
* absence (ADR-0126 §4: no row means active).
* - **contributions** — [#28722, ADR-0029 D7 / ADR-0146 D3] what OTHER packages
* bring to the base's surface, each item stamped with its package: see
* {@link readBaseContributions}.
*
* No per-organization axis (ADR-0131 D6): every source is environment-level,
* so every caller the read gate admits gets the same answer. A base with no
Expand Down Expand Up @@ -3168,15 +3172,91 @@ async function readPackageCustomizations(
.map((row) => ({ type: row.metadataType, name: row.name }))
.sort((a, b) => a.type.localeCompare(b.type) || a.name.localeCompare(b.name));

const contributions = readBaseContributions(baseId, pair.ok ? pair.value : undefined, registry);

return {
packageId: baseId,
overrides,
extensions,
switchedOff,
counts: { overrides: overrides.length, extensions: extensions.length, switchedOff: switchedOff.length },
contributions,
counts: {
overrides: overrides.length,
extensions: extensions.length,
switchedOff: switchedOff.length,
contributions: contributions.length,
},
};
}

/**
* [#28722, ADR-0029 D7 as amended by ADR-0146 D3] The `contributions` of
* `GET /packages/:id/customizations`: every field and flow a package other than
* the base `baseId` and its pair `pairId` contributes to an object the base
* OWNS (`getObjectOwner`), each entry naming that package. Read from the two
* registries that already record the contributor, never inferred:
*
* - **field** — the object registry's `extend` contributors of the object, as
* the customizations' `extensions` reads the pair's: 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 it.
* One entry per contributor and field, so two packages adding a field of
* one name are two entries.
* - **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.
*
* Ordered by object, kind, name and package.
*/
function readBaseContributions(
baseId: string,
pairId: string | undefined,
registry: any,
): GetPackageCustomizationsResponse['contributions'] {
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;

const owned = new Set<string>();
for (const object of registry.getAllObjects(baseId) as Array<{ name?: unknown }>) {
const name = object?.name;
if (typeof name === 'string' && registry.getObjectOwner(name)?.packageId === baseId) owned.add(name);
}

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;
for (const field of Object.keys(contributor.definition?.fields ?? {})) {
contributions.push({ kind: 'field', object: name, name: field, packageId: contributor.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 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 });
}
}

const kindOrder = { field: 0, flow: 1 } as const;
return contributions.sort((a, b) => a.object.localeCompare(b.object)
|| kindOrder[a.kind] - kindOrder[b.kind]
|| a.name.localeCompare(b.name)
|| a.packageId.localeCompare(b.packageId));
}

async function assemblePackageManifest(
deps: DomainHandlerDeps,
packageId: string,
Expand Down
Loading
Loading