@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.@implementsand@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.
Install
Section titled “Install”bun add @nxara/olio-schema graphql@16graphql 16 is a peer dependency.
compose
Section titled “compose”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, atype Queryis added, so no module needs a placeholder_emptyfield. - Root names are yours. With
schema { query: RootQuery }(orextend schema), the roots are the types named there; without a schema definition, GraphQL’s defaults apply.rootsreports the ones the schema has:{ query: "RootQuery" }, never a default with no type behind it. ownershipmaps module id → type → the fields that module declares, in a definition or anextend. The defining module also owns__isTypeOfor__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’sSourcename. So parse with one (parse(new Source(sdl, "users/user.gql"))), asloadTypeDefsdoes. 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 orextendblock for the fields it declares. Every@bypassis removed fromdocumentandschemabefore the other directives run, so@copyand@implementsdon’t carry it to the fields they copy. It needs no declaration;bypassDefinitionholds 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.
Directives
Section titled “Directives”Pass the same list to codegen and to olio, so both see the same schema:
import { copyDirective, implementsDirective } from "@nxara/olio-schema";
export const directives = [implementsDirective(), copyDirective()];
// codegen.tsawait codegen({ modules: "src/modules", typeDefs: "**/typedefs/**/*.gql", output: "src/generated.ts", directives});
// index.tsexport 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
Section titled “@implements”@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.
Rules for both directives
Section titled “Rules for both directives”-
fromtakes 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"
Writing a directive
Section titled “Writing a directive”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 whilecontext.isPending(source)is true.applyDirectivescalls 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.