zenstack-graphql is a standalone GraphQL adapter for ZenStack-style model metadata. It generates a framework-agnostic GraphQLSchema with Hasura-like CRUD roots, model-driven filters and ordering, aggregates, nested relation inserts, core insert/update/delete mutations, ZenStack procedure roots, and optional custom root resolvers.
ZenStack is a schema-first TypeScript data platform and ORM that lets you model your data, access policies, and API-facing behavior in ZModel, then generate a typed runtime on top of your database. This package is the GraphQL layer that sits on top of that metadata and exposes it with Hasura-inspired conventions.
- Node.js
>=18.17 graphql^16.11.0as a peer dependency- ZenStack V3 schema metadata and a request-scoped ZenStack client
npm install zenstack-graphql graphqlUse the lowest-level API that matches your app:
zenstack-graphql/core- For direct schema generation and custom GraphQL server wiring
zenstack-graphql/server- For the framework-agnostic transport handler
zenstack-graphql/hasura- For convenience helpers around
x-hasura-rolerequest extraction and schema slicing
- For convenience helpers around
zenstack-graphql- Convenience root export that re-exports the full public surface
import { createZenStackGraphQLSchema } from 'zenstack-graphql/core';
const schema = createZenStackGraphQLSchema({
schema: {
models: [
{
name: 'User',
fields: [
{ name: 'id', kind: 'scalar', type: 'Int', isId: true },
{ name: 'name', kind: 'scalar', type: 'String' },
],
},
],
},
async getClient(context) {
return context.db;
},
});createZenStackGraphQLSchema({ schema, getClient, compatibility, naming, features, relay, slicing, scalars, scalarAliases, hooks, extensions })createZenStackGraphQLSchemaFactory({ schema, getClient, getSlicing, getCacheKey, ... })new GraphQLApiHandler({ schema, getSlicing, getCacheKey, ... })createFetchGraphQLHandler(...)normalizeSchema(schema)normalizeError(error)
The generated schema uses Hasura-like defaults:
- Query roots:
users,users_by_pk,users_aggregate - Mutation roots:
insert_users,insert_users_one,update_users,update_users_by_pk,delete_users,delete_users_by_pk - String filters include Hasura-style pattern operators like
_like,_nlike,_ilike, and_nilike, plus extended prefix/suffix/contains variants - Provider-specific filters now include PostgreSQL scalar-list operators (
has,hasEvery,hasSome,isEmpty) and ZenStack-styleJsonfilters with JSON-path support - Comparable scalar filters include
_between - Strongly typed JSON / typedef-backed fields can be filtered recursively, including list-object filters with
some,every, andnone insert_*andinsert_*_onesupporton_conflict*_insert_inputsupports nested relationdatainserts*_set_inputsupports relation-aware updates for the nested mutation shapes supported by the underlying ZenStack ORM- To-many relation filters support the ORM-backed
some,every, andnonesemantics via additive GraphQL fields likeposts_some,posts_every, andposts_none features.computedFieldsenables read-only@computedfields detected from ZenStack-generated metadataslicingsupports schema pruning with ZenStack-style model, operation, procedure, and filter slicing, plus GraphQL field visibility pruning for role-specific schemascreateZenStackGraphQLSchemaFactorycaches one generated schema per slice key, which makes role-aware introspection and execution much easier- ZModel
procedureandmutation proceduredefinitions are exposed as GraphQL query and mutation roots viaclient.$procs extensions.queryandextensions.mutationlet you attach manual GraphQL root fields that receive the same request-scoped ZenStack client as generated resolvers*_by_pkroots are emitted only for real primary keys- Relation aggregate
order_byon parent collections is currently supported only forcount, matching the documented ORMorderBy: { relation: { _count: ... } }shape distinct_onis generated only for providers where the ORM supportsdistinctrelay.enabledadds an opt-in Relay query layer with<models>_connection, nested<relation>_connection, andnode(id:)
For closer compatibility with existing Hasura documents, the easiest path is:
compatibility: 'hasura-compat'- Turns on the safe Hasura-oriented compatibility bundle:
- singular table-style roots from
model.dbName/model.name - Hasura/Postgres scalar aliases like
uuid,timestamptz,jsonb,numeric,bigint, andcitext - Hasura-style generated helper/input type names like
payment_payable_bool_expanduuid_comparison_exp - ORM-backed relation aggregate count predicates like
posts_aggregate: { count: { predicate: { _eq: 0 } } }
- singular table-style roots from
- Turns on the safe Hasura-oriented compatibility bundle:
If you only want part of that behavior, the lower-level knobs are still available:
naming: 'hasura-table'- Uses singular table-root names from
model.dbName/model.name, such asidentity_organization,identity_organization_by_pk, andinsert_identity_organization_one
- Uses singular table-root names from
scalarAliases: 'hasura'- Renames the generated GraphQL scalar surface to Hasura/Postgres-style names where safe:
DateTime -> timestamptzDecimal -> numericJson -> jsonbBigInt -> bigint- native DB hints like
@db.Uuid -> uuidand@db.Citext -> citext
- Renames the generated GraphQL scalar surface to Hasura/Postgres-style names where safe:
Example:
const schema = createZenStackGraphQLSchema({
schema: zenstackSchema,
compatibility: 'hasura-compat',
async getClient(context) {
return context.db;
},
});The low-level schema factory is still available, but the package now also includes a ZenStack-style
api handler + server adapter layer so you can integrate GraphQL the same way ZenStack's REST and
RPC services integrate with different server frameworks.
Use GraphQLApiHandler when you want a framework-agnostic transport boundary:
import { GraphQLApiHandler } from 'zenstack-graphql';
const handler = new GraphQLApiHandler({
schema,
});
const response = await handler.handleRequest({
client: db,
method: 'POST',
path: '/api/graphql',
requestBody: {
query: 'query { users { id name } }',
},
});GraphQLApiHandler is intentionally shaped to be assignment-compatible with ZenStack's
ApiHandler type from @zenstackhq/server/api, so it can participate in the same
logical handler/server-adapter model.
Then use a thin framework adapter to resolve the request-scoped client.
When a ZenStack server adapter already fits your GraphQL route shape, you can use it directly
with GraphQLApiHandler instead of going through this package's fetch helper.
import type { NextRequest } from 'next/server';
import { GraphQLApiHandler } from 'zenstack-graphql/server';
import { NextRequestHandler } from '@zenstackhq/server/next';
const apiHandler = new GraphQLApiHandler({
schema,
allowedPaths: [''],
});
const handler = NextRequestHandler({
apiHandler,
async getClient(request) {
return getZenStackClientFromRequest(request);
},
useAppDir: true,
});
type RouteContext = {
params: Promise<{ path?: string[] }>;
};
export const POST = (request: NextRequest, context: RouteContext) =>
handler(request, {
params: context.params.then((params) => ({
path: params.path ?? [],
})),
});Mount that handler in an optional catch-all route like
app/api/graphql/[[...path]]/route.ts. The tiny params.path ?? [] normalization is only
there because ZenStack's current Next.js adapter expects catch-all path params, while the root
GraphQL endpoint does not supply one.
import express from 'express';
import { GraphQLApiHandler } from 'zenstack-graphql/server';
import { ZenStackMiddleware } from '@zenstackhq/server/express';
const app = express();
app.use(express.json());
const graphqlApiHandler = new GraphQLApiHandler({ schema });
app.use(
'/api/graphql',
ZenStackMiddleware({
apiHandler: graphqlApiHandler,
async getClient(req) {
return getZenStackClientFromRequest(req);
},
})
);For that direct adapter path, install @zenstackhq/server alongside zenstack-graphql.
import { Hono } from 'hono';
import { GraphQLApiHandler } from 'zenstack-graphql/server';
import { createHonoHandler } from '@zenstackhq/server/hono';
const app = new Hono();
const apiHandler = new GraphQLApiHandler({ schema });
const graphql = createHonoHandler({
apiHandler,
async getClient(c) {
return getZenStackClientFromRequest(c);
},
});
app.use('/api/graphql/*', graphql);import { createFileRoute } from '@tanstack/react-router';
import { GraphQLApiHandler } from 'zenstack-graphql/server';
import { TanStackStartHandler } from '@zenstackhq/server/tanstack-start';
const apiHandler = new GraphQLApiHandler({
schema,
allowedPaths: ['graphql'],
});
const handler = TanStackStartHandler({
apiHandler,
async getClient(request) {
return getZenStackClientFromRequest(request);
},
});
export const Route = createFileRoute('/api/$')({
server: {
handlers: {
GET: handler,
POST: handler,
PUT: handler,
PATCH: handler,
DELETE: handler,
},
},
});The current adapter layer supports:
- the framework-agnostic
GraphQLApiHandler - fetch / Web
Requesthandlers - direct use with ZenStack's Express, Next.js, Hono, and TanStack Start adapters
All of them share the same core execution path, including request-wide mutation transactions, Relay support, procedures, extensions, and role-aware schema slicing.
For fixed GraphQL endpoints, the framework-specific adapters that rely on catch-all routing should
be mounted on catch-all-style routes and paired with allowedPaths in GraphQLApiHandler:
- Next.js:
app/api/graphql/[[...path]]/route.tswithallowedPaths: [''] - Hono:
app.use('/api/graphql/*', ...)withallowedPaths: [''] - TanStack Start:
createFileRoute('/api/$')withallowedPaths: ['graphql']
If you want a lightweight compatibility layer for Hasura-style role headers, use
createHasuraCompatibilityHelpers.
import { createHasuraCompatibilityHelpers } from 'zenstack-graphql/hasura';
const hasura = createHasuraCompatibilityHelpers<Request, 'admin' | 'user'>({
defaultRole: 'admin',
getHeaders(request) {
return request.headers;
},
normalizeRole(role) {
return role?.toLowerCase() === 'user' ? 'user' : 'admin';
},
getSlicing(role) {
return role === 'user'
? {
models: {
user: {
excludedFields: ['age'],
},
},
}
: undefined;
},
});
type RequestScopedClient = ReturnType<typeof getZenStackClientFromRequest> & {
__graphqlRole?: 'admin' | 'user';
};
const apiHandler = new GraphQLApiHandler<RequestScopedClient>({
schema,
getSlicing(request) {
return hasura.getSlicing(new Request('http://local.invalid'), {
role: request.client.__graphqlRole ?? 'admin',
});
},
getCacheKey({ request }) {
return hasura.getCacheKey({
context: { role: request.client.__graphqlRole ?? 'admin' },
});
},
});
createFetchGraphQLHandler({
apiHandler,
async getClient(request) {
const baseClient = await getZenStackClientFromRequest(request);
const role = hasura.getContext(request).role;
return new Proxy(baseClient as RequestScopedClient, {
get(target, property, receiver) {
if (property === '__graphqlRole') {
return role;
}
const value = Reflect.get(target, property, receiver);
return typeof value === 'function' ? value.bind(target) : value;
},
});
},
});That helper intentionally stays small. It standardizes:
- the
x-hasura-roleheader name - role extraction from
Headersor Node-style header objects - default-role fallback
- request-to-context mapping
- role-based schema slicing and cache keys
For one-off migrations, the repo now includes a CLI that can turn a Hasura Postgres metadata export
plus live Postgres introspection into a best-effort schema.zmodel.
npm run hasura:import -- \
--metadata-dir /path/to/hasura/metadata \
--database-url "$DATABASE_URL" \
--source default \
--out ./schema.zmodel \
--reportV1 importer scope:
- Hasura Postgres metadata only
- tracked tables and tracked views
- best-effort ZenStack
@@allowpolicy generation - inline TODO comments for unsupported permission features and no-key views
This adapter is aiming for "mostly painless for common Hasura CRUD use cases", not full Hasura platform parity.
Supported well today:
- Hasura-like list,
*_by_pk, and*_aggregatequery roots - Optional
compatibility: 'hasura-compat'preset for table-style roots, Hasura/Postgres scalar aliases, Hasura-style generated helper/input type names, and safe aggregatecount.predicatecompatibility - Optional
naming: 'hasura-table'mode for singular table-root compatibility with existing Hasura documents - Core insert, update, and delete mutation roots with
returning on_conflictoninsert_*andinsert_*_one- Nested relation inserts and the supported nested relation update shapes exposed by ZenStack ORM
- Aggregates, relation aggregate fields, and parent
order_byby relationcount - Hasura-style filtering and ordering, including
_between, relationsome/every/none, and provider-gateddistinct_on - ORM-backed Hasura aggregate count predicates like
_eq: 0and_gt: 0on<relation>_aggregate.count - Optional
scalarAliases: 'hasura'mode for Hasura/Postgres scalar names likeuuid,timestamptz,jsonb,numeric,bigint, andcitext - ZenStack custom procedures as GraphQL roots
- Manual custom root resolvers through
extensions - Role-aware schema pruning through
slicingor the schema factory - Request-wide mutation transactions when the client exposes
$transaction - Optional Relay root and nested connections plus
node(id:)
Supported, but with explicit limits:
- Relation aggregate
order_byonly supportscount - Provider-specific operators only appear where ZenStack metadata says the backend supports them
- Typed JSON / typedef filters are supported recursively for scalar, enum, typedef, and list-of-typedef fields, but not arbitrary relation fields nested inside typedefs
- Role-aware schemas are static per slice key; auth enforcement still belongs in the ZenStack client you provide
- Relay is implemented as a parallel type layer, so connection
nodeobjects useUserNode/PostNodetypes instead of reusing the existing Hasura-styleUser/Postobject types
Intentionally unsupported right now:
- Subscriptions
- Hasura remote schemas
- Auto-generated database-native SQL function/procedure roots
- Cursor pagination
- Relation aggregate ordering beyond ORM-backed
count - Any feature that would require in-memory query semantics instead of safe ORM lowering
See docs/compatibility.md for the longer compatibility matrix and docs/migration.md for a practical Hasura migration checklist. Release notes for the current adapter surface are in CHANGELOG.md.
If you want different GraphQL schemas per role, use the schema factory and derive slicing
from request context.
import {
createZenStackGraphQLSchemaFactory,
} from 'zenstack-graphql/core';
const factory = createZenStackGraphQLSchemaFactory({
schema,
getClient: async (context) => context.db,
getSlicing(context) {
return context.role === 'admin'
? undefined
: {
models: {
user: {
excludedFields: ['age'],
excludedOperations: ['deleteMany', 'deleteByPk'],
},
},
};
},
getCacheKey({ context }) {
return context.role;
},
});
const graphqlSchema = await factory.getSchema(context);
const result = await factory.execute({
contextValue: context,
source: '{ users { id name } }',
});- The adapter accepts a normalized metadata object today so it can work as a standalone package before being wired into a full ZenStack V3 repository.
- Delegates are expected to look Prisma-like (
findMany,findUnique,aggregate,create,update,delete, and optional bulk variants). - Provider capabilities are normalized from the schema metadata so backend-specific filter behavior can be gated cleanly as the adapter grows.
- ZenStack custom procedures are supported; database-native SQL routines are not auto-generated today.
- The root
zenstack-graphqlentrypoint is a convenience export; framework-specific subpaths are the cleaner long-term import surface for apps and examples.
The repository now includes four runnable examples:
examples/nextjs-demo- Full browser playground with schema viewer, seeded data panel, role switching, and sample operations
examples/express-demo- Minimal Express server using ZenStack's
ZenStackMiddlewarewithGraphQLApiHandler
- Minimal Express server using ZenStack's
examples/hono-demo- Minimal Hono server using ZenStack's
createHonoHandlerwithGraphQLApiHandler
- Minimal Hono server using ZenStack's
examples/tanstack-start-demo- TanStack Start app using ZenStack's
TanStackStartHandlerwithGraphQLApiHandler
- TanStack Start app using ZenStack's
All four examples use a real ZenStack schema, generate local metadata with zenstack generate,
boot a SQLite database, and support Hasura-style role selection via the x-hasura-role header.
The Next.js playground includes examples for:
- Nested reads and aggregates
- CRUD mutations, nested inserts, and
on_conflict - Atomic rollback across multiple mutation fields
- JSON-path filters and
_between - Relay root connections, nested relation connections, and
node(id:) - ZenStack procedures and manual extension roots
- Role-pruned schemas with
x-hasura-role
cd examples/nextjs-demo
npm install
npm run devOr from the repo root:
npm run demo:devcd examples/express-demo
npm install
npm run devOr from the repo root:
npm run demo:express:devcd examples/hono-demo
npm install
npm run devOr from the repo root:
npm run demo:hono:devcd examples/tanstack-start-demo
npm install
npm run devOr from the repo root:
npm run demo:tanstack:dev