Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/invitation-status-derive-from-spec.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
---
'@objectstack/client': patch
---

fix(client): `organizations.invitations.list()` / `listMine()` type `status` from the spec's `InvitationStatus` enum instead of a hand-copied literal (#7781).

`list()`'s row `status` was hand-written as `'pending' | 'accepted' | 'rejected' | 'canceled'` —
missing `expired`, ObjectStack's own terminal state driven by `expiresAt`. `listMine()` typed the
same field as a bare `string`. Both are now `InvitationStatus`, imported from
`@objectstack/spec/identity` — the same union `sys_invitation.status` binds its select options to
(#7726) — so a value added to the spec enum reaches the SDK by construction instead of silently
diverging again.

Types-only, no wire change: the value already arrived off the wire regardless of what the
annotation said, so nothing about what `list()` / `listMine()` return at runtime moves. What
changes is that TypeScript narrowing (a `switch` over `status`, for example) now sees all five
values, including `expired`.
17 changes: 13 additions & 4 deletions packages/client/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,6 +84,7 @@ import type {
ApprovalDecisionResult,
} from '@objectstack/spec/contracts';
import type { ExecutionStatus } from '@objectstack/spec/automation';
import type { InvitationStatus } from '@objectstack/spec/identity';
import { Logger, createLogger } from '@objectstack/core/logger';
import { RealtimeAPI } from './realtime-api';

Expand DownExpand Up@@ -2011,8 +2012,8 @@ export class ObjectStackClient {
*/
invitations: {
/**
* List pending/accepted/canceled invitations for an organization.
* Requires owner/admin role on that org.
* List pending/accepted/rejected/expired/canceled invitations for an
* organization. Requires owner/admin role on that org.
*
* better-auth: GET /organization/list-invitations?organizationId=…
*/
Expand All@@ -2023,11 +2024,16 @@ export class ObjectStackClient {
);
const data = await res.json();
const invitations = Array.isArray(data) ? data : (data?.data ?? data?.invitations ?? []);
// [#7781] `status` is `InvitationStatus` (from `@objectstack/spec/identity`)
// rather than a hand-copied literal — the SDK previously restated the
// vocabulary and drifted from it (missing `expired`). Derived from the
// spec union, so a future value reaches here by construction; see
// `invitation-status-vocabulary.test.ts` for the pin.
return { invitations: invitations as Array<{
id: string;
email: string;
role: string;
status: 'pending' | 'accepted' | 'rejected' | 'canceled';
status: InvitationStatus;
organizationId: string;
inviterId: string;
expiresAt: string;
Expand All@@ -2046,11 +2052,14 @@ export class ObjectStackClient {
const res = await this.fetch(`${this.baseUrl}${route}/organization/list-user-invitations`);
const data = await res.json();
const invitations = Array.isArray(data) ? data : (data?.data ?? data?.invitations ?? []);
// [#7781] Was a bare `string` — inconsistent with `list()` above and
// just as untethered from the spec vocabulary. Same derivation as
// `list()`.
return { invitations: invitations as Array<{
id: string;
email: string;
role: string;
status: string;
status: InvitationStatus;
organizationId: string;
inviterId: string;
expiresAt: string;
Expand Down
89 changes: 89 additions & 0 deletions packages/client/src/invitation-status-vocabulary.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// #7781 — `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, which had already widened the spec side to
// five values (adding `canceled`) while this file stayed at four and drifted
// the other way.
//
// The fix types both methods FROM `InvitationStatus` rather than restating it
// (see `organizations.invitations.{list,listMine}` in `./index.ts`), so a
// future value added to the spec enum reaches the SDK by construction. This
// file is the pin that makes a REGRESSION — someone re-literalizing either
// method, or the spec enum moving without the (already-derived) client
// following — a compile failure instead of a silent third divergence.
//
// Note WHY this is a type-level (`tsc`) pin and not a runtime one: the defect
// was entirely inside a type annotation over a `JSON.parse` cast. The value
// arrives off the wire regardless of what the annotation says (see #7781's
// own "Impact" section) — no input you could feed a running client would ever
// make a purely-runtime test fail here. The `Eq<...>` comparison below is
// against `InvitationStatus` ITSELF, never a second hand-written literal that
// happens to agree today; the runtime `describe` block below is a companion
// that pins the enum's actual content in prose, for a reader who is not
// re-deriving the type by eye.
//
// Exported at module scope (not inside `it()`): an unread alias in a function
// body is TS6196 under `noUnusedLocals`, and — the part that matters — a pin
// no program compiles is a phantom check that stays green after the guarded
// code is deleted. `pnpm --filter @objectstack/client typecheck` (which runs
// `tsc --noEmit` over `src/**/*` via `tsconfig.test.json`) is the gate that
// reads this file; `vitest` does not type-check (#4311) and only runs the
// `describe` block.
import { describe, it, expect } from 'vitest';
import type { InvitationStatus } from '@objectstack/spec/identity';
import { InvitationStatus as InvitationStatusSchema } from '@objectstack/spec/identity';
import type { ObjectStackClient } from './index';

/** Type-level identity helper — same shape as the spec package's pin tests. */
type Eq< A, B > = (< T >() => T extends A ? 1 : 2) extends (< T >() => T extends B ? 1 : 2) ? true : false;
type Assert< T extends true > = T;

type ListInvitationsResult = Awaited< ReturnType< ObjectStackClient[ 'organizations' ][ 'invitations' ][ 'list' ] > >;
type ListMineResult = Awaited< ReturnType< ObjectStackClient[ 'organizations' ][ 'invitations' ][ 'listMine' ] > >;

type ListRowStatus = ListInvitationsResult[ 'invitations' ][ number ][ 'status' ];
type ListMineRowStatus = ListMineResult[ 'invitations' ][ number ][ 'status' ];

/**
* `list()`'s declared row `status` IS `InvitationStatus` — not a literal union
* that merely lists the same values today. A re-literalization (even one that
* currently agrees) or a spec-side change this file's import does not follow
* makes `Eq` evaluate `false`, and `Assert< false >` fails to compile.
*/
export type ListStatusIsTheSpecUnion = Assert< Eq< ListRowStatus, InvitationStatus > >;

/** Same requirement for `listMine()`, which was a bare `string` before #7781. */
export type ListMineStatusIsTheSpecUnion = Assert< Eq< ListMineRowStatus, InvitationStatus > >;

describe('#7781 organizations.invitations — status vocabulary parity with the spec enum', () => {
it('the spec enum currently carries exactly the five shipped values, in this order', () => {
// Runtime companion to the type-level pin above: this is the CONTENT a
// non-TypeScript reader can check without reading compiler output. Order
// matters here only in that it documents what's live, not because either
// consuming SELECT depends on enum declaration order.
expect([...InvitationStatusSchema.options]).toEqual([
'pending',
'accepted',
'rejected',
'expired',
'canceled',
]);
});

it('every value the SDK previously hand-listed is a real spec value', () => {
// Reverse of the card's headline complaint: the pre-fix literal was
// `'pending' | 'accepted' | 'rejected' | 'canceled'` — checking here that
// none of those four is dead SDK-only surface the platform never emits.
for (const value of ['pending', 'accepted', 'rejected', 'canceled'] as const) {
expect(InvitationStatusSchema.options).toContain(value);
}
});

it('refuses a value outside the vocabulary', () => {
expect(InvitationStatusSchema.safeParse('cancelled').success).toBe(false);
});
});
Loading