first commit

This commit is contained in:
2026-05-29 08:20:56 +01:00
commit 949299f58d
6368 changed files with 527176 additions and 0 deletions

View File

@@ -0,0 +1,90 @@
# @suaveplan/api-graphql-yoga — GraphQL Yoga engine adapter for @suaveplan/api-graphql. Default GraphQL engine per design doc §10.5. Implements the abstract GraphqlEngine contract from @suaveplan/api-graphql via createYogaGraphqlEngine(opts) — Zod-validated YogaGraphqlOptions, federation through @graphql-tools/schema makeExecutableSchema, schema-collision detection delegated to the canonical GRAPHQL.SCHEMA_COLLISION code, per-request DataLoader scoping bound to RequestContext, depth + complexity guards, masked errors / introspection toggles, graphql-ws subscription transport over an injected api-ws broker, OTel instruments (requests counter, request duration histogram, resolver duration histogram, subscriptions_active gauge, schema_rebuilds counter), 10 GRAPHQL_YOGA_* error codes, and a ./testing subpath re-exporting runGraphqlEngineConformance bound to the Yoga factory.
Stage-7 GraphQL Yoga engine adapter. **Default GraphQL engine** for the
Suaveplan API plugin system per design doc §10.5 — Bun-friendly,
fastest for our usage pattern, and simpler to operate than Apollo
Server. Implements the abstract `GraphqlEngine` contract from
`@suaveplan/api-graphql` so the same conformance suite
(`runGraphqlEngineConformance`) passes against this adapter and any
future engine; swapping engines is host-config-only.
<!-- begin:auto-docs -->
## Documentation tree
- [execute-helpers](./src/engine/execute-helpers.md) — Houses the smaller execution helpers the Yoga engine uses on every request, kept out of `yoga-engine.ts` so the orchest…
- [yoga-engine](./src/engine/yoga-engine.md) — Implements the abstract `GraphqlEngine` contract from `@suaveplan/api-graphql` over the Yoga / `graphql-js` stack. The…
- [error-codes](./src/error-codes/error-codes.md) — Defines the frozen `GRAPHQL_YOGA.*` error-code registry and the `GraphqlYogaError` subclass of `SuaveplanError` used th…
- [error-codes](./src/error-codes/error-codes.md) — Defines the frozen `GRAPHQL_YOGA.*` error-code registry and the `GraphqlYogaError` subclass of `SuaveplanError` used th…
- [federation](./src/federation/federation.md) — Aggregates plugin-side `GraphqlRegistry` contributions into a single `(MergedSchema, ResolverMap)` pair that the Yoga e…
- [federation](./src/federation/federation.md) — Aggregates plugin-side `GraphqlRegistry` contributions into a single `(MergedSchema, ResolverMap)` pair that the Yoga e…
- [guards](./src/guards/depth-and-complexity.md) — Implements the static depth + complexity guard that the Yoga engine runs after parsing each query and before execution.…
- [depth-and-complexity](./src/guards/depth-and-complexity.md) — Implements the static depth + complexity guard that the Yoga engine runs after parsing each query and before execution.…
- [instruments](./src/instruments/instruments.md) — Builds and caches the OpenTelemetry instrument bundle the Yoga GraphQL engine emits per request. The bundle covers a co…
- [instruments](./src/instruments/instruments.md) — Builds and caches the OpenTelemetry instrument bundle the Yoga GraphQL engine emits per request. The bundle covers a co…
- [loaders](./src/loaders/loaders.md) — Defines the per-request loader bag the Yoga engine constructs at pipeline step 7 and disposes at step 11. Two concurren…
- [loaders](./src/loaders/loaders.md) — Defines the per-request loader bag the Yoga engine constructs at pipeline step 7 and disposes at step 11. Two concurren…
- [options](./src/options/options.md) — Defines `YogaGraphqlOptions`, the Zod-validated configuration boundary that hosts use to wire `createYogaGraphqlEngine`…
- [options](./src/options/options.md) — Defines `YogaGraphqlOptions`, the Zod-validated configuration boundary that hosts use to wire `createYogaGraphqlEngine`…
- [subscriptions](./src/subscriptions/subscriptions.md) — Bridges the `WsBroker` contract from `@suaveplan/api-ws` to GraphQL `Subscription` resolvers via `AsyncIterable<Payload…
- [subscriptions](./src/subscriptions/subscriptions.md) — Bridges the `WsBroker` contract from `@suaveplan/api-ws` to GraphQL `Subscription` resolvers via `AsyncIterable<Payload…
- [testing](./src/testing/testing.md) — Binds the abstract `runGraphqlEngineConformance` from `@suaveplan/api-graphql/testing` to the Yoga engine factory so co…
- [testing](./src/testing/testing.md) — Binds the abstract `runGraphqlEngineConformance` from `@suaveplan/api-graphql/testing` to the Yoga engine factory so co…
<!-- end:auto-docs -->
## Modules
- **engine** — `createYogaGraphqlEngine(config)` + helpers
(`maskOrPassthrough`, `withTimeout`, `assertIntrospectionAllowed`,
`wrapSubscription`).
- **federation** — `federatePlugins(entries)` aggregating multiple
plugin contributions into one `(MergedSchema, ResolverMap)` pair via
`mergeSdl` from `@suaveplan/api-graphql`.
- **options** — Zod-validated `YogaGraphqlOptions` +
`resolveYogaGraphqlOptions` with safe defaults
(`endpoint: "/graphql"`, `maxQueryDepth: 10`,
`maxQueryComplexity: 1000`, `timeoutMs: 30000`, `maskedErrors: true`).
- **guards** — `enforceGuards(doc, { maxDepth, maxComplexity })` static
depth + complexity rejection.
- **loaders** — `buildRequestLoaders(factories)` per-request DataLoader
bag bound to `RequestContext`.
- **subscriptions** — `subscribeBrokerTopic(broker, topic, tenant)`
bridges `api-ws` to GraphQL `AsyncIterable`.
- **instruments** — `getYogaInstruments()` OTel counter / histogram /
gauge bundle.
- **error-codes** — 10 `GRAPHQL_YOGA.*` codes + `GraphqlYogaError`.
- **testing** — `runYogaConformance(label?, config?)` bound conformance
harness.
## Quick start
```ts
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import { createYogaGraphqlEngine } from "@suaveplan/api-graphql-yoga";
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
const result = await engine.executeQuery({ query: "{ ping }" }, ctx);
```
## Why this engine?
- **Bun runtime parity** — graphql-yoga is a thin layer over `graphql-js`
and runs natively under Bun without polyfills.
- **WebSocket subscriptions over `api-ws`** — `graphql-ws` protocol
bridged to the WS broker via `subscribeBrokerTopic`.
- **Per-request DataLoader scoping** — design doc §10.3 mandates
per-request DataLoader instances; the `buildRequestLoaders` helper
enforces this at the engine boundary.
- **Depth + complexity guards** — denial-of-service rejection before
resolvers run.
- **Masked error mode** — flips production error messages to
`"Unexpected error."` while preserving the `path` for log-side
debugging.
## License
MIT — Suaveplan Team.

