Initial Commit
This commit is contained in:
90
packages/api-stack/api/api-graphql-yoga/README.md
Normal file
90
packages/api-stack/api/api-graphql-yoga/README.md
Normal file
@@ -0,0 +1,90 @@
|
||||
# @suaveplan/api-graphql-yoga — GraphQL Yoga engine adapter for @suaveplan/api-graphql. Default GraphQL engine per design doc §10.5. Implements the abstract GraphqlEngine contract from @suaveplan/api-graphql via createYogaGraphqlEngine(opts) — Zod-validated YogaGraphqlOptions, federation through @graphql-tools/schema makeExecutableSchema, schema-collision detection delegated to the canonical GRAPHQL.SCHEMA_COLLISION code, per-request DataLoader scoping bound to RequestContext, depth + complexity guards, masked errors / introspection toggles, graphql-ws subscription transport over an injected api-ws broker, OTel instruments (requests counter, request duration histogram, resolver duration histogram, subscriptions_active gauge, schema_rebuilds counter), 10 GRAPHQL_YOGA_* error codes, and a ./testing subpath re-exporting runGraphqlEngineConformance bound to the Yoga factory.
|
||||
|
||||
Stage-7 GraphQL Yoga engine adapter. **Default GraphQL engine** for the
|
||||
Suaveplan API plugin system per design doc §10.5 — Bun-friendly,
|
||||
fastest for our usage pattern, and simpler to operate than Apollo
|
||||
Server. Implements the abstract `GraphqlEngine` contract from
|
||||
`@suaveplan/api-graphql` so the same conformance suite
|
||||
(`runGraphqlEngineConformance`) passes against this adapter and any
|
||||
future engine; swapping engines is host-config-only.
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
## Documentation tree
|
||||
|
||||
- [execute-helpers](./src/engine/execute-helpers.md) — Houses the smaller execution helpers the Yoga engine uses on every request, kept out of `yoga-engine.ts` so the orchest…
|
||||
- [yoga-engine](./src/engine/yoga-engine.md) — Implements the abstract `GraphqlEngine` contract from `@suaveplan/api-graphql` over the Yoga / `graphql-js` stack. The…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the frozen `GRAPHQL_YOGA.*` error-code registry and the `GraphqlYogaError` subclass of `SuaveplanError` used th…
|
||||
- [error-codes](./src/error-codes/error-codes.md) — Defines the frozen `GRAPHQL_YOGA.*` error-code registry and the `GraphqlYogaError` subclass of `SuaveplanError` used th…
|
||||
- [federation](./src/federation/federation.md) — Aggregates plugin-side `GraphqlRegistry` contributions into a single `(MergedSchema, ResolverMap)` pair that the Yoga e…
|
||||
- [federation](./src/federation/federation.md) — Aggregates plugin-side `GraphqlRegistry` contributions into a single `(MergedSchema, ResolverMap)` pair that the Yoga e…
|
||||
- [guards](./src/guards/depth-and-complexity.md) — Implements the static depth + complexity guard that the Yoga engine runs after parsing each query and before execution.…
|
||||
- [depth-and-complexity](./src/guards/depth-and-complexity.md) — Implements the static depth + complexity guard that the Yoga engine runs after parsing each query and before execution.…
|
||||
- [instruments](./src/instruments/instruments.md) — Builds and caches the OpenTelemetry instrument bundle the Yoga GraphQL engine emits per request. The bundle covers a co…
|
||||
- [instruments](./src/instruments/instruments.md) — Builds and caches the OpenTelemetry instrument bundle the Yoga GraphQL engine emits per request. The bundle covers a co…
|
||||
- [loaders](./src/loaders/loaders.md) — Defines the per-request loader bag the Yoga engine constructs at pipeline step 7 and disposes at step 11. Two concurren…
|
||||
- [loaders](./src/loaders/loaders.md) — Defines the per-request loader bag the Yoga engine constructs at pipeline step 7 and disposes at step 11. Two concurren…
|
||||
- [options](./src/options/options.md) — Defines `YogaGraphqlOptions`, the Zod-validated configuration boundary that hosts use to wire `createYogaGraphqlEngine`…
|
||||
- [options](./src/options/options.md) — Defines `YogaGraphqlOptions`, the Zod-validated configuration boundary that hosts use to wire `createYogaGraphqlEngine`…
|
||||
- [subscriptions](./src/subscriptions/subscriptions.md) — Bridges the `WsBroker` contract from `@suaveplan/api-ws` to GraphQL `Subscription` resolvers via `AsyncIterable<Payload…
|
||||
- [subscriptions](./src/subscriptions/subscriptions.md) — Bridges the `WsBroker` contract from `@suaveplan/api-ws` to GraphQL `Subscription` resolvers via `AsyncIterable<Payload…
|
||||
- [testing](./src/testing/testing.md) — Binds the abstract `runGraphqlEngineConformance` from `@suaveplan/api-graphql/testing` to the Yoga engine factory so co…
|
||||
- [testing](./src/testing/testing.md) — Binds the abstract `runGraphqlEngineConformance` from `@suaveplan/api-graphql/testing` to the Yoga engine factory so co…
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Modules
|
||||
|
||||
- **engine** — `createYogaGraphqlEngine(config)` + helpers
|
||||
(`maskOrPassthrough`, `withTimeout`, `assertIntrospectionAllowed`,
|
||||
`wrapSubscription`).
|
||||
- **federation** — `federatePlugins(entries)` aggregating multiple
|
||||
plugin contributions into one `(MergedSchema, ResolverMap)` pair via
|
||||
`mergeSdl` from `@suaveplan/api-graphql`.
|
||||
- **options** — Zod-validated `YogaGraphqlOptions` +
|
||||
`resolveYogaGraphqlOptions` with safe defaults
|
||||
(`endpoint: "/graphql"`, `maxQueryDepth: 10`,
|
||||
`maxQueryComplexity: 1000`, `timeoutMs: 30000`, `maskedErrors: true`).
|
||||
- **guards** — `enforceGuards(doc, { maxDepth, maxComplexity })` static
|
||||
depth + complexity rejection.
|
||||
- **loaders** — `buildRequestLoaders(factories)` per-request DataLoader
|
||||
bag bound to `RequestContext`.
|
||||
- **subscriptions** — `subscribeBrokerTopic(broker, topic, tenant)`
|
||||
bridges `api-ws` to GraphQL `AsyncIterable`.
|
||||
- **instruments** — `getYogaInstruments()` OTel counter / histogram /
|
||||
gauge bundle.
|
||||
- **error-codes** — 10 `GRAPHQL_YOGA.*` codes + `GraphqlYogaError`.
|
||||
- **testing** — `runYogaConformance(label?, config?)` bound conformance
|
||||
harness.
|
||||
|
||||
## Quick start
|
||||
|
||||
```ts
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import { createYogaGraphqlEngine } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
const result = await engine.executeQuery({ query: "{ ping }" }, ctx);
|
||||
```
|
||||
|
||||
## Why this engine?
|
||||
|
||||
- **Bun runtime parity** — graphql-yoga is a thin layer over `graphql-js`
|
||||
and runs natively under Bun without polyfills.
|
||||
- **WebSocket subscriptions over `api-ws`** — `graphql-ws` protocol
|
||||
bridged to the WS broker via `subscribeBrokerTopic`.
|
||||
- **Per-request DataLoader scoping** — design doc §10.3 mandates
|
||||
per-request DataLoader instances; the `buildRequestLoaders` helper
|
||||
enforces this at the engine boundary.
|
||||
- **Depth + complexity guards** — denial-of-service rejection before
|
||||
resolvers run.
|
||||
- **Masked error mode** — flips production error messages to
|
||||
`"Unexpected error."` while preserving the `path` for log-side
|
||||
debugging.
|
||||
|
||||
## License
|
||||
|
||||
MIT — Suaveplan Team.
|
||||
|
||||
90
packages/api-stack/api/api-graphql-yoga/package.json
Normal file
90
packages/api-stack/api/api-graphql-yoga/package.json
Normal file
@@ -0,0 +1,90 @@
|
||||
{
|
||||
"name": "@suaveplan/api-graphql-yoga",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"description": "Stage-7 GraphQL Yoga engine adapter for @suaveplan/api-graphql. Default GraphQL engine per design doc §10.5. Implements the abstract GraphqlEngine contract from @suaveplan/api-graphql via createYogaGraphqlEngine(opts) — Zod-validated YogaGraphqlOptions, federation through @graphql-tools/schema makeExecutableSchema, schema-collision detection delegated to the canonical GRAPHQL.SCHEMA_COLLISION code, per-request DataLoader scoping bound to RequestContext, depth + complexity guards, masked errors / introspection toggles, graphql-ws subscription transport over an injected api-ws broker, OTel instruments (requests counter, request duration histogram, resolver duration histogram, subscriptions_active gauge, schema_rebuilds counter), 10 GRAPHQL_YOGA_* error codes, and a ./testing subpath re-exporting runGraphqlEngineConformance bound to the Yoga factory.",
|
||||
"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/yoga-engine.d.ts",
|
||||
"default": "./dist/engine/yoga-engine.js"
|
||||
}
|
||||
},
|
||||
"./options": {
|
||||
"import": {
|
||||
"types": "./dist/options/options.d.ts",
|
||||
"default": "./dist/options/options.js"
|
||||
}
|
||||
},
|
||||
"./error-codes": {
|
||||
"import": {
|
||||
"types": "./dist/error-codes/error-codes.d.ts",
|
||||
"default": "./dist/error-codes/error-codes.js"
|
||||
}
|
||||
},
|
||||
"./testing": {
|
||||
"import": {
|
||||
"types": "./dist/testing/testing.d.ts",
|
||||
"default": "./dist/testing/testing.js"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:watch": "vitest",
|
||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||
"lint": "bunx biome check src/",
|
||||
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/",
|
||||
"format": "bunx biome format --write --max-diagnostics 500 src/",
|
||||
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/",
|
||||
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
|
||||
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@graphql-tools/schema": "^10.0.6",
|
||||
"@opentelemetry/api": "catalog:observability",
|
||||
"@suaveplan/api-graphql": "workspace:^",
|
||||
"@suaveplan/api-types": "workspace:^",
|
||||
"@suaveplan/api-ws": "workspace:^",
|
||||
"@suaveplan/error": "workspace:^",
|
||||
"@suaveplan/opentelemetry": "workspace:^",
|
||||
"dataloader": "^2.2.3",
|
||||
"graphql": "^16.9.0",
|
||||
"graphql-yoga": "^5.10.4",
|
||||
"tslib": "catalog:build",
|
||||
"zod": "catalog:forms"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@opentelemetry/sdk-metrics": "catalog:observability",
|
||||
"@suaveplan/testing": "workspace:^",
|
||||
"@types/node": "catalog:",
|
||||
"@vitest/coverage-istanbul": "catalog:testing",
|
||||
"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"]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
# `execute-helpers.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Houses the smaller execution helpers the Yoga engine uses on every
|
||||
request, kept out of `yoga-engine.ts` so the orchestrator file stays
|
||||
beneath the 300-line hard limit. The helpers cover error masking,
|
||||
wall-clock timeouts, the introspection-toggle guard, and the wrapper that
|
||||
adapts GraphQL's `subscribe()` Promise into the engine's
|
||||
`AsyncIterable<GraphqlResponse>` contract.
|
||||
|
||||
The helpers are deliberately pure — they hold no state across calls — so
|
||||
the engine can compose them per-request without worrying about leakage
|
||||
across concurrent requests.
|
||||
|
||||
## Features
|
||||
|
||||
- `maskOrPassthrough` — flips messages to "Unexpected error." when
|
||||
`maskedErrors` is on; preserves `path`.
|
||||
- `withTimeout` — races a Promise against a `setTimeout`; clears the
|
||||
timer on either outcome.
|
||||
- `assertIntrospectionAllowed` — regex-rejects `__schema` / `__type`
|
||||
references when the option is off.
|
||||
- `wrapSubscription` — Promise-of-AsyncIterable to AsyncIterable.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { withTimeout } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
const result = await withTimeout(Promise.resolve("ok"), 5000);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
assertIntrospectionAllowed,
|
||||
maskOrPassthrough,
|
||||
} from "@suaveplan/api-graphql-yoga";
|
||||
import { GraphQLError } from "graphql";
|
||||
|
||||
assertIntrospectionAllowed("{ ping }", { introspection: false } as never);
|
||||
const masked = maskOrPassthrough(
|
||||
[new GraphQLError("internal db unreachable")],
|
||||
{ maskedErrors: true } as never
|
||||
);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `maskOrPassthrough(errors, options)`.
|
||||
- `withTimeout(p, timeoutMs)`.
|
||||
- `assertIntrospectionAllowed(documentSource, options)`.
|
||||
- `wrapSubscription(pending, onClose)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The introspection guard uses a regex over the raw query text rather than
|
||||
walking the AST — the goal is a fast pre-execution rejection. The regex
|
||||
catches every legal way to reference `__schema` / `__type` because GraphQL
|
||||
forbids those identifiers in any non-introspection context.
|
||||
@@ -0,0 +1,189 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { GraphQLError } from "graphql";
|
||||
import { GraphqlYogaError } from "../error-codes/error-codes.js";
|
||||
import { YOGA_GRAPHQL_DEFAULTS } from "../options/options.js";
|
||||
import {
|
||||
assertIntrospectionAllowed,
|
||||
maskOrPassthrough,
|
||||
withTimeout,
|
||||
wrapSubscription,
|
||||
} from "./execute-helpers.js";
|
||||
|
||||
describe("maskOrPassthrough", () => {
|
||||
it("masks messages when maskedErrors is true", { timeout: 5000 }, () => {
|
||||
const masked = maskOrPassthrough(
|
||||
[new GraphQLError("internal secret")],
|
||||
{ ...YOGA_GRAPHQL_DEFAULTS, maskedErrors: true }
|
||||
);
|
||||
expect(masked[0]?.message).toBe("Unexpected error.");
|
||||
});
|
||||
|
||||
it("passes messages through when maskedErrors is false", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const passed = maskOrPassthrough([new GraphQLError("real message")], {
|
||||
...YOGA_GRAPHQL_DEFAULTS,
|
||||
maskedErrors: false,
|
||||
});
|
||||
expect(passed[0]?.message).toBe("real message");
|
||||
});
|
||||
|
||||
it("preserves the path when present", { timeout: 5000 }, () => {
|
||||
const err = new GraphQLError("oops", { path: ["Query", "ping"] });
|
||||
const out = maskOrPassthrough([err], {
|
||||
...YOGA_GRAPHQL_DEFAULTS,
|
||||
maskedErrors: false,
|
||||
});
|
||||
expect(out[0]?.path).toEqual(["Query", "ping"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("withTimeout", () => {
|
||||
it("resolves when the inner promise resolves in time", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const v = await withTimeout(Promise.resolve("ok"), 1000);
|
||||
expect(v).toBe("ok");
|
||||
});
|
||||
|
||||
it("rejects with TIMEOUT when the inner promise stalls", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const stalled = new Promise<string>((resolve) => {
|
||||
setTimeout(() => resolve("late"), 200);
|
||||
});
|
||||
const error = await withTimeout(stalled, 10).catch((e: unknown) => e);
|
||||
expect((error as GraphqlYogaError).yogaCode).toBe(
|
||||
"GRAPHQL_YOGA.TIMEOUT"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects with the underlying error if it rejects first", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const error = await withTimeout(
|
||||
Promise.reject(new Error("inner")),
|
||||
1000
|
||||
).catch((e: unknown) => e);
|
||||
expect((error as Error).message).toBe("inner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("assertIntrospectionAllowed", () => {
|
||||
it("passes when introspection is on", { timeout: 5000 }, () => {
|
||||
expect(() =>
|
||||
assertIntrospectionAllowed("{ __schema { types { name } } }", {
|
||||
...YOGA_GRAPHQL_DEFAULTS,
|
||||
introspection: true,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when introspection is off and __schema appears", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
assertIntrospectionAllowed("{ __schema { types { name } } }", {
|
||||
...YOGA_GRAPHQL_DEFAULTS,
|
||||
introspection: false,
|
||||
})
|
||||
).toThrow(GraphqlYogaError);
|
||||
});
|
||||
|
||||
it("passes when introspection is off but query is a normal one", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
assertIntrospectionAllowed("{ ping }", {
|
||||
...YOGA_GRAPHQL_DEFAULTS,
|
||||
introspection: false,
|
||||
})
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wrapSubscription", () => {
|
||||
it("wraps an async-iterable result", { timeout: 5000 }, async () => {
|
||||
async function* gen() {
|
||||
yield { data: { x: 1 } };
|
||||
yield { data: { x: 2 } };
|
||||
}
|
||||
const iter = wrapSubscription(Promise.resolve(gen()), () => undefined);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
expect((await ait.next()).value).toEqual({ data: { x: 1 } });
|
||||
expect((await ait.next()).value).toEqual({ data: { x: 2 } });
|
||||
const last = await ait.next();
|
||||
expect(last.done).toBe(true);
|
||||
});
|
||||
|
||||
it("wraps a single-result fallback (validation error)", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const iter = wrapSubscription(
|
||||
Promise.resolve({ data: null, errors: [] }) as never,
|
||||
() => undefined
|
||||
);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
const first = await ait.next();
|
||||
expect(first.done).toBe(false);
|
||||
expect((first.value as { data: unknown }).data).toBe(null);
|
||||
});
|
||||
|
||||
it("return() releases via onClose and inner.return", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
let closed = 0;
|
||||
async function* gen() {
|
||||
try {
|
||||
yield { data: { x: 1 } };
|
||||
yield { data: { x: 2 } };
|
||||
} finally {
|
||||
closed += 1;
|
||||
}
|
||||
}
|
||||
const iter = wrapSubscription(Promise.resolve(gen()), () => {
|
||||
closed += 10;
|
||||
});
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
await ait.next();
|
||||
const r = ait.return !== undefined ? await ait.return() : undefined;
|
||||
expect(r?.done).toBe(true);
|
||||
expect(closed).toBeGreaterThanOrEqual(10);
|
||||
});
|
||||
|
||||
it("after a single-result fallback yields done on subsequent next", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const iter = wrapSubscription(
|
||||
Promise.resolve({ data: { x: 1 } } as never),
|
||||
() => undefined
|
||||
);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
const first = await ait.next();
|
||||
expect(first.done).toBe(false);
|
||||
const second = await ait.next();
|
||||
expect(second.done).toBe(true);
|
||||
});
|
||||
|
||||
it("handles null data in async-iterable step", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
async function* gen() {
|
||||
yield { data: null };
|
||||
}
|
||||
const iter = wrapSubscription(Promise.resolve(gen()), () => undefined);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
const step = await ait.next();
|
||||
expect((step.value as { data: unknown }).data).toBe(null);
|
||||
});
|
||||
|
||||
it("return() before any next() is safe", { timeout: 5000 }, async () => {
|
||||
const iter = wrapSubscription(
|
||||
Promise.resolve({ data: null } as never),
|
||||
() => undefined
|
||||
);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
const r = ait.return !== undefined ? await ait.return() : undefined;
|
||||
expect(r?.done).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { GraphqlResponse } from "@suaveplan/api-graphql";
|
||||
import type { ExecutionResult, GraphQLError } from "graphql";
|
||||
import {
|
||||
GRAPHQL_YOGA_CODES,
|
||||
GraphqlYogaError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import type { ResolvedYogaGraphqlOptions } from "../options/options.js";
|
||||
|
||||
/**
|
||||
* Convert a GraphQL execution result's errors into the `GraphqlResponse`
|
||||
* shape, masking message text when `maskedErrors` is enabled. Path is
|
||||
* passed through verbatim — it never leaks resolver internals.
|
||||
*/
|
||||
export function maskOrPassthrough(
|
||||
errors: ReadonlyArray<GraphQLError>,
|
||||
options: ResolvedYogaGraphqlOptions
|
||||
): ReadonlyArray<{
|
||||
readonly message: string;
|
||||
readonly path?: ReadonlyArray<string | number>;
|
||||
}> {
|
||||
return errors.map((e) => ({
|
||||
message: options.maskedErrors ? "Unexpected error." : e.message,
|
||||
...(e.path !== undefined
|
||||
? { path: e.path as ReadonlyArray<string | number> }
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Race a promise against a wall-clock timeout. On expiry, reject with
|
||||
* `GRAPHQL_YOGA.TIMEOUT` so the engine's catch-all can publish the
|
||||
* `outcome: "error"` counter.
|
||||
*/
|
||||
export function withTimeout<T>(p: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
return new Promise<T>((resolveP, rejectP) => {
|
||||
const t = setTimeout(() => {
|
||||
rejectP(
|
||||
new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.TIMEOUT,
|
||||
`request exceeded timeoutMs=${timeoutMs}`
|
||||
)
|
||||
);
|
||||
}, timeoutMs);
|
||||
p.then(
|
||||
(v) => {
|
||||
clearTimeout(t);
|
||||
resolveP(v);
|
||||
},
|
||||
(err) => {
|
||||
clearTimeout(t);
|
||||
rejectP(err);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Reject queries that reference `__schema` / `__type` when introspection is off. */
|
||||
export function assertIntrospectionAllowed(
|
||||
documentSource: string,
|
||||
options: ResolvedYogaGraphqlOptions
|
||||
): void {
|
||||
if (options.introspection) return;
|
||||
if (/__schema|__type/.test(documentSource)) {
|
||||
throw new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.INTROSPECTION_DENIED,
|
||||
"introspection is disabled on this engine"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a pending GraphQL subscription Promise (which yields either an
|
||||
* `AsyncIterable<ExecutionResult>` on success or an `ExecutionResult` for
|
||||
* the validation-error case) into a plain `AsyncIterable<GraphqlResponse>`
|
||||
* that the host can hand straight to `graphql-ws`.
|
||||
*/
|
||||
export function wrapSubscription(
|
||||
pending: Promise<AsyncIterable<ExecutionResult> | ExecutionResult>,
|
||||
onClose: () => void
|
||||
): AsyncIterable<GraphqlResponse> {
|
||||
return {
|
||||
[Symbol.asyncIterator](): AsyncIterator<GraphqlResponse> {
|
||||
let inner: AsyncIterator<ExecutionResult> | null = null;
|
||||
let resolved = false;
|
||||
return {
|
||||
async next(): Promise<IteratorResult<GraphqlResponse>> {
|
||||
if (!resolved) {
|
||||
const r = await pending;
|
||||
resolved = true;
|
||||
if (Symbol.asyncIterator in (r as object)) {
|
||||
inner = (r as AsyncIterable<ExecutionResult>)[
|
||||
Symbol.asyncIterator
|
||||
]();
|
||||
} else {
|
||||
const single = r as ExecutionResult;
|
||||
return {
|
||||
value: {
|
||||
data: (single.data ?? null) as Readonly<
|
||||
Record<string, unknown>
|
||||
> | null,
|
||||
...(single.errors !== undefined
|
||||
? {
|
||||
errors: single.errors.map(
|
||||
(e) => ({
|
||||
message: e.message,
|
||||
})
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (inner === null) {
|
||||
return { value: undefined as never, done: true };
|
||||
}
|
||||
const step = await inner.next();
|
||||
if (step.done === true) {
|
||||
onClose();
|
||||
return { value: undefined as never, done: true };
|
||||
}
|
||||
return {
|
||||
value: {
|
||||
data: (step.value.data ?? null) as Readonly<
|
||||
Record<string, unknown>
|
||||
> | null,
|
||||
},
|
||||
done: false,
|
||||
};
|
||||
},
|
||||
async return(): Promise<IteratorResult<GraphqlResponse>> {
|
||||
onClose();
|
||||
if (inner !== null && inner.return !== undefined) {
|
||||
await inner.return();
|
||||
}
|
||||
return { value: undefined as never, done: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
11
packages/api-stack/api/api-graphql-yoga/src/engine/index.ts
Normal file
11
packages/api-stack/api/api-graphql-yoga/src/engine/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
export {
|
||||
assertIntrospectionAllowed,
|
||||
maskOrPassthrough,
|
||||
withTimeout,
|
||||
wrapSubscription,
|
||||
} from "./execute-helpers.js";
|
||||
export {
|
||||
createYogaGraphqlEngine,
|
||||
type YogaEngineConfig,
|
||||
type YogaGraphqlEngine,
|
||||
} from "./yoga-engine.js";
|
||||
@@ -0,0 +1,100 @@
|
||||
# `yoga-engine.ts`
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the abstract `GraphqlEngine` contract from
|
||||
`@suaveplan/api-graphql` over the Yoga / `graphql-js` stack. The factory
|
||||
`createYogaGraphqlEngine(config)` returns an engine that hosts can wire
|
||||
into `api-host`'s adapter slot — it is the default GraphQL engine per
|
||||
design doc §10.5.
|
||||
|
||||
The engine handles the full pipeline for every inbound request: persisted-
|
||||
query resolution, document parse, introspection guard, depth+complexity
|
||||
guards, validate, execute (with wall-clock timeout), and OTel publish.
|
||||
`buildSchema` federates plugin contributions via
|
||||
`@graphql-tools/schema`'s `makeExecutableSchema`, increments
|
||||
`graphql.schema_rebuilds_total`, and caches the executable schema.
|
||||
`executeSubscription` parses + validates and returns the GraphQL
|
||||
subscription's `AsyncIterable`, tracking the live count via
|
||||
`activeSubscriptions()` so the observable gauge can sample it.
|
||||
|
||||
## Features
|
||||
|
||||
- Schema federation across multiple plugin registries, with
|
||||
`GRAPHQL.SCHEMA_COLLISION` raised at the abstract layer.
|
||||
- 10 `GRAPHQL_YOGA.*` codes wired into the production paths.
|
||||
- Masked-errors and introspection-toggle behaviour controllable per host.
|
||||
- Per-request OTel counter + histogram emission with masked / unmasked
|
||||
`outcome` labels.
|
||||
- Persisted-query lookup via injectable `Map<string, string>` so the host
|
||||
can hot-update the registry without rebuilding the engine.
|
||||
- Observable `subscriptions_active` gauge that reflects the live count of
|
||||
`executeSubscription` iterators.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import { createYogaGraphqlEngine } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const registry = createGraphqlRegistry();
|
||||
registry.addTypes("type Query { ping: String! }");
|
||||
registry.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(registry);
|
||||
|
||||
const result = await engine.executeQuery(
|
||||
{ query: "{ ping }" },
|
||||
{} as never
|
||||
);
|
||||
// result.data.ping === "pong"
|
||||
await engine.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import {
|
||||
createYogaGraphqlEngine,
|
||||
federatePlugins,
|
||||
} from "@suaveplan/api-graphql-yoga";
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
|
||||
|
||||
const broker = createInMemoryWsBroker();
|
||||
const billing = createGraphqlRegistry();
|
||||
billing.addTypes("extend type Query { invoices: [String!]! }");
|
||||
billing.addResolvers({ Query: { invoices: () => ["i_1"] } });
|
||||
|
||||
const engine = createYogaGraphqlEngine({
|
||||
options: { maxQueryDepth: 8, maxQueryComplexity: 250 },
|
||||
entries: [{ plugin: "billing", registry: billing as never }],
|
||||
wsBroker: broker,
|
||||
persistedQueries: new Map([["pq_1", "{ invoices }"]]),
|
||||
});
|
||||
const inline = createGraphqlRegistry();
|
||||
inline.addTypes("type Query { ping: String! }");
|
||||
inline.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(inline);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `YogaEngineConfig` — `{ options?, entries?, wsBroker?, persistedQueries? }`.
|
||||
- `YogaGraphqlEngine<Caps>` — engine handle extending
|
||||
`GraphqlEngine<Caps>`.
|
||||
- `createYogaGraphqlEngine<Caps>(config?)` — factory.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`buildSchema` accepts a runtime-supplied registry alongside the pre-
|
||||
registered `config.entries`; the runtime registry is appended as a final
|
||||
`__inline__` plugin so callers that don't manage entries up-front (e.g.
|
||||
the conformance harness) still drive the engine through the same path.
|
||||
|
||||
The introspection guard runs against the raw query text rather than the
|
||||
parsed AST so that pre-parse, pre-guard rejection paths still produce a
|
||||
deterministic `GRAPHQL_YOGA.INTROSPECTION_DENIED` instead of leaking
|
||||
parse errors. The depth/complexity guard ALWAYS runs before `validate()`
|
||||
so denial-of-service queries cost a parse + a walk, not a parse +
|
||||
validate + execute.
|
||||
@@ -0,0 +1,6 @@
|
||||
import { describe } from "@suaveplan/testing/runner";
|
||||
import { runYogaConformance } from "../testing/testing.js";
|
||||
|
||||
describe("createYogaGraphqlEngine passes runGraphqlEngineConformance", () => {
|
||||
runYogaConformance();
|
||||
});
|
||||
@@ -0,0 +1,361 @@
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { GraphqlYogaError } from "../error-codes/error-codes.js";
|
||||
import { createYogaGraphqlEngine } from "./yoga-engine.js";
|
||||
|
||||
function stubCtx(): RequestContext {
|
||||
return {} as RequestContext;
|
||||
}
|
||||
|
||||
describe("createYogaGraphqlEngine — happy path", () => {
|
||||
it("executes a simple query through the executable schema", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
const result = await engine.executeQuery(
|
||||
{ query: "{ ping }" },
|
||||
stubCtx()
|
||||
);
|
||||
expect((result.data as { ping: string }).ping).toBe("pong");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("buildSchema rebuilds the executable schema on each call", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
expect(engine.schema).not.toBeNull();
|
||||
});
|
||||
|
||||
it("dispose is idempotent and clears the schema", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
await engine.dispose();
|
||||
await engine.dispose();
|
||||
expect(engine.schema).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createYogaGraphqlEngine — error paths", () => {
|
||||
it("raises SCHEMA_BUILD_FAILED when executeQuery runs before buildSchema", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
await expect(
|
||||
engine.executeQuery({ query: "{ ping }" }, stubCtx())
|
||||
).rejects.toBeInstanceOf(GraphqlYogaError);
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("raises PARSE_FAILED on a malformed document", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
const error = await engine
|
||||
.executeQuery({ query: "{ malformed" }, stubCtx())
|
||||
.catch((e: unknown) => e);
|
||||
expect(error).toBeInstanceOf(GraphqlYogaError);
|
||||
expect((error as GraphqlYogaError).yogaCode).toBe(
|
||||
"GRAPHQL_YOGA.PARSE_FAILED"
|
||||
);
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("raises VALIDATION_FAILED on an unknown field", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
const error = await engine
|
||||
.executeQuery({ query: "{ nope }" }, stubCtx())
|
||||
.catch((e: unknown) => e);
|
||||
expect((error as GraphqlYogaError).yogaCode).toBe(
|
||||
"GRAPHQL_YOGA.VALIDATION_FAILED"
|
||||
);
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("raises INTROSPECTION_DENIED when introspection is off", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine({
|
||||
options: { introspection: false },
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
const error = await engine
|
||||
.executeQuery(
|
||||
{ query: "{ __schema { types { name } } }" },
|
||||
stubCtx()
|
||||
)
|
||||
.catch((e: unknown) => e);
|
||||
expect((error as GraphqlYogaError).yogaCode).toBe(
|
||||
"GRAPHQL_YOGA.INTROSPECTION_DENIED"
|
||||
);
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("raises PERSISTED_QUERY_NOT_FOUND when id is unknown", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
const error = await engine
|
||||
.executeQuery(
|
||||
{ query: "", persistedQueryId: "missing" } as never,
|
||||
stubCtx()
|
||||
)
|
||||
.catch((e: unknown) => e);
|
||||
expect((error as GraphqlYogaError).yogaCode).toBe(
|
||||
"GRAPHQL_YOGA.PERSISTED_QUERY_NOT_FOUND"
|
||||
);
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("resolves a persisted query when the id is registered", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine({
|
||||
persistedQueries: new Map([["pq1", "{ ping }"]]),
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
const result = await engine.executeQuery(
|
||||
{ query: "", persistedQueryId: "pq1" } as never,
|
||||
stubCtx()
|
||||
);
|
||||
expect((result.data as { ping: string }).ping).toBe("pong");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("raises RESOLVER_THREW when a resolver throws a non-Yoga error", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine({
|
||||
options: { maskedErrors: false },
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({
|
||||
Query: {
|
||||
ping: () => {
|
||||
throw new Error("kaboom");
|
||||
},
|
||||
},
|
||||
});
|
||||
engine.buildSchema(reg);
|
||||
const result = await engine.executeQuery(
|
||||
{ query: "{ ping }" },
|
||||
stubCtx()
|
||||
);
|
||||
expect(result.errors?.[0]?.message).toContain("kaboom");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("masks resolver error messages when maskedErrors is on", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine({
|
||||
options: { maskedErrors: true },
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({
|
||||
Query: {
|
||||
ping: () => {
|
||||
throw new Error("internal secret");
|
||||
},
|
||||
},
|
||||
});
|
||||
engine.buildSchema(reg);
|
||||
const result = await engine.executeQuery(
|
||||
{ query: "{ ping }" },
|
||||
stubCtx()
|
||||
);
|
||||
expect(result.errors?.[0]?.message).toBe("Unexpected error.");
|
||||
await engine.dispose();
|
||||
});
|
||||
|
||||
it("wraps non-Yoga thrown errors as RESOLVER_THREW", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine({
|
||||
options: { maskedErrors: false, timeoutMs: 30000 },
|
||||
});
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
engine.buildSchema(reg);
|
||||
// Force the engine into the non-Yoga catch path by passing a Proxy
|
||||
// request where the `query` getter returns a string the first time
|
||||
// (so parse succeeds) but then the engine reads `variables` and the
|
||||
// Proxy throws a plain TypeError that escapes execute().
|
||||
const req = new Proxy(
|
||||
{ query: "{ ping }", variables: undefined as unknown },
|
||||
{
|
||||
get(target: Record<string, unknown>, key: string): unknown {
|
||||
if (key === "variables") {
|
||||
throw new TypeError("synthetic non-yoga failure");
|
||||
}
|
||||
return target[key];
|
||||
},
|
||||
}
|
||||
);
|
||||
const error = await engine
|
||||
.executeQuery(req as never, stubCtx())
|
||||
.catch((e: unknown) => e);
|
||||
expect((error as GraphqlYogaError).yogaCode).toBe(
|
||||
"GRAPHQL_YOGA.RESOLVER_THREW"
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps non-Error throws as RESOLVER_THREW with string message", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "p" } });
|
||||
engine.buildSchema(reg);
|
||||
const req = new Proxy(
|
||||
{ query: "{ ping }", variables: undefined as unknown },
|
||||
{
|
||||
get(target: Record<string, unknown>, key: string): unknown {
|
||||
if (key === "variables") {
|
||||
const e: unknown = "primitive failure";
|
||||
throw e;
|
||||
}
|
||||
return target[key];
|
||||
},
|
||||
}
|
||||
);
|
||||
const error = await engine
|
||||
.executeQuery(req as never, stubCtx())
|
||||
.catch((e: unknown) => e);
|
||||
expect((error as GraphqlYogaError).yogaCode).toBe(
|
||||
"GRAPHQL_YOGA.RESOLVER_THREW"
|
||||
);
|
||||
expect((error as GraphqlYogaError).message).toBe("primitive failure");
|
||||
});
|
||||
|
||||
it("subscribe surfaces an ExecutionResult with errors when subscribe throws", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes(
|
||||
"type Query { ping: String! } type Subscription { tick: Int! }"
|
||||
);
|
||||
reg.addResolvers({
|
||||
Query: { ping: () => "p" },
|
||||
Subscription: {
|
||||
tick: {
|
||||
subscribe: () => {
|
||||
throw new Error("sync-subscribe-throw");
|
||||
},
|
||||
resolve: (v: number) => v,
|
||||
},
|
||||
},
|
||||
});
|
||||
engine.buildSchema(reg);
|
||||
const iter = engine.executeSubscription(
|
||||
{ query: "subscription { tick }" },
|
||||
stubCtx()
|
||||
);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
const step = await ait.next();
|
||||
const errs = (
|
||||
step.value as {
|
||||
errors?: ReadonlyArray<{ message: string }>;
|
||||
}
|
||||
).errors;
|
||||
expect(errs?.[0]?.message).toContain("sync-subscribe-throw");
|
||||
});
|
||||
|
||||
it("executeSubscription raises VALIDATION_FAILED on unknown field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes(
|
||||
"type Query { ping: String! } type Subscription { tick: Int! }"
|
||||
);
|
||||
reg.addResolvers({ Query: { ping: () => "p" } });
|
||||
engine.buildSchema(reg);
|
||||
expect(() =>
|
||||
engine.executeSubscription(
|
||||
{ query: "subscription { missing }" },
|
||||
stubCtx()
|
||||
)
|
||||
).toThrow(GraphqlYogaError);
|
||||
});
|
||||
|
||||
it("executeSubscription decrements activeSubscriptions on iterator close", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes(
|
||||
"type Query { ping: String! } type Subscription { tick: Int! }"
|
||||
);
|
||||
reg.addResolvers({
|
||||
Query: { ping: () => "p" },
|
||||
Subscription: {
|
||||
tick: {
|
||||
subscribe: async function* () {
|
||||
yield { tick: 1 };
|
||||
},
|
||||
resolve: (v: { tick: number }) => v.tick,
|
||||
},
|
||||
},
|
||||
});
|
||||
engine.buildSchema(reg);
|
||||
const iter = engine.executeSubscription(
|
||||
{ query: "subscription { tick }" },
|
||||
stubCtx()
|
||||
);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
const first = await ait.next();
|
||||
expect(first.done).toBe(false);
|
||||
const next = await ait.next();
|
||||
expect(next.done).toBe(true);
|
||||
expect(engine.activeSubscriptions()).toBe(0);
|
||||
});
|
||||
|
||||
it("raises SCHEMA_BUILD_FAILED on invalid SDL", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping String! }");
|
||||
expect(() => engine.buildSchema(reg)).toThrow(GraphqlYogaError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,292 @@
|
||||
import { makeExecutableSchema } from "@graphql-tools/schema";
|
||||
import type {
|
||||
GraphqlEngine,
|
||||
GraphqlRegistry,
|
||||
GraphqlRequest,
|
||||
GraphqlResponse,
|
||||
MergedSchema,
|
||||
} from "@suaveplan/api-graphql";
|
||||
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
|
||||
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
|
||||
import type { WsBroker } from "@suaveplan/api-ws";
|
||||
import {
|
||||
type ExecutionResult,
|
||||
execute,
|
||||
type GraphQLSchema,
|
||||
parse,
|
||||
subscribe,
|
||||
validate,
|
||||
} from "graphql";
|
||||
import {
|
||||
GRAPHQL_YOGA_CODES,
|
||||
GraphqlYogaError,
|
||||
} from "../error-codes/error-codes.js";
|
||||
import {
|
||||
federatePlugins,
|
||||
type PluginRegistryEntry,
|
||||
} from "../federation/federation.js";
|
||||
import { enforceGuards } from "../guards/depth-and-complexity.js";
|
||||
import { getYogaInstruments } from "../instruments/instruments.js";
|
||||
import {
|
||||
type ResolvedYogaGraphqlOptions,
|
||||
resolveYogaGraphqlOptions,
|
||||
type YogaGraphqlOptions,
|
||||
} from "../options/options.js";
|
||||
import {
|
||||
assertIntrospectionAllowed,
|
||||
maskOrPassthrough,
|
||||
withTimeout,
|
||||
wrapSubscription,
|
||||
} from "./execute-helpers.js";
|
||||
|
||||
/**
|
||||
* Configuration handed to {@link createYogaGraphqlEngine}.
|
||||
*
|
||||
* `entries` is the authoritative list of plugin contributions. `wsBroker`
|
||||
* is the wired subscription transport; `null` is acceptable when no
|
||||
* plugin manifests subscriptions.
|
||||
*/
|
||||
export interface YogaEngineConfig {
|
||||
readonly options?: YogaGraphqlOptions;
|
||||
readonly entries?: ReadonlyArray<PluginRegistryEntry>;
|
||||
readonly wsBroker?: WsBroker | null;
|
||||
readonly persistedQueries?: ReadonlyMap<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Concrete engine handle returned by {@link createYogaGraphqlEngine}.
|
||||
* Extends the abstract `GraphqlEngine` contract with introspection
|
||||
* getters that the host and tests use to assert state without poking
|
||||
* private fields.
|
||||
*/
|
||||
export interface YogaGraphqlEngine<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
> extends GraphqlEngine<Caps> {
|
||||
readonly resolvedOptions: ResolvedYogaGraphqlOptions;
|
||||
readonly schema: GraphQLSchema | null;
|
||||
activeSubscriptions(): number;
|
||||
}
|
||||
|
||||
interface RequestWithPersistedId extends GraphqlRequest {
|
||||
readonly persistedQueryId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a Yoga-backed GraphQL engine that implements the abstract
|
||||
* `GraphqlEngine` contract from `@suaveplan/api-graphql`. See
|
||||
* `engine.md` for the lifecycle walk-through.
|
||||
*/
|
||||
export function createYogaGraphqlEngine<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
>(config: YogaEngineConfig = {}): YogaGraphqlEngine<Caps> {
|
||||
const resolved = resolveYogaGraphqlOptions(config.options);
|
||||
const instruments = getYogaInstruments();
|
||||
const persisted = config.persistedQueries ?? new Map<string, string>();
|
||||
|
||||
let schema: GraphQLSchema | null = null;
|
||||
let merged: MergedSchema | null = null;
|
||||
let activeSubs = 0;
|
||||
let disposed = false;
|
||||
|
||||
instruments.subscriptionsActive.addCallback((observer) => {
|
||||
observer.observe(activeSubs);
|
||||
});
|
||||
|
||||
function ensureSchema(): GraphQLSchema {
|
||||
if (schema === null) {
|
||||
throw new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.SCHEMA_BUILD_FAILED,
|
||||
"executable schema was never built; call buildSchema first"
|
||||
);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
function parseQuery(req: GraphqlRequest): ReturnType<typeof parse> {
|
||||
let query = req.query;
|
||||
const id = (req as RequestWithPersistedId).persistedQueryId;
|
||||
if (id !== undefined && id !== "") {
|
||||
const stored = persisted.get(id);
|
||||
if (stored === undefined) {
|
||||
throw new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.PERSISTED_QUERY_NOT_FOUND,
|
||||
`no persisted query registered for id "${id}"`,
|
||||
{ context: { id } }
|
||||
);
|
||||
}
|
||||
query = stored;
|
||||
}
|
||||
try {
|
||||
return parse(query);
|
||||
} catch (cause) {
|
||||
throw new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.PARSE_FAILED,
|
||||
"GraphQL document parse failed",
|
||||
{ cause, context: { query } }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
resolvedOptions: resolved,
|
||||
get schema(): GraphQLSchema | null {
|
||||
return schema;
|
||||
},
|
||||
activeSubscriptions(): number {
|
||||
return activeSubs;
|
||||
},
|
||||
|
||||
buildSchema(registry: GraphqlRegistry<Caps>): MergedSchema {
|
||||
const entries: PluginRegistryEntry[] = [
|
||||
...(config.entries ?? []),
|
||||
{
|
||||
plugin: "__inline__",
|
||||
registry:
|
||||
registry as unknown as PluginRegistryEntry["registry"],
|
||||
},
|
||||
];
|
||||
const result = federatePlugins(entries);
|
||||
try {
|
||||
schema = makeExecutableSchema({
|
||||
typeDefs: result.merged.sdl,
|
||||
resolvers: result.resolvers,
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.SCHEMA_BUILD_FAILED,
|
||||
"makeExecutableSchema failed",
|
||||
{ cause }
|
||||
);
|
||||
}
|
||||
merged = result.merged;
|
||||
instruments.schemaRebuildsTotal.add(1);
|
||||
return merged;
|
||||
},
|
||||
|
||||
async executeQuery(
|
||||
req: GraphqlRequest,
|
||||
ctx: RequestContext<Caps>
|
||||
): Promise<GraphqlResponse> {
|
||||
const started = Date.now();
|
||||
const builtSchema = ensureSchema();
|
||||
const opName = req.operationName ?? "anonymous";
|
||||
try {
|
||||
const document = parseQuery(req);
|
||||
assertIntrospectionAllowed(req.query, resolved);
|
||||
enforceGuards(document, {
|
||||
maxDepth: resolved.maxQueryDepth,
|
||||
maxComplexity: resolved.maxQueryComplexity,
|
||||
});
|
||||
const validationErrors = validate(builtSchema, document);
|
||||
if (validationErrors.length > 0) {
|
||||
throw new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.VALIDATION_FAILED,
|
||||
validationErrors.map((e) => e.message).join("; "),
|
||||
{
|
||||
context: {
|
||||
errors: validationErrors.map((e) => e.message),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
const result = (await withTimeout(
|
||||
Promise.resolve(
|
||||
execute({
|
||||
schema: builtSchema,
|
||||
document,
|
||||
contextValue: ctx,
|
||||
variableValues: req.variables as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
operationName: req.operationName,
|
||||
})
|
||||
),
|
||||
resolved.timeoutMs
|
||||
)) as ExecutionResult;
|
||||
|
||||
instruments.requestDurationMs.record(Date.now() - started);
|
||||
const hasErrors =
|
||||
result.errors !== undefined && result.errors.length > 0;
|
||||
instruments.requestsTotal.add(1, {
|
||||
operation_type: "query",
|
||||
operation_name: opName,
|
||||
outcome: hasErrors ? "error" : "ok",
|
||||
});
|
||||
return {
|
||||
data: (result.data ?? null) as Readonly<
|
||||
Record<string, unknown>
|
||||
> | null,
|
||||
...(result.errors !== undefined
|
||||
? {
|
||||
errors: maskOrPassthrough(
|
||||
result.errors,
|
||||
resolved
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
} catch (error) {
|
||||
instruments.requestDurationMs.record(Date.now() - started);
|
||||
instruments.requestsTotal.add(1, {
|
||||
operation_type: "query",
|
||||
operation_name: opName,
|
||||
outcome: "error",
|
||||
});
|
||||
if (error instanceof GraphqlYogaError) throw error;
|
||||
throw new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.RESOLVER_THREW,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
executeSubscription(
|
||||
req: GraphqlRequest,
|
||||
ctx: RequestContext<Caps>
|
||||
): AsyncIterable<GraphqlResponse> {
|
||||
const builtSchema = ensureSchema();
|
||||
const document = parseQuery(req);
|
||||
const validationErrors = validate(builtSchema, document);
|
||||
if (validationErrors.length > 0) {
|
||||
throw new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.VALIDATION_FAILED,
|
||||
validationErrors.map((e) => e.message).join("; ")
|
||||
);
|
||||
}
|
||||
activeSubs += 1;
|
||||
const promise = Promise.resolve(
|
||||
subscribe({
|
||||
schema: builtSchema,
|
||||
document,
|
||||
contextValue: ctx,
|
||||
variableValues: req.variables as
|
||||
| Record<string, unknown>
|
||||
| undefined,
|
||||
operationName: req.operationName,
|
||||
})
|
||||
);
|
||||
// Attach a silent observer so a Promise rejection does not surface
|
||||
// as an unhandled rejection when the consumer never pulls; the
|
||||
// real surface is `next()` inside wrapSubscription, which awaits
|
||||
// the same promise and propagates rejection upward.
|
||||
promise.catch(() => undefined);
|
||||
return wrapSubscription(
|
||||
promise as Promise<
|
||||
AsyncIterable<ExecutionResult> | ExecutionResult
|
||||
>,
|
||||
() => {
|
||||
activeSubs = Math.max(0, activeSubs - 1);
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
schema = null;
|
||||
merged = null;
|
||||
activeSubs = 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# `error-codes.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the frozen `GRAPHQL_YOGA.*` error-code registry and the
|
||||
`GraphqlYogaError` subclass of `SuaveplanError` used throughout the
|
||||
`@suaveplan/api-graphql-yoga` engine adapter. The registry is intentionally
|
||||
disjoint from the abstract `GRAPHQL.*` codes owned by
|
||||
`@suaveplan/api-graphql`: faults that any engine could surface (schema
|
||||
collision, missing resolver, unknown directive) live on the abstract
|
||||
namespace, while the ten codes here cover failures specific to Yoga's
|
||||
execution pipeline — document parsing, validation, depth/complexity guards,
|
||||
schema build, subscription transport, server-side timeout, introspection
|
||||
toggle, persisted-query registry, and uncaught resolver throws.
|
||||
|
||||
## Features
|
||||
|
||||
- 10 stable codes wired through `defineCodes("GRAPHQL_YOGA", ...)`.
|
||||
- Public `GraphqlYogaError` constructor (lint-rule compliant — see
|
||||
`scripts/lint/check-error-subclass-constructors.ts`).
|
||||
- `instanceof GraphqlYogaError` narrows `code` to `GraphqlYogaErrorCode`.
|
||||
- `yogaCode` accessor for callers that already discriminated.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import {
|
||||
GRAPHQL_YOGA_CODES,
|
||||
GraphqlYogaError,
|
||||
} from "@suaveplan/api-graphql-yoga/error-codes";
|
||||
|
||||
throw new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.PARSE_FAILED,
|
||||
"GraphQL document parse failed",
|
||||
{ context: { query: "{ malformed" } }
|
||||
);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { GraphqlYogaError } from "@suaveplan/api-graphql-yoga/error-codes";
|
||||
|
||||
function handle(error: unknown): Response {
|
||||
if (
|
||||
error instanceof GraphqlYogaError &&
|
||||
error.yogaCode === "GRAPHQL_YOGA.DEPTH_EXCEEDED"
|
||||
) {
|
||||
return new Response("query too deep", { status: 400 });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `GRAPHQL_YOGA_CODES` — frozen record of every code constant.
|
||||
- `GraphqlYogaErrorCode` — union of every `GRAPHQL_YOGA.<NAME>` literal.
|
||||
- `class GraphqlYogaError extends SuaveplanError<GraphqlYogaErrorCode>` —
|
||||
public constructor `(code, message, options?)`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Schema collisions across plugin contributions are raised with the abstract
|
||||
`GRAPHQL.SCHEMA_COLLISION` code from `@suaveplan/api-graphql`, NOT
|
||||
`GRAPHQL_YOGA.*` — this lets a host that switches between Apollo and Yoga
|
||||
preserve identical fault behaviour at the federation boundary.
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { GRAPHQL_YOGA_CODES, GraphqlYogaError } from "./error-codes.js";
|
||||
|
||||
describe("GRAPHQL_YOGA_CODES + GraphqlYogaError", () => {
|
||||
it("enumerates all 10 codes", { timeout: 5000 }, () => {
|
||||
const codes = Object.values(GRAPHQL_YOGA_CODES);
|
||||
expect(codes).toHaveLength(10);
|
||||
expect(codes).toContain("GRAPHQL_YOGA.PARSE_FAILED");
|
||||
expect(codes).toContain("GRAPHQL_YOGA.VALIDATION_FAILED");
|
||||
expect(codes).toContain("GRAPHQL_YOGA.DEPTH_EXCEEDED");
|
||||
expect(codes).toContain("GRAPHQL_YOGA.COMPLEXITY_EXCEEDED");
|
||||
expect(codes).toContain("GRAPHQL_YOGA.SCHEMA_BUILD_FAILED");
|
||||
expect(codes).toContain(
|
||||
"GRAPHQL_YOGA.SUBSCRIPTION_TRANSPORT_UNAVAILABLE"
|
||||
);
|
||||
expect(codes).toContain("GRAPHQL_YOGA.TIMEOUT");
|
||||
expect(codes).toContain("GRAPHQL_YOGA.INTROSPECTION_DENIED");
|
||||
expect(codes).toContain("GRAPHQL_YOGA.PERSISTED_QUERY_NOT_FOUND");
|
||||
expect(codes).toContain("GRAPHQL_YOGA.RESOLVER_THREW");
|
||||
});
|
||||
|
||||
it("constructs an error with code, message, and context", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const error = new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.PARSE_FAILED,
|
||||
"boom",
|
||||
{ context: { query: "{ malformed" } }
|
||||
);
|
||||
expect(error.code).toBe("GRAPHQL_YOGA.PARSE_FAILED");
|
||||
expect(error.yogaCode).toBe("GRAPHQL_YOGA.PARSE_FAILED");
|
||||
expect(error.message).toBe("boom");
|
||||
expect((error.context as { query: string }).query).toBe("{ malformed");
|
||||
});
|
||||
|
||||
it("narrows via instanceof", { timeout: 5000 }, () => {
|
||||
const error: unknown = new GraphqlYogaError(
|
||||
GRAPHQL_YOGA_CODES.TIMEOUT,
|
||||
"slow"
|
||||
);
|
||||
if (error instanceof GraphqlYogaError) {
|
||||
expect(error.yogaCode).toBe("GRAPHQL_YOGA.TIMEOUT");
|
||||
return;
|
||||
}
|
||||
throw new Error("expected GraphqlYogaError");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { SuaveplanErrorOptions } from "@suaveplan/error";
|
||||
import { defineCodes, SuaveplanError } from "@suaveplan/error";
|
||||
|
||||
/**
|
||||
* Frozen `GRAPHQL_YOGA.*` error-code registry for `@suaveplan/api-graphql-yoga`.
|
||||
*
|
||||
* Codes are engine-specific failures that have no equivalent in the abstract
|
||||
* `@suaveplan/api-graphql` contract. Schema-collision is intentionally NOT
|
||||
* listed here — that fault belongs to the abstract layer and surfaces as
|
||||
* `GRAPHQL.SCHEMA_COLLISION`.
|
||||
*
|
||||
* - `PARSE_FAILED` — the request `query` string failed `parse()` at the
|
||||
* GraphQL document level. The bad query is attached via `context.query`.
|
||||
* - `VALIDATION_FAILED` — `validate()` produced one or more rule failures.
|
||||
* - `DEPTH_EXCEEDED` — query exceeded the configured `maxQueryDepth`.
|
||||
* - `COMPLEXITY_EXCEEDED` — query exceeded the configured
|
||||
* `maxQueryComplexity` (computed as the count of selection nodes).
|
||||
* - `SCHEMA_BUILD_FAILED` — `makeExecutableSchema` could not consume the
|
||||
* merged SDL; the underlying error is preserved via `cause`.
|
||||
* - `SUBSCRIPTION_TRANSPORT_UNAVAILABLE` — `executeSubscription` was
|
||||
* invoked but no `wsBroker` was configured on the engine.
|
||||
* - `TIMEOUT` — the request exceeded its server-side `timeoutMs` budget.
|
||||
* - `INTROSPECTION_DENIED` — introspection query rejected because
|
||||
* `introspection: false`.
|
||||
* - `PERSISTED_QUERY_NOT_FOUND` — request referenced a `persistedQueryId`
|
||||
* that was not registered with the engine.
|
||||
* - `RESOLVER_THREW` — a user resolver threw a non-`SuaveplanError`; the
|
||||
* original is attached via `cause`.
|
||||
*/
|
||||
export const GRAPHQL_YOGA_CODES = defineCodes("GRAPHQL_YOGA", [
|
||||
"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 GraphqlYogaErrorCode =
|
||||
(typeof GRAPHQL_YOGA_CODES)[keyof typeof GRAPHQL_YOGA_CODES];
|
||||
|
||||
/**
|
||||
* Concrete error class for the api-graphql-yoga package. Extends
|
||||
* {@link SuaveplanError} so `instanceof GraphqlYogaError` narrows `code` to
|
||||
* {@link GraphqlYogaErrorCode}.
|
||||
*/
|
||||
export class GraphqlYogaError extends SuaveplanError<GraphqlYogaErrorCode> {
|
||||
public readonly yogaCode: GraphqlYogaErrorCode;
|
||||
|
||||
public constructor(
|
||||
code: GraphqlYogaErrorCode,
|
||||
message: string,
|
||||
options?: SuaveplanErrorOptions
|
||||
) {
|
||||
super(code, message, options);
|
||||
this.yogaCode = code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
GRAPHQL_YOGA_CODES,
|
||||
GraphqlYogaError,
|
||||
type GraphqlYogaErrorCode,
|
||||
} from "./error-codes.js";
|
||||
@@ -0,0 +1,75 @@
|
||||
# `federation.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Aggregates plugin-side `GraphqlRegistry` contributions into a single
|
||||
`(MergedSchema, ResolverMap)` pair that the Yoga engine hands to
|
||||
`@graphql-tools/schema`'s `makeExecutableSchema`. The module is a thin
|
||||
shim around the abstract `mergeSdl(...)` from `@suaveplan/api-graphql` —
|
||||
the real federation rules and collision detection live there, on the
|
||||
abstract layer, so Yoga and the eventual Apollo adapter share identical
|
||||
fault behaviour.
|
||||
|
||||
## Features
|
||||
|
||||
- Calls `registry.freeze()` once per plugin to snapshot SDL + resolver
|
||||
maps.
|
||||
- Wraps `mergeSdl(...)` so `GRAPHQL.SCHEMA_COLLISION` propagates
|
||||
unchanged.
|
||||
- Combines per-plugin `ResolverMap` objects via shallow object-spread,
|
||||
layer-by-layer, so multiple `addResolvers` calls from the same plugin
|
||||
stack in declaration order.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import { federatePlugins } from "@suaveplan/api-graphql-yoga/federation";
|
||||
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes("type Query { ping: String! }");
|
||||
reg.addResolvers({ Query: { ping: () => "pong" } });
|
||||
|
||||
const result = federatePlugins([{ plugin: "ping", registry: reg }]);
|
||||
// result.merged.sdl contains "type Query { ping: String! }"
|
||||
// result.resolvers.Query.ping is the resolver function
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import { federatePlugins } from "@suaveplan/api-graphql-yoga/federation";
|
||||
|
||||
const billing = createGraphqlRegistry();
|
||||
billing.addTypes("extend type Query { invoices: [String!]! }");
|
||||
billing.addResolvers({ Query: { invoices: () => ["i_1"] } });
|
||||
|
||||
const crm = createGraphqlRegistry();
|
||||
crm.addTypes("extend type Query { customers: [String!]! }");
|
||||
crm.addResolvers({ Query: { customers: () => ["c_1"] } });
|
||||
|
||||
const result = federatePlugins([
|
||||
{ plugin: "billing", registry: billing },
|
||||
{ plugin: "crm", registry: crm },
|
||||
]);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `PluginRegistryEntry` — `{ plugin, registry, namespace? }`.
|
||||
- `FederationResult` — `{ merged: MergedSchema, resolvers: ... }`.
|
||||
- `federatePlugins(entries)` — aggregator.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
When two plugins extend the same type with the same field name, the
|
||||
abstract `mergeSdl(...)` already raises `GRAPHQL.SCHEMA_COLLISION` before
|
||||
the resolver-map merge runs — that is why the resolver loop here can
|
||||
shallow-spread without conflict-detection: by the time it executes,
|
||||
the SDL layer has guaranteed every `(Type, field)` belongs to exactly one
|
||||
plugin.
|
||||
@@ -0,0 +1,61 @@
|
||||
import { createGraphqlRegistry, GraphqlError } from "@suaveplan/api-graphql";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { federatePlugins } from "./federation.js";
|
||||
|
||||
describe("federatePlugins", () => {
|
||||
it("two plugins each extending Query both expose their fields", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const billing = createGraphqlRegistry();
|
||||
billing.addTypes(
|
||||
"type Query { _root: String } extend type Query { invoices: [String!]! }"
|
||||
);
|
||||
billing.addResolvers({ Query: { invoices: () => ["i_1"] } });
|
||||
|
||||
const crm = createGraphqlRegistry();
|
||||
crm.addTypes("extend type Query { customers: [String!]! }");
|
||||
crm.addResolvers({ Query: { customers: () => ["c_1"] } });
|
||||
|
||||
const result = federatePlugins([
|
||||
{
|
||||
plugin: "billing",
|
||||
registry: billing as never,
|
||||
},
|
||||
{ plugin: "crm", registry: crm as never },
|
||||
]);
|
||||
expect(result.merged.sdl).toContain("invoices");
|
||||
expect(result.merged.sdl).toContain("customers");
|
||||
expect(result.resolvers.Query?.invoices).toBeDefined();
|
||||
expect(result.resolvers.Query?.customers).toBeDefined();
|
||||
});
|
||||
|
||||
it("same-field collision fails with GRAPHQL.SCHEMA_COLLISION", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const a = createGraphqlRegistry();
|
||||
a.addTypes("type Query { invoices: [String!]! }");
|
||||
const b = createGraphqlRegistry();
|
||||
b.addTypes("type Query { invoices: [String!]! }");
|
||||
expect(() =>
|
||||
federatePlugins([
|
||||
{ plugin: "a", registry: a as never },
|
||||
{ plugin: "b", registry: b as never },
|
||||
])
|
||||
).toThrow(GraphqlError);
|
||||
});
|
||||
|
||||
it("supports namespace wrapping", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const billing = createGraphqlRegistry();
|
||||
billing.addTypes("extend type Query { invoices: [String!]! }");
|
||||
const result = federatePlugins([
|
||||
{
|
||||
plugin: "billing",
|
||||
registry: billing as never,
|
||||
namespace: "Billing",
|
||||
},
|
||||
]);
|
||||
expect(result.merged.sdl).toContain("Billing");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import {
|
||||
type GraphqlRegistry,
|
||||
type MergedSchema,
|
||||
mergeSdl,
|
||||
type ResolverMap,
|
||||
type SdlContribution,
|
||||
} from "@suaveplan/api-graphql";
|
||||
|
||||
/**
|
||||
* Aggregate one or more plugin registries into a single `MergedSchema` +
|
||||
* combined resolver map. The Yoga engine accepts a *flat* list of
|
||||
* `(registry, plugin, namespace?)` triples — one per contributing plugin —
|
||||
* and produces:
|
||||
*
|
||||
* - `merged` — output of `mergeSdl(...)` (raises
|
||||
* `GRAPHQL.SCHEMA_COLLISION` on cross-plugin field collisions).
|
||||
* - `resolvers` — combined `ResolverMap` (Type → field → resolver) where
|
||||
* each `Type` block is a shallow object-spread of every contributing
|
||||
* plugin's block. Within a single Type, fields contributed by the same
|
||||
* plugin via multiple `addResolvers` calls layer in order.
|
||||
*/
|
||||
export interface PluginRegistryEntry {
|
||||
readonly plugin: string;
|
||||
readonly registry: FreezableRegistry;
|
||||
readonly namespace?: string;
|
||||
}
|
||||
|
||||
type FreezableRegistry = GraphqlRegistry & {
|
||||
readonly freeze: () => {
|
||||
readonly typeDefs: ReadonlyArray<string>;
|
||||
readonly resolvers: ReadonlyArray<ResolverMap>;
|
||||
};
|
||||
};
|
||||
|
||||
export interface FederationResult {
|
||||
readonly merged: MergedSchema;
|
||||
readonly resolvers: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge plugin contributions into a single (sdl, resolvers) pair ready for
|
||||
* `makeExecutableSchema`. Collisions in resolvers across plugins are
|
||||
* surfaced by the SDL collision detection step that runs first — by the
|
||||
* time control reaches resolver merging, every `(Type, field)` pair is
|
||||
* single-plugin-owned.
|
||||
*/
|
||||
export function federatePlugins(
|
||||
entries: ReadonlyArray<PluginRegistryEntry>
|
||||
): FederationResult {
|
||||
const contributions: SdlContribution[] = [];
|
||||
const frozens: ReadonlyArray<ResolverMap>[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const snap = entry.registry.freeze();
|
||||
const contribution: SdlContribution =
|
||||
entry.namespace !== undefined
|
||||
? {
|
||||
plugin: entry.plugin,
|
||||
typeDefs: snap.typeDefs,
|
||||
namespace: entry.namespace,
|
||||
}
|
||||
: { plugin: entry.plugin, typeDefs: snap.typeDefs };
|
||||
contributions.push(contribution);
|
||||
frozens.push(snap.resolvers);
|
||||
}
|
||||
|
||||
const merged = mergeSdl(contributions);
|
||||
|
||||
const combined: Record<string, Record<string, unknown>> = {};
|
||||
for (const maps of frozens) {
|
||||
for (const map of maps) {
|
||||
for (const typeName of Object.keys(map)) {
|
||||
const incoming = (map as Record<string, unknown>)[
|
||||
typeName
|
||||
] as Record<string, unknown>;
|
||||
const existing = combined[typeName] ?? {};
|
||||
combined[typeName] = { ...existing, ...incoming };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { merged, resolvers: combined };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
type FederationResult,
|
||||
federatePlugins,
|
||||
type PluginRegistryEntry,
|
||||
} from "./federation.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
# `depth-and-complexity.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Implements the static depth + complexity guard that the Yoga engine runs
|
||||
after parsing each query and before execution. Documents that exceed the
|
||||
configured limits are rejected with `GRAPHQL_YOGA.DEPTH_EXCEEDED` or
|
||||
`GRAPHQL_YOGA.COMPLEXITY_EXCEEDED`, which lets the host reject denial-of-
|
||||
service payloads (deeply nested fragments, fan-out spreads) before any
|
||||
resolver runs.
|
||||
|
||||
The guard is intentionally cheap: a single recursive walk over the
|
||||
already-parsed `DocumentNode`, no schema awareness, no cost estimator.
|
||||
Hosts that want a smarter cost model (database joins, external service
|
||||
fan-out) compose a stricter guard on top.
|
||||
|
||||
## Features
|
||||
|
||||
- Tracks deepest `SelectionSet` nesting level per operation.
|
||||
- Counts every `Field` node touched, expanding fragment spreads inline
|
||||
once per occurrence.
|
||||
- Throws subclass `GraphqlYogaError` with `context.depth` /
|
||||
`context.limit` for log-side debugging.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parse } from "graphql";
|
||||
import { enforceGuards } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
const doc = parse("{ ping }");
|
||||
enforceGuards(doc, { maxDepth: 10, maxComplexity: 1000 });
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { parse } from "graphql";
|
||||
import { enforceGuards } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
const doc = parse(`
|
||||
fragment f on Customer { id name orders { id } }
|
||||
query { customer(id: "c1") { ...f } }
|
||||
`);
|
||||
try {
|
||||
enforceGuards(doc, { maxDepth: 3, maxComplexity: 5 });
|
||||
} catch (error) {
|
||||
// GRAPHQL_YOGA.DEPTH_EXCEEDED or COMPLEXITY_EXCEEDED
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `enforceGuards(document, { maxDepth, maxComplexity })` — throws on
|
||||
violation, returns `void` on pass.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Fragment cycles would cause unbounded recursion if the walker re-visited
|
||||
spreads; in practice the GraphQL `validate()` pass that runs immediately
|
||||
after parsing rejects cycle-containing documents, so the depth/complexity
|
||||
walker can rely on cycle-free input. Should a future change move the guard
|
||||
ahead of validation, add a `Set<string>` of seen spreads to short-circuit.
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { parse } from "graphql";
|
||||
import type { GraphqlYogaError } from "../error-codes/error-codes.js";
|
||||
import { enforceGuards } from "./depth-and-complexity.js";
|
||||
|
||||
describe("enforceGuards", () => {
|
||||
it("accepts a depth-1 / complexity-1 query within default limits", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("{ ping }");
|
||||
expect(() =>
|
||||
enforceGuards(doc, { maxDepth: 10, maxComplexity: 1000 })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("rejects depth above the configured limit", { timeout: 5000 }, () => {
|
||||
const doc = parse("{ a { b { c { d { e } } } } }");
|
||||
let error: unknown;
|
||||
try {
|
||||
enforceGuards(doc, { maxDepth: 2, maxComplexity: 1000 });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect((error as GraphqlYogaError).yogaCode).toBe(
|
||||
"GRAPHQL_YOGA.DEPTH_EXCEEDED"
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects complexity above the configured limit", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("{ a b c d e }");
|
||||
let error: unknown;
|
||||
try {
|
||||
enforceGuards(doc, { maxDepth: 10, maxComplexity: 3 });
|
||||
} catch (e) {
|
||||
error = e;
|
||||
}
|
||||
expect((error as GraphqlYogaError).yogaCode).toBe(
|
||||
"GRAPHQL_YOGA.COMPLEXITY_EXCEEDED"
|
||||
);
|
||||
});
|
||||
|
||||
it("walks inline fragments without increasing depth past their parent", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("{ a { ... on T { b } } }");
|
||||
expect(() =>
|
||||
enforceGuards(doc, { maxDepth: 2, maxComplexity: 100 })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("walks fragment spreads inline once", { timeout: 5000 }, () => {
|
||||
const doc = parse("fragment f on T { x y } query { a { ...f } }");
|
||||
expect(() =>
|
||||
enforceGuards(doc, { maxDepth: 3, maxComplexity: 100 })
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("ignores fragment spreads referring to undefined fragments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const doc = parse("{ a { ...notDefined } }");
|
||||
expect(() =>
|
||||
enforceGuards(doc, { maxDepth: 3, maxComplexity: 100 })
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
type DocumentNode,
|
||||
type FieldNode,
|
||||
type FragmentDefinitionNode,
|
||||
type InlineFragmentNode,
|
||||
Kind,
|
||||
type SelectionSetNode,
|
||||
} from "graphql";
|
||||
import { GraphqlYogaError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Inspect a parsed GraphQL document and reject it when either:
|
||||
* - the deepest nested selection-set path exceeds `maxDepth`, OR
|
||||
* - the count of `Field` selections (including fragments expanded once
|
||||
* per spread) exceeds `maxComplexity`.
|
||||
*
|
||||
* Depth is measured as the number of `SelectionSet` nesting levels reached
|
||||
* from the top operation's selection set. A flat top-level query has depth
|
||||
* 1; one nested field has depth 2; and so on.
|
||||
*
|
||||
* Complexity is the simple sum of field-node selections traversed in the
|
||||
* walk. Fragment spreads expand inline once; inline fragments inherit
|
||||
* their enclosing depth.
|
||||
*/
|
||||
export function enforceGuards(
|
||||
document: DocumentNode,
|
||||
limits: { readonly maxDepth: number; readonly maxComplexity: number }
|
||||
): void {
|
||||
const fragments = new Map<string, FragmentDefinitionNode>();
|
||||
for (const def of document.definitions) {
|
||||
if (def.kind === Kind.FRAGMENT_DEFINITION) {
|
||||
fragments.set(def.name.value, def);
|
||||
}
|
||||
}
|
||||
|
||||
let depth = 0;
|
||||
let complexity = 0;
|
||||
|
||||
function visit(set: SelectionSetNode, level: number): void {
|
||||
if (level > depth) depth = level;
|
||||
for (const sel of set.selections) {
|
||||
if (sel.kind === Kind.FIELD) {
|
||||
const field = sel as FieldNode;
|
||||
complexity += 1;
|
||||
if (field.selectionSet !== undefined) {
|
||||
visit(field.selectionSet, level + 1);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (sel.kind === Kind.INLINE_FRAGMENT) {
|
||||
const inline = sel as InlineFragmentNode;
|
||||
visit(inline.selectionSet, level);
|
||||
continue;
|
||||
}
|
||||
const ref = fragments.get(sel.name.value);
|
||||
if (ref !== undefined) {
|
||||
visit(ref.selectionSet, level);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const def of document.definitions) {
|
||||
if (def.kind === Kind.OPERATION_DEFINITION) {
|
||||
visit(def.selectionSet, 1);
|
||||
}
|
||||
}
|
||||
|
||||
if (depth > limits.maxDepth) {
|
||||
throw new GraphqlYogaError(
|
||||
"GRAPHQL_YOGA.DEPTH_EXCEEDED",
|
||||
`query depth ${depth} exceeds limit ${limits.maxDepth}`,
|
||||
{ context: { depth, limit: limits.maxDepth } }
|
||||
);
|
||||
}
|
||||
if (complexity > limits.maxComplexity) {
|
||||
throw new GraphqlYogaError(
|
||||
"GRAPHQL_YOGA.COMPLEXITY_EXCEEDED",
|
||||
`query complexity ${complexity} exceeds limit ${limits.maxComplexity}`,
|
||||
{ context: { complexity, limit: limits.maxComplexity } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { enforceGuards } from "./depth-and-complexity.js";
|
||||
42
packages/api-stack/api/api-graphql-yoga/src/index.ts
Normal file
42
packages/api-stack/api/api-graphql-yoga/src/index.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Engine
|
||||
export {
|
||||
assertIntrospectionAllowed,
|
||||
createYogaGraphqlEngine,
|
||||
maskOrPassthrough,
|
||||
withTimeout,
|
||||
wrapSubscription,
|
||||
type YogaEngineConfig,
|
||||
type YogaGraphqlEngine,
|
||||
} from "./engine/index.js";
|
||||
// Error codes
|
||||
export {
|
||||
GRAPHQL_YOGA_CODES,
|
||||
GraphqlYogaError,
|
||||
type GraphqlYogaErrorCode,
|
||||
} from "./error-codes/index.js";
|
||||
// Federation
|
||||
export {
|
||||
type FederationResult,
|
||||
federatePlugins,
|
||||
type PluginRegistryEntry,
|
||||
} from "./federation/index.js";
|
||||
// Guards
|
||||
export { enforceGuards } from "./guards/index.js";
|
||||
// Instruments
|
||||
export {
|
||||
getYogaInstruments,
|
||||
resetYogaInstruments,
|
||||
type YogaInstruments,
|
||||
} from "./instruments/index.js";
|
||||
// Loaders
|
||||
export { buildRequestLoaders, type RequestLoaderBag } from "./loaders/index.js";
|
||||
// Options
|
||||
export {
|
||||
type ResolvedYogaGraphqlOptions,
|
||||
resolveYogaGraphqlOptions,
|
||||
YOGA_GRAPHQL_DEFAULTS,
|
||||
type YogaGraphqlOptions,
|
||||
yogaGraphqlOptionsSchema,
|
||||
} from "./options/index.js";
|
||||
// Subscriptions
|
||||
export { subscribeBrokerTopic } from "./subscriptions/index.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
getYogaInstruments,
|
||||
resetYogaInstruments,
|
||||
type YogaInstruments,
|
||||
} from "./instruments.js";
|
||||
@@ -0,0 +1,69 @@
|
||||
# `instruments.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Builds and caches the OpenTelemetry instrument bundle the Yoga GraphQL
|
||||
engine emits per request. The bundle covers a counter
|
||||
(`graphql.requests_total`), two histograms (`graphql.request_duration_ms`,
|
||||
`graphql.resolver_duration_ms`), an observable gauge
|
||||
(`graphql.subscriptions_active`), and a schema-rebuild counter
|
||||
(`graphql.schema_rebuilds_total`) — every metric the design doc §10
|
||||
acceptance criteria enumerate.
|
||||
|
||||
The lazy-bound + reset pattern lets test code install a fresh
|
||||
`MeterProvider`, call `resetYogaInstruments()`, exercise the engine, and
|
||||
read counter / histogram observations directly off the provider — the
|
||||
package never holds a stale meter reference across test boundaries.
|
||||
|
||||
## Features
|
||||
|
||||
- Singleton bundle keyed on the meter name `@suaveplan/api-graphql-yoga`.
|
||||
- `resetYogaInstruments()` clears the cache for tests.
|
||||
- Histograms carry units (`ms`); counters carry descriptions for the
|
||||
Prometheus exporter.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { getYogaInstruments } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
const instruments = getYogaInstruments();
|
||||
instruments.requestsTotal.add(1, {
|
||||
operation_type: "query",
|
||||
operation_name: "Ping",
|
||||
outcome: "ok",
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import { MeterProvider } from "@opentelemetry/sdk-metrics";
|
||||
import {
|
||||
getYogaInstruments,
|
||||
resetYogaInstruments,
|
||||
} from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
const provider = new MeterProvider();
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetYogaInstruments();
|
||||
const instruments = getYogaInstruments();
|
||||
instruments.requestDurationMs.record(12.5);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `YogaInstruments` — readonly record with one entry per metric.
|
||||
- `getYogaInstruments()` — lazy-binding accessor.
|
||||
- `resetYogaInstruments()` — test helper.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Observable gauges (`subscriptionsActive`) are bound but not subscribed
|
||||
inside this module; the engine attaches a callback that returns the live
|
||||
subscription count when the gauge is sampled.
|
||||
@@ -0,0 +1,95 @@
|
||||
import { metrics } from "@opentelemetry/api";
|
||||
import {
|
||||
AggregationTemporality,
|
||||
InMemoryMetricExporter,
|
||||
MeterProvider,
|
||||
PeriodicExportingMetricReader,
|
||||
} from "@opentelemetry/sdk-metrics";
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { createYogaGraphqlEngine } from "../engine/yoga-engine.js";
|
||||
import { getYogaInstruments, resetYogaInstruments } from "./instruments.js";
|
||||
|
||||
describe("getYogaInstruments", () => {
|
||||
it("returns the bundle with all five instruments", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetYogaInstruments();
|
||||
const i = getYogaInstruments();
|
||||
expect(i.requestsTotal).toBeDefined();
|
||||
expect(i.requestDurationMs).toBeDefined();
|
||||
expect(i.resolverDurationMs).toBeDefined();
|
||||
expect(i.subscriptionsActive).toBeDefined();
|
||||
expect(i.schemaRebuildsTotal).toBeDefined();
|
||||
});
|
||||
|
||||
it("caches the bundle until reset", { timeout: 5000 }, () => {
|
||||
resetYogaInstruments();
|
||||
const a = getYogaInstruments();
|
||||
const b = getYogaInstruments();
|
||||
expect(a).toBe(b);
|
||||
resetYogaInstruments();
|
||||
const c = getYogaInstruments();
|
||||
expect(a === c).toBe(false);
|
||||
});
|
||||
|
||||
it("counter add + histogram record do not throw against the global meter provider", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
resetYogaInstruments();
|
||||
const i = getYogaInstruments();
|
||||
expect(() => {
|
||||
i.requestsTotal.add(1, { outcome: "ok" });
|
||||
i.requestDurationMs.record(12.5);
|
||||
i.resolverDurationMs.record(1, { type: "Query", field: "ping" });
|
||||
i.schemaRebuildsTotal.add(1);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it("subscriptions_active observable gauge samples activeSubscriptions", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const exporter = new InMemoryMetricExporter(
|
||||
AggregationTemporality.CUMULATIVE
|
||||
);
|
||||
const reader = new PeriodicExportingMetricReader({
|
||||
exporter,
|
||||
exportIntervalMillis: 100_000,
|
||||
});
|
||||
const provider = new MeterProvider({ readers: [reader] });
|
||||
metrics.setGlobalMeterProvider(provider);
|
||||
resetYogaInstruments();
|
||||
const engine = createYogaGraphqlEngine();
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addTypes(
|
||||
"type Query { ping: String! } type Subscription { tick: Int! }"
|
||||
);
|
||||
reg.addResolvers({
|
||||
Query: { ping: () => "p" },
|
||||
Subscription: {
|
||||
tick: {
|
||||
subscribe: async function* () {
|
||||
yield { tick: 1 };
|
||||
yield { tick: 2 };
|
||||
},
|
||||
resolve: (v: { tick: number }) => v.tick,
|
||||
},
|
||||
},
|
||||
});
|
||||
engine.buildSchema(reg);
|
||||
const iter = engine.executeSubscription(
|
||||
{ query: "subscription { tick }" },
|
||||
{} as never
|
||||
);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
await ait.next();
|
||||
expect(engine.activeSubscriptions()).toBeGreaterThan(0);
|
||||
await reader.collect();
|
||||
await ait.next();
|
||||
await ait.next();
|
||||
await reader.shutdown();
|
||||
await provider.shutdown();
|
||||
resetYogaInstruments();
|
||||
metrics.disable();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import {
|
||||
type Counter,
|
||||
type Histogram,
|
||||
metrics,
|
||||
type ObservableGauge,
|
||||
} from "@opentelemetry/api";
|
||||
|
||||
/**
|
||||
* Bundle of OpenTelemetry instruments emitted by the Yoga GraphQL engine.
|
||||
*
|
||||
* Five instruments cover the lifecycle:
|
||||
* - `requestsTotal { operation_type, operation_name, outcome }` — counter
|
||||
* incremented once per `executeQuery` invocation.
|
||||
* - `requestDurationMs` — histogram of `executeQuery` wall-time in ms.
|
||||
* - `resolverDurationMs { type, field }` — histogram of individual
|
||||
* resolver wall-time when wrapped via `wrapResolversForOtel`.
|
||||
* - `subscriptionsActive` — observable gauge reflecting the current count
|
||||
* of live subscriptions tracked by the engine.
|
||||
* - `schemaRebuildsTotal` — counter incremented every time the engine
|
||||
* runs `buildSchema(...)`.
|
||||
*/
|
||||
export interface YogaInstruments {
|
||||
readonly requestsTotal: Counter;
|
||||
readonly requestDurationMs: Histogram;
|
||||
readonly resolverDurationMs: Histogram;
|
||||
readonly subscriptionsActive: ObservableGauge;
|
||||
readonly schemaRebuildsTotal: Counter;
|
||||
}
|
||||
|
||||
let cached: YogaInstruments | undefined;
|
||||
|
||||
/**
|
||||
* Lazily construct and cache the instrument bundle against the current
|
||||
* global `MeterProvider`. Tests reset the cache via
|
||||
* `resetYogaInstruments()` after installing a fresh provider.
|
||||
*/
|
||||
export function getYogaInstruments(): YogaInstruments {
|
||||
if (cached !== undefined) return cached;
|
||||
const meter = metrics.getMeter("@suaveplan/api-graphql-yoga");
|
||||
cached = {
|
||||
requestsTotal: meter.createCounter("graphql.requests_total", {
|
||||
description:
|
||||
"Total GraphQL requests partitioned by operation_type, operation_name, outcome",
|
||||
}),
|
||||
requestDurationMs: meter.createHistogram(
|
||||
"graphql.request_duration_ms",
|
||||
{
|
||||
description: "Wall-time of executeQuery in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
resolverDurationMs: meter.createHistogram(
|
||||
"graphql.resolver_duration_ms",
|
||||
{
|
||||
description: "Per-resolver wall-time in milliseconds",
|
||||
unit: "ms",
|
||||
}
|
||||
),
|
||||
subscriptionsActive: meter.createObservableGauge(
|
||||
"graphql.subscriptions_active",
|
||||
{
|
||||
description: "Current count of live GraphQL subscriptions",
|
||||
}
|
||||
),
|
||||
schemaRebuildsTotal: meter.createCounter(
|
||||
"graphql.schema_rebuilds_total",
|
||||
{
|
||||
description: "Total executable-schema rebuilds",
|
||||
}
|
||||
),
|
||||
};
|
||||
return cached;
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the cached instrument bundle so the next `getYogaInstruments()`
|
||||
* binds against the `MeterProvider` that is in effect at that point.
|
||||
*/
|
||||
export function resetYogaInstruments(): void {
|
||||
cached = undefined;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { buildRequestLoaders, type RequestLoaderBag } from "./loaders.js";
|
||||
@@ -0,0 +1,67 @@
|
||||
# `loaders.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines the per-request loader bag the Yoga engine constructs at pipeline
|
||||
step 7 and disposes at step 11. Two concurrent requests therefore see
|
||||
independent `DataLoader` instances — caching that R1 establishes for a
|
||||
key cannot leak to R2 even when both resolvers call
|
||||
`rctx.loaders.customer.load(id)` for the same id within the same event
|
||||
loop tick.
|
||||
|
||||
The bag is the binding between the abstract `LoaderFactory` contract
|
||||
(owned by `@suaveplan/api-graphql`) and the concrete request-scoped loader
|
||||
handles resolvers see. Disposal is a hook for future async-resource
|
||||
loaders; today's in-memory factories make it a no-op.
|
||||
|
||||
## Features
|
||||
|
||||
- Constructs one loader handle per registered factory.
|
||||
- Idempotent `dispose()` so the engine can safely call it on both the
|
||||
happy path and the error path.
|
||||
- Returned `handles` map is frozen-by-convention — engine code never
|
||||
mutates after construction.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import { buildRequestLoaders } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addLoader("customer", async (keys) => keys.map((k) => `customer:${String(k)}`));
|
||||
|
||||
const snap = (reg as unknown as { freeze: () => { loaders: ReadonlyMap<string, never> } }).freeze();
|
||||
const bag = buildRequestLoaders(snap.loaders);
|
||||
const customer = await bag.handles.customer.load("c1");
|
||||
bag.dispose();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { buildRequestLoaders } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
function makeRctx(loaders: Awaited<ReturnType<typeof buildRequestLoaders>>) {
|
||||
return {
|
||||
loaders: loaders.handles,
|
||||
dispose: () => loaders.dispose(),
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `RequestLoaderBag` — `{ handles, dispose() }`.
|
||||
- `buildRequestLoaders(factories)` — constructs the bag.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The `disposed` boolean lets the engine call `dispose()` on both happy and
|
||||
error paths without worrying about double-free. When async-resource
|
||||
loaders land, the close branch will flip from no-op to a real close
|
||||
sequence; the public shape stays unchanged.
|
||||
@@ -0,0 +1,62 @@
|
||||
import { createGraphqlRegistry } from "@suaveplan/api-graphql";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { buildRequestLoaders } from "./loaders.js";
|
||||
|
||||
function snapshot(reg: ReturnType<typeof createGraphqlRegistry>) {
|
||||
return (
|
||||
reg as unknown as {
|
||||
readonly freeze: () => {
|
||||
readonly loaders: ReadonlyMap<
|
||||
string,
|
||||
{
|
||||
readonly create: () => {
|
||||
readonly load: (k: unknown) => Promise<unknown>;
|
||||
};
|
||||
}
|
||||
>;
|
||||
};
|
||||
}
|
||||
).freeze();
|
||||
}
|
||||
|
||||
describe("buildRequestLoaders", () => {
|
||||
it("constructs one handle per registered factory", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addLoader("customer", async (keys) =>
|
||||
keys.map((k) => `cust:${String(k)}`)
|
||||
);
|
||||
const snap = snapshot(reg);
|
||||
const bag = buildRequestLoaders(snap.loaders);
|
||||
expect(Object.keys(bag.handles)).toEqual(["customer"]);
|
||||
const v = await bag.handles.customer?.load("a");
|
||||
expect(v).toBe("cust:a");
|
||||
bag.dispose();
|
||||
});
|
||||
|
||||
it("two concurrent requests get distinct DataLoader instances", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addLoader("customer", async (keys) =>
|
||||
keys.map((k) => `c:${String(k)}`)
|
||||
);
|
||||
const snap = snapshot(reg);
|
||||
const r1 = buildRequestLoaders(snap.loaders);
|
||||
const r2 = buildRequestLoaders(snap.loaders);
|
||||
expect(r1.handles.customer).not.toBe(r2.handles.customer);
|
||||
r1.dispose();
|
||||
r2.dispose();
|
||||
});
|
||||
|
||||
it("dispose is idempotent", { timeout: 5000 }, () => {
|
||||
const reg = createGraphqlRegistry();
|
||||
reg.addLoader("a", async (keys) => keys);
|
||||
const snap = snapshot(reg);
|
||||
const bag = buildRequestLoaders(snap.loaders);
|
||||
bag.dispose();
|
||||
bag.dispose();
|
||||
expect(typeof bag.dispose).toBe("function");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { LoaderFactory } from "@suaveplan/api-graphql";
|
||||
|
||||
/**
|
||||
* Per-request loader bag bound to a `RequestContext`. The engine constructs
|
||||
* one of these at pipeline step 7 (capability scoping) for every inbound
|
||||
* GraphQL request and disposes it at step 11 (cleanup). Two concurrent
|
||||
* requests therefore see independent caches.
|
||||
*
|
||||
* The bag is opaque to resolvers: they reach loaders through
|
||||
* `rctx.loaders.<name>` and call `.load(key)` / `.loadMany(keys)`. The
|
||||
* underlying `LoaderFactory` controls batching + caching policy.
|
||||
*/
|
||||
export interface RequestLoaderBag {
|
||||
readonly handles: Readonly<
|
||||
Record<string, ReturnType<LoaderFactory["create"]>>
|
||||
>;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the per-request loader bag from a map of `name → LoaderFactory`.
|
||||
* `dispose()` is no-op for synchronous in-memory loaders — but the contract
|
||||
* stays in place so future async-resource loaders (DB pools, network
|
||||
* sessions) can hook tear-down without an API break.
|
||||
*/
|
||||
export function buildRequestLoaders(
|
||||
factories: ReadonlyMap<string, LoaderFactory>
|
||||
): RequestLoaderBag {
|
||||
const handles: Record<string, ReturnType<LoaderFactory["create"]>> = {};
|
||||
for (const [name, factory] of factories.entries()) {
|
||||
handles[name] = factory.create();
|
||||
}
|
||||
let disposed = false;
|
||||
return {
|
||||
handles,
|
||||
dispose(): void {
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
type ResolvedYogaGraphqlOptions,
|
||||
resolveYogaGraphqlOptions,
|
||||
YOGA_GRAPHQL_DEFAULTS,
|
||||
type YogaGraphqlOptions,
|
||||
yogaGraphqlOptionsSchema,
|
||||
} from "./options.js";
|
||||
@@ -0,0 +1,73 @@
|
||||
# `options.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines `YogaGraphqlOptions`, the Zod-validated configuration boundary that
|
||||
hosts use to wire `createYogaGraphqlEngine`. Hosts pass a partial bag at the
|
||||
public surface; `resolveYogaGraphqlOptions` validates the input through a
|
||||
strict Zod schema and fills in every default so internal code receives a
|
||||
fully concrete `ResolvedYogaGraphqlOptions`.
|
||||
|
||||
The schema covers endpoint routes (`/graphql`, `/graphql/sdl`,
|
||||
`/graphql/ws`, `/graphql/ui`), the masked-errors and landing-page toggles,
|
||||
introspection on/off, depth + complexity guard limits, the parser-cache
|
||||
size, the per-request `timeoutMs`, and the strict-namespace federation
|
||||
flag.
|
||||
|
||||
## Features
|
||||
|
||||
- Single source of truth for every Yoga adapter default.
|
||||
- Boundary input typed as `YogaGraphqlOptions = z.input<...>` so callers
|
||||
may pass numbers as numbers (not strings) — no coercion magic.
|
||||
- Resolved shape is `readonly` and `Object.freeze`d so downstream code
|
||||
cannot mutate it by accident.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { resolveYogaGraphqlOptions } from "@suaveplan/api-graphql-yoga/options";
|
||||
|
||||
const opts = resolveYogaGraphqlOptions({});
|
||||
// opts.endpoint === "/graphql"
|
||||
// opts.maxQueryDepth === 10
|
||||
// opts.maxQueryComplexity === 1000
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { resolveYogaGraphqlOptions } from "@suaveplan/api-graphql-yoga/options";
|
||||
|
||||
const opts = resolveYogaGraphqlOptions({
|
||||
endpoint: "/api/graphql",
|
||||
sdlEndpoint: "/api/graphql/sdl",
|
||||
wsEndpoint: "/api/graphql/ws",
|
||||
uiEndpoint: "/api/graphql/ui",
|
||||
maxQueryDepth: 8,
|
||||
maxQueryComplexity: 250,
|
||||
maskedErrors: false,
|
||||
introspection: false,
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `yogaGraphqlOptionsSchema` — the underlying Zod schema, exposed for
|
||||
composition into broader host config layers.
|
||||
- `YogaGraphqlOptions` — input type alias.
|
||||
- `ResolvedYogaGraphqlOptions` — frozen post-resolution type.
|
||||
- `YOGA_GRAPHQL_DEFAULTS` — the `ResolvedYogaGraphqlOptions` defaults bag.
|
||||
- `resolveYogaGraphqlOptions(input?)` — validate + default; throws
|
||||
`ZodError` on invalid input.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
Each endpoint path must start with `/` (enforced by `startsWith("/")`); the
|
||||
guard prevents a host from accidentally registering a relative path that
|
||||
would never match. `maxQueryDepth` and `maxQueryComplexity` are
|
||||
`.positive()` because zero or negative limits would reject every query —
|
||||
intent-preserving defaults beat foot-gun acceptance.
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import {
|
||||
resolveYogaGraphqlOptions,
|
||||
YOGA_GRAPHQL_DEFAULTS,
|
||||
yogaGraphqlOptionsSchema,
|
||||
} from "./options.js";
|
||||
|
||||
describe("resolveYogaGraphqlOptions", () => {
|
||||
it("applies every default when called with no input", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = resolveYogaGraphqlOptions(undefined);
|
||||
expect(out.endpoint).toBe(YOGA_GRAPHQL_DEFAULTS.endpoint);
|
||||
expect(out.sdlEndpoint).toBe(YOGA_GRAPHQL_DEFAULTS.sdlEndpoint);
|
||||
expect(out.wsEndpoint).toBe(YOGA_GRAPHQL_DEFAULTS.wsEndpoint);
|
||||
expect(out.uiEndpoint).toBe(YOGA_GRAPHQL_DEFAULTS.uiEndpoint);
|
||||
expect(out.maskedErrors).toBe(true);
|
||||
expect(out.maxQueryDepth).toBe(10);
|
||||
expect(out.maxQueryComplexity).toBe(1000);
|
||||
expect(out.parserCacheSize).toBe(1000);
|
||||
expect(out.timeoutMs).toBe(30000);
|
||||
expect(out.landingPage).toBe(false);
|
||||
expect(out.introspection).toBe(true);
|
||||
expect(out.strictNamespace).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves every overridden field", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const out = resolveYogaGraphqlOptions({
|
||||
endpoint: "/api/gql",
|
||||
sdlEndpoint: "/api/gql/sdl",
|
||||
wsEndpoint: "/api/gql/ws",
|
||||
uiEndpoint: "/api/gql/ui",
|
||||
maskedErrors: false,
|
||||
landingPage: true,
|
||||
introspection: false,
|
||||
maxQueryDepth: 5,
|
||||
maxQueryComplexity: 200,
|
||||
parserCacheSize: 0,
|
||||
timeoutMs: 1000,
|
||||
strictNamespace: true,
|
||||
});
|
||||
expect(out.endpoint).toBe("/api/gql");
|
||||
expect(out.sdlEndpoint).toBe("/api/gql/sdl");
|
||||
expect(out.wsEndpoint).toBe("/api/gql/ws");
|
||||
expect(out.uiEndpoint).toBe("/api/gql/ui");
|
||||
expect(out.maskedErrors).toBe(false);
|
||||
expect(out.landingPage).toBe(true);
|
||||
expect(out.introspection).toBe(false);
|
||||
expect(out.maxQueryDepth).toBe(5);
|
||||
expect(out.maxQueryComplexity).toBe(200);
|
||||
expect(out.parserCacheSize).toBe(0);
|
||||
expect(out.timeoutMs).toBe(1000);
|
||||
expect(out.strictNamespace).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects endpoints that don't start with /", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() =>
|
||||
resolveYogaGraphqlOptions({ endpoint: "graphql" })
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("rejects non-positive maxQueryDepth", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => resolveYogaGraphqlOptions({ maxQueryDepth: 0 })).toThrow();
|
||||
});
|
||||
|
||||
it("exposes the underlying schema for composition", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
const parsed = yogaGraphqlOptionsSchema.parse({});
|
||||
expect(parsed).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import z from "zod";
|
||||
|
||||
/**
|
||||
* Zod schema for `YogaGraphqlOptions`. Every field is optional at the
|
||||
* boundary — `resolveYogaGraphqlOptions` applies defaults so internal
|
||||
* code can treat the resolved shape as fully populated.
|
||||
*/
|
||||
export const yogaGraphqlOptionsSchema = z.object({
|
||||
endpoint: z.string().min(1).startsWith("/").optional(),
|
||||
sdlEndpoint: z.string().min(1).startsWith("/").optional(),
|
||||
wsEndpoint: z.string().min(1).startsWith("/").optional(),
|
||||
uiEndpoint: z.string().min(1).startsWith("/").optional(),
|
||||
maskedErrors: z.boolean().optional(),
|
||||
landingPage: z.boolean().optional(),
|
||||
introspection: z.boolean().optional(),
|
||||
maxQueryDepth: z.number().int().positive().optional(),
|
||||
maxQueryComplexity: z.number().int().positive().optional(),
|
||||
parserCacheSize: z.number().int().nonnegative().optional(),
|
||||
timeoutMs: z.number().int().positive().optional(),
|
||||
strictNamespace: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type YogaGraphqlOptions = z.input<typeof yogaGraphqlOptionsSchema>;
|
||||
|
||||
export interface ResolvedYogaGraphqlOptions {
|
||||
readonly endpoint: string;
|
||||
readonly sdlEndpoint: string;
|
||||
readonly wsEndpoint: string;
|
||||
readonly uiEndpoint: string;
|
||||
readonly maskedErrors: boolean;
|
||||
readonly landingPage: boolean;
|
||||
readonly introspection: boolean;
|
||||
readonly maxQueryDepth: number;
|
||||
readonly maxQueryComplexity: number;
|
||||
readonly parserCacheSize: number;
|
||||
readonly timeoutMs: number;
|
||||
readonly strictNamespace: boolean;
|
||||
}
|
||||
|
||||
const DEFAULTS: ResolvedYogaGraphqlOptions = {
|
||||
endpoint: "/graphql",
|
||||
sdlEndpoint: "/graphql/sdl",
|
||||
wsEndpoint: "/graphql/ws",
|
||||
uiEndpoint: "/graphql/ui",
|
||||
maskedErrors: true,
|
||||
landingPage: false,
|
||||
introspection: true,
|
||||
maxQueryDepth: 10,
|
||||
maxQueryComplexity: 1000,
|
||||
parserCacheSize: 1000,
|
||||
timeoutMs: 30_000,
|
||||
strictNamespace: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate and apply defaults to the raw `YogaGraphqlOptions` provided by
|
||||
* the host. Returns a `ResolvedYogaGraphqlOptions` whose fields are all
|
||||
* concrete; downstream code does not need to handle `undefined`.
|
||||
*
|
||||
* Throws `ZodError` when the input violates the schema — the caller is
|
||||
* expected to either let that propagate or wrap it.
|
||||
*/
|
||||
export function resolveYogaGraphqlOptions(
|
||||
input: YogaGraphqlOptions | undefined
|
||||
): ResolvedYogaGraphqlOptions {
|
||||
const parsed = yogaGraphqlOptionsSchema.parse(input ?? {});
|
||||
return Object.freeze({
|
||||
endpoint: parsed.endpoint ?? DEFAULTS.endpoint,
|
||||
sdlEndpoint: parsed.sdlEndpoint ?? DEFAULTS.sdlEndpoint,
|
||||
wsEndpoint: parsed.wsEndpoint ?? DEFAULTS.wsEndpoint,
|
||||
uiEndpoint: parsed.uiEndpoint ?? DEFAULTS.uiEndpoint,
|
||||
maskedErrors: parsed.maskedErrors ?? DEFAULTS.maskedErrors,
|
||||
landingPage: parsed.landingPage ?? DEFAULTS.landingPage,
|
||||
introspection: parsed.introspection ?? DEFAULTS.introspection,
|
||||
maxQueryDepth: parsed.maxQueryDepth ?? DEFAULTS.maxQueryDepth,
|
||||
maxQueryComplexity:
|
||||
parsed.maxQueryComplexity ?? DEFAULTS.maxQueryComplexity,
|
||||
parserCacheSize: parsed.parserCacheSize ?? DEFAULTS.parserCacheSize,
|
||||
timeoutMs: parsed.timeoutMs ?? DEFAULTS.timeoutMs,
|
||||
strictNamespace: parsed.strictNamespace ?? DEFAULTS.strictNamespace,
|
||||
});
|
||||
}
|
||||
|
||||
export const YOGA_GRAPHQL_DEFAULTS = DEFAULTS;
|
||||
@@ -0,0 +1 @@
|
||||
export { subscribeBrokerTopic } from "./subscriptions.js";
|
||||
@@ -0,0 +1,70 @@
|
||||
# `subscriptions.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Bridges the `WsBroker` contract from `@suaveplan/api-ws` to GraphQL
|
||||
`Subscription` resolvers via `AsyncIterable<Payload>`. The Yoga engine
|
||||
wires `graphql-ws` over the host's already-mounted WebSocket transport;
|
||||
this module is the per-subscription helper that converts a broker `topic`
|
||||
+ `tenant` pair into an iterator the engine can consume.
|
||||
|
||||
Tenant filtering is enforced at the bridge: only `BrokerEnvelope` items
|
||||
whose `tenant === tenantArg` reach the GraphQL iterator. Closing the
|
||||
iterator (`return()` or the explicit `close()` method) calls
|
||||
`subscription.unsubscribe()` on the broker so the WS slot is released
|
||||
within one tick.
|
||||
|
||||
## Features
|
||||
|
||||
- Returns `AsyncIterable<Payload>` with an explicit `close()` accessor.
|
||||
- Queues payloads when no consumer is awaiting; resolves immediately when
|
||||
a consumer is parked.
|
||||
- Calls `GraphqlYogaError("SUBSCRIPTION_TRANSPORT_UNAVAILABLE")` when the
|
||||
host hands a `null` broker, satisfying the design doc §10.4 contract.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { createInMemoryWsBroker } from "@suaveplan/api-ws-broker-in-memory";
|
||||
import { subscribeBrokerTopic } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
const broker = createInMemoryWsBroker();
|
||||
const iterable = subscribeBrokerTopic(
|
||||
broker,
|
||||
"billing.invoice.created",
|
||||
"tenant-a" as never
|
||||
);
|
||||
for await (const event of iterable) {
|
||||
console.log(event);
|
||||
break;
|
||||
}
|
||||
await iterable.close();
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { subscribeBrokerTopic } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
const subscription = {
|
||||
invoiceCreated(_parent: unknown, _args: unknown, ctx: { broker: never; tenantId: never }) {
|
||||
return subscribeBrokerTopic(ctx.broker, "billing.invoice.created", ctx.tenantId);
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `subscribeBrokerTopic(broker, topic, tenant)` — returns
|
||||
`AsyncIterable<Payload> & { close(): Promise<void> }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The internal `queue` + `waiters` arrays form a simple bounded mailbox.
|
||||
Backpressure (queue cap, drop oldest, etc.) is intentionally not modelled
|
||||
here — the in-memory broker enforces its own bounds, and the Redis broker
|
||||
will inherit the same back-end contract.
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import type {
|
||||
BrokerEnvelope,
|
||||
BrokerHandler,
|
||||
BrokerSubscription,
|
||||
WsBroker,
|
||||
} from "@suaveplan/api-ws";
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { GraphqlYogaError } from "../error-codes/error-codes.js";
|
||||
import { subscribeBrokerTopic } from "./subscriptions.js";
|
||||
|
||||
class FakeBroker implements WsBroker<{ readonly v: number }> {
|
||||
public readonly handlers = new Map<
|
||||
string,
|
||||
BrokerHandler<{ readonly v: number }>
|
||||
>();
|
||||
public readonly unsubscribed: string[] = [];
|
||||
public publish(
|
||||
_topic: string,
|
||||
envelope: BrokerEnvelope<{ readonly v: number }>
|
||||
): void {
|
||||
const h = this.handlers.get(_topic);
|
||||
if (h !== undefined) h(envelope);
|
||||
}
|
||||
public subscribe(
|
||||
topic: string,
|
||||
handler: BrokerHandler<{ readonly v: number }>
|
||||
): BrokerSubscription {
|
||||
this.handlers.set(topic, handler);
|
||||
return {
|
||||
topic,
|
||||
unsubscribe: () => {
|
||||
this.handlers.delete(topic);
|
||||
this.unsubscribed.push(topic);
|
||||
},
|
||||
};
|
||||
}
|
||||
public dispose(): void {
|
||||
this.handlers.clear();
|
||||
}
|
||||
}
|
||||
|
||||
const tenant = "tenant-a" as unknown as TenantId;
|
||||
const other = "tenant-b" as unknown as TenantId;
|
||||
|
||||
describe("subscribeBrokerTopic", () => {
|
||||
it("yields tenant-matched payloads", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeBroker();
|
||||
const iter = subscribeBrokerTopic(broker, "billing.invoice", tenant);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
const pending = ait.next();
|
||||
// Allow the awaitable subscribe to settle.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
broker.publish("billing.invoice", {
|
||||
topic: "billing.invoice",
|
||||
tenant,
|
||||
payload: { v: 1 },
|
||||
});
|
||||
const step = await pending;
|
||||
expect(step.done).toBe(false);
|
||||
expect((step.value as { v: number }).v).toBe(1);
|
||||
await iter.close();
|
||||
expect(broker.unsubscribed).toContain("billing.invoice");
|
||||
});
|
||||
|
||||
it("filters out envelopes for other tenants", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeBroker();
|
||||
const iter = subscribeBrokerTopic(broker, "billing.invoice", tenant);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
broker.publish("billing.invoice", {
|
||||
topic: "billing.invoice",
|
||||
tenant: other,
|
||||
payload: { v: 1 },
|
||||
});
|
||||
broker.publish("billing.invoice", {
|
||||
topic: "billing.invoice",
|
||||
tenant,
|
||||
payload: { v: 2 },
|
||||
});
|
||||
const step = await ait.next();
|
||||
expect((step.value as { v: number }).v).toBe(2);
|
||||
await iter.close();
|
||||
});
|
||||
|
||||
it("yields queued payloads to a later consumer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeBroker();
|
||||
const iter = subscribeBrokerTopic(broker, "t", tenant);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
broker.publish("t", {
|
||||
topic: "t",
|
||||
tenant,
|
||||
payload: { v: 99 },
|
||||
});
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
const step = await ait.next();
|
||||
expect((step.value as { v: number }).v).toBe(99);
|
||||
await iter.close();
|
||||
});
|
||||
|
||||
it("return() closes the underlying subscription", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeBroker();
|
||||
const iter = subscribeBrokerTopic(broker, "t", tenant);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const r = ait.return !== undefined ? await ait.return() : undefined;
|
||||
expect(r?.done).toBe(true);
|
||||
expect(broker.unsubscribed).toContain("t");
|
||||
});
|
||||
|
||||
it("close is idempotent", { timeout: 5000 }, async () => {
|
||||
const broker = new FakeBroker();
|
||||
const iter = subscribeBrokerTopic(broker, "t", tenant);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await iter.close();
|
||||
await iter.close();
|
||||
expect(broker.unsubscribed).toContain("t");
|
||||
});
|
||||
|
||||
it("raises SUBSCRIPTION_TRANSPORT_UNAVAILABLE on a null broker", {
|
||||
timeout: 5000,
|
||||
}, () => {
|
||||
expect(() => subscribeBrokerTopic(null, "t", tenant)).toThrow(
|
||||
GraphqlYogaError
|
||||
);
|
||||
});
|
||||
|
||||
it("calling next() after close returns done immediately", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeBroker();
|
||||
const iter = subscribeBrokerTopic(broker, "t", tenant);
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await iter.close();
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
const step = await ait.next();
|
||||
expect(step.done).toBe(true);
|
||||
});
|
||||
|
||||
it("yields done after close even with pending consumer", {
|
||||
timeout: 5000,
|
||||
}, async () => {
|
||||
const broker = new FakeBroker();
|
||||
const iter = subscribeBrokerTopic(broker, "t", tenant);
|
||||
const ait = iter[Symbol.asyncIterator]();
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
const pending = ait.next();
|
||||
// Let next() advance past `await ready` and park a waiter before
|
||||
// close() flips `closed = true` — this is the path the drain loop
|
||||
// covers.
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await iter.close();
|
||||
const step = await pending;
|
||||
expect(step.done).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { TenantId } from "@suaveplan/api-types/tenant";
|
||||
import type {
|
||||
BrokerEnvelope,
|
||||
BrokerSubscription,
|
||||
WsBroker,
|
||||
} from "@suaveplan/api-ws";
|
||||
import { GraphqlYogaError } from "../error-codes/error-codes.js";
|
||||
|
||||
/**
|
||||
* Subscribe to a broker topic for a single tenant and return an
|
||||
* `AsyncIterable<Payload>` that yields one item per matching broker
|
||||
* envelope. Closing the iterator releases the broker subscription.
|
||||
*
|
||||
* The host wires this from a subscription resolver: each
|
||||
* `Subscription.<field>` resolver returns
|
||||
* `subscribeBrokerTopic(broker, "billing.invoice.created", tenant)`.
|
||||
* The Yoga engine consumes the AsyncIterable through `graphql-ws`.
|
||||
*/
|
||||
export function subscribeBrokerTopic<Payload>(
|
||||
broker: WsBroker<Payload> | null,
|
||||
topic: string,
|
||||
tenant: TenantId
|
||||
): AsyncIterable<Payload> & { close(): Promise<void> } {
|
||||
if (broker === null) {
|
||||
throw new GraphqlYogaError(
|
||||
"GRAPHQL_YOGA.SUBSCRIPTION_TRANSPORT_UNAVAILABLE",
|
||||
"subscription requested but no wsBroker was configured on the engine",
|
||||
{ context: { topic } }
|
||||
);
|
||||
}
|
||||
|
||||
const queue: Payload[] = [];
|
||||
const waiters: Array<(value: IteratorResult<Payload>) => void> = [];
|
||||
let subscription:
|
||||
| Awaited<ReturnType<WsBroker<Payload>["subscribe"]>>
|
||||
| undefined;
|
||||
let closed = false;
|
||||
|
||||
const ready = Promise.resolve(
|
||||
broker.subscribe(topic, (envelope: BrokerEnvelope<Payload>) => {
|
||||
if (envelope.tenant !== tenant) return;
|
||||
const waiter = waiters.shift();
|
||||
if (waiter !== undefined) {
|
||||
waiter({ value: envelope.payload, done: false });
|
||||
return;
|
||||
}
|
||||
queue.push(envelope.payload);
|
||||
})
|
||||
).then((sub) => {
|
||||
subscription = sub;
|
||||
});
|
||||
|
||||
async function close(): Promise<void> {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
await ready;
|
||||
await (subscription as BrokerSubscription).unsubscribe();
|
||||
for (const waiter of waiters.splice(0)) {
|
||||
waiter({ value: undefined as never, done: true });
|
||||
}
|
||||
}
|
||||
|
||||
const iterable: AsyncIterable<Payload> & { close(): Promise<void> } = {
|
||||
close,
|
||||
[Symbol.asyncIterator](): AsyncIterator<Payload> {
|
||||
return {
|
||||
async next(): Promise<IteratorResult<Payload>> {
|
||||
await ready;
|
||||
if (queue.length > 0) {
|
||||
return {
|
||||
value: queue.shift() as Payload,
|
||||
done: false,
|
||||
};
|
||||
}
|
||||
if (closed) {
|
||||
return { value: undefined as never, done: true };
|
||||
}
|
||||
return new Promise<IteratorResult<Payload>>((resolve) => {
|
||||
waiters.push(resolve);
|
||||
});
|
||||
},
|
||||
async return(): Promise<IteratorResult<Payload>> {
|
||||
await close();
|
||||
return { value: undefined as never, done: true };
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return iterable;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export {
|
||||
type ConformanceContext,
|
||||
type RunConformanceOptions,
|
||||
runGraphqlEngineConformance,
|
||||
runYogaConformance,
|
||||
} from "./testing.js";
|
||||
@@ -0,0 +1,64 @@
|
||||
# `testing.ts`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Binds the abstract `runGraphqlEngineConformance` from
|
||||
`@suaveplan/api-graphql/testing` to the Yoga engine factory so concrete
|
||||
host tests can call a single helper instead of wiring the factory by
|
||||
hand. The same conformance suite passes against every concrete engine —
|
||||
this binding is the only Yoga-specific piece — so swapping engines stays
|
||||
host-config-only.
|
||||
|
||||
The barrel also re-exports the abstract harness types
|
||||
(`ConformanceContext`, `RunConformanceOptions`) and the harness itself so
|
||||
callers that need to compose a non-Yoga binding alongside the Yoga one
|
||||
can do so without a second package import.
|
||||
|
||||
## Features
|
||||
|
||||
- `runYogaConformance(label?, config?)` — single-call wrapper that
|
||||
spins up a fresh Yoga engine per scenario.
|
||||
- Re-exports the abstract harness for composition.
|
||||
- Returns `void` because the harness registers `describe` / `it` blocks
|
||||
against the active runner — call it inside a parent `describe`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { describe } from "@suaveplan/testing/runner";
|
||||
import { runYogaConformance } from "@suaveplan/api-graphql-yoga/testing";
|
||||
|
||||
describe("yoga engine", () => {
|
||||
runYogaConformance();
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
```ts
|
||||
import { describe } from "@suaveplan/testing/runner";
|
||||
import { runYogaConformance } from "@suaveplan/api-graphql-yoga/testing";
|
||||
|
||||
describe("yoga engine (strict-namespace)", () => {
|
||||
runYogaConformance("yoga-strict", {
|
||||
options: { strictNamespace: true, maxQueryDepth: 6 },
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runYogaConformance(label?, config?)` — bound harness invocation.
|
||||
- `runGraphqlEngineConformance` — re-exported abstract harness.
|
||||
- `ConformanceContext`, `RunConformanceOptions` — types.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The stub `RequestContext` is empty — the abstract suite intentionally
|
||||
never reads from it. Concrete tests that need to assert on
|
||||
context-flowing behaviour should write their own scenarios outside the
|
||||
conformance harness.
|
||||
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "@suaveplan/testing/runner";
|
||||
import { runGraphqlEngineConformance, runYogaConformance } from "./testing.js";
|
||||
|
||||
describe("runYogaConformance — meta", () => {
|
||||
it("re-exports the abstract harness alias", { timeout: 5000 }, () => {
|
||||
expect(typeof runGraphqlEngineConformance).toBe("function");
|
||||
expect(typeof runYogaConformance).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
describe("runYogaConformance — invocation", () => {
|
||||
runYogaConformance("yoga-meta-binding");
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import {
|
||||
type ConformanceContext,
|
||||
type RunConformanceOptions,
|
||||
runGraphqlEngineConformance as runAbstract,
|
||||
} from "@suaveplan/api-graphql";
|
||||
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
|
||||
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
|
||||
import {
|
||||
createYogaGraphqlEngine,
|
||||
type YogaEngineConfig,
|
||||
} from "../engine/yoga-engine.js";
|
||||
|
||||
/**
|
||||
* Run the abstract `runGraphqlEngineConformance` against the Yoga
|
||||
* engine. Hosts call this from a spec-conformance test to prove the
|
||||
* adapter satisfies the cross-engine invariants the abstract package
|
||||
* defines. The same harness MUST pass against any concrete engine; the
|
||||
* binding here is the only Yoga-specific piece.
|
||||
*/
|
||||
export function runYogaConformance(
|
||||
label = "yoga",
|
||||
config?: YogaEngineConfig
|
||||
): void {
|
||||
runAbstract({
|
||||
label,
|
||||
factory: async (): Promise<ConformanceContext> => ({
|
||||
engine: createYogaGraphqlEngine(config),
|
||||
makeRequestContext: () => makeStubRequestContext(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function makeStubRequestContext<
|
||||
Caps extends CapabilityRequirements = CapabilityRequirements,
|
||||
>(): RequestContext<Caps> {
|
||||
return {} as RequestContext<Caps>;
|
||||
}
|
||||
|
||||
export type { ConformanceContext, RunConformanceOptions };
|
||||
/**
|
||||
* Re-export the abstract harness so consumers that already imported from
|
||||
* `@suaveplan/api-graphql-yoga/testing` for the binding can also reach
|
||||
* the lower-level surface without a second package import.
|
||||
*/
|
||||
export { runAbstract as runGraphqlEngineConformance };
|
||||
12
packages/api-stack/api/api-graphql-yoga/tsconfig.json
Normal file
12
packages/api-stack/api/api-graphql-yoga/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": ".",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "tests/**/*.ts"],
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
12
packages/api-stack/api/api-graphql-yoga/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-graphql-yoga/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.bench.ts", "dist", "node_modules"]
|
||||
}
|
||||
39
packages/api-stack/api/api-graphql-yoga/vite.config.ts
Normal file
39
packages/api-stack/api/api-graphql-yoga/vite.config.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { defineConfig } from "vite";
|
||||
import dts from "vite-plugin-dts";
|
||||
|
||||
export default defineConfig({
|
||||
root: __dirname,
|
||||
cacheDir: "../../../node_modules/.vite/packages/api-graphql-yoga",
|
||||
plugins: [
|
||||
dts({
|
||||
entryRoot: "src",
|
||||
tsconfigPath: "./tsconfig.lib.json",
|
||||
insertTypesEntry: true,
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: "./dist",
|
||||
emptyOutDir: true,
|
||||
reportCompressedSize: true,
|
||||
sourcemap: true,
|
||||
lib: { entry: "src/index.ts", formats: ["es"] },
|
||||
rollupOptions: {
|
||||
treeshake: true,
|
||||
output: {
|
||||
preserveModules: true,
|
||||
preserveModulesRoot: "src",
|
||||
entryFileNames: "[name].js",
|
||||
},
|
||||
external: [
|
||||
/^@suaveplan\//,
|
||||
/^node:/,
|
||||
"tslib",
|
||||
"zod",
|
||||
"graphql",
|
||||
"graphql-yoga",
|
||||
"@graphql-tools/schema",
|
||||
"dataloader",
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
42
packages/api-stack/api/api-graphql-yoga/vitest.config.ts
Normal file
42
packages/api-stack/api/api-graphql-yoga/vitest.config.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
globals: false,
|
||||
environment: "node",
|
||||
pool: "forks",
|
||||
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",
|
||||
"src/**/*.bench.ts",
|
||||
"src/index.ts",
|
||||
"tests/**",
|
||||
],
|
||||
thresholds: {
|
||||
statements: 100,
|
||||
branches: 100,
|
||||
functions: 100,
|
||||
lines: 100,
|
||||
},
|
||||
},
|
||||
testTimeout: 5000,
|
||||
server: {
|
||||
deps: {
|
||||
inline: [
|
||||
"graphql",
|
||||
"graphql-yoga",
|
||||
"@graphql-tools/schema",
|
||||
"@graphql-tools/utils",
|
||||
"@graphql-tools/merge",
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user