Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 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,90 @@
{
"name": "@suaveplan/api-graphql-yoga",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-7 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.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./engine": {
"import": {
"types": "./dist/engine/yoga-engine.d.ts",
"default": "./dist/engine/yoga-engine.js"
}
},
"./options": {
"import": {
"types": "./dist/options/options.d.ts",
"default": "./dist/options/options.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/error-codes.d.ts",
"default": "./dist/error-codes/error-codes.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/testing.d.ts",
"default": "./dist/testing/testing.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
"format": "bunx biome format --write --max-diagnostics 500 src/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@graphql-tools/schema": "^10.0.6",
"@opentelemetry/api": "catalog:observability",
"@suaveplan/api-graphql": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/api-ws": "workspace:^",
"@suaveplan/error": "workspace:^",
"@suaveplan/opentelemetry": "workspace:^",
"dataloader": "^2.2.3",
"graphql": "^16.9.0",
"graphql-yoga": "^5.10.4",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@opentelemetry/sdk-metrics": "catalog:observability",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": ["dist", "src", "README.md"]
}

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,189 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GraphQLError } from "graphql";
import { GraphqlYogaError } from "../error-codes/error-codes.js";
import { YOGA_GRAPHQL_DEFAULTS } from "../options/options.js";
import {
assertIntrospectionAllowed,
maskOrPassthrough,
withTimeout,
wrapSubscription,
} from "./execute-helpers.js";
describe("maskOrPassthrough", () => {
it("masks messages when maskedErrors is true", { timeout: 5000 }, () => {
const masked = maskOrPassthrough(
[new GraphQLError("internal secret")],
{ ...YOGA_GRAPHQL_DEFAULTS, maskedErrors: true }
);
expect(masked[0]?.message).toBe("Unexpected error.");
});
it("passes messages through when maskedErrors is false", {
timeout: 5000,
}, () => {
const passed = maskOrPassthrough([new GraphQLError("real message")], {
...YOGA_GRAPHQL_DEFAULTS,
maskedErrors: false,
});
expect(passed[0]?.message).toBe("real message");
});
it("preserves the path when present", { timeout: 5000 }, () => {
const err = new GraphQLError("oops", { path: ["Query", "ping"] });
const out = maskOrPassthrough([err], {
...YOGA_GRAPHQL_DEFAULTS,
maskedErrors: false,
});
expect(out[0]?.path).toEqual(["Query", "ping"]);
});
});
describe("withTimeout", () => {
it("resolves when the inner promise resolves in time", {
timeout: 5000,
}, async () => {
const v = await withTimeout(Promise.resolve("ok"), 1000);
expect(v).toBe("ok");
});
it("rejects with TIMEOUT when the inner promise stalls", {
timeout: 5000,
}, async () => {
const stalled = new Promise<string>((resolve) => {
setTimeout(() => resolve("late"), 200);
});
const error = await withTimeout(stalled, 10).catch((e: unknown) => e);
expect((error as GraphqlYogaError).yogaCode).toBe(
"GRAPHQL_YOGA.TIMEOUT"
);
});
it("rejects with the underlying error if it rejects first", {
timeout: 5000,
}, async () => {
const error = await withTimeout(
Promise.reject(new Error("inner")),
1000
).catch((e: unknown) => e);
expect((error as Error).message).toBe("inner");
});
});
describe("assertIntrospectionAllowed", () => {
it("passes when introspection is on", { timeout: 5000 }, () => {
expect(() =>
assertIntrospectionAllowed("{ __schema { types { name } } }", {
...YOGA_GRAPHQL_DEFAULTS,
introspection: true,
})
).not.toThrow();
});
it("throws when introspection is off and __schema appears", {
timeout: 5000,
}, () => {
expect(() =>
assertIntrospectionAllowed("{ __schema { types { name } } }", {
...YOGA_GRAPHQL_DEFAULTS,
introspection: false,
})
).toThrow(GraphqlYogaError);
});
it("passes when introspection is off but query is a normal one", {
timeout: 5000,
}, () => {
expect(() =>
assertIntrospectionAllowed("{ ping }", {
...YOGA_GRAPHQL_DEFAULTS,
introspection: false,
})
).not.toThrow();
});
});
describe("wrapSubscription", () => {
it("wraps an async-iterable result", { timeout: 5000 }, async () => {
async function* gen() {
yield { data: { x: 1 } };
yield { data: { x: 2 } };
}
const iter = wrapSubscription(Promise.resolve(gen()), () => undefined);
const ait = iter[Symbol.asyncIterator]();
expect((await ait.next()).value).toEqual({ data: { x: 1 } });
expect((await ait.next()).value).toEqual({ data: { x: 2 } });
const last = await ait.next();
expect(last.done).toBe(true);
});
it("wraps a single-result fallback (validation error)", {
timeout: 5000,
}, async () => {
const iter = wrapSubscription(
Promise.resolve({ data: null, errors: [] }) as never,
() => undefined
);
const ait = iter[Symbol.asyncIterator]();
const first = await ait.next();
expect(first.done).toBe(false);
expect((first.value as { data: unknown }).data).toBe(null);
});
it("return() releases via onClose and inner.return", {
timeout: 5000,
}, async () => {
let closed = 0;
async function* gen() {
try {
yield { data: { x: 1 } };
yield { data: { x: 2 } };
} finally {
closed += 1;
}
}
const iter = wrapSubscription(Promise.resolve(gen()), () => {
closed += 10;
});
const ait = iter[Symbol.asyncIterator]();
await ait.next();
const r = ait.return !== undefined ? await ait.return() : undefined;
expect(r?.done).toBe(true);
expect(closed).toBeGreaterThanOrEqual(10);
});
it("after a single-result fallback yields done on subsequent next", {
timeout: 5000,
}, async () => {
const iter = wrapSubscription(
Promise.resolve({ data: { x: 1 } } as never),
() => undefined
);
const ait = iter[Symbol.asyncIterator]();
const first = await ait.next();
expect(first.done).toBe(false);
const second = await ait.next();
expect(second.done).toBe(true);
});
it("handles null data in async-iterable step", {
timeout: 5000,
}, async () => {
async function* gen() {
yield { data: null };
}
const iter = wrapSubscription(Promise.resolve(gen()), () => undefined);
const ait = iter[Symbol.asyncIterator]();
const step = await ait.next();
expect((step.value as { data: unknown }).data).toBe(null);
});
it("return() before any next() is safe", { timeout: 5000 }, async () => {
const iter = wrapSubscription(
Promise.resolve({ data: null } as never),
() => undefined
);
const ait = iter[Symbol.asyncIterator]();
const r = ait.return !== undefined ? await ait.return() : undefined;
expect(r?.done).toBe(true);
});
});

View File

