mochi - Code First GraphQL for Gleam

๐Ÿ“š Documentation on hexdocs ยท ๐Ÿ“ฆ Package on hex.pm

mochi is a type-safe, Code First GraphQL library for Gleam. Define your GraphQL schemas using Gleam types and automatically generate TypeScript types and GraphQL SDL.

Inspired by:

Installation

gleam add mochi

Quick Start

import gleam/dynamic/decode
import mochi/decoders as md
import mochi/query
import mochi/schema
import mochi/types
// 1. Define your Gleam types
pub type User {
User(id: String, name: String, email: String, age: Int)
}
// 2. Create GraphQL type with type-safe field extractors
fn user_type() -> schema.ObjectType {
types.object("User")
|> types.description("A user in the system")
|> types.id("id", fn(u: User) { u.id })
|> types.string("name", fn(u: User) { u.name })
|> types.string("email", fn(u: User) { u.email })
|> types.int("age", fn(u: User) { u.age })
|> types.build(decode_user)
}
// 2a. Decoder for the build callback. mochi round-trips field values
// through Dynamic during execution; the helpers in mochi/decoders
// collapse the common boilerplate.
fn decode_user(dyn) {
let decoder = {
use id <- decode.field("id", decode.string)
use name <- decode.field("name", decode.string)
use email <- md.optional_string("email")
use age <- md.optional_int("age")
decode.success(User(id:, name:, email:, age:))
}
md.build_with(decoder, "User", dyn)
}
// 3. Define queries
fn users_query() {
query.query(
name: "users",
returns: schema.list_type(schema.named_type("User")),
resolve: fn(_ctx) { Ok(get_users()) },
)
|> query.with_encoder(types.to_dynamic)
}
fn user_query() {
query.query_with_args(
name: "user",
args: [query.arg("id", schema.non_null(schema.id_type()))],
returns: schema.named_type("User"),
resolve: fn(args, _ctx) {
use id <- result.try(query.get_id(args, "id"))
get_user_by_id(id)
},
)
}
// 4. Build the schema
pub fn create_schema() -> schema.Schema {
query.new()
|> query.add_query(users_query())
|> query.add_query(user_query())
|> query.add_type(user_type())
|> query.build
}
// 5. Execute queries
pub fn main() {
let my_schema = create_schema()
// `execution_context` takes any value โ€” your app's typed context.
// Read it back inside resolvers via `schema.context_accessor`.
let ctx = schema.execution_context(Nil)
let result = executor.execute(my_schema, ctx, "{ users { id name } }", dict.new(), option.None)
}

Performance

Mochi is built for performance on the BEAM VM.

Test system: AMD Ryzen 7 PRO 7840U (8 cores) ยท 64 GB RAM ยท 4 wrk threads ยท 100 connections ยท 10s runs ยท all servers in Docker ยท mochi v2.0.0

All servers run in Docker on the same bridge network. wrk runs on the host and hits each container via its mapped port.

No document cache โ€” parse + validate + execute every request

Simple query: { users { id name } }

ServerRuntimeReq/secLatency
mochiGleam / BEAM16,4776.07ms
bun + yogaBun11,9708.39ms
yoga (node)Node.js9,70012.98ms
apolloNode.js3,57643.81ms
mercuriusNode.js + Fastify3,30765.78ms

Medium query: { users { id name email posts { id title } } }

ServerRuntimeReq/secLatency
mochiGleam / BEAM7,52213.26ms
bun + yogaBun6,52815.20ms
yoga (node)Node.js4,93824.03ms
mercuriusNode.js + Fastify2,83450.33ms
apolloNode.js1,95478.72ms

With document cache โ€” skip parse + validate on repeated queries

Simple query: { users { id name } }

ServerRuntimeReq/secLatency
mochiGleam / BEAM16,3906.10ms
bun + yogaBun13,3797.47ms
mercuriusNode.js + Fastify10,65812.94ms
yoga (node)Node.js8,68913.49ms
apolloNode.js6,41519.42ms

Medium query: { users { id name email posts { id title } } }

ServerRuntimeReq/secLatency
mochiGleam / BEAM6,71814.85ms
bun + yogaBun5,57217.91ms
yoga (node)Node.js4,52925.79ms
mercuriusNode.js + Fastify3,77347.90ms
apolloNode.js2,54274.33ms

Why mochi is fast

