ADR-009: GraphQL Yoga with Dynamic Schema over ItemService
Date: 2026-06-17 Status: Accepted
Context
LumiBase has shipped a complete REST API on Hono, and its vision positions
Delivery consumers as accessing content via "REST/GraphQL/WS". The consumer
SDK roadmap (docs/en/roadmap/consumer-sdk.md) reserves a composable
.with(graphql()) adapter. Until now GraphQL was unimplemented on both the
API and Studio.
We want a GraphQL surface for content items (query + mutation) that:
- Runs edge-native on Cloudflare Workers as well as Node/Docker.
- Reuses — never bypasses — the existing governance baked into
ItemService: multi-tenancy (site_id), permission row/field masks, RLS, soft-deletes, revisions/provenance, HITL pins, realtime broadcast, and search indexing. - Reflects LumiBase's dynamic, JSONB-backed schema: collections and fields are declared at runtime, not table-per-type, and differ per tenant.
Options considered:
- GraphQL Yoga + graphql-js — lightweight, Workers-compatible, integrates with Hono through a single fetch handler, and supports a per-request schema factory (needed for per-tenant dynamic schemas).
- Pothos (code-first) — excellent for static schemas, but LumiBase's
schema is built at runtime from
collections/fields, so Pothos's compile-time type building is a poor fit. - Apollo Server — heavier, less optimised for the Workers edge runtime.
Decision
Adopt GraphQL Yoga + graphql-js, mounted inside the authenticated
/api/v1 Hono sub-app at /api/v1/graphql.
- Inherited security: because the route lives under the same
withTenant → withDb → withAuth → … → withRlschain as REST, GraphQL gets tenant resolution, auth, and RLS for free. - Thin resolvers: every resolver delegates to
ItemService(apps/cms/src/services/item-service.ts). No resolver touches the database directly, so all governance is inherited (Non-negotiable rules #2, #4, #5). - Dynamic schema:
buildSiteSchema()reads the per-sitecollections/fieldsmanifest viaSchemaServiceand programmatically builds aGraphQLSchema(object type per collection;Query.<collection>,Query.<collection>_by_id;Mutation.create_/update_/delete_<collection>). Filter args map 1:1 ontoItemService's existingItemFilteroperators. - Per-site schema cache:
GraphQLSchemaobjects are non-serialisable, so they are cached in-process keyed bysiteIdwith a short TTL (60s);invalidateSiteSchema(siteId)drops one immediately. - Errors: thrown
ItemServiceErrors are mapped toGraphQLErrorwithextensions.codematching the REST error vocabulary, so clients handle errors identically across surfaces. - SDK: a composable
graphql()plugin (packages/sdk/src/graphql) adds.query()/.mutate()on top of the existingrawRequest, reusing its auth/tenant headers and 401 handling.
Consequences
Positive
- One source of truth for content governance (ItemService); GraphQL cannot drift from or bypass REST's permission/tenancy rules.
- Edge-compatible, minimal dependency surface (
graphql,graphql-yoga). - Schema automatically tracks tenant collection/field changes.
Negative / trade-offs
- Per-request schema resolution costs a few manifest reads; mitigated by the in-process TTL cache.
- Field-typed introspection is best-effort: structured/relational fields fall
back to a
JSONscalar rather than fully nested GraphQL types.
Follow-ups delivered
- Hardening: query depth limit + introspection disabled in production.
- Nested relations: m2o/o2m surfaced as nested fields resolved lazily via
ItemService(m2m/m2a still on the JSON escape hatch). - Subscriptions:
Subscription.<collection>_eventsover SSE, bridged from the SiteRoom Durable Object realtime channel.
Future work
- m2m/m2a nested relation types.
- Field-level permission masking of subscription payloads.
- Persisted queries and query cost limiting.
- Extend the surface beyond items (collections/users/admin) if required.