@@ -0,0 +1,142 @@
import type { GraphqlResponse } from "@suaveplan/api-graphql";
import type { ExecutionResult, GraphQLError } from "graphql";
import {
GRAPHQL_YOGA_CODES,
GraphqlYogaError,
} from "../error-codes/error-codes.js";
import type { ResolvedYogaGraphqlOptions } from "../options/options.js";
/**
* Convert a GraphQL execution result's errors into the `GraphqlResponse`
* shape, masking message text when `maskedErrors` is enabled. Path is
* passed through verbatim — it never leaks resolver internals.
*/
export function maskOrPassthrough(
errors: ReadonlyArray<GraphQLError>,
options: ResolvedYogaGraphqlOptions
): ReadonlyArray<{
readonly message: string;
readonly path?: ReadonlyArray<string | number>;
}> {
return errors.map((e) => ({
message: options.maskedErrors ? "Unexpected error." : e.message,
...(e.path !== undefined
? { path: e.path as ReadonlyArray<string | number> }
: {}),
}));
}
/**
* Race a promise against a wall-clock timeout. On expiry, reject with
* `GRAPHQL_YOGA.TIMEOUT` so the engine's catch-all can publish the
* `outcome: "error"` counter.
*/
export function withTimeout<T>(p: Promise<T>, timeoutMs: number): Promise<T> {
return new Promise<T>((resolveP, rejectP) => {
const t = setTimeout(() => {
rejectP(
new GraphqlYogaError(
GRAPHQL_YOGA_CODES.TIMEOUT,
`request exceeded timeoutMs=${timeoutMs}`
)
);
}, timeoutMs);
p.then(
(v) => {
clearTimeout(t);
resolveP(v);
},
(err) => {
clearTimeout(t);
rejectP(err);
}
);
});
}
/** Reject queries that reference `__schema` / `__type` when introspection is off. */
export function assertIntrospectionAllowed(
documentSource: string,
options: ResolvedYogaGraphqlOptions
): void {
if (options.introspection) return;
if (/__schema|__type/.test(documentSource)) {
throw new GraphqlYogaError(
GRAPHQL_YOGA_CODES.INTROSPECTION_DENIED,
"introspection is disabled on this engine"
);
}
}
/**
* Wrap a pending GraphQL subscription Promise (which yields either an
* `AsyncIterable<ExecutionResult>` on success or an `ExecutionResult` for
* the validation-error case) into a plain `AsyncIterable<GraphqlResponse>`
* that the host can hand straight to `graphql-ws`.
*/
export function wrapSubscription(
pending: Promise<AsyncIterable<ExecutionResult> | ExecutionResult>,
onClose: () => void
): AsyncIterable<GraphqlResponse> {
return {
[Symbol.asyncIterator](): AsyncIterator<GraphqlResponse> {
let inner: AsyncIterator<ExecutionResult> | null = null;
let resolved = false;
return {
async next(): Promise<IteratorResult<GraphqlResponse>> {
if (!resolved) {
const r = await pending;
resolved = true;
if (Symbol.asyncIterator in (r as object)) {
inner = (r as AsyncIterable<ExecutionResult>)[
Symbol.asyncIterator
]();
} else {
const single = r as ExecutionResult;
return {
value: {
data: (single.data ?? null) as Readonly<
Record<string, unknown>
> | null,
...(single.errors !== undefined
? {
errors: single.errors.map(
(e) => ({
message: e.message,
})
),
}
: {}),
},
done: false,
};
}
}
if (inner === null) {
return { value: undefined as never, done: true };
}
const step = await inner.next();
if (step.done === true) {
onClose();
return { value: undefined as never, done: true };
}
return {
value: {
data: (step.value.data ?? null) as Readonly<
Record<string, unknown>
> | null,
},
done: false,
};
},
async return(): Promise<IteratorResult<GraphqlResponse>> {
onClose();
if (inner !== null && inner.return !== undefined) {
await inner.return();
}
return { value: undefined as never, done: true };
},
};
},
};
}

View File

@@ -0,0 +1,11 @@
export {
assertIntrospectionAllowed,
maskOrPassthrough,
withTimeout,
wrapSubscription,
} from "./execute-helpers.js";
export {
createYogaGraphqlEngine,
type YogaEngineConfig,
type YogaGraphqlEngine,
} from "./yoga-engine.js";

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,6 @@
import { describe } from "@suaveplan/testing/runner";
import { runYogaConformance } from "../testing/testing.js";
describe("createYogaGraphqlEngine passes runGraphqlEngineConformance", () => {
runYogaConformance();
});

View File

@@ -0,0 +1,361 @@
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GraphqlYogaError } from "../error-codes/error-codes.js";
import { createYogaGraphqlEngine } from "./yoga-engine.js";
function stubCtx(): RequestContext {
return {} as RequestContext;
}
describe("createYogaGraphqlEngine — happy path", () => {
it("executes a simple query through the executable schema", {
timeout: 5000,
}, async () => {
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 }" },
stubCtx()
);
expect((result.data as { ping: string }).ping).toBe("pong");
await engine.dispose();
});
it("buildSchema rebuilds the executable schema on each call", {
timeout: 5000,
}, () => {
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
expect(engine.schema).not.toBeNull();
});
it("dispose is idempotent and clears the schema", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
await engine.dispose();
await engine.dispose();
expect(engine.schema).toBeNull();
});
});
describe("createYogaGraphqlEngine — error paths", () => {
it("raises SCHEMA_BUILD_FAILED when executeQuery runs before buildSchema", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine();
await expect(
engine.executeQuery({ query: "{ ping }" }, stubCtx())
).rejects.toBeInstanceOf(GraphqlYogaError);
await engine.dispose();
});
it("raises PARSE_FAILED on a malformed document", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
const error = await engine
.executeQuery({ query: "{ malformed" }, stubCtx())
.catch((e: unknown) => e);
expect(error).toBeInstanceOf(GraphqlYogaError);
expect((error as GraphqlYogaError).yogaCode).toBe(
"GRAPHQL_YOGA.PARSE_FAILED"
);
await engine.dispose();
});
it("raises VALIDATION_FAILED on an unknown field", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
const error = await engine
.executeQuery({ query: "{ nope }" }, stubCtx())
.catch((e: unknown) => e);
expect((error as GraphqlYogaError).yogaCode).toBe(
"GRAPHQL_YOGA.VALIDATION_FAILED"
);
await engine.dispose();
});
it("raises INTROSPECTION_DENIED when introspection is off", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine({
options: { introspection: false },
});
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
const error = await engine
.executeQuery(
{ query: "{ __schema { types { name } } }" },
stubCtx()
)
.catch((e: unknown) => e);
expect((error as GraphqlYogaError).yogaCode).toBe(
"GRAPHQL_YOGA.INTROSPECTION_DENIED"
);
await engine.dispose();
});
it("raises PERSISTED_QUERY_NOT_FOUND when id is unknown", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
const error = await engine
.executeQuery(
{ query: "", persistedQueryId: "missing" } as never,
stubCtx()
)
.catch((e: unknown) => e);
expect((error as GraphqlYogaError).yogaCode).toBe(
"GRAPHQL_YOGA.PERSISTED_QUERY_NOT_FOUND"
);
await engine.dispose();
});
it("resolves a persisted query when the id is registered", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine({
persistedQueries: new Map([["pq1", "{ ping }"]]),
});
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
const result = await engine.executeQuery(
{ query: "", persistedQueryId: "pq1" } as never,
stubCtx()
);
expect((result.data as { ping: string }).ping).toBe("pong");
await engine.dispose();
});
it("raises RESOLVER_THREW when a resolver throws a non-Yoga error", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine({
options: { maskedErrors: false },
});
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({
Query: {
ping: () => {
throw new Error("kaboom");
},
},
});
engine.buildSchema(reg);
const result = await engine.executeQuery(
{ query: "{ ping }" },
stubCtx()
);
expect(result.errors?.[0]?.message).toContain("kaboom");
await engine.dispose();
});
it("masks resolver error messages when maskedErrors is on", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine({
options: { maskedErrors: true },
});
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({
Query: {
ping: () => {
throw new Error("internal secret");
},
},
});
engine.buildSchema(reg);
const result = await engine.executeQuery(
{ query: "{ ping }" },
stubCtx()
);
expect(result.errors?.[0]?.message).toBe("Unexpected error.");
await engine.dispose();
});
it("wraps non-Yoga thrown errors as RESOLVER_THREW", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine({
options: { maskedErrors: false, timeoutMs: 30000 },
});
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
// Force the engine into the non-Yoga catch path by passing a Proxy
// request where the `query` getter returns a string the first time
// (so parse succeeds) but then the engine reads `variables` and the
// Proxy throws a plain TypeError that escapes execute().
const req = new Proxy(
{ query: "{ ping }", variables: undefined as unknown },
{
get(target: Record<string, unknown>, key: string): unknown {
if (key === "variables") {
throw new TypeError("synthetic non-yoga failure");
}
return target[key];
},
}
);
const error = await engine
.executeQuery(req as never, stubCtx())
.catch((e: unknown) => e);
expect((error as GraphqlYogaError).yogaCode).toBe(
"GRAPHQL_YOGA.RESOLVER_THREW"
);
});
it("wraps non-Error throws as RESOLVER_THREW with string message", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "p" } });
engine.buildSchema(reg);
const req = new Proxy(
{ query: "{ ping }", variables: undefined as unknown },
{
get(target: Record<string, unknown>, key: string): unknown {
if (key === "variables") {
const e: unknown = "primitive failure";
throw e;
}
return target[key];
},
}
);
const error = await engine
.executeQuery(req as never, stubCtx())
.catch((e: unknown) => e);
expect((error as GraphqlYogaError).yogaCode).toBe(
"GRAPHQL_YOGA.RESOLVER_THREW"
);
expect((error as GraphqlYogaError).message).toBe("primitive failure");
});
it("subscribe surfaces an ExecutionResult with errors when subscribe throws", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes(
"type Query { ping: String! } type Subscription { tick: Int! }"
);
reg.addResolvers({
Query: { ping: () => "p" },
Subscription: {
tick: {
subscribe: () => {
throw new Error("sync-subscribe-throw");
},
resolve: (v: number) => v,
},
},
});
engine.buildSchema(reg);
const iter = engine.executeSubscription(
{ query: "subscription { tick }" },
stubCtx()
);
const ait = iter[Symbol.asyncIterator]();
const step = await ait.next();
const errs = (
step.value as {
errors?: ReadonlyArray<{ message: string }>;
}
).errors;
expect(errs?.[0]?.message).toContain("sync-subscribe-throw");
});
it("executeSubscription raises VALIDATION_FAILED on unknown field", {
timeout: 5000,
}, () => {
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes(
"type Query { ping: String! } type Subscription { tick: Int! }"
);
reg.addResolvers({ Query: { ping: () => "p" } });
engine.buildSchema(reg);
expect(() =>
engine.executeSubscription(
{ query: "subscription { missing }" },
stubCtx()
)
).toThrow(GraphqlYogaError);
});
it("executeSubscription decrements activeSubscriptions on iterator close", {
timeout: 5000,
}, async () => {
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes(
"type Query { ping: String! } type Subscription { tick: Int! }"
);
reg.addResolvers({
Query: { ping: () => "p" },
Subscription: {
tick: {
subscribe: async function* () {
yield { tick: 1 };
},
resolve: (v: { tick: number }) => v.tick,
},
},
});
engine.buildSchema(reg);
const iter = engine.executeSubscription(
{ query: "subscription { tick }" },
stubCtx()
);
const ait = iter[Symbol.asyncIterator]();
const first = await ait.next();
expect(first.done).toBe(false);
const next = await ait.next();
expect(next.done).toBe(true);
expect(engine.activeSubscriptions()).toBe(0);
});
it("raises SCHEMA_BUILD_FAILED on invalid SDL", {
timeout: 5000,
}, () => {
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping String! }");
expect(() => engine.buildSchema(reg)).toThrow(GraphqlYogaError);
});
});

