# Capability Spec: api-graphql-yoga **Package**: `@suaveplan/api-graphql-yoga` **Status**: SHIPPED 2026-05-26 > Every requirement carries a `**Verified by:**` line. ## Requirements ### Requirement: Implements the abstract GraphQL engine contract The package SHALL export `createYogaGraphqlEngine(opts)` producing an engine value that implements the engine contract from `@suaveplan/api-graphql`. The same conformance suite (`runGraphqlEngineConformance`) MUST pass against this adapter AND against `api-graphql-apollo` — switching engines is host-config only. #### Scenario: conformance suite passes - **WHEN** `runGraphqlEngineConformance("yoga", createYogaGraphqlEngine)` runs - **THEN** every invariant passes **Verified by:** `src/engine/yoga-engine.spec-conformance.test.ts::"createYogaGraphqlEngine passes runGraphqlEngineConformance"` --- ### Requirement: Endpoint set mounted at documented paths The engine SHALL mount four endpoints via the host's `api-router`: - `POST /graphql` — main execution endpoint accepting `{ query, variables, operationName }`. - `GET /graphql/sdl` — returns the merged SDL across all plugin contributions, content-type `text/plain`. - `WS /graphql/ws` — `graphql-ws` protocol over the host's wired `api-ws` engine. - `GET /graphql/ui` — proxied to whichever UI plugin (`api-graphql-ui-graphiql` or `api-graphql-ui-altair`) is wired; if none is wired, returns 404 (NOT a default GraphiQL — keeping UI choice opt-in). Each path is configurable via `YogaGraphqlOptions`. #### Scenario: every documented endpoint responds - **WHEN** the engine is mounted on an `api-router-in-memory` instance - **THEN** `POST /graphql`, `GET /graphql/sdl`, `WS /graphql/ws` are reachable #### Scenario: UI endpoint is 404 when no UI plugin is wired - **WHEN** no UI plugin is registered AND `GET /graphql/ui` is requested - **THEN** the response is 404 (NOT a fallback GraphiQL) **Verified by:** `src/endpoints/http.test.ts::"every documented endpoint responds"` AND `src/endpoints/ui-proxy.test.ts::"/graphql/ui returns 404 when no UI plugin is wired"` --- ### Requirement: Schema federation across plugins The engine SHALL accept schema contributions from multiple plugins (each via `registry.addTypes(sdl)` + `registry.addResolvers(map)`) and merge them into one executable schema via `@graphql-tools/schema`. `extend type Query { ... }` from multiple plugins MUST union. Two plugins declaring the same field on the same type without a `graphql.namespace` MUST fail schema build with `GRAPHQL.SCHEMA_COLLISION` (the canonical code on `@suaveplan/api-graphql`'s namespace, NOT a `GRAPHQL_YOGA_*` code). #### Scenario: two plugins each extend Query - **WHEN** plugin A contributes `extend type Query { invoices: [Invoice!]! }` AND plugin B contributes `extend type Query { customers: [Customer!]! }` - **THEN** both `invoices` and `customers` are queryable on the merged schema #### Scenario: same-field collision fails at build - **WHEN** plugin A and plugin B both contribute `extend type Query { invoices: ... }` without `graphql.namespace` - **THEN** schema build fails with `GRAPHQL.SCHEMA_COLLISION` **Verified by:** `src/federation/federation.test.ts::"two plugins each extending Query both expose their fields"` AND `src/federation/federation.test.ts::"same-field collision fails with GRAPHQL.SCHEMA_COLLISION"` --- ### Requirement: Subscriptions ride the WebSocket engine Subscriptions SHALL use `graphql-ws` protocol over the host's wired `api-ws` engine at `WS /graphql/ws`. Subscription resolvers MUST return `AsyncIterable` driven by the event bus; the engine wires `rctx.bus.asyncIterator(topic, { tenant })` directly. Subscription tear-down MUST release the WS connection's subscription slot. #### Scenario: subscription delivers tenant-scoped events - **WHEN** a client subscribes to `invoiceCreated` for tenant T AND the host emits `billing.invoice.created` for tenant T - **THEN** the subscriber receives one event AND a subscriber for tenant U receives zero #### Scenario: subscription tear-down releases the slot - **WHEN** the client closes the subscription - **THEN** the underlying `api-ws` subscription slot is released within 1000ms **Verified by:** `src/subscriptions/subscriptions.test.ts::"subscription delivers tenant-scoped events only to matching tenants"` AND `src/subscriptions/subscriptions.test.ts::"closing subscription releases the api-ws slot"` --- ### Requirement: Per-request DataLoader scoping `DataLoader` instances SHALL be constructed per-request and bound to `RequestContext`. They MUST be created at pipeline step 7 (capability scoping) and disposed at step 11 (cleanup). Two concurrent requests MUST NOT share a loader cache. #### Scenario: two concurrent requests get distinct loaders - **WHEN** requests R1 and R2 both invoke a resolver that uses `rctx.loaders.customer` - **THEN** R1's loader and R2's loader are distinct instances AND R1's cached value is NOT served to R2 **Verified by:** `src/loaders/loaders.test.ts::"two concurrent requests get distinct DataLoader instances"` --- ### Requirement: Depth and complexity guards The engine SHALL reject queries exceeding `maxQueryDepth` with `GRAPHQL_YOGA_DEPTH_EXCEEDED` AND queries exceeding `maxQueryComplexity` with `GRAPHQL_YOGA_COMPLEXITY_EXCEEDED`. Both limits MUST be configurable via `YogaGraphqlOptions`; sensible defaults (`maxQueryDepth: 10`, `maxQueryComplexity: 1000`) MUST be applied when unset. #### Scenario: query exceeding depth limit is rejected - **WHEN** a query has nested selection 11 levels deep AND `maxQueryDepth: 10` - **THEN** execution fails with `GRAPHQL_YOGA_DEPTH_EXCEEDED` **Verified by:** `src/guards/depth-and-complexity.test.ts::"query exceeding depth limit rejected with GRAPHQL_YOGA_DEPTH_EXCEEDED"` --- ### Requirement: OTel surface The engine SHALL emit: - `graphql.requests_total` counter `{ operation_type, operation_name, outcome }`. - `graphql.request_duration_ms` histogram. - `graphql.resolver_duration_ms` histogram `{ type, field }`. - `graphql.subscriptions_active` observable gauge. - `graphql.schema_rebuilds_total` counter. #### Scenario: successful query increments counter and emits histogram - **WHEN** a query executes successfully - **THEN** an in-memory `MeterProvider` observes one increment in `graphql.requests_total{outcome="ok"}` AND one observation in `graphql.request_duration_ms` **Verified by:** `src/instruments/instruments.test.ts::"successful query emits requests_total + request_duration_ms"`