From c32fa27110888bcd4d3dd0bbb0272ad5b1dca8bd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:26:17 +0000 Subject: [PATCH 1/2] fix(client)!: bind `oauth.applications.delete` to the zero-byte 200 its route answers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ObjectStackClient.oauth.applications.delete` ended `return res.json()` on a route that answers HTTP 200 with a ZERO-BYTE body, so it rejected with `SyntaxError: Unexpected end of JSON input` on EVERY successful delete — after the row had already been committed away server-side. The method had no success path a caller could observe, and the obvious recovery (retry) failed DIFFERENTLY, with the route's 404 `not_found`. Measured end to end, not inherited: real betterAuth + real oauthProvider over the real ObjectQL adapter, a real signed-up user and session, driven through the real client with only the socket stood in for. POST /oauth2/delete-client -> 200 · 0 bytes · content-type application/json · NO content-length header through the client, before -> REJECTED: SyntaxError the row, server-side -> ALREADY GONE (get-client answers 404) through the client, after -> RESOLVED | undefined Emptiness is detected by READING the body. Both shortcuts were measured and both are unusable here: the status is 200, not the 204 five other delete surfaces in this file key off, and the response carries no `content-length` header at all. A non-empty body is still parsed and its failure still thrown, so the ONLY behaviour that moves is the zero-byte case. `void` is the wire fact: "deleted" and "was already gone" are distinguished on the ERROR channel (404 `not_found`, raised by `this.fetch` before any success value exists), so a synthesised `{ deleted: true }` would be a shape the wire never sends. `exported-any-returns.json` loses this method's entry in the same change — the ledger is shrink-only, so the entry goes WITH the binding. Its last `oauth.*` entry is now gone; 35 sites remain open. The `toEqualTypeOf()` pin PR #15445 left behind for exactly this moment is replaced by `returnTypePrecisionPins15451`, and the reject/resolve flip — which no compile-time assertion can observe — is pinned in a new runtime suite. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- packages/client/exported-any-returns.json | 1 - packages/client/src/index.ts | 61 ++++++-- .../src/oauth-applications-delete.test.ts | 131 ++++++++++++++++++ .../client/src/return-type-precision.test.ts | 84 +++++++++-- 4 files changed, 246 insertions(+), 31 deletions(-) create mode 100644 packages/client/src/oauth-applications-delete.test.ts diff --git a/packages/client/exported-any-returns.json b/packages/client/exported-any-returns.json index 8b1ca838d3..922f7df404 100644 --- a/packages/client/exported-any-returns.json +++ b/packages/client/exported-any-returns.json @@ -22,7 +22,6 @@ "ObjectStackClient.organizations.teams.delete": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", "ObjectStackClient.organizations.teams.addMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", "ObjectStackClient.organizations.teams.removeMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", - "ObjectStackClient.oauth.applications.delete": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", "ObjectStackClient.auth.updateUser": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", "ObjectStackClient.auth.changePassword": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", "ObjectStackClient.auth.setInitialPassword": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise`. Invisible to every grep #8140's census and #11925 used: the method names neither `any` nor `Promise` nor `unwrapResponse`. Bind the contract the route actually answers, minding the envelope.", diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index d38d5db31d..6a411c67af 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3224,29 +3224,60 @@ export class ObjectStackClient { * Tokens and consents referencing the client cascade-delete via the * better-auth schema's `onDelete: cascade` foreign keys. * - * ⚠️ NOT YET BOUND, and deliberately so — this is the one method of the - * `oauth.*` family that #14312 left at `Promise`, with its - * `exported-any-returns.json` entry still open. + * ## Why this method does not call `res.json()` (#15451) * - * Measured against a real server: the route answers **HTTP 200 with a - * ZERO-BYTE body** (its handler returns nothing; the provider declares - * it `void`) under a `content-type: application/json` header. So the - * `res.json()` below rejects with `SyntaxError: Unexpected end of JSON - * input` on every successful delete — the delete itself has already - * committed server-side by then. + * Measured against a real server — real `betterAuth` + real + * `oauthProvider` over the real ObjectQL adapter, driven through this + * very client with only the socket stood in for: * - * No declared return type can be honest while that call stands: any - * annotation here would promise a value this method never resolves. - * Binding it therefore needs a behaviour change, which is a decision - * beyond the type-narrowing this family was scoped to — see #14312. + * POST /oauth2/delete-client -> 200 · 0 bytes + * content-type: application/json + * content-length: (absent) + * + * The handler returns nothing and the vendor declares the endpoint + * `void` (`StrictEndpoint<'/oauth2/delete-client', …, void>`). A + * `res.json()` on that body therefore rejected with `SyntaxError: + * Unexpected end of JSON input` on EVERY successful delete, while the + * row was already gone server-side — so the method had no success path + * a caller could observe, and the obvious recovery (retry) failed + * DIFFERENTLY, with the route's 404 `not_found`. + * + * ⛔ Emptiness is detected by READING the body, not from the status and + * not from `content-length`. Both were measured and both are unusable + * here: the status is `200`, not the `204` the `{ deleted: true }` + * shortcut elsewhere in this file keys off, and the response carries NO + * `content-length` header at all. Only the body itself answers. + * + * ⛔ The parse below is NOT decoration and must not be deleted as dead + * code on the grounds that nothing reads its value. It is what keeps + * this method LOUD on a malformed non-empty body: a body that is + * present but unparseable still rejects exactly as it did before, so + * the ONLY behaviour this method changed is the zero-byte case — the + * defect itself. Pinned by `oauth-applications-delete.test.ts`. + * + * ## Why `void`, and not `{ deleted: boolean }` + * + * "Deleted" and "was already gone" are distinguished by the route, but + * on the ERROR channel, not in the success value: a client that is not + * there answers 404 `{ error: 'not_found' }`, which `this.fetch` has + * already turned into a throw before this line runs. The 200 answer + * carries zero bytes and therefore zero information, so a synthesised + * `{ deleted: true }` would be a value the wire cannot support and + * strictly less informative than the 404 the caller already gets. */ - delete: async (clientId: string) => { + delete: async (clientId: string): Promise => { const route = this.getRoute('auth'); const res = await this.fetch(`${this.baseUrl}${route}/oauth2/delete-client`, { method: 'POST', body: JSON.stringify({ client_id: clientId }), }); - return res.json(); + const body = await res.text(); + if (body === '') return; + // Present but unread: validated so a malformed body still speaks, and + // discarded because the declared contract is `void`. The day this + // route starts answering a payload, widening the return type is a + // deliberate, reviewable edit here — never a silent change of shape. + JSON.parse(body); }, }, diff --git a/packages/client/src/oauth-applications-delete.test.ts b/packages/client/src/oauth-applications-delete.test.ts new file mode 100644 index 0000000000..0587ef8848 --- /dev/null +++ b/packages/client/src/oauth-applications-delete.test.ts @@ -0,0 +1,131 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#15451] `oauth.applications.delete` must RESOLVE on the zero-byte 200 its + * route actually answers — and must still reject, loudly, on anything else. + * + * ## Why this file exists at all, when its sibling is type-level + * + * `return-type-precision.test.ts` says in its own header that a runtime test + * cannot observe a return-type narrowing: the value is identical either way. + * The reverse is true here and is the whole point. This card did not narrow a + * declaration — it changed what the method DOES. Before it, the method called + * `res.json()` on a body of zero bytes and REJECTED with `SyntaxError: + * Unexpected end of JSON input` on every successful delete; after it, the + * same call resolves. No compile-time assertion can see a reject/resolve + * flip, so the two files pin the two halves and neither is redundant. + * + * ## The wire fact these fixtures encode, measured not assumed + * + * Real `betterAuth` + real `@better-auth/oauth-provider` over the real + * ObjectQL adapter, driven through the real `ObjectStackClient` with only the + * socket stood in for: + * + * POST /api/v1/auth/oauth2/delete-client + * -> 200 · 0 bytes · content-type: application/json · NO content-length + * + * Both shortcuts a reader will reach for were measured and both are unusable, + * which is why the fix reads the body instead: + * + * - `res.status === 204` — the spelling five other delete surfaces in + * `index.ts` use. The status here is **200**, so it never fires. + * - `content-length === '0'` — the header is **absent**, not zero, so a + * header test never fires either and would leave the defect in place + * while looking like a fix. + * + * ## ⛔ The malformed-body case is load-bearing, not leftover + * + * The implementation still runs `JSON.parse` on a NON-EMPTY body and throws + * the result away. That reads like dead code and is not: it is what keeps a + * malformed response loud, so the ONLY behaviour the card changed is the + * zero-byte case — the defect itself. Delete the parse "because nothing reads + * it" and `expect(...).rejects` below goes red, by design. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectStackClient } from './index'; + +const BASE = 'http://localhost:3000'; +const DELETE_URL = `${BASE}/api/v1/auth/oauth2/delete-client`; + +/** + * A client whose transport answers with a REAL `Response`. Deliberately not a + * hand-rolled double with a stubbed `json()`: the defect lived in how a real + * `Response` behaves when its body is empty, and a double that answers + * `json: async () => undefined` cannot reproduce it — it would have been + * green against the broken client too. + */ +function clientAnswering(body: BodyInit | null, init?: ResponseInit) { + const fetchMock = vi.fn(async () => new Response(body, init)); + const client = new ObjectStackClient({ baseUrl: BASE, fetch: fetchMock as never }); + return { client, fetchMock }; +} + +/** The exact answer the route was measured to send on a successful delete. */ +const ZERO_BYTE_200: [BodyInit | null, ResponseInit] = [ + null, + { status: 200, headers: { 'content-type': 'application/json' } }, +]; + +describe('#15451 oauth.applications.delete — the zero-byte 200', () => { + it('RESOLVES on the 200 / zero-byte answer the route actually sends', async () => { + const { client } = clientAnswering(...ZERO_BYTE_200); + // ⚠️ RED BEFORE: this rejected with `SyntaxError: Unexpected end of JSON + // input`, on the successful path, every single time. + await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined(); + }); + + it('resolves on an empty-STRING body too — the same zero bytes, spelled differently', async () => { + const { client } = clientAnswering('', { status: 200 }); + await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined(); + }); + + it('sends the same request bytes as before — only the RESPONSE handling moved', async () => { + const { client, fetchMock } = clientAnswering(...ZERO_BYTE_200); + await client.oauth.applications.delete('c_1'); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(DELETE_URL); + expect(init.method).toBe('POST'); + expect(init.body).toBe(JSON.stringify({ client_id: 'c_1' })); + }); + + it('⛔ still REJECTS on a malformed non-empty body — the parse is not decoration', async () => { + const { client } = clientAnswering('{ not json', { status: 200 }); + // Green in BOTH states, and recorded as such: it is here to go RED if + // someone removes the `JSON.parse` as unused, which would trade this + // card's loud bug for a quiet one. + await expect(client.oauth.applications.delete('c_1')).rejects.toThrow(SyntaxError); + }); + + it('rejects on a whitespace-only body — the boundary is EXACTLY zero bytes', async () => { + const { client } = clientAnswering('\n', { status: 200 }); + // Stated rather than left to drift: the tolerated case is the empty body + // the route sends, not "anything that looks blank". A body that is + // present but not JSON is a malformed response and says so. + await expect(client.oauth.applications.delete('c_1')).rejects.toThrow(SyntaxError); + }); + + it('resolves and DISCARDS a well-formed body, should the route ever grow one', async () => { + const { client } = clientAnswering(JSON.stringify({ deleted: true }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + // The declared contract is `void`. A payload arriving here is validated + // and dropped; surfacing it is a deliberate widening of the return type, + // never a silent change of shape under an unchanged declaration. + await expect(client.oauth.applications.delete('c_1')).resolves.toBeUndefined(); + }); + + it('"already gone" still arrives as a THROW, which is what makes `void` honest', async () => { + // The route distinguishes deleted from already-gone on the ERROR channel: + // a missing client answers 404 `{ error: 'not_found' }`. `this.fetch` + // raises that before any success value exists, so the success answer has + // no information left to carry and `{ deleted: true }` would be invented. + const { client } = clientAnswering( + JSON.stringify({ error_description: 'client not found', error: 'not_found' }), + { status: 404, headers: { 'content-type': 'application/json' } }, + ); + await expect(client.oauth.applications.delete('gone')).rejects.toThrow(/not_found/); + }); +}); diff --git a/packages/client/src/return-type-precision.test.ts b/packages/client/src/return-type-precision.test.ts index d9ffcc6807..132f2e128c 100644 --- a/packages/client/src/return-type-precision.test.ts +++ b/packages/client/src/return-type-precision.test.ts @@ -531,8 +531,11 @@ export async function returnTypePrecisionPins12104(): Promise { /** * [#14312 — the `oauth.*` family, card 1 of 3 of #12104] The five better-auth - * -backed methods #12104 deliberately left alone. FOUR are bound here; the - * fifth is named below and is still `Promise< any >` on purpose. + * -backed methods #12104 deliberately left alone. FOUR are bound here. The + * fifth — `oauth.applications.delete` — was left at `Promise< any >` by this + * card on purpose and was bound afterwards by #15451; its pins live in + * `returnTypePrecisionPins15451` below, and the section further down records + * why it could not be bound here. * * ## These shapes were read off the WIRE, not off better-auth's `.d.ts` * @@ -558,14 +561,15 @@ export async function returnTypePrecisionPins12104(): Promise { * pins hold it to. The ruling's PROHIBITIONS still bind and are satisfied * here: no `Date` is declared and no revival layer exists. * - * ## `oauth.applications.delete` is NOT bound, and that is the finding + * ## `oauth.applications.delete` was NOT bound here, and that was the finding * * Its route answers HTTP 200 with a ZERO-BYTE body, so the method's - * `res.json()` rejects with a `SyntaxError` on every successful delete. No - * annotation can be honest while that stands — binding it needs a behaviour - * change, which is a decision beyond this family's type-narrowing scope. Its - * `exported-any-returns.json` entry therefore stays open, which is exactly - * what the shrink-only ledger is for. + * `res.json()` rejected with a `SyntaxError` on every successful delete. No + * annotation could be honest while that stood — binding it needed a behaviour + * change, which was a decision beyond this family's type-narrowing scope, so + * its `exported-any-returns.json` entry stayed open. That is what the + * shrink-only ledger is for, and #15451 is the card that collected the debt: + * the entry is gone and the binding is pinned below. * * Type-level for the reason this file's header gives: only a compile-time * assertion can observe a return-type change. @@ -606,13 +610,62 @@ export async function returnTypePrecisionPins14312(): Promise { // @ts-expect-error these routes are served BARE by better-auth — there is no `{ success, data }` envelope void (await client.oauth.applications.get('c_1')).data; - // ── the method deliberately left open ──────────────────────────────── - // `delete` still resolves to `any`, so `.anythingAtAll` compiles. Pinned - // as an EQUALITY rather than a suppression: a suppression would go unused - // the moment someone bound it and would read as "binding this is a - // regression", which is the opposite of the truth. When the open decision - // on #14312 lands, this line is the one that must be replaced. - expectTypeOf(await client.oauth.applications.delete('c_1')).toEqualTypeOf(); + // ── the method this card deliberately left open ────────────────────── + // `delete` used to be pinned here as `toEqualTypeOf< any >`, with the note + // that the line would have to be replaced when #14312's open decision + // landed. It landed as #15451, and the replacement is a whole function of + // its own rather than a rewritten line, because binding this method was + // not a narrowing — see `returnTypePrecisionPins15451`. +} + +/** + * [#15451] `oauth.applications.delete` — the fifth member of the `oauth.*` + * family, and the one #14312 could not reach. + * + * ## This is NOT the narrowing its four siblings were + * + * The other four moved a DECLARATION onto a shape their route already + * answered; no byte of their behaviour changed. This one could not: while + * `return res.json()` stood, the method REJECTED on every successful delete, + * so no declared return type could be true — a `Promise< void >` here would + * have promised a resolution that never happened. Binding it meant changing + * what the method DOES, and that is why it took its own card. + * + * ## Measured, then declared + * + * Real `betterAuth` + real `oauthProvider` over the real ObjectQL adapter, + * driven through the real client with only the socket stood in for: + * + * POST /oauth2/delete-client -> 200 · 0 bytes · no content-length header + * through the client (before) -> REJECTED: SyntaxError + * the row, server-side (after) -> ALREADY GONE (get-client answers 404) + * + * `void` is the wire fact. "Deleted" and "was already gone" ARE distinguished + * by the route, but on the error channel — a missing client answers 404 + * `not_found`, which `this.fetch` raises before any success value exists — so + * a synthesised `{ deleted: true }` would carry no information the caller + * does not already have, and would not be a shape the wire ever sends. + * + * Type-level for the reason this file's header gives; the RUNTIME half — that + * the method resolves on a zero-byte 200 and still rejects on a malformed + * non-empty body — is pinned in `oauth-applications-delete.test.ts`, because + * a type-level assertion cannot observe a reject/resolve flip. + */ +export async function returnTypePrecisionPins15451(): Promise { + // ⚠️ RED BEFORE, as an EQUALITY: the method resolved to `any`, and `any` + // is not equal to `void` under vitest's branded equality. + expectTypeOf(await client.oauth.applications.delete('c_1')).toEqualTypeOf(); + + // ── direction 2: the reads `any` used to admit are now refused ─────── + // While the method returned `any` every suppression below was unused + // (TS2578) and this file did not build — which is what makes them + // evidence of the binding rather than decoration. + // @ts-expect-error the route answers zero bytes; there is no value to read a property off + void (await client.oauth.applications.delete('c_1')).anythingAtAll; + // @ts-expect-error in particular there is no `{ deleted: boolean }` receipt — that shape belongs to OTHER delete surfaces in this client + void (await client.oauth.applications.delete('c_1')).deleted; + // @ts-expect-error nor is the deleted application echoed back + void (await client.oauth.applications.delete('c_1')).client_id; } /** @@ -774,6 +827,7 @@ describe('client SDK return-type precision (#8140)', () => { expect(typeof returnTypePrecisionPins12034).toBe('function'); expect(typeof returnTypePrecisionPins12104).toBe('function'); expect(typeof returnTypePrecisionPins14312).toBe('function'); + expect(typeof returnTypePrecisionPins15451).toBe('function'); expect(typeof returnTypePrecisionPins13023).toBe('function'); expect(typeof deleteDataResponseIsNotTheMetaResetShape).toBe('function'); expect(typeof metaResetResponseDeclaresTheWireReceipt).toBe('function'); From c094e3ff4fa1cb6770440dab980a514c0a05d76a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 02:31:42 +0000 Subject: [PATCH 2/2] chore(changeset): declare the BREAKING binding of `oauth.applications.delete` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `**BREAKING**` on two independent axes: the declared return moves off an erased `any` onto `void` (a compile break, though only for reads of a value the promise never produced), and the runtime flips from always-rejecting to resolving, so a caller's `catch` stops firing on success. ⚠️ The ADR-0087 disposition is NOT claimed, and the gate is expected to red on this changeset until a maintainer settles it. Both legs were measured rather than guessed: type-surface-only REFUSED at predicate 4. A reference is a bare identifier resolved to the FIRST same-named definition, and index.ts declares TEN members named `delete`; the first (line 2397) is unannotated at both revs, so the gate reports "still UNANNOTATED" about a member this diff never touched. Issue #15627, filed off PR #15445 where the same ambiguity cost the `get` member its place in the marker — here it blocks the only member there is. no-migration-prescription mechanically ACCEPTED, and deliberately not taken. ADR-0087's D7 records that #8277 held this exemption on a detector MISS rather than a positive finding, and names that as the pattern the sixth category exists to stop. Taking it here, with the measurement in hand, would repeat it knowingly. Dropping the `**BREAKING**` token is the third exit and ADR-0087's addendum closes it for this class in as many words. So the honest state is a loud red on one gate with the reasoning written down, rather than a green held by a category that does not describe this diff. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../client-oauth-delete-zero-byte-200.md | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 .changeset/client-oauth-delete-zero-byte-200.md diff --git a/.changeset/client-oauth-delete-zero-byte-200.md b/.changeset/client-oauth-delete-zero-byte-200.md new file mode 100644 index 0000000000..c95f8cd5fb --- /dev/null +++ b/.changeset/client-oauth-delete-zero-byte-200.md @@ -0,0 +1,70 @@ +--- +"@objectstack/client": minor +--- + +fix(client)!: `oauth.applications.delete` resolves on the zero-byte 200 its route answers, instead of rejecting on every successful delete (#15451) + +**BREAKING** on two independent axes, and it makes a published method usable for the first time. Before this change `client.oauth.applications.delete(id)` **rejected on every successful delete** — there was no success path a caller could observe. It ships as `minor` under the lockstep launch-window convention (`scripts/check-changeset-no-major.mjs`); the version number is not the migration signal here, this entry is. + + + +The fifth and last method of the `oauth.*` family, and the one #14312 / PR #15445 deliberately could not close: its ruling fenced that card to *narrowing published return types*, and no declared return type could be true while the `res.json()` call stood. + +## The defect, measured end to end + +Real `betterAuth` + real `@better-auth/oauth-provider` over the real ObjectQL adapter on real SQLite, a real signed-up user and a real session, driven through the **real** `ObjectStackClient` with only the socket stood in for: + +``` +POST /api/v1/auth/oauth2/delete-client -> 200 · 0 bytes + content-type: application/json + content-length: (absent) +through the client, BEFORE -> REJECTED: SyntaxError | Unexpected end of JSON input +the row, server-side -> ALREADY GONE (get-client answers 404 not_found) +through the client, AFTER -> RESOLVED | undefined +``` + +The handler returns nothing and the vendor declares the endpoint `void`. `res.json()` had nothing to parse, so the method rejected — *after* the delete had committed. A caller who did the obvious thing saw a failure, retried, and the retry failed **differently**, because the row no longer existed. + +## What changes for a caller + +| | before | now | +|:--|:--|:--| +| a successful delete | rejects `SyntaxError` | resolves | +| the resolved value | `any` (unreachable — the promise never resolved) | `void` | +| deleting a client that is not there | rejects `not_found` | rejects `not_found` — unchanged | +| a malformed non-empty body | rejects `SyntaxError` | rejects `SyntaxError` — unchanged | + +⚠️ **The `catch` you wrote around this call stops firing on success.** Code shaped like + +```ts +try { await client.oauth.applications.delete(id); } +catch { /* the delete probably worked anyway */ } +``` + +still compiles and still runs, but its catch block was executing on **every** successful delete and now executes only on a real failure. Any workaround that lived in there is now inert and can be deleted. And because the promise never used to resolve, a read off its resolved value — `(await …delete(id)).deleted` — was dead code that has never executed; it now stops compiling (TS2339), which is the compiler delivering the change at the call site. + +## Why `void`, and not `{ deleted: boolean }` + +"Deleted" and "was already gone" **are** distinguished by the route, but on the error channel: a missing client answers 404 `{ error: 'not_found' }`, which the client already raises as a throw. The 200 answer carries zero bytes and therefore zero information, so a synthesised `{ deleted: true }` would be a shape the wire never sends and strictly less informative than the 404 a caller already receives. + +## Why the emptiness is detected by reading the body + +Both shortcuts were measured against the real route and both are unusable: the status is **200**, not the `204` five other delete surfaces in this client key off, and the response carries **no `content-length` header at all** — so a header test would never fire and would leave the defect in place while looking like a fix. The body itself is the only thing that answers. + +A non-empty body is still parsed and its failure still thrown, so **the only behaviour this change moves is the zero-byte case**: a malformed response stays loud, and the day this route grows a payload, surfacing it is a deliberate widening of the return type rather than a silent change of shape. + +`packages/client/exported-any-returns.json` loses this method's entry in the same change — the ledger is shrink-only, so the entry goes **with** the binding. Its last `oauth.*` entry is now gone; 35 sites remain open.