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

128 lines
6.5 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec Delta: api-graphql-yoga (api-graphql-yoga)
**Package**: `@suaveplan/api-graphql-yoga`
**Status**: PROPOSED
> Every requirement carries a `**Verified by:**` line.
## ADDED 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"`