Files
2026-05-27 23:29:53 +01:00

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-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"