Files
suaveplan/openspec/specs/api-graphql-apollo/spec.md
2026-05-27 23:29:53 +01:00

128 lines
5.9 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: api-graphql-apollo
**Package**: `@suaveplan/api-graphql-apollo`
**Status**: SHIPPED 2026-05-26
> Every requirement carries a `**Verified by:**` line.
## Requirements
### Requirement: Implements the abstract GraphQL engine contract
The package SHALL export `createApolloGraphqlEngine(opts)` producing an engine value that implements the engine contract from `@suaveplan/api-graphql`. The conformance suite `runGraphqlEngineConformance` MUST pass against this adapter with the same invariants required of `api-graphql-yoga`.
#### Scenario: conformance suite passes
- **WHEN** `runGraphqlEngineConformance("apollo", createApolloGraphqlEngine)` runs
- **THEN** every invariant passes
**Verified by:** `src/engine/apollo-engine.spec-conformance.test.ts::"createApolloGraphqlEngine passes runGraphqlEngineConformance"`
---
### Requirement: Endpoint set identical to Yoga adapter
The adapter SHALL mount the same four endpoints (`POST /graphql`, `GET /graphql/sdl`, `WS /graphql/ws`, `GET /graphql/ui`) at the same default paths with the same configurability. The `/graphql/ui` endpoint MUST return 404 when no UI plugin is wired — the adapter MUST NOT embed Apollo's landing page as a default (UI is opt-in via a separate package, identical to Yoga's stance).
#### Scenario: endpoint set matches Yoga
- **WHEN** the engine mounts on `api-router-in-memory`
- **THEN** the four endpoints respond at the same paths as the Yoga adapter does
#### Scenario: landing page disabled by default
- **WHEN** no UI plugin is wired
- **THEN** `GET /graphql/ui` returns 404 (NOT an Apollo landing page)
**Verified by:** `src/endpoints/http.test.ts::"endpoint set matches Yoga at default paths"` AND `src/endpoints/ui-proxy.test.ts::"/graphql/ui returns 404 when no UI plugin is wired"`
---
### Requirement: Federation across plugins with collision detection
The engine SHALL accept schema contributions from multiple plugins and merge them into one executable schema. `extend type Query` from multiple plugins MUST union. Same-field collisions without `graphql.namespace` MUST fail with `GRAPHQL.SCHEMA_COLLISION` (the canonical code, NOT a `GRAPHQL_APOLLO_*` code).
#### Scenario: two plugins extending Query both expose fields
- **WHEN** plugin A and plugin B each extend `Query`
- **THEN** both contribute fields to the merged schema
#### Scenario: collision fails at build
- **WHEN** two plugins contribute the same field on the same type
- **THEN** schema build fails with `GRAPHQL.SCHEMA_COLLISION`
**Verified by:** `src/federation/federation.test.ts::"two plugins extending Query both expose their fields"` AND `src/federation/federation.test.ts::"same-field collision fails with GRAPHQL.SCHEMA_COLLISION"`
---
### Requirement: Subscriptions ride api-ws
Subscriptions SHALL use `graphql-ws` protocol over the host's wired `api-ws` engine, with subscription resolvers returning `AsyncIterable` driven by the event bus, identical to the Yoga adapter.
#### Scenario: tenant-scoped subscription delivery
- **WHEN** a subscriber for tenant T subscribes AND an event for tenant T is emitted
- **THEN** the subscriber receives the event AND a tenant-U subscriber does not
**Verified by:** `src/subscriptions/subscriptions.test.ts::"subscription delivers tenant-scoped events only to matching tenants"`
---
### Requirement: Persisted-query manifest support
When `ApolloGraphqlOptions.persistedQueriesPath` is set, the engine SHALL accept `{ id }` in place of `{ query }` on `POST /graphql` and resolve `id` against the loaded manifest. An unknown id MUST fail with `GRAPHQL_APOLLO_PERSISTED_QUERY_NOT_FOUND`.
#### Scenario: known id resolves to its query
- **WHEN** `POST /graphql` arrives with `{ id: "abc123" }` AND `abc123` is in the manifest
- **THEN** the engine executes the manifest's query for that id
#### Scenario: unknown id is rejected
- **WHEN** the id is not in the manifest
- **THEN** the response is `GRAPHQL_APOLLO_PERSISTED_QUERY_NOT_FOUND`
**Verified by:** `src/persisted-queries/persisted-queries.test.ts::"known id resolves to its query"` AND `src/persisted-queries/persisted-queries.test.ts::"unknown id returns GRAPHQL_APOLLO_PERSISTED_QUERY_NOT_FOUND"`
---
### Requirement: Per-request DataLoader scoping
The engine SHALL create DataLoader instances per-request bound to `RequestContext`, with two concurrent requests receiving distinct loader instances.
#### Scenario: concurrent requests do not share loader caches
- **WHEN** R1 and R2 both invoke a loader-using resolver
- **THEN** R1's cached value is not served to R2
**Verified by:** `src/loaders/loaders.test.ts::"two concurrent requests get distinct DataLoader instances"`
---
### Requirement: OTel surface uses engine-agnostic metric names
The engine SHALL emit `graphql.requests_total`, `graphql.request_duration_ms`, `graphql.resolver_duration_ms`, `graphql.subscriptions_active`, and `graphql.schema_rebuilds_total` — the same metric names emitted by the Yoga adapter. Operators MUST be able to swap engines without renaming dashboards.
#### Scenario: metric names match Yoga's
- **WHEN** both engines run against the same plugin set under an in-memory `MeterProvider`
- **THEN** the set of metric names observed is identical
**Verified by:** `tests/integration/engine-swap.test.ts::"metric names are identical between Apollo and Yoga"`
---
### Requirement: Depth and complexity guards
The engine SHALL reject queries exceeding `maxQueryDepth` with `GRAPHQL_APOLLO_DEPTH_EXCEEDED` AND queries exceeding `maxQueryComplexity` with `GRAPHQL_APOLLO_COMPLEXITY_EXCEEDED`. Defaults: depth 10, complexity 1000.
#### Scenario: query exceeding depth is rejected
- **WHEN** a query has nested selection 11 levels deep AND `maxQueryDepth: 10`
- **THEN** execution fails with `GRAPHQL_APOLLO_DEPTH_EXCEEDED`
**Verified by:** `src/guards/depth-and-complexity.test.ts::"query exceeding depth limit rejected with GRAPHQL_APOLLO_DEPTH_EXCEEDED"`