Skip to content

@nxara/olio-schema

Compose module SDL into one validated GraphQL schema.

@nxara/olio-schema is the step between your .gql files and a schema. Codegen and olio both go through it, so they always agree on the schema and on which module owns which field. You rarely call it yourself, but you choose its directives. It provides:

  • compose(modules, directives): merges every module’s SDL, adds root types that modules only extend, applies directives, validates, and reports which module declares each field.
  • @implements and @copy: schema-design directives that save you from repeating fields across types. The output is ordinary GraphQL: the directives, and their definitions if you declared them, are removed.
Terminal window
bun add @nxara/olio-schema graphql@16

graphql 16 is a peer dependency.

import { compose } from "@nxara/olio-schema";
const { document, schema, ownership, roots, bypass } = compose(
[
{ id: "users", typeDefs: [usersDoc] },
{ id: "posts", typeDefs: [postsDoc] }
],
directives
);
  • Root types come for free. If modules only extend type Query, a type Query is added, so no module needs a placeholder _empty field.
  • Root names are yours. With schema { query: RootQuery } (or extend schema), the roots are the types named there; without a schema definition, GraphQL’s defaults apply. roots reports the ones the schema has: { query: "RootQuery" }, never a default with no type behind it.
  • ownership maps module id → type → the fields that module declares, in a definition or an extend. The defining module also owns __isTypeOf or __resolveType. Enum values are owned the same way, so only the declaring module can give them internal values. A scalar is owned by the module that defines it (marked __scalar).
  • Errors point at files. SDL and schema errors are collected and reported as file:line:column: message, using each document’s Source name. So parse with one (parse(new Source(sdl, "users/user.gql"))), as loadTypeDefs does. A duplicate lists every place it’s declared (also at …).
  • bypass (Map<"Type.field", Set<key>>) holds the keys @bypass(keys: …) gives each field: on a field, or on an object type or extend block for the fields it declares. Every @bypass is removed from document and schema before the other directives run, so @copy and @implements don’t carry it to the fields they copy. It needs no declaration; bypassDefinition holds its SDL for editor tooling. olio hands the keys to middleware (see olio’s docs).

Codegen and olio call compose for you, with the directives you pass them.

Pass the same list to codegen and to olio, so both see the same schema:

directives.ts
import { copyDirective, implementsDirective } from "@nxara/olio-schema";
export const directives = [implementsDirective(), copyDirective()];
// codegen.ts
await codegen({
modules: "src/modules",
typeDefs: "**/typedefs/**/*.gql",
output: "src/generated.ts",
directives
});
// index.ts
export const app = olio({ modules, directives });

Codegen records the directives’ names in Application ("~directives": "implements" | "copy"), so in a typed app olio() doesn’t compile without the same ones. For that check, a directive’s name has a literal type (SchemaDirective<"copy">).

Or apply them yourself:

import { applyDirectives } from "@nxara/olio-schema";
const document = applyDirectives(parse(sdl), directives);

Only the directives you pass are enabled; compose reports any other as Unknown directive "@copy". Their order doesn’t matter. applyDirectives runs in passes, and a type waits until the types it copies from are done. So chains work (C copies B, which copies A, which implements Node), and a cycle is an error.

@implements goes on object types and their extensions. It adds the interfaces to the type’s implements list and copies their fields:

interface Node {
id: ID!
}
interface Timestamped {
createdAt: DateTime!
}
type User @implements(from: ["Node", "Timestamped"]) {
name: String!
}

becomes:

type User implements Node & Timestamped {
id: ID!
createdAt: DateTime!
name: String!
}

Interface fields can’t be overridden. Redeclaring one with a different type, arguments or directives, whether locally or from a second interface, is an error, so a mismatch shows up when the SDL loads. Repeating a field exactly, for example to add a description, is fine.

@copy goes on object types, input types and their extensions. It copies fields without adding an implements clause. Objects copy from objects and interfaces, inputs from inputs:

input CommonPagination {
cursor: String
limit: Int
}
input UserPagination @copy(from: ["CommonPagination"]) {
cursor: ID
}

becomes:

input UserPagination {
cursor: ID
limit: Int
}

Unlike @implements, @copy allows overriding, with spread semantics ({ ...A, ...B, ...local }): a later source overrides an earlier one, local fields override every source, and each field keeps the position where it first appeared.

  • from takes a list of type names, or one name: @copy(from: "Base").

  • On an extension, fields the type already declares elsewhere are skipped, so a module can extend type User @implements(from: ["Timestamped"]).

  • Errors carry the file, line and column, and name the target type:

    users/user.gql:4:11: @copy on "User" references unknown type "Nope"

A directive is a SchemaDirective:

  • name.
  • definition: its SDL, for editor tooling. It isn’t added to the schema.
  • validate(document) (optional): runs before any transform.
  • transform(document, context): a transform that reads from other types should skip a target while context.isPending(source) is true. applyDirectives calls it again on the next pass.

Passes run while any type still carries one of the directives: on the type itself, a field, an argument or an enum value. A transform removes the usages it handles; a pass that removes none fails as a cycle.