zenstack-graphql
v0.2.0
Published
A standalone GraphQL adapter for ZenStack V3 with Hasura-like CRUD conventions.
Readme
zenstack-graphql
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.
Requirements
- Node.js
>=18.17 graphql^16.11.0as a peer dependency- ZenStack V3 schema metadata and a request-scoped ZenStack client
Install
npm install zenstack-graphql graphqlChoose Your Surface
Use 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
Core Usage
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;
},
});Public API
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;
},
});Server Adapters
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.
Next.js
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.
Express
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.
Hono
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);TanStack Start
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,
},
},
});Transport Notes
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']
Hasura Helpers
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
Hasura Importer
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
Compatibility Snapshot
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.
Role-aware schemas
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 } }',
});Notes
- 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.
Example Apps
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.
Next.js
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:devExpress
cd examples/express-demo
npm install
npm run devOr from the repo root:
npm run demo:express:devHono
cd examples/hono-demo
npm install
npm run devOr from the repo root:
npm run demo:hono:devTanStack Start
cd examples/tanstack-start-demo
npm install
npm run devOr from the repo root:
npm run demo:tanstack:dev