View File

@@ -0,0 +1,292 @@
import { makeExecutableSchema } from "@graphql-tools/schema";
import type {
GraphqlEngine,
GraphqlRegistry,
GraphqlRequest,
GraphqlResponse,
MergedSchema,
} from "@suaveplan/api-graphql";
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import type { WsBroker } from "@suaveplan/api-ws";
import {
type ExecutionResult,
execute,
type GraphQLSchema,
parse,
subscribe,
validate,
} from "graphql";
import {
GRAPHQL_YOGA_CODES,
GraphqlYogaError,
} from "../error-codes/error-codes.js";
import {
federatePlugins,
type PluginRegistryEntry,
} from "../federation/federation.js";
import { enforceGuards } from "../guards/depth-and-complexity.js";
import { getYogaInstruments } from "../instruments/instruments.js";
import {
type ResolvedYogaGraphqlOptions,
resolveYogaGraphqlOptions,
type YogaGraphqlOptions,
} from "../options/options.js";
import {
assertIntrospectionAllowed,
maskOrPassthrough,
withTimeout,
wrapSubscription,
} from "./execute-helpers.js";
/**
* Configuration handed to {@link createYogaGraphqlEngine}.
*
* `entries` is the authoritative list of plugin contributions. `wsBroker`
* is the wired subscription transport; `null` is acceptable when no
* plugin manifests subscriptions.
*/
export interface YogaEngineConfig {
readonly options?: YogaGraphqlOptions;
readonly entries?: ReadonlyArray<PluginRegistryEntry>;
readonly wsBroker?: WsBroker | null;
readonly persistedQueries?: ReadonlyMap<string, string>;
}
/**
* Concrete engine handle returned by {@link createYogaGraphqlEngine}.
* Extends the abstract `GraphqlEngine` contract with introspection
* getters that the host and tests use to assert state without poking
* private fields.
*/
export interface YogaGraphqlEngine<
Caps extends CapabilityRequirements = CapabilityRequirements,
> extends GraphqlEngine<Caps> {
readonly resolvedOptions: ResolvedYogaGraphqlOptions;
readonly schema: GraphQLSchema | null;
activeSubscriptions(): number;
}
interface RequestWithPersistedId extends GraphqlRequest {
readonly persistedQueryId?: string;
}
/**
* Build a Yoga-backed GraphQL engine that implements the abstract
* `GraphqlEngine` contract from `@suaveplan/api-graphql`. See
* `engine.md` for the lifecycle walk-through.
*/
export function createYogaGraphqlEngine<
Caps extends CapabilityRequirements = CapabilityRequirements,
>(config: YogaEngineConfig = {}): YogaGraphqlEngine<Caps> {
const resolved = resolveYogaGraphqlOptions(config.options);
const instruments = getYogaInstruments();
const persisted = config.persistedQueries ?? new Map<string, string>();
let schema: GraphQLSchema | null = null;
let merged: MergedSchema | null = null;
let activeSubs = 0;
let disposed = false;
instruments.subscriptionsActive.addCallback((observer) => {
observer.observe(activeSubs);
});
function ensureSchema(): GraphQLSchema {
if (schema === null) {
throw new GraphqlYogaError(
GRAPHQL_YOGA_CODES.SCHEMA_BUILD_FAILED,
"executable schema was never built; call buildSchema first"
);
}
return schema;
}
function parseQuery(req: GraphqlRequest): ReturnType<typeof parse> {
let query = req.query;
const id = (req as RequestWithPersistedId).persistedQueryId;
if (id !== undefined && id !== "") {
const stored = persisted.get(id);
if (stored === undefined) {
throw new GraphqlYogaError(
GRAPHQL_YOGA_CODES.PERSISTED_QUERY_NOT_FOUND,
`no persisted query registered for id "${id}"`,
{ context: { id } }
);
}
query = stored;
}
try {
return parse(query);
} catch (cause) {
throw new GraphqlYogaError(
GRAPHQL_YOGA_CODES.PARSE_FAILED,
"GraphQL document parse failed",
{ cause, context: { query } }
);
}
}
return {
resolvedOptions: resolved,
get schema(): GraphQLSchema | null {
return schema;
},
activeSubscriptions(): number {
return activeSubs;
},
buildSchema(registry: GraphqlRegistry<Caps>): MergedSchema {
const entries: PluginRegistryEntry[] = [
...(config.entries ?? []),
{
plugin: "__inline__",
registry:
registry as unknown as PluginRegistryEntry["registry"],
},
];
const result = federatePlugins(entries);
try {
schema = makeExecutableSchema({
typeDefs: result.merged.sdl,
resolvers: result.resolvers,
});
} catch (cause) {
throw new GraphqlYogaError(
GRAPHQL_YOGA_CODES.SCHEMA_BUILD_FAILED,
"makeExecutableSchema failed",
{ cause }
);
}
merged = result.merged;
instruments.schemaRebuildsTotal.add(1);
return merged;
},
async executeQuery(
req: GraphqlRequest,
ctx: RequestContext<Caps>
): Promise<GraphqlResponse> {
const started = Date.now();
const builtSchema = ensureSchema();
const opName = req.operationName ?? "anonymous";
try {
const document = parseQuery(req);
assertIntrospectionAllowed(req.query, resolved);
enforceGuards(document, {
maxDepth: resolved.maxQueryDepth,
maxComplexity: resolved.maxQueryComplexity,
});
const validationErrors = validate(builtSchema, document);
if (validationErrors.length > 0) {
throw new GraphqlYogaError(
GRAPHQL_YOGA_CODES.VALIDATION_FAILED,
validationErrors.map((e) => e.message).join("; "),
{
context: {
errors: validationErrors.map((e) => e.message),
},
}
);
}
const result = (await withTimeout(
Promise.resolve(
execute({
schema: builtSchema,
document,
contextValue: ctx,
variableValues: req.variables as
| Record<string, unknown>
| undefined,
operationName: req.operationName,
})
),
resolved.timeoutMs
)) as ExecutionResult;
instruments.requestDurationMs.record(Date.now() - started);
const hasErrors =
result.errors !== undefined && result.errors.length > 0;
instruments.requestsTotal.add(1, {
operation_type: "query",
operation_name: opName,
outcome: hasErrors ? "error" : "ok",
});
return {
data: (result.data ?? null) as Readonly<
Record<string, unknown>
> | null,
...(result.errors !== undefined
? {
errors: maskOrPassthrough(
result.errors,
resolved
),
}
: {}),
};
} catch (error) {
instruments.requestDurationMs.record(Date.now() - started);
instruments.requestsTotal.add(1, {
operation_type: "query",
operation_name: opName,
outcome: "error",
});
if (error instanceof GraphqlYogaError) throw error;
throw new GraphqlYogaError(
GRAPHQL_YOGA_CODES.RESOLVER_THREW,
error instanceof Error ? error.message : String(error),
{ cause: error }
);
}
},
executeSubscription(
req: GraphqlRequest,
ctx: RequestContext<Caps>
): AsyncIterable<GraphqlResponse> {
const builtSchema = ensureSchema();
const document = parseQuery(req);
const validationErrors = validate(builtSchema, document);
if (validationErrors.length > 0) {
throw new GraphqlYogaError(
GRAPHQL_YOGA_CODES.VALIDATION_FAILED,
validationErrors.map((e) => e.message).join("; ")
);
}
activeSubs += 1;
const promise = Promise.resolve(
subscribe({
schema: builtSchema,
document,
contextValue: ctx,
variableValues: req.variables as
| Record<string, unknown>
| undefined,
operationName: req.operationName,
})
);
// Attach a silent observer so a Promise rejection does not surface
// as an unhandled rejection when the consumer never pulls; the
// real surface is `next()` inside wrapSubscription, which awaits
// the same promise and propagates rejection upward.
promise.catch(() => undefined);
return wrapSubscription(
promise as Promise<
AsyncIterable<ExecutionResult> | ExecutionResult
>,
() => {
activeSubs = Math.max(0, activeSubs - 1);
}
);
},
async dispose(): Promise<void> {
if (disposed) return;
disposed = true;
schema = null;
merged = null;
activeSubs = 0;
},
};
}

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,47 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GRAPHQL_YOGA_CODES, GraphqlYogaError } from "./error-codes.js";
describe("GRAPHQL_YOGA_CODES + GraphqlYogaError", () => {
it("enumerates all 10 codes", { timeout: 5000 }, () => {
const codes = Object.values(GRAPHQL_YOGA_CODES);
expect(codes).toHaveLength(10);
expect(codes).toContain("GRAPHQL_YOGA.PARSE_FAILED");
expect(codes).toContain("GRAPHQL_YOGA.VALIDATION_FAILED");
expect(codes).toContain("GRAPHQL_YOGA.DEPTH_EXCEEDED");
expect(codes).toContain("GRAPHQL_YOGA.COMPLEXITY_EXCEEDED");
expect(codes).toContain("GRAPHQL_YOGA.SCHEMA_BUILD_FAILED");
expect(codes).toContain(
"GRAPHQL_YOGA.SUBSCRIPTION_TRANSPORT_UNAVAILABLE"
);
expect(codes).toContain("GRAPHQL_YOGA.TIMEOUT");
expect(codes).toContain("GRAPHQL_YOGA.INTROSPECTION_DENIED");
expect(codes).toContain("GRAPHQL_YOGA.PERSISTED_QUERY_NOT_FOUND");
expect(codes).toContain("GRAPHQL_YOGA.RESOLVER_THREW");
});
it("constructs an error with code, message, and context", {
timeout: 5000,
}, () => {
const error = new GraphqlYogaError(
GRAPHQL_YOGA_CODES.PARSE_FAILED,
"boom",
{ context: { query: "{ malformed" } }
);
expect(error.code).toBe("GRAPHQL_YOGA.PARSE_FAILED");
expect(error.yogaCode).toBe("GRAPHQL_YOGA.PARSE_FAILED");
expect(error.message).toBe("boom");
expect((error.context as { query: string }).query).toBe("{ malformed");
});
it("narrows via instanceof", { timeout: 5000 }, () => {
const error: unknown = new GraphqlYogaError(
GRAPHQL_YOGA_CODES.TIMEOUT,
"slow"
);
if (error instanceof GraphqlYogaError) {
expect(error.yogaCode).toBe("GRAPHQL_YOGA.TIMEOUT");
return;
}
throw new Error("expected GraphqlYogaError");
});
});

