diff --git a/.changeset/29617-package-export-namespace-dependencies.md b/.changeset/29617-package-export-namespace-dependencies.md new file mode 100644 index 00000000000..75e2d942563 --- /dev/null +++ b/.changeset/29617-package-export-namespace-dependencies.md @@ -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. diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index af6ac1be5ab..686288df26d 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -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)[]` | ✅ | 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)[]` | ✅ | 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]` @@ -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` | 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. | @@ -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` | 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. | diff --git a/packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts b/packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts new file mode 100644 index 00000000000..b37924cb70c --- /dev/null +++ b/packages/qa/dogfood/test/package-export-reinstall-custom-pair.dogfood.test.ts @@ -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 { + 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 | 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); + }); +}); diff --git a/packages/runtime/src/domains/packages-export-registry-row-keys.test.ts b/packages/runtime/src/domains/packages-export-registry-row-keys.test.ts new file mode 100644 index 00000000000..6685f61faf2 --- /dev/null +++ b/packages/runtime/src/domains/packages-export-registry-row-keys.test.ts @@ -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): Promise> { + 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']); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index fac0bdc3bad..b9d7334058c 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -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; } diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 03c12f79ac8..304ccd0f5d4 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -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]", diff --git a/packages/spec/src/api/package-lifecycle.test.ts b/packages/spec/src/api/package-lifecycle.test.ts index a92cf59e852..bcf00bb46ae 100644 --- a/packages/spec/src/api/package-lifecycle.test.ts +++ b/packages/spec/src/api/package-lifecycle.test.ts @@ -198,7 +198,7 @@ describe('RollbackToPackageCommitResponseSchema declares the COMMIT-rollback bod }); }); -describe('PackageExportManifestSchema declares the five fixed keys and stays open', () => { +describe('PackageExportManifestSchema declares the seven fixed keys and stays open', () => { /** A verbatim-shaped capture of a real `assemblePackageManifest` return. */ const realResponse = { id: 'com.example.crm', @@ -605,3 +605,39 @@ describe('the `nav` kind of `contributions` is declared ahead of its producer (A expect(GetPackageCustomizationsResponseSchema.shape.contributions.element.safeParse(noName).success).toBe(false); }); }); + +describe('PackageExportManifestSchema declares the install-manifest keys an install of the export decides by', () => { + const identity = { id: 'com.example.crm.custom', name: 'example-crm-custom', version: '0.1.0' }; + /** A `.custom` pair's export, as `assemblePackageManifest` writes it from the pair's registry row. */ + const pairExport = { + ...identity, + namespace: 'crm_custom', + dependencies: { 'com.example.crm': '^1' }, + dashboards: [{ name: 'crm_board', label: 'Board', overrides: { package: 'com.example.crm', basedOn: '2.1.0' } }], + }; + + it('parses a pair\'s export and PRESERVES its namespace and its base edge', () => { + const result = PackageExportManifestSchema.safeParse(pairExport); + expect(result.success).toBe(true); + if (result.success) expect(result.data).toEqual(pairExport); + }); + + it('both are optional: an export of a package whose row declares neither still parses', () => { + expect(PackageExportManifestSchema.safeParse(identity).success).toBe(true); + }); + + it('each is the install manifest\'s own declaration, refusing what an install would refuse', () => { + expect(PackageExportManifestSchema.safeParse({ ...identity, namespace: '1crm' }).success).toBe(false); + expect(PackageExportManifestSchema.safeParse({ ...identity, namespace: 'Crm' }).success).toBe(false); + expect(PackageExportManifestSchema.safeParse({ ...identity, dependencies: { 'com.example.crm': 1 } }).success).toBe(false); + expect(PackageExportManifestSchema.safeParse({ ...identity, dependencies: ['com.example.crm'] }).success).toBe(false); + }); + + it('each describe says where the value comes from and why an install needs it, citing no tracker id', () => { + const { namespace, dependencies } = PackageExportManifestSchema.shape; + expect(namespace.description).toContain('registry row'); + expect(dependencies.description).toContain('ADR-0141 D5'); + expect(dependencies.description).toContain('ADR-0146 D2'); + for (const d of [namespace.description, dependencies.description]) expect(d).not.toMatch(/#\d{3,}/); + }); +}); diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index 1ff8889028f..aa958041959 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -49,6 +49,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { MetadataLockSchema } from '../kernel/metadata-protection.zod'; +import { ManifestSchema } from '../kernel/manifest.zod'; // Ruling 5A — the `/api` re-export of the one existing declaration. The // schema (and its type) stay declared in `system/metadata-persistence.zod.ts` @@ -178,11 +179,21 @@ export const RollbackToPackageCommitResponseSchema = lazySchema(() => z.object({ * `GET /packages/:id/export` — the package's portable manifest (ADR-0070 * offline export), the same shape `marketplace-install-local` consumes. * + * Two of the fixed keys below come from the package's registry row rather + * than from its items: `namespace` and `dependencies`, each the install + * manifest's own declaration (`ManifestSchema`), so an export reinstalled + * elsewhere keeps both. Without them an install door falls + * back to the namespace derived from the id (a `.custom` pair lands under + * `custom`, not the namespace it was installed with), and the pair loses its + * edge to its base, which orders the install after the base (ADR-0141 D5) and + * admits its overrides (ADR-0146 D2). Each is written when the row declares it + * and absent when it does not. + * * HONESTLY OPEN (#12038 ruling 4A). `assemblePackageManifest` * (`runtime/src/domains/packages.ts`) builds the key set DYNAMICALLY from the * metadata type registry — one plural key per type present * (`manifest[plural] = items.map(clean)`), partitioning `views` per #5320 — - * plus the five fixed keys below. Only the fixed keys are pinnable; + * plus the seven fixed keys below. Only the fixed keys are pinnable; * enumerating the registry here would freeze this contract against future * metadata types, so everything else deliberately falls to the open * catch-all. Freezes nothing. @@ -211,6 +222,16 @@ export const PackageExportManifestSchema = lazySchema(() => z.object({ id: z.string().describe('The exported package\'s id.'), name: z.string().describe('The exported package\'s machine name.'), version: z.string().describe('The exported package\'s version.'), + namespace: ManifestSchema.shape.namespace.describe( + '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: ManifestSchema.shape.dependencies.describe( + '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: z.string().optional().describe('Display label, when the package declares one.'), // Declared ahead of its producer: #29342 writes and reads it, and drops the // `[PLANNED — not yet served]` marker in that change.