Skip to content
44 changes: 44 additions & 0 deletions .changeset/17536-client-packages-read-doors-either-stage.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
"@objectstack/client": minor
---

fix(client): the four `packages` READ members declare the stage their door is declared at (#17536)

Clause-②: yes

**BREAKING** for TypeScript consumers — a published TYPE-surface WIDENING, shipped as `minor` under the launch-window convention (`check-changeset-no-major` refuses `major` until GA; breaking-ness is carried by this banner and the ADR-0087 disposition below, never by the level). No runtime behaviour changes, and none is possible here: only a declaration moved, and the values these four methods resolve to are the values they have always resolved to.

`ObjectStackClient.packages.list` / `.get` and their `ScopedEnvironmentClient` twins returned `InstalledPackage` — the AUTHORING manifest stage, imported from `@objectstack/spec/kernel`. Since PR #17517 both read doors have been declared at EITHER stage: `ListInstalledPackagesResponseSchema.packages` is `z.array(InstalledPackageAtEitherStageSchema)` and `GetInstalledPackageResponseSchema.data` is that same schema. ⇒ A response the server is declared able to send was one this SDK's own types said could not arrive.

`packages/spec` is the one contract between producers and consumers, and `packages/client` is a consumer of it, so the consumer's declaration is what moves. All four now declare `InstalledPackageAtEitherStage` from `@objectstack/spec/api`.

**What the union is.** It is a union over the two manifest stages — `InstalledPackageSchema` and `AssembledInstalledPackageSchema` — which differ in exactly one key, `manifest`. Every other member of the row (`id`, `name`, `version`, `status`, `enabled`, `installedAt`, …) is common to both branches and reads exactly as it did, so code that reads only those members needs no change at all.

**Where the RUNTIME and the TYPE disagree, measured at this head.** The runtime schema is the strict half: `InstalledPackageAtEitherStageSchema.safeParse(row)` answers `success: false` for a row whose `manifest` belongs to neither stage — measured on a `{ bogus: 1, objects: 'not-even-an-array' }` manifest and on an empty `{}` one. The published TYPE is NOT that strict: on the assembled branch `manifest` is declared `Record<string, unknown>`, so both of those same rows COMPILE against the declared return type. ⛔ Do not read this widening as a type-level guarantee about `manifest` — the guarantee is the parse's. The type-level tolerance is a known gap, tracked as **#19324**; its root cause is the deliberate `z.ZodType<Record<string, unknown>, …>` annotation at `packages/spec/src/stack.zod.ts:1283` (#14513 — TS7056 and a declaration-chunk ceiling), and it is ⛔ not this change's to fix. It is recorded as a pin in `packages/client/src/return-type-precision.test.ts`, which reddens the day the gap closes.

**What a consumer does, concretely.** A member read off `manifest` on the union arrives as `unknown` (measured: `pkg.manifest.objects` is `unknown`). ⇒ a caller that reaches INTO `manifest` narrows by PARSING the row with a `packages/spec` schema and reading the parse's output:

```ts
import { AssembledInstalledPackageSchema } from '@objectstack/spec/api';
import { InstalledPackageSchema } from '@objectstack/spec/kernel';

const row = await client.packages.get(id);
const parsed = AssembledInstalledPackageSchema.safeParse(row);
if (parsed.success) {
// parsed.data.manifest — the ASSEMBLED stage, object definitions
} else {
const authoring = InstalledPackageSchema.parse(row);
// authoring.manifest.objects — the AUTHORING stage, glob strings
}
```

⛔ Do NOT narrow with `Array.isArray(pkg.manifest.objects)`, or with any other structural guess. It separates the stages on NEITHER level: at the type level `pkg.manifest` is the same union inside both branches of that `if`, and at runtime BOTH stages' `objects` are arrays — `z.array(z.string())` at the authoring stage against `z.array(ObjectSchema)` at the assembled one. (Measured: a row carrying no `objects` at all parses as either stage, which is the right answer for it — the key such a guess would read is not there.)

In this repository the whole consumer cost is zero sites outside `packages/client` itself: no other workspace package calls either read member.

**The WRITE members did not move** and stay declared at the authoring stage — all four of them: `install`, `enable`, `disable`, `update` still answer `Promise<InstalledPackage>`. `install` answers the row its own request contract produced (`PackageInstallRequestSchema` declares `manifest: ManifestSchema`), and PR #17517 moved the read doors alone. That asymmetry is the measurement, not an oversight, and it is pinned.

The type is reached the same way `InstalledPackage` always was, from `@objectstack/spec` rather than re-exported here: this SDK has never re-exported the package row, and this change does not start.

<!-- adr-0087: not-required (no-migration-prescription) Nothing authorable moves. No metadata key, no authored property, no config field, no accepted request shape and no stored artifact changes spelling or shape: the edit is four declared RETURN TYPES on one SDK class pair plus their docblocks, so `objectstack migrate meta` has nothing to rewrite, `spec-changes.json` has nothing to project and the upgrade guide has no row to gain. The party addressed is a TYPESCRIPT CONSUMER and the delivery channel is the compiler at their own call site — the audience the ADR-0087 ledger explicitly does not serve. The "what a consumer does" paragraph above is a source-code statement about narrowing a union, not a stored-metadata rewrite, which is the distinction #13080 records this refusal cannot make on its own.
`type-surface-only` is NOT claimed here, and it is unavailable on the merits rather than on a resolution defect — measured by driving the gate, not assumed. That category was added for a published TYPE-surface NARROWING, and its predicate 4 (`narrowed-from-erased`) reads the base annotation through `isErasedType`, whose line is "the type IS `any` / `unknown`". At the merge base all four members carried a CONCRETE annotation (`Promise<InstalledPackage>`, `Promise<{ packages: InstalledPackage[]; total: number }>`), so nothing moved off an erased type; and this change runs in the opposite direction from the one the category names. The **BREAKING** banner is carried rather than dropped — that erosion is exactly what #13080 was filed about. -->
137 changes: 118 additions & 19 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,20 @@ import {
PackageExportManifest,
ReassignOrphanedMetadataResponse,
DuplicatePackageResponse,
// [#17536] The element the two `/packages` READ doors are declared to serve.
// `ListInstalledPackagesResponseSchema.packages` is
// `z.array(InstalledPackageAtEitherStageSchema)` and
// `GetInstalledPackageResponseSchema.data` is that same schema
// (`spec/src/api/package-api.zod.ts`) — a union over the two manifest stages,
// authoring (`InstalledPackageSchema`) and assembled
// (`AssembledInstalledPackageSchema`), each a closed RUNTIME declaration. The
// client is a CONSUMER of that contract, so the widest value those doors are
// declared to answer is what they are declared to return here. ⚠️ What the
// published TYPE admits is wider than what the runtime parse accepts — the
// measurement, and what a caller does about it, are on `packages.list` below
// (#19324). The WRITE methods on the same object keep `InstalledPackage`:
// PR #17517 moved the read doors alone.
InstalledPackageAtEitherStage,
} from '@objectstack/spec/api';
import type {
ApprovalRequestRow,
Expand Down Expand Up @@ -2453,8 +2467,61 @@ export class ObjectStackClient {
* deliberately NOT declared here — the dispatcher rows have no such key, so
* declaring it would be false on that surface. #8140 bound the identical
* scoped sibling (`ScopedEnvironmentClient.packages.list`) the same way.
*/
list: async (filters?: { status?: string; type?: string; enabled?: boolean }): Promise<{ packages: InstalledPackage[]; total: number }> => {
*
* ⚠️ [#17536] The ROW type moved, the envelope did not. #11925 bound the
* element to `InstalledPackage` — the AUTHORING stage — and PR #17517 then
* declared the door at EITHER stage:
* `ListInstalledPackagesResponseSchema.packages` is
* `z.array(InstalledPackageAtEitherStageSchema)`. That left two declarations
* one layer apart disagreeing, with this one under-declaring: a row a
* `defineStack()` host installed carries the ASSEMBLED body, the door is
* declared able to serve it, and this SDK's own types said it could not
* arrive. The contract is `packages/spec` and this is a consumer of it
* (Prime Directive #12), so the consumer's declaration is what moves.
*
* ⚠️ Clause-② widening, and what it costs a caller: the element is a union
* of two stages that differ in ONE key, `manifest`. Every other member of
* the row — `id`, `name`, `version`, `status`, `enabled`, … — is common to
* both branches and reads exactly as before, so a caller that reads only
* those members needs no change at all. No runtime behaviour changes; the
* values were always these.
*
* ⚠️ Reaching INTO `manifest` is where the cost is, and the TYPE will not
* do the narrowing for you. On the assembled branch `manifest` is declared
* `Record<string, unknown>`, so a member read off the union —
* `pkg.manifest.objects` — is `unknown` (measured with `tsc` against the
* published declarations, not inferred from the schemas). Narrow by PARSING
* the row with a `packages/spec` schema and reading the parse's output:
*
* ```ts
* const parsed = AssembledInstalledPackageSchema.safeParse(pkg); // spec/api
* if (parsed.success) {
* // parsed.data.manifest — the ASSEMBLED stage, object definitions
* } else {
* const authoring = InstalledPackageSchema.parse(pkg); // spec/kernel
* // authoring.manifest.objects — the AUTHORING stage, glob strings
* }
* ```
*
* ⛔ Do NOT narrow with `Array.isArray(pkg.manifest.objects)`, or with any
* other structural guess. It separates the stages on NEITHER level: at the
* type level `pkg.manifest` is the same union inside both branches of that
* `if`, and at runtime BOTH stages' `objects` are arrays —
* `z.array(z.string())` at the authoring stage against `z.array(ObjectSchema)`
* at the assembled one. (Measured: a row carrying no `objects` at all parses
* as either stage, which is the right answer for it — the key such a guess
* would read is not there.)
*
* ⚠️ The RUNTIME half is the strict one, and the asymmetry is a KNOWN GAP
* rather than a design: `InstalledPackageAtEitherStageSchema.safeParse()`
* refuses a `manifest` belonging to neither stage, while that same row
* COMPILES against this declaration. Tracked as #19324, whose root cause is
* the deliberate `z.ZodType<Record<string, unknown>, …>` annotation at
* `packages/spec/src/stack.zod.ts:1283` (#14513 — TS7056 and a
* declaration-chunk ceiling); ⛔ not something this declaration can fix, and
* ⛔ not a licence to relax either runtime branch to match the type.
*/
list: async (filters?: { status?: string; type?: string; enabled?: boolean }): Promise<{ packages: InstalledPackageAtEitherStage[]; total: number }> => {
const route = this.getRoute('packages');
const params = new URLSearchParams();
if (filters?.status) params.set('status', filters.status);
Expand All @@ -2463,7 +2530,7 @@ export class ObjectStackClient {
const qs = params.toString();
const url = `${this.baseUrl}${route}${qs ? '?' + qs : ''}`;
const res = await this.fetch(url);
return this.unwrapResponse<{ packages: InstalledPackage[]; total: number }>(res);
return this.unwrapResponse<{ packages: InstalledPackageAtEitherStage[]; total: number }>(res);
},

/**
Expand All @@ -2489,29 +2556,44 @@ export class ObjectStackClient {
*
* That is the SAME projection its `list` neighbour maps over every row
* (`packages/runtime/src/domains/packages.ts` — one expression, two
* doors), and `list` is already declared `InstalledPackage[]` directly
* above. This binding therefore makes two doors of one domain agree
* rather than making a new claim about either. `packages/spec` has
* declared the same thing all along and was never the fork's casualty:
* `GetInstalledPackageResponseSchema` is `data: InstalledPackageSchema`,
* the bare row.
* doors), and `list` carries the identical element type directly above.
* This binding therefore makes two doors of one domain agree rather than
* making a new claim about either.
*
* ⚠️ [#17536] The sentence that closed this paragraph was measured STALE
* and is corrected rather than deleted, because what it claimed is what
* made the authoring-stage binding below look settled. It read:
* «`packages/spec` has declared the same thing all along and was never the
* fork's casualty: `GetInstalledPackageResponseSchema` is `data:
* InstalledPackageSchema`, the bare row.» The first half still holds — the
* door has always answered the BARE row, no envelope. The second half
* stopped being true when PR #17517 landed: that schema's `data` now reads
* `InstalledPackageAtEitherStageSchema`, the union of the two closed
* manifest stages. It was a COMMENT and never a binding, which is why the
* disagreement it describes could sit here unread by any gate.
*
* `source` stays undeclared because there is no longer anything that
* emits it on this route; `writable` stays undeclared for the reason
* `list` leaves it undeclared.
*
* ⚠️ Clause-② narrowing. `{ package: any }` is what let
* ⚠️ Clause-② narrowing (#12034). `{ package: any }` is what let
* `(await client.packages.get(id)).package` compile, and on the only
* surface that has served this route since #16628 it was `undefined` at
* runtime — the falsehood was invisible precisely because the member was
* `any`. Callers read the row itself. Pinned in
* `return-type-precision.test.ts`, which is the only place it CAN be
* pinned: a runtime test cannot observe a return-type narrowing at all.
*
* ⚠️ Clause-② widening (#17536), the LATER and opposite move on the same
* member: the row is declared at whichever stage it was installed at, for
* the reason and at the cost written out on `list` above. Both directions
* are pinned in the same file, and for the same reason — only a
* compile-time assertion can observe either.
*/
get: async (id: string): Promise<InstalledPackage> => {
get: async (id: string): Promise<InstalledPackageAtEitherStage> => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}`);
return this.unwrapResponse<InstalledPackage>(res);
return this.unwrapResponse<InstalledPackageAtEitherStage>(res);
},

/**
Expand Down Expand Up @@ -7896,9 +7978,19 @@ export class ScopedEnvironmentClient {
* package tests.
*/
packages = {
list: async (): Promise<{ packages: InstalledPackage[]; total: number }> => {
/**
* ⚠️ [#17536] Clause-② widening, in step with the global twin
* `client.packages.list`. This path and that one reach the SAME door: the
* `@objectstack/hono` catch-all hands `dispatch()` the still-scoped path,
* `dispatch()` strips the `/environments/:environmentId` prefix, and the
* `/packages` domain answers. One door cannot be declared at two different
* element types by two SDK members, so both move together — see
* `ObjectStackClient.packages.list` for the door reading, the Prime
* Directive #12 argument and what the union costs a caller.
*/
list: async (): Promise<{ packages: InstalledPackageAtEitherStage[]; total: number }> => {
const res = await this.parent._fetch(this.url('/packages'));
return this.parent._unwrap<{ packages: InstalledPackage[]; total: number }>(res);
return this.parent._unwrap<{ packages: InstalledPackageAtEitherStage[]; total: number }>(res);
},
/**
* [#11925 bound it · #12034 corrected the shape] The BARE row, no
Expand Down Expand Up @@ -7926,22 +8018,29 @@ export class ScopedEnvironmentClient {
* site) — and the `/packages` domain answers
* `success(withWritableVerdict(qlService, toPackageResponse(pkg)))`. That
* is the identical projection its `list` neighbour above maps over, which
* is why `list` already declares `InstalledPackage[]` and needed no
* is why `list` carries the identical element type and needed no
* correction here.
*
* ⚠️ Clause-② narrowing, and the sharper of the two: this member was
* `InstalledPackage`, not `any`, so `(await scoped.packages.get(id))
* ⚠️ Clause-② narrowing (#12034), and the sharper of the two: this member
* was `InstalledPackage`, not `any`, so `(await scoped.packages.get(id))
* .package` compiled with a REAL type behind it and was `undefined` at
* runtime.
*
* ⚠️ Clause-② widening (#17536), the later and opposite move: the row is
* declared at whichever manifest stage it was installed at, because that is
* what `GetInstalledPackageResponseSchema.data` has been declared as since
* PR #17517. Same door as the global twin, same element type, same cost to
* a caller that reaches into `manifest` — written out once on
* `ObjectStackClient.packages.list`.
*
* `version` is unchanged and deliberately not touched by this card — it
* is a request-side question, and it is a live one: see the acceptance
* notes on #12034.
*/
get: async (id: string, version?: string): Promise<InstalledPackage> => {
get: async (id: string, version?: string): Promise<InstalledPackageAtEitherStage> => {
const qs = version ? `?version=${encodeURIComponent(version)}` : '';
const res = await this.parent._fetch(this.url(`/packages/${encodeURIComponent(id)}${qs}`));
return this.parent._unwrap<InstalledPackage>(res);
return this.parent._unwrap<InstalledPackageAtEitherStage>(res);
},
};

Expand Down
Loading
Loading