View File

@@ -0,0 +1,62 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Frozen `GRAPHQL_YOGA.*` error-code registry for `@suaveplan/api-graphql-yoga`.
*
* Codes are engine-specific failures that have no equivalent in the abstract
* `@suaveplan/api-graphql` contract. Schema-collision is intentionally NOT
* listed here — that fault belongs to the abstract layer and surfaces as
* `GRAPHQL.SCHEMA_COLLISION`.
*
* - `PARSE_FAILED` — the request `query` string failed `parse()` at the
* GraphQL document level. The bad query is attached via `context.query`.
* - `VALIDATION_FAILED` — `validate()` produced one or more rule failures.
* - `DEPTH_EXCEEDED` — query exceeded the configured `maxQueryDepth`.
* - `COMPLEXITY_EXCEEDED` — query exceeded the configured
* `maxQueryComplexity` (computed as the count of selection nodes).
* - `SCHEMA_BUILD_FAILED` — `makeExecutableSchema` could not consume the
* merged SDL; the underlying error is preserved via `cause`.
* - `SUBSCRIPTION_TRANSPORT_UNAVAILABLE` — `executeSubscription` was
* invoked but no `wsBroker` was configured on the engine.
* - `TIMEOUT` — the request exceeded its server-side `timeoutMs` budget.
* - `INTROSPECTION_DENIED` — introspection query rejected because
* `introspection: false`.
* - `PERSISTED_QUERY_NOT_FOUND` — request referenced a `persistedQueryId`
* that was not registered with the engine.
* - `RESOLVER_THREW` — a user resolver threw a non-`SuaveplanError`; the
* original is attached via `cause`.
*/
export const GRAPHQL_YOGA_CODES = defineCodes("GRAPHQL_YOGA", [
"PARSE_FAILED",
"VALIDATION_FAILED",
"DEPTH_EXCEEDED",
"COMPLEXITY_EXCEEDED",
"SCHEMA_BUILD_FAILED",
"SUBSCRIPTION_TRANSPORT_UNAVAILABLE",
"TIMEOUT",
"INTROSPECTION_DENIED",
"PERSISTED_QUERY_NOT_FOUND",
"RESOLVER_THREW",
] as const);
export type GraphqlYogaErrorCode =
(typeof GRAPHQL_YOGA_CODES)[keyof typeof GRAPHQL_YOGA_CODES];
/**
* Concrete error class for the api-graphql-yoga package. Extends
* {@link SuaveplanError} so `instanceof GraphqlYogaError` narrows `code` to
* {@link GraphqlYogaErrorCode}.
*/
export class GraphqlYogaError extends SuaveplanError<GraphqlYogaErrorCode> {
public readonly yogaCode: GraphqlYogaErrorCode;
public constructor(
code: GraphqlYogaErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.yogaCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
GRAPHQL_YOGA_CODES,
GraphqlYogaError,
type GraphqlYogaErrorCode,
} from "./error-codes.js";

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,61 @@
import { createGraphqlRegistry, GraphqlError } from "@suaveplan/api-graphql";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { federatePlugins } from "./federation.js";
describe("federatePlugins", () => {
it("two plugins each extending Query both expose their fields", {
timeout: 5000,
}, () => {
const billing = createGraphqlRegistry();
billing.addTypes(
"type Query { _root: String } 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 as never,
},
{ plugin: "crm", registry: crm as never },
]);
expect(result.merged.sdl).toContain("invoices");
expect(result.merged.sdl).toContain("customers");
expect(result.resolvers.Query?.invoices).toBeDefined();
expect(result.resolvers.Query?.customers).toBeDefined();
});
it("same-field collision fails with GRAPHQL.SCHEMA_COLLISION", {
timeout: 5000,
}, () => {
const a = createGraphqlRegistry();
a.addTypes("type Query { invoices: [String!]! }");
const b = createGraphqlRegistry();
b.addTypes("type Query { invoices: [String!]! }");
expect(() =>
federatePlugins([
{ plugin: "a", registry: a as never },
{ plugin: "b", registry: b as never },
])
).toThrow(GraphqlError);
});
it("supports namespace wrapping", {
timeout: 5000,
}, () => {
const billing = createGraphqlRegistry();
billing.addTypes("extend type Query { invoices: [String!]! }");
const result = federatePlugins([
{
plugin: "billing",
registry: billing as never,
namespace: "Billing",
},
]);
expect(result.merged.sdl).toContain("Billing");
});
});

View File