View File

@@ -0,0 +1,62 @@
# `execute-helpers.ts`
## Purpose
Houses the smaller execution helpers the Yoga engine uses on every
request, kept out of `yoga-engine.ts` so the orchestrator file stays
beneath the 300-line hard limit. The helpers cover error masking,
wall-clock timeouts, the introspection-toggle guard, and the wrapper that
adapts GraphQL's `subscribe()` Promise into the engine's
`AsyncIterable<GraphqlResponse>` contract.
The helpers are deliberately pure — they hold no state across calls — so
the engine can compose them per-request without worrying about leakage
across concurrent requests.
## Features
- `maskOrPassthrough` — flips messages to "Unexpected error." when
`maskedErrors` is on; preserves `path`.
- `withTimeout` — races a Promise against a `setTimeout`; clears the
timer on either outcome.
- `assertIntrospectionAllowed` — regex-rejects `__schema` / `__type`
references when the option is off.
- `wrapSubscription` — Promise-of-AsyncIterable to AsyncIterable.
## Usage: Basic
```ts
import { withTimeout } from "@suaveplan/api-graphql-yoga";
const result = await withTimeout(Promise.resolve("ok"), 5000);
```
## Usage: Advanced
```ts
import {
assertIntrospectionAllowed,
maskOrPassthrough,
} from "@suaveplan/api-graphql-yoga";
import { GraphQLError } from "graphql";
assertIntrospectionAllowed("{ ping }", { introspection: false } as never);
const masked = maskOrPassthrough(
[new GraphQLError("internal db unreachable")],
{ maskedErrors: true } as never
);
```
## API Reference
- `maskOrPassthrough(errors, options)`.
- `withTimeout(p, timeoutMs)`.
- `assertIntrospectionAllowed(documentSource, options)`.
- `wrapSubscription(pending, onClose)`.
## Implementation Notes
The introspection guard uses a regex over the raw query text rather than
walking the AST — the goal is a fast pre-execution rejection. The regex
catches every legal way to reference `__schema` / `__type` because GraphQL
forbids those identifiers in any non-introspection context.

