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

5.9 KiB

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"