BEAM scheduler vs. single-threaded JS event loop. Node.js serialises all requests through one event loop. The BEAM runs a scheduler per CPU core, each handling thousands of lightweight processes simultaneously. Under 100 concurrent connections mochi saturates all cores; Node.js cannot without clustering.

No shared-heap GC pauses. Every request on the BEAM lives in its own process heap. When a request finishes, that heap is reclaimed instantly โ€” no stop-the-world pause. V8 has a single shared heap across all in-flight requests, so GC pauses add latency spikes for every concurrent request.

Flat execution path. Gleam pattern matching on union types compiles to native BEAM tagged-tuple dispatch. There are no promise chains, middleware stacks, or resolver-wrapping layers.

Note on the cache results. The Node.js servers gain 2โ€“3ร— throughput from caching because parse + validate dominates their request cost. Mochi's throughput barely moves with cache enabled, and direction can flip between query sizes โ€” see the medians from a 5-run mochi-only sweep at 100 connections ร— 10s:

QueryNo cache (median)With cache (median)ฮ”
simple (46 bytes)17,47416,860โˆ’3.5%
medium (50 bytes, heavier execution)7,9038,264+4.6%

Both gaps are at the edge of run-to-run variance. The reason cache barely helps mochi: after the lexer rewrite, parsing a small query takes ~3 ยตs out of a ~6 ms request โ€” replacing it with a 250 ns ets:lookup saves 0.05% of request time, well below the wrk noise floor. As parse cost scales with query size and complexity, the saving grows; an in-process measurement on a 700-byte query shows parse 122 ยตs vs cached lookup 2 ยตs (60ร— difference) and the cache becomes an unambiguous win.

The cache is therefore mostly useful in mochi for large queries (deeply nested, lots of fields, fragments) โ€” exactly the cases where parsing actually costs something. For the small queries common in microservice traffic, mochi's parser is fast enough that caching is unnecessary. See mochi/test/perf_bench.gleam for the in-process numbers.

Running the benchmarks

cd examples/mochi_wisp/benchmark
./run-host-bench.sh # runs both rounds automatically

Features

TypeScript Codegen

Generate TypeScript type definitions from your schema:

import mochi_codegen
let ts_code = mochi_codegen.to_typescript(schema)
// Write to: types.generated.ts

Output:

// Generated by mochi
export type Maybe<T> = T | null | undefined;
export type Scalars = {
ID: string;
String: string;
Int: number;
Float: number;
Boolean: boolean;
};
export interface User {
id: Scalars["ID"];
name?: Maybe<Scalars["String"]>;
email?: Maybe<Scalars["String"]>;
age?: Maybe<Scalars["Int"]>;
}
export interface QueryUserArgs {
id: Scalars["ID"];
}
export interface Query {
user(args: QueryUserArgs): Maybe<User>;
users: Maybe<Maybe<User>[]>;
}

SDL Generation

Generate GraphQL SDL from your schema:

import mochi_codegen
let graphql_schema = mochi_codegen.to_sdl(schema)
// Write to: schema.graphql

Output:

# Generated by mochi
"A user in the system"
type User {
id: ID!
name: String
email: String
age: Int
}
type Query {
"Get a user by ID"
user(id: ID!): User
"Get all users"
users: [User]!
}

SDL Type Extensions

Split large schemas across multiple files using GraphQL type extensions. The CLI merges all files and resolves extensions before codegen.

# schema.graphql
type Mutation {
login(email: String!, password: String!): String!
}
# tournament.graphql
extend type Mutation {
finalizeTournament(tournamentId: ID!): Tournament!
}

All six extension kinds are supported: extend type, extend interface, extend union, extend enum, extend input, extend scalar.

Rules:

Configure multiple schema files in mochi.config.yaml:

schema:
- "schema.graphql"
- "tournament.graphql"

Or use a glob pattern:

schema: "graphql/**/*.graphql"

API Reference

Type Builders (mochi/types)

Build GraphQL object types with type-safe field extractors. See module docs for full API.

import mochi/types
// Object type with field extractors
let user_type = types.object("User")
|> types.id("id", fn(u: User) { u.id })
|> types.string("name", fn(u: User) { u.name })
|> types.int("age", fn(u: User) { u.age })
|> types.build(decode_user)
// Enum type
let role_enum = types.enum_type("Role")
|> types.value("ADMIN")
|> types.value("USER")
|> types.build_enum
// Dynamic conversion helpers for DataLoader encoders
fn user_to_dynamic(u: User) -> Dynamic {
types.record([
types.field("id", u.id),
types.field("name", u.name),
#("age", types.option(u.age)), // Option -> null if None
])
}