View File

@@ -0,0 +1,100 @@
# `yoga-engine.ts`
## Purpose
Implements the abstract `GraphqlEngine` contract from
`@suaveplan/api-graphql` over the Yoga / `graphql-js` stack. The factory
`createYogaGraphqlEngine(config)` returns an engine that hosts can wire
into `api-host`'s adapter slot — it is the default GraphQL engine per
design doc §10.5.
The engine handles the full pipeline for every inbound request: persisted-
query resolution, document parse, introspection guard, depth+complexity
guards, validate, execute (with wall-clock timeout), and OTel publish.
`buildSchema` federates plugin contributions via
`@graphql-tools/schema`'s `makeExecutableSchema`, increments
`graphql.schema_rebuilds_total`, and caches the executable schema.
`executeSubscription` parses + validates and returns the GraphQL
subscription's `AsyncIterable`, tracking the live count via
`activeSubscriptions()` so the observable gauge can sample it.
## Features
- Schema federation across multiple plugin registries, with
`GRAPHQL.SCHEMA_COLLISION` raised at the abstract layer.
- 10 `GRAPHQL_YOGA.*` codes wired into the production paths.
- Masked-errors and introspection-toggle behaviour controllable per host.
- Per-request OTel counter + histogram emission with masked / unmasked
`outcome` labels.
- Persisted-query lookup via injectable `Map<string, string>` so the host
can hot-update the registry without rebuilding the engine.
- Observable `subscriptions_active` gauge that reflects the live count of
`executeSubscription` iterators.
## Usage: Basic
```ts
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import { createYogaGraphqlEngine } from "@suaveplan/api-graphql-yoga";
const engine = createYogaGraphqlEngine();
const registry = createGraphqlRegistry();
registry.addTypes("type Query { ping: String! }");
registry.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(registry);
const result = await engine.executeQuery(
{ query: "{ ping }" },
{} as never
);
// result.data.ping === "pong"
await engine.dispose();
```
## Usage: Advanced
```ts
import {
createYogaGraphqlEngine,
federatePlugins,
} from "@suaveplan/api-graphql-yoga";
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
const broker = createInMemoryWsBroker();
const billing = createGraphqlRegistry();
billing.addTypes("extend type Query { invoices: [String!]! }");
billing.addResolvers({ Query: { invoices: () => ["i_1"] } });
const engine = createYogaGraphqlEngine({
options: { maxQueryDepth: 8, maxQueryComplexity: 250 },
entries: [{ plugin: "billing", registry: billing as never }],
wsBroker: broker,
persistedQueries: new Map([["pq_1", "{ invoices }"]]),
});
const inline = createGraphqlRegistry();
inline.addTypes("type Query { ping: String! }");
inline.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(inline);
```
## API Reference
- `YogaEngineConfig` — `{ options?, entries?, wsBroker?, persistedQueries? }`.
- `YogaGraphqlEngine<Caps>` — engine handle extending
`GraphqlEngine<Caps>`.
- `createYogaGraphqlEngine<Caps>(config?)` — factory.
## Implementation Notes
`buildSchema` accepts a runtime-supplied registry alongside the pre-
registered `config.entries`; the runtime registry is appended as a final
`__inline__` plugin so callers that don't manage entries up-front (e.g.
the conformance harness) still drive the engine through the same path.
The introspection guard runs against the raw query text rather than the
parsed AST so that pre-parse, pre-guard rejection paths still produce a
deterministic `GRAPHQL_YOGA.INTROSPECTION_DENIED` instead of leaking
parse errors. The depth/complexity guard ALWAYS runs before `validate()`
so denial-of-service queries cost a parse + a walk, not a parse +
validate + execute.

