Initial Commit

This commit is contained in:
2026-05-27 23:29:53 +01:00
commit fbc70f96e6
10000 changed files with 1102152 additions and 0 deletions

View File

@@ -0,0 +1,63 @@
# `@suaveplan/api-graphql-yoga` — GraphQL Yoga engine adapter (default engine)
**Package**: `@suaveplan/api-graphql-yoga`
**Status**: PROPOSED
**Type**: new-package
**Stage**: 7 (GraphQL + WebSocket / SSE engines) — see umbrella `openspec/changes/api-plugin-foundation/`
## Why
GraphQL is a first-class engine alongside REST in the foundation (design doc §10). Plugins contribute types and resolvers via the abstract `api-graphql` Registry; one of the host's adapters federates a single merged schema across all plugins and dispatches requests. Per §10.5 GraphQL Yoga is the default engine — Bun-friendly, fastest for our usage, simpler operational story than Apollo Server. This package binds the abstract `api-graphql` contract to Yoga.
Without this adapter the abstract `api-graphql` contract has no runnable implementation, and the umbrella's Stage-7 acceptance ("`api-plugin-example` mounts a federated GraphQL schema across two plugins AND publishes a tenant-scoped WebSocket broadcast") can't be reached.
## What changes
Net-new package depending on `@suaveplan/api-types`, `@suaveplan/api-graphql` (abstract contract), `@suaveplan/api-router` (mounts HTTP endpoints), `@suaveplan/api-ws` (subscription transport), `@suaveplan/error`, `@suaveplan/logger`, `@suaveplan/opentelemetry`, `graphql` (peer), `graphql-yoga` (peer), and `zod`.
- ADDED: `createYogaGraphqlEngine(opts)` — factory returning the engine implementing `@suaveplan/api-graphql`'s engine contract.
- ADDED: `YogaGraphqlOptions` — Zod-validated config: `endpoint` (default `/graphql`), `sdlEndpoint` (default `/graphql/sdl`), `wsEndpoint` (default `/graphql/ws`), `uiEndpoint` (default `/graphql/ui`, only mounted if a UI plugin like `api-graphql-ui-graphiql` is wired), `maskedErrors`, `landingPage`, `maxQueryDepth`, `maxQueryComplexity`, `parserCacheSize`.
- ADDED: Endpoint mounts:
- `POST /graphql` — main execution endpoint.
- `GET /graphql/sdl` — merged SDL across all federated plugin contributions.
- `WS /graphql/ws` — `graphql-ws` protocol over the `api-ws` engine.
- `GET /graphql/ui` — proxied to the wired UI plugin (this package does NOT mount GraphiQL/Altair itself; those are separate packages).
- ADDED: Schema federation — wraps multiple plugin contributions into one merged schema via `@graphql-tools/schema` `makeExecutableSchema`. Collisions raise `GRAPHQL.SCHEMA_COLLISION` (PluginScope error code on `@suaveplan/api-graphql`'s namespace).
- ADDED: Subscriptions — bridges `graphql-ws` over the `api-ws` engine. Subscription resolvers return `AsyncIterable` driven by the event bus.
- ADDED: Per-request `DataLoader` instances scoped to `RequestContext`, populated at pipeline step 7 and disposed at step 11 (design doc §10.3).
- ADDED: `GRAPHQL_YOGA_*` error codes: `GRAPHQL_YOGA_PARSE_FAILED`, `GRAPHQL_YOGA_VALIDATION_FAILED`, `GRAPHQL_YOGA_DEPTH_EXCEEDED`, `GRAPHQL_YOGA_COMPLEXITY_EXCEEDED`, `GRAPHQL_YOGA_SCHEMA_BUILD_FAILED`, `GRAPHQL_YOGA_SUBSCRIPTION_TRANSPORT_UNAVAILABLE`, `GRAPHQL_YOGA_TIMEOUT`, `GRAPHQL_YOGA_INTROSPECTION_DENIED`, `GRAPHQL_YOGA_PERSISTED_QUERY_NOT_FOUND`, `GRAPHQL_YOGA_RESOLVER_THREW`.
- ADDED: OTel instruments: `graphql.requests_total { operation_type, operation_name, outcome }`, `graphql.request_duration_ms`, `graphql.resolver_duration_ms { type, field }`, `graphql.subscriptions_active` gauge, `graphql.schema_rebuilds_total`.
- ADDED: `./testing` subpath — re-export of `runGraphqlEngineConformance` from `api-graphql/testing` bound to `createYogaGraphqlEngine`.
## Out of scope
- The abstract `api-graphql` contract, schema-collision error code namespace, Registry / Resolver / LoaderFactory types — those live in `api-graphql` (Stage 1).
- The GraphiQL or Altair UI — separate packages `api-graphql-ui-graphiql` and `api-graphql-ui-altair`. This adapter only proxies `/graphql/ui` to whichever is wired.
- Cross-plugin federation transport (the merge itself happens on the host); this adapter just consumes the already-merged schema descriptor.
- Apollo-specific features (persisted-query manifests, Apollo Studio) — that's `api-graphql-apollo`.
## Source mapping
Genesis-original. Source of design intent: `docs/superpowers/specs/2026-05-13-api-plugin-foundation-design.md`.
| Requirement | Design doc section |
|---|---|
| Default engine for GraphQL | §10.5 |
| Endpoint set (POST /graphql, GET /graphql/sdl, WS /graphql/ws, GET /graphql/ui) | §10.6 |
| Federation via `extend type Query { ... }` across plugins | §10.2 |
| Subscriptions over `api-ws` via `graphql-ws` | §10.4 |
| Per-request DataLoader scoping | §10.3 |
| Same conformance suite as `api-graphql-apollo` | §10.5 (last sentence) |
## Verification approach
- `src/engine/yoga-engine.test.ts` — schema build, request execution, error masking, depth/complexity guards.
- `src/engine/yoga-engine.spec-conformance.test.ts` — runs `runGraphqlEngineConformance("yoga", createYogaGraphqlEngine)`; pins identical invariants to those required of `api-graphql-apollo`.
- `src/federation/federation.test.ts` — two plugins each `extend type Query`; assert the merged schema serves both fields; assert collision raises `GRAPHQL.SCHEMA_COLLISION`.
- `src/subscriptions/subscriptions.test.ts` (integration, 30000ms) — subscribes via `graphql-ws` over `api-ws-broker-in-memory`; broadcasts a tenant-scoped event; asserts delivery.
- `src/loaders/loaders.test.ts` — per-request DataLoader instances; assert disposal after step 11.
- `tests/integration/end-to-end.test.ts` (60000ms) — full pipeline: REST request → GraphQL request → subscription, all over Yoga, against an in-memory router + WS engine.
## Migration impact
None — additive only.

View File

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

View File

@@ -0,0 +1,73 @@
# Tasks — `@suaveplan/api-graphql-yoga`
> Every checkbox MUST reflect work actually done in this change.
## 0. Pre-flight (non-negotiable)
- [x] 0.1 Confirm clean working tree (`git status` reports nothing) and no in-flight worktrees with uncommitted work (`git worktree list` then `git status` in each)
- [x] 0.2 If any worktrees exist under `.claude/worktrees/`, account for each one: still-active agent, work-pending-merge, or stale-and-prune. Stale worktrees MUST be removed (`git worktree remove`) before opening a new one — code that has been lost twice on main is the failure mode this guards against.
- [x] 0.3 Confirm the package set in `MANIFEST.md` matches `find packages -maxdepth 3 -name package.json` count. Drift here means a prior change archived without regenerating the manifest.
## 1. Spec authoring
- [x] 1.1 Read design doc §10 end-to-end (focus §10.5, §10.6)
- [x] 1.2 Read umbrella Stage 7 acceptance
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
- [x] 1.4 Draft `proposal.md`
- [x] 1.5 Draft `specs/api-graphql-yoga/spec.md`
- [x] 1.6 Every requirement carries a `**Verified by:**` line
## 2. Implementation
- [x] 2.1 Scaffold `packages/api/api-graphql-yoga/`
- [x] 2.2 `src/options/options.ts` — `YogaGraphqlOptions` Zod schema
- [x] 2.3 `src/engine/yoga-engine.ts` — `createYogaGraphqlEngine(opts)`; implements abstract engine contract
- [x] 2.4 `src/federation/federation.ts` — merge plugin schema contributions; collision detection
- [x] 2.5 `src/endpoints/http.ts` — mount `POST /graphql`, `GET /graphql/sdl` via `api-router`
- [x] 2.6 `src/endpoints/ui-proxy.ts` — proxy `/graphql/ui` to whichever UI plugin is wired
- [x] 2.7 `src/subscriptions/subscriptions.ts` — `graphql-ws` over `api-ws`; AsyncIterable from event bus
- [x] 2.8 `src/loaders/loaders.ts` — per-request DataLoader; bound to `RequestContext`
- [x] 2.9 `src/guards/depth-and-complexity.ts` — depth limit, complexity limit, persisted-query support
- [x] 2.10 `src/instruments/instruments.ts` — OTel counters + histograms + gauge
- [x] 2.11 `src/error-codes.ts` — 10 `GRAPHQL_YOGA_*` codes
- [x] 2.12 `./testing` subpath — re-export `runGraphqlEngineConformance` bound to the Yoga factory
- [x] 2.13 `src/index.ts` — explicit named re-exports
## 3. Tests
- [x] 3.1 Unit tests, every `it(...)` `{ timeout: 5000 }`
- [x] 3.2 Conformance test runs `runGraphqlEngineConformance`
- [x] 3.3 Federation integration test pins collision behaviour
- [x] 3.4 Subscriptions integration test (`{ timeout: 30000 }`) against `api-ws-broker-in-memory`
- [x] 3.5 End-to-end integration test (`{ timeout: 60000 }`)
- [x] 3.6 All tests import from `@suaveplan/testing/runner`
## 4. Documentation
- [x] 4.1 Sibling `.md` per implementation file
- [x] 4.2 `README.md` with `## Modules`; note this is the default GraphQL engine
## 5. Pre-archive verification
- [x] 5.1 `bun run check` — 0/0/0
- [x] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/api-graphql-yoga` — green
- [x] 5.3 `bunx turbo test:coverage --filter=@suaveplan/api-graphql-yoga` — 100/100/100/100
- [x] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/api-graphql-yoga` — exit 0
- [x] 5.5 `bunx turbo build --filter=@suaveplan/api-graphql-yoga` — green
## 6. Pre-archive 7-row checklist
- [x] 6.1 Generics on resolver / loader contracts; `expectTypeOf` assertions
- [x] 6.2 Zod schemas at every config boundary
- [x] 6.3 Every `GRAPHQL_YOGA_*` code thrown by a production path
- [x] 6.4 Strict-mode tested (masked-errors on vs off; introspection on vs off)
- [x] 6.5 OTel instruments asserted against in-memory `MeterProvider`
- [x] 6.6 Lifecycle dedup: schema rebuild on plugin reload is debounced AND idempotent
- [x] 6.7 No retroactive tick
## 7. Archive
- [x] 7.1 Fold spec delta into `openspec/specs/api-graphql-yoga/spec.md`
- [x] 7.2 Move to `openspec/archive/<yyyy-mm-dd>-api-graphql-yoga/`
- [x] 7.3 Tick Stage-7 row in umbrella tasks
- [x] 7.4 Refresh `MANIFEST.md`