@@ -0,0 +1,83 @@
import {
type GraphqlRegistry,
type MergedSchema,
mergeSdl,
type ResolverMap,
type SdlContribution,
} from "@suaveplan/api-graphql";
/**
* Aggregate one or more plugin registries into a single `MergedSchema` +
* combined resolver map. The Yoga engine accepts a *flat* list of
* `(registry, plugin, namespace?)` triples — one per contributing plugin —
* and produces:
*
* - `merged` — output of `mergeSdl(...)` (raises
* `GRAPHQL.SCHEMA_COLLISION` on cross-plugin field collisions).
* - `resolvers` — combined `ResolverMap` (Type → field → resolver) where
* each `Type` block is a shallow object-spread of every contributing
* plugin's block. Within a single Type, fields contributed by the same
* plugin via multiple `addResolvers` calls layer in order.
*/
export interface PluginRegistryEntry {
readonly plugin: string;
readonly registry: FreezableRegistry;
readonly namespace?: string;
}
type FreezableRegistry = GraphqlRegistry & {
readonly freeze: () => {
readonly typeDefs: ReadonlyArray<string>;
readonly resolvers: ReadonlyArray<ResolverMap>;
};
};
export interface FederationResult {
readonly merged: MergedSchema;
readonly resolvers: Record<string, Record<string, unknown>>;
}
/**
* Merge plugin contributions into a single (sdl, resolvers) pair ready for
* `makeExecutableSchema`. Collisions in resolvers across plugins are
* surfaced by the SDL collision detection step that runs first — by the
* time control reaches resolver merging, every `(Type, field)` pair is
* single-plugin-owned.
*/
export function federatePlugins(
entries: ReadonlyArray<PluginRegistryEntry>
): FederationResult {
const contributions: SdlContribution[] = [];
const frozens: ReadonlyArray<ResolverMap>[] = [];
for (const entry of entries) {
const snap = entry.registry.freeze();
const contribution: SdlContribution =
entry.namespace !== undefined
? {
plugin: entry.plugin,
typeDefs: snap.typeDefs,
namespace: entry.namespace,
}
: { plugin: entry.plugin, typeDefs: snap.typeDefs };
contributions.push(contribution);
frozens.push(snap.resolvers);
}
const merged = mergeSdl(contributions);
const combined: Record<string, Record<string, unknown>> = {};
for (const maps of frozens) {
for (const map of maps) {
for (const typeName of Object.keys(map)) {
const incoming = (map as Record<string, unknown>)[
typeName
] as Record<string, unknown>;
const existing = combined[typeName] ?? {};
combined[typeName] = { ...existing, ...incoming };
}
}
}
return { merged, resolvers: combined };
}

View File

@@ -0,0 +1,5 @@
export {
type FederationResult,
federatePlugins,
type PluginRegistryEntry,
} from "./federation.js";

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,68 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parse } from "graphql";
import type { GraphqlYogaError } from "../error-codes/error-codes.js";
import { enforceGuards } from "./depth-and-complexity.js";
describe("enforceGuards", () => {
it("accepts a depth-1 / complexity-1 query within default limits", {
timeout: 5000,
}, () => {
const doc = parse("{ ping }");
expect(() =>
enforceGuards(doc, { maxDepth: 10, maxComplexity: 1000 })
).not.toThrow();
});
it("rejects depth above the configured limit", { timeout: 5000 }, () => {
const doc = parse("{ a { b { c { d { e } } } } }");
let error: unknown;
try {
enforceGuards(doc, { maxDepth: 2, maxComplexity: 1000 });
} catch (e) {
error = e;
}
expect((error as GraphqlYogaError).yogaCode).toBe(
"GRAPHQL_YOGA.DEPTH_EXCEEDED"
);
});
it("rejects complexity above the configured limit", {
timeout: 5000,
}, () => {
const doc = parse("{ a b c d e }");
let error: unknown;
try {
enforceGuards(doc, { maxDepth: 10, maxComplexity: 3 });
} catch (e) {
error = e;
}
expect((error as GraphqlYogaError).yogaCode).toBe(
"GRAPHQL_YOGA.COMPLEXITY_EXCEEDED"
);
});
it("walks inline fragments without increasing depth past their parent", {
timeout: 5000,
}, () => {
const doc = parse("{ a { ... on T { b } } }");
expect(() =>
enforceGuards(doc, { maxDepth: 2, maxComplexity: 100 })
).not.toThrow();
});
it("walks fragment spreads inline once", { timeout: 5000 }, () => {
const doc = parse("fragment f on T { x y } query { a { ...f } }");
expect(() =>
enforceGuards(doc, { maxDepth: 3, maxComplexity: 100 })
).not.toThrow();
});
it("ignores fragment spreads referring to undefined fragments", {
timeout: 5000,
}, () => {
const doc = parse("{ a { ...notDefined } }");
expect(() =>
enforceGuards(doc, { maxDepth: 3, maxComplexity: 100 })
).not.toThrow();
});
});

View File

@@ -0,0 +1,82 @@
import {
type DocumentNode,
type FieldNode,
type FragmentDefinitionNode,
type InlineFragmentNode,
Kind,
type SelectionSetNode,
} from "graphql";
import { GraphqlYogaError } from "../error-codes/error-codes.js";
/**
* Inspect a parsed GraphQL document and reject it when either:
* - the deepest nested selection-set path exceeds `maxDepth`, OR
* - the count of `Field` selections (including fragments expanded once
* per spread) exceeds `maxComplexity`.
*
* Depth is measured as the number of `SelectionSet` nesting levels reached
* from the top operation's selection set. A flat top-level query has depth
* 1; one nested field has depth 2; and so on.
*
* Complexity is the simple sum of field-node selections traversed in the
* walk. Fragment spreads expand inline once; inline fragments inherit
* their enclosing depth.
*/
export function enforceGuards(
document: DocumentNode,
limits: { readonly maxDepth: number; readonly maxComplexity: number }
): void {
const fragments = new Map<string, FragmentDefinitionNode>();
for (const def of document.definitions) {
if (def.kind === Kind.FRAGMENT_DEFINITION) {
fragments.set(def.name.value, def);
}
}
let depth = 0;
let complexity = 0;
function visit(set: SelectionSetNode, level: number): void {
if (level > depth) depth = level;
for (const sel of set.selections) {
if (sel.kind === Kind.FIELD) {
const field = sel as FieldNode;
complexity += 1;
if (field.selectionSet !== undefined) {
visit(field.selectionSet, level + 1);
}
continue;
}
if (sel.kind === Kind.INLINE_FRAGMENT) {
const inline = sel as InlineFragmentNode;
visit(inline.selectionSet, level);
continue;
}
const ref = fragments.get(sel.name.value);
if (ref !== undefined) {
visit(ref.selectionSet, level);
}
}
}
for (const def of document.definitions) {
if (def.kind === Kind.OPERATION_DEFINITION) {
visit(def.selectionSet, 1);
}
}
if (depth > limits.maxDepth) {
throw new GraphqlYogaError(
"GRAPHQL_YOGA.DEPTH_EXCEEDED",
`query depth ${depth} exceeds limit ${limits.maxDepth}`,
{ context: { depth, limit: limits.maxDepth } }
);
}
if (complexity > limits.maxComplexity) {
throw new GraphqlYogaError(
"GRAPHQL_YOGA.COMPLEXITY_EXCEEDED",
`query complexity ${complexity} exceeds limit ${limits.maxComplexity}`,
{ context: { complexity, limit: limits.maxComplexity } }
);
}
}

View File

@@ -0,0 +1 @@
export { enforceGuards } from "./depth-and-complexity.js";

View File

@@ -0,0 +1,42 @@
// Engine
export {
assertIntrospectionAllowed,
createYogaGraphqlEngine,
maskOrPassthrough,
withTimeout,
wrapSubscription,
type YogaEngineConfig,
type YogaGraphqlEngine,
} from "./engine/index.js";
// Error codes
export {
GRAPHQL_YOGA_CODES,
GraphqlYogaError,
type GraphqlYogaErrorCode,
} from "./error-codes/index.js";
// Federation
export {
type FederationResult,
federatePlugins,
type PluginRegistryEntry,
} from "./federation/index.js";
// Guards
export { enforceGuards } from "./guards/index.js";
// Instruments
export {
getYogaInstruments,
resetYogaInstruments,
type YogaInstruments,
} from "./instruments/index.js";
// Loaders
export { buildRequestLoaders, type RequestLoaderBag } from "./loaders/index.js";
// Options
export {
type ResolvedYogaGraphqlOptions,
resolveYogaGraphqlOptions,
YOGA_GRAPHQL_DEFAULTS,
type YogaGraphqlOptions,
yogaGraphqlOptionsSchema,
} from "./options/index.js";
// Subscriptions
export { subscribeBrokerTopic } from "./subscriptions/index.js";

View File

