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

feat(spec): the customizations read's `contributions` declares a `nav` kind, with an entry shape per kind

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

`GET /packages/:id/customizations` names the package behind each item another installed package contributes to a base (ADR-0029 D7 as amended by ADR-0146 D3). `GetPackageCustomizationsResponseSchema.contributions` now declares a third kind: the navigation items another package's `navigationContributions` add to an app the base owns.

- **The entry shape is per kind.** A `field` or `flow` entry is `{ kind, object, name, packageId }` and accepts exactly what it accepted before. A `nav` entry is `{ kind: 'nav', app, group?, name, packageId }`: the base-owned app, the group the contribution targets when it targets one, the nav item's id, and the contributing package. Each shape is strict, so a `nav` entry carrying `object`, or a `field` entry carrying `app`, is refused.
- **Order.** The `field` and `flow` entries come first, in the order they had before. The `nav` entries follow, by app, group (an entry with no group first), name and package. `counts.contributions` still counts items.
- **Not served yet.** The runtime lists no `nav` entry yet. The kind is declared ahead of its producer, and its describe opens `[PLANNED — not yet served]`. No nav item gains a key: the served app's `navigation` stays as declared.

TypeScript code that reads `object` from a `contributions` entry now narrows on `kind` first (`entry.kind !== 'nav'`), because a `nav` entry has no `object`.
4 changes: 3 additions & 1 deletion content/docs/api/metadata-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,9 @@ never overridden; it is copied under a new name. What the reads then serve is in
[Metadata lifecycle](/docs/concepts/metadata-lifecycle#customizing-an-installed-package-the-paired-custom-override-adr-0146).
`POST /packages/hotcrm/ensure-customization` creates the pair when absent (Studio calls
it on the first change), and `GET /packages/hotcrm/customizations` lists its overrides,
extensions and switched-off items in one read.
extensions and switched-off items in one read, beside `contributions`: the fields and flows
other installed packages contribute to `hotcrm`, each entry naming its package. Its `nav` kind
(a navigation item another package contributes to a `hotcrm` app) is declared and not yet served.

**Body**: `{ type: "object", name: "account", item: { ... } }`
**Response**: `{ success: true }`
Expand Down
20 changes: 17 additions & 3 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, and the fields and flows other packages contribute to it.
Per-package customization read: overrides, extensions and switched-off items of one installed base, and the fields, flows and (not yet served) navigation items other packages contribute to it.

### Properties

Expand All @@ -173,7 +173,7 @@ 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. |
| **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. |
| **contributions** | `({ kind: Enum<'field' \| 'flow'>; object: string; name: string; packageId: string } \| { kind: 'nav'; app: string; group?: string; name: string; … })[]` | ✅ | Every field, flow and navigation item another installed package contributes to the base, 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; 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); and, declared ahead of its producer and not yet served, every navigation item such a package contributes to an app the base owns. The entry shape is per `kind`: `object` for `field` and `flow`, `app` and an optional `group` for `nav`. Ordered: the `field` and `flow` entries first, by object, kind, name and package; then the `nav` entries, by app, group (an entry with no group first), 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 @@ -202,7 +202,9 @@ 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]`
### Nested Shape: `GetPackageCustomizationsResponse.contributions[number][option 1]`

A field or flow another package contributes to one of the base's objects.

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
Expand All @@ -211,6 +213,18 @@ Per-package customization read: overrides, extensions and switched-off items of
| **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.contributions[number][option 2]`

[PLANNED — not yet served] Declared ahead of its producer (ADR-0049): the read lists no `nav` entry yet. A navigation item another installed package contributes to an app the base owns, one entry per item of the contribution's `items` (a contributed group's children ride inside it and are not entries of their own), read from the same contribution registry the served app's navigation fold reads (ADR-0029 D7). An item that declares no `id` (a separator may omit it) has no `name` to give and is not an entry. A disabled package's contributions are not listed, as the fold drops them.

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **kind** | `'nav'` | ✅ | What is contributed: `nav`, a navigation item another package's `navigationContributions` entry adds to the navigation of an app the base owns. |
| **app** | `string` | ✅ | The base-owned app the item is contributed to: the contribution's `app`. |
| **group** | `string` | optional | The group nav item the contribution targets: its `group`, as the contribution declares it. Absent when the contribution targets the app's top level. A group the app does not declare is still named here, though the served navigation appends those items at the app's top level. |
| **name** | `string` | ✅ | The nav item's id, as the contribution's `items` declare it. |
| **packageId** | `string` | ✅ | The package that contributes the item: the package whose `navigationContributions` declares it. Never the base and never its `<base>.custom` pair. |

