Skip to content

@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.

Terminal window
bun add @nxara/olio @nxara/olio-tools graphql@16
bun add -d @nxara/olio-codegen

graphql 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).

Run codegen to write generated.ts from your modules’ SDL, then register its Application once. This is where every builder gets its types:

src/olio.d.ts
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 };
}
}
  • application is the generated type: which modules exist and what each one owns.
  • ctx is the context every resolver and middleware receives. Your server builds it per request (see @nxara/olio-http).
  • sub_ctx and mappers are optional (see Subscriptions and Mappers).

A module is built with module.<id>, where <id> is its folder name under modules/:

src/modules/users/index.ts
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:

query.ts
import { query } from "@nxara/olio";
export const Query = query.users({
user: (root, { id }, ctx, info) => findUser(id)
});
// resolvers/user.ts
import { 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.

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.ts
sales/orders/index.ts
import { 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.ts
import { 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 typeDefs glob finds files of its own there. With **/typedefs/**/*.gql, sales/orders/typedefs/ makes orders a feature, while sales/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, extend it (orders adds Customer.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.sales for fields the module itself declares).
  • Middleware nests. For a feature’s field, the chain runs app → module → feature. mw.sales reaches every feature’s fields, and mw.sales.orders reaches only the fields of orders.
  • 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.

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 (or Mutation), so nobody needs a placeholder type Query { _empty: String }. A module that only extends root types can also be tested alone, olio({ modules: [usersModule] }), as long as it adds a Query field. GraphQL requires one, so a module that only extends Mutation can’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 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:

  1. app *.*
  2. app Type.*
  3. app Type.field
  4. module Type.*
  5. module Type.field
  6. feature Type.* (for a feature’s field)
  7. 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 write mw({ RootQuery: { … } }) and export 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’s middleware, it gets that field’s types: root is its parent (the mapper, if any) and args its arguments. So mw.users({ User: { email: [({ root }, next) => …] } }) sees the User row. Root types have root: undefined.
  • Under a "*" key, the types stay loose, since the key spans fields.
  • On its own, like requireAuth, it’s typed MiddlewareFn: root: unknown and args: Record<string, unknown>, so it fits any query or mutation key.
  • With a separate sub_ctx, "*" and Subscription keys need MiddlewareFn<Context | SubscriptionContext> or MiddlewareFn<SubscriptionContext> (see Subscriptions).

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. bypass holds the keys of the field being resolved, so marking login doesn’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.gql or introspection. You don’t need to declare it. bypassDefinition from @nxara/olio-schema is its SDL, for editor tooling. @copy doesn’t carry it to copied fields.
  • Typed. With codegen, bypass is a ReadonlySet of the keys used anywhere in the SDL (BypassKey), so bypass.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").

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 Subscription middleware get sub_ctx, and "*" middleware can run on either kind: type it MiddlewareFn<Context | SubscriptionContext>. Without a separate sub_ctx, both are just ctx.
  • The event source is yours (in-memory, Redis, …). apps/example-server/src/pubsub.ts has 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.

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 stays

Extensions 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 GraphQLFieldExtensions count too.
  • Middleware still wraps resolve.
  • Subscription fields take the same form, with the async generator as resolve.
  • At runtime, the object may hold only resolve and extensions.
  • Without codegen, extensions are a plain Record<string, unknown>.

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:

src/olio.d.ts
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 a NodeRow, and __resolveType receives 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. __resolveType receives the same union.
  • Unions map member by member.

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) enums option, never in resolvers. Once CustomEnums registers an enum, enums is required, so they can’t be forgotten.
  • In their own file, resolvers.<id>.<Enum>({ ... }) types a map, and loadEnums from @nxara/olio-tools loads such files (unchecked, like loadResolvers).
  • 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: ....

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.
  • Fields covers every field a module declares, whichever module that is. It’s plain indexed access, so your editor completes the type, then the field, then args / result.
  • result is 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 typed Maybe<T>: graphql fills in the default before the resolver runs, but a client can still send null. A service called from elsewhere takes its own parameter type, e.g. { term: string; limit?: number | null }.

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) and createFeature(module, id, config). Without an Application there are no known ids, so module.<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 }) }. loadResolvers does this for you, using export names.
  • You can still register ctx (and sub_ctx) without an Application, so middleware and resolvers get a typed context.
  • To get the fully typed builders without running codegen, write the Application type by hand. It’s a plain type: per module (and "module/feature"), each type’s { type; fields; args? }, root types with root: "query" | "mutation" | "subscription", abstract types with resolveType, enums with fields: never; enum: "A" | "B", scalars with fields: never; scalar: true. Codegen’s docs shows the format.