@@ -0,0 +1,5 @@
export {
getYogaInstruments,
resetYogaInstruments,
type YogaInstruments,
} from "./instruments.js";

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,95 @@
import { metrics } from "@opentelemetry/api";
import {
AggregationTemporality,
InMemoryMetricExporter,
MeterProvider,
PeriodicExportingMetricReader,
} from "@opentelemetry/sdk-metrics";
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createYogaGraphqlEngine } from "../engine/yoga-engine.js";
import { getYogaInstruments, resetYogaInstruments } from "./instruments.js";
describe("getYogaInstruments", () => {
it("returns the bundle with all five instruments", {
timeout: 5000,
}, () => {
resetYogaInstruments();
const i = getYogaInstruments();
expect(i.requestsTotal).toBeDefined();
expect(i.requestDurationMs).toBeDefined();
expect(i.resolverDurationMs).toBeDefined();
expect(i.subscriptionsActive).toBeDefined();
expect(i.schemaRebuildsTotal).toBeDefined();
});
it("caches the bundle until reset", { timeout: 5000 }, () => {
resetYogaInstruments();
const a = getYogaInstruments();
const b = getYogaInstruments();
expect(a).toBe(b);
resetYogaInstruments();
const c = getYogaInstruments();
expect(a === c).toBe(false);
});
it("counter add + histogram record do not throw against the global meter provider", {
timeout: 5000,
}, () => {
resetYogaInstruments();
const i = getYogaInstruments();
expect(() => {
i.requestsTotal.add(1, { outcome: "ok" });
i.requestDurationMs.record(12.5);
i.resolverDurationMs.record(1, { type: "Query", field: "ping" });
i.schemaRebuildsTotal.add(1);
}).not.toThrow();
});
it("subscriptions_active observable gauge samples activeSubscriptions", {
timeout: 5000,
}, async () => {
const exporter = new InMemoryMetricExporter(
AggregationTemporality.CUMULATIVE
);
const reader = new PeriodicExportingMetricReader({
exporter,
exportIntervalMillis: 100_000,
});
const provider = new MeterProvider({ readers: [reader] });
metrics.setGlobalMeterProvider(provider);
resetYogaInstruments();
const engine = createYogaGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes(
"type Query { ping: String! } type Subscription { tick: Int! }"
);
reg.addResolvers({
Query: { ping: () => "p" },
Subscription: {
tick: {
subscribe: async function* () {
yield { tick: 1 };
yield { tick: 2 };
},
resolve: (v: { tick: number }) => v.tick,
},
},
});
engine.buildSchema(reg);
const iter = engine.executeSubscription(
{ query: "subscription { tick }" },
{} as never
);
const ait = iter[Symbol.asyncIterator]();
await ait.next();
expect(engine.activeSubscriptions()).toBeGreaterThan(0);
await reader.collect();
await ait.next();
await ait.next();
await reader.shutdown();
await provider.shutdown();
resetYogaInstruments();
metrics.disable();
});
});

View File

@@ -0,0 +1,81 @@
import {
type Counter,
type Histogram,
metrics,
type ObservableGauge,
} from "@opentelemetry/api";
/**
* Bundle of OpenTelemetry instruments emitted by the Yoga GraphQL engine.
*
* Five instruments cover the lifecycle:
* - `requestsTotal { operation_type, operation_name, outcome }` — counter
* incremented once per `executeQuery` invocation.
* - `requestDurationMs` — histogram of `executeQuery` wall-time in ms.
* - `resolverDurationMs { type, field }` — histogram of individual
* resolver wall-time when wrapped via `wrapResolversForOtel`.
* - `subscriptionsActive` — observable gauge reflecting the current count
* of live subscriptions tracked by the engine.
* - `schemaRebuildsTotal` — counter incremented every time the engine
* runs `buildSchema(...)`.
*/
export interface YogaInstruments {
readonly requestsTotal: Counter;
readonly requestDurationMs: Histogram;
readonly resolverDurationMs: Histogram;
readonly subscriptionsActive: ObservableGauge;
readonly schemaRebuildsTotal: Counter;
}
let cached: YogaInstruments | undefined;
/**
* Lazily construct and cache the instrument bundle against the current
* global `MeterProvider`. Tests reset the cache via
* `resetYogaInstruments()` after installing a fresh provider.
*/
export function getYogaInstruments(): YogaInstruments {
if (cached !== undefined) return cached;
const meter = metrics.getMeter("@suaveplan/api-graphql-yoga");
cached = {
requestsTotal: meter.createCounter("graphql.requests_total", {
description:
"Total GraphQL requests partitioned by operation_type, operation_name, outcome",
}),
requestDurationMs: meter.createHistogram(
"graphql.request_duration_ms",
{
description: "Wall-time of executeQuery in milliseconds",
unit: "ms",
}
),
resolverDurationMs: meter.createHistogram(
"graphql.resolver_duration_ms",
{
description: "Per-resolver wall-time in milliseconds",
unit: "ms",
}
),
subscriptionsActive: meter.createObservableGauge(
"graphql.subscriptions_active",
{
description: "Current count of live GraphQL subscriptions",
}
),
schemaRebuildsTotal: meter.createCounter(
"graphql.schema_rebuilds_total",
{
description: "Total executable-schema rebuilds",
}
),
};
return cached;
}
/**
* Drop the cached instrument bundle so the next `getYogaInstruments()`
* binds against the `MeterProvider` that is in effect at that point.
*/
export function resetYogaInstruments(): void {
cached = undefined;
}

View File

@@ -0,0 +1 @@
export { buildRequestLoaders, type RequestLoaderBag } from "./loaders.js";

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,62 @@
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildRequestLoaders } from "./loaders.js";
function snapshot(reg: ReturnType<typeof createGraphqlRegistry>) {
return (
reg as unknown as {
readonly freeze: () => {
readonly loaders: ReadonlyMap<
string,
{
readonly create: () => {
readonly load: (k: unknown) => Promise<unknown>;
};
}
>;
};
}
).freeze();
}
describe("buildRequestLoaders", () => {
it("constructs one handle per registered factory", {
timeout: 5000,
}, async () => {
const reg = createGraphqlRegistry();
reg.addLoader("customer", async (keys) =>
keys.map((k) => `cust:${String(k)}`)
);
const snap = snapshot(reg);
const bag = buildRequestLoaders(snap.loaders);
expect(Object.keys(bag.handles)).toEqual(["customer"]);
const v = await bag.handles.customer?.load("a");
expect(v).toBe("cust:a");
bag.dispose();
});
it("two concurrent requests get distinct DataLoader instances", {
timeout: 5000,
}, () => {
const reg = createGraphqlRegistry();
reg.addLoader("customer", async (keys) =>
keys.map((k) => `c:${String(k)}`)
);
const snap = snapshot(reg);
const r1 = buildRequestLoaders(snap.loaders);
const r2 = buildRequestLoaders(snap.loaders);
expect(r1.handles.customer).not.toBe(r2.handles.customer);
r1.dispose();
r2.dispose();
});
it("dispose is idempotent", { timeout: 5000 }, () => {
const reg = createGraphqlRegistry();
reg.addLoader("a", async (keys) => keys);
const snap = snapshot(reg);
const bag = buildRequestLoaders(snap.loaders);
bag.dispose();
bag.dispose();
expect(typeof bag.dispose).toBe("function");
});
});

View File

@@ -0,0 +1,41 @@
import type { LoaderFactory } from "@suaveplan/api-graphql";
/**
* Per-request loader bag bound to a `RequestContext`. The engine constructs
* one of these at pipeline step 7 (capability scoping) for every inbound
* GraphQL request and disposes it at step 11 (cleanup). Two concurrent
* requests therefore see independent caches.
*
* The bag is opaque to resolvers: they reach loaders through
* `rctx.loaders.<name>` and call `.load(key)` / `.loadMany(keys)`. The
* underlying `LoaderFactory` controls batching + caching policy.
*/
export interface RequestLoaderBag {
readonly handles: Readonly<
Record<string, ReturnType<LoaderFactory["create"]>>
>;
dispose(): void;
}
/**
* Build the per-request loader bag from a map of `name → LoaderFactory`.
* `dispose()` is no-op for synchronous in-memory loaders — but the contract
* stays in place so future async-resource loaders (DB pools, network
* sessions) can hook tear-down without an API break.
*/
export function buildRequestLoaders(
factories: ReadonlyMap<string, LoaderFactory>
): RequestLoaderBag {
const handles: Record<string, ReturnType<LoaderFactory["create"]>> = {};
for (const [name, factory] of factories.entries()) {
handles[name] = factory.create();
}
let disposed = false;
return {
handles,
dispose(): void {
if (disposed) return;
disposed = true;
},
};
}

View File

