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

7.5 KiB

Capability Spec: api-graphql

Package: @suaveplan/api-graphql Status: SHIPPED 2026-05-25

Every requirement carries a **Verified by:** line.

Requirements

Requirement: GraphqlRegistry accumulator contract

The package SHALL export the GraphqlRegistry interface with addTypes(sdl: string): void, addResolvers(map: ResolverMap): void, addLoader<K, V>(name: string, batcher: BatchFn<K, V>): void. Multiple calls of each method MUST accumulate (not overwrite); the host calls freeze() after every plugin has contributed. The registry MUST be generic over Caps extends CapabilityMap so resolvers and loaders receive the typed RequestContext<Caps>.

Scenario: addTypes accumulates across calls

  • WHEN two SDL strings are added in sequence
  • THEN both contributions are visible in the merged output

Scenario: addLoader keyed by name

  • WHEN two loaders with the same name are registered
  • THEN the second call throws GRAPHQL.LOADER_NOT_REGISTERED is NOT thrown but a duplicate-registration error is — pinned as part of GRAPHQL_CODES design — actually LOADER_DUPLICATE_NAME per the registry tests

Verified by: src/registry/registry.test.ts::"addTypes accumulates across calls" AND src/registry/registry.test.ts::"addLoader rejects duplicate names"


Requirement: import z from "zod" everywhere

Every Zod import MUST use the default form.

Scenario: no named import of z

  • WHEN the test greps import z
  • THEN zero matches

Verified by: tests/zod-import-form.test.ts::"every src file uses default-import z"


Requirement: Federation merger with collision detection

The package SHALL export mergeSdl(contributions: readonly SdlContribution[]): MergedSchema. Multiple plugins contributing extend type Query { ... } SHALL be unioned. A collision (two plugins declaring the same field on the same type without the field being declared as an extend) MUST throw GRAPHQL.SCHEMA_COLLISION with details.path = "Type.field" AND details.plugins = [A, B]. The merger MUST honour graphql.namespace: when a plugin declares graphql.namespace: "Billing" in its manifest, its Query/Mutation/Subscription extensions MUST be wrapped into BillingQuery/BillingMutation/BillingSubscription types referenced from the root.

Scenario: two plugins extend Query without collision

  • WHEN plugin A extends Query { invoices } AND plugin B extends Query { users }
  • THEN the merged schema has both fields on Query

Scenario: collision reported with plugin pair

  • WHEN plugin A and plugin B both declare Query.users without namespace
  • THEN GRAPHQL.SCHEMA_COLLISION is thrown AND details.plugins is ["A", "B"]

Scenario: namespace wrapper applied

  • WHEN plugin "Billing" declares graphql.namespace: "Billing" AND extends Query { invoices }
  • THEN the merged schema exposes Query.billing: BillingQuery AND BillingQuery.invoices is reachable

Verified by: src/federation/merge-sdl.test.ts::"two plugins extend Query without collision" AND src/federation/merge-sdl.test.ts::"collision reports plugin pair" AND src/federation/namespace.test.ts::"namespace wraps contributions under wrapper type"


Requirement: Resolver type is RequestContext-bound

The package SHALL export Resolver<Parent, Args, Result, Caps extends CapabilityMap> where the third argument is RequestContext<Caps> (not PluginContext). Resolvers MUST receive tenant-scoped db, request-bound principal, and the per-request DataLoaders.

Scenario: resolver third arg is RequestContext

  • WHEN expectTypeOf<Parameters<Resolver<P, A, R, C>>[2]>() is checked
  • THEN it equals RequestContext<C> (not PluginContext<C>)

Verified by: src/resolver/resolver.spec-conformance.test.ts::"resolver third argument is RequestContext"


Requirement: SubscriptionResolver returns AsyncIterable

The package SHALL export SubscriptionResolver<Parent, Args, Result, Caps> whose subscribe returns AsyncIterable<Result> | Promise<AsyncIterable<Result>>. Subscriptions ride the WebSocket engine (api-ws); this contract MUST reference api-ws's SubscriptionBus via type only (no runtime dep).

Scenario: subscribe returns AsyncIterable

  • WHEN expectTypeOf<SubscriptionResolver<P, A, R, C>["subscribe"]>() is checked
  • THEN the return type unifies to AsyncIterable<R> | Promise<AsyncIterable<R>>

Verified by: src/subscription/subscription.spec-conformance.test.ts::"subscribe returns AsyncIterable"


Requirement: Directive contracts

The package SHALL export descriptors for @auth(scope: String!) and @rateLimit(rpm: Int!) directives. Each descriptor MUST declare: directive name, locations (FIELD_DEFINITION), argument schema, host-side handler hook. Engine adapters install the directives at schema-build time.

Scenario: @auth descriptor present

  • WHEN the test imports authDirective
  • THEN the descriptor's name === "auth" AND locations includes FIELD_DEFINITION

Verified by: src/directives/directives.test.ts::"auth and rateLimit descriptors present"


Requirement: GraphqlEngine lifecycle interface

The package SHALL export GraphqlEngine interface: buildSchema(registry: GraphqlRegistry): MergedSchema, executeQuery(req: GraphqlRequest, ctx: RequestContext): Promise<GraphqlResponse>, executeSubscription(req, ctx): AsyncIterable<GraphqlResponse>, dispose(): Promise<void>. Concrete engines (api-graphql-yoga, api-graphql-apollo) implement this interface.

Scenario: every required method present

  • WHEN expectTypeOf<GraphqlEngine>() is checked
  • THEN all four methods are declared with the documented signatures

Verified by: src/engine/engine.spec-conformance.test.ts::"GraphqlEngine declares buildSchema executeQuery executeSubscription dispose"


Requirement: CapabilityId augmentation contributes "graphql"

The package SHALL augment the CapabilityId union from @suaveplan/api-types to include "graphql". Type-only augmentation (no runtime cost).

Scenario: "graphql" is assignable to CapabilityId

  • WHEN expectTypeOf<"graphql">().toMatchTypeOf<CapabilityId>() is checked
  • THEN the assertion passes

Verified by: src/capability-id/capability-id.spec-conformance.test.ts::"graphql is a valid CapabilityId after augmentation"


Requirement: Error codes registry frozen

The package SHALL export GRAPHQL_CODES via defineCodes containing exactly: SCHEMA_COLLISION, RESOLVER_MISSING, LOADER_DUPLICATE_NAME, LOADER_NOT_REGISTERED, DIRECTIVE_UNKNOWN, SUBSCRIPTION_TRANSPORT_UNAVAILABLE, NAMESPACE_REQUIRED.

Scenario: every code reachable

  • WHEN the test enumerates GRAPHQL_CODES
  • THEN every code has a production throw site

Verified by: src/error-codes/error-codes.spec-conformance.test.ts::"every GRAPHQL_CODES entry thrown by production path"


Requirement: Conformance harness for engines

The package SHALL export runGraphqlEngineConformance({ factory, label }) from ./testing. Both api-graphql-yoga and api-graphql-apollo (Stage 7) MUST pass it.

Scenario: harness covers query, mutation, subscription, federation

  • WHEN the harness is run against a stub engine
  • THEN test categories exist for query, mutation, subscription, federation, directive handling

Verified by: src/testing/conformance.test.ts::"harness covers query mutation subscription federation directives"