View File

@@ -0,0 +1,71 @@
# `error-codes.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the frozen `GRAPHQL_YOGA.*` error-code registry and the
`GraphqlYogaError` subclass of `SuaveplanError` used throughout the
`@suaveplan/api-graphql-yoga` engine adapter. The registry is intentionally
disjoint from the abstract `GRAPHQL.*` codes owned by
`@suaveplan/api-graphql`: faults that any engine could surface (schema
collision, missing resolver, unknown directive) live on the abstract
namespace, while the ten codes here cover failures specific to Yoga's
execution pipeline — document parsing, validation, depth/complexity guards,
schema build, subscription transport, server-side timeout, introspection
toggle, persisted-query registry, and uncaught resolver throws.
## Features
- 10 stable codes wired through `defineCodes("GRAPHQL_YOGA", ...)`.
- Public `GraphqlYogaError` constructor (lint-rule compliant — see
`scripts/lint/check-error-subclass-constructors.ts`).
- `instanceof GraphqlYogaError` narrows `code` to `GraphqlYogaErrorCode`.
- `yogaCode` accessor for callers that already discriminated.
## Usage: Basic
```ts
import {
GRAPHQL_YOGA_CODES,
GraphqlYogaError,
} from "@suaveplan/api-graphql-yoga/error-codes";
throw new GraphqlYogaError(
GRAPHQL_YOGA_CODES.PARSE_FAILED,
"GraphQL document parse failed",
{ context: { query: "{ malformed" } }
);
```
## Usage: Advanced
```ts
import { GraphqlYogaError } from "@suaveplan/api-graphql-yoga/error-codes";
function handle(error: unknown): Response {
if (
error instanceof GraphqlYogaError &&
error.yogaCode === "GRAPHQL_YOGA.DEPTH_EXCEEDED"
) {
return new Response("query too deep", { status: 400 });
}
throw error;
}
```
## API Reference
- `GRAPHQL_YOGA_CODES` — frozen record of every code constant.
- `GraphqlYogaErrorCode` — union of every `GRAPHQL_YOGA.<NAME>` literal.
- `class GraphqlYogaError extends SuaveplanError<GraphqlYogaErrorCode>` —
public constructor `(code, message, options?)`.
## Implementation Notes
Schema collisions across plugin contributions are raised with the abstract
`GRAPHQL.SCHEMA_COLLISION` code from `@suaveplan/api-graphql`, NOT
`GRAPHQL_YOGA.*` — this lets a host that switches between Apollo and Yoga
preserve identical fault behaviour at the federation boundary.

View File