Query Builders (mochi/query)

Define queries and mutations with type-safe resolvers. See module docs for full API.

import mochi/query
// Query with arguments
let user_query = query.query_with_args(
name: "user",
args: [query.arg("id", schema.non_null(schema.id_type()))],
returns: schema.named_type("User"),
resolve: fn(args, ctx) {
use id <- result.try(query.get_id(args, "id"))
get_user_by_id(id)
},
)
// Build schema
let my_schema = query.new()
|> query.add_query(user_query)
|> query.add_type(user_type)
|> query.build

Argument Parsing Helpers

Resolvers receive arguments as a mochi/args.Args opaque type โ€” typed access without exposing Dict(String, Dynamic) in your code. Every query.get_* helper accepts Args directly:

// Required arguments (return Result)
query.get_string(args, "name") // -> Result(String, GqlError)
query.get_id(args, "id") // -> Result(String, GqlError)
query.get_int(args, "age") // -> Result(Int, GqlError)
query.get_float(args, "price") // -> Result(Float, GqlError)
query.get_bool(args, "active") // -> Result(Bool, GqlError)
// Optional arguments (return Option)
query.get_optional_string(args, "filter") // -> Option(String)
query.get_optional_int(args, "limit") // -> Option(Int)
// List arguments
query.get_string_list(args, "tags") // -> Result(List(String), GqlError)
query.get_int_list(args, "ids") // -> Result(List(Int), GqlError)
// Decode a nested input object via a stdlib decoder
query.decode_input(args, "input", input_decoder) // -> Result(a, GqlError)

The same accessors are also available without the GqlError wrapping in mochi/args directly (returning a structured ArgError) โ€” useful when you want to translate to your own error type.

Decoder Helpers (mochi/decoders)

Small helpers that collapse boilerplate in the types.build(decoder) callback. mochi round-trips field values through Dynamic during execution, so every ObjectType needs a fn(Dynamic) -> Result(t, String) decoder; the helpers below shrink the most common cases. They follow gleam_stdlib's continuation-passing convention so they compose with use-bindings.

import gleam/dynamic/decode
import mochi/decoders as md
fn decode_user(dyn) {
let decoder = {
use id <- decode.field("id", decode.string)
use email <- md.optional_string("email") // default ""
use age <- md.optional_int("age") // default 0
use enabled <- md.optional_bool("enabled") // default False
use friends <- md.list_filtering("friends", decode_user)
decode.success(User(id:, email:, age:, enabled:, friends:))
}
md.build_with(decoder, "User", dyn)
}
HelperUse case
build_with(decoder, type_name, dyn)Run a stdlib decoder + tag the error with the GraphQL type name. The first inner DecodeError (expected/found/path) is included in the message.
optional_string(name) / optional_int(name) / optional_bool(name)Common output-decoder fallbacks. Continuation-passing form for use-binding.
list_filtering(name, item)Optional list field whose items are decoded via a per-item callback; malformed items are silently dropped, missing field defaults to [].

Output decoders only. The optional_* helpers conflate "field absent" with "field present but defaulted." That's correct for types.build callbacks (mochi only invokes them on schema-conforming output values) but wrong for input validation โ€” never use these for mutation arguments where "user sent nothing" must differ from "user sent an empty value."

Schema Module (mochi/schema)

Low-level schema building and type definitions. See module docs for full API.

import mochi/schema
// Field types
schema.string_type() // String
schema.int_type() // Int
schema.list_type(inner) // [Type]
schema.non_null(inner) // Type!
schema.named_type("User") // Custom type
// Interface and union types
let node = schema.interface("Node")
|> schema.interface_field(schema.field_def("id", schema.non_null(schema.id_type())))
let search = schema.union("SearchResult")
|> schema.union_member(user_type)
|> schema.union_member(post_type)

User context (schema.UserContext)

Resolvers expect a specific user-context type โ€” the one your app configured. The execution context wraps that value opaquely so the API doesn't pretend to accept "any data here":

// Construct: pass any app-defined value
let ctx = schema.execution_context(MyAppContext(user_id: "u1", db: pool))
// Read inside a resolver: define an accessor once, use everywhere
pub const get_app_ctx = schema.context_accessor(my_app_context_decoder)
fn some_resolver(args, ctx) {
use app <- result.try(get_app_ctx(ctx))
// app: MyAppContext
...
}

