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
27 changes: 27 additions & 0 deletions .changeset/client-packages-get-single-true-type.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
"@objectstack/client": minor
---

fix(client): `packages.get` binds the bare `InstalledPackage` row on both the global and the environment-scoped client, replacing a `{ package }` envelope no surface emits (#12034)

`client.packages.get(id)` and `ScopedEnvironmentClient.packages.get(id)` now resolve to **`InstalledPackage`** — the row itself — instead of an object wrapping it.

**Migration — read the row directly, not `.package`:**

```ts
// before
const { package: pkg } = await client.packages.get('com.acme.crm');
const pkg2 = (await scoped.packages.get('com.acme.crm')).package;

// after
const pkg = await client.packages.get('com.acme.crm');
const pkg2 = await scoped.packages.get('com.acme.crm');
```

FROM `{ package: any }` (global) and `{ package: InstalledPackage }` (scoped) TO `InstalledPackage` on both.

This is a **narrowing**: a `.package` read compiles today and stops compiling after this change. That is the point of the change rather than a side effect of it — the wrapper was never what the wire sent, so every one of those reads was already `undefined` at runtime, and on the global method the `any` member is what kept the falsehood invisible. Nothing about the request or the wire changes; only the declaration moves to match what the server has been sending.

Why it can be bound now, when #11925 deliberately left it erased: this route used to be served by two implementations that disagreed — the runtime dispatcher sent the bare row, the `@objectstack/rest` registrar sent `{ package }` — so no declaration was true on both. The registrar's read routes were removed in #16628, leaving the dispatcher's `/packages` domain as the single implementation. It builds the detail body with the same expression it maps over every `list` row, which is why this type now agrees with the `InstalledPackage[]` that `packages.list` has already declared, and with `GetInstalledPackageResponseSchema` in `@objectstack/spec`, which has declared `data: InstalledPackageSchema` all along.

The environment-scoped method is the sharper half of the change: its member was a real `InstalledPackage`, not `any`, so `.package` reads there looked type-safe while returning `undefined` against every surface that has served that path since #16628.
119 changes: 78 additions & 41 deletions packages/client/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2410,34 +2410,49 @@ export class ObjectStackClient {
/**
* Get a specific installed package by its ID (reverse domain identifier).
*
* ⛔ [#11925 / #12034] STILL NOT bound, and the `{ package }` envelope is
* left exactly as it was. #12034 shipped its `install` / `enable` /
* `disable` neighbours (one producer each) and deliberately did NOT ship
* this one, because this route is a REAL fork with no single true type.
* Both bodies below were MEASURED by driving each registrar, not read off
* the source:
*
* dispatcher handlePackages('/<id>', 'GET')
* -> { success: true, data: { id, manifest, enabled, status } }
* rest GET /api/v1/packages/:id
* -> { success: true, data: { package: { …row, source } } }
*
* `unwrapResponse` strips one envelope, so the post-unwrap value is the
* BARE row on the dispatcher and `{ package }` on REST. Binding either
* member here hardens a claim that is false on the other surface. Making
* it bindable means converging the two PRODUCERS — a wire-behaviour change
* to two mounted surfaces, above this card's authority, with a clause-②
* narrowing analysis of its own. The measured convergence cost is recorded
* on #12034 for that ruling.
*
* Its SCOPED twin `ScopedEnvironmentClient.packages.get` IS bound, because
* only the REST registrar serves the scoped mount — one surface, one
* shape.
*/
get: async (id: string) => {
* [#12034] Bound to `InstalledPackage` — the BARE row, no envelope. This
* was the last of the four `packages.*` methods #11925 left unbound, and
* the reason it was unbindable is GONE.
*
* What blocked it was a REAL fork: two mounted surfaces answering
* different envelopes, dispatcher `success(pkg)` against REST
* `sendOk(res, { package: { …row, source } })`. #16628 removed the REST
* twin outright. `registerPackageRoutes` mounts ONE route now —
* `POST /packages/publish` — which is not a claim about registration
* order but about the single `routes` array it hands to
* `mountDirectRoutes`, the same array it reports back as the description
* of what it mounted (`packages/rest/src/package-routes.ts`). So
* `runtime`'s `/packages` domain is the one implementation left, and it
* answers the bare row:
*
* GET /packages/:id
* -> success(withWritableVerdict(qlService, toPackageResponse(pkg)))
*
* 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.
*
* `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
* `(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.
*/
get: async (id: string): Promise<InstalledPackage> => {
const route = this.getRoute('packages');
const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}`);
return this.unwrapResponse<{ package: any }>(res);
return this.unwrapResponse<InstalledPackage>(res);
},

/**
Expand Down Expand Up @@ -7549,25 +7564,47 @@ export class ScopedEnvironmentClient {
return this.parent._unwrap<{ packages: InstalledPackage[]; total: number }>(res);
},
/**
* [#11925] The asymmetry #8140 recorded, now closed. Its neighbour `list`
* above carried BOTH a return annotation and a type argument, so #8140
* bound it; this method carried neither and was left erased — same object
* literal, same route family, opposite treatment purely because one lacked
* the annotation.
* [#11925 bound it · #12034 corrected the shape] The BARE row, no
* envelope — the same type its global twin `client.packages.get` now
* carries, and for the same reason.
*
* ⚠️ The rationale this binding shipped with was FALSIFIED, and it is
* worth stating what it claimed because the claim is what made the
* `{ package }` envelope look safe: *"only the REST registrar serves the
* scoped path — so the `{ package }` envelope declared here is the one
* that route actually sends."* #16628 deleted the registrar's
* `GET /packages/:id`. `registerPackageRoutes` is still mounted on BOTH
* `{base}/packages` and `{base}/environments/:environmentId/packages`
* (`direct-mount-composition.ts` iterates that list of bases), so the
* mount the sentence named is still there — it just mounts one route now,
* `POST /packages/publish`, and no read. ⇒ Between #16628 and this
* change the declaration here described a body NO surface emitted
* anywhere, which is strictly worse than the erasure #11925 removed.
*
* What serves this path is the dispatcher, reached through the
* `@objectstack/hono` catch-all the scoped hosts mount: `dispatch()`
* strips the `/environments/:environmentId` prefix — that catch-all is
* the ONLY entry that hands `dispatch()` a still-scoped path
* (`packages/runtime/src/http-dispatcher.ts` says so at the stripping
* 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
* correction here.
*
* ⚠️ Clause-② narrowing, 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.
*
* The scoped mount is unambiguous, which is what makes it bindable while
* the GLOBAL `client.packages.get` is not: `registerPackageRoutes` is
* mounted at both `{base}/packages` and
* `{base}/environments/:environmentId/packages`, and only the REST
* registrar serves the scoped path — so the `{ package }` envelope
* declared here is the one that route actually sends. The handler also
* spreads a `source: 'database' | 'registry'` discriminator onto the row,
* left undeclared for the same reason `list` leaves it undeclared.
* `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<{ package: InstalledPackage }> => {
get: async (id: string, version?: string): Promise<InstalledPackage> => {
const qs = version ? `?version=${encodeURIComponent(version)}` : '';
const res = await this.parent._fetch(this.url(`/packages/${encodeURIComponent(id)}${qs}`));
return this.parent._unwrap<{ package: InstalledPackage }>(res);
return this.parent._unwrap<InstalledPackage>(res);
},
};

Expand Down
Loading
Loading