diff --git a/.changeset/28914-package-export-bundle.md b/.changeset/28914-package-export-bundle.md new file mode 100644 index 00000000000..bb2c4a23f25 --- /dev/null +++ b/.changeset/28914-package-export-bundle.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): `PackageExportBundleSchema` declares the whole-deployment export, every installed package as installable packages in one read + +Clause-②: yes (widening) +Metadata-protocol: added + +A tenant on ObjectOS Cloud or Enterprise can take its ontology to the Apache-2.0 runtime only one package at a time today (`GET /packages/:id/export`). This declares the contract of the read that answers all of it at once, `GET /packages/export`. The route, and a CLI command that calls it, follow in `@objectstack/runtime` and `@objectstack/cli`; no runtime serves it yet. Until a runtime mounts the route, the schema's description opens with `[PLANNED — not yet served]` (ADR-0049): the shape is the contract that door must answer, not a read a client can make today. + +- **`@objectstack/spec`**: `PackageExportBundleSchema` (with `PackageExportBundle` and `PackageExportBundleParsed`) in `@objectstack/spec/api`. The answer is `{ packages }`: every package whose registry row's `source` is not `code` (the marketplace and authored packages, a `.custom` pair among them; a `code` package is registered by the deployment's own code and travels with that code, not with the bundle), enabled or not, each exactly as `GET /packages/:id/export` answers it. The bundle carries no enabled state, so every entry installs enabled. The entries are `PackageExportManifestSchema` itself, so the per-package export stays the one producer. A base's `.custom` pair is its own entry with its override declarations, and environment customizations stay under the package that owns them. The bundle carries no credential value: the per-package export withholds credential positions, and a `credentialRef` stays a reference. +- **Order**: dependencies first, the order a boot hydrates installed packages in (ADR-0141 D5). An entry comes after every entry its package's `manifest.dependencies` names, so a pair comes after its base. Install the entries in the order listed; a consumer never re-sorts them. +- The answer is strict at the top level; each entry stays open, as the per-package export is. diff --git a/content/docs/references/api/package-lifecycle.mdx b/content/docs/references/api/package-lifecycle.mdx index 803dbea9b3e..2b163f98475 100644 --- a/content/docs/references/api/package-lifecycle.mdx +++ b/content/docs/references/api/package-lifecycle.mdx @@ -50,8 +50,8 @@ authored after that retirement per the ruling's sequencing (3A). ## TypeScript Usage ```typescript -import { DiscardPackageDraftsResponseSchema, DuplicatePackageResponseSchema, EnsureCustomizationPackageRequestSchema, EnsureCustomizationPackageResponseSchema, GetPackageCustomizationsRequestSchema, GetPackageCustomizationsResponseSchema, ImportUnmanagedPackageRequestSchema, ImportUnmanagedPackageResponseSchema, ListPackageCommitsResponseSchema, PackageExportManifestSchema, PackagePublishResultSchema, ReassignOrphanedMetadataResponseSchema, RevertPackageCommitResponseSchema, RollbackToPackageCommitResponseSchema } from '@objectstack/spec/api'; -import type { DiscardPackageDraftsResponse, DuplicatePackageResponse, EnsureCustomizationPackageRequest, EnsureCustomizationPackageResponse, GetPackageCustomizationsRequest, GetPackageCustomizationsResponse, ImportUnmanagedPackageRequest, ImportUnmanagedPackageResponse, ListPackageCommitsResponse, PackageExportManifest, PackagePublishResult, ReassignOrphanedMetadataResponse, RevertPackageCommitResponse, RollbackToPackageCommitResponse } from '@objectstack/spec/api'; +import { DiscardPackageDraftsResponseSchema, DuplicatePackageResponseSchema, EnsureCustomizationPackageRequestSchema, EnsureCustomizationPackageResponseSchema, GetPackageCustomizationsRequestSchema, GetPackageCustomizationsResponseSchema, ImportUnmanagedPackageRequestSchema, ImportUnmanagedPackageResponseSchema, ListPackageCommitsResponseSchema, PackageExportBundleSchema, PackageExportManifestSchema, PackagePublishResultSchema, ReassignOrphanedMetadataResponseSchema, RevertPackageCommitResponseSchema, RollbackToPackageCommitResponseSchema } from '@objectstack/spec/api'; +import type { DiscardPackageDraftsResponse, DuplicatePackageResponse, EnsureCustomizationPackageRequest, EnsureCustomizationPackageResponse, GetPackageCustomizationsRequest, GetPackageCustomizationsResponse, ImportUnmanagedPackageRequest, ImportUnmanagedPackageResponse, ListPackageCommitsResponse, PackageExportBundle, PackageExportManifest, PackagePublishResult, ReassignOrphanedMetadataResponse, RevertPackageCommitResponse, RollbackToPackageCommitResponse } from '@objectstack/spec/api'; // Validate data const result = DiscardPackageDraftsResponseSchema.parse(data); @@ -306,6 +306,28 @@ A field or flow another package contributes to one of the base's objects. | **createdAt** | `string` | optional | When the commit was made (ISO-8601 string), when recorded. | +--- + +## PackageExportBundle + +[PLANNED — not yet served] Whole-deployment export: every installed package the deployment's own code does not register (registry `source` other than `code`), as installable packages in dependency order. Carries no credential value and no enabled state. Declared ahead of its producer (ADR-0049): no runtime serves `GET /packages/export` yet. The server half of the whole-deployment package export mounts it, together with a future CLI command that calls it; until then this is the contract that door must answer, not a read a client can make. + +### Properties + +| 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. | + +### Nested Shape: `PackageExportBundle.packages[number]` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **id** | `string` | ✅ | The exported package's id. | +| **name** | `string` | ✅ | The exported package's machine name. | +| **version** | `string` | ✅ | The exported package's version. | +| **label** | `string` | optional | Display label, when the package declares one. | + + --- ## PackageExportManifest diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 4b1feb3488a..f2196d8f9e6 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1603 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1604 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -21,7 +21,7 @@ counts are sums of the rows they head. Regenerate with | Module | Pages | Schemas | Description | | :--- | ---: | ---: | :--- | | [AI Protocol](/docs/references/ai) | 12 | 68 | Agents, tools, skills, RAG and knowledge sources, model registry, conversations. | -| [API Protocol](/docs/references/api) | 35 | 473 | REST contracts, endpoints, routing, realtime, batch, discovery. | +| [API Protocol](/docs/references/api) | 35 | 474 | REST contracts, endpoints, routing, realtime, batch, discovery. | | [Automation Protocol](/docs/references/automation) | 15 | 78 | Flows and their nodes, approvals, ETL pipelines, webhooks, state machines, execution records. | | [Data Protocol](/docs/references/data) | 31 | 174 | Objects, fields, queries, filters, datasources and drivers — the ObjectQL layer. | | [Identity Protocol](/docs/references/identity) | 5 | 27 | Users and accounts, organizations, positions, SCIM provisioning. | @@ -34,7 +34,7 @@ counts are sums of the rows they head. Regenerate with | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | | [System Protocol](/docs/references/system) | 39 | 292 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 175 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **208** | **1603** | 14 protocol modules | +| **Total** | **208** | **1604** | 14 protocol modules | --- @@ -63,7 +63,7 @@ Agents, tools, skills, RAG and knowledge sources, model registry, conversations. ## API Protocol -**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **35 pages, 473 schemas** +**Source:** `packages/spec/src/api/` · **Import:** `@objectstack/spec/api` · **35 pages, 474 schemas** REST contracts, endpoints, routing, realtime, batch, discovery. @@ -90,7 +90,7 @@ REST contracts, endpoints, routing, realtime, batch, discovery. | [`odata.zod.ts`](/docs/references/api/odata) | `ODataConfig`, `ODataError`, `ODataFilterFunction`, `ODataMetadata`, `ODataQuery`, `ODataResponse` | | [`package-api.zod.ts`](/docs/references/api/package-api) | `GetInstalledPackageRequest`, `ListInstalledPackagesRequest`, `PackageApiErrorCode`, `PackageInstallBody`, `PackageInstallManifest`, `PackageInstallRequest`, `PackageInstallResponse`, `PackagePathParams`, `PackageRollbackRequest`, `PackageUpgradeRequest`, `PackageUpgradeResponse`, `ResolveDependenciesRequest`, `ResolveDependenciesResponse`, `UninstallPackageApiRequest`, `UninstallPackageApiResponse`, `UploadArtifactRequest`, `UploadArtifactResponse` | | [`package-api-assembled.zod.ts`](/docs/references/api/package-api-assembled) | `AssembledInstalledPackage`, `GetInstalledPackageResponse`, `InstalledPackageAtEitherStage`, `ListInstalledPackagesResponse` | -| [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `EnsureCustomizationPackageRequest`, `EnsureCustomizationPackageResponse`, `GetPackageCustomizationsRequest`, `GetPackageCustomizationsResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | +| [`package-lifecycle.zod.ts`](/docs/references/api/package-lifecycle) | `DiscardPackageDraftsResponse`, `DuplicatePackageResponse`, `EnsureCustomizationPackageRequest`, `EnsureCustomizationPackageResponse`, `GetPackageCustomizationsRequest`, `GetPackageCustomizationsResponse`, `ImportUnmanagedPackageRequest`, `ImportUnmanagedPackageResponse`, `ListPackageCommitsResponse`, `PackageExportBundle`, `PackageExportManifest`, `PackagePublishResult`, `ReassignOrphanedMetadataResponse`, `RevertPackageCommitResponse`, `RollbackToPackageCommitResponse` | | [`plugin-rest-api.zod.ts`](/docs/references/api/plugin-rest-api) | `ErrorHandlingConfig`, `OpenApiGenerationConfig`, `RequestValidationConfig`, `ResponseEnvelopeConfig`, `RestApiEndpoint`, `RestApiPluginConfig`, `RestApiRouteCategory`, `RestApiRouteRegistration`, `ValidationMode` | | [`protocol.zod.ts`](/docs/references/api/protocol) | `AiAgentCapabilities`, `AiAgentChatRequest`, `AiAgentSummary`, `AiAgentsResponse`, `AiChatRequest`, `AiChatResponse`, `AiCompleteRequest`, `AiConversation`, `AiMessage`, `AiModelsResponse`, `AiPendingAction`, `AiPendingActionStatus`, `AiStreamChunk`, `ApproveAiPendingActionResponse`, `AuditMetaItemRequest`, `AuditMetaItemResponse`, `AutomationActionsResponse`, `AutomationTriggerRequest`, `AutomationTriggerResponse`, `BatchDataRequest`, `BatchDataResponse`, `CheckPermissionRequest`, `CheckPermissionResponse`, `CloneDataResponse`, `CreateAiConversationRequest`, `CreateDataRequest`, `CreateDataResponse`, `CreateManyDataRequest`, `CreateManyDataResponse`, `DeleteDataRequest`, `DeleteDataResponse`, `DeleteManyDataRequest`, `DeleteManyDataResponse`, `DeleteMetaItemRequest`, `DeleteMetaItemResponse`, `DiffMetaItemResponse`, `DisablePackageRequest`, `DisablePackageResponse`, `EnablePackageRequest`, `EnablePackageResponse`, `FindDataRequest`, `FindDataResponse`, `FindReferencesToMetaResponse`, `FlowRegistration`, `FlowRegistrationRefusal`, `GetDataRequest`, `GetDataResponse`, `GetDiscoveryRequest`, `GetDiscoveryResponse`, `GetEffectivePermissionsRequest`, `GetEffectivePermissionsResponse`, `GetFieldLabelsRequest`, `GetFieldLabelsResponse`, `GetLocalesRequest`, `GetLocalesResponse`, `GetMetaDiagnosticsResponse`, `GetMetaItemCachedRequest`, `GetMetaItemCachedResponse`, `GetMetaItemLayeredRequest`, `GetMetaItemLayeredResponse`, `GetMetaItemRequest`, `GetMetaItemResponse`, `GetMetaItemsRequest`, `GetMetaItemsResponse`, `GetMetaTypesRequest`, `GetMetaTypesResponse`, `GetNotificationPreferencesRequest`, `GetNotificationPreferencesResponse`, `GetObjectPermissionsRequest`, `GetObjectPermissionsResponse`, `GetPackageRequest`, `GetPackageResponse`, `GetPresenceRequest`, `GetPresenceResponse`, `GetPublishedMetaItemResponse`, `GetRecordFieldAccessResponse`, `GetTranslationsRequest`, `GetTranslationsResponse`, `GetUiViewRequest`, `GetUiViewResponse`, `HistoryMetaItemRequest`, `HistoryMetaItemResponse`, `HttpFindQueryParams`, `InstallPackageRequest`, `InstallPackageResponse`, `ListAiConversationsRequest`, `ListAiConversationsResponse`, `ListAiPendingActionsRequest`, `ListAiPendingActionsResponse`, `ListDraftsResponse`, `ListNotificationsRequest`, `ListNotificationsResponse`, `ListPackagesRequest`, `ListPackagesResponse`, `MarkAllNotificationsReadRequest`, `MarkAllNotificationsReadResponse`, `MarkNotificationsReadRequest`, `MarkNotificationsReadResponse`, `MetadataAuthoringClient`, `MetadataAuthoringSurface`, `MetadataConflictError`, `Notification`, `NotificationPreferences`, `PublicFormSubmitResponse`, `PublishMetaItemRequest`, `PublishMetaItemResponse`, `PublishPackageDraftsRequest`, `PublishPackageDraftsResponse`, `RealtimeConnectRequest`, `RealtimeConnectResponse`, `RealtimeDisconnectRequest`, `RealtimeDisconnectResponse`, `RealtimeSubscribeRequest`, `RealtimeSubscribeResponse`, `RealtimeUnsubscribeRequest`, `RealtimeUnsubscribeResponse`, `RegisterDeviceRequest`, `RegisterDeviceResponse`, `RejectAiPendingActionResponse`, `RollbackMetaItemResponse`, `RuntimeAuthoringIssue`, `SaveMetaItemRequest`, `SaveMetaItemResponse`, `SearchAllHit`, `SearchAllPageHit`, `SearchAllResponse`, `SetPresenceRequest`, `SetPresenceResponse`, `UninstallPackageRequest`, `UninstallPackageResponse`, `UnregisterDeviceRequest`, `UnregisterDeviceResponse`, `UpdateAiConversationRequest`, `UpdateDataRequest`, `UpdateDataResponse`, `UpdateManyDataRequest`, `UpdateManyDataResponse`, `UpdateNotificationPreferencesRequest`, `UpdateNotificationPreferencesResponse`, `ValidateDataIssue`, `ValidateDataRequest`, `ValidateDataResponse` | | [`push-delivery.zod.ts`](/docs/references/api/push-delivery) | `PushDeliveryData`, `PushRoute` | diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md index 2124b03f462..77495e7d445 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/api.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `api/` | 488 | +| `api/` | 489 | diff --git a/packages/spec/api-surface/api.json b/packages/spec/api-surface/api.json index 376090a851d..3ac6b824000 100644 --- a/packages/spec/api-surface/api.json +++ b/packages/spec/api-surface/api.json @@ -778,6 +778,9 @@ "PROVENANCE_WAIVERS (const)", "PackageApiErrorCode (const)", "PackageApiErrorCode (type)", + "PackageExportBundle (type)", + "PackageExportBundleParsed (type)", + "PackageExportBundleSchema (const)", "PackageExportManifest (type)", "PackageExportManifestParsed (type)", "PackageExportManifestSchema (const)", diff --git a/packages/spec/authorable-surface/api.json b/packages/spec/authorable-surface/api.json index 685522133e1..ff3d9739939 100644 --- a/packages/spec/authorable-surface/api.json +++ b/packages/spec/authorable-surface/api.json @@ -1301,6 +1301,7 @@ "api/OperatorMapping:odata", "api/OperatorMapping:operator", "api/OperatorMapping:rest", + "api/PackageExportBundle:packages", "api/PackageExportManifest:id", "api/PackageExportManifest:label", "api/PackageExportManifest:name", diff --git a/packages/spec/declaration-map/api.json b/packages/spec/declaration-map/api.json index c4243b537c9..74683917166 100644 --- a/packages/spec/declaration-map/api.json +++ b/packages/spec/declaration-map/api.json @@ -570,6 +570,8 @@ "OperatorMapping": "api/OperatorMapping", "OperatorMappingSchema": "api/OperatorMapping", "PackageApiErrorCode": "api/PackageApiErrorCode", + "PackageExportBundle": "api/PackageExportBundle", + "PackageExportBundleSchema": "api/PackageExportBundle", "PackageExportManifest": "api/PackageExportManifest", "PackageExportManifestSchema": "api/PackageExportManifest", "PackageInstallBody": "api/PackageInstallBody", diff --git a/packages/spec/export-origins/api.json b/packages/spec/export-origins/api.json index 99bb8369ee7..fca37e62787 100644 --- a/packages/spec/export-origins/api.json +++ b/packages/spec/export-origins/api.json @@ -750,6 +750,9 @@ "OperatorMappingSchema": "src/api/query-adapter.zod.ts#OperatorMappingSchema (const)", "PROVENANCE_WAIVERS": "src/api/error-code-ledger.zod.ts#PROVENANCE_WAIVERS (const)", "PackageApiErrorCode": "src/api/package-api.zod.ts#PackageApiErrorCode (type)", + "PackageExportBundle": "src/api/package-lifecycle.zod.ts#PackageExportBundle (type)", + "PackageExportBundleParsed": "src/api/package-lifecycle.zod.ts#PackageExportBundleParsed (type)", + "PackageExportBundleSchema": "src/api/package-lifecycle.zod.ts#PackageExportBundleSchema (const)", "PackageExportManifest": "src/api/package-lifecycle.zod.ts#PackageExportManifest (type)", "PackageExportManifestParsed": "src/api/package-lifecycle.zod.ts#PackageExportManifestParsed (type)", "PackageExportManifestSchema": "src/api/package-lifecycle.zod.ts#PackageExportManifestSchema (const)", diff --git a/packages/spec/json-schema.manifest/api.json b/packages/spec/json-schema.manifest/api.json index 9872fdfaa37..9d1ebd2e95f 100644 --- a/packages/spec/json-schema.manifest/api.json +++ b/packages/spec/json-schema.manifest/api.json @@ -311,6 +311,7 @@ "api/OpenApiSpec", "api/OperatorMapping", "api/PackageApiErrorCode", + "api/PackageExportBundle", "api/PackageExportManifest", "api/PackageInstallBody", "api/PackageInstallManifest", diff --git a/packages/spec/src/api/package-lifecycle.test.ts b/packages/spec/src/api/package-lifecycle.test.ts index 95418351294..8bb51ac72ed 100644 --- a/packages/spec/src/api/package-lifecycle.test.ts +++ b/packages/spec/src/api/package-lifecycle.test.ts @@ -16,6 +16,7 @@ import { RevertPackageCommitResponseSchema, RollbackToPackageCommitResponseSchema, PackageExportManifestSchema, + PackageExportBundleSchema, ReassignOrphanedMetadataResponseSchema, DuplicatePackageResponseSchema, PackagePublishResultSchema, @@ -24,6 +25,7 @@ import { GetPackageCustomizationsResponseSchema, } from './package-lifecycle.zod'; import { PackagePublishResultSchema as SystemPackagePublishResultSchema } from '../system/metadata-persistence.zod'; +import { InstalledPackageSourceSchema } from '../kernel/package-registry.zod'; describe('the ruled re-export of PackagePublishResultSchema into the `/api` namespace', () => { it('is the SAME declaration as the `/system` original — a re-export, never a second copy', () => { @@ -222,6 +224,129 @@ describe('PackageExportManifestSchema declares the four fixed keys and stays ope }); }); +describe('PackageExportBundleSchema declares the whole-deployment export as composed per-package exports (#28914)', () => { + /** + * The bundle the card's done criteria name: a base, its `.custom` pair and a + * third package that extends the base, dependencies first. Each entry is + * shaped as `GET /packages/:id/export` answers it — the pair carries its + * overriding item with the `overrides` block (ADR-0146 D8 item 3), and the + * connector keeps its `credentialRef` as the reference it is. + */ + const bundle = { + packages: [ + { + id: 'com.example.crm', + name: 'example-crm', + version: '2.1.0', + label: 'Example CRM', + objects: [{ name: 'crm_customer', label: 'Customer', fields: { name: { type: 'text' } } }], + views: [{ name: 'crm_customer_list', object: 'crm_customer', type: 'grid' }], + connectors: [{ + name: 'crm_vendor', + label: 'Vendor', + type: 'api', + provider: 'openapi', + providerConfig: { spec: 'https://vendor.example.com/openapi.json' }, + auth: { + type: 'oauth2', + grant: 'client_credentials', + tokenUrl: 'https://vendor.example.com/oauth/token', + clientId: 'client-123', + credentialRef: 'VENDOR_CLIENT_SECRET', + }, + }], + }, + { + id: 'com.example.crm.custom', + name: 'com.example.crm.custom', + version: '1.0.0', + views: [{ + name: 'crm_customer_list', + object: 'crm_customer', + type: 'grid', + overrides: { package: 'com.example.crm', basedOn: '2.1.0' }, + }], + }, + { + id: 'com.example.revenue', + name: 'example-revenue', + version: '1.0.0', + objectExtensions: [{ extend: 'crm_customer', fields: { revenue_tier: { type: 'text' } } }], + }, + ], + }; + + it('parses the bundle and PRESERVES every entry, in the order listed', () => { + const result = PackageExportBundleSchema.safeParse(bundle); + expect(result.success).toBe(true); + if (!result.success) return; + expect(result.data).toEqual(bundle); + expect(result.data.packages.map((entry) => entry.id)).toEqual([ + 'com.example.crm', + 'com.example.crm.custom', + 'com.example.revenue', + ]); + }); + + it('judges each entry by THE per-package export schema — composed, never a second copy', () => { + expect(PackageExportBundleSchema.shape.packages.element).toBe(PackageExportManifestSchema); + const nameless = { packages: [bundle.packages[0], { objects: [] }] }; + const result = PackageExportBundleSchema.safeParse(nameless); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues.map((issue) => issue.path.join('.'))).toEqual( + expect.arrayContaining(['packages.1.id', 'packages.1.name', 'packages.1.version']), + ); + }); + + it('parses an empty bundle — a deployment that holds only platform packages', () => { + expect(PackageExportBundleSchema.safeParse({ packages: [] }).success).toBe(true); + }); + + it('requires `packages` — an answer with no list is not a bundle', () => { + const result = PackageExportBundleSchema.safeParse({}); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues[0]?.path).toEqual(['packages']); + }); + + it('is strict at the top level — a member the door starts serving is a change here first', () => { + const result = PackageExportBundleSchema.safeParse({ ...bundle, cycles: [] }); + expect(result.success).toBe(false); + if (result.success) return; + expect(result.error.issues[0]?.code).toBe('unrecognized_keys'); + expect((result.error.issues[0] as { keys?: string[] }).keys).toEqual(['cycles']); + }); + + // Declared before it is served (ADR-0049): no runtime mounts + // `GET /packages/export` until the server slice of #28914 lands. That + // change drops the marker, adds the route's ledger row and flips this test. + it('is PLANNED until a runtime serves the route: the describe says so, naming no tracker id', () => { + const description = PackageExportBundleSchema.description ?? ''; + expect(description).toMatch(/^\[PLANNED — not yet served\] /); + expect(description).toContain('ADR-0049'); + expect(description).toContain('no runtime serves `GET /packages/export` yet'); + // Customer-facing prose carries no tracker id (check:doc-authoring); the slice is named in the comments. + expect(description).not.toMatch(/#\d{3,}/); + }); + + // Which packages the door selects is read off the registry row's `source` + // (InstalledPackageSourceSchema), never derived from `manifest.scope`: the + // registry's own rule, and the runtime's #14375 correction of the same + // derivation. Both describes state the selection and the missing enabled state. + it('selects by the registry row\'s `source`, never by `manifest.scope`, and says it carries no enabled state', () => { + const packagesDescription = PackageExportBundleSchema.shape.packages.description ?? ''; + const description = PackageExportBundleSchema.description ?? ''; + for (const text of [packagesDescription, description]) { + expect(text).toContain('`source`'); + expect(text).toContain('`code`'); + expect(text).not.toMatch(/scope/); + expect(text).toContain('enabled'); + } + expect(InstalledPackageSourceSchema.options).toContain('code'); + }); +}); + describe('ReassignOrphanedMetadataResponseSchema declares the adopt-orphans body', () => { /** A verbatim-shaped capture of a real `reassignOrphanedMetadata` return. */ const realResponse = { diff --git a/packages/spec/src/api/package-lifecycle.zod.ts b/packages/spec/src/api/package-lifecycle.zod.ts index 9bacdedb72d..ef904ecdaed 100644 --- a/packages/spec/src/api/package-lifecycle.zod.ts +++ b/packages/spec/src/api/package-lifecycle.zod.ts @@ -191,6 +191,104 @@ export const PackageExportManifestSchema = lazySchema(() => z.object({ + 'is registry-derived at runtime and deliberately NOT enumerated here.', ))); +/** + * `GET /packages/export` — the deployment's whole ontology as installable + * packages, in one read (#28914): the exit right that + * {@link PackageExportManifestSchema}'s route grants one package at a time. + * + * ⚠️ DECLARED AHEAD OF ITS PRODUCER (ADR-0049). No runtime serves + * `GET /packages/export` yet: no handler mounts it, so a call today is not + * answered with this shape. This declaration is the contract the server slice + * of #28914 must meet (Clause-②, widening): it mounts the route on the runtime + * dispatcher, enveloped like its lifecycle siblings (`{ success, data }`), and + * answers this schema as the `data`. Until it does, the describe carries + * `[PLANNED — not yet served]`. The house has no other planned channel for an + * API response: the liveness ledgers are per metadata type, and the runtime + * route ledger records only what the dispatcher serves. So the change that + * mounts the route drops the marker, adds the route's ledger row and flips the + * test that pins the marker. + * + * Everything below is what that door MUST do. None of it is served today. + * + * ## One producer + * + * Each entry must be exactly what `GET /packages/:id/export` (served today) + * answers for that package. The bundle composes the per-package export and + * must never assemble, clean or redact an item a second way, so the two reads + * cannot disagree about a package. Two consequences follow on purpose: + * + * - **A `.custom` pair is its own entry**, carrying its overriding items with + * their `overrides` blocks (ADR-0146 D8 item 3), and its base's entry carries + * the base unchanged. Environment-scope customizations stay where the + * per-package export already places them: under the package that owns them. + * - **No credential value leaves.** The per-package export already withholds + * credential positions, as every served metadata read does. The bundle must + * add no rule of its own: a `credentialRef` stays the reference it is. + * + * ## Which packages, in which order + * + * Every package whose registry row's `source` (`InstalledPackageSourceSchema`, + * kernel/package-registry.zod.ts) is not `code`, each once: the marketplace + * and authored packages, a `.custom` pair among them. A `code` package is + * registered by the running deployment's own code (a `defineStack()` host's + * packages, a plugin's bundled manifest, a platform package), so it travels + * with that code and not with the bundle: an install of the bundle never + * re-installs it. The row's `source` is the one key that says so: the + * registry's own ⛔ forbids deriving it from `manifest.scope` or `writable`, + * and the per-package export already reads the row that carries it. A pair of + * a `code` base is still exported. A metadata item bound to no package is in + * no entry; ADR-0146 D11's migration binds it to one first. + * + * Enabled or not, every such package is exported, and the bundle carries no + * enabled state: the per-package export has no `enabled` or `status` key, so an + * install of the bundle installs every entry enabled. A package the tenant + * switched off comes back on, and the consumer disables it again if it wants it + * off. + * + * The order must be ADR-0141 D5's, the order a boot hydrates installed + * packages in: dependencies first. An entry comes after every entry its + * package's `manifest.dependencies` names, so a pair comes after its base. + * Installing the entries in the order listed is the whole consumption rule: a + * consumer never re-sorts. Members of a dependency cycle keep the read's + * listing order among themselves, as a boot hydrates them. + * + * ## The door + * + * The read gate must be the per-package export's: `studio.access` or + * `setup.access`, or a system context, asked before anything is read. The + * query set must be CLOSED and empty: the door refuses a query parameter + * `400 VALIDATION_ERROR`, naming it. No request schema is declared, because + * the door reads no path id, no body and no parameter. `export` can never be a + * package id (one has at least two dot-separated segments, + * `MANIFEST_ID_PATTERN`), so the path never shadows `GET /packages/:id`. + * + * The answer must be whole or absent: a package whose export cannot be + * assembled fails the read, naming that package. ⛔ Never a bundle that + * silently lacks a package. Strict at the top level: a member the door starts + * serving is a change to this declaration first. Each entry stays open, as + * ruled for {@link PackageExportManifestSchema}. + */ +export const PackageExportBundleSchema = lazySchema(() => z.object({ + packages: z.array(PackageExportManifestSchema).describe( + '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.', + ), + // Declared before it is served (ADR-0049): the marker comes off when the + // server slice of #28914 mounts `GET /packages/export`, in the same change + // as the route's ledger row. +}).strict().describe( + '[PLANNED — not yet served] Whole-deployment export: every installed package the deployment\'s own code does ' + + 'not register (registry `source` other than `code`), as installable packages in dependency order. Carries no ' + + 'credential value and no enabled state. Declared ahead of its producer ' + + '(ADR-0049): no runtime serves `GET /packages/export` yet. The server half of the whole-deployment package ' + + 'export mounts it, together with a future CLI command that calls it; until then this is the contract that ' + + 'door must answer, not a read a client can make.', +)); + /** * `POST /packages/:id/adopt-orphans` — bulk-rebind package-less (orphaned) * metadata into this base (ADR-0070 D5); the client method is @@ -560,6 +658,9 @@ export type RollbackToPackageCommitResponseParsed = z.infer; /** Post-parse shape of {@link PackageExportManifest} — defaults applied, transforms run (ADR-0122). */ export type PackageExportManifestParsed = z.infer; +export type PackageExportBundle = z.input; +/** Post-parse shape of {@link PackageExportBundle} — defaults applied, transforms run (ADR-0122). */ +export type PackageExportBundleParsed = z.infer; export type ReassignOrphanedMetadataResponse = z.input; /** Post-parse shape of {@link ReassignOrphanedMetadataResponse} — defaults applied, transforms run (ADR-0122). */ export type ReassignOrphanedMetadataResponseParsed = z.infer;