usm-api-graphql
v0.6.0
Published
Universal Schema Model — GraphQL API layer: turns a usm-core SearchSchema into Hasura-style typeDefs + resolvers (queries, aggregates, live-query and streaming subscriptions)
Downloads
161
Readme
usm-api-graphql
Universal Schema Model — the GraphQL API layer for usm-core.
Turns a SearchSchema into a Hasura-style GraphQL interface:
- object types reflected from each collection,
*_bool_exp/*_order_by/*_aggregateinputs, a*_select_columnenum fordistinct_on, and*_comparison_expscalar comparison inputs,- read queries:
<table>,<type>_by_pk,<table>_aggregate, - one live-query subscription per collection (driven by a
usm-coreSubscriptionManager), - one streaming subscription per collection —
<table>_stream(driven by ausm-coreStreamingSubscriptionManager).
It depends only on usm-core; it emits SDL strings + resolver maps and does not
import graphql itself (except the bundled custom scalars). graphql is a peer
dependency so your app supplies a single copy.
Install
npm install usm-api-graphql graphql @graphql-tools/schemagraphql is a peer dependency; @graphql-tools/schema is an optional peer used
only by the buildExecutableSchema helper.
Usage
import { buildExecutableSchema } from "usm-api-graphql";
import { SubscriptionManager, StreamingSubscriptionManager } from "usm-core";
// model.searchSchema comes from usm-adapter-postgres' knexSearchSchema(model)
const subscriptionManager = new SubscriptionManager(model.searchSchema, changeSource, {
defaultPollInterval: 2000
});
const streamingSubscriptionManager = new StreamingSubscriptionManager(model.searchSchema, changeSource);
const schema = await buildExecutableSchema(model.searchSchema, {
subscriptionManager,
streamingSubscriptionManager
});
// -> GraphQLSchema with queries + aggregates + subscriptions, ready to serveEither manager can be left out: pass only the one whose subscriptions you want.
For finer control, use the builder directly:
import { SearchSchemaGraphQLBuilder, GraphQLISO8601TimestampType, GraphQLJSONType } from "usm-api-graphql";
import { makeExecutableSchema } from "@graphql-tools/schema";
const { typeDefs, resolvers } = new SearchSchemaGraphQLBuilder(model.searchSchema, {
subscriptions: true,
subscriptionManager,
streamingSubscriptions: true,
streamingSubscriptionManager,
scalars: { ISO8601Timestamp: GraphQLISO8601TimestampType, JSON: GraphQLJSONType }
}).build();
const schema = makeExecutableSchema({ typeDefs, resolvers });Query shape
query {
authors(where: { name: { _ilike: "%a%" } }, order_by: [{ name: asc }], limit: 10) {
id
name
books { id title }
}
authors_aggregate(where: { name: { _ilike: "%a%" } }) {
aggregate { count }
}
}
subscription {
authors(where: { name: { _ilike: "%a%" } }, poll_interval: 5000) { id name }
}Streaming subscriptions
A live query re-sends its whole result set whenever any of it changes. A stream sends each row once, in the order of a cursor column, in batches — Hasura's streaming subscriptions, and the same query shape:
subscription NewBooks($since: ISO8601Timestamp) {
books_stream(
cursor: { initial_value: { modified: $since }, ordering: ASC }
batch_size: 500
where: { author_id: { _eq: 3 } }
) {
id
title
modified
}
}Each payload carries the next batch past the one before it, so a client tracks the
highest modified it has seen and sends that back as $since to resume after a
reconnect. Send null to read the collection from the beginning.
Notes on the dialect, all in the direction of accepting what a Hasura client sends:
batch_sizeis optional here where Hasura requires it. Left out, a stream takes the manager'sdefaultBatchSize; either way it is capped atmaxBatchSize.cursortakes a list for Hasura's sake — a single cursor coerces into one — but a stream reads by exactly one column, and anything else is an error rather than a silent choice between them.- Rows sharing a cursor value are each delivered once, including ones written at
that value after the stream has passed it. (Hasura reads such a group with
FETCH FIRST n WITH TIES, which keeps it whole in one batch but leaves behind rows written at that value afterwards.) - A reconnect is at-least-once: a timestamp cursor served as a millisecond value cannot separate the rows Postgres holds a microsecond apart, so resuming replays the last millisecond. Within one subscription each row still goes out once.
- Streams are not multiplexed: each subscriber sits at its own point in the collection, so each costs its own query. Live queries still share cohorts.
Streaming a collection needs select to be allowed by whatever authorize policy
the app supplies, the same as querying it.
distinct_on
distinct_on keeps one row per distinct combination of the named columns, and
the query's own order_by decides both which row of each group is kept and the
order the groups come back in — so "the most recent review of each book, newest
first" is one read:
query {
reviews(distinct_on: [book_id], order_by: [{ book_id: asc }, { written: desc }], limit: 25) {
book_id
written
rating
}
}This differs from Hasura, which requires order_by to lead with the
distinct_on columns and so cannot sort the groups by anything else. Here the
deduplication runs as a subquery and the ordering is applied over it, which is
what makes the query above expressible; naming the distinct columns first in
order_by, as above, is still how you choose which row of each group survives.
Note that a limit cannot narrow the scan: every row matching the where is
grouped before any is dropped. On a large table, bound the rows first — a
where on the column you order by, backed by an index that leads with it — or
the read costs the whole table however small the limit.
Local development note
The inter-package dependency on usm-core is declared as file:../usm-core for
in-repo development. When publishing, replace it with a version range (e.g.
"usm-core": "^0.1.0"), or use a workspace / npm install --install-links.
