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..0b9eed9d49 --- /dev/null +++ b/.changeset/client-oauth-delete-zero-byte-200.md @@ -0,0 +1,56 @@ +--- +"@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. 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'); diff --git a/packages/spec/src/migrations/entries/semantic/18.client-oauth-applications-delete-void.ts b/packages/spec/src/migrations/entries/semantic/18.client-oauth-applications-delete-void.ts new file mode 100644 index 0000000000..a8d3c120bd --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.client-oauth-applications-delete-void.ts @@ -0,0 +1,113 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// Anchors for this entry, kept in source rather than in the strings below: the +// entry's prose is projected into `packages/spec/spec-changes.json` and +// `docs/protocol-upgrade-guide.md`, which are read by consumers who cannot +// resolve this repo's internal issue numbers. +// +// card objectstack-ai/objectstack#15451 +// landing PR objectstack-ai/objectstack#15675 +// disposition objectstack-ai/objectstack#15674 (ruled D, 2026-09-05: this +// class routes through ADR-0087 `registered`) +// precedents objectstack-ai/objectstack#13023, #13079 (the three sibling +// entries this is shaped on: `client-delete-result-success`, +// `client-meta-reset-result-reset`, +// `client-envelope-convergence-analytics-automation`) +// family objectstack-ai/objectstack#14312 (the `oauth.*` binding card +// whose ruling fenced this method out, PR #15445) +// pins `packages/client/src/oauth-applications-delete.test.ts`, +// `packages/client/src/return-type-precision.test.ts` +// vendor `StrictEndpoint<'/oauth2/delete-client', ..., void>` in +// `@better-auth/oauth-provider` + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'client-oauth-applications-delete-void', + surface: + 'client.oauth.applications.delete(clientId) — both halves of what a caller of this ' + + 'published `@objectstack/client` method observes: the DECLARED return, ' + + '`Promise` before and `Promise` after, and the SETTLE BEHAVIOUR, which ' + + 'rejected with `SyntaxError: Unexpected end of JSON input` on every successful ' + + 'delete before and resolves after', + replacement: + 'no value — `void`. There is nothing to move a read TO, because the promise never ' + + 'resolved for a caller to read anything off it. The migration is on the settle ' + + 'path instead: `try { await client.oauth.applications.delete(id); } catch { ' + + '/* it probably worked */ }` → drop the workaround, the `catch` was executing on ' + + 'EVERY successful delete and now executes only on a real failure. A read off the ' + + 'resolved value — `(await client.oauth.applications.delete(id)).deleted` — was ' + + 'unreachable code that has never executed and now stops compiling (TS2339). Same ' + + 'call, same request, same wire body', + reason: + 'The route answers HTTP 200 with a ZERO-BYTE body: `POST {auth}/oauth2/delete-client` ' + + 'returns nothing from its handler, the vendor declares the endpoint `void`, and the ' + + 'response carries `content-type: application/json` with NO `content-length` header ' + + 'at all. The method ended `return res.json()`, so it rejected `SyntaxError: ' + + 'Unexpected end of JSON input` on every successful delete — after the row had ' + + 'already been removed server-side. There was no success path a caller could ' + + 'observe, and the obvious recovery made it worse: the retry failed DIFFERENTLY, ' + + 'with the route\'s 404 `not_found`, because the client was already gone. The method ' + + 'now reads the body as text, returns on the empty case, and still parses (and still ' + + 'throws on) a non-empty one — so the ONLY behaviour that moved is the zero-byte ' + + 'case, which is the defect itself. THE WIRE IS BYTE-IDENTICAL: same route, same ' + + 'request body, same status codes, same error bodies — which on this route are ' + + 'better-auth\'s FLAT `{ error, error_description }`, NOT ObjectStack\'s nested ' + + 'ADR-0112 envelope; no Zod schema and ' + + 'no `packages/spec` declaration moves, no authorable key and no stored ' + + 'representation is involved, so a raw-HTTP caller is unaffected and ' + + '`objectstack migrate meta` has nothing to rewrite. This is registered rather than ' + + 'exempted because the change is NOT compiler-delivered where it matters, and the ' + + 'gap is exact rather than theoretical. The change has two halves and only one of ' + + 'them has a diagnostic. (1) The declared return moves from a ledgered `any` to ' + + '`void`, so a typed caller that read a property off the resolved value now gets ' + + '`error TS2339` — but that read was UNREACHABLE, since the promise never resolved, ' + + 'so the compiler names only code that has never run. (2) The half that DID run on ' + + 'every call — a `try`/`catch` wrapped around the delete — compiles identically ' + + 'before and after, with no diagnostic anywhere, while its `catch` block stops ' + + 'executing. So for the only behaviour that was ever observable, `tsc` names ZERO ' + + 'sites; and for an untyped JS caller there is no constrained channel at all. That ' + + 'is why the ledger entry is the only notification that reaches an upgrader — the ' + + 'same argument the three sibling entries on this package make ' + + '(`client-delete-result-success`, `client-meta-reset-result-reset`, ' + + '`client-envelope-convergence-analytics-automation`). ⚠️ Note the DIRECTION, which ' + + 'is the inverse of the usual break: this does not stop working code from working, ' + + 'it makes a method that could never succeed succeed. The hazard is therefore ' + + 'inverted too — code written to survive a permanent failure is now inert, and any ' + + 'alerting or error budget fed by this method\'s rejections goes quiet. ⛔ Do not ' + + 'keep the old behaviour behind a flag or a wrapper that re-throws: there is one ' + + 'producer shape, and the rejection was never a contract, it was a parse of an empty ' + + 'string. ⛔ Do not synthesise `{ deleted: true }` either: the 200 carries zero bytes ' + + 'and therefore zero information, and "it was already gone" is distinguished on the ' + + 'ERROR channel — a client that is not there answers 404 `{ error: \'not_found\' }`, ' + + 'which `ObjectStackClient.fetch` raises as a throw before the body reader runs — so ' + + 'a synthesised success value would be a shape the wire never sends and strictly ' + + 'less informative than the 404 the caller already receives. ADR-0087 D3.', + acceptanceCriteria: + '⚠️ The real work is behavioural and NOTHING will report it: every `try`/`catch` ' + + 'wrapped around `client.oauth.applications.delete()` has to be re-read one by one, ' + + 'because it compiles identically before and after while its `catch` block goes from ' + + 'running on every successful delete to running only on a real failure. Anything ' + + 'that block did — treating the delete as failed, retrying it (the retry answered ' + + '404 `not_found`, which may itself have been swallowed), skipping post-delete ' + + 'cleanup, cache invalidation, audit writes or a UI refresh, or reporting the delete ' + + 'to a user as failed — is now on the other branch, and the cleanup paths that were ' + + 'skipped run for the first time. Verify that is what you want rather than assuming ' + + 'it restores prior behaviour. Alerting, error budgets and dashboards fed by ' + + '`SyntaxError` rejections from this method drop to zero: that is the fix landing, ' + + 'not an outage. Any test that passed while asserting this call rejects on a ' + + 'successful delete was asserting on the defect and needs rewriting, not renaming. ' + + 'On the type side, no code reads a property off the resolved value; `tsc` names ' + + 'those sites for a typed caller (TS2339), but every one of them was unreachable, so ' + + 'a clean type-check is NOT evidence that the sweep above was done. An untyped JS ' + + 'caller gets no report at all. Nothing about the request, the route, the status ' + + 'codes or the thrown error shapes changes and no server needs upgrading — the ' + + 'server has always answered this way; only the client stopped mis-reading it. ' + + 'Populations, measured at this landing: in the ObjectStack repo, ZERO production ' + + 'call sites — the only references are the pins that ship with this change ' + + '(`oauth-applications-delete.test.ts`, `return-type-precision.test.ts`); in ' + + 'objectui at the pinned `.objectui-sha`, ZERO — neither `oauth.applications` nor ' + + '`delete-client` appears anywhere in that tree; `objectstack-ai/cloud` is NOT ' + + 'MEASURED, and a `catch` there that swallowed this method\'s rejection is now dead ' + + 'code that this entry is the only notice of.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 9e3c0dfa05..8ff4187f35 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6151,6 +6151,95 @@ const step18: MigrationStep = { + 'that passed while asserting on `deleted` was asserting on `undefined` and needs ' + 'rewriting, not renaming.', }, + { + id: 'client-oauth-applications-delete-void', + surface: + 'client.oauth.applications.delete(clientId) — both halves of what a caller of this ' + + 'published `@objectstack/client` method observes: the DECLARED return, ' + + '`Promise` before and `Promise` after, and the SETTLE BEHAVIOUR, which ' + + 'rejected with `SyntaxError: Unexpected end of JSON input` on every successful ' + + 'delete before and resolves after', + replacement: + 'no value — `void`. There is nothing to move a read TO, because the promise never ' + + 'resolved for a caller to read anything off it. The migration is on the settle ' + + 'path instead: `try { await client.oauth.applications.delete(id); } catch { ' + + '/* it probably worked */ }` → drop the workaround, the `catch` was executing on ' + + 'EVERY successful delete and now executes only on a real failure. A read off the ' + + 'resolved value — `(await client.oauth.applications.delete(id)).deleted` — was ' + + 'unreachable code that has never executed and now stops compiling (TS2339). Same ' + + 'call, same request, same wire body', + reason: + 'The route answers HTTP 200 with a ZERO-BYTE body: `POST {auth}/oauth2/delete-client` ' + + 'returns nothing from its handler, the vendor declares the endpoint `void`, and the ' + + 'response carries `content-type: application/json` with NO `content-length` header ' + + 'at all. The method ended `return res.json()`, so it rejected `SyntaxError: ' + + 'Unexpected end of JSON input` on every successful delete — after the row had ' + + 'already been removed server-side. There was no success path a caller could ' + + 'observe, and the obvious recovery made it worse: the retry failed DIFFERENTLY, ' + + 'with the route\'s 404 `not_found`, because the client was already gone. The method ' + + 'now reads the body as text, returns on the empty case, and still parses (and still ' + + 'throws on) a non-empty one — so the ONLY behaviour that moved is the zero-byte ' + + 'case, which is the defect itself. THE WIRE IS BYTE-IDENTICAL: same route, same ' + + 'request body, same status codes, same error bodies — which on this route are ' + + 'better-auth\'s FLAT `{ error, error_description }`, NOT ObjectStack\'s nested ' + + 'ADR-0112 envelope; no Zod schema and ' + + 'no `packages/spec` declaration moves, no authorable key and no stored ' + + 'representation is involved, so a raw-HTTP caller is unaffected and ' + + '`objectstack migrate meta` has nothing to rewrite. This is registered rather than ' + + 'exempted because the change is NOT compiler-delivered where it matters, and the ' + + 'gap is exact rather than theoretical. The change has two halves and only one of ' + + 'them has a diagnostic. (1) The declared return moves from a ledgered `any` to ' + + '`void`, so a typed caller that read a property off the resolved value now gets ' + + '`error TS2339` — but that read was UNREACHABLE, since the promise never resolved, ' + + 'so the compiler names only code that has never run. (2) The half that DID run on ' + + 'every call — a `try`/`catch` wrapped around the delete — compiles identically ' + + 'before and after, with no diagnostic anywhere, while its `catch` block stops ' + + 'executing. So for the only behaviour that was ever observable, `tsc` names ZERO ' + + 'sites; and for an untyped JS caller there is no constrained channel at all. That ' + + 'is why the ledger entry is the only notification that reaches an upgrader — the ' + + 'same argument the three sibling entries on this package make ' + + '(`client-delete-result-success`, `client-meta-reset-result-reset`, ' + + '`client-envelope-convergence-analytics-automation`). ⚠️ Note the DIRECTION, which ' + + 'is the inverse of the usual break: this does not stop working code from working, ' + + 'it makes a method that could never succeed succeed. The hazard is therefore ' + + 'inverted too — code written to survive a permanent failure is now inert, and any ' + + 'alerting or error budget fed by this method\'s rejections goes quiet. ⛔ Do not ' + + 'keep the old behaviour behind a flag or a wrapper that re-throws: there is one ' + + 'producer shape, and the rejection was never a contract, it was a parse of an empty ' + + 'string. ⛔ Do not synthesise `{ deleted: true }` either: the 200 carries zero bytes ' + + 'and therefore zero information, and "it was already gone" is distinguished on the ' + + 'ERROR channel — a client that is not there answers 404 `{ error: \'not_found\' }`, ' + + 'which `ObjectStackClient.fetch` raises as a throw before the body reader runs — so ' + + 'a synthesised success value would be a shape the wire never sends and strictly ' + + 'less informative than the 404 the caller already receives. ADR-0087 D3.', + acceptanceCriteria: + '⚠️ The real work is behavioural and NOTHING will report it: every `try`/`catch` ' + + 'wrapped around `client.oauth.applications.delete()` has to be re-read one by one, ' + + 'because it compiles identically before and after while its `catch` block goes from ' + + 'running on every successful delete to running only on a real failure. Anything ' + + 'that block did — treating the delete as failed, retrying it (the retry answered ' + + '404 `not_found`, which may itself have been swallowed), skipping post-delete ' + + 'cleanup, cache invalidation, audit writes or a UI refresh, or reporting the delete ' + + 'to a user as failed — is now on the other branch, and the cleanup paths that were ' + + 'skipped run for the first time. Verify that is what you want rather than assuming ' + + 'it restores prior behaviour. Alerting, error budgets and dashboards fed by ' + + '`SyntaxError` rejections from this method drop to zero: that is the fix landing, ' + + 'not an outage. Any test that passed while asserting this call rejects on a ' + + 'successful delete was asserting on the defect and needs rewriting, not renaming. ' + + 'On the type side, no code reads a property off the resolved value; `tsc` names ' + + 'those sites for a typed caller (TS2339), but every one of them was unreachable, so ' + + 'a clean type-check is NOT evidence that the sweep above was done. An untyped JS ' + + 'caller gets no report at all. Nothing about the request, the route, the status ' + + 'codes or the thrown error shapes changes and no server needs upgrading — the ' + + 'server has always answered this way; only the client stopped mis-reading it. ' + + 'Populations, measured at this landing: in the ObjectStack repo, ZERO production ' + + 'call sites — the only references are the pins that ship with this change ' + + '(`oauth-applications-delete.test.ts`, `return-type-precision.test.ts`); in ' + + 'objectui at the pinned `.objectui-sha`, ZERO — neither `oauth.applications` nor ' + + '`delete-client` appears anywhere in that tree; `objectstack-ai/cloud` is NOT ' + + 'MEASURED, and a `catch` there that swallowed this method\'s rejection is now dead ' + + 'code that this entry is the only notice of.', + }, { id: 'cluster-driver-dangling-values-removed', surface: 'kernel.cluster.driver (ClusterDriverSchema, kernel/cluster.zod.ts) '