Skip to content

Commit 7302c0b

Browse files
hotlongclaude
andauthored
fix(client): derive invitation status from the spec's InvitationStatus union (#7781) (#8076)
`organizations.invitations.list()` hand-wrote its row `status` as 'pending' | 'accepted' | 'rejected' | 'canceled' — missing `expired`, ObjectStack's own terminal state driven by `expiresAt`. `listMine()` was worse: a bare `string`. Both are two more hand-copied spellings of the vocabulary InvitationStatus (@objectstack/spec/identity) already owns, same divergence family as #7726. Both methods now type `status: InvitationStatus`, imported from the spec, so a future value added to the enum reaches the SDK by construction instead of silently diverging again. Added a type-level + runtime pin (invitation-status-vocabulary.test.ts) that fails to compile if either method is ever re-literalized — reverse-verified by temporarily re-literalizing list()'s status back to the old 4-value union and confirming `pnpm --filter @objectstack/client typecheck` fails with a type error in the pin file, then restoring the fix and confirming green again. Types-only, no wire change. Claude-Session: https://claude.ai/code/session_01B3Kurx8qufrDzNjk4rag7V Co-authored-by: Claude <noreply@anthropic.com>
1 parent 026508b commit 7302c0b

3 files changed

Lines changed: 119 additions & 4 deletions

