@nxara/olio
The olio runtime: modular, type-safe GraphQL applications. You split an API into modules, each owning its SDL, resolvers and middleware, and olio() composes them into one executable GraphQLSchema. Every resolver and middleware key is checked against the Application type generated by @nxara/olio-codegen.
Install
Section titled “Install”bun add @nxara/olio @nxara/olio-tools graphql@16bun add -d @nxara/olio-codegengraphql 16 is a peer dependency. @nxara/olio-tools loads modules and files from globs. The samples below use it, but it’s optional (see Without codegen or tools).
Register your app
Section titled “Register your app”Run codegen to write generated.ts from your modules’ SDL, then register its Application once. This is where every builder gets its types:
import type { Application } from "./generated.js";
declare module "@nxara/olio" { interface Olio { application: Application; ctx: { req_id: string; userId?: string }; // Optional: subscriptions' own context. Leave it out and they get ctx. sub_ctx: { connectionId: string; userId?: string }; }}applicationis the generated type: which modules exist and what each one owns.ctxis the context every resolver and middleware receives. Your server builds it per request (see@nxara/olio-http).sub_ctxandmappersare optional (see Subscriptions and Mappers).
Modules
Section titled “Modules”A module is built with module.<id>, where <id> is its folder name under modules/:
import { module, mw } from "@nxara/olio";import { loadResolvers, loadTypeDefs } from "@nxara/olio-tools";
export const usersModule = module.users({ typeDefs: await loadTypeDefs(new URL("typedefs/*.gql", import.meta.url)), resolvers: await loadResolvers( new URL("{query,mutation,subscription}.{js,ts}", import.meta.url), new URL("resolvers/*.{js,ts}", import.meta.url) ), middleware: mw.users({ Query: { user: [cache()] } })});Resolver files export one object per type, built with a typed builder:
import { query } from "@nxara/olio";
export const Query = query.users({ user: (root, { id }, ctx, info) => findUser(id)});
// resolvers/user.tsimport { resolvers } from "@nxara/olio";
export const User = resolvers.users.User({ name: (user, { trim }) => user.name});mutation.<id> and subscription.<id> work the same way. See Subscriptions for what a subscription field looks like.
You can also write the map inline, without builders. It’s typed from the module the same way:
export const usersModule = module.users({ typeDefs, resolvers: { Query: { user: (root, { id }) => findUser(id) }, // id: string User: { name: (user) => user.name }, // user: what User maps to DateTime: DateTimeResolver // a scalar this module defines }, enums: { Role: { ADMIN: 1, GUEST: 2 } } // internal values, see below});Unknown types and fields, other modules’ fields and wrong return types are compile errors, reported at the field.
resolvers is one map: inline like this, or what loadResolvers returns. A loaded map passes through unchecked, since each file was typed where it was written. A scalar’s implementation comes from the module whose SDL defines it, and only from that module.
Features: splitting a large module
Section titled “Features: splitting a large module”When a module grows, split it into features: subfolders that each own part of the module’s schema, with their own resolvers and middleware.
src/modules/sales/├── index.ts # the module: module-wide middleware, loads its features├── store.ts # shared code├── typedefs/ # SDL the module itself owns (optional)├── customers/ # feature "customers"│ ├── index.ts│ ├── typedefs/customers.gql│ └── query.ts mutation.ts└── orders/ # feature "orders" ├── index.ts ├── typedefs/orders.gql └── query.ts mutation.tsimport { feature, mw } from "@nxara/olio";import { loadResolvers, loadTypeDefs } from "@nxara/olio-tools";
export const orders = feature.sales.orders({ typeDefs: await loadTypeDefs(new URL("typedefs/*.gql", import.meta.url)), resolvers: await loadResolvers( new URL("{query,mutation}.{js,ts}", import.meta.url) ), middleware: mw.sales.orders({ Mutation: { cancelOrder: [requireRole("MANAGER")] } })});
// sales/index.tsimport { module, mw } from "@nxara/olio";import { loadFeatures } from "@nxara/olio-tools";
export const salesModule = module.sales({ middleware: mw.sales({ Mutation: { "*": [journal()] } }), // every feature's fields features: await loadFeatures(new URL("*/index.{js,ts}", import.meta.url))});- Discovery. Codegen treats a direct subfolder of a module as a feature when the
typeDefsglob finds files of its own there. With**/typedefs/**/*.gql,sales/orders/typedefs/makesordersa feature, whilesales/typedefs/stays the module’s own SDL. Features don’t nest: deeper folders belong to their feature. - Isolation. A feature owns the fields its SDL declares, like a module does, and only it can resolve them. To add a field to a sibling’s type,
extendit (ordersaddsCustomer.orders). - Builders. Each feature has its own typed builders:
query.sales.orders,mutation.sales.orders,resolvers.sales.orders.SalesOrder,mw.sales.orders. The module’s are still there (query.salesfor fields the module itself declares). - Middleware nests. For a feature’s field, the chain runs app → module → feature.
mw.salesreaches every feature’s fields, andmw.sales.ordersreaches only the fields oforders. - Names. A feature folder can’t share its name with a type the module declares, or with a function property (
name,length, …), because both sit side by side in the builders. Codegen tells you if one does.
Application
Section titled “Application”olio() takes the modules, the app-wide middleware and the schema directives, and builds the app:
import { mw, olio } from "@nxara/olio";import { loadModules } from "@nxara/olio-tools";
import { directives } from "./directives.js"; // the list codegen uses
const middleware = mw({ "*": { "*": [requestLogger()] }, Mutation: { "*": [requireAuth()] }});
export const app = olio({ middleware, modules: await loadModules( new URL("modules/*/index.{js,ts}", import.meta.url) ), directives // required once codegen had some});// app.schema → any GraphQL server (graphql-http, Yoga, Apollo, …)You can also list modules yourself: modules: [usersModule, authModule].
olio() composes the modules’ SDL through @nxara/olio-schema and checks the result before returning:
- Root types come for free. Modules
extend type Query(orMutation), so nobody needs a placeholdertype Query { _empty: String }. A module that only extends root types can also be tested alone,olio({ modules: [usersModule] }), as long as it adds aQueryfield. GraphQL requires one, so a module that only extendsMutationcan’t stand alone. A module that uses another module’s types takes that module too:olio({ modules: [catalog, reviews] }). - A module resolves only what it declares. It owns the fields in its own definitions and
extends, and the defining module owns__isTypeOf/__resolveType. Resolving another module’s field throws:Module "posts": "User.name" is declared by module "users"; only that module can resolve it. With codegen, the same message is the compile error, on the field:Type '(row) => …' is not assignable to type '"User.name is declared by module users; only that module can resolve it"'. - Errors point at files. SDL and schema errors are reported as
file:line:column: message.
Middleware
Section titled “Middleware”Middleware wraps resolvers, for concerns such as auth, logging and caching. A middleware receives the field’s { root, args, ctx, info, bypass } and a next function that runs the rest of the chain:
import type { MiddlewareFn } from "@nxara/olio";
const requireAuth = (): MiddlewareFn => ({ root, args, ctx, info }, next) => { if (!ctx.userId) throw new Error("Unauthorized"); return next(); };Middleware is nested, { Type: { field | "*": [...] } }. For each resolver, the chain runs top-down:
- app
*.* - app
Type.* - app
Type.field - module
Type.* - module
Type.field - feature
Type.*(for a feature’s field) - feature
Type.field
Each layer targets different fields:
- The application addresses root types only, under whatever names the schema gives them: with
schema { query: RootQuery }you writemw({ RootQuery: { … } })andexport const RootQuery = query.users({ … }). - A module addresses the fields it and its features declare, including fields they add to other modules’ types with
extend. - A feature addresses only its own fields.
Middleware wraps the resolvers modules define, and any field it names explicitly. "*" keys only reach fields with a resolver, but naming a field (User: { email: [redact()] }) wraps GraphQL’s default resolver when the field has none, so the middleware always runs.
Every key is checked when olio() builds the schema, even without codegen. A key that matches no field, or an application key on a non-root type, throws.
How a middleware is typed depends on where it’s written:
- Inline under a field key, in
mw.<id>()or a module’smiddleware, it gets that field’s types:rootis its parent (the mapper, if any) andargsits arguments. Somw.users({ User: { email: [({ root }, next) => …] } })sees theUserrow. Root types haveroot: undefined. - Under a
"*"key, the types stay loose, since the key spans fields. - On its own, like
requireAuth, it’s typedMiddlewareFn:root: unknownandargs: Record<string, unknown>, so it fits any query or mutation key. - With a separate
sub_ctx,"*"andSubscriptionkeys needMiddlewareFn<Context | SubscriptionContext>orMiddlewareFn<SubscriptionContext>(see Subscriptions).
Skipping middleware: @bypass
Section titled “Skipping middleware: @bypass”A field tells its middleware what to skip with @bypass in the SDL. Each middleware then decides for itself:
extend type Query { me: Employee @bypass(keys: "auth")}
# On a type, or an extend block: every field declared in that block.extend type Mutation @bypass(keys: ["auth", "audit"]) { login(email: String!, password: String!): LoginResult!}const requireAuth = (): MiddlewareFn => ({ ctx, bypass }, next) => { if (!ctx.userId && !bypass.has("auth")) throw unauthenticated(); return next(); };- Per field, never per request.
bypassholds the keys of the field being resolved, so markinglogindoesn’t relax anything else in the same operation. - Where it goes. On a field of an object type, or on an object type (definition or
extend), where it covers the fields declared in that block. A block in one module doesn’t reach another module’s fields on the same type. Anywhere else is an error with its location. - Out of the schema. olio and codegen read and remove it while composing, so it isn’t in the served schema,
schema.gqlor introspection. You don’t need to declare it.bypassDefinitionfrom@nxara/olio-schemais its SDL, for editor tooling.@copydoesn’t carry it to copied fields. - Typed. With codegen,
bypassis aReadonlySetof the keys used anywhere in the SDL (BypassKey), sobypass.has("auht")is a compile error. Untyped, it’s a set of strings. - Nothing is dropped for you. Other middleware, such as a logger, keeps running on a field marked
@bypass(keys: "auth").
Subscriptions
Section titled “Subscriptions”A subscription field is a function that returns an async iterable of the field’s values. Prefer the event source’s own iterator, filtered at the source:
import { subscription } from "@nxara/olio";
import { pubsub } from "../../pubsub.js";
export const Subscription = subscription.users({ userUpdated: (_root, { id }) => pubsub.subscribe("user:updated", (user) => user.id === id)});An async generator works too, for mapping events (async *userUpdated() { for await (const row of …) yield … }), with one catch: while it waits inside for await, it can only end at its next event. graphql-ws waits for that before running onComplete and onClose, so a client that leaves while nothing happens isn’t cleaned up until something does. A source whose return() ends it at once, like the one in apps/example-server/src/pubsub.ts, doesn’t have that problem.
- Each yielded value is the field’s value, checked against the field type (with mappers applied). Nested field resolvers run for every event.
- Middleware runs once, when a client subscribes, which is the place for auth and logging. Per-event logic belongs in the iterator.
- Context: subscription resolvers and
Subscriptionmiddleware getsub_ctx, and"*"middleware can run on either kind: type itMiddlewareFn<Context | SubscriptionContext>. Without a separatesub_ctx, both are justctx. - The event source is yours (in-memory, Redis, …).
apps/example-server/src/pubsub.tshas a small typed in-memory one. - Serving them: run graphql-ws or graphql-sse with
app.schema.@nxara/olio-http’s docs has the setup.
Field extensions
Section titled “Field extensions”A field’s resolver can also be an object, { resolve?, extensions? }, which attaches extensions to the schema field. Tools read them from there: graphql-query-complexity’s complexity, Grafast’s plans, or your own middleware through info.parentType.getFields()[info.fieldName]?.extensions. Read there, registered keys keep their types (complexity is a number, or a function of { args, childComplexity }), and graphql’s index signature still lets any other key through as unknown.
export const Query = query.inventory({ products: { resolve: (_root, { filter }) => listProducts(filter), extensions: { complexity: ({ args, childComplexity }) => (args.filter?.pageSize ?? 20) * childComplexity } }});resolvers.users.User({ email: { extensions: { roles: ["ADMIN"] } } }); // no resolve: the default resolver staysExtensions are typed: register the keys they may hold, and any other key is a compile error. Source and Args are the field’s parent and arguments, for extensions that are functions:
declare module "@nxara/olio" { interface FieldExtensions<Source, Args> { // graphql-query-complexity's shape: the field's args and its selection's cost complexity?: | number | ((estimate: { args: Args; childComplexity: number }) => number); roles?: Role[]; }}- Keys a library adds to graphql’s own
GraphQLFieldExtensionscount too. - Middleware still wraps
resolve. - Subscription fields take the same form, with the async generator as
resolve. - At runtime, the object may hold only
resolveandextensions. - Without codegen, extensions are a plain
Record<string, unknown>.
Mappers
Section titled “Mappers”By default, a resolver returns the generated type: a User resolver must return a full User. To return what you store instead, and let field resolvers fill in the rest, register a mapper:
import type { UserRow } from "./db.js";import type { Application } from "./generated.js";
declare module "@nxara/olio" { interface Olio { application: Application; mappers: { User: UserRow }; }}Then every resolver that returns a User returns a UserRow. That holds through lists, nulls, unions and the fields of other types, so UserPage.data takes rows too. User field resolvers also receive the row as root.
Mappers work for object types and interfaces, keyed by GraphQL type name:
- An interface with a mapper (
mappers: { Node: NodeRow }): fields returning it return aNodeRow, and__resolveTypereceives one. - An interface without a mapper: fields returning it return any implementer, each as its own mapper (
UserRow | Post), because that is what graphql resolves.__resolveTypereceives the same union. - Unions map member by member.
Enums with internal values
Section titled “Enums with internal values”An enum can have internal values: what resolvers return and receive in place of its GraphQL names, such as numbers. Their types are registered in codegen (CustomEnums) rather than as mappers, because they also change argument and input types. See codegen’s docs.
- The values go in the owning module’s (or feature’s)
enumsoption, never inresolvers. OnceCustomEnumsregisters an enum,enumsis required, so they can’t be forgotten. - In their own file,
resolvers.<id>.<Enum>({ ... })types a map, andloadEnumsfrom@nxara/olio-toolsloads such files (unchecked, likeloadResolvers). - In an untyped app, nothing knows values were expected, so a resolver returning an internal value no module mapped fails with
Enum "Role" cannot represent value: 1. No module gave "Role" internal values: ....
Field types
Section titled “Field types”Resolvers built with the builders are typed for you. For code outside them, such as services, loaders and tests, take the types from generated.ts and olio:
import type { Fields } from "@nxara/olio";
import type { LoginInput, Role, Scalars, User } from "./generated.js";
type UserArgs = Fields["Query"]["user"]["args"]; // { id: string }type UserResult = Fields["Query"]["user"]["result"]; // User | null (UserRow | null with mappers)type NameArgs = Fields["User"]["name"]["args"]; // { trim?: boolean | null }type When = Scalars["DateTime"]["input"]; // a scalar, by direction- Object, input, enum, union and interface types are exported by name from
generated.ts. Fieldscovers every field a module declares, whichever module that is. It’s plain indexed access, so your editor completes the type, then the field, thenargs/result.resultis what the resolver returns, so with mappers it’s the mapped type.- An argument with an SDL default (
limit: Int = 10) is a required key typedMaybe<T>: graphql fills in the default before the resolver runs, but a client can still sendnull. A service called from elsewhere takes its own parameter type, e.g.{ term: string; limit?: number | null }.
Without codegen or tools
Section titled “Without codegen or tools”olio needs neither @nxara/olio-codegen nor @nxara/olio-tools. Write SDL as strings and resolvers as plain maps keyed by type, and build modules with createModule / createFeature. Everything works the same at runtime (middleware layers, features, enum values, subscriptions), only with looser types:
import { createFeature, createModule, mw, olio } from "@nxara/olio";
const users = createModule("users", { typeDefs: "type User { id: ID! name: String! } extend type Query { user(id: ID!): User }", // root: unknown, args: Record<string, unknown>, ctx: your registered ctx resolvers: { Query: { user: (_root, args) => ({ id: String(args.id), name: "Ada" }) } }, middleware: { Query: { "*": [audit()] } }, features: [ createFeature("users", "admin", { typeDefs: "extend type Mutation { ban(id: ID!): Boolean! }", resolvers: { Mutation: { ban: () => true } } }) ]});
export const app = olio({ middleware: mw({ Query: { "*": [requireAuth()] } }), modules: [users]});- Use
createModule(id, config)andcreateFeature(module, id, config). Without anApplicationthere are no known ids, somodule.<id>,query.<id>and friends have nothing to check against. - Key resolver maps by type:
{ Query: { user } }. The builders (query.users({ user })) return the inner map, so wrap what they return the same way:{ Query: query.users({ user }) }.loadResolversdoes this for you, using export names. - You can still register
ctx(andsub_ctx) without anApplication, so middleware and resolvers get a typed context. - To get the fully typed builders without running codegen, write the
Applicationtype by hand. It’s a plain type: per module (and"module/feature"), each type’s{ type; fields; args? }, root types withroot: "query" | "mutation" | "subscription", abstract types withresolveType, enums withfields: never; enum: "A" | "B", scalars withfields: never; scalar: true. Codegen’s docs shows the format.