Skip to content

Commit 7092d63

Browse files
claude[bot]os-salesclaude
authored
fix(client)!: bind the organizations.* family to the wire shapes better-auth sends (#16576)
* fix(client)!: bind the organizations.* family to the wire shapes better-auth sends Nineteen methods of the organizations.* namespace ended `return res.json()` with no return annotation, so lib.dom's `Response.json(): Promise<any>` was their published type. Each now declares the shape its route actually serves, and its exported-any-returns.json entry is deleted in the same change — as is the entry for organizations.invitations.resend, which carries no annotation of its own and inherits invite's (22 entries before, 2 after): organizations.create -> OrganizationCreateResult organizations.update -> OrganizationEchoWire organizations.setActive -> OrganizationWire | null organizations.get -> OrganizationFullWire | null organizations.listMembers -> OrganizationMembersPage organizations.invite -> OrganizationInvitationWire<'pending'> organizations.leave -> OrganizationMemberWithUserWire organizations.delete -> OrganizationWire organizations.removeMember -> OrganizationRemoveMemberResult organizations.updateMemberRole -> OrganizationMemberWire organizations.getActiveMember -> OrganizationMemberWithUserWire organizations.invitations.cancel -> OrganizationInvitationWire<'canceled'> organizations.invitations.accept -> OrganizationInvitationAcceptResult organizations.invitations.reject -> OrganizationInvitationRejectResult organizations.teams.create/update -> OrganizationTeamWire organizations.teams.delete -> OrganizationTeamRemovedReceipt organizations.teams.addMember -> OrganizationTeamMemberWire organizations.teams.removeMember -> OrganizationTeamMemberRemovedReceipt The shapes were read off the wire against a real server, not off better-auth's own .d.ts: a real AuthManager (better-auth 1.7.2, organization plugin, teams enabled) over a real SqlDriver (better-sqlite3), driven raw and again through the real ObjectStackClient with only the socket stood in for, plus an in-memory-engine leg for absent-vs-null. Four times the vendor's declaration was the wrong answer: delete answers the organization row, not the id string its OpenAPI stub declares; updateMemberRole answers the member bare, not `{ member }`; metadata is decoded on create/update only and is the stored JSON text on every read route; removeMember joins `user` only on the by-email path. Timestamps are ISO-8601 strings, never Date and never revived (maintainer ruling on the family card): the adapter runs `supportsDates: false` and JSON.stringify puts the ISO string back on the wire. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ * chore(changeset): name only the type-surface-only refs the ADR-0087 dotted walker can resolve Five direct members of organizations (create, update, listMembers, delete, removeMember) share a name with a member of a nested literal, and the gate's walker counts nested same-named definitions inside the region, so those five have no resolvable spelling; they are stated in the marker's prose with the issue that records the walker limitation. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ * fix(client): correct two claims in the organizations.* binding — a compile-block line and the teams updatedAt guarantee The changeset's 'reads that stop compiling' block carried one line that does not compile: JSON.parse on the read-route metadata, whose declared type is string | null | undefined (TS2345). It now guards the value first, which is the honest spelling of 'the caller's step'. OrganizationTeamWire's JSDoc claimed teams.create and teams.update both set updatedAt explicitly. The vendor's update-team handler writes no timestamp; the value on the wire comes from better-auth's team schema, whose updatedAt declares an onUpdate default the adapter applies on every update of the model, with the platform's own sys_team.updated_at stamping behind it. Measured on a real SQL driver and on an engine with no platform stamping in the loop: update-team on the vendor-minted default team answered a fresh updatedAt on both. The type is unchanged; only the sentence moved. The sibling OrganizationFullTeamWire's JSDoc no longer presents a hand-rolled test fake's absent key as a measured store behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YFY46JydE1gMxQG1TqBcMZ --------- Co-authored-by: os-sales <sales@objectstack.ai> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent b4abb0a commit 7092d63

4 files changed

Lines changed: 518 additions & 41 deletions

File tree

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
"@objectstack/client": minor
3+
---
4+
5+
fix(client)!: the `organizations.*` family declares the wire shapes better-auth actually sends — nineteen published `Promise< any >` returns narrowed, twenty ledger entries closed (#14314)
6+
7+
**BREAKING** for a typed caller, and it breaks nothing that ever worked at runtime. No request bytes, no URL and no response handling change: this is a declaration catching up with what the routes have always answered. 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.
8+
9+
<!-- adr-0087: not-required (type-surface-only packages/client/src/index.ts#organizations.setActive, packages/client/src/index.ts#organizations.get, packages/client/src/index.ts#organizations.invite, packages/client/src/index.ts#organizations.leave, packages/client/src/index.ts#organizations.updateMemberRole, packages/client/src/index.ts#organizations.getActiveMember, packages/client/src/index.ts#organizations.invitations.cancel, packages/client/src/index.ts#organizations.invitations.accept, packages/client/src/index.ts#organizations.invitations.reject, packages/client/src/index.ts#organizations.teams.create, packages/client/src/index.ts#organizations.teams.update, packages/client/src/index.ts#organizations.teams.delete, packages/client/src/index.ts#organizations.teams.addMember, packages/client/src/index.ts#organizations.teams.removeMember) A published TYPE-SURFACE narrowing. Each of the nineteen members was UNANNOTATED at the merge base, so lib.dom's `Response.json()` published it as an erased `any`; each now declares the shape its route already answered, read off the wire against a real server. The fourteen refs above are the ones the dotted-path walker can resolve. The other FIVE members narrowed by this same change — organizations.create, organizations.update, organizations.listMembers, organizations.delete and organizations.removeMember — are direct members of `organizations` whose names recur inside its nested `teams` / `invitations` literals, and the walker counts nested same-named definitions inside the region, so they have no resolvable spelling today (filed as #16571); they carry the same annotation change, are pinned by the same type-level test and are judged by check:exported-any-returns on the built dist. No method body changed, so no request or response byte moves, and the diff touches no `packages/spec` path and no ADR-0087 shape surface. The affected party is a TypeScript consumer and the compiler delivers the break at their own call site; `objectstack migrate meta`, `spec-changes.json` and the upgrade guide have nothing to rewrite, so a ledger entry would be false data in the one ledger this gate keeps true. The twentieth ledger entry, `organizations.invitations.resend`, carries no annotation of its own and closes because it delegates to the now-bound `invite`; it is not named here because its site is unchanged. -->
10+
11+
Card 3 of 3 of the #12104 family, under the maintainer's 2026-08-31 ruling: the wire contract is the only source of truth, better-auth's own `Date`-typed fields are the pre-serialization SERVER shape, and every timestamp is declared as the ISO-8601 `string` the wire carries — no `Date`, no revival layer.
12+
13+
## What changed
14+
15+
Nineteen `organizations.*` methods ended `return res.json()` with no return annotation, so `lib.dom`'s `Response.json(): Promise< any >` was their published type. Each now declares the shape its route serves, and its `exported-any-returns.json` entry is deleted in the same change — together with the entry for `organizations.invitations.resend`, which has no annotation of its own and inherits `invite`'s (22 entries before, 2 after):
16+
17+
| method | resolved to (before) | resolves to (now) |
18+
|:--|:--|:--|
19+
| `client.organizations.create(req)` | `any` | `OrganizationCreateResult` |
20+
| `client.organizations.update(id, data)` | `any` | `OrganizationEchoWire` |
21+
| `client.organizations.setActive(id)` | `any` | `OrganizationWire \| null` |
22+
| `client.organizations.get(id)` | `any` | `OrganizationFullWire \| null` |
23+
| `client.organizations.listMembers(id)` | `any` | `OrganizationMembersPage` |
24+
| `client.organizations.invite(req)` | `any` | `OrganizationInvitationWire<'pending'>` |
25+
| `client.organizations.leave(id)` | `any` | `OrganizationMemberWithUserWire` |
26+
| `client.organizations.delete(id)` | `any` | `OrganizationWire` |
27+
| `client.organizations.removeMember(id, params)` | `any` | `OrganizationRemoveMemberResult` |
28+
| `client.organizations.updateMemberRole(id, params)` | `any` | `OrganizationMemberWire` |
29+
| `client.organizations.getActiveMember(id)` | `any` | `OrganizationMemberWithUserWire` |
30+
| `client.organizations.invitations.cancel(id)` | `any` | `OrganizationInvitationWire<'canceled'>` |
31+
| `client.organizations.invitations.accept(id)` | `any` | `OrganizationInvitationAcceptResult` |
32+
| `client.organizations.invitations.reject(id)` | `any` | `OrganizationInvitationRejectResult` |
33+
| `client.organizations.invitations.resend(inv)` | `any` (inherited) | `OrganizationInvitationWire<'pending'>` (inherited from `invite`) |
34+
| `client.organizations.teams.create(req)` | `any` | `OrganizationTeamWire` |
35+
| `client.organizations.teams.update(params)` | `any` | `OrganizationTeamWire` |
36+
| `client.organizations.teams.delete(params)` | `any` | `OrganizationTeamRemovedReceipt` |
37+
| `client.organizations.teams.addMember(params)` | `any` | `OrganizationTeamMemberWire` |
38+
| `client.organizations.teams.removeMember(params)` | `any` | `OrganizationTeamMemberRemovedReceipt` |
39+
40+
`OrganizationWire`, `OrganizationEchoWire`, `OrganizationCreateResult`, `OrganizationFullWire`, `OrganizationMemberWire`, `OrganizationMemberUserWire`, `OrganizationMemberWithUserWire`, `OrganizationMembersPage`, `OrganizationRemoveMemberResult`, `OrganizationInvitationWire`, `OrganizationInvitationAcceptResult`, `OrganizationInvitationRejectResult`, `OrganizationTeamWire`, `OrganizationFullTeamWire`, `OrganizationTeamMemberWire`, `OrganizationTeamRemovedReceipt` and `OrganizationTeamMemberRemovedReceipt` are newly exported from `@objectstack/client`. Every one of these routes is served BARE by better-auth (`auth-route-ledger.ts` records them `source: 'better-auth'`) — there is no `{ success, data }` envelope to unwrap and none is introduced. `@objectstack/spec/identity`'s `Organization` / `Member` / `Invitation` are deliberately NOT relayed: each declares `updatedAt` required, and the wire never carries it (the adapter's output transform walks better-auth's own schema, which has no such column); `InvitationStatus` IS relayed, narrowed to the literal each handler pins.
41+
42+
## The exact reads that stop compiling
43+
44+
Everything below compiled before only because `any` is assignable to, and indexable by, everything.
45+
46+
```ts
47+
const org = await client.organizations.setActive(id);
48+
org.id; // now TS18047 — `setActive` (and `get`) answer `null` for an empty id with no active organization
49+
if (org?.metadata) JSON.parse(org.metadata); // fine — on the READ routes `metadata` is the stored JSON text, `null`/absent when unset
50+
(await client.organizations.get(id))!.metadata.plan; // now TS2339 — it is a string here, not an object
51+
52+
const echo = await client.organizations.update(id, { metadata: { plan: 'pro' } });
53+
JSON.parse(echo.metadata); // now TS2345 — the two WRITE routes (`create`, `update`) echo `metadata` already decoded
54+
55+
const deleted = await client.organizations.delete(id);
56+
deleted.length; // now TS2339 — the route answers the organization ROW, not the id string the vendor's OpenAPI stub declares
57+
deleted.updatedAt; // now TS2339 — `sys_organization.updated_at` never reaches the wire
58+
deleted.createdAt.getTime(); // now TS2339 — ISO-8601 STRING, not a Date; `new Date(deleted.createdAt)` is the rewrite
59+
60+
const m = await client.organizations.updateMemberRole(id, { memberId, role: 'admin' });
61+
m.member.role; // now TS2339 — the row is answered BARE, not as `{ member }` (the vendor's stub is wrong)
62+
63+
const removed = await client.organizations.removeMember(id, { memberIdOrEmail });
64+
removed.member.user.email; // now TS18048 — `user` is joined on ONLY when the member was addressed by email
65+
66+
const inv = await client.organizations.invite({ email, organizationId: id });
67+
if (inv.status === 'accepted') { /* now TS2367 — `invite` answers the literal `'pending'` */ }
68+
69+
(await client.organizations.listMembers(id)).data; // now TS2339 — no envelope on any route of this family
70+
```
71+
72+
A caller that read `id`, `name`, `slug`, `role`, `email`, `members`, `total` or `message` off these values, or narrowed `null` where it can arrive, needs no change.
73+
74+
## Timestamps: ISO-8601 `string`, never `Date`
75+
76+
`createdAt` on every row type, `updatedAt` on teams and `expiresAt` on invitations are the vendor's `Date`-typed fields. The adapter is declared `supportsDates: false`, better-auth revives the stored string into a `Date` server-side, and `JSON.stringify` puts an ISO-8601 string back on the wire — measured `"createdAt":"2026-09-07T09:27:01.545Z"` on a real SQL driver. They are declared `string`, a type-level pin holds them there, and no revival layer exists in the SDK.
77+
78+
## Where the vendor's own declarations were the wrong answer
79+
80+
- `delete`'s OpenAPI stub declares the deleted id as a `string`; the handler answers the organization row.
81+
- `updateMemberRole`'s stub declares `{ member }`; the handler answers the membership row bare, without `user`.
82+
- `metadata` is one column with two wire forms: `create` and `update` decode it, every read route answers the stored JSON text (`setActive`, `get`, `delete`, `list`).
83+
- `removeMember` joins `user` on only when the member was addressed by email; the by-id path strips it.
84+
- Inside `get(...).teams` the vendor's `memberCount` is NOT stripped (it is on `teams.create` / `teams.update`). `teams.update` writes no timestamp of its own — `updatedAt` there comes from better-auth's team schema (`onUpdate` default, applied on every update) with the platform's `sys_team.updated_at` stamping behind it, measured on a real SQL driver; the default team minted at organization creation is written without `updatedAt` by the vendor and carries the platform's stamp, so `get(...).teams[].updatedAt` is declared optional as the safe direction.
85+
86+
## Not a behaviour change
87+
88+
`getActiveMember(organizationId)` keeps sending its query parameter; the measured fact that the server ignores it and answers the session's ACTIVE organization is recorded in the method's JSDoc and filed separately — a body change is outside this family's ruled narrowing scope.

packages/client/exported-any-returns.json

Lines changed: 0 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,26 +2,6 @@
22
"$comment": "Exported callables of @objectstack/client whose AWAITED return type resolves to `any` (#11927). Judged against the BUILT dist by `pnpm --filter @objectstack/client check:exported-any-returns`, because the erasure is invisible in source text when a method carries no return annotation. SHRINK-ONLY and EXACT in both directions: a site here that no longer resolves to `any` is RED until its entry is deleted, and a site NOT here that resolves to `any` is RED — that unlisted case is the everyday one and the reason this file exists. There is deliberately NO --update flag: every entry is debt with a name on it, and a reason a tool wrote is a silencer rather than a worklist. SCOPE, and the one exclusion worth stating out loud: a return type that CONTAINS `any` (`{ packages: any[]; total: number }`, `Promise<Record<string, any>>`) is not listed, because it is not flagged — the gate asks whether the type IS `any`, the same line packages/spec's check:exported-any draws, and admitting the broader question costs the gate its zero-false-positive property. That is why 21 of #11925's 38 unannotated methods are absent here: they are `any`-CONTAINING, and they remain #11925's to close. Nothing is silently absorbed in either direction. A caller-supplied `<T = any>` is likewise never listed: the record type and the action payload really are the caller's, and flagging them is the pressure that turns a correct generic into a wrong concrete type.",
33
"entries": {
44
"ObjectStackClient.meta.migrateStored": "#11925 — no return annotation; the published type comes from `this.unwrapResponse<any>(res)`. Invisible to a `Promise<` grep because the text never appears in the method. Bind the contract the route actually answers, minding the envelope.",
5-
"ObjectStackClient.organizations.create": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
6-
"ObjectStackClient.organizations.update": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
7-
"ObjectStackClient.organizations.setActive": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
8-
"ObjectStackClient.organizations.get": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
9-
"ObjectStackClient.organizations.listMembers": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
10-
"ObjectStackClient.organizations.invite": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
11-
"ObjectStackClient.organizations.leave": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
12-
"ObjectStackClient.organizations.delete": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
13-
"ObjectStackClient.organizations.removeMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
14-
"ObjectStackClient.organizations.updateMemberRole": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
15-
"ObjectStackClient.organizations.getActiveMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
16-
"ObjectStackClient.organizations.invitations.cancel": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
17-
"ObjectStackClient.organizations.invitations.accept": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
18-
"ObjectStackClient.organizations.invitations.reject": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
19-
"ObjectStackClient.organizations.invitations.resend": "#12104 — no return annotation; delegates to `organizations.invite` and inherits ITS erasure rather than carrying one of its own. Binding `invite` closes this entry too, so do not annotate this site separately.",
20-
"ObjectStackClient.organizations.teams.create": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
21-
"ObjectStackClient.organizations.teams.update": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
22-
"ObjectStackClient.organizations.teams.delete": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
23-
"ObjectStackClient.organizations.teams.addMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
24-
"ObjectStackClient.organizations.teams.removeMember": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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.",
255
"ObjectStackClient.auth.deleteUser": "#12104 — no return annotation; `return res.json()`, and lib.dom declares `Response.json(): Promise<any>`. 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."
266
}
277
}

0 commit comments

Comments
 (0)