@@ -0,0 +1,75 @@
# `federation.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Aggregates plugin-side `GraphqlRegistry` contributions into a single
`(MergedSchema, ResolverMap)` pair that the Yoga engine hands to
`@graphql-tools/schema`'s `makeExecutableSchema`. The module is a thin
shim around the abstract `mergeSdl(...)` from `@suaveplan/api-graphql` —
the real federation rules and collision detection live there, on the
abstract layer, so Yoga and the eventual Apollo adapter share identical
fault behaviour.
## Features
- Calls `registry.freeze()` once per plugin to snapshot SDL + resolver
maps.
- Wraps `mergeSdl(...)` so `GRAPHQL.SCHEMA_COLLISION` propagates
unchanged.
- Combines per-plugin `ResolverMap` objects via shallow object-spread,
layer-by-layer, so multiple `addResolvers` calls from the same plugin
stack in declaration order.
## Usage: Basic
```ts
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import { federatePlugins } from "@suaveplan/api-graphql-yoga/federation";
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
const result = federatePlugins([{ plugin: "ping", registry: reg }]);
// result.merged.sdl contains "type Query { ping: String! }"
// result.resolvers.Query.ping is the resolver function
```
## Usage: Advanced
```ts
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import { federatePlugins } from "@suaveplan/api-graphql-yoga/federation";
const billing = createGraphqlRegistry();
billing.addTypes("extend type Query { invoices: [String!]! }");
billing.addResolvers({ Query: { invoices: () => ["i_1"] } });
const crm = createGraphqlRegistry();
crm.addTypes("extend type Query { customers: [String!]! }");
crm.addResolvers({ Query: { customers: () => ["c_1"] } });
const result = federatePlugins([
{ plugin: "billing", registry: billing },
{ plugin: "crm", registry: crm },
]);
```
## API Reference
- `PluginRegistryEntry` — `{ plugin, registry, namespace? }`.
- `FederationResult` — `{ merged: MergedSchema, resolvers: ... }`.
- `federatePlugins(entries)` — aggregator.
## Implementation Notes
When two plugins extend the same type with the same field name, the
abstract `mergeSdl(...)` already raises `GRAPHQL.SCHEMA_COLLISION` before
the resolver-map merge runs — that is why the resolver loop here can
shallow-spread without conflict-detection: by the time it executes,
the SDL layer has guaranteed every `(Type, field)` belongs to exactly one
plugin.

View File

@@ -0,0 +1,67 @@
# `depth-and-complexity.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Implements the static depth + complexity guard that the Yoga engine runs
after parsing each query and before execution. Documents that exceed the
configured limits are rejected with `GRAPHQL_YOGA.DEPTH_EXCEEDED` or
`GRAPHQL_YOGA.COMPLEXITY_EXCEEDED`, which lets the host reject denial-of-
service payloads (deeply nested fragments, fan-out spreads) before any
resolver runs.
The guard is intentionally cheap: a single recursive walk over the
already-parsed `DocumentNode`, no schema awareness, no cost estimator.
Hosts that want a smarter cost model (database joins, external service
fan-out) compose a stricter guard on top.
## Features
- Tracks deepest `SelectionSet` nesting level per operation.
- Counts every `Field` node touched, expanding fragment spreads inline
once per occurrence.
- Throws subclass `GraphqlYogaError` with `context.depth` /
`context.limit` for log-side debugging.
## Usage: Basic
```ts
import { parse } from "graphql";
import { enforceGuards } from "@suaveplan/api-graphql-yoga";
const doc = parse("{ ping }");
enforceGuards(doc, { maxDepth: 10, maxComplexity: 1000 });
```
## Usage: Advanced
```ts
import { parse } from "graphql";
import { enforceGuards } from "@suaveplan/api-graphql-yoga";
const doc = parse(`
fragment f on Customer { id name orders { id } }
query { customer(id: "c1") { ...f } }
`);
try {
enforceGuards(doc, { maxDepth: 3, maxComplexity: 5 });
} catch (error) {
// GRAPHQL_YOGA.DEPTH_EXCEEDED or COMPLEXITY_EXCEEDED
}
```
## API Reference
- `enforceGuards(document, { maxDepth, maxComplexity })` — throws on
violation, returns `void` on pass.
## Implementation Notes
Fragment cycles would cause unbounded recursion if the walker re-visited
spreads; in practice the GraphQL `validate()` pass that runs immediately
after parsing rejects cycle-containing documents, so the depth/complexity
walker can rely on cycle-free input. Should a future change move the guard
ahead of validation, add a `Set<string>` of seen spreads to short-circuit.

