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
19 changes: 19 additions & 0 deletions .changeset/30464-install-response-bare-row.md
Original file line number Diff line number Diff line change
@@ -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: <row> }` 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.

<!-- adr-0087: not-required (no-migration-prescription) an API answer shape, not metadata: no stored row or authored source holds it, so objectstack migrate meta has nothing to rewrite. The consumer line above is for integrator source code, which the compiler now flags (ADR-0087 D8); type-surface-only is closed to it only because the schema lives in packages/spec -->
19 changes: 14 additions & 5 deletions content/docs/references/api/package-api.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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`

Expand Down Expand Up @@ -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<string, any>` | 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 |


---
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.

| Dir | Sites |
|---|---|
| `api/` | 501 |
| `api/` | 499 |
61 changes: 61 additions & 0 deletions packages/client/src/packages-write-envelope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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']),
);
});
});
6 changes: 3 additions & 3 deletions packages/spec/dropped-refinements.baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
69 changes: 27 additions & 42 deletions packages/spec/src/api/package-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});

Expand Down
41 changes: 27 additions & 14 deletions packages/spec/src/api/package-api.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -522,22 +522,35 @@ export type PackageInstallBody = z.input<typeof PackageInstallBodySchema>;
export type PackageInstallBodyParsed = z.infer<typeof PackageInstallBodySchema>;

/**
* 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<typeof PackageInstallResponseSchema>;
/** Post-parse shape of {@link PackageInstallResponse} — defaults applied, transforms run (ADR-0122). */
Expand Down
Loading