schema.user_context(value) and schema.read_user_context(uc, decoder) are the lower-level constructor/reader if you don't want a pre-bound accessor.

Custom Directives

Define custom directives for your schema. See module docs for full API.

import mochi/schema
let auth = schema.directive("auth", [schema.FieldLocation])
|> schema.directive_argument(schema.arg("role", schema.string_type()))
|> schema.directive_handler(fn(args, value) { Ok(value) })

Guards

Guards are lightweight precondition checks that run before a resolver. If a guard returns Ok(Nil), the resolver proceeds. If it returns Error(message), the resolver is skipped entirely. See mochi/docs/guards.md for the full guide.

// Define a reusable guard
fn require_auth(ctx: schema.ExecutionContext) -> Result(Nil, String) {
case get_current_user(ctx) {
Some(_) -> Ok(Nil)
None -> Error("Authentication required")
}
}
// High-level API: attach to queries, mutations, or fields
let my_posts = query.query_with_args(name: "myPosts", ...)
|> query.with_guard(require_auth)
let create_post = query.mutation(name: "createPost", ...)
|> query.with_guard(require_auth)
// Low-level API: attach directly to field definitions
schema.field_def("secret", schema.string_type())
|> schema.resolver(my_resolver)
|> schema.guard(require_auth_guard)
// Multiple guards (checked in list order)
|> schema.guards([require_auth_guard, require_admin_guard])

Subscriptions (mochi_transport)

Real-time updates with a PubSub pattern over WebSocket or SSE. Provided by the mochi_transport package.

import mochi_transport/subscription
import mochi_transport/websocket
let pubsub = subscription.new()
let state = websocket.new_connection(schema, pubsub, ctx)
subscription.publish(pubsub, subscription.topic("user:created"), user_data)

Error Handling (mochi/error)

GraphQL-spec compliant errors with extensions. See module docs for full API.

import mochi/error
let err = error.new("Something went wrong")
|> error.with_code("INTERNAL_ERROR")
|> error.with_extension("retryAfter", types.to_dynamic(60))

Response Handling (mochi/response)

Construct and serialize GraphQL responses. See module docs for full API.

import mochi/response
let resp = response.from_execution_result(exec_result)
let json_string = response.to_json(resp)

JSON Serialization (mochi/json)

Built-in JSON encoding. Returns Result so unsupported runtime shapes (tuples, functions, references, โ€ฆ) surface as errors rather than silently encoding as null.

import mochi/json
let assert Ok(json_string) = json.encode(dynamic_value)
let assert Ok(pretty) = json.encode_pretty(dynamic_value, 2)
// Inspect failures
case json.encode(value) {
Ok(s) -> use_response(s)
Error(e) -> log_error(json.describe_error(e))
}

Parse Caching (mochi/document_cache)

The document cache is enabled automatically when you build a schema with query.build. Parsed ast.Document values are stored in an ETS table (Erlang) or Map (JavaScript) keyed by the query string. Repeated requests for the same query skip the parser entirely.

After the v2.0 lexer rewrite, parsing a small query (~50 bytes) takes ~3 ยตs. The cache only pays off when parse cost meaningfully exceeds the lookup + term-copy cost โ€” so by default queries below 200 bytes bypass the cache and just re-parse. Above the threshold, caching is unambiguously a win (a 700-byte query parses in ~120 ยตs vs ~2 ยตs for a hit).

Defaults:

import mochi/document_cache
// Automatic (via query.build โ€” recommended)
let schema = query.new() |> ... |> query.build
// Manual config โ€” for example to cap entries lower or change the threshold
let cache = document_cache.new_with_min_size(500, 0) // size 500, no skip
let schema = schema.schema()
|> schema.with_document_cache(cache)
|> ...

Batch Execution (mochi/batch)

Execute multiple GraphQL requests in a single call. Useful for HTTP batch endpoints.

import mochi/batch
let requests = [
batch.request("{ users { id name } }"),
batch.request_with_variables("{ user(id: $id) { name } }", vars),
batch.request_with_operation("query GetMe { me { id } }", "GetMe"),
]
let config = batch.default_config()
|> batch.with_max_batch_size(10)
|> batch.with_parallel_execution(True) // spawn one Erlang process per request
let result = batch.execute_batch(schema, requests, config, ctx)
// result.results -> List(ExecutionResult) in original order
// result.all_succeeded -> Bool
// result.failure_count -> Int

