6.4 KiB
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-typetext/plain.WS /graphql/ws—graphql-wsprotocol over the host's wiredapi-wsengine.GET /graphql/ui— proxied to whichever UI plugin (api-graphql-ui-graphiqlorapi-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-memoryinstance - THEN
POST /graphql,GET /graphql/sdl,WS /graphql/wsare reachable
Scenario: UI endpoint is 404 when no UI plugin is wired
- WHEN no UI plugin is registered AND
GET /graphql/uiis 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 contributesextend type Query { customers: [Customer!]! } - THEN both
invoicesandcustomersare queryable on the merged schema
Scenario: same-field collision fails at build
- WHEN plugin A and plugin B both contribute
extend type Query { invoices: ... }withoutgraphql.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
invoiceCreatedfor tenant T AND the host emitsbilling.invoice.createdfor 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-wssubscription 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_totalcounter{ operation_type, operation_name, outcome }.graphql.request_duration_mshistogram.graphql.resolver_duration_mshistogram{ type, field }.graphql.subscriptions_activeobservable gauge.graphql.schema_rebuilds_totalcounter.
Scenario: successful query increments counter and emits histogram
- WHEN a query executes successfully
- THEN an in-memory
MeterProviderobserves one increment ingraphql.requests_total{outcome="ok"}AND one observation ingraphql.request_duration_ms
Verified by: src/instruments/instruments.test.ts::"successful query emits requests_total + request_duration_ms"