@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-dataGetting 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 type | Filter 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.
@microsoft/rayfin-client
RayfinClient construction, configuration options, and the client.data, client.auth, and client.functions facades, with exact signatures from the SDK.
@microsoft/rayfin-auth
The Auth client surface — signOut, session management, and the OpaqueSession shape — with exact signatures from the SDK.