View File

@@ -0,0 +1,69 @@
# `instruments.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Builds and caches the OpenTelemetry instrument bundle the Yoga GraphQL
engine emits per request. The bundle covers a counter
(`graphql.requests_total`), two histograms (`graphql.request_duration_ms`,
`graphql.resolver_duration_ms`), an observable gauge
(`graphql.subscriptions_active`), and a schema-rebuild counter
(`graphql.schema_rebuilds_total`) — every metric the design doc §10
acceptance criteria enumerate.
The lazy-bound + reset pattern lets test code install a fresh
`MeterProvider`, call `resetYogaInstruments()`, exercise the engine, and
read counter / histogram observations directly off the provider — the
package never holds a stale meter reference across test boundaries.
## Features
- Singleton bundle keyed on the meter name `@suaveplan/api-graphql-yoga`.
- `resetYogaInstruments()` clears the cache for tests.
- Histograms carry units (`ms`); counters carry descriptions for the
Prometheus exporter.
## Usage: Basic
```ts
import { getYogaInstruments } from "@suaveplan/api-graphql-yoga";
const instruments = getYogaInstruments();
instruments.requestsTotal.add(1, {
operation_type: "query",
operation_name: "Ping",
outcome: "ok",
});
```
## Usage: Advanced
```ts
import { metrics } from "@opentelemetry/api";
import { MeterProvider } from "@opentelemetry/sdk-metrics";
import {
getYogaInstruments,
resetYogaInstruments,
} from "@suaveplan/api-graphql-yoga";
const provider = new MeterProvider();
metrics.setGlobalMeterProvider(provider);
resetYogaInstruments();
const instruments = getYogaInstruments();
instruments.requestDurationMs.record(12.5);
```
## API Reference
- `YogaInstruments` — readonly record with one entry per metric.
- `getYogaInstruments()` — lazy-binding accessor.
- `resetYogaInstruments()` — test helper.
## Implementation Notes
Observable gauges (`subscriptionsActive`) are bound but not subscribed
inside this module; the engine attaches a callback that returns the live
subscription count when the gauge is sampled.

View File

@@ -0,0 +1,67 @@
# `loaders.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines the per-request loader bag the Yoga engine constructs at pipeline
step 7 and disposes at step 11. Two concurrent requests therefore see
independent `DataLoader` instances — caching that R1 establishes for a
key cannot leak to R2 even when both resolvers call
`rctx.loaders.customer.load(id)` for the same id within the same event
loop tick.
The bag is the binding between the abstract `LoaderFactory` contract
(owned by `@suaveplan/api-graphql`) and the concrete request-scoped loader
handles resolvers see. Disposal is a hook for future async-resource
loaders; today's in-memory factories make it a no-op.
## Features
- Constructs one loader handle per registered factory.
- Idempotent `dispose()` so the engine can safely call it on both the
happy path and the error path.
- Returned `handles` map is frozen-by-convention — engine code never
mutates after construction.
## Usage: Basic
```ts
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import { buildRequestLoaders } from "@suaveplan/api-graphql-yoga";
const reg = createGraphqlRegistry();
reg.addLoader("customer", async (keys) => keys.map((k) => `customer:${String(k)}`));
const snap = (reg as unknown as { freeze: () => { loaders: ReadonlyMap<string, never> } }).freeze();
const bag = buildRequestLoaders(snap.loaders);
const customer = await bag.handles.customer.load("c1");
bag.dispose();
```
## Usage: Advanced
```ts
import { buildRequestLoaders } from "@suaveplan/api-graphql-yoga";
function makeRctx(loaders: Awaited<ReturnType<typeof buildRequestLoaders>>) {
return {
loaders: loaders.handles,
dispose: () => loaders.dispose(),
};
}
```
## API Reference
- `RequestLoaderBag` — `{ handles, dispose() }`.
- `buildRequestLoaders(factories)` — constructs the bag.
## Implementation Notes
The `disposed` boolean lets the engine call `dispose()` on both happy and
error paths without worrying about double-free. When async-resource
loaders land, the close branch will flip from no-op to a real close
sequence; the public shape stays unchanged.

