diff --git a/.changeset/invitation-status-derive-from-spec.md b/.changeset/invitation-status-derive-from-spec.md new file mode 100644 index 0000000000..d592feff3e --- /dev/null +++ b/.changeset/invitation-status-derive-from-spec.md @@ -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`. diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index fce948431b..c0618ae1e0 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -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'; @@ -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=… */ @@ -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; @@ -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; diff --git a/packages/client/src/invitation-status-vocabulary.test.ts b/packages/client/src/invitation-status-vocabulary.test.ts new file mode 100644 index 0000000000..d8c44e4787 --- /dev/null +++ b/packages/client/src/invitation-status-vocabulary.test.ts @@ -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); + }); +});