diff --git a/docs/docs/graphql/gqlorm.md b/docs/docs/graphql/gqlorm.md new file mode 100644 index 0000000000..23ccbf6614 --- /dev/null +++ b/docs/docs/graphql/gqlorm.md @@ -0,0 +1,282 @@ +--- +description: Prisma-inspired GraphQL query builder with automatic schema generation and live queries +--- + +# gqlorm + +`gqlorm` is a Prisma-inspired GraphQL query builder that lets you fetch data +from your Cedar backend using an ORM-style API on the frontend. Instead of +hand-writing GraphQL documents, you write familiar Prisma-like queries and +gqlorm generates the GraphQL for you — complete with live-query support. + +```tsx +// Before: writing GraphQL by hand +const QUERY = gql` + query FindTodos { + todos { + id + title + body + done + } + } +` + +// With gqlorm: Prisma-style queries +const { data } = useLiveQuery((db) => db.todo.findMany()) +``` + +:::caution + +gqlorm is an **experimental** feature. Enable it in `cedar.toml` and expect APIs +to evolve as the feature matures. + +::: + +## What gqlorm provides + +- **Auto-generated GraphQL types and resolvers** from your Prisma schema — no manual SDL required for basic CRUD reads +- **ORM-style query builder** on the frontend: `db.todo.findMany()`, `db.post.findUnique({ where: { id: 1 } })`, etc. +- **Live queries out of the box** via the `useLiveQuery` hook, which automatically adds the `@live` directive +- **Automatic auth scoping** — queries are scoped to the current user and organization when your schema includes membership fields +- **Sensitive-field filtering** — fields like `password`, `secret`, and `token` are hidden from the GraphQL API by default + +## Enabling gqlorm + +Add the experimental flag to your `cedar.toml`: + +```toml title="cedar.toml" +[experimental.gqlorm] +enabled = true +``` + +When you run `yarn cedar dev` or `yarn cedar build`, Cedar generates three +artifacts in `.cedar/`: + +| File | Purpose | +| :--------------------------------------------- | :----------------------------------------------------------------- | +| `.cedar/gqlorm-schema.json` | Frontend model schema mapping model names to visible scalar fields | +| `.cedar/gqlorm/backend.ts` | Auto-generated GraphQL SDL and resolvers for the API side | +| `.cedar/types/includes/web-gqlorm-models.d.ts` | TypeScript type declarations for the frontend query builder | + +## Frontend setup + +Import the generated schema and call `configureGqlorm` once at app startup. +Typically you do this at the top of `App.tsx`: + +```tsx title="web/src/App.tsx" +import { configureGqlorm } from '@cedarjs/gqlorm/setup' +import schema from '../../.cedar/gqlorm-schema.json' with { type: 'json' } + +configureGqlorm({ schema }) +``` + +`configureGqlorm` is idempotent and safe to call multiple times. Passing `schema` lets the query builder know which scalar fields exist for each model, so `useLiveQuery((db) => db.todo.findMany())` requests every visible field instead of falling back to `id` only. + +## Fetching data with `useLiveQuery` + +`useLiveQuery` is the primary way to fetch data on the web side. Pass it a query function and it returns `{ data, loading, error }` just like a standard GraphQL query hook — but the query is annotated with `@live` so it automatically re-fetches when the underlying data changes. + +```tsx title="web/src/components/LiveTodos/LiveTodos.tsx" +import { useLiveQuery } from '@cedarjs/gqlorm/react/useLiveQuery' + +const LiveTodos = () => { + const { data, loading, error } = useLiveQuery((db) => db.todo.findMany()) + + if (loading) { + return
Loading...
+ } + + if (error) { + return
Error: {error.message}
+ } + + if (!data || data.length === 0) { + return
No todos yet
+ } + + return ( + + ) +} + +export default LiveTodos +``` + +### Supported query operations + +The query function supports the same read operations you know from Prisma: + +| Operation | Description | Example | +| :------------------ | :-------------------------------------- | :---------------------------------------------------- | +| `findMany` | List all matching records | `db.todo.findMany()` | +| `findUnique` | Fetch a single record by unique field | `db.todo.findUnique({ where: { id: 1 } })` | +| `findFirst` | Fetch the first matching record | `db.todo.findFirst({ where: { done: true } })` | +| `findUniqueOrThrow` | Like `findUnique` but throws if missing | `db.todo.findUniqueOrThrow({ where: { id: 1 } })` | +| `findFirstOrThrow` | Like `findFirst` but throws if missing | `db.todo.findFirstOrThrow({ where: { done: true } })` | + +`findFirst`, `findUniqueOrThrow`, and `findFirstOrThrow` are client-side abstractions. They generate GraphQL queries against the same singular-model field that `findUnique` uses. The only difference is how the result is handled: `findUnique`/`findFirst` return `null` when no record matches, while `findUniqueOrThrow`/`findFirstOrThrow` throw an error. + +### Filtering and sorting + +You can use `where`, `orderBy`, `take`, and `skip` just like Prisma: + +```tsx +const { data } = useLiveQuery((db) => + db.post.findMany({ + where: { published: true }, + orderBy: { createdAt: 'desc' }, + take: 10, + }) +) +``` + +Complex `where` clauses with `AND`, `OR`, and operators like `gt`, `contains`, etc. are also supported: + +```tsx +const { data } = useLiveQuery((db) => + db.post.findMany({ + where: { + AND: [{ published: true }, { createdAt: { gt: '2024-01-01' } }], + }, + }) +) +``` + +### Selecting specific fields + +Without an explicit `select`, `useLiveQuery` requests every visible scalar field defined in the generated schema. To request only specific fields, pass a `select` object: + +```tsx +const { data } = useLiveQuery((db) => + db.todo.findMany({ + select: { id: true, title: true }, + }) +) +``` + +## Query builder API (advanced) + +If you need more control — for example to build a one-off GraphQL document without React — you can use the query builder directly: + +```ts +import { buildQuery, buildQueryFromFunction } from '@cedarjs/gqlorm' + +// Build from model/operation/args +const graphqlQuery = buildQuery('todo', 'findMany', { + where: { done: false }, +}) + +// Build from a query function +const liveQuery = buildQueryFromFunction( + (db) => db.todo.findUnique({ where: { id: 1 } }), + { isLive: true } +) +``` + +Both return a `GraphQLQuery` object with `query` (string) and optional `variables`. + +## Controlling schema visibility + +gqlorm decides which models and fields are exposed through a small set of rules you control with documentation directives in `schema.prisma`. + +### Hide a model + +Add `/// @gqlorm hide` above the model to exclude it entirely: + +```prisma title="api/db/schema.prisma" +/// @gqlorm hide +model InternalAuditLog { + id Int @id @default(autoincrement()) + action String + createdAt DateTime @default(now()) +} +``` + +### Hide or show a field + +Add `/// @gqlorm hide` or `/// @gqlorm show` above a field: + +```prisma title="api/db/schema.prisma" +model User { + id Int @id @default(autoincrement()) + email String @unique + /// @gqlorm hide + apiKey String // stays hidden even though it doesn't match the heuristic + /// @gqlorm show + metadata Json // explicitly exposed even if the heuristic would hide it +} +``` + +### Sensitive-field heuristics + +By default, gqlorm hides any scalar field whose lowercased name contains one of these substrings: + +`password`, `secret`, `token`, `hash`, `salt`, `apikey`, `secretkey`, `encryptionkey`, `privatekey` + +If a field is auto-hidden, Cedar prints a warning at build time telling you how to confirm the hide (`/// @gqlorm hide`) or override it (`/// @gqlorm show`). + +## Auth and multi-tenancy + +When your Prisma schema includes a membership model (by default `Membership`) with `userId` and `organizationId` fields, gqlorm automatically scopes generated resolvers: + +- **User scoping** — if a model has a `userId` field, `findMany` returns only rows belonging to `currentUser.id`, and `findUnique` verifies ownership before returning the record. +- **Organization scoping** — if a model has an `organizationId` field and a `Membership` model exists, `findMany` restricts results to organizations the current user belongs to. + +The membership model itself is exempt from organization scoping (it is the source of membership data). + +### Configuring membership fields + +If your schema uses different names, configure them in `cedar.toml`: + +```toml title="cedar.toml" +[experimental.gqlorm] +enabled = true +membershipModel = "TeamMember" +membershipUserField = "memberId" +membershipOrganizationField = "teamId" +``` + +## How the backend works + +Cedar generates `.cedar/gqlorm/backend.ts` during the build. This file: + +1. Exports a `schema` object (a `gql` document) with GraphQL types for each visible model and `Query` fields for `findMany` and `findUnique`. +2. Exports a `createGqlormResolvers(db)` factory that returns resolver functions wired to your Prisma client. + +The frontend query builder supports five operations (`findMany`, `findUnique`, +`findFirst`, `findUniqueOrThrow`, `findFirstOrThrow`), but the backend only +needs two GraphQL fields: a plural one (`models`) for `findMany`, and a singular +one (`model`) for the other four. `findFirst`, `findUniqueOrThrow`, and +`findFirstOrThrow` are client-side abstractions — the generated GraphQL queries +hit the same singular-model resolver as `findUnique`, and only differ in how the +result is handled (returning `null` vs. throwing). + +A Babel plugin injects the `db` import into `api/src/functions/graphql.ts` and +passes it to `createGqlormResolvers`, so the generated resolvers are merged into +your GraphQL schema automatically. + +If you already have a manually-written SDL file that defines a type with the +same name (e.g. `type Todo { ... }` in `api/src/graphql/todos.sdl.ts`), gqlorm +skips generating that model to avoid duplicate-type errors. + +## Limitations and known behavior + +- **Read-only for now** — gqlorm currently generates queries (`findMany`, `findUnique`, `findFirst`, etc.). Mutations (`create`, `update`, `delete`) are not yet auto-generated. +- **Scalar and enum fields only** — relation fields are excluded from the generated schema. You can still use `include` in the query builder, but nested relations default to selecting `id` only unless the schema is extended. +- **Live queries require a stateful server** — because `@live` uses Server-Sent Events, you cannot use live queries on serverless deploy targets like Netlify or Vercel without additional infrastructure. See the [Realtime docs](realtime.md) for details. +- **Experimental flag required** — all gqlorm behavior is gated behind `experimental.gqlorm.enabled`. + +## Summary + +gqlorm lets you treat your GraphQL API like a Prisma client on the frontend. After enabling the experimental flag and calling `configureGqlorm`, you can write: + +```tsx +const { data } = useLiveQuery((db) => db.todo.findMany()) +``` + +and Cedar handles the rest: generating the GraphQL document, keeping it in sync with your schema, scoping it to the current user, and re-fetching automatically when data changes. diff --git a/docs/sidebars.js b/docs/sidebars.js index 8a8ba658df..92b0a0d454 100644 --- a/docs/sidebars.js +++ b/docs/sidebars.js @@ -156,6 +156,7 @@ module.exports = { { type: 'doc', label: 'About', id: 'graphql' }, { type: 'doc', label: 'Caching', id: 'graphql/caching' }, { type: 'doc', label: 'Fragments', id: 'graphql/fragments' }, + { type: 'doc', label: 'gqlorm', id: 'graphql/gqlorm' }, { type: 'doc', label: 'Trusted Documents',