Small product teams
A complete, permission-correct backend — files, webhooks, custom fields included — without a platform team or a vendor engine to run.
A POSTGRES-TO-API GENERATOR
Hand-writing CRUD resolvers, bolting auth onto every endpoint, babysitting a vendor engine — skip all of it. gqlith reads your schema and generates a GraphQL, REST and MCP API plus a fully-typed TypeScript client, with one permission model and one filter language, as TypeScript you own and deploy to the edge.
# the old way: weeks of CRUD, a bolted-on auth layer, an engine to operate
~/gqlith $ gqlith generate
✓read schema · 24 tables, 9 enums
✓GraphQL + REST + MCP + typed TS client · no gqlith at runtime
done in an afternoon
~/gqlith $
# try it
~/gqlith $ gqlith generate # example schema: Blog
CREATE TABLE posts ( id uuid PRIMARY KEY, author_id uuid REFERENCES authors(id), title text NOT NULL, body text NOT NULL, status text NOT NULL, -- 'draft' | 'published' published_at timestamptz, created_at timestamptz DEFAULT now()); COMMENT ON TABLE posts IS ' @gqlith.rbac(reader: read; author: read, write; admin: *) @gqlith.rowFilter( reader: status = ''published''; author: author_id = $userId OR status = ''published''; admin: * )';
CREATE TABLE projects ( id uuid PRIMARY KEY, tenant_id uuid NOT NULL, name text NOT NULL, archived boolean DEFAULT false, created_at timestamptz DEFAULT now()); COMMENT ON TABLE projects IS ' @gqlith.tenancy(column: tenant_id) @gqlith.rbac(member: read; owner: read, write; admin: *) @gqlith.rowFilter(member: archived = false) @gqlith.audit(table) @gqlith.rateLimit(scope: identity, budget: 600/min)';
CREATE TABLE tasks ( id uuid PRIMARY KEY, user_id uuid NOT NULL, title text NOT NULL, done boolean DEFAULT false, due_at timestamptz, _version int DEFAULT 1, deleted_at timestamptz); COMMENT ON TABLE tasks IS ' @gqlith.rowFilter(*: user_id = $userId) @gqlith.softDelete(column: deleted_at) @gqlith.version(column: _version)';
CREATE TABLE products ( id uuid PRIMARY KEY, name text NOT NULL, price_cents int NOT NULL, tags text[] DEFAULT '{}', attributes jsonb DEFAULT '{}', in_stock boolean DEFAULT true); CREATE TABLE reviews ( id uuid PRIMARY KEY, product_id uuid REFERENCES products(id), rating int NOT NULL, body text); COMMENT ON TABLE products IS ' @gqlith.rbac(public: read; staff: *) @gqlith.rowFilter(public: in_stock = true)'; COMMENT ON COLUMN products.attributes IS '@gqlith.jsonSchema(fields: "tier: enum(free|pro)!, weightKg: float")';
CREATE TABLE notifications ( id uuid PRIMARY KEY, user_id uuid NOT NULL, kind text NOT NULL, -- 'mention' | 'reply' | 'system' body text NOT NULL, read_at timestamptz, created_at timestamptz DEFAULT now()); COMMENT ON TABLE notifications IS ' @gqlith.rowFilter(*: user_id = $userId)';
query LatestPosts {
posts(
where: { status: { eq: "published" } }
orderBy: [publishedAt_DESC]
first: 10
) {
edges {
node {
id
title
author { name }
publishedAt
}
}
}
}GET /api/v1/posts
?filter=status = 'published'
&sort=-publishedAt
&page[size]=10
200 OK
{
"data": [
{
"type": "posts",
"id": "01HW…",
"attributes": { "title": "…", "publishedAt": "…" },
"relationships": { "author": { "data": { "type": "authors", "id": "…" } } }
}
]
}# 1. the agent discovers what this caller may see
{ "name": "describe_schema", "arguments": { "entity": "posts" } }
# → fields, operations, runnable examples — permission-projected.
# A reader's guide never even mentions createPost.
# 2. then queries with the same filter grammar as REST
{
"name": "graphql_query",
"arguments": {
"query": "{ posts(limit: 10, filterql: \"status = 'published'\", orderBy: [publishedAt_DESC]) { edges { node { id title publishedAt } } } }"
}
}query MyProjects {
projects(
where: { archived: { eq: false } }
orderBy: [createdAt_DESC]
) {
edges { node { id name createdAt } }
}
}
# tenant_id is injected from the caller's claim — never sent by the client.GET /api/v1/projects?filter=archived = false
X-Tenant-Id: <ignored — injected from token>
# Cross-tenant id?
GET /api/v1/projects/<other-tenant-uuid>
404 Not Found
{ "errors": [{ "status": "404", "title": "Not Found" }] }
# No "forbidden" — never confirm the row exists.{
"name": "graphql_query",
"arguments": {
"query": "{ projects(filterql: \"archived = false\") { edges { node { id name createdAt } } } }"
}
}
# tenant_id is injected from the caller's token — the agent
# cannot ask for another tenant's rows, however hard it tries.
{
"name": "graphql_mutation",
"arguments": {
"mutation": "mutation { createProject(data: { name: \"Q3 launch\" }) { id } }"
}
}
# tenant_id auto-set, audit log written in the same tx.mutation MarkDone {
updateTask(
where: { id: "01HW…" }
data: { done: true }
expectedVersion: 7 # optimistic concurrency
) {
id done _version # _version is now 8
}
}PATCH /api/v1/tasks/01HW…
If-Match: "7"
Content-Type: application/vnd.api+json
{ "data": { "type": "tasks", "attributes": { "done": true } } }
200 OK
ETag: "8"
# Stale write?
412 Precondition Failed
{ "errors": [{ "title": "Version mismatch", "detail": "Expected 7, got 9" }] }{
"name": "graphql_mutation",
"arguments": {
"mutation": "mutation { updateTask(where: { id: \"01HW…\" }, data: { done: true }, expectedVersion: 7) { id done } }"
}
}
# writes only run through graphql_mutation — a mutation sent
# to graphql_query is rejected at the wire, before any handler.
# stale expectedVersion? STALE_OBJECT, same as every surface.query TopRatedVegan {
products(
where: {
tags: { contains: ["vegan"] }
reviews: { some: { rating: { gte: 4 } } }
attributes: { $path: ["tier"], eq: "pro" }
}
orderBy: [priceCents_ASC]
first: 20
) {
edges { node { id name priceCents } }
}
}GET /api/v1/products
?filter=tags contains ('vegan')
AND reviews ANY (rating >= 4)
AND attributes__tier = 'pro'
&sort=priceCents
&page[size]=20
# Same filter grammar, URL-encoded. attributes__tier is a
# typed JSONB path — declared once, filterable everywhere.
# Compiled to a single parameterized SQL query — no N+1.# the agent learns the grammar from the schema itself
{ "name": "filter_help", "arguments": { "entity": "products" } }
# → operators + example filters built from the real columns.
{
"name": "graphql_query",
"arguments": {
"query": "{ products(limit: 20, filterql: \"tags contains ('vegan') AND reviews ANY (rating >= 4) AND attributes__tier = 'pro'\", orderBy: [priceCents_ASC]) { edges { node { id name priceCents } } } }"
}
}subscription Inbox {
notificationCreated { # live, over GraphQL SSE
id kind body createdAt
}
}
# Streamed at GET /graphql/stream. RBAC + row filter are
# re-checked at event time — you only receive your own.
query Unread {
notifications(where: { readAt: { isNull: true } }, first: 20) {
edges { node { id kind body } }
}
}GET /api/v1/notifications?filter=readAt is null&sort=-createdAt
200 OK
{
"data": [
{
"type": "notifications",
"id": "01HW…",
"attributes": { "kind": "mention", "body": "…", "createdAt": "…" }
}
]
}
# Live updates aren't REST — they stream over GraphQL SSE:
# GET /graphql/stream (Accept: text/event-stream){
"name": "graphql_query",
"arguments": {
"query": "{ notifications(limit: 20, filterql: \"readAt is null\", orderBy: [createdAt_DESC]) { edges { node { id kind body } } } }"
}
}
# row filter enforced — the agent only ever reads the caller's rows.
# Agents run one-shot documents — live subscriptions stay GraphQL/SSE.One schema in. Three working APIs out. Switch between the examples.
# Why it holds together
# author the rule once, in a SQL comment
@gqlith.rbac(member: read; chef: read, write)
@gqlith.rowFilter(member: status = 'published')
# enforced identically on every surface
GraphQL recipes(where: …) ✓ rbac ✓ rowFilter ✓ tenancy
REST GET /api/v1/recipes ✓ rbac ✓ rowFilter ✓ tenancy
MCP graphql_query ✓ rbac ✓ rowFilter ✓ tenancy # the same filter, three front-ends
GraphQL where: { servings: { gte: 4 }, cuisine: { name: { icontains: "thai" } } }
REST ?filter=servings >= 4 AND cuisine.name icontains 'thai'
MCP "filterql": "servings >= 4 AND cuisine.name icontains 'thai'"
# → one AST → parameterized SQL generated/
├─ schema.ts # your types, your code
├─ graphql/ # resolvers · connections · subscriptions
├─ rest/ # JSON:API 1.1
├─ mcp/ # self-describing, permission-projected agent surface
├─ client-sdk/ # fully-typed fluent TS client for your frontend
└─ runtime/ # 0 dependencies on gqlith
$ npm uninstall gqlith # the API keeps running COMMENT ON TABLE recipes IS '
@gqlith.rbac(member: read; chef: read, write; admin: *)
@gqlith.rowFilter(
member: status = ''published'';
chef: author_id = $userId OR status = ''published''
)
@gqlith.tenancy(column: tenant_id)
'; const { ok, data, errors } = await gq.recipes
.where({ status: { eq: "published" }, servings: { gte: 4 } })
.orderBy(["createdAt_DESC"])
.page({ first: 10 })
.select({ id: true, title: true, authorUser: { name: true } });
// data is typed to EXACTLY this selection —
// an unselected field is a compile error, not a runtime surprise # Batteries included
The things every other generator leaves as “do it yourself” — generated for you, and governed by the same permission model as everything else.
client SDK A fluent TypeScript client emitted next to the server — return types narrowed to exactly what you select. One schema, no drift.
@gqlith.file Direct-to-store uploads with mint → confirm, signed downloads, image variants, per-tenant quotas and lifecycle GC — S3, R2, GCS.
rls: generate Postgres RLS compiled from the same annotations as the API, with JWT claims propagated per transaction. Defense in depth, one source.
@gqlith.jsonSchema Declare a JSONB column's shape once: real GraphQL types, validated writes, typed filtering — and a drift-check CLI.
crons Cron triggers generated to native primitives — Cloudflare crons, Deno.cron — with a dev-mode interval locally.
tenancy: row tenant_id injected into every read and write. Cross-tenant access returns 404 — no error leakage.
@gqlith.customFields Let each customer define their own fields at runtime — a governed catalog, typed values, inline atomic writes, filtering and sorting on every surface.
@gqlith.version Lost-update protection with a _version field on GraphQL and ETag / If-Match on REST.
one tx Multiple root mutations run in one transaction. Any error rolls back all of them — automatically.
@gqlith.softDelete Deletes become tombstones, auto-filtered from every query, with a generated restore mutation.
on("orders.update") React to changes through a transactional outbox — at-least-once delivery, retries, DLQ, idempotent effects — plus verified inbound and HMAC-signed outgoing webhooks.
nested writes create, connect, connectOrCreate, update, upsert, disconnect, delete, set — multi-hop, in a single mutation.
@gqlith.rateLimit One sliding-window budget shared across GraphQL, REST and MCP per identity — no dodging it by switching protocol.
createToken Issue API keys for your own users with custom claims — revocation takes effect on the very next request.
@gqlith.audit Before/after diffs written inside the same transaction as the change. Roll back the write, roll back the trail.
subscriptions SSE subscriptions re-check RBAC, row filters and tenancy at event time — live updates never leak a hidden row.
MCP A self-describing agent surface, projected through the caller's permissions — an agent can discover and query only what the caller may see.
groupedAggregate count, sum, avg, min, max, distinctCount — grouped, HAVING-filtered, per-row authorized, and typed in the client SDK.
cache A cache slot with in-memory, Cloudflare KV and Redis backends — for JWKS and your own reads, swappable by config.
telemetry Traces and metrics over OTLP to Datadog, Prometheus or Sentry — permission denials, rate-limit hits and audit emits included.
# Filtering
The same expressive grammar in a search box, a URL, or an agent tool — far past the usual eq / in / contains.
status = 'published' AND servings >= 4 ORDER BY createdAt DESC cuisine.name icontains 'italian' AND reviews ANY (rating >= 4) createdAt >= now-7d createdAt >= now/M tags contains ('vegan', 'gluten-free') payload__tier = 'pro' title icontains(any) ('quick', 'easy', 'simple') count(*) >= 5 recipes_rated_at_least(4) = != < > betweenlike / ilikeregex / iregexfts full-textin / not incontains / overlaphasKey (JSONB)some / every / nonedot.notation relationsnow-7d · now/Mcount(*) → HAVING:named params
# Own it
gqlith emits self-contained TypeScript with zero runtime dependency on us. No engine to boot, no sidecar, no vendor process — uninstall the generator and nothing changes for the people using your API.
~/app $ npm uninstall gqlith
removed 1 package in 412ms
~/app $ curl -s localhost:8787/api/v1/health
200 OK # the API does not care — it is your code now
# Who it’s for
Stand up a secure REST + MCP backend over your Postgres in an afternoon — then own every line of it.
A complete, permission-correct backend — files, webhooks, custom fields included — without a platform team or a vendor engine to run.
Generate the boring 80% — CRUD, auth, filtering, uploads, a typed client — and hand-edit the rest. It’s your code.
A self-describing MCP server over your data on day one — projected through the caller’s permissions, so agents only ever see what the caller is allowed to.
# Straight answers
# Compare
We evaluated the field. Each tool was strong at one thing and silent on the rest. Compared within category — codegen, runtime engine, router.
| Capability | gqlith | Typical alternative |
|---|---|---|
| GraphQL + REST + MCP from one schema | One permission model across all three | GraphQL-only, or a second surface with a different auth story |
| Typed TypeScript client SDK | Generated with the API — selection-typed, no drift | Types only, or a separate codegen pipeline you babysit |
| One filter language across surfaces | Object, text DSL and agent string | A GraphQL-only dialect per tool |
| Vendor runtime | None — no engine to operate, own the code, deploy to the edge | A vendor engine or extension, always running |
| Postgres RLS | Generated from the same annotations (opt-in) | Hand-written policies, or a proprietary authz layer instead |
| File uploads & storage | Built in — governed, direct-to-store, signed URLs | A separate platform service, or DIY |
| Tenant-defined custom fields | Built in — typed, filterable, discoverable | DIY EAV tables or an untyped JSONB bag |
| Durable webhooks & scheduled jobs | Built in — transactional outbox, native crons | A separate queue plus glue code |
| Multi-tenancy (no leakage) | Built in | DIY — RLS + session vars / manual WHERE |
| In-transaction audit log | Built in | DIY trigger or middleware |
| Rate limiting across protocols | Built in, one shared budget | Paid tier / GraphQL-only / infra |
Compared within category — every claim grounded in each tool’s own docs. See the full comparison.
gqlith is in private early access. Leave your email and we’ll bring you in as we open it up — a small group at a time.