File tree

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
'@objectstack/client': patch
3+
---
4+
5+
fix(client): `organizations.invitations.list()` / `listMine()` type `status` from the spec's `InvitationStatus` enum instead of a hand-copied literal (#7781).
6+
7+
`list()`'s row `status` was hand-written as `'pending' | 'accepted' | 'rejected' | 'canceled'`
8+
missing `expired`, ObjectStack's own terminal state driven by `expiresAt`. `listMine()` typed the
9+
same field as a bare `string`. Both are now `InvitationStatus`, imported from
10+
`@objectstack/spec/identity` — the same union `sys_invitation.status` binds its select options to
11+
(#7726) — so a value added to the spec enum reaches the SDK by construction instead of silently
12+
diverging again.
13+
14+
Types-only, no wire change: the value already arrived off the wire regardless of what the
15+
annotation said, so nothing about what `list()` / `listMine()` return at runtime moves. What
16+
changes is that TypeScript narrowing (a `switch` over `status`, for example) now sees all five
17+
values, including `expired`.

packages/client/src/index.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ import type {
8484
ApprovalDecisionResult,
8585
} from '@objectstack/spec/contracts';
8686
import type { ExecutionStatus } from '@objectstack/spec/automation';
87+
import type { InvitationStatus } from '@objectstack/spec/identity';
8788
import { Logger, createLogger } from '@objectstack/core/logger';
8889
import { RealtimeAPI } from './realtime-api';
8990

@@ -2011,8 +2012,8 @@ export class ObjectStackClient {
20112012
*/
20122013
invitations: {
20132014
/**
2014-
* List pending/accepted/canceled invitations for an organization.
2015-
* Requires owner/admin role on that org.
2015+
* List pending/accepted/rejected/expired/canceled invitations for an
2016+
* organization. Requires owner/admin role on that org.
20162017
*
20172018
* better-auth: GET /organization/list-invitations?organizationId=…
20182019
*/
@@ -2023,11 +2024,16 @@ export class ObjectStackClient {
20232024
);
20242025
const data = await res.json();
20252026
const invitations = Array.isArray(data) ? data : (data?.data ?? data?.invitations ?? []);
2027+
// [#7781] `status` is `InvitationStatus` (from `@objectstack/spec/identity`)
2028+
// rather than a hand-copied literal — the SDK previously restated the
2029+
// vocabulary and drifted from it (missing `expired`). Derived from the
2030+
// spec union, so a future value reaches here by construction; see
2031+
// `invitation-status-vocabulary.test.ts` for the pin.
20262032
return { invitations: invitations as Array<{
20272033
id: string;
20282034
email: string;
20292035
role: string;
2030-
status: 'pending' | 'accepted' | 'rejected' | 'canceled';
2036+
status: InvitationStatus;
20312037
organizationId: string;
20322038
inviterId: string;
20332039
expiresAt: string;
@@ -2046,11 +2052,14 @@ export class ObjectStackClient {
20462052
const res = await this.fetch(`${this.baseUrl}${route}/organization/list-user-invitations`);
20472053
const data = await res.json();
20482054
const invitations = Array.isArray(data) ? data : (data?.data ?? data?.invitations ?? []);
2055+
// [#7781] Was a bare `string` — inconsistent with `list()` above and
2056+
// just as untethered from the spec vocabulary. Same derivation as
2057+
// `list()`.
20492058
return { invitations: invitations as Array<{
20502059
id: string;
20512060
email: string;
20522061
role: string;
2053-
status: string;
2062+
status: InvitationStatus;
20542063
organizationId: string;
20552064
inviterId: string;
20562065
expiresAt: string;
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #7781 — `organizations.invitations.list()` hand-wrote its row `status` as
4+
// `'pending' | 'accepted' | 'rejected' | 'canceled'`, missing `expired`
5+
// (ObjectStack's own terminal state, driven by `expiresAt`). `listMine()` was
6+
// worse: a bare `string`. Both are two more hand-copied spellings of the
7+
// vocabulary `InvitationStatus` (`@objectstack/spec/identity`) already owns —
8+
// same divergence family as #7726, which had already widened the spec side to
9+
// five values (adding `canceled`) while this file stayed at four and drifted
10+
// the other way.
11+
//
12+
// The fix types both methods FROM `InvitationStatus` rather than restating it
13+
// (see `organizations.invitations.{list,listMine}` in `./index.ts`), so a
14+
// future value added to the spec enum reaches the SDK by construction. This
15+
// file is the pin that makes a REGRESSION — someone re-literalizing either
16+
// method, or the spec enum moving without the (already-derived) client
17+
// following — a compile failure instead of a silent third divergence.
18+
//
19+
// Note WHY this is a type-level (`tsc`) pin and not a runtime one: the defect
20+
// was entirely inside a type annotation over a `JSON.parse` cast. The value
21+
// arrives off the wire regardless of what the annotation says (see #7781's
22+
// own "Impact" section) — no input you could feed a running client would ever
23+
// make a purely-runtime test fail here. The `Eq<...>` comparison below is
24+
// against `InvitationStatus` ITSELF, never a second hand-written literal that
25+
// happens to agree today; the runtime `describe` block below is a companion
26+
// that pins the enum's actual content in prose, for a reader who is not
27+
// re-deriving the type by eye.
28+
//
29+
// Exported at module scope (not inside `it()`): an unread alias in a function
30+
// body is TS6196 under `noUnusedLocals`, and — the part that matters — a pin
31+
// no program compiles is a phantom check that stays green after the guarded
32+
// code is deleted. `pnpm --filter @objectstack/client typecheck` (which runs
33+
// `tsc --noEmit` over `src/**/*` via `tsconfig.test.json`) is the gate that
34+
// reads this file; `vitest` does not type-check (#4311) and only runs the
35+
// `describe` block.
36+
import { describe, it, expect } from 'vitest';
37+
import type { InvitationStatus } from '@objectstack/spec/identity';
38+
import { InvitationStatus as InvitationStatusSchema } from '@objectstack/spec/identity';
39+
import type { ObjectStackClient } from './index';
40+
41+
/** Type-level identity helper — same shape as the spec package's pin tests. */
42+
type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
43+
type Assert< T extends true > = T;
44+
45+
type ListInvitationsResult = Awaited< ReturnType< ObjectStackClient[ 'organizations' ][ 'invitations' ][ 'list' ] > >;
46+
type ListMineResult = Awaited< ReturnType< ObjectStackClient[ 'organizations' ][ 'invitations' ][ 'listMine' ] > >;
47+
48+
type ListRowStatus = ListInvitationsResult[ 'invitations' ][ number ][ 'status' ];
49+
type ListMineRowStatus = ListMineResult[ 'invitations' ][ number ][ 'status' ];
50+
51+
/**
52+
* `list()`'s declared row `status` IS `InvitationStatus` — not a literal union
53+
* that merely lists the same values today. A re-literalization (even one that
54+
* currently agrees) or a spec-side change this file's import does not follow
55+
* makes `Eq` evaluate `false`, and `Assert< false >` fails to compile.
56+
*/
57+
export type ListStatusIsTheSpecUnion = Assert< Eq< ListRowStatus, InvitationStatus > >;
58+
59+
/** Same requirement for `listMine()`, which was a bare `string` before #7781. */
60+
export type ListMineStatusIsTheSpecUnion = Assert< Eq< ListMineRowStatus, InvitationStatus > >;
61+
62+
describe('#7781 organizations.invitations — status vocabulary parity with the spec enum', () => {
63+
it('the spec enum currently carries exactly the five shipped values, in this order', () => {
64+
// Runtime companion to the type-level pin above: this is the CONTENT a
65+
// non-TypeScript reader can check without reading compiler output. Order
66+
// matters here only in that it documents what's live, not because either
67+
// consuming SELECT depends on enum declaration order.
68+
expect([...InvitationStatusSchema.options]).toEqual([
69+
'pending',
70+
'accepted',
71+
'rejected',
72+
'expired',
73+
'canceled',
74+
]);
75+
});
76+
77+
it('every value the SDK previously hand-listed is a real spec value', () => {
78+
// Reverse of the card's headline complaint: the pre-fix literal was
79+
// `'pending' | 'accepted' | 'rejected' | 'canceled'` — checking here that
80+
// none of those four is dead SDK-only surface the platform never emits.
81+
for (const value of ['pending', 'accepted', 'rejected', 'canceled'] as const) {
82+
expect(InvitationStatusSchema.options).toContain(value);
83+
}
84+
});
85+
86+
it('refuses a value outside the vocabulary', () => {
87+
expect(InvitationStatusSchema.safeParse('cancelled').success).toBe(false);
88+
});
89+
});

0 commit comments

Comments
 (0)