### Nested Shape: `GetPackageCustomizationsResponse.counts`

| Property | Type | Required | Description |
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/` | 486 |
| `api/` | 487 |
4 changes: 3 additions & 1 deletion packages/runtime/src/domains/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3245,7 +3245,9 @@ function readBaseContributions(
pairId: string | undefined,
registry: any,
): GetPackageCustomizationsResponse['contributions'] {
type Contribution = GetPackageCustomizationsResponse['contributions'][number];
// The `field` and `flow` entries: the `nav` kind is declared in the spec
// ahead of this producer (#27928) and is not listed here yet (#28722).
type Contribution = Exclude<GetPackageCustomizationsResponse['contributions'][number], { kind: 'nav' }>;
const contributions: Contribution[] = [];
if (typeof registry?.getAllObjects !== 'function') return contributions;
// The one stamp reading both kinds share: the package `packageId` names
Expand Down
114 changes: 114 additions & 0 deletions packages/spec/src/api/package-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import {
PackagePublishResultSchema,
EnsureCustomizationPackageRequestSchema,
EnsureCustomizationPackageResponseSchema,
GetPackageCustomizationsResponseSchema,
} from './package-lifecycle.zod';
import { PackagePublishResultSchema as SystemPackagePublishResultSchema } from '../system/metadata-persistence.zod';

Expand Down Expand Up @@ -314,3 +315,116 @@ describe('EnsureCustomizationPackageResponseSchema declares the ensure door\'s a
}
});
});

describe('GetPackageCustomizationsResponseSchema.contributions: the entry shape is per kind (ADR-0029 D7, ADR-0146 D3)', () => {
const CONTRIBUTOR = 'com.example.acme-sync';
const FIELD = { kind: 'field', object: 'crm_account', name: 'acme_sync_id', packageId: CONTRIBUTOR };
const FLOW = { kind: 'flow', object: 'crm_account', name: 'acme_account_sync', packageId: CONTRIBUTOR };
const NAV_IN_GROUP = { kind: 'nav', app: 'crm', group: 'group_service', name: 'nav_acme_sync', packageId: CONTRIBUTOR };
const NAV_TOP_LEVEL = { kind: 'nav', app: 'crm', name: 'nav_acme_console', packageId: CONTRIBUTOR };

/** A whole answer around `contributions`, its count derived the way the producer derives it. */
const answer = (contributions: unknown[]) => ({
packageId: 'com.acme.crm',
overrides: [],
extensions: [],
switchedOff: [],
contributions,
counts: { overrides: 0, extensions: 0, switchedOff: 0, contributions: contributions.length },
});

/** The single issue a refused answer carries, by code and path. */
const refusalOf = (contributions: unknown[]) => {
const result = GetPackageCustomizationsResponseSchema.safeParse(answer(contributions));
expect(result.success).toBe(false);
return result.success ? [] : result.error.issues.map((issue) => ({
code: issue.code,
path: issue.path,
keys: (issue as { keys?: string[] }).keys,
}));
};

it('CONTROL: a field and a flow entry parse and are PRESERVED, exactly as before the nav kind', () => {
const served = answer([FIELD, FLOW]);
const result = GetPackageCustomizationsResponseSchema.safeParse(served);
expect(result.success, JSON.stringify(result.success ? null : result.error.issues)).toBe(true);
if (result.success) expect(result.data).toEqual(served);
});

it('CONTROL: a field entry still requires its `object`', () => {
const { object: _dropped, ...noObject } = FIELD;
expect(refusalOf([noObject])).toEqual([{ code: 'invalid_type', path: ['contributions', 0, 'object'], keys: undefined }]);
});

it('a nav entry naming the group its contribution targets parses and is PRESERVED', () => {
const served = answer([FIELD, NAV_IN_GROUP]);
const result = GetPackageCustomizationsResponseSchema.safeParse(served);
expect(result.success, JSON.stringify(result.success ? null : result.error.issues)).toBe(true);
if (result.success) expect(result.data).toEqual(served);
});

it('a nav entry contributed to the app\'s top level has no group, and none is invented', () => {
const result = GetPackageCustomizationsResponseSchema.safeParse(answer([NAV_TOP_LEVEL]));
expect(result.success, JSON.stringify(result.success ? null : result.error.issues)).toBe(true);
if (result.success) {
expect(result.data.contributions).toEqual([NAV_TOP_LEVEL]);
expect('group' in result.data.contributions[0]).toBe(false);
}
});

it('a nav entry carrying `object` is refused: the nav shape is on an app, never an object', () => {
expect(refusalOf([{ ...NAV_IN_GROUP, object: 'crm_account' }]))
.toEqual([{ code: 'unrecognized_keys', path: ['contributions', 0], keys: ['object'] }]);
});

it('a field or flow entry carrying `app` or `group` is refused: those shapes are on an object', () => {
expect(refusalOf([{ ...FIELD, app: 'crm' }]))
.toEqual([{ code: 'unrecognized_keys', path: ['contributions', 0], keys: ['app'] }]);
expect(refusalOf([{ ...FLOW, group: 'group_service' }]))
.toEqual([{ code: 'unrecognized_keys', path: ['contributions', 0], keys: ['group'] }]);
});

it('a nav entry requires its app, its item id and its package', () => {
for (const key of ['app', 'name', 'packageId'] as const) {
const partial: Record<string, unknown> = { ...NAV_IN_GROUP };
delete partial[key];
expect(refusalOf([partial]), key).toEqual([{ code: 'invalid_type', path: ['contributions', 0, key], keys: undefined }]);
}
});

it('a kind outside field, flow and nav is still refused', () => {
const issues = refusalOf([{ ...FIELD, kind: 'action' }]);
expect(issues).toHaveLength(1);
expect(issues[0]).toMatchObject({ code: 'invalid_union', path: ['contributions', 0, 'kind'] });
});
});

describe('the `nav` kind of `contributions` is declared ahead of its producer (ADR-0049)', () => {
type Described = { description?: string };
const member = (GetPackageCustomizationsResponseSchema as unknown as {
shape: { contributions: Described & { element: { options: Described[] } } };
}).shape.contributions;
const [objectArm, navArm] = member.element.options;

it('the nav arm\'s describe opens with the planned marker and names the ADR, never a tracker id', () => {
expect(navArm.description).toMatch(/^\[PLANNED — not yet served\] /);
expect(navArm.description).toContain('ADR-0049');
expect(navArm.description).not.toMatch(/#\d{3,}/);
});

it('the member and the field / flow arm say the nav kind is not yet served, and the field / flow arm carries no marker', () => {
expect(member.description).toContain('not yet served');
expect(objectArm.description).not.toMatch(/PLANNED/);
expect(member.description).not.toMatch(/#\d{3,}/);
});

it('the schema-level describe says the navigation items are not yet served, so serving them drops that too', () => {
expect(GetPackageCustomizationsResponseSchema.description).toContain('not yet served');
});

it('the nav arm says an item with no id (a separator may omit it) is not an entry: `name` is required', () => {
expect(navArm.description).toContain('An item that declares no `id` (a separator may omit it) has no `name` to give and is not an entry.');
const { name: _dropped, ...noName } = { kind: 'nav', app: 'crm', name: 'nav_sep', packageId: 'com.example.acme-sync' };
expect(GetPackageCustomizationsResponseSchema.shape.contributions.element.safeParse(noName).success).toBe(false);
});
});
Loading
Loading