Query Security (mochi/security)

Protect against malicious queries. See module docs for full API.

import mochi/security
case security.validate(document, security.default_config()) {
Ok(_) -> execute_query(document)
Error(err) -> error_response(err)
}

Automatic Persisted Queries (mochi/apq)

APQ is built into the core โ€” no extra package needed. Clients send a SHA256 hash instead of the full query string on repeat requests, reducing bandwidth on mobile or high-latency networks.

The protocol works in two passes:

  1. First request โ€” client sends { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": "<hash>" } } } with no query field. Server responds with PersistedQueryNotFound.
  2. Second request โ€” client resends with both query and extensions. Server verifies the hash, stores the query, and executes it.
  3. All subsequent requests โ€” client sends hash only. Server looks it up and executes directly.

Wire it into your HTTP handler by holding an apq.Store in your server state:

import gleam/dict
import gleam/option.{None, Some}
import mochi/apq
import mochi/executor
// Server state โ€” hold this across requests (e.g. in an Agent or ETS)
let store = apq.new()
// In your request handler:
fn handle_graphql(body: String, store: apq.Store) -> #(apq.Store, String) {
let #(query_opt, extensions) = parse_request(body)
case apq.parse_extension(extensions) {
None -> {
// Normal request โ€” no APQ
let result = executor.execute_query_with_context(schema, query_opt, ...)
#(store, respond(result))
}
Some(ext) -> {
case apq.process(store, query_opt, ext.sha256_hash) {
Ok(#(store, query)) -> {
let result = executor.execute_query_with_context(schema, query, ...)
#(store, respond(result))
}
Error(apq.NotFound) ->
#(store, persisted_query_not_found_response())
Error(apq.HashMismatch(..)) ->
#(store, bad_request_response("hash mismatch"))
}
}
}
}

The apq.Store is an immutable dict โ€” you get a new one back from apq.process whenever a query is registered. Store it in an Erlang Agent or ETS table to share it across the process pool.

GraphQL Playgrounds (mochi_codegen)

Built-in interactive GraphQL explorers. Requires the mochi_codegen package.

import mochi_codegen
mochi_codegen.graphiql("/graphql") // GraphiQL IDE
mochi_codegen.apollo_sandbox("/graphql") // Apollo Sandbox

WebSocket / SSE Transport (mochi_transport)

Real-time subscriptions over WebSocket (graphql-ws protocol) or Server-Sent Events. Provided by the mochi_transport package.

import mochi_transport/websocket
let state = websocket.new_connection(schema, pubsub, ctx)
let result = websocket.handle_message(state, client_msg)

DataLoader (mochi/dataloader)

Prevent N+1 queries with automatic batching. See module docs for full API.