View File

@@ -0,0 +1,73 @@
# `options.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Defines `YogaGraphqlOptions`, the Zod-validated configuration boundary that
hosts use to wire `createYogaGraphqlEngine`. Hosts pass a partial bag at the
public surface; `resolveYogaGraphqlOptions` validates the input through a
strict Zod schema and fills in every default so internal code receives a
fully concrete `ResolvedYogaGraphqlOptions`.
The schema covers endpoint routes (`/graphql`, `/graphql/sdl`,
`/graphql/ws`, `/graphql/ui`), the masked-errors and landing-page toggles,
introspection on/off, depth + complexity guard limits, the parser-cache
size, the per-request `timeoutMs`, and the strict-namespace federation
flag.
## Features
- Single source of truth for every Yoga adapter default.
- Boundary input typed as `YogaGraphqlOptions = z.input<...>` so callers
may pass numbers as numbers (not strings) — no coercion magic.
- Resolved shape is `readonly` and `Object.freeze`d so downstream code
cannot mutate it by accident.
## Usage: Basic
```ts
import { resolveYogaGraphqlOptions } from "@suaveplan/api-graphql-yoga/options";
const opts = resolveYogaGraphqlOptions({});
// opts.endpoint === "/graphql"
// opts.maxQueryDepth === 10
// opts.maxQueryComplexity === 1000
```
## Usage: Advanced
```ts
import { resolveYogaGraphqlOptions } from "@suaveplan/api-graphql-yoga/options";
const opts = resolveYogaGraphqlOptions({
endpoint: "/api/graphql",
sdlEndpoint: "/api/graphql/sdl",
wsEndpoint: "/api/graphql/ws",
uiEndpoint: "/api/graphql/ui",
maxQueryDepth: 8,
maxQueryComplexity: 250,
maskedErrors: false,
introspection: false,
});
```
## API Reference
- `yogaGraphqlOptionsSchema` — the underlying Zod schema, exposed for
composition into broader host config layers.
- `YogaGraphqlOptions` — input type alias.
- `ResolvedYogaGraphqlOptions` — frozen post-resolution type.
- `YOGA_GRAPHQL_DEFAULTS` — the `ResolvedYogaGraphqlOptions` defaults bag.
- `resolveYogaGraphqlOptions(input?)` — validate + default; throws
`ZodError` on invalid input.
## Implementation Notes
Each endpoint path must start with `/` (enforced by `startsWith("/")`); the
guard prevents a host from accidentally registering a relative path that
would never match. `maxQueryDepth` and `maxQueryComplexity` are
`.positive()` because zero or negative limits would reject every query —
intent-preserving defaults beat foot-gun acceptance.

View File

