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

154 lines
7.5 KiB
Markdown

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