Skip to content

Commit a6a369e

Browse files
feat(spec,runtime): a package export carries its namespace and dependencies, so a reinstalled .custom pair keeps both (#29617) (#29651)
domain:spec#3 · os-support-ai · session_01WGufzfgvGexKn9vLHsy89K <!-- claude-projects-attribution: {"github_login":"os-warren"} --> _Requested by **Warren** · [project thread](https://claude.ai/code/project/chan_01U5RdnAAmkSQmZbqh6FtqZ8?thread=cmsg_01U5RdnAAmkSQmZbqh6FtqZ8EmRX3Uw1Mi3c7GaMwhSmnr)_ Fixes #29617 Clause-②: yes (widening) Metadata-protocol: added Skills-docs: none — both keys are written by the export door from the registry row and read by the install door; no author writes them, and `skills/objectstack-platform/SKILL.md` already sends the reader to the pair's export, which now reinstalls whole ## 维护者速读 - 改了什么:`GET /packages/:id/export` 的导出清单新增两个可选固定键 `namespace` 与 `dependencies`,从包的注册表行读取并写出;spec 的 `PackageExportManifestSchema` 同步声明这两个键(形状直接复用安装清单 `ManifestSchema` 的同名键)。 - 为什么改:导出再经 install-local 重装时,`.custom` 配对包丢了命名空间(落到 `custom`)和对基础包的依赖;实测重装直接被 ADR-0146 D2 覆盖闸门拒绝 `403 NOT_OVERRIDABLE`。 - 风险与回滚:只多写两个键,行里没有就不写;旧导出照旧可装。回滚即撤回本 PR。 - 先看:`packages/runtime/src/domains/packages.ts` 中 `assemblePackageManifest` 末尾新增的两行;`packages/spec/src/api/package-lifecycle.zod.ts` 的两个新键;dogfood 新测试 `package-export-reinstall-custom-pair.dogfood.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/dogfood exec vitest run test/package-export-reinstall-custom-pair.dogfood.test.ts`,预期 3 条全绿(源端导出带两键、目标端重装 200、重装后注册表行命名空间为 `showcase_custom` 且依赖指向 showcase)。 - 需要你做的:无。两个键名已获批准(见下方 Name approval),triage 的契约复审 PASS 在案,席位落地。 ## What changes - **Spec.** `PackageExportManifestSchema` (`packages/spec/src/api/package-lifecycle.zod.ts`) declares two more optional fixed keys, `namespace` and `dependencies`. Each is the install manifest's own declaration, `ManifestSchema.shape.namespace` / `ManifestSchema.shape.dependencies`, re-described for the export, so an export refuses exactly what an install would. The schema stays open for the per-type collections. - **Runtime.** `assemblePackageManifest` (`packages/runtime/src/domains/packages.ts`) writes both from the package's registry row (`registry.getPackage(id).manifest`), as the row declares them, and writes neither when the row declares none. `dependencies` is a copy of the row's map. - Generated: `packages/spec/authorable-surface/api.json` (two rows) and `content/docs/references/api/package-lifecycle.mdx`. - One changeset, `@objectstack/spec` minor and `@objectstack/runtime` minor (the kind is `added`, on both packages the diff moves). ## Why Measured by the filer through the real door on `81cd9eb0`, and again here by ablation on this branch: the export answered the pair's items with `id`, `name`, `version`, and none of the two keys its registry row holds. Reinstalled through `POST /api/v1/marketplace/install-local`, the pair had no namespace (the door derives `custom` from the id) and no edge to its base. The edge is what orders the pair after its base (ADR-0141 D5) and what admits its `overrides` declarations at the managed install (ADR-0146 D2). With the emission ablated, the round-trip pin's install answers: ```text 403 NOT_OVERRIDABLE — 'dashboard/showcase_revenue_pulse' declares an override of 'com.example.showcase', which is outside the dependency closure of 'com.example.showcase.custom'. Declare a dependency on 'com.example.showcase' in the `dependencies` of 'com.example.showcase.custom''s manifest, or remove `overrides` (ADR-0146 D2). Nothing was installed or written. ``` This also unblocks the server slice of #28914 (the whole-ontology export), which composes this per-package export. ## Out of scope - **`type`.** The export still does not write `type`. Adding it alone would not make the export a `POST /packages` body. - **The flat-collection refusal of `POST /api/v1/packages`.** That door still refuses an export (400: no `type`, flat collection keys). It is not a reinstall door; install-local is the door that reinstalls an export. ## Name approval The maintainer approved the two key names `namespace` and `dependencies` on `PackageExportManifestSchema` as named: Warren chose 「全部批准」 on the decision card 「六个命名/措辞全部按推荐批准吗?」 (item 6), project thread "Decide pending spec name approvals", 2026-11-03T00:08Z. Seat pointer on #29617: comment 6591852561. ## Tests - **Round trip, two real showcase boots** (new `packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts`). Source boot: the ensure door creates `com.example.showcase.custom`, a dashboard override is saved into it and published, and the pair's export carries the `namespace` (`showcase_custom`) and `dependencies` (`com.example.showcase`) its registry row declares (the row is the control). Target boot, a fresh showcase with install-local mounted: the export installs 200, and the reinstalled pair's registry row declares the same namespace and the same edge to the showcase. - **Door unit pin** (new `packages/runtime/src/domains/packages-export-registry-row-keys.test.ts`): the real dispatcher over a registry double. The pair's export carries both keys (a copy of the row's map) and parses as `PackageExportManifestSchema`; a row declaring neither writes neither; `type` stays out. - **Spec** (`packages/spec/src/api/package-lifecycle.test.ts`, a new block at the end of the file): a pair's export parses and keeps both keys; both are optional; each refuses what an install would (`1crm`, `Crm`, a non-string range, an array); each describe names its source and the ADRs and no tracker id. - **Ablation** (fix committed first; `scripts/ablation-replace.mjs`, runtime rebuilt, `ablation-dist-preflight.mjs runtime 'manifest.namespace = namespace' --absent` passed): the dogfood file went 3 of 3 red (source export lacks `namespace`; install `403 NOT_OVERRIDABLE` as quoted above; target row `404`), the door pin 2 of 3 red. Restore proved `blob == HEAD`, `git diff HEAD` empty, `git status --porcelain` empty; runtime rebuilt, preflight found the marker in 2 built files, and the dogfood file was 3 of 3 green again. ## Acceptance notes - **Serial after PR #29521** (`withheldMaps`, same schema), which merged while this branch was open. The new keys sit between `version` and `label`, apart from #29521's lines; `main` was merged in at `d280265b`, its two generated artifacts (`authorable-surface/api.json`, `package-lifecycle.mdx`) regenerated from the merged tree rather than text-merged, and the docblock now counts "the seven fixed keys below" (the existing spec test title follows). The new spec tests are a block at the end of `package-lifecycle.test.ts`. - No `protocol.ts` file is touched (freeze until 2026-11-03 06:00Z). - `UNMANAGED_INSTALL_KEY_DISPOSITIONS` needs no row: `namespace` and `dependencies` are already classified there as `ManifestSchema` keys. ## Verification Head `3d93d712` (main merged at `d280265b`, which carries #29521; the merge's two generated artifacts were regenerated from the merged tree and the docblock now counts seven fixed keys). - Round trip after the merge, dogfood closure rebuilt: `test/package-export-reinstall-custom-pair.dogfood.test.ts` `Tests 3 passed (3)`; runtime export pins (4 files) `Tests 12 passed (12)`; lock `VERDICT command-exit 0`. - `pnpm --filter @objectstack/spec test` at `3d93d712`: `Test Files 801 passed (801)`, `Tests 23734 passed | 1 todo`. - `pnpm typecheck` for spec, runtime and dogfood at `3d93d712`: exit 0. - `pnpm --filter @objectstack/runtime test`: before the merge (`6d249e4f`) `Test Files 530 passed (530)`, `Tests 8515 passed | 19 skipped`; the re-run at `3d93d712` was still running at hand-back (the merge brought one runtime test file and a vitest config line, no source). - `pnpm --filter @objectstack/spec check:generated` at `3d93d712`: all 14 artifacts current. - eslint (`--no-inline-config`) on the five changed `.ts` files: exit 0. Repo-wide `pnpm lint` is CI's. <details><summary>dispatch-gates --commands and the ran list</summary> Before the merge (`6d249e4f`, 105 derived): 100 ran, 97 exit 0. NOT MEASURED, exit 3 (prerequisite not met): `node scripts/check-plugin-teardown-shape.mjs --self-test` (its fixture commit is outside the shallow clone), `pnpm check:dual-build-cjs-loads` (needs every package's `dist/`), and `check:skill-examples` (client-react unbuilt; after building it, exit 0, 281 examples type-check). The last five (`check:type-check-debt` onward) were stopped by the merge. After the merge (`3d93d712`, 104 derived): re-running at hand-back; 45 had run, 44 exit 0, the same `check-plugin-teardown-shape --self-test` exit 3. CI runs every family. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint passes on the changed files (repo-wide `pnpm lint` in CI). - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [ ] The *Before Submitting* list in CONTRIBUTING.md is done (not walked item by item in this run). --- _Generated by [Claude Code](https://claude.ai/code/session_01JQwxbWPCwHb9Zxnf7ou79C)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 0688156 commit a6a369e

8 files changed

Lines changed: 275 additions & 3 deletions

File tree

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
feat(spec,runtime): a package export carries its namespace and its dependencies, so a reinstalled `.custom` pair keeps both
7+
8+
Clause-②: yes (widening)
9+
Metadata-protocol: added
10+
11+
`GET /packages/:id/export` answered a package's items and its `id`, `name`, `version` and `label`, and nothing of the two install-manifest keys an install of the export decides by. A `.custom` pair exported and reinstalled through `POST /api/v1/marketplace/install-local` therefore lost its namespace (the install door derives one from the id, so the pair landed under `custom` instead of, for example, `showcase_custom`) and its edge to its base, which orders the pair's install after the base (ADR-0141 D5) and admits its `overrides` declarations at the managed install (ADR-0146 D2).
12+
13+
- **Spec.** `PackageExportManifestSchema` declares two more optional fixed keys, `namespace` and `dependencies`, each the install manifest's own declaration (`ManifestSchema.shape.namespace` / `.dependencies`), so an export refuses exactly what an install would. The schema stays open for the per-type collections.
14+
- **Runtime.** The export writes both from the package's registry row, as the row's manifest declares them, and writes neither when the row declares none.
15+
16+
Nothing to do for existing exports: an export taken before this change still installs as before. Export the package again to carry both keys. `type` is still not written, and `POST /api/v1/packages` still refuses an export; the install-local door is the one that reinstalls an export.

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -323,7 +323,7 @@ A field or flow another package contributes to one of the base's objects.
323323

324324
| Property | Type | Required | Description |
325325
| :--- | :--- | :--- | :--- |
326-
| **packages** | `({ id: string; name: string; version: string; label?: string; … } & Record<string, any>)[]` | ✅ | Every package whose registry row's `source` is not `code` (the marketplace and authored packages, a `.custom` pair among them), enabled or not, each once and each as `GET /packages/:id/export` answers it, dependencies first: an entry comes after every entry its package's `manifest.dependencies` names, so a `.custom` pair comes after its base. Install the entries in this order; never re-sort them. No entry carries an enabled state, so every entry installs enabled. Empty when every package of the deployment is registered by its own code. |
326+
| **packages** | `({ id: string; name: string; version: string; namespace?: string; … } & Record<string, any>)[]` | ✅ | Every package whose registry row's `source` is not `code` (the marketplace and authored packages, a `.custom` pair among them), enabled or not, each once and each as `GET /packages/:id/export` answers it, dependencies first: an entry comes after every entry its package's `manifest.dependencies` names, so a `.custom` pair comes after its base. Install the entries in this order; never re-sort them. No entry carries an enabled state, so every entry installs enabled. Empty when every package of the deployment is registered by its own code. |
327327

328328
### Nested Shape: `PackageExportBundle.packages[number]`
329329

@@ -332,6 +332,8 @@ A field or flow another package contributes to one of the base's objects.
332332
| **id** | `string` | ✅ | The exported package's id. |
333333
| **name** | `string` | ✅ | The exported package's machine name. |
334334
| **version** | `string` | ✅ | The exported package's version. |
335+
| **namespace** | `string` | optional | The namespace the package was installed with, as its registry row's manifest declares it; absent when the row declares none. An install of the export keeps it instead of deriving one from the id, so a `.custom` pair is reinstalled under its own namespace rather than `custom`. |
336+
| **dependencies** | `Record<string, string>` | optional | The packages this package depends on, each id mapped to its version range, as its registry row's manifest declares them; absent when the row declares none. An install of the export orders it after each of them (ADR-0141 D5), and a `.custom` pair keeps the edge to its base that admits its overrides (ADR-0146 D2). |
335337
| **label** | `string` | optional | Display label, when the package declares one. |
336338
| **withheldMaps** | `{ type: 'connector'; name: string; path: Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …> }[]` | optional | [PLANNED — not yet served] Declared ahead of its producer (ADR-0049): no export writes this list yet and no install door reads it yet. Every map the export withheld WHOLE because a served read never carries it: a connector's header and environment-variable maps. The item is written with that map absent, so once served an import reads this list to tell an incomplete map from one the package never had, and names each one for an operator to re-enter. Positions only: no header or variable name is listed. Absence says nothing about what was withheld when the exporter predates this key; from an exporter that writes it, absence means nothing was withheld. |
337339

@@ -347,6 +349,8 @@ A field or flow another package contributes to one of the base's objects.
347349
| **id** | `string` | ✅ | The exported package's id. |
348350
| **name** | `string` | ✅ | The exported package's machine name. |
349351
| **version** | `string` | ✅ | The exported package's version. |
352+
| **namespace** | `string` | optional | The namespace the package was installed with, as its registry row's manifest declares it; absent when the row declares none. An install of the export keeps it instead of deriving one from the id, so a `.custom` pair is reinstalled under its own namespace rather than `custom`. |
353+
| **dependencies** | `Record<string, string>` | optional | The packages this package depends on, each id mapped to its version range, as its registry row's manifest declares them; absent when the row declares none. An install of the export orders it after each of them (ADR-0141 D5), and a `.custom` pair keeps the edge to its base that admits its overrides (ADR-0146 D2). |
350354
| **label** | `string` | optional | Display label, when the package declares one. |
351355
| **withheldMaps** | `{ type: 'connector'; name: string; path: Enum<'providerConfig.headers' \| 'providerConfig.defaultHeaders' \| …> }[]` | optional | [PLANNED — not yet served] Declared ahead of its producer (ADR-0049): no export writes this list yet and no install door reads it yet. Every map the export withheld WHOLE because a served read never carries it: a connector's header and environment-variable maps. The item is written with that map absent, so once served an import reads this list to tell an incomplete map from one the package never had, and names each one for an operator to re-enter. Positions only: no header or variable name is listed. Absence says nothing about what was withheld when the exporter predates this key; from an exporter that writes it, absence means nothing was withheld. |
352356

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// [#29617, ADR-0141 D5 / ADR-0146 D2] A `.custom` pair exported by
4+
// `GET /packages/:id/export` reinstalls, through
5+
// `POST /api/v1/marketplace/install-local`, as the pair it was: under the
6+
// namespace it was installed with, depending on its base, its override
7+
// admitted.
8+
//
9+
// ## What was measured before the fix
10+
//
11+
// The export answered the pair's items and `id`, `name`, `version`, and
12+
// nothing of the two install-manifest keys its registry row holds. So the
13+
// reinstall knew no namespace (the install door derives one from the id:
14+
// `custom`) and no base edge, and the base edge is what orders the pair after
15+
// its base (ADR-0141 D5) and what admits the pair's `overrides` declarations
16+
// at the managed install (ADR-0146 D2).
17+
//
18+
// ## What this file pins, over two showcase boots
19+
//
20+
// 1. source: the ensure door creates the showcase's pair, a dashboard
21+
// override is published into it, and the pair's export carries the
22+
// `namespace` and `dependencies` its registry row declares (the row is
23+
// the control);
24+
// 2. target: a fresh showcase installs that export through install-local,
25+
// 200, and the reinstalled pair's registry row declares the same
26+
// namespace and the same edge to the showcase, its override carried
27+
// with its `overrides` block.
28+
//
29+
// Boots fixture stacks of its own (the install-local plugin mounted on the
30+
// target), so it stays out of `SHARED_SHOWCASE`.
31+
32+
import { mkdtempSync, rmSync } from 'node:fs';
33+
import { tmpdir } from 'node:os';
34+
import { join } from 'node:path';
35+
36+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
37+
import { type VerifyStack } from '@objectstack/verify';
38+
import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection';
39+
import { bootShowcase } from './showcase-boot.js';
40+
import { declaredOverride, ensurePair, inPair, pairOf } from './customization-pair.js';
41+
42+
const SHOWCASE = 'com.example.showcase';
43+
const PAIR = pairOf(SHOWCASE);
44+
const ROUTE = '/marketplace/install-local';
45+
const DASHBOARD = 'showcase_revenue_pulse';
46+
const EDITED = 'Revenue pulse (customized)';
47+
48+
type Answer = { status: number; body: any };
49+
50+
async function call(stack: VerifyStack, token: string, method: string, path: string, body?: unknown): Promise<Answer> {
51+
const res = await stack.apiAs(token, method, path, body);
52+
return { status: res.status, body: await res.json().catch(() => null) };
53+
}
54+
55+
/** The manifest a `GET /packages/:id` answer carries for the package's registry row. */
56+
const rowManifest = (answer: Answer): Record<string, any> | undefined =>
57+
answer.body?.data?.manifest ?? answer.body?.data?.package?.manifest;
58+
59+
describe('dogfood: a `.custom` pair\'s export reinstalls under its own namespace, depending on its base', () => {
60+
let storageDir: string;
61+
let sourceRow: Answer;
62+
let published: Answer;
63+
let exported: Answer;
64+
let installed: Answer;
65+
let targetRow: Answer;
66+
67+
beforeAll(async () => {
68+
const source = await bootShowcase();
69+
try {
70+
const token = await source.signIn();
71+
await ensurePair(source, token, SHOWCASE);
72+
sourceRow = await call(source, token, 'GET', `/packages/${PAIR}`);
73+
const saved = await call(source, token, 'PUT', inPair(`/meta/dashboard/${DASHBOARD}?mode=draft`, SHOWCASE),
74+
declaredOverride({ name: DASHBOARD, label: EDITED, columns: 12, widgets: [] }, SHOWCASE, '0.1.0'));
75+
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
76+
published = await call(source, token, 'POST', `/packages/${PAIR}/publish-drafts`, {});
77+
exported = await call(source, token, 'GET', `/packages/${PAIR}/export`);
78+
} finally {
79+
await source.stop();
80+
}
81+
82+
storageDir = mkdtempSync(join(tmpdir(), 'dogfood-export-reinstall-pair-'));
83+
const target = await bootShowcase({
84+
extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })],
85+
});
86+
try {
87+
const token = await target.signIn();
88+
installed = await call(target, token, 'POST', ROUTE, { manifest: exported.body?.data });
89+
targetRow = await call(target, token, 'GET', `/packages/${PAIR}`);
90+
} finally {
91+
await target.stop();
92+
}
93+
}, 600_000);
94+
95+
afterAll(() => {
96+
if (storageDir) rmSync(storageDir, { recursive: true, force: true });
97+
});
98+
99+
it('source: the pair\'s export carries the namespace and the base edge its registry row declares', () => {
100+
expect(published.status, JSON.stringify(published.body)).toBe(200);
101+
expect(sourceRow.status, JSON.stringify(sourceRow.body)).toBe(200);
102+
// CONTROL: the row the export reads declares both.
103+
const row = rowManifest(sourceRow);
104+
expect(row?.namespace, JSON.stringify(sourceRow.body)).toBe('showcase_custom');
105+
expect(Object.keys(row?.dependencies ?? {})).toEqual([SHOWCASE]);
106+
107+
expect(exported.status, JSON.stringify(exported.body)).toBe(200);
108+
const manifest = exported.body?.data;
109+
expect(manifest?.namespace).toBe(row?.namespace);
110+
expect(manifest?.dependencies).toEqual(row?.dependencies);
111+
expect((manifest?.dashboards ?? []).map((d: any) => [d.name, d.label, d.overrides?.package]))
112+
.toEqual([[DASHBOARD, EDITED, SHOWCASE]]);
113+
});
114+
115+
it('target: install-local reinstalls the export, 200, with its override admitted', () => {
116+
expect(installed.status, JSON.stringify(installed.body)).toBe(200);
117+
expect(installed.body?.data?.manifestId).toBe(PAIR);
118+
});
119+
120+
it('target: the reinstalled pair declares the namespace it was exported with and its edge to the showcase', () => {
121+
expect(targetRow.status, JSON.stringify(targetRow.body)).toBe(200);
122+
const row = rowManifest(targetRow);
123+
expect(row?.namespace, JSON.stringify(targetRow.body)).toBe('showcase_custom');
124+
expect(row?.dependencies).toEqual(rowManifest(sourceRow)?.dependencies);
125+
});
126+
});
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#29617, ADR-0141 D5 / ADR-0146 D2] `GET /packages/:id/export` writes the
5+
* two install-manifest keys an install of the export decides by, read off the
6+
* package's registry row: `namespace` and `dependencies`
7+
* (`PackageExportManifestSchema`). Without them a reinstalled `.custom` pair
8+
* lands under the namespace derived from its id (`custom`) and without the
9+
* edge to its base that orders its install and admits its overrides.
10+
*
11+
* The door is the real dispatcher over a registry and protocol double: what
12+
* is pinned is what the door writes from the row. The round trip through
13+
* install-local is pinned on two real showcase boots in
14+
* `packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts`.
15+
*/
16+
import { describe, expect, it, vi } from 'vitest';
17+
import { PackageExportManifestSchema } from '@objectstack/spec/api';
18+
import { HttpDispatcher } from '../http-dispatcher.js';
19+
20+
const BASE = 'com.example.crm';
21+
const PAIR = 'com.example.crm.custom';
22+
23+
const system = (): any => ({ request: {}, environmentId: 'platform', executionContext: { isSystem: true } });
24+
25+
async function exported(row: Record<string, unknown>): Promise<Record<string, any>> {
26+
const registry = { getPackage: vi.fn(() => row), getAllPackages: vi.fn().mockReturnValue([]) };
27+
const protocol = { getMetaItemsPerPackage: vi.fn(async () => ({ items: [] })), getMetaItems: vi.fn(async () => ({ items: [] })) };
28+
const kernel: any = {
29+
context: { getService: (name: string) => (name === 'objectql' ? { registry } : name === 'protocol' ? protocol : null) },
30+
};
31+
const r = await new HttpDispatcher(kernel).handlePackages(`/${encodeURIComponent(PAIR)}/export`, 'GET', undefined, {}, system());
32+
expect(r.response?.status, JSON.stringify(r.response?.body)).toBe(200);
33+
const body: any = r.response?.body;
34+
return body?.data ?? body;
35+
}
36+
37+
describe('[#29617] GET /packages/:id/export writes the namespace and the dependencies its registry row declares', () => {
38+
it('a `.custom` pair\'s export carries its namespace and its edge to its base, and parses as the declared export', async () => {
39+
const dependencies = { [BASE]: '^1' };
40+
const row = { id: PAIR, manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0', type: 'app', namespace: 'crm_custom', dependencies } };
41+
const manifest = await exported(row);
42+
expect(manifest).toMatchObject({ id: PAIR, namespace: 'crm_custom', dependencies: { [BASE]: '^1' } });
43+
expect(manifest.dependencies, 'a copy of the row\'s map, never the row\'s own object').not.toBe(dependencies);
44+
expect(PackageExportManifestSchema.safeParse(manifest).success).toBe(true);
45+
});
46+
47+
it('a row that declares neither writes neither — no key is invented', async () => {
48+
const manifest = await exported({ id: PAIR, manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0' } });
49+
expect(manifest).toEqual({ id: PAIR, name: 'crm-custom', version: '0.1.0' });
50+
});
51+
52+
it('`type` stays out of the export: it is not one of the keys this door writes', async () => {
53+
const manifest = await exported({ id: PAIR, manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0', type: 'app', namespace: 'crm_custom' } });
54+
expect(Object.keys(manifest).sort()).toEqual(['id', 'name', 'namespace', 'version']);
55+
});
56+
});

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3466,6 +3466,17 @@ context: HttpProtocolContext,
34663466
if (pkg?.manifest?.label ?? pkg?.label) {
34673467
manifest.label = pkg?.manifest?.label ?? pkg?.label;
34683468
}
3469+
// [#29617, ADR-0141 D5 / ADR-0146 D2] The two install-manifest keys an
3470+
// install of this export decides by, read off the registry row's manifest
3471+
// as it declares them (`PackageExportManifestSchema.namespace` /
3472+
// `.dependencies`): without `namespace` the install door derives one from
3473+
// the id (a `.custom` pair lands under `custom`), and without
3474+
// `dependencies` a pair loses the edge to its base that orders its install
3475+
// and admits its overrides. Absent on the row ⇒ absent here.
3476+
const namespace = pkg?.manifest?.namespace;
3477+
if (namespace !== undefined && namespace !== null) manifest.namespace = namespace;
3478+
const dependencies = pkg?.manifest?.dependencies;
3479+
if (dependencies !== undefined && dependencies !== null) manifest.dependencies = { ...dependencies };
34693480
return manifest;
34703481
}
34713482

‎packages/spec/authorable-surface/api.json‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1311,9 +1311,11 @@
13111311
"api/OperatorMapping:operator",
13121312
"api/OperatorMapping:rest",
13131313
"api/PackageExportBundle:packages",
1314+
"api/PackageExportManifest:dependencies",
13141315
"api/PackageExportManifest:id",
13151316
"api/PackageExportManifest:label",
13161317
"api/PackageExportManifest:name",
1318+
"api/PackageExportManifest:namespace",
13171319
"api/PackageExportManifest:version",
13181320
"api/PackageExportManifest:withheldMaps",
13191321
"api/PackageInstallManifest:capabilities [RETIRED]",

0 commit comments

Comments
 (0)