import mochi/dataloader
import mochi/schema
// Create loader from find function (one-liner)
let pokemon_loader = dataloader.int_loader_result(
data.find_pokemon, pokemon_to_dynamic, "Pokemon not found",
)
// Register loaders and load data
let ctx = schema.execution_context(Nil)
|> schema.with_loaders([#("pokemon", pokemon_loader)])
let #(ctx, result) = schema.load_by_id(ctx, "pokemon", 25)

Codegen (mochi_codegen)

Generate TypeScript types, GraphQL SDL, Gleam resolver stubs, and serve playground UIs. Requires the mochi_codegen package.

import mochi_codegen
let ts_code = mochi_codegen.to_typescript(schema)
let graphql_code = mochi_codegen.to_sdl(schema)

CLI โ€” generate everything from a config file:

gleam run -m mochi_codegen/cli -- init # create mochi.config.yaml
gleam run -m mochi_codegen/cli -- generate # generate from config

Operation resolver generation โ€” point the CLI at your .gql client operation files and it emits complete mochi field-builder boilerplate (decode blocks, resolve stubs, encoder stubs, and a register() function). Only fill in the resolve: body.

# mochi.config.yaml
schema: "graphql/schema.graphql"
operations_input: "src/graphql/**/*.gql"
output:
gleam_types: "src/api/domain/"
resolvers: "src/api/schema/"
operations: "src/api/schema/"
typescript: "apps/web/src/generated/types.ts"

Examples

See the mochi-examples repository for complete working examples:

Basic Schema Example

import mochi/query
import mochi/schema
import mochi/types
pub type User {
User(id: String, name: String, email: String)
}
pub type Post {
Post(id: String, title: String, author_id: String)
}
fn user_type() -> schema.ObjectType {
types.object("User")
|> types.id("id", fn(u: User) { u.id })
|> types.string("name", fn(u: User) { u.name })
|> types.string("email", fn(u: User) { u.email })
|> types.build(fn(_) { Ok(User("", "", "")) })
}
fn post_type() -> schema.ObjectType {
types.object("Post")
|> types.id("id", fn(p: Post) { p.id })
|> types.string("title", fn(p: Post) { p.title })
|> types.string("authorId", fn(p: Post) { p.author_id })
|> types.build(fn(_) { Ok(Post("", "", "")) })
}
pub fn create_schema() -> schema.Schema {
query.new()
|> query.add_query(
query.query(
name: "users",
returns: schema.list_type(schema.named_type("User")),
resolve: fn(_) { Ok([]) },
)
|> query.with_encoder(types.to_dynamic),
)
|> query.add_type(user_type())
|> query.add_type(post_type())
|> query.build
}

Mutation Example

pub type CreateUserInput {
CreateUserInput(name: String, email: String)
}
fn create_user_mutation() {
query.mutation_with_args(
name: "createUser",
args: [query.arg("input", schema.non_null(schema.named_type("CreateUserInput")))],
returns: schema.named_type("User"),
resolve: fn(args, _ctx) {
use input <- result.try(query.decode_input(args, "input", input_decoder))
let user = User(id: generate_id(), name: input.name, email: input.email)
db.insert_user(user)
Ok(user)
},
)
|> query.with_description("Create a new user")
}
let schema = query.new()
|> query.add_mutation(create_user_mutation())
|> query.build

Package Structure

Install only the packages you need:

gleam add mochi # Core (required)
gleam add mochi_codegen # SDL/TS codegen + CLI
gleam add mochi_relay # Relay cursor pagination
gleam add mochi_transport # graphql-ws + SSE subscriptions
gleam add mochi_upload # Multipart file uploads
PackagePurpose
mochiCore GraphQL engine
mochi_codegenSDL + TypeScript codegen + GraphiQL + CLI
mochi_relayRelay-style cursor pagination
mochi_transportWebSocket (graphql-ws) + SSE subscriptions
mochi_uploadMultipart file uploads

Automatic Persisted Queries (mochi/apq) is built into the core โ€” no separate package needed.

mochi/ # Core GraphQL engine
โ”œโ”€โ”€ query.gleam # Query/Mutation/Subscription builders
โ”œโ”€โ”€ types.gleam # Type builders (object, enum, fields)
โ”œโ”€โ”€ args.gleam # Typed Args opaque + accessors
โ”œโ”€โ”€ output.gleam # Typed Value tree used by JSON encoding
โ”œโ”€โ”€ schema.gleam # Core schema types, ExecutionContext, UserContext
โ”œโ”€โ”€ executor.gleam # Query execution with null propagation
โ”œโ”€โ”€ validation.gleam # Query validation
โ”œโ”€โ”€ document_cache.gleam # ETS-backed parse cache (Erlang + JS)
โ”œโ”€โ”€ batch.gleam # Batch query execution with parallel dispatch
โ”œโ”€โ”€ dataloader.gleam # N+1 query prevention
โ”œโ”€โ”€ error.gleam # GraphQL-spec compliant errors
โ”œโ”€โ”€ response.gleam # Response serialization
โ”œโ”€โ”€ security.gleam # Depth/complexity/alias limits
โ”œโ”€โ”€ apq.gleam # Automatic Persisted Queries
โ””โ”€โ”€ internal/ # Parser/lexer/AST/SDL โ€” undocumented surface
โ”œโ”€โ”€ ast.gleam
โ”œโ”€โ”€ lexer.gleam
โ”œโ”€โ”€ sdl_ast.gleam
โ”œโ”€โ”€ sdl_lexer.gleam
โ””โ”€โ”€ sdl_parser.gleam
mochi_relay/ # Relay cursor pagination
mochi_transport/ # graphql-ws WebSocket + SSE transports + PubSub
mochi_upload/ # GraphQL multipart file uploads
mochi_codegen/ # SDL + TypeScript + Gleam codegen + GraphiQL + CLI

Running Tests

gleam test

Development

gleam build # Build
gleam check # Check for warnings
gleam format src test # Format

License

Apache 2.0