first commit
This commit is contained in:
90
docs/api-stack/api/api-graphql-yoga/README.md
Normal file
90
docs/api-stack/api/api-graphql-yoga/README.md
Normal 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.
|
||||
|
||||
@@ -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.
|
||||
100
docs/api-stack/api/api-graphql-yoga/src/engine/yoga-engine.md
Normal file
100
docs/api-stack/api/api-graphql-yoga/src/engine/yoga-engine.md
Normal 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
67
docs/api-stack/api/api-graphql-yoga/src/loaders/loaders.md
Normal file
67
docs/api-stack/api/api-graphql-yoga/src/loaders/loaders.md
Normal 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.
|
||||
73
docs/api-stack/api/api-graphql-yoga/src/options/options.md
Normal file
73
docs/api-stack/api/api-graphql-yoga/src/options/options.md
Normal 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.
|
||||
@@ -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.
|
||||
64
docs/api-stack/api/api-graphql-yoga/src/testing/testing.md
Normal file
64
docs/api-stack/api/api-graphql-yoga/src/testing/testing.md
Normal 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.
|
||||
Reference in New Issue
Block a user