@@ -0,0 +1,7 @@
export {
type ResolvedYogaGraphqlOptions,
resolveYogaGraphqlOptions,
YOGA_GRAPHQL_DEFAULTS,
type YogaGraphqlOptions,
yogaGraphqlOptionsSchema,
} from "./options.js";

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,78 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
resolveYogaGraphqlOptions,
YOGA_GRAPHQL_DEFAULTS,
yogaGraphqlOptionsSchema,
} from "./options.js";
describe("resolveYogaGraphqlOptions", () => {
it("applies every default when called with no input", {
timeout: 5000,
}, () => {
const out = resolveYogaGraphqlOptions(undefined);
expect(out.endpoint).toBe(YOGA_GRAPHQL_DEFAULTS.endpoint);
expect(out.sdlEndpoint).toBe(YOGA_GRAPHQL_DEFAULTS.sdlEndpoint);
expect(out.wsEndpoint).toBe(YOGA_GRAPHQL_DEFAULTS.wsEndpoint);
expect(out.uiEndpoint).toBe(YOGA_GRAPHQL_DEFAULTS.uiEndpoint);
expect(out.maskedErrors).toBe(true);
expect(out.maxQueryDepth).toBe(10);
expect(out.maxQueryComplexity).toBe(1000);
expect(out.parserCacheSize).toBe(1000);
expect(out.timeoutMs).toBe(30000);
expect(out.landingPage).toBe(false);
expect(out.introspection).toBe(true);
expect(out.strictNamespace).toBe(false);
});
it("preserves every overridden field", {
timeout: 5000,
}, () => {
const out = resolveYogaGraphqlOptions({
endpoint: "/api/gql",
sdlEndpoint: "/api/gql/sdl",
wsEndpoint: "/api/gql/ws",
uiEndpoint: "/api/gql/ui",
maskedErrors: false,
landingPage: true,
introspection: false,
maxQueryDepth: 5,
maxQueryComplexity: 200,
parserCacheSize: 0,
timeoutMs: 1000,
strictNamespace: true,
});
expect(out.endpoint).toBe("/api/gql");
expect(out.sdlEndpoint).toBe("/api/gql/sdl");
expect(out.wsEndpoint).toBe("/api/gql/ws");
expect(out.uiEndpoint).toBe("/api/gql/ui");
expect(out.maskedErrors).toBe(false);
expect(out.landingPage).toBe(true);
expect(out.introspection).toBe(false);
expect(out.maxQueryDepth).toBe(5);
expect(out.maxQueryComplexity).toBe(200);
expect(out.parserCacheSize).toBe(0);
expect(out.timeoutMs).toBe(1000);
expect(out.strictNamespace).toBe(true);
});
it("rejects endpoints that don't start with /", {
timeout: 5000,
}, () => {
expect(() =>
resolveYogaGraphqlOptions({ endpoint: "graphql" })
).toThrow();
});
it("rejects non-positive maxQueryDepth", {
timeout: 5000,
}, () => {
expect(() => resolveYogaGraphqlOptions({ maxQueryDepth: 0 })).toThrow();
});
it("exposes the underlying schema for composition", {
timeout: 5000,
}, () => {
const parsed = yogaGraphqlOptionsSchema.parse({});
expect(parsed).toEqual({});
});
});

View File

@@ -0,0 +1,84 @@
import z from "zod";
/**
* Zod schema for `YogaGraphqlOptions`. Every field is optional at the
* boundary — `resolveYogaGraphqlOptions` applies defaults so internal
* code can treat the resolved shape as fully populated.
*/
export const yogaGraphqlOptionsSchema = z.object({
endpoint: z.string().min(1).startsWith("/").optional(),
sdlEndpoint: z.string().min(1).startsWith("/").optional(),
wsEndpoint: z.string().min(1).startsWith("/").optional(),
uiEndpoint: z.string().min(1).startsWith("/").optional(),
maskedErrors: z.boolean().optional(),
landingPage: z.boolean().optional(),
introspection: z.boolean().optional(),
maxQueryDepth: z.number().int().positive().optional(),
maxQueryComplexity: z.number().int().positive().optional(),
parserCacheSize: z.number().int().nonnegative().optional(),
timeoutMs: z.number().int().positive().optional(),
strictNamespace: z.boolean().optional(),
});
export type YogaGraphqlOptions = z.input<typeof yogaGraphqlOptionsSchema>;
export interface ResolvedYogaGraphqlOptions {
readonly endpoint: string;
readonly sdlEndpoint: string;
readonly wsEndpoint: string;
readonly uiEndpoint: string;
readonly maskedErrors: boolean;
readonly landingPage: boolean;
readonly introspection: boolean;
readonly maxQueryDepth: number;
readonly maxQueryComplexity: number;
readonly parserCacheSize: number;
readonly timeoutMs: number;
readonly strictNamespace: boolean;
}
const DEFAULTS: ResolvedYogaGraphqlOptions = {
endpoint: "/graphql",
sdlEndpoint: "/graphql/sdl",
wsEndpoint: "/graphql/ws",
uiEndpoint: "/graphql/ui",
maskedErrors: true,
landingPage: false,
introspection: true,
maxQueryDepth: 10,
maxQueryComplexity: 1000,
parserCacheSize: 1000,
timeoutMs: 30_000,
strictNamespace: false,
};
/**
* Validate and apply defaults to the raw `YogaGraphqlOptions` provided by
* the host. Returns a `ResolvedYogaGraphqlOptions` whose fields are all
* concrete; downstream code does not need to handle `undefined`.
*
* Throws `ZodError` when the input violates the schema — the caller is
* expected to either let that propagate or wrap it.
*/
export function resolveYogaGraphqlOptions(
input: YogaGraphqlOptions | undefined
): ResolvedYogaGraphqlOptions {
const parsed = yogaGraphqlOptionsSchema.parse(input ?? {});
return Object.freeze({
endpoint: parsed.endpoint ?? DEFAULTS.endpoint,
sdlEndpoint: parsed.sdlEndpoint ?? DEFAULTS.sdlEndpoint,
wsEndpoint: parsed.wsEndpoint ?? DEFAULTS.wsEndpoint,
uiEndpoint: parsed.uiEndpoint ?? DEFAULTS.uiEndpoint,
maskedErrors: parsed.maskedErrors ?? DEFAULTS.maskedErrors,
landingPage: parsed.landingPage ?? DEFAULTS.landingPage,
introspection: parsed.introspection ?? DEFAULTS.introspection,
maxQueryDepth: parsed.maxQueryDepth ?? DEFAULTS.maxQueryDepth,
maxQueryComplexity:
parsed.maxQueryComplexity ?? DEFAULTS.maxQueryComplexity,
parserCacheSize: parsed.parserCacheSize ?? DEFAULTS.parserCacheSize,
timeoutMs: parsed.timeoutMs ?? DEFAULTS.timeoutMs,
strictNamespace: parsed.strictNamespace ?? DEFAULTS.strictNamespace,
});
}
export const YOGA_GRAPHQL_DEFAULTS = DEFAULTS;

View File

