Rayfin

@microsoft/rayfin-data

The fluent GraphQL query and mutation API behind client.data — select, where, orderBy, pagination, and CRUD methods with exact signatures.

@microsoft/rayfin-data is the DAB-compliant data client that powers client.data on both RayfinClient and RayfinServerClient. Most applications never import it directly — you get a typed instance for free from @microsoft/rayfin-client — but it is a standalone package if you want the query builder without the rest of the SDK.

Installation

npm install @microsoft/rayfin-data

Getting a client

createDataApi builds the typed proxy that client.data is:

function createDataApi<TSchema extends EntitySchema>(
  apiClient: ApiClient,
): DataApi<TSchema> & TypedDataClients<TSchema>;

type TypedDataClients<TSchema extends EntitySchema> = {
  [K in keyof TSchema]: GraphQLEntityClient<TSchema, K>;
};

Each property on the result — dataApi.Todo, dataApi.Note, and so on — is a GraphQLEntityClient scoped to that entity.

Query chain

GraphQLEntityClient exposes both direct query methods and a fluent builder (GraphQLQueryBuilder) for composing select / where / orderBy / pagination:

class GraphQLEntityClient<TSchema extends EntitySchema, TEntity extends keyof TSchema> {
  select<TFields extends FieldSelection<TSchema[TEntity]>>(fields: TFields): GraphQLQueryBuilder<TSchema, TEntity>;
  where(conditions: FilterInput<TSchema[TEntity]>): GraphQLQueryBuilder<TSchema, TEntity>;
  orderBy(order: OrderByInput<TSchema[TEntity]>): GraphQLQueryBuilder<TSchema, TEntity>;
  first(count: number): GraphQLQueryBuilder<TSchema, TEntity>;
  findMany(filter?: FilterInput<TSchema[TEntity]>): Promise<TSchema[TEntity][]>;
  findFirst(filter?: FilterInput<TSchema[TEntity]>): Promise<TSchema[TEntity] | null>;
  findById(id: string): Promise<TSchema[TEntity] | null>;
  create(input: CreateInput<TSchema[TEntity]>): Promise<TSchema[TEntity]>;
  update(where: WhereUniqueInput<TSchema[TEntity]>, data: UpdateInput<TSchema[TEntity]>): Promise<TSchema[TEntity]>;
  delete(where: WhereUniqueInput<TSchema[TEntity]>): Promise<TSchema[TEntity]>;
  upsert(
    where: WhereUniqueInput<TSchema[TEntity]>,
    create: CreateInput<TSchema[TEntity]>,
    update: UpdateInput<TSchema[TEntity]>,
  ): Promise<TSchema[TEntity]>;
}

class GraphQLQueryBuilder<TSchema extends EntitySchema, TEntity extends keyof TSchema> {
  select<TFields extends FieldSelection<TSchema[TEntity]>>(fields: TFields): this;
  where(conditions: FilterInput<TSchema[TEntity]>): this;
  orderBy(order: OrderByInput<TSchema[TEntity]>): this;
  first(count: number): this;
  after(cursor: string): this;
  execute(): Promise<TSchema[TEntity][]>;
  executePaginated(): Promise<PagedResult<TSchema[TEntity]>>;
  findFirst(): Promise<TSchema[TEntity] | null>;
}

select, where, orderBy, and first return this, so they chain in any order before a terminal call to execute(), executePaginated(), or findFirst().

Reading records

const notes = await client.data.Note.select([
  'id',
  'title',
  'isPinned',
  'notebook.id',      // dot-path — only valid inside select(), not where()
  'notebook.name',
])
  .where({ isPinned: { eq: true } })
  .orderBy({ createdAt: 'desc' })
  .execute();

.execute() returns a single page — the Data API caps a response at its default page size (100 records) even when the underlying table has more rows, and gives no signal that more records exist. Use it only for queries you know are bounded (a .where() filter that can match at most a handful of rows, or a small lookup table); for anything that can grow unbounded, use pagination instead.

Filtering — FilterInput

where() takes one entry per field, keyed to a type-specific filter shape, plus optional and / or arrays for boolean composition:

type FilterInput<T> = {
  [K in keyof T]?: FilterValue<T, K>;
} & {
  and?: FilterInput<T>[];
  or?: FilterInput<T>[];
};
Field typeFilter operators
string (StringFilterInput)eq, neq, gt, gte, lt, lte, contains, notContains, startsWith, endsWith, isNull, in
number (NumberFilterInput)eq, neq, gt, gte, lt, lte, isNull, in
boolean (BooleanFilterInput)eq, neq, isNull, in
Date (DateFilterInput)eq, neq, gt, gte, lt, lte, isNull, in
relationship field{ isNull: boolean } (only when the relationship itself is optional)

Filter by the foreign key column (customer_id), not a relationship dot-path (customer.id) — dot-paths are select-only. See Known limitations.

Sorting — OrderByInput

type OrderByInput<T> = { [K in keyof T]?: 'asc' | 'desc' };

Directions are lowercase strings, not an enum.

Pagination

interface PaginationConfig {
  first?: number;
  after?: string;
}

interface PagedResult<T> {
  items: T[];
  hasNextPage: boolean;
  endCursor?: string;
  totalCount?: number;
}

Data API Builder only supports forward pagination (first / after) — there is no before / last. Use .first(n) to set the page size and .executePaginated() to get a page plus cursor metadata; pass the previous page's endCursor to .after() for the next page:

const page = await client.data.Note.select(['id', 'title'])
  .orderBy({ createdAt: 'desc' })
  .first(25)
  .executePaginated();

// page.items, page.hasNextPage, page.endCursor

const nextPage = await client.data.Note.select(['id', 'title'])
  .orderBy({ createdAt: 'desc' })
  .first(25)
  .after(page.endCursor!)
  .executePaginated();

