diff --git a/.changeset/30464-install-response-bare-row.md b/.changeset/30464-install-response-bare-row.md new file mode 100644 index 00000000000..4dfd9b805d5 --- /dev/null +++ b/.changeset/30464-install-response-bare-row.md @@ -0,0 +1,19 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): `PackageInstallResponseSchema` declares the installed row that `POST /api/v1/packages` really answers + +Clause-②: yes (narrowing) +Metadata-protocol: none + +**BREAKING** accept-set narrowing at the install answer contract (`@objectstack/spec`): `PackageInstallResponseSchema` refuses the `{ package }` wrapper it accepted, and `PackageInstallResponse['data']` no longer declares `package`, `dependencyResolution`, `namespaceConflicts` or `message`. + +`POST /api/v1/packages` answers `{ success: true, data: }` with status `201`, where the row is the `InstalledPackage` the registry recorded. `PackageInstallResponseSchema` (the output `PackageApiContracts.installPackage` binds) declared `data: { package, dependencyResolution?, namespaceConflicts?, message? }` instead. No surface sent that wrapper, so code written against the contract read `data.package` and got `undefined`. The first-party client already followed the wire: `client.packages.install` resolves to `InstalledPackage`. + +- **FROM** `data: { package: InstalledPackage, dependencyResolution?, namespaceConflicts?, message? }` **TO** `data: InstalledPackage`. The name, the type names (`PackageInstallResponse`, `PackageInstallResponseParsed`) and the binding are unchanged. The server's answer is unchanged. +- **Both directions.** The schema now accepts the bare row the door serves, which it refused, and refuses the `{ package }` wrapper no server sends. The type `PackageInstallResponse['data']` loses `package`, `dependencyResolution`, `namespaceConflicts` and `message`, so TypeScript code that reads them stops compiling. That code read `undefined` at runtime already. +- **Fix for a consumer:** read the row from `data` itself. Replace `response.data.package` with `response.data`. Drop any read of `data.dependencyResolution`, `data.namespaceConflicts` or `data.message` on this route: the door never sent them. +- **Level.** `patch`: this is the shape of an API answer, not anything a metadata author writes. + + diff --git a/content/docs/references/api/package-api.mdx b/content/docs/references/api/package-api.mdx index d185002b5e7..9e74602ead9 100644 --- a/content/docs/references/api/package-api.mdx +++ b/content/docs/references/api/package-api.mdx @@ -461,7 +461,7 @@ Install package response | **success** | `boolean` | ✅ | Operation success status | | **error** | `{ code: Enum<'VALIDATION_ERROR' \| 'INVALID_FIELD' \| 'MISSING_REQUIRED_FIELD' \| …>; declaredCode?: string; message: string; userMessage?: string; … }` | optional | Error details if success is false | | **meta** | `{ timestamp: string; duration?: integer; requestId?: string; traceId?: string }` | optional | Response metadata | -| **data** | `{ package: object; dependencyResolution?: object; namespaceConflicts?: object[]; message?: string }` | ✅ | | +| **data** | `{ manifest: object; source: Enum<'code' \| 'marketplace' \| 'authored'>; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; … }` | ✅ | The installed package row, served bare as `data`: the row the registry recorded, at the authoring stage | ### Nested Shape: `PackageInstallResponse.error` @@ -490,10 +490,19 @@ Install package response | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **package** | `{ manifest: object; source: Enum<'code' \| 'marketplace' \| 'authored'>; status?: Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>; enabled?: boolean; … }` | ✅ | Installed package details | -| **dependencyResolution** | `{ dependencies: object[]; canProceed: boolean; requiredActions: object[]; installOrder: string[]; … }` | optional | Dependency resolution result | -| **namespaceConflicts** | `{ type: 'namespace_conflict'; requestedNamespace: string; conflictingPackageId: string; conflictingPackageName: string; … }[]` | optional | Namespace conflicts detected | -| **message** | `string` | optional | Installation status message | +| **manifest** | `{ id: string; namespace?: string; defaultDatasource?: string; version: string; … }` | ✅ | Package manifest at the AUTHORING stage; a row installed by a `defineStack()` host carries the assembled body instead — see `AssembledInstalledPackageSchema` / `InstalledPackageAtEitherStageSchema` | +| **source** | `Enum<'code' \| 'marketplace' \| 'authored'>` | ✅ | Where the installed package came from: code (registered by the deployment's own code), marketplace (installed from the marketplace), or authored (a database-authored package) | +| **status** | `Enum<'installed' \| 'disabled' \| 'installing' \| 'upgrading' \| 'uninstalling' \| 'error'>` | optional (default: `"installed"`) | Package state: installed, disabled, installing, upgrading, uninstalling, or error | +| **enabled** | `boolean` | optional (default: `true`) | Whether the package is currently enabled | +| **installedAt** | `string` | optional | Installation timestamp | +| **updatedAt** | `string` | optional | Last update timestamp | +| **installedVersion** | `string` | optional | Currently installed version for quick access | +| **previousVersion** | `string` | optional | Version before the last upgrade | +| **statusChangedAt** | `string` | optional | Status change timestamp | +| **errorMessage** | `string` | optional | Error message when status is error | +| **settings** | `Record` | optional | User-provided configuration settings | +| **upgradeHistory** | `{ fromVersion: string; toVersion: string; upgradedAt: string; status: Enum<'success' \| 'failed' \| 'rolled_back'>; … }[]` | optional | Version upgrade history | +| **registeredNamespaces** | `string[]` | optional | Namespace prefixes registered by this package | --- 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 12717707e6a..21eda271dce 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/` | 501 | +| `api/` | 499 | diff --git a/packages/client/src/packages-write-envelope.test.ts b/packages/client/src/packages-write-envelope.test.ts index f53f39310b7..f7094ac31d3 100644 --- a/packages/client/src/packages-write-envelope.test.ts +++ b/packages/client/src/packages-write-envelope.test.ts @@ -68,6 +68,9 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { SchemaRegistry } from '@objectstack/objectql'; import { HttpDispatcher } from '@objectstack/runtime'; +// The route map ships from `/api-assembled` (its read responses embed the +// assembled package body); the install output it binds is declared in `/api`. +import { PackageApiContracts } from '@objectstack/spec/api-assembled'; import { ObjectStackClient } from './index'; const BASE_URL = 'http://localhost:3000'; @@ -232,3 +235,61 @@ describe('#12034 — the packages write verbs answer the bare row, not `{ packag expect(await client.packages.enable(MANIFEST.id)).toEqual(body.data); }); }); + +/** + * [#30464] The published contract for `POST /api/v1/packages`, held against + * what the door really answers. + * + * The half above pins the CLIENT's declaration to the wire. This half pins the + * SPEC's: `PackageApiContracts.installPackage.output` is what an integrator + * generates a client from, and it declared `data: { package, … }` — a wrapper + * no surface sends, so code written against it read `data.package` and got + * `undefined`. The contract now follows the wire (the door was not changed). + * + * Same in-process chain as above: a real `SchemaRegistry`, the real + * `HttpDispatcher` answering it, and the dispatcher's own body handed to the + * declared schema untouched. Nothing here writes the expected body out by + * hand, which is how the false declaration survived in the first place. + */ +describe('#30464 — PackageApiContracts.installPackage.output parses the install door\'s real answer', () => { + async function installThroughTheDoor() { + const registry = new SchemaRegistry(); + const { dispatcher } = dispatcherBackedClient(registry); + const raw = await dispatcher.handlePackages('', 'POST', { manifest: MANIFEST }, {}, CONTEXT()); + return { registry, status: raw.response?.status, body: raw.response?.body as any }; + } + + it('the door\'s 201 body parses green, and the parse keeps every key the door sent', async () => { + const { registry, status, body } = await installThroughTheDoor(); + expect(status).toBe(201); + + const parsed = PackageApiContracts.installPackage.output.safeParse(body); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + + // The declared `data` IS the row the registry recorded, and no key the + // door sent is stripped by the parse. (`toMatchObject`, not `toEqual`: + // the parsed value also carries the manifest defaults the declaration + // applies — `scope`, `defaultDatasource` — which is the ADR-0122 + // `Parsed` shape, not a residue.) + expect(body.data).toEqual(registry.getPackage(MANIFEST.id)); + expect(parsed.data!.data).toMatchObject(body.data); + expect(parsed.data!.data.manifest.id).toBe(MANIFEST.id); + }); + + it('the old `{ package }` reading is refused by the contract, and absent from the wire', async () => { + const { body } = await installThroughTheDoor(); + + expect('package' in body.data).toBe(false); + expect('message' in body.data).toBe(false); + + // The wrapper the contract used to promise, built from the door's own + // row, no longer passes it: the declaration and the wire cannot both + // be read as true. + const wrapped = { ...body, data: { package: body.data } }; + const parsed = PackageApiContracts.installPackage.output.safeParse(wrapped); + expect(parsed.success).toBe(false); + expect(parsed.error!.issues.map((i) => i.path.join('.'))).toEqual( + expect.arrayContaining(['data.manifest', 'data.source']), + ); + }); +}); diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index bc451089254..27215c7c907 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -588,9 +588,9 @@ }, "api/PackageInstallResponse": { "sites": [ - "data.package.manifest.data.element.records.element.valueType", - "data.package.manifest.navigationContributions.element.items.element.lazy", - "data.package.manifest.navigationContributions.element.items.element.lazy.options[0]" + "data.manifest.data.element.records.element.valueType", + "data.manifest.navigationContributions.element.items.element.lazy", + "data.manifest.navigationContributions.element.items.element.lazy.options[0]" ] }, "api/PackageUpgradeRequest": { diff --git a/packages/spec/src/api/package-api.test.ts b/packages/spec/src/api/package-api.test.ts index 7ee572150bb..72be27d12e2 100644 --- a/packages/spec/src/api/package-api.test.ts +++ b/packages/spec/src/api/package-api.test.ts @@ -201,53 +201,38 @@ describe('PackageInstallRequestSchema', () => { }); describe('PackageInstallResponseSchema', () => { - it('should accept a successful install response', () => { - const result = PackageInstallResponseSchema.parse({ - success: true, - data: { - package: { - manifest: { id: 'com.acme.crm', name: 'acme_crm', version: '1.0.0', type: 'plugin' }, - source: 'authored', - status: 'installed', - enabled: true, - }, - dependencyResolution: { - dependencies: [ - { packageId: 'com.acme.core', requiredRange: '^1.0.0', status: 'satisfied', resolvedVersion: '1.2.0' }, - ], - canProceed: true, - requiredActions: [], - installOrder: ['com.acme.core', 'com.acme.crm'], - }, - message: 'Package installed successfully', - }, - }); + // The row `POST /api/v1/packages` answers as `data` — bare, no wrapper. The + // wire half (the real dispatcher's answer parsed with this schema) is pinned + // in `packages/client/src/packages-write-envelope.test.ts`. + const ROW = { + manifest: { id: 'com.acme.crm', name: 'acme_crm', version: '1.0.0', type: 'plugin' }, + source: 'authored', + status: 'installed', + enabled: true, + installedAt: '2026-11-05T00:00:00.000Z', + }; + + it('accepts the installed row itself as data', () => { + const result = PackageInstallResponseSchema.parse({ success: true, data: ROW }); expect(result.success).toBe(true); - expect(result.data.package.status).toBe('installed'); - expect(result.data.dependencyResolution?.canProceed).toBe(true); + expect(result.data.manifest.id).toBe('com.acme.crm'); + expect(result.data.source).toBe('authored'); + expect(result.data.status).toBe('installed'); + expect(result.data.enabled).toBe(true); }); - it('should accept install response with namespace conflicts', () => { - const result = PackageInstallResponseSchema.parse({ + it('refuses the { package } wrapper no surface sends, at data.manifest and data.source', () => { + const result = PackageInstallResponseSchema.safeParse({ success: true, - data: { - package: { - manifest: { id: 'com.acme.crm', name: 'acme_crm', version: '1.0.0', type: 'plugin' }, - source: 'authored', - status: 'installed', - enabled: true, - }, - namespaceConflicts: [{ - type: 'namespace_conflict', - requestedNamespace: 'acme', - conflictingPackageId: 'com.other.plugin', - conflictingPackageName: 'Other Plugin', - suggestion: 'acme_crm', - }], - }, + data: { package: ROW, message: 'Package installed successfully' }, }); - expect(result.data.namespaceConflicts).toHaveLength(1); - expect(result.data.namespaceConflicts![0].suggestion).toBe('acme_crm'); + expect(result.success).toBe(false); + const paths = result.error!.issues.map((i) => i.path.join('.')); + expect(paths).toEqual(expect.arrayContaining(['data.manifest', 'data.source'])); + }); + + it('is the output the installPackage contract binds', () => { + expect(PackageApiContracts.installPackage.output).toBe(PackageInstallResponseSchema); }); }); diff --git a/packages/spec/src/api/package-api.zod.ts b/packages/spec/src/api/package-api.zod.ts index f7410b2a370..97457791a94 100644 --- a/packages/spec/src/api/package-api.zod.ts +++ b/packages/spec/src/api/package-api.zod.ts @@ -522,22 +522,35 @@ export type PackageInstallBody = z.input; export type PackageInstallBodyParsed = z.infer; /** - * Response after installing a package. + * Response after installing a package (`POST /api/v1/packages`): the envelope, + * with the installed row itself as `data`. + * + * ## `data` is the BARE row the door serves + * + * The install door answers `success(pkg)` with status `201`, where `pkg` is the + * `InstalledPackage` row `SchemaRegistry.installPackage` recorded, on both + * install arms (the protocol service's and the no-protocol fallback). So `data` + * is {@link InstalledPackageSchema}, reused rather than restated. + * + * Until #30464 this declared `data: { package, dependencyResolution?, + * namespaceConflicts?, message? }`, a wrapper no surface ever sent: an + * integrator coding against it read `data.package` and got `undefined`. The + * first-party client had already followed the wire (#12034: + * `client.packages.install` resolves to `InstalledPackage`). ⛔ The fix is the + * contract following the wire, never the door growing the old envelope. + * + * The row is at the AUTHORING stage, because this door installs the manifest + * its own request schema ({@link PackageInstallBodySchema}) declares. It carries + * no `writable` / `disableable`: those are the READ doors' per-read verdicts + * (`InstalledPackageAtEitherStageSchema`, `./package-api-assembled.zod.ts`), + * which this door does not compute. + * + * Contract-vs-wire pin (the real dispatcher's answer parsed with + * `PackageApiContracts.installPackage.output`): + * `packages/client/src/packages-write-envelope.test.ts`. */ export const PackageInstallResponseSchema = lazySchema(() => BaseResponseSchema.extend({ - data: z.object({ - package: InstalledPackageSchema.describe('Installed package details'), - dependencyResolution: DependencyResolutionResultSchema.optional() - .describe('Dependency resolution result'), - namespaceConflicts: z.array(z.object({ - type: z.literal('namespace_conflict').describe('Error type'), - requestedNamespace: z.string().describe('Requested namespace'), - conflictingPackageId: z.string().describe('Conflicting package ID'), - conflictingPackageName: z.string().describe('Conflicting package name'), - suggestion: z.string().optional().describe('Suggested alternative'), - })).optional().describe('Namespace conflicts detected'), - message: z.string().optional().describe('Installation status message'), - }), + data: InstalledPackageSchema.describe('The installed package row, served bare as `data`: the row the registry recorded, at the authoring stage'), }).describe('Install package response')); export type PackageInstallResponse = z.input; /** Post-parse shape of {@link PackageInstallResponse} — defaults applied, transforms run (ADR-0122). */