From 5c8be5a65bddf47daa6ecdf370e9dfd65803cf90 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 30 Aug 2026 06:29:20 +0000 Subject: [PATCH] feat(client): declare the three response keys `environments.create()` really receives (#12883) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `client.environments.create()` declared its unwrap shape as the single key `environment`, while `POST /api/v1/cloud/environments` answers 201 with `warnings`, `durationMs` and a conditional `hostnameAssignment` as well. `warnings` is the partial-degradation channel, so no SDK caller could learn what a provision failed to do without an `as any`. Per the 2026-08-29 maintainer ruling (verbatim 「同意」, option 甲) the three keys are declared as the INLINE WIRE SHAPE and are NOT bound to `@objectstack/spec/cloud`'s `ProvisionEnvironmentResponseSchema`, honouring the namespace docblock's recorded snake_case constraint. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TvqBFLRzXdSPcbusDoED9k --- ...ents-create-declares-wire-response-keys.md | 33 ++++++ .../src/client.environments-namespace.test.ts | 102 ++++++++++++++++++ packages/client/src/index.ts | 44 +++++++- 3 files changed, 174 insertions(+), 5 deletions(-) create mode 100644 .changeset/environments-create-declares-wire-response-keys.md diff --git a/.changeset/environments-create-declares-wire-response-keys.md b/.changeset/environments-create-declares-wire-response-keys.md new file mode 100644 index 0000000000..5a38bcf728 --- /dev/null +++ b/.changeset/environments-create-declares-wire-response-keys.md @@ -0,0 +1,33 @@ +--- +"@objectstack/client": minor +--- + +feat(client): `environments.create()` declares the three response keys the control plane really sends — `warnings`, `durationMs`, and a conditional `hostnameAssignment` (#12883) + +`client.environments.create()` declared its unwrap shape as the single key +`environment`, while `POST /api/v1/cloud/environments` answers `201` with three +more. `warnings` in particular is the channel a partially-degraded provision +uses to report what it could not do, and no SDK caller could reach it without +an `as any`. + +This is **additive**: `environment` keeps its shape and every existing call site +keeps compiling. What changes is that three previously-erased keys are now +declared and reachable. + +```ts +const res = await client.environments.create({ organization_id, display_name }); + +res.warnings; // string[] — partial-degradation channel, no cast needed +res.durationMs; // number +res.hostnameAssignment; // optional — present ONLY when the control plane + // renamed a colliding hostname; absence stays absence +``` + +Per the 2026-08-29 maintainer ruling (verbatim 「同意」, option 甲) the three keys +are typed as the **inline wire shape** and are deliberately **not** bound to +`@objectstack/spec/cloud`'s `ProvisionEnvironmentResponseSchema`: those are +camelCase row contracts, and the `/api/v1/cloud/*` control plane this namespace +calls speaks snake_case — the constraint already recorded on the namespace +docblock. Binding them would typecheck and be false. + +The request side of the same method is untouched and remains a separate card. diff --git a/packages/client/src/client.environments-namespace.test.ts b/packages/client/src/client.environments-namespace.test.ts index 0afc4c991f..0598f85fa2 100644 --- a/packages/client/src/client.environments-namespace.test.ts +++ b/packages/client/src/client.environments-namespace.test.ts @@ -121,6 +121,53 @@ export function createDeclaresNoDatabaseBlock(): void { void created.database; } +/** + * `create` declares the three keys the route sends BESIDE `environment`. + * + * ⚖️ Maintainer ruling, 2026-08-29, verbatim: 「同意」 — option 甲. `warnings` + * and `durationMs` are declared PRESENT, `hostnameAssignment` OPTIONAL (the + * producer's own "absence stays absence" contract), all three typed as the + * INLINE WIRE SHAPE and ⛔ NOT bound to `@objectstack/spec/cloud`'s + * `ProvisionEnvironmentResponseSchema` — those are camelCase row contracts for + * a control plane that speaks snake_case on `/api/v1/cloud/*` (#11925/#12036). + * + * Each read below is a separate assertion on purpose: a key that regresses on + * its own is named by the failure instead of hidden behind a sibling's. + */ +export function createDeclaresTheWireResponseKeys(): void { + const created = {} as CreateShape; + + // PRESENT, not optional. Assigning into a non-optional local is the pin: + // weakening either key to `?` puts `undefined` in the type, which stops + // being assignable, and the red lands on the declaration rather than in + // some caller months later. + const warnings: string[] = created.warnings; + const durationMs: number = created.durationMs; + void warnings; + void durationMs; + + // OPTIONAL by the producer's contract — forwarded only when the control + // plane renamed a colliding hostname, so `hostnameAssignment !== undefined` + // is itself the signal. The `@ts-expect-error` IS the optionality + // assertion: it holds only while `undefined` is part of the type, so + // promoting the key to required makes the error vanish and this line goes + // red as an unused expect-error. + // @ts-expect-error `hostnameAssignment` is optional; a non-optional local cannot take its `undefined` + const assignment: { requestedHostname: string; assignedHostname: string } = created.hostnameAssignment; + void assignment; + void created.hostnameAssignment?.requestedHostname; + void created.hostnameAssignment?.assignedHostname; + + // ⛔ `credential` stays UNDECLARED, and this pin is what keeps it that way. + // `ProvisionEnvironmentResponseSchema` declares it REQUIRED while the + // producer body this declaration was written against does not send it — + // an unjudged divergence that is NOT settled by copying the key here. + // Declaring it would make the SDK promise a key the wire does not carry, + // which is this method's own defect class pointed the other way. + // @ts-expect-error `POST /cloud/environments` is not declared to answer a `credential`; `rotateCredential` is the method that does + void created.credential; +} + /** Helper: a client whose `fetch` answers one canned BaseResponse envelope. */ function clientAnswering(data: unknown) { const fetchMock = vi.fn().mockResolvedValue({ @@ -202,6 +249,60 @@ describe('[ADR-0006 D2] client.environments — the control-plane namespace afte expect(fetchMock.mock.calls[0][1].method).toBe('POST'); }); + it('relays `warnings` and `durationMs` beside `environment` — reachable without an `as any`', async () => { + const { client } = clientAnswering({ + environment: { id: 'env_new', display_name: 'Dev' }, + warnings: ['seed package skipped: registry unreachable'], + durationMs: 4210, + }); + + const res = await client.environments.create({ + organization_id: 'org_1', + display_name: 'Dev', + }); + + // `warnings` is the partial-degradation channel: this read is the whole + // point of the card, and before the declaration widened it needed a cast. + expect(res.warnings).toEqual(['seed package skipped: registry unreachable']); + expect(res.durationMs).toBe(4210); + }); + + it('relays `hostnameAssignment` when the control plane renamed a colliding hostname', async () => { + const { client } = clientAnswering({ + environment: { id: 'env_new', hostname: 'dev-a1b2' }, + warnings: [], + durationMs: 900, + hostnameAssignment: { requestedHostname: 'dev', assignedHostname: 'dev-a1b2' }, + }); + + const res = await client.environments.create({ + organization_id: 'org_1', + display_name: 'Dev', + }); + + expect(res.hostnameAssignment?.requestedHostname).toBe('dev'); + expect(res.hostnameAssignment?.assignedHostname).toBe('dev-a1b2'); + }); + + it('absence stays absence — no `hostnameAssignment` key when the requested hostname was kept', async () => { + const { client } = clientAnswering({ + environment: { id: 'env_new', hostname: 'dev' }, + warnings: [], + durationMs: 880, + }); + + const res = await client.environments.create({ + organization_id: 'org_1', + display_name: 'Dev', + }); + + // `in` rather than a truthiness check: the producer's contract is that + // the KEY is absent, and reading absence as "unknown" is what its own + // schema comment forbids. A relay that materialised `undefined` would + // pass a truthiness assertion while breaking that contract. + expect('hostnameAssignment' in res).toBe(false); + }); + it('relays the activate envelope — `environment` beside `sessionUpdated`', async () => { const { client } = clientAnswering({ environment: { id: 'env_1' }, @@ -232,5 +333,6 @@ describe('[ADR-0006 D2] client.environments — the control-plane namespace afte expect(typeof listEnvelopeCarriesTheWireKeys).toBe('function'); expect(typeof singleRowEnvelopesCarryTheWireKey).toBe('function'); expect(typeof createDeclaresNoDatabaseBlock).toBe('function'); + expect(typeof createDeclaresTheWireResponseKeys).toBe('function'); }); }); diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 419e2c62ba..6a0ff280d5 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -2194,11 +2194,45 @@ export class ObjectStackClient { // promised could not happen. `get` is the method that really does // answer a `database` block. // - // The keys the route DOES send beside `environment` (`warnings`, - // `durationMs`, and a conditional `hostnameAssignment`) are deliberately - // not declared here — adding them is new published surface and a - // separate decision, not part of this rename. - return this.unwrapResponse<{ environment: any }>(res); + // The three keys the route sends BESIDE `environment` are declared here + // as of 2026-08-29. That absence used to be deliberate and this comment + // used to say so; the decision it was waiting for has since been made, so + // the stance is recorded rather than left standing: + // + // ⚖️ Maintainer ruling, 2026-08-29, verbatim: 「同意」— option 甲. + // `warnings` and `durationMs` are declared PRESENT, `hostnameAssignment` + // OPTIONAL (the producer's own "absence stays absence" contract), and + // all three are typed as the INLINE WIRE SHAPE. + // + // ⛔ Inline, and NOT bound to `@objectstack/spec/cloud`'s + // `ProvisionEnvironmentResponseSchema`. That is the namespace docblock's + // #11925/#12036 constraint applied to the response side: those contracts + // are camelCase row types for a control plane that speaks snake_case on + // `/api/v1/cloud/*`, so binding them would typecheck and be false. The + // ruling names the inline shape for that reason. + // + // ⚠️ Two facts a later reader must not have to rediscover: + // + // - The producer shape is an INHERITED reading, not one measured from + // this repo. `objectstack-ai/cloud` is not readable from here, so the + // handler body quoted on the card (`packages/service-cloud/src/routes/ + // environment-lifecycle.ts`, POST `/cloud/environments`, spreading + // `environment` / `warnings` / `durationMs` / conditional + // `hostnameAssignment`) is the card author's 2026-08-28 measurement, + // relayed. No gate in this repo can check it. + // - `ProvisionEnvironmentResponseSchema` additionally declares a REQUIRED + // `credential`, which that handler quote does not send, and marks + // `warnings` `.optional()`, which the ruling declares present. Both + // divergences are recorded and UNJUDGED; neither is settled here. The + // `credential` one is why binding the schema is not the safe default + // it looks like: it would make this SDK declare a key the wire does + // not carry — this method's own defect class, pointed the other way. + return this.unwrapResponse<{ + environment: any; + warnings: string[]; + durationMs: number; + hostnameAssignment?: { requestedHostname: string; assignedHostname: string }; + }>(res); }, /**