@@ -0,0 +1,70 @@
# `subscriptions.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Bridges the `WsBroker` contract from `@suaveplan/api-ws` to GraphQL
`Subscription` resolvers via `AsyncIterable<Payload>`. The Yoga engine
wires `graphql-ws` over the host's already-mounted WebSocket transport;
this module is the per-subscription helper that converts a broker `topic`
+ `tenant` pair into an iterator the engine can consume.
Tenant filtering is enforced at the bridge: only `BrokerEnvelope` items
whose `tenant === tenantArg` reach the GraphQL iterator. Closing the
iterator (`return()` or the explicit `close()` method) calls
`subscription.unsubscribe()` on the broker so the WS slot is released
within one tick.
## Features
- Returns `AsyncIterable<Payload>` with an explicit `close()` accessor.
- Queues payloads when no consumer is awaiting; resolves immediately when
a consumer is parked.
- Calls `GraphqlYogaError("SUBSCRIPTION_TRANSPORT_UNAVAILABLE")` when the
host hands a `null` broker, satisfying the design doc §10.4 contract.
## Usage: Basic
```ts
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
import { subscribeBrokerTopic } from "@suaveplan/api-graphql-yoga";
const broker = createInMemoryWsBroker();
const iterable = subscribeBrokerTopic(
broker,
"billing.invoice.created",
"tenant-a" as never
);
for await (const event of iterable) {
console.log(event);
break;
}
await iterable.close();
```
## Usage: Advanced
```ts
import { subscribeBrokerTopic } from "@suaveplan/api-graphql-yoga";
const subscription = {
invoiceCreated(_parent: unknown, _args: unknown, ctx: { broker: never; tenantId: never }) {
return subscribeBrokerTopic(ctx.broker, "billing.invoice.created", ctx.tenantId);
},
};
```
## API Reference
- `subscribeBrokerTopic(broker, topic, tenant)` — returns
`AsyncIterable<Payload> & { close(): Promise<void> }`.
## Implementation Notes
The internal `queue` + `waiters` arrays form a simple bounded mailbox.
Backpressure (queue cap, drop oldest, etc.) is intentionally not modelled
here — the in-memory broker enforces its own bounds, and the Redis broker
will inherit the same back-end contract.

View File

@@ -0,0 +1,64 @@
# `testing.ts`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Binds the abstract `runGraphqlEngineConformance` from
`@suaveplan/api-graphql/testing` to the Yoga engine factory so concrete
host tests can call a single helper instead of wiring the factory by
hand. The same conformance suite passes against every concrete engine —
this binding is the only Yoga-specific piece — so swapping engines stays
host-config-only.
The barrel also re-exports the abstract harness types
(`ConformanceContext`, `RunConformanceOptions`) and the harness itself so
callers that need to compose a non-Yoga binding alongside the Yoga one
can do so without a second package import.
## Features
- `runYogaConformance(label?, config?)` — single-call wrapper that
spins up a fresh Yoga engine per scenario.
- Re-exports the abstract harness for composition.
- Returns `void` because the harness registers `describe` / `it` blocks
against the active runner — call it inside a parent `describe`.
## Usage: Basic
```ts
import { describe } from "@suaveplan/testing/runner";
import { runYogaConformance } from "@suaveplan/api-graphql-yoga/testing";
describe("yoga engine", () => {
runYogaConformance();
});
```
## Usage: Advanced
```ts
import { describe } from "@suaveplan/testing/runner";
import { runYogaConformance } from "@suaveplan/api-graphql-yoga/testing";
describe("yoga engine (strict-namespace)", () => {
runYogaConformance("yoga-strict", {
options: { strictNamespace: true, maxQueryDepth: 6 },
});
});
```
## API Reference
- `runYogaConformance(label?, config?)` — bound harness invocation.
- `runGraphqlEngineConformance` — re-exported abstract harness.
- `ConformanceContext`, `RunConformanceOptions` — types.
## Implementation Notes
The stub `RequestContext` is empty — the abstract suite intentionally
never reads from it. Concrete tests that need to assert on
context-flowing behaviour should write their own scenarios outside the
conformance harness.