Initial Commit
This commit is contained in:
127
openspec/specs/api-graphql-yoga/spec.md
Normal file
127
openspec/specs/api-graphql-yoga/spec.md
Normal file
@@ -0,0 +1,127 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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"`
|
||||
Reference in New Issue
Block a user