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/29617-package-export-namespace-dependencies.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/spec': minor
'@objectstack/runtime': minor
---

feat(spec,runtime): a package export carries its namespace and its dependencies, so a reinstalled `.custom` pair keeps both

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

`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).

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

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.
6 changes: 5 additions & 1 deletion content/docs/references/api/package-lifecycle.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -323,7 +323,7 @@ A field or flow another package contributes to one of the base's objects.

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **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. |
| **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. |

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

Expand All @@ -332,6 +332,8 @@ A field or flow another package contributes to one of the base's objects.
| **id** | `string` | ✅ | The exported package's id. |
| **name** | `string` | ✅ | The exported package's machine name. |
| **version** | `string` | ✅ | The exported package's version. |
| **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`. |
| **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). |
| **label** | `string` | optional | Display label, when the package declares one. |
| **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. |

Expand All @@ -347,6 +349,8 @@ A field or flow another package contributes to one of the base's objects.
| **id** | `string` | ✅ | The exported package's id. |
| **name** | `string` | ✅ | The exported package's machine name. |
| **version** | `string` | ✅ | The exported package's version. |
| **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`. |
| **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). |
| **label** | `string` | optional | Display label, when the package declares one. |
| **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. |

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// [#29617, ADR-0141 D5 / ADR-0146 D2] A `.custom` pair exported by
// `GET /packages/:id/export` reinstalls, through
// `POST /api/v1/marketplace/install-local`, as the pair it was: under the
// namespace it was installed with, depending on its base, its override
// admitted.
//
// ## What was measured before the fix
//
// The export answered the pair's items and `id`, `name`, `version`, and
// nothing of the two install-manifest keys its registry row holds. So the
// reinstall knew no namespace (the install door derives one from the id:
// `custom`) and no base edge, and the base edge is what orders the pair after
// its base (ADR-0141 D5) and what admits the pair's `overrides` declarations
// at the managed install (ADR-0146 D2).
//
// ## What this file pins, over two showcase boots
//
// 1. source: the ensure door creates the showcase's pair, a dashboard
// override is published into it, and the pair's export carries the
// `namespace` and `dependencies` its registry row declares (the row is
// the control);
// 2. target: a fresh showcase installs that export through install-local,
// 200, and the reinstalled pair's registry row declares the same
// namespace and the same edge to the showcase, its override carried
// with its `overrides` block.
//
// Boots fixture stacks of its own (the install-local plugin mounted on the
// target), so it stays out of `SHARED_SHOWCASE`.

import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { type VerifyStack } from '@objectstack/verify';
import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection';
import { bootShowcase } from './showcase-boot.js';
import { declaredOverride, ensurePair, inPair, pairOf } from './customization-pair.js';

const SHOWCASE = 'com.example.showcase';
const PAIR = pairOf(SHOWCASE);
const ROUTE = '/marketplace/install-local';
const DASHBOARD = 'showcase_revenue_pulse';
const EDITED = 'Revenue pulse (customized)';

type Answer = { status: number; body: any };

async function call(stack: VerifyStack, token: string, method: string, path: string, body?: unknown): Promise<Answer> {
const res = await stack.apiAs(token, method, path, body);
return { status: res.status, body: await res.json().catch(() => null) };
}

/** The manifest a `GET /packages/:id` answer carries for the package's registry row. */
const rowManifest = (answer: Answer): Record<string, any> | undefined =>
answer.body?.data?.manifest ?? answer.body?.data?.package?.manifest;

describe('dogfood: a `.custom` pair\'s export reinstalls under its own namespace, depending on its base', () => {
let storageDir: string;
let sourceRow: Answer;
let published: Answer;
let exported: Answer;
let installed: Answer;
let targetRow: Answer;

beforeAll(async () => {
const source = await bootShowcase();
try {
const token = await source.signIn();
await ensurePair(source, token, SHOWCASE);
sourceRow = await call(source, token, 'GET', `/packages/${PAIR}`);
const saved = await call(source, token, 'PUT', inPair(`/meta/dashboard/${DASHBOARD}?mode=draft`, SHOWCASE),
declaredOverride({ name: DASHBOARD, label: EDITED, columns: 12, widgets: [] }, SHOWCASE, '0.1.0'));
expect(saved.status, JSON.stringify(saved.body)).toBe(200);
published = await call(source, token, 'POST', `/packages/${PAIR}/publish-drafts`, {});
exported = await call(source, token, 'GET', `/packages/${PAIR}/export`);
} finally {
await source.stop();
}

storageDir = mkdtempSync(join(tmpdir(), 'dogfood-export-reinstall-pair-'));
const target = await bootShowcase({
extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })],
});
try {
const token = await target.signIn();
installed = await call(target, token, 'POST', ROUTE, { manifest: exported.body?.data });
targetRow = await call(target, token, 'GET', `/packages/${PAIR}`);
} finally {
await target.stop();
}
}, 600_000);

afterAll(() => {
if (storageDir) rmSync(storageDir, { recursive: true, force: true });
});

it('source: the pair\'s export carries the namespace and the base edge its registry row declares', () => {
expect(published.status, JSON.stringify(published.body)).toBe(200);
expect(sourceRow.status, JSON.stringify(sourceRow.body)).toBe(200);
// CONTROL: the row the export reads declares both.
const row = rowManifest(sourceRow);
expect(row?.namespace, JSON.stringify(sourceRow.body)).toBe('showcase_custom');
expect(Object.keys(row?.dependencies ?? {})).toEqual([SHOWCASE]);

expect(exported.status, JSON.stringify(exported.body)).toBe(200);
const manifest = exported.body?.data;
expect(manifest?.namespace).toBe(row?.namespace);
expect(manifest?.dependencies).toEqual(row?.dependencies);
expect((manifest?.dashboards ?? []).map((d: any) => [d.name, d.label, d.overrides?.package]))
.toEqual([[DASHBOARD, EDITED, SHOWCASE]]);
});

it('target: install-local reinstalls the export, 200, with its override admitted', () => {
expect(installed.status, JSON.stringify(installed.body)).toBe(200);
expect(installed.body?.data?.manifestId).toBe(PAIR);
});

it('target: the reinstalled pair declares the namespace it was exported with and its edge to the showcase', () => {
expect(targetRow.status, JSON.stringify(targetRow.body)).toBe(200);
const row = rowManifest(targetRow);
expect(row?.namespace, JSON.stringify(targetRow.body)).toBe('showcase_custom');
expect(row?.dependencies).toEqual(rowManifest(sourceRow)?.dependencies);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#29617, ADR-0141 D5 / ADR-0146 D2] `GET /packages/:id/export` writes the
* two install-manifest keys an install of the export decides by, read off the
* package's registry row: `namespace` and `dependencies`
* (`PackageExportManifestSchema`). Without them a reinstalled `.custom` pair
* lands under the namespace derived from its id (`custom`) and without the
* edge to its base that orders its install and admits its overrides.
*
* The door is the real dispatcher over a registry and protocol double: what
* is pinned is what the door writes from the row. The round trip through
* install-local is pinned on two real showcase boots in
* `packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts`.
*/
import { describe, expect, it, vi } from 'vitest';
import { PackageExportManifestSchema } from '@objectstack/spec/api';
import { HttpDispatcher } from '../http-dispatcher.js';

const BASE = 'com.example.crm';
const PAIR = 'com.example.crm.custom';

const system = (): any => ({ request: {}, environmentId: 'platform', executionContext: { isSystem: true } });

async function exported(row: Record<string, unknown>): Promise<Record<string, any>> {
const registry = { getPackage: vi.fn(() => row), getAllPackages: vi.fn().mockReturnValue([]) };
const protocol = { getMetaItemsPerPackage: vi.fn(async () => ({ items: [] })), getMetaItems: vi.fn(async () => ({ items: [] })) };
const kernel: any = {
context: { getService: (name: string) => (name === 'objectql' ? { registry } : name === 'protocol' ? protocol : null) },
};
const r = await new HttpDispatcher(kernel).handlePackages(`/${encodeURIComponent(PAIR)}/export`, 'GET', undefined, {}, system());
expect(r.response?.status, JSON.stringify(r.response?.body)).toBe(200);
const body: any = r.response?.body;
return body?.data ?? body;
}

describe('[#29617] GET /packages/:id/export writes the namespace and the dependencies its registry row declares', () => {
it('a `.custom` pair\'s export carries its namespace and its edge to its base, and parses as the declared export', async () => {
const dependencies = { [BASE]: '^1' };
const row = { id: PAIR, manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0', type: 'app', namespace: 'crm_custom', dependencies } };
const manifest = await exported(row);
expect(manifest).toMatchObject({ id: PAIR, namespace: 'crm_custom', dependencies: { [BASE]: '^1' } });
expect(manifest.dependencies, 'a copy of the row\'s map, never the row\'s own object').not.toBe(dependencies);
expect(PackageExportManifestSchema.safeParse(manifest).success).toBe(true);
});

it('a row that declares neither writes neither — no key is invented', async () => {
const manifest = await exported({ id: PAIR, manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0' } });
expect(manifest).toEqual({ id: PAIR, name: 'crm-custom', version: '0.1.0' });
});

it('`type` stays out of the export: it is not one of the keys this door writes', async () => {
const manifest = await exported({ id: PAIR, manifest: { id: PAIR, name: 'crm-custom', version: '0.1.0', type: 'app', namespace: 'crm_custom' } });
expect(Object.keys(manifest).sort()).toEqual(['id', 'name', 'namespace', 'version']);
});
});
11 changes: 11 additions & 0 deletions packages/runtime/src/domains/packages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3466,6 +3466,17 @@ context: HttpProtocolContext,
if (pkg?.manifest?.label ?? pkg?.label) {
manifest.label = pkg?.manifest?.label ?? pkg?.label;
}
// [#29617, ADR-0141 D5 / ADR-0146 D2] The two install-manifest keys an
// install of this export decides by, read off the registry row's manifest
// as it declares them (`PackageExportManifestSchema.namespace` /
// `.dependencies`): without `namespace` the install door derives one from
// the id (a `.custom` pair lands under `custom`), and without
// `dependencies` a pair loses the edge to its base that orders its install
// and admits its overrides. Absent on the row ⇒ absent here.
const namespace = pkg?.manifest?.namespace;
if (namespace !== undefined && namespace !== null) manifest.namespace = namespace;
const dependencies = pkg?.manifest?.dependencies;
if (dependencies !== undefined && dependencies !== null) manifest.dependencies = { ...dependencies };
return manifest;
}

Expand Down
2 changes: 2 additions & 0 deletions packages/spec/authorable-surface/api.json
Original file line number Diff line number Diff line change
Expand Up @@ -1302,9 +1302,11 @@
"api/OperatorMapping:operator",
"api/OperatorMapping:rest",
"api/PackageExportBundle:packages",
"api/PackageExportManifest:dependencies",
"api/PackageExportManifest:id",
"api/PackageExportManifest:label",
"api/PackageExportManifest:name",
"api/PackageExportManifest:namespace",
"api/PackageExportManifest:version",
"api/PackageExportManifest:withheldMaps",
"api/PackageInstallManifest:capabilities [RETIRED]",
Expand Down
Loading
Loading