@@ -0,0 +1 @@
export { subscribeBrokerTopic } from "./subscriptions.js";

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,163 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type {
BrokerEnvelope,
BrokerHandler,
BrokerSubscription,
WsBroker,
} from "@suaveplan/api-ws";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GraphqlYogaError } from "../error-codes/error-codes.js";
import { subscribeBrokerTopic } from "./subscriptions.js";
class FakeBroker implements WsBroker<{ readonly v: number }> {
public readonly handlers = new Map<
string,
BrokerHandler<{ readonly v: number }>
>();
public readonly unsubscribed: string[] = [];
public publish(
_topic: string,
envelope: BrokerEnvelope<{ readonly v: number }>
): void {
const h = this.handlers.get(_topic);
if (h !== undefined) h(envelope);
}
public subscribe(
topic: string,
handler: BrokerHandler<{ readonly v: number }>
): BrokerSubscription {
this.handlers.set(topic, handler);
return {
topic,
unsubscribe: () => {
this.handlers.delete(topic);
this.unsubscribed.push(topic);
},
};
}
public dispose(): void {
this.handlers.clear();
}
}
const tenant = "tenant-a" as unknown as TenantId;
const other = "tenant-b" as unknown as TenantId;
describe("subscribeBrokerTopic", () => {
it("yields tenant-matched payloads", { timeout: 5000 }, async () => {
const broker = new FakeBroker();
const iter = subscribeBrokerTopic(broker, "billing.invoice", tenant);
const ait = iter[Symbol.asyncIterator]();
const pending = ait.next();
// Allow the awaitable subscribe to settle.
await new Promise((r) => setTimeout(r, 0));
broker.publish("billing.invoice", {
topic: "billing.invoice",
tenant,
payload: { v: 1 },
});
const step = await pending;
expect(step.done).toBe(false);
expect((step.value as { v: number }).v).toBe(1);
await iter.close();
expect(broker.unsubscribed).toContain("billing.invoice");
});
it("filters out envelopes for other tenants", {
timeout: 5000,
}, async () => {
const broker = new FakeBroker();
const iter = subscribeBrokerTopic(broker, "billing.invoice", tenant);
const ait = iter[Symbol.asyncIterator]();
await new Promise((r) => setTimeout(r, 0));
broker.publish("billing.invoice", {
topic: "billing.invoice",
tenant: other,
payload: { v: 1 },
});
broker.publish("billing.invoice", {
topic: "billing.invoice",
tenant,
payload: { v: 2 },
});
const step = await ait.next();
expect((step.value as { v: number }).v).toBe(2);
await iter.close();
});
it("yields queued payloads to a later consumer", {
timeout: 5000,
}, async () => {
const broker = new FakeBroker();
const iter = subscribeBrokerTopic(broker, "t", tenant);
await new Promise((r) => setTimeout(r, 0));
broker.publish("t", {
topic: "t",
tenant,
payload: { v: 99 },
});
const ait = iter[Symbol.asyncIterator]();
const step = await ait.next();
expect((step.value as { v: number }).v).toBe(99);
await iter.close();
});
it("return() closes the underlying subscription", {
timeout: 5000,
}, async () => {
const broker = new FakeBroker();
const iter = subscribeBrokerTopic(broker, "t", tenant);
const ait = iter[Symbol.asyncIterator]();
await new Promise((r) => setTimeout(r, 0));
const r = ait.return !== undefined ? await ait.return() : undefined;
expect(r?.done).toBe(true);
expect(broker.unsubscribed).toContain("t");
});
it("close is idempotent", { timeout: 5000 }, async () => {
const broker = new FakeBroker();
const iter = subscribeBrokerTopic(broker, "t", tenant);
await new Promise((r) => setTimeout(r, 0));
await iter.close();
await iter.close();
expect(broker.unsubscribed).toContain("t");
});
it("raises SUBSCRIPTION_TRANSPORT_UNAVAILABLE on a null broker", {
timeout: 5000,
}, () => {
expect(() => subscribeBrokerTopic(null, "t", tenant)).toThrow(
GraphqlYogaError
);
});
it("calling next() after close returns done immediately", {
timeout: 5000,
}, async () => {
const broker = new FakeBroker();
const iter = subscribeBrokerTopic(broker, "t", tenant);
await new Promise((r) => setTimeout(r, 0));
await iter.close();
const ait = iter[Symbol.asyncIterator]();
const step = await ait.next();
expect(step.done).toBe(true);
});
it("yields done after close even with pending consumer", {
timeout: 5000,
}, async () => {
const broker = new FakeBroker();
const iter = subscribeBrokerTopic(broker, "t", tenant);
const ait = iter[Symbol.asyncIterator]();
await new Promise((r) => setTimeout(r, 0));
const pending = ait.next();
// Let next() advance past `await ready` and park a waiter before
// close() flips `closed = true` — this is the path the drain loop
// covers.
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
await iter.close();
const step = await pending;
expect(step.done).toBe(true);
});
});

View File

@@ -0,0 +1,91 @@
import type { TenantId } from "@suaveplan/api-types/tenant";
import type {
BrokerEnvelope,
BrokerSubscription,
WsBroker,
} from "@suaveplan/api-ws";
import { GraphqlYogaError } from "../error-codes/error-codes.js";
/**
* Subscribe to a broker topic for a single tenant and return an
* `AsyncIterable<Payload>` that yields one item per matching broker
* envelope. Closing the iterator releases the broker subscription.
*
* The host wires this from a subscription resolver: each
* `Subscription.<field>` resolver returns
* `subscribeBrokerTopic(broker, "billing.invoice.created", tenant)`.
* The Yoga engine consumes the AsyncIterable through `graphql-ws`.
*/
export function subscribeBrokerTopic<Payload>(
broker: WsBroker<Payload> | null,
topic: string,
tenant: TenantId
): AsyncIterable<Payload> & { close(): Promise<void> } {
if (broker === null) {
throw new GraphqlYogaError(
"GRAPHQL_YOGA.SUBSCRIPTION_TRANSPORT_UNAVAILABLE",
"subscription requested but no wsBroker was configured on the engine",
{ context: { topic } }
);
}
const queue: Payload[] = [];
const waiters: Array<(value: IteratorResult<Payload>) => void> = [];
let subscription:
| Awaited<ReturnType<WsBroker<Payload>["subscribe"]>>
| undefined;
let closed = false;
const ready = Promise.resolve(
broker.subscribe(topic, (envelope: BrokerEnvelope<Payload>) => {
if (envelope.tenant !== tenant) return;
const waiter = waiters.shift();
if (waiter !== undefined) {
waiter({ value: envelope.payload, done: false });
return;
}
queue.push(envelope.payload);
})
).then((sub) => {
subscription = sub;
});
async function close(): Promise<void> {
if (closed) return;
closed = true;
await ready;
await (subscription as BrokerSubscription).unsubscribe();
for (const waiter of waiters.splice(0)) {
waiter({ value: undefined as never, done: true });
}
}
const iterable: AsyncIterable<Payload> & { close(): Promise<void> } = {
close,
[Symbol.asyncIterator](): AsyncIterator<Payload> {
return {
async next(): Promise<IteratorResult<Payload>> {
await ready;
if (queue.length > 0) {
return {
value: queue.shift() as Payload,
done: false,
};
}
if (closed) {
return { value: undefined as never, done: true };
}
return new Promise<IteratorResult<Payload>>((resolve) => {
waiters.push(resolve);
});
},
async return(): Promise<IteratorResult<Payload>> {
await close();
return { value: undefined as never, done: true };
},
};
},
};
return iterable;
}

View File

@@ -0,0 +1,6 @@
export {
type ConformanceContext,
type RunConformanceOptions,
runGraphqlEngineConformance,
runYogaConformance,
} from "./testing.js";

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.

View File

@@ -0,0 +1,13 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { runGraphqlEngineConformance, runYogaConformance } from "./testing.js";
describe("runYogaConformance — meta", () => {
it("re-exports the abstract harness alias", { timeout: 5000 }, () => {
expect(typeof runGraphqlEngineConformance).toBe("function");
expect(typeof runYogaConformance).toBe("function");
});
});
describe("runYogaConformance — invocation", () => {
runYogaConformance("yoga-meta-binding");
});

View File

@@ -0,0 +1,45 @@
import {
type ConformanceContext,
type RunConformanceOptions,
runGraphqlEngineConformance as runAbstract,
} from "@suaveplan/api-graphql";
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import {
createYogaGraphqlEngine,
type YogaEngineConfig,
} from "../engine/yoga-engine.js";
/**
* Run the abstract `runGraphqlEngineConformance` against the Yoga
* engine. Hosts call this from a spec-conformance test to prove the
* adapter satisfies the cross-engine invariants the abstract package
* defines. The same harness MUST pass against any concrete engine; the
* binding here is the only Yoga-specific piece.
*/
export function runYogaConformance(
label = "yoga",
config?: YogaEngineConfig
): void {
runAbstract({
label,
factory: async (): Promise<ConformanceContext> => ({
engine: createYogaGraphqlEngine(config),
makeRequestContext: () => makeStubRequestContext(),
}),
});
}
function makeStubRequestContext<
Caps extends CapabilityRequirements = CapabilityRequirements,
>(): RequestContext<Caps> {
return {} as RequestContext<Caps>;
}
export type { ConformanceContext, RunConformanceOptions };
/**
* Re-export the abstract harness so consumers that already imported from
* `@suaveplan/api-graphql-yoga/testing` for the binding can also reach
* the lower-level surface without a second package import.
*/
export { runAbstract as runGraphqlEngineConformance };

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": ".",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts", "tests/**/*.ts"],
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022", "DOM"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,39 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../node_modules/.vite/packages/api-graphql-yoga",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: { entry: "src/index.ts", formats: ["es"] },
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
/^@suaveplan\//,
/^node:/,
"tslib",
"zod",
"graphql",
"graphql-yoga",
"@graphql-tools/schema",
"dataloader",
],
},
},
});

View File

@@ -0,0 +1,42 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
globals: false,
environment: "node",
pool: "forks",
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"src/**/*.bench.ts",
"src/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 5000,
server: {
deps: {
inline: [
"graphql",
"graphql-yoga",
"@graphql-tools/schema",
"@graphql-tools/utils",
"@graphql-tools/merge",
],
},
},
},
});