diff --git a/.changeset/17536-client-packages-read-doors-either-stage.md b/.changeset/17536-client-packages-read-doors-either-stage.md new file mode 100644 index 00000000000..bed084fd43a --- /dev/null +++ b/.changeset/17536-client-packages-read-doors-either-stage.md @@ -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`, 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, …>` 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`. `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. + + diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index c12b554d20a..7f819e89ae5 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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, @@ -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`, 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, …>` 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); @@ -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); }, /** @@ -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 => { + get: async (id: string): Promise => { const route = this.getRoute('packages'); const res = await this.fetch(`${this.baseUrl}${route}/${encodeURIComponent(id)}`); - return this.unwrapResponse(res); + return this.unwrapResponse(res); }, /** @@ -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 @@ -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 => { + get: async (id: string, version?: string): Promise => { const qs = version ? `?version=${encodeURIComponent(version)}` : ''; const res = await this.parent._fetch(this.url(`/packages/${encodeURIComponent(id)}${qs}`)); - return this.parent._unwrap(res); + return this.parent._unwrap(res); }, }; diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index 8ed2d396e45..78185c990b5 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -66,6 +66,13 @@ import type { OrganizationWire, } from './index'; import type { SearchAllResponse } from '@objectstack/spec/api'; +// [#17536] The two READ doors' declared element, and the branch of it that made +// the client's authoring-stage declaration wrong. Both are imported as TYPES: +// the pins below are the only thing that can observe a return-type move. +import type { + AssembledInstalledPackage, + InstalledPackageAtEitherStage, +} from '@objectstack/spec/api'; import type { AnalyticsMetadataResponse, AnalyticsSqlResponse, @@ -193,8 +200,12 @@ export async function returnTypePrecisionPins(): Promise { // ── shape class 6: the ScopedEnvironmentClient MIRROR carries the same types ─ expectTypeOf(await scoped.automation.getFlow('flow_a')).toEqualTypeOf(); expectTypeOf(await scoped.automation.getRun('flow_a', 'run_1')).toEqualTypeOf(); + // ⚠️ [#17536] The ELEMENT moved off `InstalledPackage` (the authoring stage) + // onto the union the door is declared at. The envelope #8140 pinned here is + // untouched; see `installedPackageEitherStagePins17536` below for the door + // reading and the two directions that measure it. expectTypeOf(await scoped.packages.list()).toEqualTypeOf<{ - packages: InstalledPackage[]; + packages: InstalledPackageAtEitherStage[]; total: number; }>(); @@ -303,8 +314,11 @@ export async function returnTypePrecisionPins11925(): Promise { // send `{ packages, total: packages.length }`. The REST rows also carry a // `source` discriminator the dispatcher rows lack, so it stays undeclared // — the same treatment #8140 gave the scoped sibling directly above. + // ⚠️ [#17536] The ELEMENT moved, the envelope did not — this pin is still + // #11925's statement that both mounted surfaces agree on + // `{ packages, total }`, and that half is unchanged. expectTypeOf(await client.packages.list()).toEqualTypeOf<{ - packages: InstalledPackage[]; + packages: InstalledPackageAtEitherStage[]; total: number; }>(); @@ -332,7 +346,14 @@ export async function returnTypePrecisionPins11925(): Promise { // sees it — and answers the bare row. ⇒ "one surface and one shape" is // still true; the shape is the ROW, and #12034 moved the declaration to // match it. - expectTypeOf(await scoped.packages.get('com.acme.crm')).toEqualTypeOf(); + // + // ⚠️ [#17536] And the ROW's own declaration moved once more, at the stage + // axis rather than the envelope axis: `GetInstalledPackageResponseSchema` + // has read `data: InstalledPackageAtEitherStageSchema` since PR #17517. "One + // surface and one shape" is STILL true — the shape is the row at whichever + // manifest stage it was installed at. + expectTypeOf(await scoped.packages.get('com.acme.crm')) + .toEqualTypeOf(); // ── direction 2: a WRONG shape must now be rejected ─────────────────── // ⚠️ Only ONE of the two below was red before #11925's change, and the @@ -568,9 +589,21 @@ export async function returnTypePrecisionPins12034(): Promise { // The two the ruling added. Same row, same projection: the `/packages` // domain builds the detail body and every `list` row with ONE expression, // `withWritableVerdict(qlService, toPackageResponse(pkg))`, so this pin and - // the `InstalledPackage[]` on `list` are two readings of one producer. - expectTypeOf(await client.packages.get('com.acme.crm')).toEqualTypeOf(); - expectTypeOf(await scoped.packages.get('com.acme.crm')).toEqualTypeOf(); + // the element type on `list` are two readings of one producer. + // + // ⚠️ [#17536] Which is why these two READ members moved and the WRITE + // members above did not — all four of them (`install`, `enable`, `disable`, + // `update`) still answer `Promise`. One producer expression + // serves `list` and `get`, and PR #17517 declared what it serves — + // `InstalledPackageAtEitherStageSchema` — on both read doors. The install + // member answers the row its own request contract produced + // (`PackageInstallRequestSchema` declares `manifest: ManifestSchema`, the + // AUTHORING stage), so its declaration is unchanged and this asymmetry is the + // measurement, not an oversight. + expectTypeOf(await client.packages.get('com.acme.crm')) + .toEqualTypeOf(); + expectTypeOf(await scoped.packages.get('com.acme.crm')) + .toEqualTypeOf(); // ── direction 2: the read the false declaration invited must now FAIL ─ // ⚠️ RED BEFORE, all three: while the member was `any`, `.package` was a @@ -616,6 +649,154 @@ export async function returnTypePrecisionPins12034(): Promise { void (await scoped.packages.get('com.acme.crm')).package; } +declare const authoringRow: InstalledPackage; +declare const assembledRow: AssembledInstalledPackage; + +/** + * [#17536] The four `/packages` READ members declare the stage the DOOR is + * declared at, not the stage they happened to be bound to first. + * + * ## What was wrong + * + * PR #17517 declared both read doors at EITHER manifest stage — + * `ListInstalledPackagesResponseSchema.packages` is + * `z.array(InstalledPackageAtEitherStageSchema)` and + * `GetInstalledPackageResponseSchema.data` is that same schema. This SDK went on + * declaring `InstalledPackage`, the AUTHORING stage, on all four read members. + * ⇒ A response the server is declared able to send was one this client's own + * types said could not arrive. `packages/spec` is the one contract and this is a + * consumer of it (Prime Directive #12), so the consumer's declaration moves. + * + * ## Why these pins are type-level, like every other pin in this file + * + * Nothing about the VALUES changed. A `defineStack()` host has been installing + * assembled-manifest rows into the same table the whole time, and both read + * doors have been serving them. Only the declaration moved, so only a + * compile-time assertion can observe it — a runtime test is green either way. + * + * ## The two directions, and the control that makes direction 1 mean something + * + * Direction 1 asserts the declared return now ADMITS an assembled-stage row. + * That assertion is only evidence if an assembled row was REFUSED before, so the + * refusal is pinned in the same breath: the four WRITE members did not move, + * and the `@ts-expect-error` on `install` below is exactly the assignment + * direction 1 makes. If `AssembledInstalledPackage` were assignable to + * `InstalledPackage` after all, that suppression would go UNUSED and tsc would + * report TS2578 — so the pair fails loudly instead of passing vacuously. + * + * Direction 2 asserts what the widening did NOT buy, and it is narrower than it + * looks: the suppressed assignment gives `manifest` a STRING PRIMITIVE, which + * both branches of the union refuse, so the suppression is USED. That is the + * line that reddens (TS2578, unused directive) if these members are ever + * "widened" to `any` / `unknown` to make a payload fit. + * + * ⛔ It does NOT measure object-shaped tolerance, and at this head there is + * some: on the assembled branch `manifest` is declared `Record` + * (the deliberate annotation at `packages/spec/src/stack.zod.ts:1283`, #14513), + * so an object `manifest` belonging to NEITHER stage compiles against these + * members. The runtime is the half that is correct — + * `InstalledPackageAtEitherStageSchema.safeParse()` refuses that same row, and + * that refusal is pinned beside its producer in + * `packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts`. + * The type-level gap is #19324's to close; the third pin below records it as the + * behaviour it is, so the day it closes this file says so. + * + * ## Ablation, measured rather than asserted + * + * Restore `packages/client/src/index.ts` to the blob it carried BEFORE this card + * — `git show 21e6b9887c^:packages/client/src/index.ts`, blob `c12b554d20`, a + * fixed anchor rather than a moving `origin/main` — keep this file, run + * `tsc --noEmit -p tsconfig.test.json`: exit 2, **ten** errors — the five + * `toEqualTypeOf` pins that name the union (TS2344, here and in the three blocks + * above), the four direction-1 assignments below (TS2322), and the #19324 gap + * pin below (TS2322 — with the authoring stage restored its `objects` value is + * refused; ⚠️ that is NOT the tolerance it records at head, so read its own + * comment for what it measures). Both `@ts-expect-error` lines stay USED in that + * run (0 TS2578), which is what makes them controls rather than evidence: they + * are labelled GREEN IN BOTH STATES below. + */ +export function installedPackageEitherStagePins17536(): void { + // ── direction 1: the assembled stage is ADMITTED, on all four read members ─ + // RED BEFORE, all four: the declared element was `InstalledPackage`, whose + // `manifest` is `ManifestSchema` (the authoring stage), and an assembled body + // is not assignable to it. + const getAdmitsAssembled: Awaited> = assembledRow; + const scopedGetAdmitsAssembled: Awaited> = assembledRow; + const listAdmitsAssembled: Awaited>['packages'][number] = + assembledRow; + const scopedListAdmitsAssembled: Awaited< + ReturnType + >['packages'][number] = assembledRow; + + // GREEN IN BOTH STATES — regression guard, not red-before evidence. The move + // is a WIDENING: the authoring stage stays admitted. A "fix" that swapped one + // stage for the other rather than declaring the union reddens here. + const getStillAdmitsAuthoring: Awaited> = authoringRow; + + // ── the control: the WRITE members did not move ────────────────────────── + // GREEN IN BOTH STATES — a control, not red-before evidence, and that is + // exactly its job. `POST /packages` declares `manifest: ManifestSchema` on + // its REQUEST contract, so the row it answers is the authoring stage and + // stays declared as one. This suppression is the same assignment direction 1 + // makes, against the member that did NOT move: it is USED precisely because + // an assembled row is refused by an `InstalledPackage` annotation, so if the + // two were assignable after all it would go unused (TS2578) and direction 1 + // would be shown to have been passing vacuously. + // @ts-expect-error the install door answers the AUTHORING stage; an assembled row is not one + const installRefusesAssembled: Awaited> = assembledRow; + + // ── direction 2: a manifest that is not an OBJECT AT ALL is refused ───── + // GREEN IN BOTH STATES — the second control, and this is the whole of what + // it measures: a STRING PRIMITIVE is refused by both branches of the union, + // so the suppression is USED. It reddens (TS2578, unused suppression) if + // these members are ever widened to `any` or `unknown`. ⛔ It says nothing + // about an object-shaped `manifest` belonging to neither stage — that one + // compiles today, and is pinned as such directly below. + // + // ⚠️ The suppression sits on the PROPERTY, not on the `const`. Measured: tsc + // reports this mismatch at the offending member of the object literal, so a + // directive on the declaration line covers the wrong line and goes unused — + // TS2578, a red gate that says nothing about the union. + const neitherStage: Awaited> = { + ...authoringRow, + // @ts-expect-error a manifest must at least be an object; neither branch of the union admits a string + manifest: 'com.acme.crm@1.0.0', + }; + + // ── the KNOWN GAP, pinned as the behaviour it IS (#19324) ───────────── + // ⛔ NOT a guarantee — a measurement, written down so it cannot change in + // silence. `AssembledInstalledPackage['manifest']` is + // `Record` (from the deliberate + // `z.ZodType, …>` annotation at + // `packages/spec/src/stack.zod.ts:1283`, #14513 — TS7056 and a + // declaration-chunk ceiling), so the assembled branch admits ANY object and + // the assignment below COMPILES at this head. Measured with `tsc` against the + // published declarations; the runtime disagrees and is the correct half: + // `InstalledPackageAtEitherStageSchema.safeParse()` answers `success: false` + // for this very row. + // + // ⚠️ There is deliberately no `@ts-expect-error` here. The day #19324 types + // the assembled body, tsc reds on THIS line — and that red is the + // notification this pin exists to deliver: read it as "the gap closed", then + // delete this block and tighten the `manifest` guidance on + // `ObjectStackClient.packages.list` in `index.ts`, which sends callers + // through a `packages/spec` parse precisely because the type cannot carry + // the narrowing today. + const objectToleranceGap19324: Awaited> = { + ...authoringRow, + manifest: { bogus: 1, objects: 'not-even-an-array' }, + }; + + void getAdmitsAssembled; + void scopedGetAdmitsAssembled; + void listAdmitsAssembled; + void scopedListAdmitsAssembled; + void getStillAdmitsAuthoring; + void installRefusesAssembled; + void neitherStage; + void objectToleranceGap19324; +} + /** * [#12104 — the in-repo half] The SIXTH erasure spelling: no return annotation * and `return res.json()`, whose published type came from `lib.dom`'s @@ -1248,6 +1429,7 @@ describe('client SDK return-type precision (#8140)', () => { expect(typeof returnTypePrecisionPins11925).toBe('function'); expect(typeof returnTypePrecisionPins12038).toBe('function'); expect(typeof returnTypePrecisionPins12034).toBe('function'); + expect(typeof installedPackageEitherStagePins17536).toBe('function'); expect(typeof returnTypePrecisionPins12104).toBe('function'); expect(typeof returnTypePrecisionPins14312).toBe('function'); expect(typeof returnTypePrecisionPins15451).toBe('function');