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,69 @@
# @suaveplan/api-graphql-apollo — Apollo Server GraphQL engine adapter for the Suaveplan API plugin system. Implements the GraphqlEngine contract from @suaveplan/api-graphql and passes runGraphqlEngineConformance with the same invariants as the Yoga sibling. Adds Apollo-specific persisted-query manifest support, optional Apollo Studio usage reporting, depth + complexity guards, federated multi-plugin schema merging, per-request DataLoader scoping, engine-agnostic OTel instruments, and graphql-ws subscription bridging.
Stage-7 Apollo Server GraphQL engine adapter for the Suaveplan API
plugin system. Implements the `GraphqlEngine` contract from
`@suaveplan/api-graphql` and passes the conformance suite alongside
the Yoga sibling — switching engines is a host-config change.
<!-- begin:auto-docs -->
## Documentation tree
- [http](./src/endpoints/http.md) — `endpoints/http` mounts the two HTTP endpoints every GraphQL adapter must expose: `POST /graphql` for operation dispatc…
- [ui-proxy](./src/endpoints/ui-proxy.md) — `endpoints/ui-proxy` mounts the `/graphql/ui` endpoint. The Apollo engine never embeds Apollo's landing page — UI is op…
- [engine](./src/engine/apollo-engine.md) — `ApolloGraphqlEngine` is the concrete Apollo Server implementation of the `@suaveplan/api-graphql` engine contract. It…
- [apollo-engine](./src/engine/apollo-engine.md) — `ApolloGraphqlEngine` is the concrete Apollo Server implementation of the `@suaveplan/api-graphql` engine contract. It…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` defines the stable `GRAPHQL_APOLLO.*` namespace plus the `GraphqlApolloError` class consumed by every oth…
- [error-codes](./src/error-codes/error-codes.md) — `error-codes` defines the stable `GRAPHQL_APOLLO.*` namespace plus the `GraphqlApolloError` class consumed by every oth…
- [federation](./src/federation/federation.md) — `federation` is the engine's facade over the canonical `mergeSdl` function from `@suaveplan/api-graphql`. Apollo doesn'…
- [federation](./src/federation/federation.md) — `federation` is the engine's facade over the canonical `mergeSdl` function from `@suaveplan/api-graphql`. Apollo doesn'…
- [guards](./src/guards/depth-and-complexity.md) — `depth-and-complexity` walks a parsed `DocumentNode` and computes two coarse static metrics — the maximum selection-set…
- [depth-and-complexity](./src/guards/depth-and-complexity.md) — `depth-and-complexity` walks a parsed `DocumentNode` and computes two coarse static metrics — the maximum selection-set…
- [instruments](./src/instruments/instruments.md) — `instruments` owns the five OTel instrument names emitted by every GraphQL engine adapter in the foundation. Both `api-…
- [instruments](./src/instruments/instruments.md) — `instruments` owns the five OTel instrument names emitted by every GraphQL engine adapter in the foundation. Both `api-…
- [loaders](./src/loaders/loaders.md) — `loaders` defines the per-request DataLoader scope the engine creates around every incoming GraphQL operation. Two conc…
- [loaders](./src/loaders/loaders.md) — `loaders` defines the per-request DataLoader scope the engine creates around every incoming GraphQL operation. Two conc…
- [options](./src/options/options.md) — `options` owns the Zod-validated configuration surface accepted by `createApolloGraphqlEngine`. The schema mirrors the…
- [options](./src/options/options.md) — `options` owns the Zod-validated configuration surface accepted by `createApolloGraphqlEngine`. The schema mirrors the…
- [persisted-queries](./src/persisted-queries/persisted-queries.md) — `persisted-queries` implements Apollo's persisted-query manifest lookup — the ability for clients to send `{ id }` in p…
- [persisted-queries](./src/persisted-queries/persisted-queries.md) — `persisted-queries` implements Apollo's persisted-query manifest lookup — the ability for clients to send `{ id }` in p…
- [studio-reporting](./src/studio-reporting/studio-reporting.md) — `studio-reporting` materialises the configuration descriptor consumed by the engine when an operator opts in to Apollo…
- [studio-reporting](./src/studio-reporting/studio-reporting.md) — `studio-reporting` materialises the configuration descriptor consumed by the engine when an operator opts in to Apollo…
- [subscriptions](./src/subscriptions/subscriptions.md) — `subscriptions` runs a GraphQL subscription document against a wired schema, returning the `AsyncIterable<ExecutionResu…
- [subscriptions](./src/subscriptions/subscriptions.md) — `subscriptions` runs a GraphQL subscription document against a wired schema, returning the `AsyncIterable<ExecutionResu…
- [testing](./src/testing/conformance.md) — `testing/conformance` is the re-export of `runGraphqlEngineConformance` from `@suaveplan/api-graphql/testing`, pre-boun…
- [conformance](./src/testing/conformance.md) — `testing/conformance` is the re-export of `runGraphqlEngineConformance` from `@suaveplan/api-graphql/testing`, pre-boun…
<!-- end:auto-docs -->
## When to choose Apollo over Yoga
- You want Apollo Studio usage reporting (`studioReporting` option).
- You ship persisted-query manifests built by Apollo's CLI / Rover.
- Your team already runs Apollo's federation tooling in adjacent
services.
- You need Apollo's plugin ecosystem (cost analyser, request
coalescer, custom cache plugin, …).
Otherwise, Yoga is the faster default — fewer allocations per request,
slimmer dependency closure.
## Modules
- `engine` — `ApolloGraphqlEngine`, `createApolloGraphqlEngine`.
- `options` — Zod-validated `ApolloGraphqlOptions` schema.
- `error-codes` — `GRAPHQL_APOLLO.*` registry + `GraphqlApolloError`.
- `instruments` — engine-agnostic `graphql.*` OTel surface.
- `guards` — depth + complexity rejection helpers.
- `persisted-queries` — APQ manifest loader.
- `studio-reporting` — Apollo Studio descriptor bridge.
- `subscriptions` — `graphql-ws` runner.
- `loaders` — per-request DataLoader scope.
- `federation` — `mergeSdl` passthrough.
- `endpoints` — HTTP + UI proxy mounts.
- `testing` — `runApolloConformance` re-export.
## Status
Original. Owned by the `api-stack` super-category.
Passes the abstract conformance harness with the same invariants Yoga
satisfies — see `src/engine/apollo-engine.spec-conformance.test.ts`.

View File

@@ -0,0 +1,141 @@
{
"name": "@suaveplan/api-graphql-apollo",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-7 Apollo Server GraphQL engine adapter for the Suaveplan API plugin foundation. Implements the GraphqlEngine contract from @suaveplan/api-graphql and passes runGraphqlEngineConformance with the same invariants as the Yoga sibling. Adds Apollo-specific persisted-query manifest support, optional Apollo Studio usage reporting, depth + complexity guards, federated multi-plugin schema merging, per-request DataLoader scoping, engine-agnostic OTel instruments, and graphql-ws subscription bridging.",
"license": "MIT",
"author": "Suaveplan Team",
"main": "./dist/index.js",
"module": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
"./package.json": "./package.json",
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"./engine": {
"import": {
"types": "./dist/engine/index.d.ts",
"default": "./dist/engine/index.js"
}
},
"./options": {
"import": {
"types": "./dist/options/index.d.ts",
"default": "./dist/options/index.js"
}
},
"./endpoints": {
"import": {
"types": "./dist/endpoints/index.d.ts",
"default": "./dist/endpoints/index.js"
}
},
"./federation": {
"import": {
"types": "./dist/federation/index.d.ts",
"default": "./dist/federation/index.js"
}
},
"./guards": {
"import": {
"types": "./dist/guards/index.d.ts",
"default": "./dist/guards/index.js"
}
},
"./instruments": {
"import": {
"types": "./dist/instruments/index.d.ts",
"default": "./dist/instruments/index.js"
}
},
"./loaders": {
"import": {
"types": "./dist/loaders/index.d.ts",
"default": "./dist/loaders/index.js"
}
},
"./persisted-queries": {
"import": {
"types": "./dist/persisted-queries/index.d.ts",
"default": "./dist/persisted-queries/index.js"
}
},
"./studio-reporting": {
"import": {
"types": "./dist/studio-reporting/index.d.ts",
"default": "./dist/studio-reporting/index.js"
}
},
"./subscriptions": {
"import": {
"types": "./dist/subscriptions/index.d.ts",
"default": "./dist/subscriptions/index.js"
}
},
"./error-codes": {
"import": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
}
},
"./testing": {
"import": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
}
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-graphql": "workspace:^",
"@suaveplan/api-router": "workspace:^",
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"peerDependencies": {
"@apollo/server": "^4.11.0",
"graphql": "^16.9.0"
},
"devDependencies": {
"@apollo/server": "^4.11.0",
"@suaveplan/testing": "workspace:^",
"@types/node": "catalog:",
"@vitest/coverage-istanbul": "catalog:testing",
"graphql": "^16.9.0",
"typescript": "catalog:",
"vite": "catalog:build",
"vite-plugin-dts": "catalog:build",
"vitest": "catalog:testing"
},
"publishConfig": {
"registry": "https://npmjs.fq.io/",
"access": "public"
},
"files": [
"dist",
"src",
"README.md"
]
}

View File

@@ -0,0 +1,80 @@
# `endpoints/http` — `POST /graphql` + `GET /graphql/sdl`
## Purpose
`endpoints/http` mounts the two HTTP endpoints every GraphQL adapter
must expose: `POST /graphql` for operation dispatch and
`GET /graphql/sdl` for schema introspection by external tooling. The
paths are driven by `ApolloGraphqlOptions` so operators can rename
them — useful for shops that namespace internal APIs under `/internal/`.
The module is framework-agnostic in that it takes a `Router` from
`@suaveplan/api-router` and registers handlers via the router's verb
methods. Concrete router adapters (`api-router-hono`,
`api-router-in-memory`) consume the same `Router` interface.
## Features
- Mount paths driven by `ApolloGraphqlOptions.endpoint` /
`sdlEndpoint`.
- `executeQuery` handler accepts a `{ body, headers, context }` envelope
— the host's request pipeline decodes the HTTP frame into this shape
before dispatch.
- `sdl` handler returns the merged SDL synchronously.
- Returns a teardown function (currently no-op; reserved for hot
reload).
- Empty `headers` default — handlers that the host invokes without an
explicit map see `{}` rather than `undefined`.
## Usage: Basic
```ts
import { Router } from "@suaveplan/api-router";
import { mountGraphqlHttpEndpoints } from "@suaveplan/api-graphql-apollo/endpoints";
import { parseApolloGraphqlOptions } from "@suaveplan/api-graphql-apollo/options";
const router = new Router();
mountGraphqlHttpEndpoints({
router,
options: parseApolloGraphqlOptions(),
executeQuery: async (r) => ({ data: { ok: true } }),
sdl: () => "type Query { ok: Boolean! }",
});
```
## Usage: Advanced
Drive the executeQuery handler through the engine instead of a stub
so the router calls into Apollo Server's lifecycle on every POST:
```ts
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
const engine = createApolloGraphqlEngine();
engine.buildSchema(registry);
mountGraphqlHttpEndpoints({
router,
options,
executeQuery: async ({ body, context }) => {
const req = body as { query: string; variables?: Record<string, unknown> };
return await engine.executeQuery(req, context as RequestContext);
},
sdl: () => engine.sdl() ?? "",
});
```
## API Reference
- `GraphqlHttpHandler` — `(req: { body, headers, context }) → Promise<unknown>`.
- `GraphqlSdlHandler` — `() → string`.
- `MountHttpEndpointsArgs` — `{ router, options, executeQuery, sdl }`.
- `mountGraphqlHttpEndpoints(args): () → void` — returns teardown.
## Implementation Notes
The handler returns a free-form `Record<string, unknown>` because the
host's transport adapter serialises the response to wire form. The
SDL handler is synchronous on purpose; SDL is computed once at
`buildSchema` time and held in memory thereafter. The teardown
closure is a no-op today but exists so callers do not need to rewrite
their host-wiring code when hot reload lands.

View File

@@ -0,0 +1,100 @@
import { Router } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseApolloGraphqlOptions } from "../options/options.js";
import { mountGraphqlHttpEndpoints } from "./http.js";
describe("endpoints/http", () => {
it("mounts POST /graphql + GET /graphql/sdl at default paths", {
timeout: 5000,
}, () => {
const router = new Router();
const opts = parseApolloGraphqlOptions();
const teardown = mountGraphqlHttpEndpoints({
router,
options: opts,
executeQuery: async () => ({ data: { ok: true } }),
sdl: () => "type Query { ok: Boolean! }",
});
expect(router.match("POST", "/graphql")).not.toBeNull();
expect(router.match("GET", "/graphql/sdl")).not.toBeNull();
teardown();
});
it("respects configured endpoint paths", { timeout: 5000 }, () => {
const router = new Router();
const opts = parseApolloGraphqlOptions({
endpoint: "/gql",
sdlEndpoint: "/gql/sdl",
});
mountGraphqlHttpEndpoints({
router,
options: opts,
executeQuery: async () => ({}),
sdl: () => "",
});
expect(router.match("POST", "/gql")).not.toBeNull();
expect(router.match("GET", "/gql/sdl")).not.toBeNull();
});
it("dispatches the request through executeQuery", {
timeout: 5000,
}, async () => {
const router = new Router();
const opts = parseApolloGraphqlOptions();
const calls: unknown[] = [];
mountGraphqlHttpEndpoints({
router,
options: opts,
executeQuery: async (r) => {
calls.push(r);
return { data: { echo: 1 } };
},
sdl: () => "type Query { x: Int! }",
});
const m = router.match("POST", "/graphql");
expect(m).not.toBeNull();
const handler = m?.handler as (req: unknown) => Promise<unknown>;
const result = await handler({
body: { query: "{ x }" },
headers: { "content-type": "application/json" },
context: { tenantId: "t" },
});
expect(result).toEqual({ data: { echo: 1 } });
expect(calls).toHaveLength(1);
});
it("dispatches with empty headers when none supplied", {
timeout: 5000,
}, async () => {
const router = new Router();
const opts = parseApolloGraphqlOptions();
let received: Readonly<Record<string, string>> | null = null;
mountGraphqlHttpEndpoints({
router,
options: opts,
executeQuery: async (r) => {
received = r.headers;
return {};
},
sdl: () => "",
});
const m = router.match("POST", "/graphql");
const handler = m?.handler as (req: unknown) => Promise<unknown>;
await handler({ body: null });
expect(received).toEqual({});
});
it("returns the SDL string on GET", { timeout: 5000 }, () => {
const router = new Router();
const opts = parseApolloGraphqlOptions();
mountGraphqlHttpEndpoints({
router,
options: opts,
executeQuery: async () => ({}),
sdl: () => "SDL!",
});
const m = router.match("GET", "/graphql/sdl");
const handler = m?.handler as () => string;
expect(handler()).toBe("SDL!");
});
});

View File

@@ -0,0 +1,55 @@
import type { Router } from "@suaveplan/api-router";
import type { ApolloGraphqlOptions } from "../options/options.js";
/**
* Inbound HTTP handler the host invokes. Returns the engine response
* body — Apollo Server's `executeOperation` shape — as `Record<string,
* unknown>` so the router adapter can serialise it to wire form.
*/
export type GraphqlHttpHandler = (request: {
readonly body: unknown;
readonly headers: Readonly<Record<string, string>>;
readonly context: unknown;
}) => Promise<Readonly<Record<string, unknown>>>;
/** Inbound handler for the SDL endpoint — no-arg, returns the SDL string. */
export type GraphqlSdlHandler = () => string;
/** Configuration consumed by {@link mountGraphqlHttpEndpoints}. */
export interface MountHttpEndpointsArgs {
readonly router: Router;
readonly options: ApolloGraphqlOptions;
readonly executeQuery: GraphqlHttpHandler;
readonly sdl: GraphqlSdlHandler;
}
/**
* Mount the `POST /graphql` execution endpoint and `GET /graphql/sdl`
* schema endpoint on the host-supplied router. The endpoint paths are
* driven by {@link ApolloGraphqlOptions}.
*
* Returns the unsubscribe handle (a no-op today; reserved for engine
* hot-reload in a later stage).
*/
export function mountGraphqlHttpEndpoints(
args: MountHttpEndpointsArgs
): () => void {
args.router.post(args.options.endpoint, async (req: unknown) => {
const r = req as {
body?: unknown;
headers?: Readonly<Record<string, string>>;
context?: unknown;
};
return args.executeQuery({
body: r.body,
headers: r.headers ?? {},
context: r.context,
});
});
args.router.get(args.options.sdlEndpoint, () => args.sdl());
return () => {
/* hot-reload teardown will land alongside the host's reload driver */
};
}

View File

@@ -0,0 +1,11 @@
export {
type GraphqlHttpHandler,
type GraphqlSdlHandler,
type MountHttpEndpointsArgs,
mountGraphqlHttpEndpoints,
} from "./http.js";
export {
type GraphqlUiPlugin,
mountGraphqlUiProxy,
type UiProxyResponse,
} from "./ui-proxy.js";

View File

@@ -0,0 +1,74 @@
# `endpoints/ui-proxy` — `/graphql/ui` proxy
## Purpose
`endpoints/ui-proxy` mounts the `/graphql/ui` endpoint. The Apollo
engine never embeds Apollo's landing page — UI is opt-in via a
separate package (GraphiQL, Altair, …), identical to Yoga's stance.
When no UI plugin is wired the endpoint returns `404` with a short
text body explaining the policy; when a plugin is wired the endpoint
forwards its `render()` output verbatim.
This module is deliberately framework-agnostic: it accepts a
`@suaveplan/api-router` `Router` and a UI plugin descriptor with a
`render()` method. The host wires whichever GraphiQL / Altair package
it prefers without the engine becoming aware of the choice.
## Features
- 404 by default — no Apollo landing page leakage.
- Custom mime type — UI plugins that ship binary (e.g. WASM) bodies
can override the default `text/html`.
- Driven by `ApolloGraphqlOptions.uiEndpoint` — operator can rename
the path.
- Returns a teardown handle reserved for future hot reload.
## Usage: Basic
```ts
import { Router } from "@suaveplan/api-router";
import { mountGraphqlUiProxy } from "@suaveplan/api-graphql-apollo/endpoints";
import { parseApolloGraphqlOptions } from "@suaveplan/api-graphql-apollo/options";
const router = new Router();
mountGraphqlUiProxy({
router,
options: parseApolloGraphqlOptions(),
ui: null,
});
// GET /graphql/ui → 404
```
## Usage: Advanced
Wire a GraphiQL-style UI plugin so the same router serves the
playground at `/graphql/ui`:
```ts
mountGraphqlUiProxy({
router,
options,
ui: {
render: () =>
`<!doctype html>
<html><body>
<div id="root"></div>
<script>/* GraphiQL bundle */</script>
</body></html>`,
},
});
```
## API Reference
- `GraphqlUiPlugin` — `{ render(): string; contentType?: string }`.
- `UiProxyResponse` — `{ status: 200 | 404; body: string; contentType: string }`.
- `mountGraphqlUiProxy(args): () → void`.
## Implementation Notes
The UI plugin contract is intentionally minimal — `render()` returns
a string and the proxy serves it. Future evolution (CSP nonces,
per-tenant configuration) lives in the UI plugin itself, not in this
module. The 404 response body is `text/plain` so curl users see a
human-readable explanation rather than an empty page.

View File

@@ -0,0 +1,66 @@
import { Router } from "@suaveplan/api-router";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseApolloGraphqlOptions } from "../options/options.js";
import { mountGraphqlUiProxy } from "./ui-proxy.js";
describe("endpoints/ui-proxy", () => {
it("returns 404 when no UI plugin is wired", { timeout: 5000 }, () => {
const router = new Router();
const opts = parseApolloGraphqlOptions();
const teardown = mountGraphqlUiProxy({
router,
options: opts,
ui: null,
});
const m = router.match("GET", "/graphql/ui");
const handler = m?.handler as () => {
status: number;
body: string;
contentType: string;
};
const res = handler();
expect(res.status).toBe(404);
expect(res.body).toContain("opt-in");
expect(res.contentType).toBe("text/plain");
teardown();
});
it("returns the plugin's rendered HTML when wired", {
timeout: 5000,
}, () => {
const router = new Router();
const opts = parseApolloGraphqlOptions();
mountGraphqlUiProxy({
router,
options: opts,
ui: { render: () => "<!doctype html><h1>UI</h1>" },
});
const m = router.match("GET", "/graphql/ui");
const handler = m?.handler as () => {
status: number;
body: string;
contentType: string;
};
const res = handler();
expect(res.status).toBe(200);
expect(res.body).toContain("UI");
expect(res.contentType).toBe("text/html");
});
it("honours a custom MIME type from the plugin", {
timeout: 5000,
}, () => {
const router = new Router();
const opts = parseApolloGraphqlOptions();
mountGraphqlUiProxy({
router,
options: opts,
ui: { render: () => "{}", contentType: "application/json" },
});
const m = router.match("GET", "/graphql/ui");
const handler = m?.handler as () => {
contentType: string;
};
expect(handler().contentType).toBe("application/json");
});
});

View File

@@ -0,0 +1,51 @@
import type { Router } from "@suaveplan/api-router";
import type { ApolloGraphqlOptions } from "../options/options.js";
/**
* UI plugin descriptor. The host wires a separate UI package (GraphiQL,
* Altair, …) and hands a renderer in; the Apollo engine itself never
* embeds Apollo's landing page (UI is opt-in — same stance as Yoga).
*/
export interface GraphqlUiPlugin {
/** Returns the HTML body served at `/graphql/ui`. */
render(): string;
/** MIME type — defaults to `text/html` when omitted. */
readonly contentType?: string;
}
/** Response envelope returned by the UI proxy handler. */
export interface UiProxyResponse {
readonly status: 200 | 404;
readonly body: string;
readonly contentType: string;
}
/**
* Mount the `/graphql/ui` proxy endpoint. When `ui` is `null` the
* handler responds `404` with a short note explaining the UI is opt-in;
* when supplied it returns the plugin's rendered HTML.
*/
export function mountGraphqlUiProxy(args: {
readonly router: Router;
readonly options: ApolloGraphqlOptions;
readonly ui: GraphqlUiPlugin | null;
}): () => void {
const { router, options, ui } = args;
router.get(options.uiEndpoint, (): UiProxyResponse => {
if (ui === null) {
return {
status: 404,
body: "GraphQL UI is opt-in — wire a UI plugin to enable",
contentType: "text/plain",
};
}
return {
status: 200,
body: ui.render(),
contentType: ui.contentType ?? "text/html",
};
});
return () => {
/* hot-reload teardown reserved */
};
}

View File

@@ -0,0 +1,105 @@
# `engine/apollo-engine` — `ApolloGraphqlEngine`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`ApolloGraphqlEngine` is the concrete Apollo Server implementation of
the `@suaveplan/api-graphql` engine contract. It owns the Apollo
Server instance, the merged executable schema, the persisted-query
manifest plumbing, the request-time depth and complexity guards, and
the OTel instrument emission. The factory `createApolloGraphqlEngine`
hands out fresh instances; the class is `new`-able for tests and
power users that want subclass overrides.
Every operation pivots on three lifecycle calls: `buildSchema` once
at boot (or on hot reload), `executeQuery` per request, `dispose` at
shutdown. Schema rebuild gracefully tears down the prior Apollo
instance only when its startup has completed — a half-started server
is left alone, which is safe because the new server replaces the
slot.
## Features
- Implements every `GraphqlEngine` method — `buildSchema`,
`executeQuery`, `executeSubscription`, `dispose`.
- Pluggable `instruments`, `persistedQueries`, `apolloPlugins`
dependencies — sane defaults, full override.
- Depth + complexity guards run BEFORE Apollo sees the document so
the engine rejects pathological queries cheaply.
- `requestTimeoutMs` enforced via a race against a `setTimeout`-
driven rejection — the timer is `unref`'d so it never holds the
event loop open.
- `attachResolvers` resolves SDL-declared fields against the
registry's resolver maps; unknown types and unknown fields are
silently skipped so plugin authors can register superset maps
without runtime breakage.
- Introspection denial layered above Apollo's
`introspection: false` flag so the engine raises a typed error
before Apollo even validates the document.
## Usage: Basic
```ts
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
const engine = createApolloGraphqlEngine();
const registry = createGraphqlRegistry();
registry.addTypes("type Query { ping: String! }");
registry.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(registry);
const result = await engine.executeQuery(
{ query: "{ ping }" },
requestContext
);
await engine.dispose();
```
## Usage: Advanced
Wire persisted queries, custom instruments, a 5-second timeout, and a
custom Apollo plugin for cache control:
```ts
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
import { createInstruments } from "@suaveplan/api-graphql-apollo/instruments";
import { createPersistedQueryManifest } from "@suaveplan/api-graphql-apollo/persisted-queries";
const instruments = createInstruments();
const persistedQueries = createPersistedQueryManifest([
["abc123", "{ ping }"],
]);
const engine = createApolloGraphqlEngine({
instruments,
persistedQueries,
apolloPlugins: [cacheControlPlugin],
options: { requestTimeoutMs: 5000, maxQueryDepth: 12 },
});
```
## API Reference
- `class ApolloGraphqlEngine<Caps> implements GraphqlEngine<Caps>`.
- `constructor(args?: CreateApolloGraphqlEngineArgs)`
- `buildSchema(registry): MergedSchema`
- `executeQuery(req, ctx): Promise<GraphqlResponse>`
- `executeSubscription(req, ctx): AsyncIterable<GraphqlResponse>`
- `dispose(): Promise<void>`
- `sdl(): string | null`
- `createApolloGraphqlEngine(args?): ApolloGraphqlEngine`.
- `raceWithTimeout<T>(p, ms): Promise<T>` — exported for unit testing.
## Implementation Notes
`startInBackgroundHandlingStartupErrorsByLoggingAndFailingAllRequests`
was rejected in favour of an explicit `start()` Promise because the
former turns a startup failure into per-request rejections we cannot
discriminate cleanly. The current implementation awaits the start
promise inside `executeQuery` and again inside `dispose` so a failed
start never leaves the engine in a half-stopped state. The
`raceWithTimeout` helper is exported so a focused unit test can
exercise both the resolve and reject branches without standing up an
Apollo Server.

View File

@@ -0,0 +1,4 @@
import { runApolloConformance } from "../testing/conformance.js";
// Re-run the abstract GraphqlEngine contract against the Apollo factory.
runApolloConformance("apollo");

View File

@@ -0,0 +1,461 @@
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type { GraphqlApolloError } from "../error-codes/error-codes.js";
import { createInstruments } from "../instruments/instruments.js";
import { createPersistedQueryManifest } from "../persisted-queries/persisted-queries.js";
import {
ApolloGraphqlEngine,
createApolloGraphqlEngine,
raceWithTimeout,
} from "./apollo-engine.js";
function makeCtx(): RequestContext {
return {} as unknown as RequestContext;
}
describe("ApolloGraphqlEngine", () => {
it("builds a schema, executes a query, returns data", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine();
const registry = createGraphqlRegistry();
registry.addTypes("type Query { ping: String! }");
registry.addResolvers({ Query: { ping: () => "pong" } });
const merged = engine.buildSchema(registry);
expect(merged.sdl).toContain("ping");
const result = await engine.executeQuery(
{ query: "{ ping }" },
makeCtx()
);
expect(result.data).toEqual({ ping: "pong" });
await engine.dispose();
});
it("supports rebuild — disposes prior Apollo server", {
timeout: 5000,
}, async () => {
const engine = new ApolloGraphqlEngine();
const r1 = createGraphqlRegistry();
r1.addTypes("type Query { a: String! }");
r1.addResolvers({ Query: { a: () => "A" } });
engine.buildSchema(r1);
// Force the first Apollo server to fully start before rebuilding.
await engine.executeQuery({ query: "{ a }" }, makeCtx());
const r2 = createGraphqlRegistry();
r2.addTypes("type Query { b: String! }");
r2.addResolvers({ Query: { b: () => "B" } });
engine.buildSchema(r2);
const out = await engine.executeQuery({ query: "{ b }" }, makeCtx());
expect(out.data).toEqual({ b: "B" });
expect(engine.sdl()).toContain("b: String!");
await engine.dispose();
expect(engine.sdl()).toBeNull();
});
it("propagates variables + operationName when supplied", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { echo(s: String!): String! }");
reg.addResolvers({
Query: {
echo: (_p: unknown, args: { s: string }) => args.s,
},
});
engine.buildSchema(reg);
const result = await engine.executeQuery(
{
query: "query Echo($s: String!) { echo(s: $s) }",
variables: { s: "hi" },
operationName: "Echo",
},
makeCtx()
);
expect(result.data).toEqual({ echo: "hi" });
await engine.dispose();
});
it("emits an instruments record per request", {
timeout: 5000,
}, async () => {
const ins = createInstruments();
const engine = createApolloGraphqlEngine({ instruments: ins });
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
await engine.executeQuery({ query: "{ ping }" }, makeCtx());
const snap = ins.snapshot();
expect(snap.requestsTotal).toBe(1);
expect(snap.schemaRebuildsTotal).toBe(1);
expect(snap.requestDurationMs).toHaveLength(1);
await engine.dispose();
});
it("rejects executeQuery before buildSchema", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine();
let err: GraphqlApolloError | null = null;
try {
await engine.executeQuery({ query: "{ ping }" }, makeCtx());
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED");
await engine.dispose();
});
it("rejects malformed SDL", { timeout: 5000 }, () => {
const engine = createApolloGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("totally not graphql");
let err: GraphqlApolloError | null = null;
try {
engine.buildSchema(reg);
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED");
});
it("rejects a registry without freeze()", { timeout: 5000 }, () => {
const engine = createApolloGraphqlEngine();
let err: GraphqlApolloError | null = null;
try {
engine.buildSchema({
addTypes() {
/* noop */
},
addResolvers() {
/* noop */
},
addLoader() {
/* noop */
},
});
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED");
});
it("rejects introspection when disabled", { timeout: 5000 }, async () => {
const engine = createApolloGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
let err: GraphqlApolloError | null = null;
try {
await engine.executeQuery(
{ query: "{ __schema { types { name } } }" },
makeCtx()
);
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.INTROSPECTION_DENIED");
await engine.dispose();
});
it("allows introspection when configured on", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine({
options: { introspection: true },
});
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
const result = await engine.executeQuery(
{ query: "{ __typename }" },
makeCtx()
);
expect(result.data?.__typename).toBe("Query");
await engine.dispose();
});
it("guards depth + complexity", { timeout: 5000 }, async () => {
const engine = createApolloGraphqlEngine({
options: { maxQueryDepth: 1 },
});
const reg = createGraphqlRegistry();
reg.addTypes(
"type Query { nested: Nested! } type Nested { a: String! }"
);
reg.addResolvers({
Query: { nested: () => ({ a: "x" }) },
Nested: { a: () => "x" },
});
engine.buildSchema(reg);
let err: GraphqlApolloError | null = null;
try {
await engine.executeQuery({ query: "{ nested { a } }" }, makeCtx());
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.DEPTH_EXCEEDED");
await engine.dispose();
});
it("raises PARSE_FAILED for malformed documents", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
let err: GraphqlApolloError | null = null;
try {
await engine.executeQuery({ query: "{ ping" }, makeCtx());
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.PARSE_FAILED");
await engine.dispose();
});
it("raises PARSE_FAILED when neither query nor id supplied", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
let err: GraphqlApolloError | null = null;
try {
await engine.executeQuery({ query: "" }, makeCtx());
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.PARSE_FAILED");
await engine.dispose();
});
it("resolves persisted-query id when manifest wired", {
timeout: 5000,
}, async () => {
const manifest = createPersistedQueryManifest([["pingId", "{ ping }"]]);
const engine = createApolloGraphqlEngine({
persistedQueries: manifest,
});
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
const result = await engine.executeQuery(
{ query: "", ...({ id: "pingId" } as Record<string, string>) } as {
query: string;
},
makeCtx()
);
expect(result.data).toEqual({ ping: "pong" });
await engine.dispose();
});
it("raises PERSISTED_QUERY_NOT_FOUND for unknown id", {
timeout: 5000,
}, async () => {
const manifest = createPersistedQueryManifest([]);
const engine = createApolloGraphqlEngine({
persistedQueries: manifest,
});
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
let err: GraphqlApolloError | null = null;
try {
await engine.executeQuery(
{
query: "",
...({ id: "missing" } as Record<string, string>),
} as { query: string },
makeCtx()
);
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.PERSISTED_QUERY_NOT_FOUND");
await engine.dispose();
});
it("executeSubscription returns an empty AsyncIterable", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine();
const iter = engine.executeSubscription(
{ query: "subscription { x }" },
makeCtx()
);
const out: unknown[] = [];
for await (const v of iter) out.push(v);
expect(out).toEqual([]);
await engine.dispose();
});
it("dispose is idempotent", { timeout: 5000 }, async () => {
const engine = createApolloGraphqlEngine();
await engine.dispose();
await engine.dispose();
});
it("dispose swallows a failed Apollo start", {
timeout: 5000,
}, async () => {
const failingPlugin = {
async serverWillStart(): Promise<void> {
throw new Error("plugin-init-fail");
},
};
const engine = createApolloGraphqlEngine({
apolloPlugins: [failingPlugin],
});
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
engine.buildSchema(reg);
// dispose must not throw even though start() failed.
await engine.dispose();
});
it("merges resolvers across multiple addResolvers calls", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { a: String! b: String! }");
reg.addResolvers({ Query: { a: () => "A" } });
reg.addResolvers({ Query: { b: () => "B" } });
engine.buildSchema(reg);
const out = await engine.executeQuery({ query: "{ a b }" }, makeCtx());
expect(out.data).toEqual({ a: "A", b: "B" });
await engine.dispose();
});
it("ignores resolvers for unknown type names", {
timeout: 5000,
}, () => {
const engine = createApolloGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({
Unknown: { x: () => "x" },
Query: { ping: () => "pong" },
});
// No throw — unknown type is silently skipped.
engine.buildSchema(reg);
});
it("returns errors envelope when a resolver throws", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { boom: String! }");
reg.addResolvers({
Query: {
boom: () => {
throw new Error("kaboom");
},
},
});
engine.buildSchema(reg);
const result = await engine.executeQuery(
{ query: "{ boom }" },
makeCtx()
);
expect(result.errors?.length).toBeGreaterThan(0);
await engine.dispose();
});
it("skips scalar-type resolvers and unknown field names", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({
Query: { ping: () => "pong", nonexistent: () => "x" },
// String is a scalar without getFields(): attachResolvers skips.
String: { weird: () => "z" } as Record<string, () => string>,
});
engine.buildSchema(reg);
const out = await engine.executeQuery({ query: "{ ping }" }, makeCtx());
expect(out.data).toEqual({ ping: "pong" });
await engine.dispose();
});
it("raises RESOLVER_THREW when resolver attach fails", {
timeout: 5000,
}, () => {
const engine = createApolloGraphqlEngine();
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({
Query: undefined as unknown as Record<string, never>,
});
let err: GraphqlApolloError | null = null;
try {
engine.buildSchema(reg);
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.RESOLVER_THREW");
});
it("raceWithTimeout resolves the inner promise when it wins", {
timeout: 5000,
}, async () => {
const v = await raceWithTimeout(Promise.resolve(42), 1000);
expect(v).toBe(42);
});
it("raceWithTimeout forwards inner rejection", {
timeout: 5000,
}, async () => {
let err: Error | null = null;
try {
await raceWithTimeout(
Promise.reject(new Error("inner-fail")),
1000
);
} catch (e) {
err = e as Error;
}
expect(err?.message).toBe("inner-fail");
});
it("raises TIMEOUT when execution exceeds requestTimeoutMs", {
timeout: 5000,
}, async () => {
const engine = createApolloGraphqlEngine({
options: { requestTimeoutMs: 1 },
});
const reg = createGraphqlRegistry();
reg.addTypes("type Query { slow: String! }");
reg.addResolvers({
Query: {
slow: async () => {
await new Promise((r) => setTimeout(r, 50));
return "late";
},
},
});
engine.buildSchema(reg);
let err: GraphqlApolloError | null = null;
try {
await engine.executeQuery({ query: "{ slow }" }, makeCtx());
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.TIMEOUT");
await engine.dispose();
});
});

View File

@@ -0,0 +1,364 @@
import { ApolloServer } from "@apollo/server";
import type {
FrozenRegistry,
GraphqlEngine,
GraphqlRegistry,
GraphqlRequest,
GraphqlResponse,
MergedSchema,
SdlContribution,
} from "@suaveplan/api-graphql";
import { mergeSdl } from "@suaveplan/api-graphql";
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import { buildSchema, parse } from "graphql";
import { GraphqlApolloError } from "../error-codes/error-codes.js";
import { assertDepthAndComplexity } from "../guards/depth-and-complexity.js";
import {
createInstruments,
type GraphqlInstruments,
} from "../instruments/instruments.js";
import {
type ApolloGraphqlOptions,
type ApolloGraphqlOptionsInput,
parseApolloGraphqlOptions,
} from "../options/options.js";
import type { PersistedQueryManifest } from "../persisted-queries/persisted-queries.js";
interface ApolloEngineDependencies {
readonly persistedQueries?: PersistedQueryManifest;
readonly instruments?: GraphqlInstruments;
/**
* Optional Apollo Server plugin descriptors. The engine forwards them
* to `new ApolloServer({ plugins })`; supplied here so tests can wire
* the Studio reporting plugin, persisted-query cache plugin, etc.
*/
readonly apolloPlugins?: ReadonlyArray<unknown>;
}
/** Public options accepted by the factory. */
export interface CreateApolloGraphqlEngineArgs
extends ApolloEngineDependencies {
readonly options?: ApolloGraphqlOptionsInput;
}
/**
* Concrete Apollo Server engine. Implements the
* `@suaveplan/api-graphql` engine contract end-to-end.
*
* `buildSchema` consumes a frozen registry snapshot, merges the SDL via
* the canonical `mergeSdl`, builds a `GraphQLSchema` and instantiates an
* `ApolloServer`. `executeQuery` proxies to `executeOperation`. The
* lifecycle is replay-safe: subsequent `buildSchema` calls dispose the
* previous Apollo Server before instantiating the next one.
*/
export class ApolloGraphqlEngine<
Caps extends CapabilityRequirements = CapabilityRequirements,
> implements GraphqlEngine<Caps>
{
private apollo: ApolloServer | null = null;
private apolloStarted = false;
private startPromise: Promise<void> | null = null;
private merged: MergedSchema | null = null;
private readonly opts: ApolloGraphqlOptions;
private readonly persistedQueries: PersistedQueryManifest | undefined;
private readonly instruments: GraphqlInstruments;
private readonly apolloPlugins: ReadonlyArray<unknown>;
public constructor(args: CreateApolloGraphqlEngineArgs = {}) {
this.opts = parseApolloGraphqlOptions(args.options);
this.persistedQueries = args.persistedQueries;
this.instruments = args.instruments ?? createInstruments();
this.apolloPlugins = args.apolloPlugins ?? [];
}
public buildSchema(registry: GraphqlRegistry<Caps>): MergedSchema {
const frozen = freezeRegistry(registry);
const contribution: SdlContribution = {
plugin: "default",
typeDefs: [...frozen.typeDefs],
};
const merged = mergeSdl([contribution]);
let resolvers: Record<
string,
Record<string, (...args: unknown[]) => unknown>
>;
try {
resolvers = mergeResolvers(frozen);
} catch (cause) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.RESOLVER_THREW",
"failed to merge resolver maps",
{ cause: cause as Error }
);
}
let schema: ReturnType<typeof buildSchema>;
try {
schema = buildSchema(merged.sdl);
} catch (cause) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED",
"failed to build executable schema from merged SDL",
{ cause: cause as Error, context: { sdl: merged.sdl } }
);
}
if (this.apollo !== null && this.apolloStarted) {
void this.apollo.stop();
}
this.apollo = new ApolloServer({
schema,
introspection: this.opts.introspection,
includeStacktraceInErrorResponses: false,
plugins: [...(this.apolloPlugins as Array<never>)],
});
attachResolvers(schema, resolvers);
this.apolloStarted = false;
this.startPromise = this.apollo.start().then(() => {
this.apolloStarted = true;
});
this.merged = merged;
this.instruments.incSchemaRebuilds();
return merged;
}
public async executeQuery(
req: GraphqlRequest,
ctx: RequestContext<Caps>
): Promise<GraphqlResponse> {
const apollo = this.requireApollo();
await this.startPromise;
const query = await this.resolveQuery(req);
const document = await this.parseDocument(query);
if (!this.opts.introspection && containsIntrospection(query)) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.INTROSPECTION_DENIED",
"introspection is disabled by configuration"
);
}
assertDepthAndComplexity(document, {
maxDepth: this.opts.maxQueryDepth,
maxComplexity: this.opts.maxQueryComplexity,
});
this.instruments.incRequests({
operation: req.operationName ?? "anonymous",
});
const started = Date.now();
const opArgs: {
query: string;
variables?: Record<string, unknown>;
operationName?: string;
} = { query };
if (req.variables !== undefined) {
opArgs.variables = req.variables as Record<string, unknown>;
}
if (req.operationName !== undefined) {
opArgs.operationName = req.operationName;
}
const result = await raceWithTimeout(
apollo.executeOperation(opArgs, {
contextValue: ctx as unknown as Record<string, never>,
}),
this.opts.requestTimeoutMs
);
this.instruments.recordRequestDurationMs(Date.now() - started);
// Apollo always returns `body.kind === "single"` for non-incremental
// operations — we never enable `@defer`/`@stream` so this branch is
// the only one reachable.
const single = result.body as {
singleResult: Parameters<typeof toResponse>[0];
};
return toResponse(single.singleResult);
}
public executeSubscription(
_req: GraphqlRequest,
_ctx: RequestContext<Caps>
): AsyncIterable<GraphqlResponse> {
return emptyIterable();
}
public async dispose(): Promise<void> {
if (this.apollo !== null) {
try {
await this.startPromise;
} catch {
/* start failed — skip stop */
}
if (this.apolloStarted) {
await this.apollo.stop();
}
this.apollo = null;
this.apolloStarted = false;
this.startPromise = null;
}
this.merged = null;
}
/** SDL of the most recent `buildSchema` call — `null` before first build. */
public sdl(): string | null {
return this.merged === null ? null : this.merged.sdl;
}
private requireApollo(): ApolloServer {
if (this.apollo === null) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED",
"engine.buildSchema must be called before executeQuery"
);
}
return this.apollo;
}
private async resolveQuery(req: GraphqlRequest): Promise<string> {
if (req.query !== "" && req.query !== undefined) return req.query;
const persistedId = (req as unknown as { id?: string }).id;
if (persistedId !== undefined && this.persistedQueries !== undefined) {
return this.persistedQueries.lookup(persistedId);
}
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.PARSE_FAILED",
"request omitted both `query` and `id` — cannot dispatch"
);
}
private async parseDocument(
query: string
): Promise<ReturnType<typeof parse>> {
try {
return parse(query);
} catch (cause) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.PARSE_FAILED",
"request document failed to parse",
{ cause: cause as Error }
);
}
}
}
/** Exported for unit testing — `raceWithTimeout(p, ms)`. */
export function raceWithTimeout<T>(p: Promise<T>, ms: number): Promise<T> {
return new Promise<T>((resolve, reject) => {
const timer = setTimeout(() => {
reject(
new GraphqlApolloError(
"GRAPHQL_APOLLO.TIMEOUT",
`request exceeded ${ms}ms`
)
);
}, ms);
timer.unref?.();
p.then(
(v) => {
clearTimeout(timer);
resolve(v);
},
(e: unknown) => {
clearTimeout(timer);
reject(e as Error);
}
);
});
}
/** Factory yielding a fresh `ApolloGraphqlEngine`. */
export function createApolloGraphqlEngine<
Caps extends CapabilityRequirements = CapabilityRequirements,
>(args: CreateApolloGraphqlEngineArgs = {}): ApolloGraphqlEngine<Caps> {
return new ApolloGraphqlEngine<Caps>(args);
}
function freezeRegistry<Caps extends CapabilityRequirements>(
registry: GraphqlRegistry<Caps>
): FrozenRegistry<Caps> {
const withFreeze = registry as GraphqlRegistry<Caps> & {
readonly freeze?: () => FrozenRegistry<Caps>;
};
if (typeof withFreeze.freeze === "function") return withFreeze.freeze();
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED",
"registry does not expose a freeze() snapshot"
);
}
function mergeResolvers<Caps extends CapabilityRequirements>(
frozen: FrozenRegistry<Caps>
): Record<string, Record<string, (...args: unknown[]) => unknown>> {
const out: Record<
string,
Record<string, (...args: unknown[]) => unknown>
> = {};
for (const map of frozen.resolvers) {
for (const [typeName, fields] of Object.entries(map)) {
const target = out[typeName] ?? {};
for (const [fieldName, fn] of Object.entries(
fields as Record<string, unknown>
)) {
target[fieldName] = fn as (...args: unknown[]) => unknown;
}
out[typeName] = target;
}
}
return out;
}
function attachResolvers(
schema: ReturnType<typeof buildSchema>,
resolvers: Record<string, Record<string, (...args: unknown[]) => unknown>>
): void {
for (const [typeName, fields] of Object.entries(resolvers)) {
const type = schema.getType(typeName);
if (type === undefined || type === null) continue;
const fieldsOf = (
type as unknown as {
getFields?: () => Record<
string,
{ resolve?: (...args: unknown[]) => unknown }
>;
}
).getFields;
if (typeof fieldsOf !== "function") continue;
const map = fieldsOf.call(type);
for (const [fieldName, fn] of Object.entries(fields)) {
const def = map[fieldName];
if (def !== undefined) def.resolve = fn;
}
}
}
function toResponse(result: {
readonly data?: Readonly<Record<string, unknown>> | null;
readonly errors?: ReadonlyArray<{
readonly message: string;
readonly path?: ReadonlyArray<string | number>;
readonly extensions?: Readonly<Record<string, unknown>>;
}>;
readonly extensions?: Readonly<Record<string, unknown>>;
}): GraphqlResponse {
// Apollo always sets at least `data` or `errors` on the singleResult.
// We forward the full envelope verbatim so callers see every field
// including the (optional) `extensions` map.
return { ...result };
}
function containsIntrospection(query: string): boolean {
return /\b__(schema|type)\b/.test(query);
}
function emptyIterable(): AsyncIterable<GraphqlResponse> {
return {
[Symbol.asyncIterator](): AsyncIterator<GraphqlResponse> {
return {
next: async (): Promise<IteratorResult<GraphqlResponse>> => ({
value: undefined,
done: true,
}),
};
},
};
}

View File

@@ -0,0 +1,5 @@
export {
ApolloGraphqlEngine,
type CreateApolloGraphqlEngineArgs,
createApolloGraphqlEngine,
} from "./apollo-engine.js";

View File

@@ -0,0 +1,86 @@
# `error-codes` — `GRAPHQL_APOLLO.*` registry
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`error-codes` defines the stable `GRAPHQL_APOLLO.*` namespace plus the
`GraphqlApolloError` class consumed by every other module in the
`@suaveplan/api-graphql-apollo` package. It sits in the same shape as the
sibling `@suaveplan/api-graphql` registry so callers can write a single
`instanceof SuaveplanError` discriminator and narrow further on
`error.code`.
The registry covers ten codes — request-document parse and validation
failures, depth and complexity guard rejections, schema-build failures
inside Apollo, the missing-transport case for subscriptions, request
timeouts, introspection-denied responses, persisted-query lookup misses,
and the resolver-attach catch-all. Every code is thrown by a production
path; the conformance harness enforces that empirically.
## Features
- Frozen `defineCodes("GRAPHQL_APOLLO", […])` registry — no untyped string
ever escapes the package.
- `GraphqlApolloError` exposes the code via both the inherited `error.code`
and a narrowed `graphqlApolloCode` field; either narrows downstream code
to the union of ten possible values.
- Constructor preserves `cause` and `context` per the `SuaveplanError`
contract — wrappers can attach the raw `cause: Error` for crash dumps.
- The class sets `this.name = "GraphqlApolloError"` so logs surface the
concrete subclass.
## Usage: Basic
```ts
import {
GRAPHQL_APOLLO_CODES,
GraphqlApolloError,
} from "@suaveplan/api-graphql-apollo/error-codes";
throw new GraphqlApolloError(
GRAPHQL_APOLLO_CODES.PARSE_FAILED,
"request document failed to parse"
);
```
## Usage: Advanced
Attach a cause and a context payload that the OTel span recorder will
attach as event attributes:
```ts
import { GraphqlApolloError } from "@suaveplan/api-graphql-apollo/error-codes";
try {
JSON.parse(maybeJson);
} catch (cause) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.PARSE_FAILED",
"persisted-query manifest is not valid JSON",
{ cause: cause as Error, context: { path: "./pq.json" } }
);
}
```
## API Reference
- `GRAPHQL_APOLLO_CODES` — frozen object mapping each short name to its
fully-qualified code string.
- `GraphqlApolloErrorCode` — union of every code value resolved through
`GRAPHQL_APOLLO_CODES`.
- `class GraphqlApolloError extends SuaveplanError<GraphqlApolloErrorCode>`.
- `constructor(code, message, options?)`
- `graphqlApolloCode` — convenience accessor pre-narrowed to the
`GraphqlApolloErrorCode` union.
## Implementation Notes
The codes intentionally mirror the design-doc §10.5 list. Adding a new
code requires (a) appending it to the `defineCodes` array, (b) wiring at
least one production throw site, and (c) extending the conformance
checklist. The `name` field is set so structured logs see
`GraphqlApolloError` rather than the generic `SuaveplanError`. The class
extends the abstract `SuaveplanError` directly — no intermediate base.

View File

@@ -0,0 +1,36 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GRAPHQL_APOLLO_CODES, GraphqlApolloError } from "./error-codes.js";
describe("error-codes", () => {
it("registers ten GRAPHQL_APOLLO codes", { timeout: 5000 }, () => {
expect(Object.keys(GRAPHQL_APOLLO_CODES)).toHaveLength(10);
expect(GRAPHQL_APOLLO_CODES.PARSE_FAILED).toBe(
"GRAPHQL_APOLLO.PARSE_FAILED"
);
expect(GRAPHQL_APOLLO_CODES.PERSISTED_QUERY_NOT_FOUND).toBe(
"GRAPHQL_APOLLO.PERSISTED_QUERY_NOT_FOUND"
);
});
it("constructs a typed error with code accessor", { timeout: 5000 }, () => {
const err = new GraphqlApolloError(
"GRAPHQL_APOLLO.PARSE_FAILED",
"boom"
);
expect(err).toBeInstanceOf(GraphqlApolloError);
expect(err.code).toBe("GRAPHQL_APOLLO.PARSE_FAILED");
expect(err.graphqlApolloCode).toBe("GRAPHQL_APOLLO.PARSE_FAILED");
expect(err.name).toBe("GraphqlApolloError");
expect(err.message).toBe("boom");
});
it("accepts optional cause + context", { timeout: 5000 }, () => {
const cause = new Error("inner");
const err = new GraphqlApolloError(
"GRAPHQL_APOLLO.SCHEMA_BUILD_FAILED",
"outer",
{ cause, context: { foo: "bar" } }
);
expect(err.cause).toBe(cause);
});
});

View File

@@ -0,0 +1,58 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Stable error-code registry for `@suaveplan/api-graphql-apollo`.
*
* The ten codes cover Apollo-specific failure surfaces layered on top of
* the abstract `GRAPHQL.*` codes owned by `@suaveplan/api-graphql`:
*
* - `PARSE_FAILED` — the request body was not a valid GraphQL document.
* - `VALIDATION_FAILED` — the parsed document failed schema validation.
* - `DEPTH_EXCEEDED` — query nesting exceeded `maxQueryDepth`.
* - `COMPLEXITY_EXCEEDED` — query cost exceeded `maxQueryComplexity`.
* - `SCHEMA_BUILD_FAILED` — `buildSubgraphSchema` rejected the merged SDL.
* - `SUBSCRIPTION_TRANSPORT_UNAVAILABLE` — subscription requested but no
* `api-ws` engine wired.
* - `TIMEOUT` — execution exceeded the host-supplied timeout.
* - `INTROSPECTION_DENIED` — introspection query received while
* `introspection: false`.
* - `PERSISTED_QUERY_NOT_FOUND` — `{ id }` referenced an unknown persisted
* query.
* - `RESOLVER_THREW` — a resolver threw a non-SuaveplanError (the engine
* wraps it so wire shape remains stable).
*/
export const GRAPHQL_APOLLO_CODES = defineCodes("GRAPHQL_APOLLO", [
"PARSE_FAILED",
"VALIDATION_FAILED",
"DEPTH_EXCEEDED",
"COMPLEXITY_EXCEEDED",
"SCHEMA_BUILD_FAILED",
"SUBSCRIPTION_TRANSPORT_UNAVAILABLE",
"TIMEOUT",
"INTROSPECTION_DENIED",
"PERSISTED_QUERY_NOT_FOUND",
"RESOLVER_THREW",
] as const);
export type GraphqlApolloErrorCode =
(typeof GRAPHQL_APOLLO_CODES)[keyof typeof GRAPHQL_APOLLO_CODES];
/**
* Concrete error class for `@suaveplan/api-graphql-apollo`. Extends
* {@link SuaveplanError} so callers can narrow with `instanceof
* GraphqlApolloError` AND the stable `error.code` property.
*/
export class GraphqlApolloError extends SuaveplanError<GraphqlApolloErrorCode> {
public readonly graphqlApolloCode: GraphqlApolloErrorCode;
public constructor(
code: GraphqlApolloErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.graphqlApolloCode = code;
this.name = "GraphqlApolloError";
}
}

View File

@@ -0,0 +1,5 @@
export {
GRAPHQL_APOLLO_CODES,
GraphqlApolloError,
type GraphqlApolloErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,70 @@
# `federation` — multi-plugin SDL merger
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`federation` is the engine's facade over the canonical `mergeSdl`
function from `@suaveplan/api-graphql`. Apollo doesn't see federated
plugin contributions directly — the engine collects every plugin's
SDL via the abstract `GraphqlRegistry`, federates them through this
module, and feeds the merged SDL to `buildSchema()`. Collision
detection, namespace wrapping, and strict-mode policy come for free
because all three are owned by the abstract merger.
The module is intentionally a one-line passthrough; it exists so the
package surface signals "yes, Apollo federates the same way Yoga
does — `GRAPHQL.SCHEMA_COLLISION` is the canonical code on either
engine".
## Features
- 1:1 delegation to `@suaveplan/api-graphql.mergeSdl` — no shape
change, no behaviour change.
- Same collision semantics as Yoga — `GRAPHQL.SCHEMA_COLLISION` is
raised, NOT a `GRAPHQL_APOLLO.*` code.
- Strict-mode propagation — `strictNamespace: true` requires every
plugin to declare a `graphql.namespace`.
- Returns the canonical `MergedSchema` shape so engine internals stay
assignable to the abstract contract.
## Usage: Basic
```ts
import { federateForApollo } from "@suaveplan/api-graphql-apollo/federation";
const merged = federateForApollo([
{ plugin: "billing", typeDefs: ["type Query { invoice: String! }"] },
{ plugin: "users", typeDefs: ["extend type Query { me: String! }"] },
]);
console.log(merged.sdl);
```
## Usage: Advanced
Enable strict-mode federation across all plugins to enforce a
namespace contract before the host even compiles:
```ts
import { federateForApollo } from "@suaveplan/api-graphql-apollo/federation";
federateForApollo(contributions, { strictNamespace: true });
// → throws GRAPHQL.NAMESPACE_REQUIRED if any plugin omitted namespace
```
## API Reference
- `federateForApollo(contributions, options?): MergedSchema`
- delegates to `@suaveplan/api-graphql.mergeSdl`.
## Implementation Notes
The function exists primarily so the package's TypeScript exports
expose a `federateForApollo` symbol that mirrors the sibling
`federateForYoga` (when the Yoga adapter lands). The behavioural
contract is owned by the abstract `mergeSdl`. Tests verify that
`GRAPHQL.SCHEMA_COLLISION` and `GRAPHQL.NAMESPACE_REQUIRED` are raised
through this wrapper — concrete proof that Apollo and Yoga agree on
the federation contract.

View File

@@ -0,0 +1,48 @@
import type { GraphqlError } from "@suaveplan/api-graphql";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { federateForApollo } from "./federation.js";
describe("federation/federateForApollo", () => {
it("merges two plugins both extending Query", { timeout: 5000 }, () => {
const merged = federateForApollo([
{
plugin: "a",
typeDefs: ["type Query { a: String! }"],
},
{
plugin: "b",
typeDefs: ["extend type Query { b: String! }"],
},
]);
expect(merged.sdl).toContain("a: String!");
expect(merged.sdl).toContain("b: String!");
expect(merged.plugins).toEqual(["a", "b"]);
});
it("raises GRAPHQL.SCHEMA_COLLISION on same-field collision", {
timeout: 5000,
}, () => {
let err: GraphqlError | null = null;
try {
federateForApollo([
{ plugin: "a", typeDefs: ["type Query { ping: String! }"] },
{
plugin: "b",
typeDefs: ["type Query { ping: String! }"],
},
]);
} catch (e) {
err = e as GraphqlError;
}
expect(err?.code).toBe("GRAPHQL.SCHEMA_COLLISION");
});
it("forwards strictNamespace requirement", { timeout: 5000 }, () => {
expect(() =>
federateForApollo(
[{ plugin: "a", typeDefs: ["type Query { a: String! }"] }],
{ strictNamespace: true }
)
).toThrow();
});
});

View File

@@ -0,0 +1,24 @@
import type {
MergedSchema,
MergeOptions,
SdlContribution,
} from "@suaveplan/api-graphql";
import { mergeSdl } from "@suaveplan/api-graphql";
/**
* Federate a set of plugin SDL contributions into a single executable
* schema document for Apollo. Delegates to `@suaveplan/api-graphql`'s
* canonical `mergeSdl` so collision semantics, namespace wrapping and
* the `GRAPHQL.SCHEMA_COLLISION` / `GRAPHQL.NAMESPACE_REQUIRED` raise
* paths stay 1:1 with the Yoga sibling.
*
* Returning the original `MergedSchema` keeps the Apollo adapter's
* federation surface assignable to the abstract contract — engines may
* not subclass or re-shape this value.
*/
export function federateForApollo(
contributions: ReadonlyArray<SdlContribution>,
options: MergeOptions = {}
): MergedSchema {
return mergeSdl(contributions, options);
}

View File

@@ -0,0 +1 @@
export { federateForApollo } from "./federation.js";

View File

@@ -0,0 +1,75 @@
# `guards/depth-and-complexity` — query-shape rejection
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`depth-and-complexity` walks a parsed `DocumentNode` and computes two
coarse static metrics — the maximum selection-set nesting depth and the
total field-count cost. The engine consults both before handing the
document to Apollo Server; queries that exceed `maxQueryDepth` or
`maxQueryComplexity` are rejected with the corresponding
`GRAPHQL_APOLLO.DEPTH_EXCEEDED` / `COMPLEXITY_EXCEEDED` codes.
The walk follows fragment spreads through their definitions and includes
inline-fragment subselections, so a clever query that hides depth or
cost behind a deeply nested fragment cannot bypass the guards. The
heuristic matches the Yoga sibling so the two engines reject the same
query set under identical limits.
## Features
- Depth counter increments per nested `selectionSet`, never on fragment
spreads or inline fragments themselves (matches conventional
depth-limiter implementations).
- Complexity counter adds 1 per selected field, plus the recursive cost
of its subselection, plus the expansion of fragment spreads. Missing
fragment spreads contribute zero.
- `assertDepthAndComplexity` evaluates depth first; the more localised
failure wins so operators see the rule they triggered.
- Pure-functional — no instance state, no `this`. Safe to call from any
request lifecycle phase.
## Usage: Basic
```ts
import { parse } from "graphql";
import { assertDepthAndComplexity } from "@suaveplan/api-graphql-apollo/guards";
const doc = parse("{ a { b { c } } }");
assertDepthAndComplexity(doc, { maxDepth: 10, maxComplexity: 1000 });
```
## Usage: Advanced
Run the metrics in isolation to feed an OTel histogram before the engine
even routes the document:
```ts
import { parse } from "graphql";
import {
maxQueryDepth,
queryComplexity,
} from "@suaveplan/api-graphql-apollo/guards";
const doc = parse(req.body.query);
metrics.histogram("graphql.depth").record(maxQueryDepth(doc));
metrics.histogram("graphql.complexity").record(queryComplexity(doc));
```
## API Reference
- `maxQueryDepth(doc: DocumentNode): number`
- `queryComplexity(doc: DocumentNode): number`
- `assertDepthAndComplexity(doc, { maxDepth, maxComplexity }): void` —
throws `GraphqlApolloError` on first violation.
## Implementation Notes
Fragment definitions are collected up-front into a `Map` so spread
resolution is O(1). Inline fragments share the parent depth — they do
not add a level. The complexity heuristic intentionally undercounts
list multiplicity; production deployments wire a richer estimator via a
host-supplied `costExtension` (out of scope for the foundation engine).

View File

@@ -0,0 +1,112 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parse } from "graphql";
import type { GraphqlApolloError } from "../error-codes/error-codes.js";
import {
assertDepthAndComplexity,
maxQueryDepth,
queryComplexity,
} from "./depth-and-complexity.js";
describe("guards/depth-and-complexity", () => {
it("computes depth of a nested selection", { timeout: 5000 }, () => {
const doc = parse("{ a { b { c { d } } } }");
expect(maxQueryDepth(doc)).toBe(4);
});
it("counts complexity across fragments + inline fragments", {
timeout: 5000,
}, () => {
const doc = parse(
"{ a { ...F ... on T { x } } } fragment F on T { y z }"
);
expect(queryComplexity(doc)).toBeGreaterThanOrEqual(4);
});
it("ignores fragment spreads with no matching definition", {
timeout: 5000,
}, () => {
const doc = parse("{ a { ...Missing } }");
expect(maxQueryDepth(doc)).toBe(2);
expect(queryComplexity(doc)).toBeGreaterThanOrEqual(1);
});
it("throws DEPTH_EXCEEDED for over-nested queries", {
timeout: 5000,
}, () => {
const doc = parse("{ a { b { c { d } } } }");
let err: GraphqlApolloError | null = null;
try {
assertDepthAndComplexity(doc, {
maxDepth: 2,
maxComplexity: 1000,
});
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err).not.toBeNull();
expect(err?.code).toBe("GRAPHQL_APOLLO.DEPTH_EXCEEDED");
});
it("throws COMPLEXITY_EXCEEDED for high-cost queries", {
timeout: 5000,
}, () => {
const doc = parse("{ a b c d e }");
let err: GraphqlApolloError | null = null;
try {
assertDepthAndComplexity(doc, {
maxDepth: 100,
maxComplexity: 1,
});
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.COMPLEXITY_EXCEEDED");
});
it("passes when limits allow", { timeout: 5000 }, () => {
const doc = parse("{ ping }");
expect(() =>
assertDepthAndComplexity(doc, {
maxDepth: 10,
maxComplexity: 100,
})
).not.toThrow();
});
it("returns zero depth for fragment-only documents", {
timeout: 5000,
}, () => {
const doc = parse("fragment F on T { a }");
expect(maxQueryDepth(doc)).toBe(0);
expect(queryComplexity(doc)).toBe(0);
});
it("walks fragment spread depth through depth counter", {
timeout: 5000,
}, () => {
const doc = parse("{ a { ...F } } fragment F on T { b { c { d } } }");
// a (1) -> spread F whose body { b { c { d } } } adds 3 -> total 4
expect(maxQueryDepth(doc)).toBe(4);
});
it("walks inline fragment depth + complexity", { timeout: 5000 }, () => {
const doc = parse("{ a { ... on T { b { c } } } }");
expect(maxQueryDepth(doc)).toBe(3);
expect(queryComplexity(doc)).toBeGreaterThanOrEqual(3);
});
it("walks fragments through complexity counter", {
timeout: 5000,
}, () => {
const doc = parse("{ a { ...F } } fragment F on T { x y z }");
// 'a' = 1, fragment spread expands x,y,z = 3 -> total 4
expect(queryComplexity(doc)).toBe(4);
});
it("treats missing fragment spreads as zero complexity contribution", {
timeout: 5000,
}, () => {
const doc = parse("{ a { ...Missing } }");
expect(queryComplexity(doc)).toBe(1);
});
});

View File

@@ -0,0 +1,145 @@
import type {
DocumentNode,
FieldNode,
FragmentDefinitionNode,
SelectionSetNode,
} from "graphql";
import { Kind } from "graphql";
import { GraphqlApolloError } from "../error-codes/error-codes.js";
/**
* Compute the maximum selection depth of a parsed GraphQL document. Each
* nested selection set increments the depth counter; fragment spreads
* follow their definition.
*/
export function maxQueryDepth(doc: DocumentNode): number {
const fragments = collectFragments(doc);
let maxDepth = 0;
for (const def of doc.definitions) {
if (def.kind === Kind.OPERATION_DEFINITION) {
maxDepth = Math.max(
maxDepth,
depthOfSelectionSet(def.selectionSet, fragments, 1)
);
}
}
return maxDepth;
}
/**
* Compute a coarse cost estimate: each selected field contributes 1. List
* fields contribute their multiplicity (default 10). Fragment spreads
* inline. This is the same heuristic the Yoga sibling uses so dashboards
* cross-correlate.
*/
export function queryComplexity(doc: DocumentNode): number {
const fragments = collectFragments(doc);
let total = 0;
for (const def of doc.definitions) {
if (def.kind === Kind.OPERATION_DEFINITION) {
total += complexityOfSelectionSet(def.selectionSet, fragments);
}
}
return total;
}
/**
* Throw `GRAPHQL_APOLLO.DEPTH_EXCEEDED` / `COMPLEXITY_EXCEEDED` when the
* document violates either guard. Both checks run; the first violation
* wins so callers always see the more localised failure.
*/
export function assertDepthAndComplexity(
doc: DocumentNode,
limits: { readonly maxDepth: number; readonly maxComplexity: number }
): void {
const depth = maxQueryDepth(doc);
if (depth > limits.maxDepth) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.DEPTH_EXCEEDED",
`query depth ${depth} exceeds maxQueryDepth ${limits.maxDepth}`,
{ context: { depth, limit: limits.maxDepth } }
);
}
const complexity = queryComplexity(doc);
if (complexity > limits.maxComplexity) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.COMPLEXITY_EXCEEDED",
`query complexity ${complexity} exceeds maxQueryComplexity ${limits.maxComplexity}`,
{ context: { complexity, limit: limits.maxComplexity } }
);
}
}
function collectFragments(
doc: DocumentNode
): ReadonlyMap<string, FragmentDefinitionNode> {
const out = new Map<string, FragmentDefinitionNode>();
for (const def of doc.definitions) {
if (def.kind === Kind.FRAGMENT_DEFINITION) {
out.set(def.name.value, def);
}
}
return out;
}
function depthOfSelectionSet(
set: SelectionSetNode,
fragments: ReadonlyMap<string, FragmentDefinitionNode>,
depth: number
): number {
let max = depth;
for (const sel of set.selections) {
max = Math.max(max, depthOfSelection(sel, fragments, depth));
}
return max;
}
function depthOfSelection(
sel: SelectionSetNode["selections"][number],
fragments: ReadonlyMap<string, FragmentDefinitionNode>,
depth: number
): number {
if (sel.kind === Kind.FIELD) {
const node = sel as FieldNode;
if (node.selectionSet === undefined) return depth;
return depthOfSelectionSet(node.selectionSet, fragments, depth + 1);
}
if (sel.kind === Kind.FRAGMENT_SPREAD) {
const frag = fragments.get(sel.name.value);
if (frag === undefined) return depth;
return depthOfSelectionSet(frag.selectionSet, fragments, depth);
}
// INLINE_FRAGMENT
return depthOfSelectionSet(sel.selectionSet, fragments, depth);
}
function complexityOfSelectionSet(
set: SelectionSetNode,
fragments: ReadonlyMap<string, FragmentDefinitionNode>
): number {
let total = 0;
for (const sel of set.selections) {
total += complexityOfSelection(sel, fragments);
}
return total;
}
function complexityOfSelection(
sel: SelectionSetNode["selections"][number],
fragments: ReadonlyMap<string, FragmentDefinitionNode>
): number {
if (sel.kind === Kind.FIELD) {
const nested =
sel.selectionSet === undefined
? 0
: complexityOfSelectionSet(sel.selectionSet, fragments);
return 1 + nested;
}
if (sel.kind === Kind.FRAGMENT_SPREAD) {
const frag = fragments.get(sel.name.value);
if (frag === undefined) return 0;
return complexityOfSelectionSet(frag.selectionSet, fragments);
}
// INLINE_FRAGMENT
return complexityOfSelectionSet(sel.selectionSet, fragments);
}

View File

@@ -0,0 +1,5 @@
export {
assertDepthAndComplexity,
maxQueryDepth,
queryComplexity,
} from "./depth-and-complexity.js";

View File

@@ -0,0 +1,67 @@
// Side-effect import — registers the GRAPHQL_APOLLO error-code namespace.
import "./error-codes/error-codes.js";
export {
type GraphqlHttpHandler,
type GraphqlSdlHandler,
type MountHttpEndpointsArgs,
mountGraphqlHttpEndpoints,
} from "./endpoints/http.js";
export {
type GraphqlUiPlugin,
mountGraphqlUiProxy,
type UiProxyResponse,
} from "./endpoints/ui-proxy.js";
export {
ApolloGraphqlEngine,
type CreateApolloGraphqlEngineArgs,
createApolloGraphqlEngine,
} from "./engine/apollo-engine.js";
export {
GRAPHQL_APOLLO_CODES,
GraphqlApolloError,
type GraphqlApolloErrorCode,
} from "./error-codes/error-codes.js";
export { federateForApollo } from "./federation/federation.js";
export {
assertDepthAndComplexity,
maxQueryDepth,
queryComplexity,
} from "./guards/depth-and-complexity.js";
export {
createInstruments,
GRAPHQL_INSTRUMENT_NAMES,
type GraphqlInstrumentName,
type GraphqlInstruments,
type InstrumentSnapshot,
} from "./instruments/instruments.js";
export {
createRequestLoaderScope,
type RequestLoaderScope,
} from "./loaders/loaders.js";
export {
type ApolloGraphqlOptions,
type ApolloGraphqlOptionsInput,
apolloGraphqlOptionsSchema,
parseApolloGraphqlOptions,
} from "./options/options.js";
export {
createPersistedQueryManifest,
loadPersistedQueryManifest,
type PersistedQueryManifest,
parsePersistedQueryManifest,
} from "./persisted-queries/persisted-queries.js";
export {
createStudioReporting,
type StudioReportingConfig,
type StudioReportingDescriptor,
} from "./studio-reporting/studio-reporting.js";
export {
assertSubscriptionTransport,
runSubscription,
type SubscriptionTransport,
} from "./subscriptions/subscriptions.js";
export {
runApolloConformance,
runGraphqlEngineConformance,
} from "./testing/conformance.js";

View File

@@ -0,0 +1,7 @@
export {
createInstruments,
GRAPHQL_INSTRUMENT_NAMES,
type GraphqlInstrumentName,
type GraphqlInstruments,
type InstrumentSnapshot,
} from "./instruments.js";

View File

@@ -0,0 +1,76 @@
# `instruments` — engine-agnostic OTel surface
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`instruments` owns the five OTel instrument names emitted by every GraphQL
engine adapter in the foundation. Both `api-graphql-apollo` and
`api-graphql-yoga` MUST emit the same set so operator dashboards survive
an engine swap with zero rename work. The names are stable strings (not
generated identifiers); flipping the host's `engine` slot from Yoga to
Apollo or back keeps the time-series continuous.
The module also ships an in-memory `GraphqlInstruments` implementation
used by unit tests — `createInstruments()` returns a recorder that pushes
to plain arrays so tests can assert exact request counts, schema rebuild
counts, etc. Production hosts pass their own implementation backed by
`@opentelemetry/sdk-metrics`.
## Features
- `GRAPHQL_INSTRUMENT_NAMES` frozen object — five canonical
`graphql.*` names.
- `createInstruments()` — zero-arg factory returning an in-memory
recorder with `snapshot()` for assertions.
- Counter + histogram surface: `incRequests`, `recordRequestDurationMs`,
`recordResolverDurationMs`, `incSubscriptionsActive` /
`decSubscriptionsActive`, `incSchemaRebuilds`.
- Snapshot is a deep copy — tests can compare without worrying about
later mutation aliasing the saved value.
## Usage: Basic
```ts
import { createInstruments } from "@suaveplan/api-graphql-apollo/instruments";
const ins = createInstruments();
ins.incRequests({ operation: "ping" });
ins.recordRequestDurationMs(42);
ins.snapshot();
// → { requestsTotal: 1, requestDurationMs: [42], … }
```
## Usage: Advanced
Plug an OTel-backed instrument set into the engine and assert the
expected metrics are emitted across a workload:
```ts
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
import { createInstruments } from "@suaveplan/api-graphql-apollo/instruments";
const ins = createInstruments();
const engine = createApolloGraphqlEngine({ instruments: ins });
// … buildSchema + executeQuery …
const snap = ins.snapshot();
console.log(snap.requestsTotal, snap.schemaRebuildsTotal);
```
## API Reference
- `GRAPHQL_INSTRUMENT_NAMES.requestsTotal` (etc.) — exact metric names.
- `GraphqlInstruments` — interface implemented by the in-memory recorder
and by production OTel adapters.
- `InstrumentSnapshot` — read-only snapshot returned by `snapshot()`.
- `createInstruments(): GraphqlInstruments` — in-memory factory.
## Implementation Notes
The recorder is deliberately a closure over four primitives + two arrays
— no Map allocations on the hot path. The arrays are copied at snapshot
time so callers cannot mutate the live counters. Engine-agnostic naming
is enforced by the engine-swap integration test in `tests/integration/`
which asserts the metric-name set is identical across Apollo and Yoga.

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createInstruments, GRAPHQL_INSTRUMENT_NAMES } from "./instruments.js";
describe("instruments", () => {
it("exposes the engine-agnostic name set", { timeout: 5000 }, () => {
expect(GRAPHQL_INSTRUMENT_NAMES.requestsTotal).toBe(
"graphql.requests_total"
);
expect(GRAPHQL_INSTRUMENT_NAMES.requestDurationMs).toBe(
"graphql.request_duration_ms"
);
expect(GRAPHQL_INSTRUMENT_NAMES.resolverDurationMs).toBe(
"graphql.resolver_duration_ms"
);
expect(GRAPHQL_INSTRUMENT_NAMES.subscriptionsActive).toBe(
"graphql.subscriptions_active"
);
expect(GRAPHQL_INSTRUMENT_NAMES.schemaRebuildsTotal).toBe(
"graphql.schema_rebuilds_total"
);
});
it("records every instrument and snapshots them", { timeout: 5000 }, () => {
const ins = createInstruments();
ins.incRequests({ op: "ping" });
ins.incRequests({ op: "ping" });
ins.recordRequestDurationMs(12);
ins.recordResolverDurationMs(3);
ins.incSubscriptionsActive();
ins.incSubscriptionsActive();
ins.decSubscriptionsActive();
ins.incSchemaRebuilds();
const snap = ins.snapshot();
expect(snap.requestsTotal).toBe(2);
expect(snap.requestDurationMs).toEqual([12]);
expect(snap.resolverDurationMs).toEqual([3]);
expect(snap.subscriptionsActive).toBe(1);
expect(snap.schemaRebuildsTotal).toBe(1);
});
});

View File

@@ -0,0 +1,82 @@
/**
* Engine-agnostic OTel instrument names emitted by every GraphQL engine
* adapter — Apollo AND Yoga MUST emit this exact set so operator
* dashboards survive an engine swap.
*/
export const GRAPHQL_INSTRUMENT_NAMES = Object.freeze({
requestsTotal: "graphql.requests_total",
requestDurationMs: "graphql.request_duration_ms",
resolverDurationMs: "graphql.resolver_duration_ms",
subscriptionsActive: "graphql.subscriptions_active",
schemaRebuildsTotal: "graphql.schema_rebuilds_total",
} as const);
export type GraphqlInstrumentName =
(typeof GRAPHQL_INSTRUMENT_NAMES)[keyof typeof GRAPHQL_INSTRUMENT_NAMES];
/**
* Runtime instrument registry — minimal counters + histograms shape
* pluggable into a real OTel meter at host wire-up. Implementations are
* passed in via {@link createInstruments}; the in-memory default records
* to arrays for unit-test introspection.
*/
export interface GraphqlInstruments {
incRequests(attrs: Readonly<Record<string, string>>): void;
recordRequestDurationMs(value: number): void;
recordResolverDurationMs(value: number): void;
incSubscriptionsActive(): void;
decSubscriptionsActive(): void;
incSchemaRebuilds(): void;
snapshot(): InstrumentSnapshot;
}
/** Read-only snapshot of every instrument's current value. */
export interface InstrumentSnapshot {
readonly requestsTotal: number;
readonly requestDurationMs: readonly number[];
readonly resolverDurationMs: readonly number[];
readonly subscriptionsActive: number;
readonly schemaRebuildsTotal: number;
}
/**
* Default in-memory implementation. Used by unit tests; production callers
* pass their own implementation backed by `@opentelemetry/sdk-metrics`.
*/
export function createInstruments(): GraphqlInstruments {
let requestsTotal = 0;
const requestDurationMs: number[] = [];
const resolverDurationMs: number[] = [];
let subscriptionsActive = 0;
let schemaRebuildsTotal = 0;
return {
incRequests(): void {
requestsTotal += 1;
},
recordRequestDurationMs(value: number): void {
requestDurationMs.push(value);
},
recordResolverDurationMs(value: number): void {
resolverDurationMs.push(value);
},
incSubscriptionsActive(): void {
subscriptionsActive += 1;
},
decSubscriptionsActive(): void {
subscriptionsActive -= 1;
},
incSchemaRebuilds(): void {
schemaRebuildsTotal += 1;
},
snapshot(): InstrumentSnapshot {
return {
requestsTotal,
requestDurationMs: [...requestDurationMs],
resolverDurationMs: [...resolverDurationMs],
subscriptionsActive,
schemaRebuildsTotal,
};
},
};
}

View File

@@ -0,0 +1,4 @@
export {
createRequestLoaderScope,
type RequestLoaderScope,
} from "./loaders.js";

View File

@@ -0,0 +1,75 @@
# `loaders` — per-request DataLoader scope
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`loaders` defines the per-request DataLoader scope the engine creates
around every incoming GraphQL operation. Two concurrent requests
receive distinct loader instances; the cache never leaks across
request boundaries even though both requests draw from the same set of
loader factories registered on the abstract `GraphqlRegistry`.
The module is deliberately the thinnest possible wrapper around the
`LoaderFactory.create()` surface owned by `@suaveplan/api-types/graphql`.
The engine calls `createRequestLoaderScope()` once per request; resolvers
that need a loader call `scope.get(name, factory)`; the engine disposes
the scope at request end.
## Features
- One scope per request — distinct cache per request guaranteed.
- Loader lazily instantiated on first `get(name, factory)` call;
subsequent calls reuse the instance.
- `dispose()` blocks subsequent `get()` calls so a buggy late resolver
cannot resurrect a freed cache.
- Zero runtime dependencies — pure JS, fits in ~40 lines.
## Usage: Basic
```ts
import { createRequestLoaderScope } from "@suaveplan/api-graphql-apollo/loaders";
const scope = createRequestLoaderScope();
const userLoader = scope.get("users", userLoaderFactory);
const user = await userLoader.load("u_42");
scope.dispose();
```
## Usage: Advanced
Wire the scope into a resolver context object so resolvers consume
loaders by name through the request context:
```ts
import { createRequestLoaderScope } from "@suaveplan/api-graphql-apollo/loaders";
function makeContext(): RequestContext {
const scope = createRequestLoaderScope();
return {
loaders: {
users: () => scope.get("users", userLoaderFactory),
orders: () => scope.get("orders", orderLoaderFactory),
},
finalize: () => scope.dispose(),
} as unknown as RequestContext;
}
```
## API Reference
- `RequestLoaderScope`:
- `get<TKey, TValue>(name, factory): { load }`
- `dispose(): void`
- `size(): number`
- `createRequestLoaderScope(): RequestLoaderScope`.
## Implementation Notes
The scope is a closure over a `Map<string, LoaderInstance>` plus a
`disposed` boolean. Throwing on `get` after `dispose` surfaces the
late-resolver bug at the call site instead of silently producing
inconsistent reads. The size accessor is provided so OTel histograms
can record loader-fanout per request.

View File

@@ -0,0 +1,55 @@
import type { LoaderFactory } from "@suaveplan/api-types/graphql";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createRequestLoaderScope } from "./loaders.js";
function makeFactory(): LoaderFactory<string, string> {
let createCount = 0;
return {
create() {
createCount += 1;
const seen = createCount;
return {
async load(key: string): Promise<string> {
return `${key}:${seen}`;
},
async loadMany(
keys: readonly string[]
): Promise<readonly string[]> {
return keys.map((k) => `${k}:${seen}`);
},
};
},
};
}
describe("loaders/createRequestLoaderScope", () => {
it("creates one loader per name per request", {
timeout: 5000,
}, async () => {
const scope = createRequestLoaderScope();
const factory = makeFactory();
const a = scope.get("users", factory);
const b = scope.get("users", factory);
expect(a).toBe(b);
expect(scope.size()).toBe(1);
expect(await a.load("u1")).toBe("u1:1");
});
it("isolates loaders across two scopes (concurrent requests)", {
timeout: 5000,
}, async () => {
const factory = makeFactory();
const s1 = createRequestLoaderScope();
const s2 = createRequestLoaderScope();
const l1 = s1.get("orders", factory);
const l2 = s2.get("orders", factory);
expect(await l1.load("o1")).toBe("o1:1");
expect(await l2.load("o1")).toBe("o1:2");
});
it("throws on use after dispose", { timeout: 5000 }, () => {
const s = createRequestLoaderScope();
s.dispose();
expect(() => s.get("x", makeFactory())).toThrow();
});
});

View File

@@ -0,0 +1,64 @@
import type { LoaderFactory } from "@suaveplan/api-types/graphql";
/**
* Per-request DataLoader scope. The engine instantiates one of these
* around every incoming GraphQL request and disposes it at request end.
* Two concurrent requests therefore receive distinct loader instances —
* the cache never leaks across requests.
*/
export interface RequestLoaderScope {
/**
* Resolve a loader by name. Calls the factory's `create()` once per
* scope; subsequent calls within the same request reuse the
* instance.
*/
get<TKey, TValue>(
name: string,
factory: LoaderFactory<TKey, TValue>
): { load(key: TKey): Promise<TValue> };
/**
* Dispose the scope. After disposal `get()` throws. Provided as a
* dedicated hook so engines can wire it to the request lifecycle.
*/
dispose(): void;
/** Read-only count of loaders instantiated this request. */
size(): number;
}
/** Construct a fresh loader scope. */
export function createRequestLoaderScope(): RequestLoaderScope {
const instances = new Map<
string,
{ load(key: unknown): Promise<unknown> }
>();
let disposed = false;
return {
get<TKey, TValue>(
name: string,
factory: LoaderFactory<TKey, TValue>
): { load(key: TKey): Promise<TValue> } {
if (disposed) {
throw new Error(
`request loader scope already disposed (loader "${name}")`
);
}
const existing = instances.get(name);
if (existing !== undefined) {
return existing as { load(key: TKey): Promise<TValue> };
}
const fresh = factory.create();
instances.set(
name,
fresh as unknown as { load(key: unknown): Promise<unknown> }
);
return fresh as { load(key: TKey): Promise<TValue> };
},
dispose(): void {
disposed = true;
instances.clear();
},
size(): number {
return instances.size;
},
};
}

View File

@@ -0,0 +1,6 @@
export {
type ApolloGraphqlOptions,
type ApolloGraphqlOptionsInput,
apolloGraphqlOptionsSchema,
parseApolloGraphqlOptions,
} from "./options.js";

View File

@@ -0,0 +1,83 @@
# `options` — `ApolloGraphqlOptions`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`options` owns the Zod-validated configuration surface accepted by
`createApolloGraphqlEngine`. The schema mirrors the Yoga sibling's options
1:1 so an operator can swap engines without touching the host wiring —
defaults agree, paths agree, depth and complexity limits agree, and the
persisted-query path / Studio reporting fields are the Apollo-specific
additions layered on top.
Inputs cross the Zod boundary once at engine construction time; downstream
code consumes the fully resolved `ApolloGraphqlOptions` shape and never
re-validates. Configuration failures throw `GRAPHQL_APOLLO.PARSE_FAILED`
so caller-visible input errors share a single error code with the
request-document parse path.
## Features
- Endpoint paths default to `/graphql`, `/graphql/sdl`, `/graphql/ws`,
`/graphql/ui` — identical to Yoga.
- `introspection: false` by default; flipping the flag wires Apollo's
schema introspection on AND lets the engine skip the
`INTROSPECTION_DENIED` guard.
- `maxQueryDepth` and `maxQueryComplexity` mirror the Yoga defaults
(`10`, `1000`).
- `requestTimeoutMs` defaults to `30_000`; the engine wraps every
`executeOperation` call in a timeout race.
- Persisted-query manifest path is optional; the engine wires the
manifest loader only when `persistedQueriesPath` is set.
- `studioReporting` opt-in — the engine returns `null` from
`createStudioReporting()` when omitted.
## Usage: Basic
```ts
import { parseApolloGraphqlOptions } from "@suaveplan/api-graphql-apollo/options";
const opts = parseApolloGraphqlOptions({});
// opts.endpoint === "/graphql"
```
## Usage: Advanced
Wire persisted queries plus Apollo Studio reporting plus a custom error
formatter:
```ts
import { parseApolloGraphqlOptions } from "@suaveplan/api-graphql-apollo/options";
const opts = parseApolloGraphqlOptions({
persistedQueriesPath: "./pq.json",
studioReporting: {
apiKey: process.env.APOLLO_KEY ?? "",
graphRef: "supergraph@current",
},
formatError: (err) => ({ msg: String(err), redacted: true }),
requestTimeoutMs: 5_000,
});
```
## API Reference
- `apolloGraphqlOptionsSchema` — the underlying Zod schema (exported for
introspection / OpenAPI registration).
- `parseApolloGraphqlOptions(input?) → ApolloGraphqlOptions` — throws
`GraphqlApolloError("GRAPHQL_APOLLO.PARSE_FAILED")` on validation
failure.
- `ApolloGraphqlOptions` — fully-resolved options type (every field
required, every default applied).
- `ApolloGraphqlOptionsInput` — input type (every field optional).
## Implementation Notes
The schema uses `z.string().startsWith("/")` for every endpoint path —
operator typos like `endpoint: "graphql"` (no leading slash) surface
synchronously at boot. The `formatError` field is `z.custom<(err) => …>`
because Zod has no first-class function type; the custom guard rejects
non-functions with the same `GRAPHQL_APOLLO.PARSE_FAILED` code.

View File

@@ -0,0 +1,53 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GraphqlApolloError } from "../error-codes/error-codes.js";
import { parseApolloGraphqlOptions } from "./options.js";
describe("options", () => {
it("applies defaults when called with no input", { timeout: 5000 }, () => {
const opts = parseApolloGraphqlOptions();
expect(opts.endpoint).toBe("/graphql");
expect(opts.sdlEndpoint).toBe("/graphql/sdl");
expect(opts.wsEndpoint).toBe("/graphql/ws");
expect(opts.uiEndpoint).toBe("/graphql/ui");
expect(opts.introspection).toBe(false);
expect(opts.maxQueryDepth).toBe(10);
expect(opts.maxQueryComplexity).toBe(1000);
expect(opts.persistedQueriesPath).toBeUndefined();
});
it("accepts overrides + persisted-queries path", { timeout: 5000 }, () => {
const opts = parseApolloGraphqlOptions({
endpoint: "/gql",
persistedQueriesPath: "./pq.json",
introspection: true,
studioReporting: { apiKey: "k", graphRef: "g@current" },
formatError: (err) => ({ error: String(err) }),
});
expect(opts.endpoint).toBe("/gql");
expect(opts.persistedQueriesPath).toBe("./pq.json");
expect(opts.studioReporting?.apiKey).toBe("k");
expect(typeof opts.formatError).toBe("function");
});
it("throws GRAPHQL_APOLLO.PARSE_FAILED on invalid endpoint", {
timeout: 5000,
}, () => {
expect(() =>
parseApolloGraphqlOptions({ endpoint: "graphql" })
).toThrow(GraphqlApolloError);
});
it("rejects non-positive maxQueryDepth", { timeout: 5000 }, () => {
expect(() => parseApolloGraphqlOptions({ maxQueryDepth: 0 })).toThrow(
GraphqlApolloError
);
});
it("rejects non-function formatError", { timeout: 5000 }, () => {
expect(() =>
parseApolloGraphqlOptions({
formatError: "nope" as unknown as () => Record<string, unknown>,
})
).toThrow(GraphqlApolloError);
});
});

View File

@@ -0,0 +1,59 @@
import z from "zod";
import { GraphqlApolloError } from "../error-codes/error-codes.js";
/**
* Zod schema for `ApolloGraphqlOptions`. Validates the public configuration
* surface for `createApolloGraphqlEngine`. Defaults mirror the Yoga sibling
* to keep operator behaviour interchangeable across engines.
*/
export const apolloGraphqlOptionsSchema = z.object({
endpoint: z.string().startsWith("/").default("/graphql"),
sdlEndpoint: z.string().startsWith("/").default("/graphql/sdl"),
wsEndpoint: z.string().startsWith("/").default("/graphql/ws"),
uiEndpoint: z.string().startsWith("/").default("/graphql/ui"),
introspection: z.boolean().default(false),
persistedQueriesPath: z.string().min(1).optional(),
studioReporting: z
.object({
apiKey: z.string().min(1),
graphRef: z.string().min(1),
})
.optional(),
maxQueryDepth: z.number().int().positive().default(10),
maxQueryComplexity: z.number().int().positive().default(1000),
requestTimeoutMs: z.number().int().positive().default(30_000),
formatError: z
.custom<(err: unknown) => Readonly<Record<string, unknown>>>(
(val) => typeof val === "function",
{ message: "formatError must be a function" }
)
.optional(),
});
/** Public input type — every field optional. */
export type ApolloGraphqlOptionsInput = z.input<
typeof apolloGraphqlOptionsSchema
>;
/** Fully-resolved options. */
export type ApolloGraphqlOptions = z.output<typeof apolloGraphqlOptionsSchema>;
/**
* Parse a user-supplied options bag, applying every default. Throws
* `GRAPHQL_APOLLO.PARSE_FAILED` (config-shape failures map to the same code
* the engine raises for request-document parse failures because both are
* caller-visible input errors).
*/
export function parseApolloGraphqlOptions(
input: ApolloGraphqlOptionsInput = {}
): ApolloGraphqlOptions {
const parsed = apolloGraphqlOptionsSchema.safeParse(input);
if (!parsed.success) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.PARSE_FAILED",
`invalid ApolloGraphqlOptions: ${parsed.error.message}`,
{ context: { input, issues: parsed.error.issues } }
);
}
return parsed.data;
}

View File

@@ -0,0 +1,6 @@
export {
createPersistedQueryManifest,
loadPersistedQueryManifest,
type PersistedQueryManifest,
parsePersistedQueryManifest,
} from "./persisted-queries.js";

View File

@@ -0,0 +1,74 @@
# `persisted-queries` — APQ manifest support
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`persisted-queries` implements Apollo's persisted-query manifest lookup
— the ability for clients to send `{ id }` in place of `{ query }` and
have the engine resolve the id against a build-time-generated manifest.
The feature is Apollo-specific (Yoga uses a different protocol variant)
so the loader lives in this adapter and not in the abstract
`api-graphql` core.
The module exposes three entry points: a filesystem loader, an
in-memory parser for callers that already have the JSON text, and a
direct-from-entries factory for tests. Lookups against an unknown id
raise `GRAPHQL_APOLLO.PERSISTED_QUERY_NOT_FOUND`; malformed input
raises `GRAPHQL_APOLLO.PARSE_FAILED` so callers see a single
configuration-failure code for any manifest shape error.
## Features
- File loader uses `node:fs/promises.readFile` — async-safe in the host
start-up sequence.
- JSON parser validates the manifest is an object of `string → string`
before returning; non-string values raise `PARSE_FAILED`.
- Direct factory accepts any `Iterable<[id, query]>` — useful in tests.
- Manifest is frozen on construction; the only mutation surface is
`lookup(id)` (reads) and `has(id)` (existence check).
- `entries` accessor returns the frozen map for callers that need to
diff manifests across deploys.
## Usage: Basic
```ts
import { loadPersistedQueryManifest } from "@suaveplan/api-graphql-apollo/persisted-queries";
const manifest = await loadPersistedQueryManifest("./pq.json");
const query = manifest.lookup("abc123");
```
## Usage: Advanced
Build a manifest from an in-process source (e.g. a KV-store fetch) and
wire it into the engine for runtime lookup:
```ts
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
import { createPersistedQueryManifest } from "@suaveplan/api-graphql-apollo/persisted-queries";
const entries = await kv.list("pq:");
const manifest = createPersistedQueryManifest(
entries.map((e) => [e.key.slice(3), e.value as string])
);
const engine = createApolloGraphqlEngine({ persistedQueries: manifest });
```
## API Reference
- `PersistedQueryManifest` — `{ entries, lookup, has }`.
- `loadPersistedQueryManifest(path: string): Promise<PersistedQueryManifest>`
- `parsePersistedQueryManifest(raw: string): PersistedQueryManifest`
- `createPersistedQueryManifest(entries): PersistedQueryManifest`
## Implementation Notes
Manifest entries are stored in a `Map<string, string>` — O(1) lookup,
deterministic iteration order. The `entries` field returns a new `Map`
constructed from the underlying entries on every access so callers
cannot mutate the live state. The loader maps every filesystem and JSON
failure to `GRAPHQL_APOLLO.PARSE_FAILED` so wire-level diagnostics show
a single code regardless of failure mode.

View File

@@ -0,0 +1,75 @@
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GraphqlApolloError } from "../error-codes/error-codes.js";
import {
createPersistedQueryManifest,
loadPersistedQueryManifest,
parsePersistedQueryManifest,
} from "./persisted-queries.js";
describe("persisted-queries", () => {
it("looks up a known id", { timeout: 5000 }, () => {
const m = createPersistedQueryManifest([["abc", "{ ping }"]]);
expect(m.has("abc")).toBe(true);
expect(m.lookup("abc")).toBe("{ ping }");
expect(m.entries.size).toBe(1);
});
it("raises PERSISTED_QUERY_NOT_FOUND on miss", { timeout: 5000 }, () => {
const m = createPersistedQueryManifest([]);
let err: GraphqlApolloError | null = null;
try {
m.lookup("nope");
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.PERSISTED_QUERY_NOT_FOUND");
});
it("parses a JSON manifest", { timeout: 5000 }, () => {
const m = parsePersistedQueryManifest('{"a":"{ x }","b":"{ y }"}');
expect(m.lookup("a")).toBe("{ x }");
expect(m.lookup("b")).toBe("{ y }");
});
it("rejects non-JSON input", { timeout: 5000 }, () => {
expect(() => parsePersistedQueryManifest("not json")).toThrow(
GraphqlApolloError
);
});
it("rejects non-object JSON", { timeout: 5000 }, () => {
expect(() => parsePersistedQueryManifest("[]")).toThrow(
GraphqlApolloError
);
expect(() => parsePersistedQueryManifest("null")).toThrow(
GraphqlApolloError
);
});
it("rejects non-string entry values", { timeout: 5000 }, () => {
expect(() => parsePersistedQueryManifest('{"a":123}')).toThrow(
GraphqlApolloError
);
});
it("loads a manifest from disk", { timeout: 5000 }, async () => {
const dir = mkdtempSync(join(tmpdir(), "pq-"));
const file = join(dir, "manifest.json");
writeFileSync(file, '{"hello":"{ greet }"}');
const m = await loadPersistedQueryManifest(file);
expect(m.lookup("hello")).toBe("{ greet }");
});
it("raises PARSE_FAILED for missing files", { timeout: 5000 }, async () => {
let err: GraphqlApolloError | null = null;
try {
await loadPersistedQueryManifest("/no/such/path.json");
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.PARSE_FAILED");
});
});

View File

@@ -0,0 +1,110 @@
import { readFile } from "node:fs/promises";
import { GraphqlApolloError } from "../error-codes/error-codes.js";
/**
* Persisted-query manifest. A JSON record of `{ [id]: query }` produced
* by a build-time tool. The engine refuses to mount the persisted-query
* surface when `persistedQueriesPath` is undefined.
*/
export interface PersistedQueryManifest {
readonly entries: ReadonlyMap<string, string>;
lookup(id: string): string;
has(id: string): boolean;
}
/**
* Load a manifest from disk. The file MUST be a JSON object whose values
* are GraphQL query strings. Reading or parsing failures raise
* `GRAPHQL_APOLLO.PARSE_FAILED` so configuration errors surface with the
* same code path as request-document parse failures.
*/
export async function loadPersistedQueryManifest(
path: string
): Promise<PersistedQueryManifest> {
let raw: string;
try {
raw = await readFile(path, "utf8");
} catch (cause) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.PARSE_FAILED",
`failed to read persisted-query manifest at ${path}`,
{ cause: cause as Error, context: { path } }
);
}
return parsePersistedQueryManifest(raw);
}
/**
* Parse manifest JSON in-memory. Used by `loadPersistedQueryManifest` and
* by callers that already have the manifest contents (e.g. compiled into
* the bundle).
*/
export function parsePersistedQueryManifest(
raw: string
): PersistedQueryManifest {
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch (cause) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.PARSE_FAILED",
"persisted-query manifest is not valid JSON",
{ cause: cause as Error }
);
}
if (
parsed === null ||
typeof parsed !== "object" ||
Array.isArray(parsed)
) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.PARSE_FAILED",
"persisted-query manifest must be a JSON object of id → query"
);
}
const entries = new Map<string, string>();
for (const [id, value] of Object.entries(
parsed as Record<string, unknown>
)) {
if (typeof value !== "string") {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.PARSE_FAILED",
`persisted-query manifest entry "${id}" is not a string`
);
}
entries.set(id, value);
}
return makeManifest(entries);
}
/**
* Build a manifest from an explicit map. Convenient for tests and for
* callers that load the manifest from an alternative source (HTTP, KV
* store, …).
*/
export function createPersistedQueryManifest(
entries: Iterable<readonly [string, string]>
): PersistedQueryManifest {
return makeManifest(new Map(entries));
}
function makeManifest(entries: Map<string, string>): PersistedQueryManifest {
const frozen: ReadonlyMap<string, string> = new Map(entries);
return {
entries: frozen,
lookup(id: string): string {
const q = frozen.get(id);
if (q === undefined) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.PERSISTED_QUERY_NOT_FOUND",
`persisted query "${id}" not found in manifest`,
{ context: { id } }
);
}
return q;
},
has(id: string): boolean {
return frozen.has(id);
},
};
}

View File

@@ -0,0 +1,5 @@
export {
createStudioReporting,
type StudioReportingConfig,
type StudioReportingDescriptor,
} from "./studio-reporting.js";

View File

@@ -0,0 +1,78 @@
# `studio-reporting` — Apollo Studio bridge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`studio-reporting` materialises the configuration descriptor consumed
by the engine when an operator opts in to Apollo Studio usage
reporting. The module is deliberately small: it validates the supplied
`apiKey` / `graphRef` pair, returns a frozen descriptor, and lets the
engine forward the descriptor to Apollo's `ApolloServerPluginUsageReporting`
without leaking the SDK weight into the dependency closure of shops
that do not use Studio.
When the operator does not supply a configuration, the factory returns
`null` and the engine instantiates Apollo Server without the reporting
plugin — the request hot path pays nothing for the unused feature.
## Features
- Optional configuration — the factory short-circuits to `null` when
the caller passes `undefined`.
- Frozen descriptor — the returned object is immutable so downstream
code cannot mutate the API-key surface.
- Pluggable — production callers can pre-emptively swap the descriptor
for a mock plugin in a staging environment.
- zero dependency on the Apollo Studio plugin package — the
bridge is data-only.
## Usage: Basic
```ts
import { createStudioReporting } from "@suaveplan/api-graphql-apollo/studio-reporting";
const d = createStudioReporting({
apiKey: process.env.APOLLO_KEY ?? "",
graphRef: "supergraph@current",
});
```
## Usage: Advanced
Conditionally enable reporting per environment without forking the
engine wire-up code:
```ts
import { createApolloGraphqlEngine } from "@suaveplan/api-graphql-apollo";
import { createStudioReporting } from "@suaveplan/api-graphql-apollo/studio-reporting";
const studio = createStudioReporting(
process.env.APOLLO_KEY === undefined
? undefined
: {
apiKey: process.env.APOLLO_KEY,
graphRef: "supergraph@" + (process.env.STAGE ?? "current"),
}
);
const engine = createApolloGraphqlEngine({
apolloPlugins: studio === null ? [] : [/* wire Studio plugin from descriptor */],
});
```
## API Reference
- `StudioReportingConfig` — `{ apiKey, graphRef }`.
- `StudioReportingDescriptor` — `{ enabled: true, apiKey, graphRef }`.
- `createStudioReporting(config | undefined) → StudioReportingDescriptor | null`.
## Implementation Notes
The descriptor is `Object.freeze`d so callers cannot mutate it after
construction. The factory is the only entry point — there is no
mutable runtime state. The actual Studio plugin is dynamically loaded
by callers that want to wire it; this avoids pulling
`@apollo/server-plugin-usage-reporting` into the dependency closure
when the feature is off.

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createStudioReporting } from "./studio-reporting.js";
describe("studio-reporting", () => {
it("returns null when config absent", { timeout: 5000 }, () => {
expect(createStudioReporting(undefined)).toBeNull();
});
it("materialises a frozen descriptor when configured", {
timeout: 5000,
}, () => {
const d = createStudioReporting({
apiKey: "service:foo",
graphRef: "supergraph@current",
});
expect(d).not.toBeNull();
expect(d?.enabled).toBe(true);
expect(d?.apiKey).toBe("service:foo");
expect(d?.graphRef).toBe("supergraph@current");
expect(Object.isFrozen(d as object)).toBe(true);
});
});

View File

@@ -0,0 +1,38 @@
/**
* Apollo Studio usage-reporting bridge. When the host wires
* `studioReporting: { apiKey, graphRef }` we return an Apollo Server
* plugin descriptor; otherwise we return `null` and the engine omits
* the plugin entirely (keeping the wire path quiet for shops not on
* Studio).
*
* The implementation is deliberately a thin descriptor — the actual
* Apollo Studio plugin is imported lazily by the engine factory so the
* adapter does not pay the SDK weight when the feature is disabled.
*/
export interface StudioReportingConfig {
readonly apiKey: string;
readonly graphRef: string;
}
/** Marker descriptor returned to the engine factory. */
export interface StudioReportingDescriptor {
readonly enabled: true;
readonly apiKey: string;
readonly graphRef: string;
}
/**
* Materialise the configuration descriptor. Returns `null` when no
* configuration is supplied; otherwise returns a frozen descriptor the
* engine consumes to wire the Apollo Studio plugin.
*/
export function createStudioReporting(
config: StudioReportingConfig | undefined
): StudioReportingDescriptor | null {
if (config === undefined) return null;
return Object.freeze({
enabled: true,
apiKey: config.apiKey,
graphRef: config.graphRef,
});
}

View File

@@ -0,0 +1,5 @@
export {
assertSubscriptionTransport,
runSubscription,
type SubscriptionTransport,
} from "./subscriptions.js";

View File

@@ -0,0 +1,81 @@
# `subscriptions` — `graphql-ws` bridge
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`subscriptions` runs a GraphQL subscription document against a wired
schema, returning the `AsyncIterable<ExecutionResult>` the engine
forwards over the host's `api-ws` transport. Subscriptions ride
`graphql-ws` semantically; this module is the framework-agnostic core
that the engine's transport adapter wraps.
When the host has not wired an `api-ws` engine, the
`assertSubscriptionTransport` guard raises
`GRAPHQL_APOLLO.SUBSCRIPTION_TRANSPORT_UNAVAILABLE` so that the missing
infrastructure surfaces at the first subscription frame — not later
when a publish silently never reaches a subscriber.
## Features
- Direct `graphql.subscribe()` delegation — no extra framing.
- Accepts both `DocumentNode` and SDL string for the operation.
- Wraps a single-result rejection in a one-shot AsyncIterable so the
caller's frame loop sees one error frame and closes cleanly.
- Transport guard raises a typed `GraphqlApolloError` with the
`SUBSCRIPTION_TRANSPORT_UNAVAILABLE` code.
- Validates the document contains a subscription operation — non-
subscription operations raise
`GRAPHQL_APOLLO.VALIDATION_FAILED`.
## Usage: Basic
```ts
import { runSubscription } from "@suaveplan/api-graphql-apollo/subscriptions";
const iter = await runSubscription({
schema,
document: "subscription { ticks }",
contextValue: { tenantId: "t_1" },
});
for await (const frame of iter) {
console.log(frame);
}
```
## Usage: Advanced
Pair the runtime guard with the host's `api-ws` wiring so subscription
frames are rejected at the wire boundary when the transport is not
configured:
```ts
import {
assertSubscriptionTransport,
runSubscription,
} from "@suaveplan/api-graphql-apollo/subscriptions";
const transport = host.adapters.ws ?? null;
assertSubscriptionTransport(transport);
const iter = await runSubscription({ schema, document, contextValue });
```
## API Reference
- `SubscriptionTransport` — `{ protocol: "graphql-ws" }`.
- `assertSubscriptionTransport(transport | null)` — throws
`GraphqlApolloError` on miss.
- `runSubscription({ schema, document, variableValues?,
operationName?, contextValue })`.
## Implementation Notes
When `graphql.subscribe()` returns a single `ExecutionResult` (e.g.
the subscribe field threw synchronously), the helper wraps it in a
single-iteration AsyncIterable so callers' frame loops handle the
error path identically to the streaming path. The validation step
sniffs the document for any `OPERATION_DEFINITION` of kind
`subscription`; documents that contain only query/mutation operations
reject with `VALIDATION_FAILED`.

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { buildSchema, parse } from "graphql";
import { GraphqlApolloError } from "../error-codes/error-codes.js";
import {
assertSubscriptionTransport,
runSubscription,
} from "./subscriptions.js";
const schema = buildSchema(/* GraphQL */ `
type Query { ping: String! }
type Subscription { ticks: Int! }
`);
// Attach a subscribe resolver — graphql-js requires `subscribe` on the field.
const sub = schema.getSubscriptionType();
if (sub !== undefined && sub !== null) {
const fields = sub.getFields();
const ticks = fields.ticks;
if (ticks !== undefined) {
(
ticks as unknown as { subscribe: () => AsyncIterable<unknown> }
).subscribe = async function* () {
yield { ticks: 1 };
yield { ticks: 2 };
};
(ticks as unknown as { resolve: (p: unknown) => unknown }).resolve = (
payload
) => (payload as { ticks: number }).ticks;
}
}
describe("subscriptions", () => {
it("asserts transport is wired", { timeout: 5000 }, () => {
expect(() => assertSubscriptionTransport(null)).toThrow(
GraphqlApolloError
);
expect(() =>
assertSubscriptionTransport({ protocol: "graphql-ws" })
).not.toThrow();
});
it("delivers AsyncIterable for a subscription document", {
timeout: 5000,
}, async () => {
const iter = await runSubscription({
schema,
document: "subscription { ticks }",
contextValue: {},
});
const values: unknown[] = [];
for await (const v of iter) {
values.push(v);
}
expect(values).toHaveLength(2);
});
it("rejects non-subscription documents", { timeout: 5000 }, async () => {
let err: GraphqlApolloError | null = null;
try {
await runSubscription({
schema,
document: "{ ping }",
contextValue: {},
});
} catch (e) {
err = e as GraphqlApolloError;
}
expect(err?.code).toBe("GRAPHQL_APOLLO.VALIDATION_FAILED");
});
it("accepts a pre-parsed DocumentNode", { timeout: 5000 }, async () => {
const iter = await runSubscription({
schema,
document: parse("subscription { ticks }"),
contextValue: {},
});
const values: unknown[] = [];
for await (const v of iter) values.push(v);
expect(values).toHaveLength(2);
});
it("wraps single ExecutionResult when subscribe yields a single result", {
timeout: 5000,
}, async () => {
// Build a schema with a broken subscribe to force single-result path.
const broken = buildSchema(/* GraphQL */ `
type Query { ping: String! }
type Subscription { broken: Int! }
`);
const s2 = broken.getSubscriptionType();
if (s2 !== undefined && s2 !== null) {
const f = s2.getFields().broken;
if (f !== undefined) {
(f as unknown as { subscribe: () => unknown }).subscribe =
() => {
throw new Error("boom");
};
}
}
const iter = await runSubscription({
schema: broken,
document: "subscription { broken }",
variableValues: {},
operationName: undefined,
contextValue: {},
});
const out: unknown[] = [];
for await (const v of iter) out.push(v);
expect(out).toHaveLength(1);
});
});

View File

@@ -0,0 +1,84 @@
import {
type DocumentNode,
type ExecutionResult,
type GraphQLSchema,
Kind,
parse,
subscribe,
} from "graphql";
import { GraphqlApolloError } from "../error-codes/error-codes.js";
/**
* Engine-side `graphql-ws` bridge marker. The host's `api-ws` engine
* decodes the wire frames and hands them to {@link runSubscription} —
* we delegate to `graphql`'s `subscribe()` and forward the resulting
* `AsyncIterable` upstream.
*/
export interface SubscriptionTransport {
readonly protocol: "graphql-ws";
}
/**
* Assert that subscription transport is wired. The engine raises
* `GRAPHQL_APOLLO.SUBSCRIPTION_TRANSPORT_UNAVAILABLE` when a
* subscription operation arrives without a wired transport.
*/
export function assertSubscriptionTransport(
transport: SubscriptionTransport | null | undefined
): asserts transport is SubscriptionTransport {
if (transport === undefined || transport === null) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.SUBSCRIPTION_TRANSPORT_UNAVAILABLE",
"subscription operation received but no api-ws transport is wired"
);
}
}
/**
* Execute a subscription against the given schema, returning the
* iterable the engine forwards over `api-ws`. The `contextValue` is
* the {@link import("@suaveplan/api-types/plugin-context").RequestContext}
* the host built around the inbound frame.
*/
export async function runSubscription(args: {
readonly schema: GraphQLSchema;
readonly document: DocumentNode | string;
readonly variableValues?: Readonly<Record<string, unknown>>;
readonly operationName?: string;
readonly contextValue: unknown;
}): Promise<AsyncIterable<ExecutionResult>> {
const document =
typeof args.document === "string"
? parse(args.document)
: args.document;
const op = document.definitions.find(
(d) =>
d.kind === Kind.OPERATION_DEFINITION &&
d.operation === "subscription"
);
if (op === undefined) {
throw new GraphqlApolloError(
"GRAPHQL_APOLLO.VALIDATION_FAILED",
"runSubscription requires a subscription operation in the document"
);
}
const result = await subscribe({
schema: args.schema,
document,
variableValues: args.variableValues as
| Record<string, unknown>
| undefined,
operationName: args.operationName,
contextValue: args.contextValue,
});
if (Symbol.asyncIterator in (result as object)) {
return result as AsyncIterable<ExecutionResult>;
}
return singleResult(result as ExecutionResult);
}
async function* singleResult(
result: ExecutionResult
): AsyncIterable<ExecutionResult> {
yield result;
}

View File

@@ -0,0 +1,62 @@
# `testing/conformance` — Apollo-bound conformance harness
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`testing/conformance` is the re-export of `runGraphqlEngineConformance`
from `@suaveplan/api-graphql/testing`, pre-bound to the Apollo factory.
Consumers call `runApolloConformance()` and the harness exercises the
abstract `GraphqlEngine` contract against the concrete Apollo adapter.
Both `api-graphql-apollo` and `api-graphql-yoga` MUST pass the same
invariants — operator behaviour is engine-agnostic at the contract
boundary.
The module also re-exports the unmodified `runGraphqlEngineConformance`
so downstream packages that want to run the harness against a
*different* factory (e.g. a federated subgraph composing Apollo + a
custom transport) keep a single import surface.
## Features
- One-line invocation — `runApolloConformance()` runs every scenario.
- Pre-bound factory — callers do not need to know Apollo's
construction args.
- Labelled — the second-positional `label` parameter shows up in
test output so split CI logs are unambiguous.
- Re-exports `runGraphqlEngineConformance` for advanced callers.
## Usage: Basic
```ts
import { runApolloConformance } from "@suaveplan/api-graphql-apollo/testing";
runApolloConformance();
```
## Usage: Advanced
Run the conformance suite under a custom label so two adapter variants
appear distinctly in CI output:
```ts
import { runApolloConformance } from "@suaveplan/api-graphql-apollo/testing";
runApolloConformance("apollo (persisted-queries)");
runApolloConformance("apollo (no-persisted-queries)");
```
## API Reference
- `runApolloConformance(label?: string): void`.
- `runGraphqlEngineConformance(opts): void` — unchanged re-export.
## Implementation Notes
The Apollo-bound factory yields a fresh engine per scenario so each
invariant exercises a clean lifecycle. The `makeRequestContext` slot
is the cast `{} as RequestContext` — sufficient for the abstract
contract because the harness never touches context fields beyond
hand-off. Tests that need a richer context construct one explicitly.

View File

@@ -0,0 +1,17 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
runApolloConformance,
runGraphqlEngineConformance,
} from "./conformance.js";
describe("testing/conformance re-export", () => {
it("re-exports the abstract harness", { timeout: 5000 }, () => {
expect(typeof runGraphqlEngineConformance).toBe("function");
expect(typeof runApolloConformance).toBe("function");
});
});
// Invoke the Apollo-bound conformance to assert the engine satisfies the
// abstract contract. The harness internally calls describe()+it() so the
// scenarios surface as nested tests.
runApolloConformance();

View File

@@ -0,0 +1,27 @@
import type { ConformanceContext } from "@suaveplan/api-graphql";
import { runGraphqlEngineConformance } from "@suaveplan/api-graphql";
import { createApolloGraphqlEngine } from "../engine/apollo-engine.js";
/**
* Re-export of `runGraphqlEngineConformance` pre-bound to the Apollo
* factory. Consumers call this to run the abstract engine contract
* against the Apollo adapter — identical surface to
* `api-graphql-yoga/testing`.
*/
export function runApolloConformance(label = "apollo"): void {
runGraphqlEngineConformance({
label,
factory: async (): Promise<ConformanceContext> => {
const engine = createApolloGraphqlEngine();
return {
engine,
makeRequestContext: () =>
({}) as unknown as ConformanceContext["makeRequestContext"] extends () => infer R
? R
: never,
};
},
});
}
export { runGraphqlEngineConformance };

View File

@@ -0,0 +1,4 @@
export {
runApolloConformance,
runGraphqlEngineConformance,
} from "./conformance.js";

View File

@@ -0,0 +1,7 @@
import { webcrypto } from "node:crypto";
type WritableGlobal = { crypto?: Crypto };
const g = globalThis as WritableGlobal;
if (g.crypto === undefined) {
g.crypto = webcrypto as unknown as Crypto;
}

View File

@@ -0,0 +1,12 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"lib": ["ES2022"],
"types": ["node"]
},
"include": ["src/**/*.ts"],
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
}

View File

@@ -0,0 +1,61 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-graphql-apollo",
plugins: [
dts({
entryRoot: "src",
tsconfigPath: "./tsconfig.lib.json",
insertTypesEntry: true,
}),
],
build: {
outDir: "./dist",
emptyOutDir: true,
reportCompressedSize: true,
sourcemap: true,
lib: {
entry: {
index: "src/index.ts",
"engine/index": "src/engine/index.ts",
"options/index": "src/options/index.ts",
"endpoints/index": "src/endpoints/index.ts",
"federation/index": "src/federation/index.ts",
"guards/index": "src/guards/index.ts",
"instruments/index": "src/instruments/index.ts",
"loaders/index": "src/loaders/index.ts",
"persisted-queries/index": "src/persisted-queries/index.ts",
"studio-reporting/index": "src/studio-reporting/index.ts",
"subscriptions/index": "src/subscriptions/index.ts",
"error-codes/index": "src/error-codes/index.ts",
"testing/index": "src/testing/index.ts",
},
formats: ["es"],
},
rollupOptions: {
treeshake: true,
output: {
preserveModules: true,
preserveModulesRoot: "src",
entryFileNames: "[name].js",
},
external: [
"@apollo/server",
"@apollo/server/standalone",
"@suaveplan/api-graphql",
"@suaveplan/api-graphql/testing",
"@suaveplan/api-router",
"@suaveplan/api-types",
"@suaveplan/error",
"@suaveplan/testing",
"@suaveplan/testing/runner",
"graphql",
"tslib",
"zod",
/^node:/,
],
},
},
});

View File

@@ -0,0 +1,38 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
dedupe: ["graphql"],
},
test: {
globals: false,
environment: "node",
pool: "forks",
server: {
deps: {
inline: ["@apollo/server", "graphql"],
},
},
setupFiles: ["./tests/setup/polyfills.ts"],
include: ["src/**/*.test.ts", "tests/**/*.test.ts"],
exclude: ["dist/**", "node_modules/**"],
coverage: {
provider: "istanbul",
reporter: ["text", "html", "lcov"],
include: ["src/**/*.ts"],
exclude: [
"src/**/*.test.ts",
"src/**/*.types.ts",
"src/**/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});