Keep select, where, and orderBy identical across pages — a stable sort order is required for the cursor to advance correctly. first(n) is bounded by DAB's maximum page size (100,000); totalCount is present on PagedResult but is not populated by DAB today. There is no count() method on the query chain. count exists as an aggregation operation over numeric fields — see Aggregation below. For a row count, select minimal fields and use results.length, or page.items.length per page. See Known limitations.

Aggregation

groupBy() and aggregate() are entry points on both GraphQLEntityClient and GraphQLQueryBuilder. They return a GraphQLAggregationBuilder, whose execute() resolves to one row per group. Calling aggregate() without groupBy() produces a single grand-total row whose fields is empty.

class GraphQLEntityClient<TSchema extends EntitySchema, TEntity extends keyof TSchema> {
  groupBy<const TGroup extends readonly ScalarKeys<TSchema[TEntity]>[]>(
    fields: TGroup,
  ): GroupedAggregationStage<TSchema, TEntity, TGroup>;
  aggregate<const TSpec extends AggregationSpec<TSchema[TEntity]>>(
    spec: TSpec,
  ): GraphQLAggregationBuilder<TSchema, TEntity, readonly [], TSpec>;
}

class GraphQLAggregationBuilder<TSchema, TEntity, TGroup, TSpec> {
  execute(): Promise<GroupedAggregationRow<TSchema[TEntity], TGroup, TSpec>[]>;
}

The specification is keyed by aliases you choose. Each entry holds exactly one operation, whose value is either a field-name shorthand or an options object:

type AggregationOps<T> = {
  sum: AggregationOpValue<NumericKeys<T>>;
  avg: AggregationOpValue<NumericKeys<T>>;
  min: AggregationOpValue<NumericKeys<T>>;
  max: AggregationOpValue<NumericKeys<T>>;
  count: AggregationOpValue<NumericKeys<T>>;
};

type AggregationOpValue<F> = F | { field: F; having?: NumberFilterInput; distinct?: boolean };
type AggregationSpec<T> = Record<string, ExactlyOne<AggregationOps<T>>>;

interface GroupedAggregationRow<T, G extends readonly ScalarKeys<T>[], S> {
  fields: Pick<T, G[number] & keyof T>;
  aggregations: { [K in Extract<keyof S, string>]: AggregationResult<S[K]> };
}

AggregationResult is number for count and number | null for sum / avg / min / max, because DAB emits those as nullable and SQL returns NULL over an empty or all-null group.

const rows = await client.data.Order
  .where({ status: { eq: 'shipped' } })
  .groupBy(['region'])
  .aggregate({
    revenue: { sum: 'amount' },
    biggest: { max: { field: 'amount', having: { gt: 500 } } },
  })
  .execute();

Note

Every operation — count included — is typed against NumericKeys<T>, because DAB generates each aggregation's field argument as the entity's NumericAggregateFields enum. Non-numeric fields are a compile error.

Aliases and field tokens are validated at runtime against the GraphQL name grammar (/^[_A-Za-z][_0-9A-Za-z]*$/, and must not begin with __). DAB rejects a query combining groupBy with items, so aggregate() throws if it follows select(), first(), after(), or orderBy(); the RowQueryBuilder return type makes those combinations compile-time errors as well.

Mutations

// MutationInput<T> turns @one() relationship fields into "full object or { id }" inputs
type CreateInput<T> = Omit<MutationInput<T>, 'id'> & Partial<Pick<T, 'id'>>;
type UpdateInput<T> = Partial<MutationInput<T>>;
type WhereUniqueInput<T> = { id: string };

create, update, delete, and upsert are available both directly on GraphQLEntityClient (client.data.Todo.create(...)) and do not go through the query builder.

const todo = await client.data.Todo.create({
  title: 'Write docs',
  isCompleted: false,
  user_id: session.user.id,
});

await client.data.Todo.update({ id: todo.id }, { isCompleted: true });

await client.data.Todo.delete({ id: todo.id });

await client.data.Todo.upsert(
  { id: todo.id },
  { title: 'Write docs', isCompleted: false, user_id: session.user.id },
  { isCompleted: true },
);

Relationship fields in mutations

A @one() relationship field in create / update input accepts either the full related object or an object with just the primary key — both produce the same GraphQL mutation. The primary-key-only form is the recommended shorthand:

// ID-only shorthand (recommended)
await client.data.Note.create({
  title: 'Meeting notes',
  notebook: { id: notebookId },
});

// Full object also works
await client.data.Note.create({
  title: 'Meeting notes',
  notebook: notebookObject,
});

@many() array fields are accepted on mutation input but ignored at runtime — manage the inverse side by updating the child entity's @one() field instead.

Field selection and types

type FieldSelection<T> = readonly (CleanEntityKeys<T> | NestedFieldPath<T>)[];

select() accepts entity field names and one level of relationship dot-paths ('notebook.name'). Nested queries beyond the second level are not supported — see Known limitations.

Advanced: the underlying GraphQL client

@microsoft/rayfin-data also exports the lower-level pieces GraphQLEntityClient builds on, for advanced or standalone use:

class GraphQLClient {
  constructor(apiClient: ApiClient, endpoint?: string);
  request<T = any>(query: string, variables?: Record<string, any>, operationName?: string): Promise<T>;
  query<T = any>(query: string, variables?: Record<string, any>): Promise<T>;
  mutation<T = any>(mutation: string, variables?: Record<string, any>): Promise<T>;
}

Most applications should use client.data.<Entity> rather than calling GraphQLClient directly — it exists so GraphQLEntityClient and GraphQLQueryBuilder have a raw query/mutation execution primitive to build on.

Something wrong on this page?Report an issueEdit this page

On this page