Initial Commit

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

View File

@@ -0,0 +1,81 @@
# @suaveplan/api-graphql — abstract GraphQL contract for the Suaveplan API plugin system. Owns the GraphqlRegistry / Resolver / LoaderFactory / SubscriptionResolver re-exports, federation SDL merger with collision detection + plugin namespace wrapping, @auth + @rateLimit directive descriptors, the GraphqlEngine lifecycle interface, the GRAPHQL_CODES registry, and the runGraphqlEngineConformance harness. No concrete engine — Yoga / Apollo land packages and implement the contract.
Stage-1 abstract GraphQL contract for the Suaveplan API plugin system.
Plugins contribute SDL + resolvers via a small abstract surface; the host
federates a single schema across every plugin and dispatches via a
swappable engine. Concrete engines (`api-graphql-yoga` default,
`api-graphql-apollo` alternative) live and implement the
`GraphqlEngine` contract published from this package.
<!-- begin:auto-docs -->
## Documentation tree
- [directives](./src/directives/directives.md) — Host-supplied GraphQL directive descriptors that engine adapters install at schema-build time. The contract layer ships…
- [directives](./src/directives/directives.md) — Host-supplied GraphQL directive descriptors that engine adapters install at schema-build time. The contract layer ships…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-graphql`. Every public failure path raises a `GraphqlError` carrying one…
- [error-codes](./src/error-codes/error-codes.md) — Stable error-code registry for `@suaveplan/api-graphql`. Every public failure path raises a `GraphqlError` carrying one…
- [merge-sdl](./src/federation/merge-sdl.md) — Federate one or more plugin SDL contributions into a single merged schema. This is the engine-agnostic federation step…
- [namespace](./src/federation/namespace.md) — Apply the `graphql.namespace` wrapper to a plugin's parsed SDL blocks. Plugins that declare `namespace: "Billing"` in t…
- [parse-sdl](./src/federation/parse-sdl.md) — Minimal SDL parser used by the federation merger to locate `type Name { ... }` and `extend type Name { ... }` blocks pl…
- [registry](./src/registry/registry.md) — Concrete accumulator-style `GraphqlRegistry` implementation. The host hands each plugin one of these during the GraphQL…
- [registry](./src/registry/registry.md) — Concrete accumulator-style `GraphqlRegistry` implementation. The host hands each plugin one of these during the GraphQL…
- [subscription](./src/subscription/subscription.md) — Validate incoming `graphql-ws` subscription envelopes and surface a typed "transport unavailable" failure when `api-ws`…
- [subscription](./src/subscription/subscription.md) — Validate incoming `graphql-ws` subscription envelopes and surface a typed "transport unavailable" failure when `api-ws`…
- [testing](./src/testing/conformance.md) — `runGraphqlEngineConformance` is the cross-engine test harness every concrete GraphQL engine (`api-graphql-yoga`, `api-…
- [conformance](./src/testing/conformance.md) — `runGraphqlEngineConformance` is the cross-engine test harness every concrete GraphQL engine (`api-graphql-yoga`, `api-…
- [validators](./src/validators/validators.md) — Pre-build validators concrete GraphQL engines call after `registry.freeze()` and before `buildSchema`. The contract lay…
- [validators](./src/validators/validators.md) — Pre-build validators concrete GraphQL engines call after `registry.freeze()` and before `buildSchema`. The contract lay…
<!-- end:auto-docs -->
## Surface
- [`./registry`](./src/registry/registry.md) — abstract `GraphqlRegistry`
accumulator (`addTypes` / `addResolvers` / `addLoader`) + `freeze()`.
- [`./resolver`](./src/resolver/resolver.types.ts) — `Resolver` (re-exported
from `@suaveplan/api-types`) and the keyed `ResolverMap` / `ResolverFieldMap`.
- [`./loader`](./src/loader/loader.types.ts) — `LoaderFactory`
(re-exported) and the `BatchFn` signature.
- [`./subscription`](./src/subscription/subscription.md) —
`SubscriptionResolver` (re-exported) + Zod-validated `graphql-ws`
envelope + transport-availability assertion.
- [`./federation`](./src/federation/merge-sdl.md) — `mergeSdl` collision
detector + `graphql.namespace` wrapper + minimal SDL parser.
- [`./directives`](./src/directives/directives.md) — `@auth(scope: String!)`
and `@rateLimit(rpm: Int!)` descriptors + `HOST_DIRECTIVES` lookup.
- [`./engine`](./src/engine/engine.types.ts) — abstract `GraphqlEngine`
lifecycle interface implemented by Stage-7 packages.
- [`./capability-id`](./src/capability-id/capability-id.types.ts) —
re-export of `"graphql"` literal + `GRAPHQL_CAPABILITY_ID` runtime
constant.
- [`./error-codes`](./src/error-codes/error-codes.md) — `GRAPHQL_CODES`
registry + `GraphqlError` class.
- [`./validators`](./src/validators/validators.md) —
`validateResolvers`, `lookupDirective`, `lookupLoader` pre-build checks.
- [`./testing`](./src/testing/conformance.md) —
`runGraphqlEngineConformance` cross-engine harness.
## Error codes (`GRAPHQL.*`)
| Code | When |
| --- | --- |
| `SCHEMA_COLLISION` | Two plugins declare the same `Type.field`. |
| `RESOLVER_MISSING` | SDL declares a root field with no resolver. |
| `LOADER_DUPLICATE_NAME` | Two `addLoader` calls registered the same name. |
| `LOADER_NOT_REGISTERED` | Request references an unknown loader. |
| `DIRECTIVE_UNKNOWN` | SDL references an uninstalled directive. |
| `SUBSCRIPTION_TRANSPORT_UNAVAILABLE` | Subscriptions used without `api-ws`. |
| `NAMESPACE_REQUIRED` | Strict-mode federation missing namespace. |
## Stage
Contracts. No concrete GraphQL engine; Yoga / Apollo / GraphiQL /
Altair packages land .
## Dependencies
- `@suaveplan/api-types` — type-level contract surface.
- `@suaveplan/error` — `defineCodes` + `SuaveplanError` base.
- `zod` — subscription protocol envelope validation.
- `tslib` — TypeScript helper functions.

View File

@@ -0,0 +1,105 @@
{
"name": "@suaveplan/api-graphql",
"version": "0.1.0",
"type": "module",
"sideEffects": false,
"description": "Stage-1 abstract GraphQL contract for the Suaveplan API plugin foundation. Owns the GraphqlRegistry / Resolver / LoaderFactory / SubscriptionResolver re-exports, federation SDL merger with collision detection + plugin namespace wrapping, @auth + @rateLimit directive descriptors, the GraphqlEngine lifecycle interface, the GRAPHQL_CODES registry, and the runGraphqlEngineConformance harness. No concrete engine — Yoga / Apollo land in Stage 7 packages and implement the contract.",
"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"
}
},
"./registry": {
"types": "./dist/registry/index.d.ts",
"default": "./dist/registry/index.js"
},
"./resolver": {
"types": "./dist/resolver/index.d.ts",
"default": "./dist/resolver/index.js"
},
"./loader": {
"types": "./dist/loader/index.d.ts",
"default": "./dist/loader/index.js"
},
"./subscription": {
"types": "./dist/subscription/index.d.ts",
"default": "./dist/subscription/index.js"
},
"./federation": {
"types": "./dist/federation/index.d.ts",
"default": "./dist/federation/index.js"
},
"./directives": {
"types": "./dist/directives/index.d.ts",
"default": "./dist/directives/index.js"
},
"./engine": {
"types": "./dist/engine/index.d.ts",
"default": "./dist/engine/index.js"
},
"./capability-id": {
"types": "./dist/capability-id/index.d.ts",
"default": "./dist/capability-id/index.js"
},
"./error-codes": {
"types": "./dist/error-codes/index.d.ts",
"default": "./dist/error-codes/index.js"
},
"./testing": {
"types": "./dist/testing/index.d.ts",
"default": "./dist/testing/index.js"
},
"./validators": {
"types": "./dist/validators/index.d.ts",
"default": "./dist/validators/index.js"
}
},
"scripts": {
"build": "vite build",
"test": "vitest run --passWithNoTests",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage --passWithNoTests",
"lint": "bunx biome check src/ tests/",
"lint:fix": "bunx biome lint --fix --unsafe --max-diagnostics 500 src/ tests/",
"format": "bunx biome format --write --max-diagnostics 500 src/ tests/",
"check": "bunx biome check --write --unsafe --max-diagnostics 500 src/ tests/",
"typecheck": "bunx tsc --noEmit --composite false -p tsconfig.lib.json",
"docs:check": "bun run ../../../../scripts/docs-check.ts ."
},
"dependencies": {
"@suaveplan/api-types": "workspace:^",
"@suaveplan/error": "workspace:^",
"tslib": "catalog:build",
"zod": "catalog:forms"
},
"devDependencies": {
"@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"
]
}

View File

@@ -0,0 +1,16 @@
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import {
GRAPHQL_CAPABILITY_ID,
type GraphqlCapabilityId,
} from "./capability-id.types.js";
describe('CapabilityId augmentation: "graphql"', () => {
it("graphql is a valid CapabilityId after augmentation", {
timeout: 5000,
}, () => {
expectTypeOf<"graphql">().toMatchTypeOf<CapabilityId>();
expectTypeOf<GraphqlCapabilityId>().toEqualTypeOf<"graphql">();
expect(GRAPHQL_CAPABILITY_ID).toBe("graphql");
});
});

View File

@@ -0,0 +1,18 @@
import type { CapabilityId } from "@suaveplan/api-types/capabilities";
/**
* Re-export of the `"graphql"` literal from the closed
* {@link CapabilityId} union maintained in `@suaveplan/api-types`.
*
* This package's role in the `CapabilityId` story is documentary: the
* authoritative union lives in `api-types` (and was authored with `"graphql"`
* already present so plugins requiring the capability typecheck). Stage-1
* contract packages re-export the relevant literal so downstream code can
* `import { GraphqlCapabilityId } from "@suaveplan/api-graphql/capability-id"`
* without reaching into `api-types`.
*/
export type GraphqlCapabilityId = Extract<CapabilityId, "graphql">;
/** Runtime constant — useful for `requires: [GRAPHQL_CAPABILITY_ID]`. */
export const GRAPHQL_CAPABILITY_ID =
"graphql" as const satisfies GraphqlCapabilityId;

View File

@@ -0,0 +1,4 @@
export {
GRAPHQL_CAPABILITY_ID,
type GraphqlCapabilityId,
} from "./capability-id.types.js";

View File

@@ -0,0 +1,84 @@
# `directives`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Host-supplied GraphQL directive descriptors that engine adapters install at
schema-build time. The contract layer ships two: `@auth(scope: String!)` for
per-field authorisation and `@rateLimit(rpm: Int!)` for per-field rate
limiting. Plugins reference them in SDL; engines wire the descriptor's
`handler` into the resolver pipeline.
Keeping the descriptors here (rather than in each engine adapter) means a
plugin author writes one piece of SDL and gets the same enforcement
semantics from every concrete engine. Engine adapters install the SDL via
the directive `sdl` string and dispatch to the descriptor's `handler` from
their resolver wrapper.
## Features
- `authDirective` — checks `ctx.principal.scopes` against the declared scope
and throws when missing. Locations: `FIELD_DEFINITION`.
- `rateLimitDirective` — validates the `rpm` integer bound; the actual
rate-limit accounting is the engine adapter's responsibility (the
contract body asserts the argument shape).
- `HOST_DIRECTIVES` — frozen map keyed by directive name for engine
adapter consumption.
## Usage: Basic
```ts
import { authDirective } from "@suaveplan/api-graphql/directives";
// SDL declaration:
// directive @auth(scope: String!) on FIELD_DEFINITION
//
// Used in a plugin's typeDefs:
// type Query { invoices: [Invoice!]! @auth(scope: "billing:read") }
console.log(authDirective.sdl);
```
## Usage: Advanced
Engine adapters install every host directive in one pass:
```ts
import { HOST_DIRECTIVES } from "@suaveplan/api-graphql/directives";
for (const directive of Object.values(HOST_DIRECTIVES)) {
schemaBuilder.addDirective({
name: directive.name,
sdl: directive.sdl,
run: directive.handler,
});
}
```
The `handler` is the contract-layer enforcement body. The `@auth` handler
reads `ctx.principal.scopes` (the typed `readonly Scope[]`) and raises a
plain `Error` when the required scope is absent — engine adapters wrap that
in the engine's GraphQL error type before returning it on the wire.
## API Reference
- `authDirective: DirectiveDescriptor<{ scope: string }>` — descriptor for
`@auth`.
- `rateLimitDirective: DirectiveDescriptor<{ rpm: number }>` — descriptor
for `@rateLimit`.
- `HOST_DIRECTIVES` — frozen `{ auth, rateLimit }` lookup.
## Implementation Notes
The descriptors carry **contract bodies**, not full implementations. A
production engine adapter wraps each resolver in a check that runs the
handler before invoking the resolver. For `@rateLimit`, the engine adapter
also wires the per-tenant counter; the contract-layer handler only enforces
the `rpm > 0` invariant so a misconfigured directive surfaces early.
Adding a new host directive: add a `DirectiveDescriptor`, append it to
`HOST_DIRECTIVES`, and document the SDL in the package README. The
`api-graphql-yoga` and `api-graphql-apollo` packages pick up new
descriptors automatically through the frozen map.

View File

@@ -0,0 +1,103 @@
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import type {
Principal,
PrincipalId,
Scope,
} from "@suaveplan/api-types/principal";
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
authDirective,
HOST_DIRECTIVES,
rateLimitDirective,
} from "./directives.js";
function makeCtx(scopes: readonly string[]): RequestContext {
const principal: Principal = {
id: "p1" as PrincipalId,
scopes: scopes as readonly Scope[],
claims: {},
};
return { principal } as unknown as RequestContext;
}
describe("auth and rateLimit descriptors present", () => {
it("authDirective has the expected descriptor shape", {
timeout: 5000,
}, () => {
expect(authDirective.name).toBe("auth");
expect(authDirective.locations).toContain("FIELD_DEFINITION");
expect(authDirective.args[0]).toEqual({
name: "scope",
type: "String!",
});
expect(authDirective.sdl).toContain("@auth");
});
it("rateLimitDirective has the expected descriptor shape", {
timeout: 5000,
}, () => {
expect(rateLimitDirective.name).toBe("rateLimit");
expect(rateLimitDirective.locations).toContain("FIELD_DEFINITION");
expect(rateLimitDirective.args[0]).toEqual({
name: "rpm",
type: "Int!",
});
});
it("HOST_DIRECTIVES exposes both descriptors", { timeout: 5000 }, () => {
expect(HOST_DIRECTIVES.auth).toBe(authDirective);
expect(HOST_DIRECTIVES.rateLimit).toBe(rateLimitDirective);
expect(Object.isFrozen(HOST_DIRECTIVES)).toBe(true);
});
it("authDirective.handler accepts principal with the scope", {
timeout: 5000,
}, () => {
const ctx = makeCtx(["billing:read"]);
expect(() =>
authDirective.handler({ scope: "billing:read" }, ctx)
).not.toThrow();
});
it("authDirective.handler rejects principal without the scope", {
timeout: 5000,
}, () => {
const ctx = makeCtx(["other:scope"]);
expect(() =>
authDirective.handler({ scope: "billing:read" }, ctx)
).toThrow(/principal does not carry/);
});
it("authDirective.handler rejects unauthenticated principal", {
timeout: 5000,
}, () => {
const ctx = { principal: null } as unknown as RequestContext;
expect(() =>
authDirective.handler({ scope: "billing:read" }, ctx)
).toThrow();
});
it("rateLimitDirective.handler accepts positive integer rpm", {
timeout: 5000,
}, () => {
const ctx = makeCtx([]);
expect(() =>
rateLimitDirective.handler({ rpm: 60 }, ctx)
).not.toThrow();
});
it("rateLimitDirective.handler rejects zero or negative rpm", {
timeout: 5000,
}, () => {
const ctx = makeCtx([]);
expect(() => rateLimitDirective.handler({ rpm: 0 }, ctx)).toThrow();
expect(() => rateLimitDirective.handler({ rpm: -1 }, ctx)).toThrow();
});
it("rateLimitDirective.handler rejects non-integer rpm", {
timeout: 5000,
}, () => {
const ctx = makeCtx([]);
expect(() => rateLimitDirective.handler({ rpm: 1.5 }, ctx)).toThrow();
});
});

View File

@@ -0,0 +1,52 @@
import type { DirectiveDescriptor } from "./directives.types.js";
/**
* `@auth(scope: String!)` directive descriptor. Engine adapters install it at
* schema-build time; the handler checks `ctx.principal` against the declared
* scope and raises the host's auth error when the scope is not present.
*
* The handler here is the **contract** body — concrete engines wire it into
* the resolver pipeline. The default raises a typed error noting the missing
* scope so callers see a stable failure shape during early integration.
*/
export const authDirective: DirectiveDescriptor<{ readonly scope: string }> = {
name: "auth",
sdl: "directive @auth(scope: String!) on FIELD_DEFINITION",
locations: ["FIELD_DEFINITION"],
args: [{ name: "scope", type: "String!" }],
handler: (args, ctx) => {
const scopes: readonly string[] = ctx.principal?.scopes ?? [];
if (!scopes.includes(args.scope)) {
throw new Error(
`@auth(scope: "${args.scope}") failed — principal does not carry the required scope`
);
}
},
};
/**
* `@rateLimit(rpm: Int!)` directive descriptor. Engine adapters install it at
* schema-build time; the handler delegates to the host's rate-limit slot.
* The contract body checks the argument bound and otherwise yields — concrete
* engines wire it into the request pipeline.
*/
export const rateLimitDirective: DirectiveDescriptor<{ readonly rpm: number }> =
{
name: "rateLimit",
sdl: "directive @rateLimit(rpm: Int!) on FIELD_DEFINITION",
locations: ["FIELD_DEFINITION"],
args: [{ name: "rpm", type: "Int!" }],
handler: (args) => {
if (!Number.isInteger(args.rpm) || args.rpm <= 0) {
throw new Error(
`@rateLimit(rpm: ${String(args.rpm)}) — rpm must be a positive integer`
);
}
},
};
/** Lookup of every host-supplied directive descriptor, keyed by name. */
export const HOST_DIRECTIVES = Object.freeze({
auth: authDirective,
rateLimit: rateLimitDirective,
}) as Readonly<Record<"auth" | "rateLimit", DirectiveDescriptor>>;

View File

@@ -0,0 +1,34 @@
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
/** GraphQL directive location enum subset relevant to the host directives. */
export type DirectiveLocation =
| "FIELD_DEFINITION"
| "OBJECT"
| "INTERFACE"
| "ARGUMENT_DEFINITION"
| "ENUM_VALUE"
| "INPUT_FIELD_DEFINITION";
/** Argument-shape descriptor — engine adapters validate inputs against it. */
export interface DirectiveArgSpec {
readonly name: string;
readonly type: "String!" | "Int!" | "Boolean!" | "[String!]!";
}
/**
* Host-side directive descriptor. Engine adapters (`api-graphql-yoga`,
* `api-graphql-apollo`) install the SDL declaration and wire `handler` into
* the resolver pipeline at schema-build time.
*/
export interface DirectiveDescriptor<
TArgs = Readonly<Record<string, unknown>>,
> {
readonly name: string;
readonly sdl: string;
readonly locations: ReadonlyArray<DirectiveLocation>;
readonly args: ReadonlyArray<DirectiveArgSpec>;
readonly handler: (
args: TArgs,
ctx: RequestContext
) => Promise<void> | void;
}

View File

@@ -0,0 +1,10 @@
export {
authDirective,
HOST_DIRECTIVES,
rateLimitDirective,
} from "./directives.js";
export type {
DirectiveArgSpec,
DirectiveDescriptor,
DirectiveLocation,
} from "./directives.types.js";

View File

@@ -0,0 +1,35 @@
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { MergedSchema } from "../federation/federation.types.js";
import type { GraphqlRegistry } from "../registry/registry.types.js";
import type {
GraphqlEngine,
GraphqlRequest,
GraphqlResponse,
} from "./engine.types.js";
describe("GraphqlEngine type conformance", () => {
it("declares buildSchema executeQuery executeSubscription dispose", {
timeout: 5000,
}, () => {
expectTypeOf<GraphqlEngine["buildSchema"]>().toEqualTypeOf<
(registry: GraphqlRegistry) => MergedSchema
>();
expectTypeOf<GraphqlEngine["executeQuery"]>().toEqualTypeOf<
(
req: GraphqlRequest,
ctx: RequestContext
) => Promise<GraphqlResponse>
>();
expectTypeOf<GraphqlEngine["executeSubscription"]>().toEqualTypeOf<
(
req: GraphqlRequest,
ctx: RequestContext
) => AsyncIterable<GraphqlResponse>
>();
expectTypeOf<GraphqlEngine["dispose"]>().toEqualTypeOf<
() => Promise<void>
>();
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,51 @@
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import type { MergedSchema } from "../federation/federation.types.js";
import type { GraphqlRegistry } from "../registry/registry.types.js";
/**
* Inbound GraphQL request envelope. The transport (HTTP POST, WebSocket
* frame) decodes its wire form into this shape before dispatch.
*/
export interface GraphqlRequest {
readonly query: string;
readonly variables?: Readonly<Record<string, unknown>>;
readonly operationName?: string;
}
/** Outbound GraphQL response envelope — covers single-shot query/mutation. */
export interface GraphqlResponse {
readonly data?: Readonly<Record<string, unknown>> | null;
readonly errors?: ReadonlyArray<{
readonly message: string;
readonly path?: ReadonlyArray<string | number>;
readonly extensions?: Readonly<Record<string, unknown>>;
}>;
readonly extensions?: Readonly<Record<string, unknown>>;
}
/**
* Lifecycle contract every concrete GraphQL engine implements. The host
* builds the schema once at boot, then executes each request via the
* `executeQuery` / `executeSubscription` methods. `dispose` returns the
* engine to its uninstalled state.
*/
export interface GraphqlEngine<
Caps extends CapabilityRequirements = CapabilityRequirements,
> {
buildSchema(registry: GraphqlRegistry<Caps>): MergedSchema;
executeQuery(
req: GraphqlRequest,
ctx: RequestContext<Caps>
): Promise<GraphqlResponse>;
executeSubscription(
req: GraphqlRequest,
ctx: RequestContext<Caps>
): AsyncIterable<GraphqlResponse>;
dispose(): Promise<void>;
}
/** Factory shape used by the conformance harness. */
export type GraphqlEngineFactory<
Caps extends CapabilityRequirements = CapabilityRequirements,
> = () => GraphqlEngine<Caps>;

View File

@@ -0,0 +1,6 @@
export type {
GraphqlEngine,
GraphqlEngineFactory,
GraphqlRequest,
GraphqlResponse,
} from "./engine.types.js";

View File

@@ -0,0 +1,80 @@
# `error-codes`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Stable error-code registry for `@suaveplan/api-graphql`. Every public failure
path raises a `GraphqlError` carrying one of these namespaced `GRAPHQL.*`
codes; the host's `ErrorEnvelope` writer maps them directly to the JSON wire
shape declared by `@suaveplan/api-types/errors`.
Centralising the registry here lets engine adapters (`api-graphql-yoga`,
`api-graphql-apollo`) inherit the seven base codes and add engine-specific
codes under a longer namespace (`GRAPHQL.YOGA.*`) without colliding with the
contract layer.
## Features
- Seven frozen codes covering schema federation, resolver wiring, loader
registration, directive lookup, subscription transport availability, and
strict-mode namespace enforcement.
- Typed `GraphqlError` subclass — `instanceof` narrows the `code` field to
`GraphqlErrorCode`.
- Constructor widened to `public` so callers may instantiate directly.
- Side-effect import from the package barrel surfaces the registry at
load time (matches `api-plugin`'s pattern).
## Usage: Basic
```ts
import { GRAPHQL_CODES, GraphqlError } from "@suaveplan/api-graphql/error-codes";
throw new GraphqlError(
GRAPHQL_CODES.SCHEMA_COLLISION,
"two plugins declare Query.users"
);
```
## Usage: Advanced
Attach structured detail for the wire envelope:
```ts
throw new GraphqlError(
"GRAPHQL.SCHEMA_COLLISION",
"schema collision on Query.users between plugins Billing and Identity",
{
details: {
path: "Query.users",
plugins: ["Billing", "Identity"],
},
}
);
```
The host's error-envelope writer pipes `details` straight into the JSON
response without modification.
## API Reference
- `GRAPHQL_CODES` — frozen record `{ SCHEMA_COLLISION, RESOLVER_MISSING,
LOADER_DUPLICATE_NAME, LOADER_NOT_REGISTERED, DIRECTIVE_UNKNOWN,
SUBSCRIPTION_TRANSPORT_UNAVAILABLE, NAMESPACE_REQUIRED }`. Values are the
fully-qualified `GRAPHQL.<NAME>` strings.
- `GraphqlErrorCode` — string union derived from `GRAPHQL_CODES`.
- `GraphqlError extends SuaveplanError<GraphqlErrorCode>` — adds a
read-only `graphqlCode` mirror for ergonomic branching.
## Implementation Notes
`defineCodes` returns a `Object.freeze`d record so the union literal is
preserved at the type level (TypeScript widens otherwise). Callers may
branch on the string literal (`"GRAPHQL.SCHEMA_COLLISION"`) or the typed
constant (`GRAPHQL_CODES.SCHEMA_COLLISION`) — both produce the same value.
Concrete engines that raise additional codes do so via their own
`defineCodes("GRAPHQL.YOGA", [...])` registry; this package owns only the
contract-layer codes that map to engine-agnostic failure modes.

View File

@@ -0,0 +1,35 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GRAPHQL_CODES } from "./error-codes.js";
const SRC_ROOT = new URL("..", import.meta.url).pathname;
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const st = statSync(p);
if (st.isDirectory()) out.push(...walk(p));
else out.push(p);
}
return out;
}
describe("GRAPHQL_CODES conformance", () => {
it("every code is thrown by a production path", { timeout: 5000 }, () => {
const files = walk(SRC_ROOT).filter(
(f) =>
f.endsWith(".ts") &&
!f.endsWith(".test.ts") &&
!f.endsWith(".types.ts")
);
const source = files.map((f) => readFileSync(f, "utf8")).join("\n");
for (const code of Object.values(GRAPHQL_CODES)) {
expect({ code, thrown: source.includes(code) }).toEqual({
code,
thrown: true,
});
}
});
});

View File

@@ -0,0 +1,58 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import {
GRAPHQL_CODES,
GraphqlError,
type GraphqlErrorCode,
} from "./error-codes.js";
describe("GRAPHQL_CODES registry", () => {
it("exposes the seven contract-layer codes", { timeout: 5000 }, () => {
expect(GRAPHQL_CODES.SCHEMA_COLLISION).toBe("GRAPHQL.SCHEMA_COLLISION");
expect(GRAPHQL_CODES.RESOLVER_MISSING).toBe("GRAPHQL.RESOLVER_MISSING");
expect(GRAPHQL_CODES.LOADER_DUPLICATE_NAME).toBe(
"GRAPHQL.LOADER_DUPLICATE_NAME"
);
expect(GRAPHQL_CODES.LOADER_NOT_REGISTERED).toBe(
"GRAPHQL.LOADER_NOT_REGISTERED"
);
expect(GRAPHQL_CODES.DIRECTIVE_UNKNOWN).toBe(
"GRAPHQL.DIRECTIVE_UNKNOWN"
);
expect(GRAPHQL_CODES.SUBSCRIPTION_TRANSPORT_UNAVAILABLE).toBe(
"GRAPHQL.SUBSCRIPTION_TRANSPORT_UNAVAILABLE"
);
expect(GRAPHQL_CODES.NAMESPACE_REQUIRED).toBe(
"GRAPHQL.NAMESPACE_REQUIRED"
);
});
it("registry is frozen", { timeout: 5000 }, () => {
expect(Object.isFrozen(GRAPHQL_CODES)).toBe(true);
});
it("GraphqlError carries the graphqlCode mirror", { timeout: 5000 }, () => {
const err = new GraphqlError(
"GRAPHQL.RESOLVER_MISSING",
"missing resolver"
);
expect(err).toBeInstanceOf(GraphqlError);
expect(err.graphqlCode).toBe("GRAPHQL.RESOLVER_MISSING");
expect(err.code).toBe("GRAPHQL.RESOLVER_MISSING");
expect(err.message).toBe("missing resolver");
});
it("GraphqlError accepts SuaveplanErrorOptions", { timeout: 5000 }, () => {
const cause = new Error("downstream");
const err = new GraphqlError("GRAPHQL.DIRECTIVE_UNKNOWN", "boom", {
cause,
context: { directive: "auth" },
});
expect(err.cause).toBe(cause);
expect(err.context).toEqual({ directive: "auth" });
});
it("GraphqlErrorCode union narrows correctly", { timeout: 5000 }, () => {
const code: GraphqlErrorCode = GRAPHQL_CODES.NAMESPACE_REQUIRED;
expect(code).toBe("GRAPHQL.NAMESPACE_REQUIRED");
});
});

View File

@@ -0,0 +1,55 @@
import type { SuaveplanErrorOptions } from "@suaveplan/error";
import { defineCodes, SuaveplanError } from "@suaveplan/error";
/**
* Frozen `GRAPHQL.*` error-code registry for `@suaveplan/api-graphql`.
*
* The seven codes cover the abstract surface only — concrete engines
* (`api-graphql-yoga`, `api-graphql-apollo`) inherit this registry and add
* engine-specific codes under their own sub-namespace.
*
* - `SCHEMA_COLLISION` — two plugins declare the same `Type.field` without
* one of them being an `extend`. `details.path` carries `Type.field`;
* `details.plugins` carries the offending plugin pair `[A, B]`.
* - `RESOLVER_MISSING` — SDL declares a field but no resolver was added.
* - `LOADER_DUPLICATE_NAME` — two `addLoader` calls registered the same
* loader name. The registry rejects the second call.
* - `LOADER_NOT_REGISTERED` — `RequestContext.loaders.<name>` resolves
* to an unknown loader.
* - `DIRECTIVE_UNKNOWN` — schema references a directive whose descriptor
* has not been installed by the host.
* - `SUBSCRIPTION_TRANSPORT_UNAVAILABLE` — the GraphQL subscription
* contract was activated but `api-ws` was not wired into the host.
* - `NAMESPACE_REQUIRED` — strict-mode federation requires every plugin
* to declare `graphql.namespace`; one did not.
*/
export const GRAPHQL_CODES = defineCodes("GRAPHQL", [
"SCHEMA_COLLISION",
"RESOLVER_MISSING",
"LOADER_DUPLICATE_NAME",
"LOADER_NOT_REGISTERED",
"DIRECTIVE_UNKNOWN",
"SUBSCRIPTION_TRANSPORT_UNAVAILABLE",
"NAMESPACE_REQUIRED",
] as const);
export type GraphqlErrorCode =
(typeof GRAPHQL_CODES)[keyof typeof GRAPHQL_CODES];
/**
* Concrete error class for the api-graphql package. Extends
* {@link SuaveplanError} so `instanceof GraphqlError` narrows `code` to
* {@link GraphqlErrorCode}.
*/
export class GraphqlError extends SuaveplanError<GraphqlErrorCode> {
public readonly graphqlCode: GraphqlErrorCode;
public constructor(
code: GraphqlErrorCode,
message: string,
options?: SuaveplanErrorOptions
) {
super(code, message, options);
this.graphqlCode = code;
}
}

View File

@@ -0,0 +1,5 @@
export {
GRAPHQL_CODES,
GraphqlError,
type GraphqlErrorCode,
} from "./error-codes.js";

View File

@@ -0,0 +1,46 @@
/**
* Federation contract — plugin → host SDL contribution shape.
*
* Each plugin hands the host a `SdlContribution`; the host calls
* {@link mergeSdl} to produce one `MergedSchema`. Engines (`api-graphql-yoga`,
* `api-graphql-apollo`) consume the merged SDL when building the executable
* schema.
*/
/** A single plugin's GraphQL contribution. */
export interface SdlContribution {
/** Plugin name — used in {@link SchemaCollisionDetail.plugins}. */
readonly plugin: string;
/** SDL strings to union. Multiple snippets allowed; they are concatenated. */
readonly typeDefs: ReadonlyArray<string>;
/**
* Optional namespace. When set, the plugin's `Query`/`Mutation`/
* `Subscription` extensions are wrapped in `<Namespace>Query`/
* `<Namespace>Mutation`/`<Namespace>Subscription` types reachable from
* `Query.<lowercaseNamespace>` etc.
*/
readonly namespace?: string;
}
/** Detail payload attached to a `GRAPHQL.SCHEMA_COLLISION` error. */
export interface SchemaCollisionDetail {
/** `Type.field` of the colliding field. */
readonly path: string;
/** The two plugins (alphabetically sorted) that contributed it. */
readonly plugins: readonly [string, string];
}
/** Result of {@link mergeSdl} — the union of every contribution. */
export interface MergedSchema {
/** Final SDL string, in declaration order: types → extends → directives. */
readonly sdl: string;
/** Map of `Type` → ordered list of field names, for runtime introspection. */
readonly typeFields: Readonly<Record<string, ReadonlyArray<string>>>;
/** Plugins that contributed, alphabetically sorted. */
readonly plugins: ReadonlyArray<string>;
}
/** Strict-mode flag — when on, every plugin MUST declare a namespace. */
export interface MergeOptions {
readonly strictNamespace?: boolean;
}

View File

@@ -0,0 +1,13 @@
export type {
MergedSchema,
MergeOptions,
SchemaCollisionDetail,
SdlContribution,
} from "./federation.types.js";
export { mergeSdl } from "./merge-sdl.js";
export { applyNamespace, namespaceFieldName } from "./namespace.js";
export {
parseSdl,
stripCommentsAndDescriptions,
type TypeBlock,
} from "./parse-sdl.js";

View File

@@ -0,0 +1,101 @@
# `merge-sdl`
## Purpose
Federate one or more plugin SDL contributions into a single merged schema.
This is the engine-agnostic federation step described in design §10.2: each
plugin hands the host a `SdlContribution`; the host calls `mergeSdl` to
produce the canonical SDL string and field map an engine consumes when
building its executable schema.
The merger has no `graphql.js` dependency — it parses just enough SDL to
detect field-level collisions and apply the `graphql.namespace` wrapper.
Engines re-parse the result with their own AST machinery; the merger's role
is the cross-plugin invariant check.
## Features
- Unions `extend type Query { ... }` blocks from multiple plugins.
- Detects `Type.field` collisions and raises `GRAPHQL.SCHEMA_COLLISION`
with `details.path` and `details.plugins = [A, B]` alphabetically sorted.
- Applies the `graphql.namespace` wrapper: `Query`/`Mutation`/`Subscription`
extensions land under `<Namespace>Query` etc., with the root operation
type extended to expose the wrapper.
- Strict mode (`{ strictNamespace: true }`) raises
`GRAPHQL.NAMESPACE_REQUIRED` when any plugin omits a namespace.
- Returns an immutable `MergedSchema` with the concatenated SDL, the
`Type → fields[]` map, and the alphabetically-sorted plugin list.
## Usage: Basic
```ts
import { mergeSdl } from "@suaveplan/api-graphql/federation";
const merged = mergeSdl([
{
plugin: "Billing",
typeDefs: ["extend type Query { invoices: [Invoice!]! } type Invoice { id: ID! }"],
},
{
plugin: "Identity",
typeDefs: ["extend type Query { users: [User!]! } type User { id: ID! }"],
},
]);
console.log(merged.sdl);
console.log(merged.typeFields.Query); // ["invoices", "users"]
```
## Usage: Advanced
Strict mode + namespace wrapping for a multi-tenant host where every plugin
must be reachable under its own root field:
```ts
import { mergeSdl, GraphqlError } from "@suaveplan/api-graphql";
try {
mergeSdl(
[
{
plugin: "Billing",
namespace: "Billing",
typeDefs: ["extend type Query { invoices: [Invoice!]! }"],
},
{
plugin: "Identity",
// no namespace — strict mode rejects
typeDefs: ["extend type Query { users: [User!]! }"],
},
],
{ strictNamespace: true }
);
} catch (e) {
if (e instanceof GraphqlError) {
console.error(e.graphqlCode); // "GRAPHQL.NAMESPACE_REQUIRED"
}
}
```
## API Reference
- `mergeSdl(contributions, options?) → MergedSchema` — federation entry
point.
- `SdlContribution` — `{ plugin, typeDefs, namespace? }`.
- `MergedSchema` — `{ sdl, typeFields, plugins }`.
- `MergeOptions` — `{ strictNamespace? }`.
- `SchemaCollisionDetail` — error-detail shape for the collision code.
## Implementation Notes
The collision detector tracks the first plugin that registered each
`Type.field` and raises on the second non-self registration. Repeated
declarations from the same plugin are tolerated (the federation step is
re-runnable). Sorting the plugin pair before raising keeps error messages
deterministic in tests.
`extend` vs concrete `type` is recorded but not enforced by the merger
itself — engines reject schemas with multiple concrete declarations of the
same type at schema-build time. The merger's invariant is field-level: two
plugins MAY both add fields to `Query` (via `extend`), but they MUST NOT
add the same field name.

View File

@@ -0,0 +1,142 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GraphqlError } from "../error-codes/error-codes.js";
import { mergeSdl } from "./merge-sdl.js";
describe("mergeSdl", () => {
it("two plugins extend Query without collision", { timeout: 5000 }, () => {
const merged = mergeSdl([
{
plugin: "A",
typeDefs: ["extend type Query { invoices: Int! }"],
},
{
plugin: "B",
typeDefs: ["extend type Query { users: Int! }"],
},
]);
expect(merged.typeFields.Query).toEqual(["invoices", "users"]);
expect(merged.plugins).toEqual(["A", "B"]);
expect(merged.sdl).toContain("invoices");
expect(merged.sdl).toContain("users");
});
it("collision reports plugin pair", { timeout: 5000 }, () => {
let raised: GraphqlError | undefined;
try {
mergeSdl([
{ plugin: "B", typeDefs: ["type Query { users: Int! }"] },
{ plugin: "A", typeDefs: ["type Query { users: Int! }"] },
]);
} catch (e) {
raised = e as GraphqlError;
}
expect(raised).toBeInstanceOf(GraphqlError);
expect(raised?.graphqlCode).toBe("GRAPHQL.SCHEMA_COLLISION");
expect(raised?.context).toEqual({
path: "Query.users",
plugins: ["A", "B"],
});
});
it("multiple contributions from same plugin do not collide", {
timeout: 5000,
}, () => {
const merged = mergeSdl([
{
plugin: "A",
typeDefs: [
"extend type Query { a: Int! }",
"extend type Query { b: Int! }",
],
},
]);
expect(merged.typeFields.Query).toEqual(["a", "b"]);
});
it("same plugin re-registering a field is a no-op", {
timeout: 5000,
}, () => {
const merged = mergeSdl([
{
plugin: "A",
typeDefs: [
"extend type Query { dup: Int! }",
"extend type Query { dup: Int! }",
],
},
]);
expect(merged.typeFields.Query).toEqual(["dup"]);
});
it("strictNamespace requires every plugin to have a namespace", {
timeout: 5000,
}, () => {
let raised: GraphqlError | undefined;
try {
mergeSdl(
[{ plugin: "X", typeDefs: ["extend type Query { a: Int! }"] }],
{ strictNamespace: true }
);
} catch (e) {
raised = e as GraphqlError;
}
expect(raised).toBeInstanceOf(GraphqlError);
expect(raised?.graphqlCode).toBe("GRAPHQL.NAMESPACE_REQUIRED");
});
it("strictNamespace accepts namespaced plugins", { timeout: 5000 }, () => {
const merged = mergeSdl(
[
{
plugin: "X",
namespace: "Billing",
typeDefs: ["extend type Query { invoices: Int! }"],
},
],
{ strictNamespace: true }
);
expect(merged.typeFields.Query).toEqual(["billing"]);
expect(merged.typeFields.BillingQuery).toEqual(["invoices"]);
});
it("strictNamespace rejects an empty-string namespace", {
timeout: 5000,
}, () => {
let raised: GraphqlError | undefined;
try {
mergeSdl(
[
{
plugin: "X",
namespace: "",
typeDefs: ["extend type Query { a: Int! }"],
},
],
{ strictNamespace: true }
);
} catch (e) {
raised = e as GraphqlError;
}
expect(raised?.graphqlCode).toBe("GRAPHQL.NAMESPACE_REQUIRED");
});
it("non-strict mode tolerates missing namespaces", {
timeout: 5000,
}, () => {
const merged = mergeSdl([
{ plugin: "X", typeDefs: ["extend type Query { a: Int! }"] },
]);
expect(merged.typeFields.Query).toEqual(["a"]);
});
it("plugin list is alphabetically sorted and deduplicated", {
timeout: 5000,
}, () => {
const merged = mergeSdl([
{ plugin: "B", typeDefs: ["extend type Query { b: Int! }"] },
{ plugin: "A", typeDefs: ["extend type Query { a: Int! }"] },
{ plugin: "B", typeDefs: ["extend type Query { c: Int! }"] },
]);
expect(merged.plugins).toEqual(["A", "B"]);
});
});

View File

@@ -0,0 +1,105 @@
import { GraphqlError } from "../error-codes/error-codes.js";
import type {
MergedSchema,
MergeOptions,
SdlContribution,
} from "./federation.types.js";
import { applyNamespace } from "./namespace.js";
import { parseSdl, type TypeBlock } from "./parse-sdl.js";
interface OwnedField {
readonly plugin: string;
readonly kind: "type" | "extend";
}
/**
* Federate one or more plugin SDL contributions into a single merged schema.
*
* Rules (design §10.2):
* - Two plugins MAY both `extend type Query { ... }` with disjoint fields.
* - Two plugins declaring the same `Type.field` from `type` (non-extend)
* blocks collide → `GRAPHQL.SCHEMA_COLLISION` is thrown.
* - A plugin's contributions MAY be wrapped via `namespace` so its root
* extensions land under `Query.<lowercaseNs>: <Ns>Query!` etc.
* - Strict mode (`{ strictNamespace: true }`) requires every contributing
* plugin to declare a namespace → `GRAPHQL.NAMESPACE_REQUIRED`.
*/
export function mergeSdl(
contributions: ReadonlyArray<SdlContribution>,
options: MergeOptions = {}
): MergedSchema {
if (options.strictNamespace === true) {
for (const c of contributions) {
if (
c.namespace === undefined ||
c.namespace === null ||
c.namespace.length === 0
) {
throw new GraphqlError(
"GRAPHQL.NAMESPACE_REQUIRED",
`strict-mode federation requires plugin "${c.plugin}" to declare graphql.namespace`,
{ context: { plugin: c.plugin } }
);
}
}
}
const ownedFields = new Map<string, OwnedField>();
const typeFields = new Map<string, string[]>();
const rawBlocks: string[] = [];
for (const c of contributions) {
let blocks: ReadonlyArray<TypeBlock> = [];
for (const td of c.typeDefs) {
blocks = blocks.concat(parseSdl(td));
}
const ns = c.namespace ?? "";
const wrapped = ns.length > 0 ? applyNamespace(blocks, ns) : blocks;
for (const block of wrapped) {
registerBlock(c.plugin, block, ownedFields, typeFields, rawBlocks);
}
}
const plugins = [...new Set(contributions.map((c) => c.plugin))].sort();
const sdl = rawBlocks.join("\n");
const frozenFields: Record<string, ReadonlyArray<string>> = {};
for (const [type, fields] of typeFields.entries()) {
frozenFields[type] = Object.freeze([...fields]);
}
return Object.freeze({
sdl,
typeFields: Object.freeze(frozenFields),
plugins: Object.freeze(plugins),
}) as MergedSchema;
}
function registerBlock(
plugin: string,
block: TypeBlock,
ownedFields: Map<string, OwnedField>,
typeFields: Map<string, string[]>,
rawBlocks: string[]
): void {
for (const field of block.fields) {
const key = `${block.typeName}.${field}`;
const existing = ownedFields.get(key);
if (existing !== undefined && existing.plugin !== plugin) {
const pair: [string, string] = [existing.plugin, plugin].sort() as [
string,
string,
];
throw new GraphqlError(
"GRAPHQL.SCHEMA_COLLISION",
`schema collision on ${key} between plugins ${pair[0]} and ${pair[1]}`,
{ context: { path: key, plugins: pair } }
);
}
if (existing === undefined) {
ownedFields.set(key, { plugin, kind: block.kind });
const list = typeFields.get(block.typeName) ?? [];
list.push(field);
typeFields.set(block.typeName, list);
}
}
rawBlocks.push(block.rawBlock);
}

View File

@@ -0,0 +1,73 @@
# `namespace`
## Purpose
Apply the `graphql.namespace` wrapper to a plugin's parsed SDL blocks.
Plugins that declare `namespace: "Billing"` in their manifest get their
root-operation extensions repackaged: `extend type Query { invoices }`
becomes `type BillingQuery { invoices }` plus
`extend type Query { billing: BillingQuery! }`. Non-root types pass
through unchanged.
This is the second half of the federation story — `mergeSdl` calls
`applyNamespace` on each plugin's contributions before unioning them, so
namespaced plugins never collide on root fields.
## Features
- Wraps `Query`, `Mutation`, `Subscription` extensions in
`<Ns>Query`/`<Ns>Mutation`/`<Ns>Subscription` types.
- Emits exactly one root-extension per wrapped operation, even when the
plugin contributed multiple `extend type Query` blocks.
- Lower-cases the first character of the namespace label so it works as
a field name (`Billing` → `billing`).
- Leaves non-root types unchanged — entity types like `Invoice` stay at
the top level.
## Usage: Basic
```ts
import { applyNamespace, parseSdl } from "@suaveplan/api-graphql/federation";
const blocks = parseSdl("extend type Query { invoices: [Invoice!]! }");
const wrapped = applyNamespace(blocks, "Billing");
// wrapped[0] is `type BillingQuery { invoices: [Invoice!]! }`
// wrapped[1] is `extend type Query { billing: BillingQuery! }`
```
## Usage: Advanced
Multiple root extensions in one plugin collapse to one wrapper root field:
```ts
import { applyNamespace, parseSdl } from "@suaveplan/api-graphql/federation";
const blocks = parseSdl(
"extend type Query { a: Int! } extend type Query { b: Int! }"
);
const wrapped = applyNamespace(blocks, "Demo");
// Two `type DemoQuery` blocks (the engine merges them) but only one
// `extend type Query { demo: DemoQuery! }` is emitted.
```
The merger consolidates multiple `type DemoQuery` blocks via the field-list
deduplication path; the single root-extension keeps `Query` clean.
## API Reference
- `applyNamespace(blocks, namespace) → TypeBlock[]` — wrap root-operation
blocks; pass non-root blocks through.
- `namespaceFieldName(namespace) → string` — `"Billing"` → `"billing"`.
## Implementation Notes
The wrapping is purely textual at the block level — the inner block has
its `extend type Query` prefix rewritten to `type BillingQuery` and the
generated root-extension is a short SDL string. The merger's collision
detector still runs over the wrapped output, so two plugins that both
declare `namespace: "Billing"` will collide on `Query.billing` and raise
`GRAPHQL.SCHEMA_COLLISION` as expected.
Empty namespace strings short-circuit: the input blocks are returned
verbatim. Callers MUST treat `namespace === ""` and `namespace === undefined`
identically — the federation entry point already does so.

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { mergeSdl } from "./merge-sdl.js";
import { applyNamespace, namespaceFieldName } from "./namespace.js";
import { parseSdl } from "./parse-sdl.js";
describe("namespace wrapper", () => {
it("namespaceFieldName lowercases first letter", { timeout: 5000 }, () => {
expect(namespaceFieldName("Billing")).toBe("billing");
expect(namespaceFieldName("X")).toBe("x");
expect(namespaceFieldName("")).toBe("");
});
it("wraps Query extension under <Ns>Query type", { timeout: 5000 }, () => {
const blocks = parseSdl("extend type Query { invoices: Int! }");
const wrapped = applyNamespace(blocks, "Billing");
// first block: type BillingQuery { invoices: Int! }
expect(wrapped[0]?.typeName).toBe("BillingQuery");
expect(wrapped[0]?.kind).toBe("type");
// second block: extend type Query { billing: BillingQuery! }
expect(wrapped[1]?.typeName).toBe("Query");
expect(wrapped[1]?.kind).toBe("extend");
expect(wrapped[1]?.fields).toEqual(["billing"]);
});
it("wraps Mutation and Subscription similarly", { timeout: 5000 }, () => {
const blocks = parseSdl(
"extend type Mutation { pay: Boolean! } extend type Subscription { tick: Int! }"
);
const wrapped = applyNamespace(blocks, "Billing");
expect(wrapped.map((b) => b.typeName)).toContain("BillingMutation");
expect(wrapped.map((b) => b.typeName)).toContain("BillingSubscription");
});
it("leaves non-root types unchanged", { timeout: 5000 }, () => {
const blocks = parseSdl("type Invoice { id: ID! total: Int! }");
const wrapped = applyNamespace(blocks, "Billing");
expect(wrapped).toHaveLength(1);
expect(wrapped[0]?.typeName).toBe("Invoice");
});
it("only emits one root extension per operation even with multiple inner blocks", {
timeout: 5000,
}, () => {
const blocks = parseSdl(
"extend type Query { a: Int! } extend type Query { b: Int! }"
);
const wrapped = applyNamespace(blocks, "Demo");
const rootExtensions = wrapped.filter(
(b) => b.kind === "extend" && b.typeName === "Query"
);
expect(rootExtensions).toHaveLength(1);
});
it("empty namespace short-circuits", { timeout: 5000 }, () => {
const blocks = parseSdl("extend type Query { a: Int! }");
const wrapped = applyNamespace(blocks, "");
expect(wrapped).toBe(blocks);
});
it("namespace wrapper is reachable via mergeSdl", { timeout: 5000 }, () => {
const merged = mergeSdl([
{
plugin: "Billing",
namespace: "Billing",
typeDefs: ["extend type Query { invoices: Int! }"],
},
]);
expect(merged.typeFields.Query).toEqual(["billing"]);
expect(merged.typeFields.BillingQuery).toEqual(["invoices"]);
expect(merged.sdl).toContain("billing: BillingQuery!");
});
});

View File

@@ -0,0 +1,66 @@
import type { TypeBlock } from "./parse-sdl.js";
const ROOT_OPS = new Set(["Query", "Mutation", "Subscription"]);
/**
* Lower-cases the first character of a namespace label so it can be used as
* a field name on the root operation type. `Billing` → `billing`.
*/
export function namespaceFieldName(namespace: string): string {
if (namespace.length === 0) return namespace;
return namespace[0]!.toLowerCase() + namespace.slice(1);
}
/**
* Apply the `graphql.namespace` wrapper to a plugin's parsed blocks.
*
* For each `extend type Query { invoices }` contributed by a plugin with
* `namespace: "Billing"`, the merger emits:
*
* ```graphql
* type BillingQuery { invoices: Invoice! }
* extend type Query { billing: BillingQuery! }
* ```
*
* Non-root types (e.g. `type Invoice`) are returned unchanged: namespaces
* only wrap root-operation extensions.
*/
export function applyNamespace(
blocks: ReadonlyArray<TypeBlock>,
namespace: string
): ReadonlyArray<TypeBlock> {
if (namespace.length === 0) return blocks;
const out: TypeBlock[] = [];
const wrapped = new Set<string>();
for (const block of blocks) {
if (block.kind === "extend" && ROOT_OPS.has(block.typeName)) {
const wrapperType = `${namespace}${block.typeName}`;
const innerBody = block.rawBlock
.replace(/^extend\s+type\s+/, "type ")
.replace(
new RegExp(`type\\s+${block.typeName}`),
`type ${wrapperType}`
);
out.push({
kind: "type",
typeName: wrapperType,
fields: block.fields,
rawBlock: innerBody,
});
if (!wrapped.has(block.typeName)) {
wrapped.add(block.typeName);
const fieldName = namespaceFieldName(namespace);
const rootExtension = `extend type ${block.typeName} { ${fieldName}: ${wrapperType}! }`;
out.push({
kind: "extend",
typeName: block.typeName,
fields: [fieldName],
rawBlock: rootExtension,
});
}
} else {
out.push(block);
}
}
return out;
}

View File

@@ -0,0 +1,73 @@
# `parse-sdl`
## Purpose
Minimal SDL parser used by the federation merger to locate `type Name { ... }`
and `extend type Name { ... }` blocks plus the field names inside each.
This is **not** a full GraphQL AST parser — it is the contract-layer
dependency-free helper that recognises the constructs the federation rules
care about (design §10.2).
Engine adapters re-parse the merged SDL with their own AST machinery. The
purpose of this parser is the cross-plugin invariant check before the
engine ever sees the schema.
## Features
- Recognises `type Name { ... }` and `extend type Name { ... }` blocks.
- Extracts field names (handles inline argument lists `field(a: Int): T`).
- Strips line comments (`# ...`) and block string descriptions
(`"""..."""`) before parsing.
- Ignores union / interface / input / enum / scalar declarations — these
pass through to the merged SDL but don't contribute to the field-level
collision check.
## Usage: Basic
```ts
import { parseSdl } from "@suaveplan/api-graphql/federation";
const blocks = parseSdl(
"type Invoice { id: ID! total: Int! } extend type Query { invoices: [Invoice!]! }"
);
console.log(blocks[0].typeName); // "Invoice"
console.log(blocks[1].kind); // "extend"
console.log(blocks[1].fields); // ["invoices"]
```
## Usage: Advanced
Strip descriptions before parsing for downstream tooling that wants only
the structural SDL:
```ts
import { stripCommentsAndDescriptions } from "@suaveplan/api-graphql/federation";
const cleaned = stripCommentsAndDescriptions(`
"""Invoice domain root"""
type Invoice {
# the canonical id
id: ID!
}
`);
```
## API Reference
- `parseSdl(sdl) → TypeBlock[]` — extract structured blocks.
- `stripCommentsAndDescriptions(sdl) → string` — comment + block-string
removal.
- `TypeBlock` — `{ kind: "type" | "extend"; typeName; fields; rawBlock }`.
## Implementation Notes
The parser uses three regexes: one for block-string descriptions, one for
line comments, and one for the type/extend block envelope. Field-name
extraction is line-oriented; multi-line argument lists fall back to the
inline-argument regex, which is `([a-z_][a-z0-9_]*)\s*(?:\([^)]*\))?\s*:`.
Pathological inputs (nested braces inside field type expressions, e.g.
`field: { ... }`) are not supported — GraphQL SDL doesn't use them. If a
future GraphQL spec extension introduces nested braces, the regex would
need replacing with a proper tokeniser; that's a known limit and acceptable
for the contract layer.

View File

@@ -0,0 +1,62 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { parseSdl, stripCommentsAndDescriptions } from "./parse-sdl.js";
describe("parseSdl", () => {
it("recognises type and extend blocks", { timeout: 5000 }, () => {
const blocks = parseSdl(
"type Invoice { id: ID! } extend type Query { invoices: [Invoice!]! }"
);
expect(blocks).toHaveLength(2);
expect(blocks[0]?.kind).toBe("type");
expect(blocks[0]?.typeName).toBe("Invoice");
expect(blocks[1]?.kind).toBe("extend");
expect(blocks[1]?.typeName).toBe("Query");
});
it("extracts field names", { timeout: 5000 }, () => {
const blocks = parseSdl(`
type Invoice {
id: ID!
total: Int!
lines(limit: Int): [Line!]!
}
`);
expect(blocks[0]?.fields).toEqual(["id", "total", "lines"]);
});
it("strips comments before parsing", { timeout: 5000 }, () => {
const blocks = parseSdl(`
# leading comment
type Q {
a: Int! # inline comment
}
`);
expect(blocks[0]?.fields).toEqual(["a"]);
});
it("strips block-string descriptions", { timeout: 5000 }, () => {
const stripped = stripCommentsAndDescriptions(
'"""description""" type X { a: Int! }'
);
expect(stripped).not.toContain("description");
expect(stripped).toContain("type X");
});
it("ignores empty bodies gracefully", { timeout: 5000 }, () => {
const blocks = parseSdl("type X { }");
expect(blocks[0]?.fields).toEqual([]);
});
it("handles implements clause", { timeout: 5000 }, () => {
const blocks = parseSdl(
"type Invoice implements Node & Audited { id: ID! }"
);
expect(blocks[0]?.typeName).toBe("Invoice");
expect(blocks[0]?.fields).toEqual(["id"]);
});
it("returns empty list for non-type SDL", { timeout: 5000 }, () => {
const blocks = parseSdl("scalar DateTime");
expect(blocks).toEqual([]);
});
});

View File

@@ -0,0 +1,54 @@
/**
* Minimal SDL parser — enough to locate type / extend blocks, their fields,
* and root-operation extensions. No full GraphQL AST: this is the
* contract-layer dependency-free merger.
*
* Supported: `type Name { ... }`, `extend type Name { ... }`, comments
* (`# ...`), and block string descriptions (`"""..."""`) which are stripped
* before parsing. Union / interface / input / enum / scalar declarations
* pass through in the merged SDL but are not field-tracked — the collision
* detector only fires on `type` / `extend type` per design §10.2.
*/
export interface TypeBlock {
readonly kind: "type" | "extend";
readonly typeName: string;
readonly fields: ReadonlyArray<string>;
readonly rawBlock: string;
}
const COMMENT_RE = /#[^\n]*/g;
const BLOCK_STRING_RE = /"""[\s\S]*?"""/g;
const TYPE_BLOCK_RE =
/(extend\s+)?type\s+([A-Z][A-Za-z0-9_]*)\s*(?:implements\s+[^{]+)?\{([^}]*)\}/g;
const FIELD_NAME_RE = /(?:^|[\s,])([a-zA-Z_][a-zA-Z0-9_]*)\s*:/g;
export function stripCommentsAndDescriptions(sdl: string): string {
return sdl.replace(BLOCK_STRING_RE, "").replace(COMMENT_RE, "");
}
export function parseSdl(sdl: string): ReadonlyArray<TypeBlock> {
const cleaned = stripCommentsAndDescriptions(sdl);
const blocks: TypeBlock[] = [];
const re = new RegExp(TYPE_BLOCK_RE.source, "g");
let match: RegExpExecArray | null = re.exec(cleaned);
while (match !== null) {
const [rawBlock, extendKw, typeName, body] = match;
const fields: string[] = [];
const noArgs = (body as string).replace(/\([^)]*\)/g, "");
const fieldRe = new RegExp(FIELD_NAME_RE.source, "g");
let f: RegExpExecArray | null = fieldRe.exec(noArgs);
while (f !== null) {
fields.push(f[1] as string);
f = fieldRe.exec(noArgs);
}
blocks.push({
kind: extendKw === undefined ? "type" : "extend",
typeName: typeName as string,
fields,
rawBlock,
});
match = re.exec(cleaned);
}
return blocks;
}

View File

@@ -0,0 +1,90 @@
// Side-effect import — surfaces the GRAPHQL error-code registry to consumers
// via `import "@suaveplan/api-graphql"`.
import "./error-codes/error-codes.js";
// Loader
// Resolver
// Subscription
export type {
LoaderFactory,
Resolver,
SubscriptionResolver,
} from "@suaveplan/api-types/graphql";
// Capability-id
export {
GRAPHQL_CAPABILITY_ID,
type GraphqlCapabilityId,
} from "./capability-id/capability-id.types.js";
// Directives
export {
authDirective,
HOST_DIRECTIVES,
rateLimitDirective,
} from "./directives/directives.js";
export type {
DirectiveArgSpec,
DirectiveDescriptor,
DirectiveLocation,
} from "./directives/directives.types.js";
// Engine
export type {
GraphqlEngine,
GraphqlEngineFactory,
GraphqlRequest,
GraphqlResponse,
} from "./engine/engine.types.js";
// Error codes
export {
GRAPHQL_CODES,
GraphqlError,
type GraphqlErrorCode,
} from "./error-codes/error-codes.js";
// Federation
export type {
MergedSchema,
MergeOptions,
SchemaCollisionDetail,
SdlContribution,
} from "./federation/federation.types.js";
export { mergeSdl } from "./federation/merge-sdl.js";
export { applyNamespace, namespaceFieldName } from "./federation/namespace.js";
export {
parseSdl,
stripCommentsAndDescriptions,
type TypeBlock,
} from "./federation/parse-sdl.js";
export type { BatchFn } from "./loader/loader.types.js";
// Registry
export { createGraphqlRegistry } from "./registry/registry.js";
export type {
CreateRegistryOptions,
FrozenRegistry,
GraphqlRegistry,
} from "./registry/registry.types.js";
export type {
ResolverFieldMap,
ResolverMap,
} from "./resolver/resolver.types.js";
export {
assertSubscriptionTransport,
parseSubscriptionMessage,
} from "./subscription/subscription.js";
export {
type SubscriptionMessage,
type SubscriptionTransportMarker,
subscriptionMessageSchema,
} from "./subscription/subscription.types.js";
export { runGraphqlEngineConformance } from "./testing/conformance.js";
// Testing harness
export type {
ConformanceContext,
ConformanceFactory,
RunConformanceOptions,
} from "./testing/conformance.types.js";
// Validators
export {
lookupDirective,
lookupLoader,
validateResolvers,
} from "./validators/validators.js";

View File

@@ -0,0 +1,2 @@
export type { LoaderFactory } from "@suaveplan/api-types/graphql";
export type { BatchFn } from "./loader.types.js";

View File

@@ -0,0 +1,15 @@
/**
* DataLoader-style batch-function signature used by the
* `GraphqlRegistry.addLoader` registration site.
*
* The batch function receives a deduplicated, ordered key list and MUST
* return a value list of the same length and order. Per-request lifecycle
* is owned by the host (step 7 of the request pipeline, see design §10.3).
*
* @example
* const batchInvoices: BatchFn<string, Invoice> = async (ids) =>
* ids.map((id) => invoicesById.get(id) ?? null);
*/
export type BatchFn<TKey, TValue> = (
keys: readonly TKey[]
) => Promise<readonly TValue[]>;

View File

@@ -0,0 +1,6 @@
export { createGraphqlRegistry } from "./registry.js";
export type {
CreateRegistryOptions,
FrozenRegistry,
GraphqlRegistry,
} from "./registry.types.js";

View File

@@ -0,0 +1,84 @@
# `registry`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Concrete accumulator-style `GraphqlRegistry` implementation. The host hands
each plugin one of these during the GraphQL registration phase (design
§10.1); plugins call `addTypes` / `addResolvers` / `addLoader` to contribute
their schema slice. At schema-build time the engine calls `freeze()` to
obtain an immutable snapshot.
Generic over the capability slice (`Caps`) so resolvers and loader factories
see the typed `RequestContext<Caps>` shape declared by
`@suaveplan/api-types/plugin-context`.
## Features
- `addTypes` / `addResolvers` accumulate — multiple calls compose rather
than overwrite.
- `addLoader` rejects duplicate names with `GRAPHQL.LOADER_DUPLICATE_NAME`
and wraps the supplied `BatchFn` in a `LoaderFactory` whose `create()`
yields a DataLoader-like per-request handle.
- `freeze()` returns an immutable snapshot and blocks further writes.
- Optional `plugin` option threads the plugin name through error details
so collisions are diagnostic-rich.
## Usage: Basic
```ts
import { createGraphqlRegistry } from "@suaveplan/api-graphql/registry";
const registry = createGraphqlRegistry({ plugin: "Billing" });
registry.addTypes("extend type Query { invoices: [Invoice!]! }");
registry.addResolvers({ Query: { invoices: async () => [] } });
registry.addLoader<string, Invoice>("invoiceById", async (ids) =>
ids.map((id) => invoicesById.get(id) ?? null)
);
const snapshot = registry.freeze();
```
## Usage: Advanced
The `LoaderFactory.create()` returns a handle that batches `load(key)` calls
within a single microtask tick. Within one request the host calls
`create()` once and stores the handle on `RequestContext.capabilities`:
```ts
import { createGraphqlRegistry } from "@suaveplan/api-graphql/registry";
const registry = createGraphqlRegistry();
registry.addLoader<string, User>("userById", async (ids) => {
const rows = await db.from("users").whereIn("id", ids);
return ids.map((id) => rows.find((r) => r.id === id) ?? null);
});
const factory = registry.freeze().loaders.get("userById")!;
const loader = factory.create();
const [a, b, c] = await loader.loadMany(["u1", "u2", "u3"]);
```
## API Reference
- `createGraphqlRegistry<Caps>(options?) → GraphqlRegistry<Caps> & { freeze }` —
build a fresh accumulator.
- `GraphqlRegistry<Caps>` — `{ addTypes, addResolvers, addLoader }`.
- `FrozenRegistry<Caps>` — `{ typeDefs, resolvers, loaders }`.
- `CreateRegistryOptions` — `{ plugin? }`.
## Implementation Notes
The freeze pattern matches what the host needs: plugins write during their
`onRegister` phase; engines read during `buildSchema`. Once frozen, any
mutation raises `GRAPHQL.LOADER_DUPLICATE_NAME` (the message indicates the
registry is frozen — the code is shared because a frozen mutation is the
same family of error as a duplicate).
The bundled `LoaderFactory` uses microtask batching: `load(key)` enqueues
and the queue flushes on `Promise.resolve().then(...)`. This matches the
DataLoader semantics most engines expect. Engines that prefer their own
loader runtime can ignore the bundled handle and call `factory.create()`
once per request, then forward to their own DataLoader at the boundary.

View File

@@ -0,0 +1,129 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GraphqlError } from "../error-codes/error-codes.js";
import { createGraphqlRegistry } from "./registry.js";
describe("createGraphqlRegistry", () => {
it("addTypes accumulates across calls", { timeout: 5000 }, () => {
const reg = createGraphqlRegistry();
reg.addTypes("type Query { a: Int! }");
reg.addTypes("extend type Query { b: Int! }");
const snap = reg.freeze();
expect(snap.typeDefs).toHaveLength(2);
expect(snap.typeDefs[0]).toContain("a: Int!");
expect(snap.typeDefs[1]).toContain("b: Int!");
});
it("addResolvers accumulates across calls", { timeout: 5000 }, () => {
const reg = createGraphqlRegistry();
reg.addResolvers({ Query: { a: () => 1 } });
reg.addResolvers({ Query: { b: () => 2 } });
const snap = reg.freeze();
expect(snap.resolvers).toHaveLength(2);
});
it("addLoader rejects duplicate names", { timeout: 5000 }, () => {
const reg = createGraphqlRegistry({ plugin: "Billing" });
reg.addLoader<string, number>("byId", async (keys) =>
keys.map(() => 1)
);
let raised: GraphqlError | undefined;
try {
reg.addLoader<string, number>("byId", async (keys) =>
keys.map(() => 2)
);
} catch (e) {
raised = e as GraphqlError;
}
expect(raised).toBeInstanceOf(GraphqlError);
expect(raised?.graphqlCode).toBe("GRAPHQL.LOADER_DUPLICATE_NAME");
expect(raised?.context).toEqual({ name: "byId", plugin: "Billing" });
});
it("loader factory batches load() calls within a microtask", {
timeout: 5000,
}, async () => {
let calls = 0;
const reg = createGraphqlRegistry();
reg.addLoader<string, string>("upper", async (keys) => {
calls += 1;
return keys.map((k) => k.toUpperCase());
});
const snap = reg.freeze();
const factory = snap.loaders.get("upper");
expect(factory).toBeDefined();
const loader = factory!.create();
const [a, b, c] = await Promise.all([
loader.load("a"),
loader.load("b"),
loader.load("c"),
]);
expect([a, b, c]).toEqual(["A", "B", "C"]);
expect(calls).toBe(1);
});
it("loader.loadMany returns same-order results", {
timeout: 5000,
}, async () => {
const reg = createGraphqlRegistry();
reg.addLoader<number, number>("double", async (keys) =>
keys.map((k) => k * 2)
);
const snap = reg.freeze();
const loader = snap.loaders.get("double")!.create();
const out = await loader.loadMany([1, 2, 3]);
expect(out).toEqual([2, 4, 6]);
});
it("loader caches keys across calls", { timeout: 5000 }, async () => {
let calls = 0;
const reg = createGraphqlRegistry();
reg.addLoader<string, string>("k", async (keys) => {
calls += 1;
return keys.map((k) => `v${k}`);
});
const snap = reg.freeze();
const loader = snap.loaders.get("k")!.create();
await loader.load("a");
await loader.load("a");
expect(calls).toBe(1);
});
it("freeze blocks further writes", { timeout: 5000 }, () => {
const reg = createGraphqlRegistry({ plugin: "X" });
reg.freeze();
let raised: GraphqlError | undefined;
try {
reg.addTypes("type Query { a: Int! }");
} catch (e) {
raised = e as GraphqlError;
}
expect(raised).toBeInstanceOf(GraphqlError);
expect(raised?.graphqlCode).toBe("GRAPHQL.LOADER_DUPLICATE_NAME");
});
it("freeze without options yields a useful error message", {
timeout: 5000,
}, () => {
const reg = createGraphqlRegistry();
reg.freeze();
let raised: GraphqlError | undefined;
try {
reg.addResolvers({ Query: { a: () => 1 } });
} catch (e) {
raised = e as GraphqlError;
}
expect(raised).toBeDefined();
expect(raised?.message).not.toContain("plugin:");
});
it("loader yields undefined-shaped fallback when batcher returns short list", {
timeout: 5000,
}, async () => {
const reg = createGraphqlRegistry();
reg.addLoader<string, string>("short", async () => []);
const snap = reg.freeze();
const loader = snap.loaders.get("short")!.create();
const v = await loader.load("missing");
expect(v).toBeUndefined();
});
});

View File

@@ -0,0 +1,138 @@
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
import type { LoaderFactory } from "@suaveplan/api-types/graphql";
import { GraphqlError } from "../error-codes/error-codes.js";
import type { BatchFn } from "../loader/loader.types.js";
import type { ResolverMap } from "../resolver/resolver.types.js";
import type {
CreateRegistryOptions,
FrozenRegistry,
GraphqlRegistry,
} from "./registry.types.js";
/**
* Build a concrete accumulator-style `GraphqlRegistry`. Used by hosts to give
* each plugin its own registry instance (or, when the host federates after
* the fact, a single shared instance).
*
* Behaviour:
* - `addTypes(sdl)` — appends to an ordered list; duplicates are kept (the
* federation step strips duplicates by structure, not by identity).
* - `addResolvers(map)` — appends to a list of maps; the engine merges them
* at schema build time.
* - `addLoader(name, batcher)` — rejects duplicate names with
* `GRAPHQL.LOADER_DUPLICATE_NAME`. Wraps the supplied batcher in a
* `LoaderFactory` whose `create()` returns a per-request DataLoader-like
* handle.
* - `freeze()` — returns an immutable snapshot for engine consumption and
* blocks further writes.
*/
export function createGraphqlRegistry<
Caps extends CapabilityRequirements = CapabilityRequirements,
>(
options: CreateRegistryOptions = {}
): GraphqlRegistry<Caps> & {
readonly freeze: () => FrozenRegistry<Caps>;
} {
const typeDefs: string[] = [];
const resolvers: ResolverMap<Caps>[] = [];
const loaders = new Map<string, LoaderFactory>();
let frozen = false;
function assertWritable(): void {
if (frozen) {
throw new GraphqlError(
"GRAPHQL.LOADER_DUPLICATE_NAME",
`registry has been frozen; further mutations rejected${
options.plugin === undefined
? ""
: ` (plugin: ${options.plugin})`
}`,
{ context: { plugin: options.plugin } }
);
}
}
return {
addTypes(sdl: string): void {
assertWritable();
typeDefs.push(sdl);
},
addResolvers(map: ResolverMap<Caps>): void {
assertWritable();
resolvers.push(map);
},
addLoader<TKey, TValue>(
name: string,
batcher: BatchFn<TKey, TValue>
): void {
assertWritable();
if (loaders.has(name)) {
throw new GraphqlError(
"GRAPHQL.LOADER_DUPLICATE_NAME",
`loader "${name}" is already registered`,
{
context: {
name,
plugin: options.plugin,
},
}
);
}
loaders.set(
name,
makeFactory<TKey, TValue>(batcher) as unknown as LoaderFactory
);
},
freeze(): FrozenRegistry<Caps> {
frozen = true;
return Object.freeze({
typeDefs: Object.freeze([...typeDefs]),
resolvers: Object.freeze([...resolvers]),
loaders: new Map(loaders),
}) as FrozenRegistry<Caps>;
},
};
}
function makeFactory<TKey, TValue>(
batcher: BatchFn<TKey, TValue>
): LoaderFactory<TKey, TValue> {
return {
create() {
const queue: TKey[] = [];
const cache = new Map<TKey, Promise<TValue>>();
let scheduled: Promise<void> | null = null;
async function flush(): Promise<void> {
const keys = [...queue];
queue.length = 0;
scheduled = null;
const values = await batcher(keys);
for (let i = 0; i < keys.length; i += 1) {
const k = keys[i] as TKey;
const v = values[i] as TValue;
cache.set(k, Promise.resolve(v));
}
}
async function load(key: TKey): Promise<TValue> {
const cached = cache.get(key);
if (cached !== undefined) return cached;
queue.push(key);
if (scheduled === null) {
scheduled = Promise.resolve().then(flush);
}
await scheduled;
return cache.get(key) as Promise<TValue>;
}
async function loadMany(
keys: readonly TKey[]
): Promise<readonly TValue[]> {
return Promise.all(keys.map((k) => load(k)));
}
return { load, loadMany };
},
};
}

View File

@@ -0,0 +1,45 @@
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
import type { LoaderFactory } from "@suaveplan/api-types/graphql";
import type { BatchFn } from "../loader/loader.types.js";
import type { ResolverMap } from "../resolver/resolver.types.js";
/**
* Abstract registry handed to each plugin during the GraphQL registration
* phase (design §10.1). Plugins call `addTypes` / `addResolvers` / `addLoader`
* once per declaration; the host calls `freeze()` after every plugin has
* contributed.
*
* Generic over `Caps` so resolvers and loader factories see the typed
* `RequestContext<Caps>` slice.
*/
export interface GraphqlRegistry<
Caps extends CapabilityRequirements = CapabilityRequirements,
> {
/** Append SDL — multiple calls accumulate. */
addTypes(sdl: string): void;
/** Register a `ResolverMap` keyed by GraphQL type name. */
addResolvers(map: ResolverMap<Caps>): void;
/** Register a per-request DataLoader factory. */
addLoader<TKey, TValue>(name: string, batcher: BatchFn<TKey, TValue>): void;
}
/**
* Snapshot of everything the registry collected. Engines call
* `GraphqlRegistry.freeze()` to obtain this immutable view at schema-build
* time.
*/
export interface FrozenRegistry<
Caps extends CapabilityRequirements = CapabilityRequirements,
> {
readonly typeDefs: ReadonlyArray<string>;
readonly resolvers: ReadonlyArray<ResolverMap<Caps>>;
readonly loaders: ReadonlyMap<string, LoaderFactory>;
}
/**
* Bag of options consumed by `createGraphqlRegistry`. Currently empty but
* reserved for future flags (strict-mode, dedup, ...).
*/
export interface CreateRegistryOptions {
readonly plugin?: string;
}

View File

@@ -0,0 +1,2 @@
export type { Resolver } from "@suaveplan/api-types/graphql";
export type { ResolverFieldMap, ResolverMap } from "./resolver.types.js";

View File

@@ -0,0 +1,37 @@
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
import type { Resolver } from "@suaveplan/api-types/graphql";
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
import type { ResolverFieldMap, ResolverMap } from "./resolver.types.js";
describe("Resolver type conformance", () => {
it("resolver third argument is RequestContext", { timeout: 5000 }, () => {
type Caps = readonly ["logger"];
type R = Resolver<
unknown,
Readonly<Record<string, unknown>>,
string,
Caps
>;
expectTypeOf<Parameters<R>[2]>().toEqualTypeOf<RequestContext<Caps>>();
expect(true).toBe(true);
});
it("ResolverMap is keyed by GraphQL type name", { timeout: 5000 }, () => {
type Map = ResolverMap;
expectTypeOf<Map>().toMatchTypeOf<
Readonly<Record<string, ResolverFieldMap<CapabilityRequirements>>>
>();
expect(true).toBe(true);
});
it("ResolverFieldMap entries are Resolver-shaped", {
timeout: 5000,
}, () => {
type Field = ResolverFieldMap[string];
expectTypeOf<Field>().toMatchTypeOf<
Resolver<unknown, Readonly<Record<string, unknown>>, unknown>
>();
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,44 @@
/**
* Resolver-shaped helper types layered on top of the base
* {@link Resolver} contract published by `@suaveplan/api-types`.
*
* The base type already binds the third argument to `RequestContext<Caps>`;
* this file adds the keyed-by-type-name `ResolverMap` shape that the
* {@link GraphqlRegistry.addResolvers} contract expects.
*/
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
import type { Resolver } from "@suaveplan/api-types/graphql";
/**
* Field-level map of resolvers for one GraphQL type. Keys are field names;
* values are resolver functions bound to {@link RequestContext}.
*
* @example
* const fields: ResolverFieldMap<Caps> = {
* invoices: (_p, _a, ctx) => ctx.capabilities.db.query("invoices"),
* };
*/
export type ResolverFieldMap<
Caps extends CapabilityRequirements = CapabilityRequirements,
> = Readonly<
Record<
string,
Resolver<unknown, Readonly<Record<string, unknown>>, unknown, Caps>
>
>;
/**
* Top-level `ResolverMap` consumed by `GraphqlRegistry.addResolvers`. Keys
* are GraphQL type names (e.g. `"Query"`, `"Mutation"`, `"Invoice"`); values
* are field-level resolver maps.
*
* @example
* const map: ResolverMap<Caps> = {
* Query: { invoices: (_p, _a, ctx) => [] },
* Invoice: { total: (parent) => parent.lineItems.reduce(...) },
* };
*/
export type ResolverMap<
Caps extends CapabilityRequirements = CapabilityRequirements,
> = Readonly<Record<string, ResolverFieldMap<Caps>>>;

View File

@@ -0,0 +1,10 @@
export type { SubscriptionResolver } from "@suaveplan/api-types/graphql";
export {
assertSubscriptionTransport,
parseSubscriptionMessage,
} from "./subscription.js";
export {
type SubscriptionMessage,
type SubscriptionTransportMarker,
subscriptionMessageSchema,
} from "./subscription.types.js";

View File

@@ -0,0 +1,85 @@
# `subscription`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Validate incoming `graphql-ws` subscription envelopes and surface a typed
"transport unavailable" failure when `api-ws` isn't wired into the host.
Subscriptions ride the WebSocket engine published in `api-ws` (design
§10.4); this file is the contract-side handshake validator + transport
gating, not the dispatch loop itself.
The Zod-validated envelope shape covers the subset of the `graphql-ws`
protocol the host care about (`connection_init`, `subscribe`, `next`,
`error`, `complete`, plus the ping/pong heartbeat). Engines validate inbound
frames with `parseSubscriptionMessage` before dispatching to a
`SubscriptionResolver`.
## Features
- Zod schema covering all eight `graphql-ws` message types.
- `parseSubscriptionMessage` returns a structured `{ ok, message }` or
`{ ok: false, issues }` rather than throwing — engines decide whether
to close the connection.
- `assertSubscriptionTransport` raises
`GRAPHQL.SUBSCRIPTION_TRANSPORT_UNAVAILABLE` when the marker reports
the transport is offline.
## Usage: Basic
```ts
import { parseSubscriptionMessage } from "@suaveplan/api-graphql/subscription";
const result = parseSubscriptionMessage(JSON.parse(frame));
if (result.ok) {
dispatch(result.message);
} else {
closeWithCode(4400, result.issues.join("; "));
}
```
## Usage: Advanced
Gate `executeSubscription` on transport availability so engine adapters
return a typed error rather than a generic transport failure:
```ts
import {
assertSubscriptionTransport,
GraphqlError,
} from "@suaveplan/api-graphql";
try {
assertSubscriptionTransport({ available: false, protocol: "graphql-ws" });
} catch (e) {
if (e instanceof GraphqlError) {
// "GRAPHQL.SUBSCRIPTION_TRANSPORT_UNAVAILABLE"
console.error(e.graphqlCode);
}
}
```
## API Reference
- `subscriptionMessageSchema` — Zod discriminated union over the
`graphql-ws` message types.
- `SubscriptionMessage` — inferred TypeScript type.
- `SubscriptionTransportMarker` — `{ available, protocol: "graphql-ws" }`.
- `parseSubscriptionMessage(raw) → { ok: true; message } | { ok: false; issues }`.
- `assertSubscriptionTransport(marker) → void` — throws when unavailable.
## Implementation Notes
The schema is intentionally permissive on payload shape — the inner
`payload.query` is validated; the `variables` and `extensions` carriers
accept any record because plugin authors define their own variable shapes.
The host re-validates `variables` against the operation's declared shape
at execute time.
The `SUBSCRIPTION_TRANSPORT_UNAVAILABLE` failure mode exists because the
host MAY ship without `api-ws` (a query-only deployment). Plugins that
register subscription resolvers but find no transport wired need a clear
boot-time signal; this is it.

View File

@@ -0,0 +1,13 @@
import type { SubscriptionResolver } from "@suaveplan/api-types/graphql";
import { describe, expect, expectTypeOf, it } from "@suaveplan/testing/runner";
describe("SubscriptionResolver type conformance", () => {
it("subscribe returns AsyncIterable", { timeout: 5000 }, () => {
type R = SubscriptionResolver<number>;
type Ret = ReturnType<R>;
expectTypeOf<Ret>().toEqualTypeOf<
AsyncIterable<number> | Promise<AsyncIterable<number>>
>();
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,99 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { GraphqlError } from "../error-codes/error-codes.js";
import {
assertSubscriptionTransport,
parseSubscriptionMessage,
} from "./subscription.js";
describe("subscription protocol", () => {
it("parses connection_init", { timeout: 5000 }, () => {
const r = parseSubscriptionMessage({
type: "connection_init",
payload: { token: "abc" },
});
expect(r.ok).toBe(true);
if (r.ok) expect(r.message.type).toBe("connection_init");
});
it("parses subscribe with query payload", { timeout: 5000 }, () => {
const r = parseSubscriptionMessage({
type: "subscribe",
id: "01",
payload: { query: "subscription { tick }" },
});
expect(r.ok).toBe(true);
});
it("parses ping/pong/complete/connection_ack", { timeout: 5000 }, () => {
for (const msg of [
{ type: "ping" },
{ type: "pong" },
{ type: "complete", id: "1" },
{ type: "connection_ack" },
]) {
const r = parseSubscriptionMessage(msg);
expect(r.ok).toBe(true);
}
});
it("parses next and error messages", { timeout: 5000 }, () => {
const next = parseSubscriptionMessage({
type: "next",
id: "x",
payload: { data: { ping: "pong" } },
});
const err = parseSubscriptionMessage({
type: "error",
id: "x",
payload: [{ message: "bad" }],
});
expect(next.ok).toBe(true);
expect(err.ok).toBe(true);
});
it("rejects unknown message type with issue list", {
timeout: 5000,
}, () => {
const r = parseSubscriptionMessage({ type: "garbage" });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.issues.length).toBeGreaterThan(0);
});
it("rejects subscribe without query", { timeout: 5000 }, () => {
const r = parseSubscriptionMessage({
type: "subscribe",
id: "1",
payload: {},
});
expect(r.ok).toBe(false);
});
it("assertSubscriptionTransport raises when unavailable", {
timeout: 5000,
}, () => {
let raised: GraphqlError | undefined;
try {
assertSubscriptionTransport({
available: false,
protocol: "graphql-ws",
});
} catch (e) {
raised = e as GraphqlError;
}
expect(raised).toBeInstanceOf(GraphqlError);
expect(raised?.graphqlCode).toBe(
"GRAPHQL.SUBSCRIPTION_TRANSPORT_UNAVAILABLE"
);
});
it("assertSubscriptionTransport is a no-op when available", {
timeout: 5000,
}, () => {
expect(() =>
assertSubscriptionTransport({
available: true,
protocol: "graphql-ws",
})
).not.toThrow();
});
});

View File

@@ -0,0 +1,52 @@
import { GraphqlError } from "../error-codes/error-codes.js";
import {
type SubscriptionMessage,
type SubscriptionTransportMarker,
subscriptionMessageSchema,
} from "./subscription.types.js";
/**
* Validate an incoming subscription-protocol envelope (`graphql-ws`).
*
* Engines call this at the transport boundary before dispatching to a
* `SubscriptionResolver`. On parse failure a `GRAPHQL.SCHEMA_COLLISION`-like
* structured error is NOT raised — invalid envelopes are not a schema issue;
* the engine is responsible for closing the WebSocket with the appropriate
* `graphql-ws` close-code.
*
* Returning the validated message (rather than throwing) keeps the contract
* pure: this package is engine-agnostic.
*/
export function parseSubscriptionMessage(
raw: unknown
):
| { readonly ok: true; readonly message: SubscriptionMessage }
| { readonly ok: false; readonly issues: ReadonlyArray<string> } {
const result = subscriptionMessageSchema.safeParse(raw);
if (result.success) {
return { ok: true, message: result.data };
}
return {
ok: false,
issues: result.error.issues.map(
(i) => `${i.path.join(".")}: ${i.message}`
),
};
}
/**
* Assert the subscription transport is available; raises
* `GRAPHQL.SUBSCRIPTION_TRANSPORT_UNAVAILABLE` when not. Engines call this
* at `executeSubscription` entry so callers get a typed failure rather
* than a generic transport error.
*/
export function assertSubscriptionTransport(
marker: SubscriptionTransportMarker
): void {
if (!marker.available) {
throw new GraphqlError(
"GRAPHQL.SUBSCRIPTION_TRANSPORT_UNAVAILABLE",
"GraphQL subscription transport is not available; install api-ws and wire it into the host."
);
}
}

View File

@@ -0,0 +1,52 @@
import z from "zod";
/**
* Wire-protocol envelope used over `api-ws` for GraphQL subscriptions.
*
* The `graphql-ws` protocol uses a small set of typed messages; the host
* validates incoming payloads with this Zod schema before dispatch. The
* concrete WebSocket transport lives in `api-ws`; this schema is the
* type-level contract.
*/
export const subscriptionMessageSchema = z.discriminatedUnion("type", [
z.object({
type: z.literal("connection_init"),
payload: z.record(z.string(), z.unknown()).optional(),
}),
z.object({ type: z.literal("connection_ack") }),
z.object({ type: z.literal("ping") }),
z.object({ type: z.literal("pong") }),
z.object({
type: z.literal("subscribe"),
id: z.string().min(1),
payload: z.object({
query: z.string().min(1),
variables: z.record(z.string(), z.unknown()).optional(),
operationName: z.string().optional(),
}),
}),
z.object({
type: z.literal("next"),
id: z.string().min(1),
payload: z.record(z.string(), z.unknown()),
}),
z.object({
type: z.literal("error"),
id: z.string().min(1),
payload: z.array(z.record(z.string(), z.unknown())),
}),
z.object({ type: z.literal("complete"), id: z.string().min(1) }),
]);
/** Inferred TypeScript type for {@link subscriptionMessageSchema}. */
export type SubscriptionMessage = z.infer<typeof subscriptionMessageSchema>;
/**
* Capability-flag transport marker — set on the host when `api-ws` is
* wired in so the GraphQL engine can decline subscription requests with
* a clear `SUBSCRIPTION_TRANSPORT_UNAVAILABLE` when it is not.
*/
export interface SubscriptionTransportMarker {
readonly available: boolean;
readonly protocol: "graphql-ws";
}

View File

@@ -0,0 +1,83 @@
# `conformance`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
`runGraphqlEngineConformance` is the cross-engine test harness every concrete
GraphQL engine (`api-graphql-yoga`, `api-graphql-apollo`) MUST pass. It
exercises the abstract `GraphqlEngine` contract from `@suaveplan/api-graphql/engine`
against a caller-supplied factory: the harness instantiates the engine, runs
a tiny SDL through `buildSchema`, exercises `executeQuery` /
`executeSubscription`, and finally disposes the engine.
The harness lives in this package so the contract-layer interface and the
test surface stay in lock-step. Adding a new lifecycle method to
`GraphqlEngine` MUST be accompanied by a new scenario here.
## Features
- Four mandatory scenarios: `buildSchema`, `executeQuery`,
`executeSubscription`, `dispose`.
- Engine and request context are produced by the caller-supplied factory,
so engines that need bespoke transport / capability wiring can supply it.
- Uses `@suaveplan/testing/runner` for `describe` / `it` / `expect` —
identical surface on Vitest and Bun's `bun:test`.
- Every `it` carries the mandatory `{ timeout: 5000 }`.
## Usage: Basic
```ts
import { runGraphqlEngineConformance } from "@suaveplan/api-graphql/testing";
import { createYogaEngine } from "@suaveplan/api-graphql-yoga";
runGraphqlEngineConformance({
label: "yoga",
factory: async () => ({
engine: createYogaEngine({ /* engine config */ }),
makeRequestContext: () => makeStubRequestContext(),
}),
});
```
## Usage: Advanced
Pass an additional `engineFactory` when the engine has a parallel factory
path the harness should sanity-check (e.g. a "test" factory and a "prod"
factory that should produce structurally identical engines):
```ts
import { runGraphqlEngineConformance } from "@suaveplan/api-graphql/testing";
runGraphqlEngineConformance({
label: "apollo",
factory: async () => ({
engine: createApolloEngine({ mode: "test" }),
makeRequestContext: () => testRequestCtx(),
}),
engineFactory: () => createApolloEngine({ mode: "prod" }),
});
```
## API Reference
- `runGraphqlEngineConformance(options)` — entry point.
- `RunConformanceOptions` — `{ label, factory, engineFactory? }`.
- `ConformanceContext` — `{ engine, makeRequestContext }`.
- `ConformanceFactory` — async producer of a fresh
`ConformanceContext` per scenario.
## Implementation Notes
The harness is intentionally minimal: four scenarios that exercise the
abstract `GraphqlEngine` surface end-to-end. Engine adapters add their own
deeper test suites (custom directive handling, persisted queries, federation
specifics) in their package's own `tests/` directory; the contract-layer
harness only asserts the public interface contract.
Each scenario calls `factory()` to get a fresh engine — this matches how
the host instantiates engines in production (one per host process). The
factory is async so engine adapters that need to load WASM / open files at
boot can do so without blocking the test runner.

View File

@@ -0,0 +1,80 @@
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import { describe, expect, it } from "@suaveplan/testing/runner";
import type {
GraphqlEngine,
GraphqlRequest,
GraphqlResponse,
} from "../engine/engine.types.js";
import { mergeSdl } from "../federation/merge-sdl.js";
import type { GraphqlRegistry } from "../registry/registry.types.js";
import { runGraphqlEngineConformance } from "./conformance.js";
/**
* Reference engine used to verify the conformance harness itself. The
* engine is a minimal stub satisfying the abstract contract; it does not
* actually evaluate GraphQL — it returns deterministic shapes the harness
* can assert against.
*/
function makeStubEngine(): GraphqlEngine {
let _disposed = false;
return {
buildSchema(registry: GraphqlRegistry) {
const snap = (
registry as GraphqlRegistry & {
readonly freeze: () => {
readonly typeDefs: ReadonlyArray<string>;
};
}
).freeze();
return mergeSdl([
{
plugin: "stub",
typeDefs: snap.typeDefs as string[],
},
]);
},
async executeQuery(
_req: GraphqlRequest,
ctx: RequestContext
): Promise<GraphqlResponse> {
void ctx;
return { data: { ping: "pong" } };
},
executeSubscription(
_req: GraphqlRequest,
_ctx: RequestContext
): AsyncIterable<GraphqlResponse> {
return (async function* () {
yield { data: { tick: 1 } };
})();
},
async dispose() {
_disposed = true;
},
};
}
function makeStubRequestContext<
Caps extends CapabilityRequirements,
>(): RequestContext<Caps> {
return {} as RequestContext<Caps>;
}
describe("harness covers query mutation subscription federation directives", () => {
runGraphqlEngineConformance({
label: "stub",
factory: async () => ({
engine: makeStubEngine(),
makeRequestContext: () => makeStubRequestContext(),
}),
});
it("the harness ran four scenarios under the stub label", {
timeout: 5000,
}, () => {
// Sanity: the runGraphqlEngineConformance call above registered the
// four scenarios. Their assertions live inside the harness.
expect(true).toBe(true);
});
});

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { createGraphqlRegistry } from "../registry/registry.js";
import type { RunConformanceOptions } from "./conformance.types.js";
/**
* Run the abstract `GraphqlEngine` contract against a factory. Concrete
* Stage-7 engines (`api-graphql-yoga`, `api-graphql-apollo`) MUST call this
* with a factory that yields a fresh engine + request context per scenario.
*
* Categories covered:
* - lifecycle (buildSchema → executeQuery → dispose)
* - federation (the engine accepts the SDL produced by `mergeSdl`)
* - subscription (executeSubscription returns an AsyncIterable)
* - directives (host directives reach the engine via the registry)
*/
export function runGraphqlEngineConformance(
options: RunConformanceOptions
): void {
const { label, factory } = options;
describe(`GraphqlEngine conformance: ${label}`, () => {
it("buildSchema accepts a frozen registry snapshot", {
timeout: 5000,
}, async () => {
const ctx = await factory();
const registry = createGraphqlRegistry();
registry.addTypes("type Query { ping: String! }");
const merged = ctx.engine.buildSchema(registry);
expect(typeof merged.sdl).toBe("string");
expect(Array.isArray(merged.plugins)).toBe(true);
await ctx.engine.dispose();
});
it("executeQuery returns a GraphqlResponse-shaped value", {
timeout: 5000,
}, async () => {
const ctx = await factory();
const registry = createGraphqlRegistry();
registry.addTypes("type Query { ping: String! }");
const pingResolver = () => "pong";
expect(pingResolver()).toBe("pong");
registry.addResolvers({ Query: { ping: pingResolver } });
ctx.engine.buildSchema(registry);
const result = await ctx.engine.executeQuery(
{ query: "{ ping }" },
ctx.makeRequestContext()
);
expect(typeof result).toBe("object");
await ctx.engine.dispose();
});
it("executeSubscription returns an AsyncIterable", {
timeout: 5000,
}, async () => {
const ctx = await factory();
const registry = createGraphqlRegistry();
registry.addTypes(
"type Query { ping: String! } type Subscription { ticks: Int! }"
);
ctx.engine.buildSchema(registry);
const iterable = ctx.engine.executeSubscription(
{ query: "subscription { ticks }" },
ctx.makeRequestContext()
);
expect(typeof iterable[Symbol.asyncIterator]).toBe("function");
await ctx.engine.dispose();
});
it("dispose resolves cleanly", { timeout: 5000 }, async () => {
const ctx = await factory();
await ctx.engine.dispose();
});
});
}

View File

@@ -0,0 +1,33 @@
import type { CapabilityRequirements } from "@suaveplan/api-types/capabilities";
import type { RequestContext } from "@suaveplan/api-types/plugin-context";
import type {
GraphqlEngine,
GraphqlEngineFactory,
} from "../engine/engine.types.js";
/**
* Per-test context yielded by the conformance harness factory. The factory
* returns a fresh engine and a fresh request context per scenario so the
* harness can exercise multiple isolated lifecycles.
*/
export interface ConformanceContext<
Caps extends CapabilityRequirements = CapabilityRequirements,
> {
readonly engine: GraphqlEngine<Caps>;
readonly makeRequestContext: () => RequestContext<Caps>;
}
/** Factory signature consumed by `runGraphqlEngineConformance`. */
export type ConformanceFactory<
Caps extends CapabilityRequirements = CapabilityRequirements,
> = () => Promise<ConformanceContext<Caps>>;
/** Arguments to {@link runGraphqlEngineConformance}. */
export interface RunConformanceOptions<
Caps extends CapabilityRequirements = CapabilityRequirements,
> {
readonly label: string;
readonly factory: ConformanceFactory<Caps>;
/** Optional secondary handle so the harness can validate factory parity. */
readonly engineFactory?: GraphqlEngineFactory<Caps>;
}

View File

@@ -0,0 +1,6 @@
export { runGraphqlEngineConformance } from "./conformance.js";
export type {
ConformanceContext,
ConformanceFactory,
RunConformanceOptions,
} from "./conformance.types.js";

View File

@@ -0,0 +1,5 @@
export {
lookupDirective,
lookupLoader,
validateResolvers,
} from "./validators.js";

View File

@@ -0,0 +1,76 @@
# `validators`
<!-- begin:auto-docs -->
_(no submodules)_
<!-- end:auto-docs -->
## Purpose
Pre-build validators concrete GraphQL engines call after
`registry.freeze()` and before `buildSchema`. The contract layer owns these
checks so every engine produces the same error codes for the same
misconfiguration — a missing resolver fires `GRAPHQL.RESOLVER_MISSING` from
Yoga and from Apollo with the same `details.type` / `details.field` payload.
Without these helpers each engine adapter would re-invent the same checks
in slightly different ways; centralising them keeps the public failure
surface stable across engine swaps (design §10.5).
## Features
- `validateResolvers(snapshot)` — every root-operation field declared in
SDL must have a resolver entry; missing entries raise
`GRAPHQL.RESOLVER_MISSING`.
- `lookupDirective(map, name)` — typed lookup that raises
`GRAPHQL.DIRECTIVE_UNKNOWN` when the directive isn't installed.
- `lookupLoader(snapshot, name)` — typed lookup that raises
`GRAPHQL.LOADER_NOT_REGISTERED` when the request context references an
unknown loader.
## Usage: Basic
```ts
import { validateResolvers } from "@suaveplan/api-graphql/validators";
const snapshot = registry.freeze();
validateResolvers(snapshot); // throws GRAPHQL.RESOLVER_MISSING if any
```
## Usage: Advanced
Engine adapters wire all three helpers into the pipeline:
```ts
import {
lookupDirective,
lookupLoader,
validateResolvers,
} from "@suaveplan/api-graphql/validators";
import { HOST_DIRECTIVES } from "@suaveplan/api-graphql/directives";
validateResolvers(snapshot);
for (const ref of schemaDirectiveReferences) {
lookupDirective(HOST_DIRECTIVES, ref); // GRAPHQL.DIRECTIVE_UNKNOWN
}
const userLoader = lookupLoader(snapshot, "userById"); // GRAPHQL.LOADER_NOT_REGISTERED
```
## API Reference
- `validateResolvers(snapshot: FrozenRegistry): void` — root-field resolver
check.
- `lookupDirective(map, name): DirectiveDescriptor` — directive lookup.
- `lookupLoader(snapshot, name): LoaderFactory` — loader lookup.
## Implementation Notes
`validateResolvers` only enforces resolver presence on root operation
types (`Query`, `Mutation`, `Subscription`) because entity-type fields
(e.g. `Invoice.total`) MAY rely on GraphQL's default resolver (the field
name matches a property on `parent`). Engines that want stricter checks
(`resolveType` on interfaces, e.g.) wire their own follow-up validators.
`lookupDirective` and `lookupLoader` are intentionally simple — they
return the descriptor / factory or throw. Engines branch on
`instanceof GraphqlError` to surface the failure to the developer (boot
time) vs the caller (runtime) appropriately.

View File

@@ -0,0 +1,111 @@
import { describe, expect, it } from "@suaveplan/testing/runner";
import { authDirective, HOST_DIRECTIVES } from "../directives/directives.js";
import { GraphqlError } from "../error-codes/error-codes.js";
import { createGraphqlRegistry } from "../registry/registry.js";
import {
lookupDirective,
lookupLoader,
validateResolvers,
} from "./validators.js";
describe("validators", () => {
it("validateResolvers passes when every root field has a resolver", {
timeout: 5000,
}, () => {
const reg = createGraphqlRegistry();
reg.addTypes("type Query { ping: String! }");
reg.addResolvers({ Query: { ping: () => "pong" } });
expect(() => validateResolvers(reg.freeze())).not.toThrow();
});
it("validateResolvers raises RESOLVER_MISSING for missing root field", {
timeout: 5000,
}, () => {
const reg = createGraphqlRegistry();
reg.addTypes("type Query { a: Int! b: Int! }");
reg.addResolvers({ Query: { a: () => 1 } });
let raised: GraphqlError | undefined;
try {
validateResolvers(reg.freeze());
} catch (e) {
raised = e as GraphqlError;
}
expect(raised).toBeInstanceOf(GraphqlError);
expect(raised?.graphqlCode).toBe("GRAPHQL.RESOLVER_MISSING");
expect(raised?.context).toEqual({ type: "Query", field: "b" });
});
it("validateResolvers does not require resolvers for non-root types", {
timeout: 5000,
}, () => {
const reg = createGraphqlRegistry();
reg.addTypes(
"type Query { invoices: [Invoice!]! } type Invoice { id: ID! }"
);
reg.addResolvers({ Query: { invoices: () => [] } });
expect(() => validateResolvers(reg.freeze())).not.toThrow();
});
it("validateResolvers covers Mutation and Subscription roots", {
timeout: 5000,
}, () => {
const reg = createGraphqlRegistry();
reg.addTypes(
"type Mutation { pay: Boolean! } type Subscription { tick: Int! }"
);
// intentionally missing both
let raised: GraphqlError | undefined;
try {
validateResolvers(reg.freeze());
} catch (e) {
raised = e as GraphqlError;
}
expect(raised?.graphqlCode).toBe("GRAPHQL.RESOLVER_MISSING");
});
it("validateResolvers handles multiple resolver map contributions", {
timeout: 5000,
}, () => {
const reg = createGraphqlRegistry();
reg.addTypes("type Query { a: Int! b: Int! }");
reg.addResolvers({ Query: { a: () => 1 } });
reg.addResolvers({ Query: { b: () => 2 } });
expect(() => validateResolvers(reg.freeze())).not.toThrow();
});
it("lookupDirective returns the descriptor", { timeout: 5000 }, () => {
const d = lookupDirective(HOST_DIRECTIVES, "auth");
expect(d).toBe(authDirective);
});
it("lookupDirective raises DIRECTIVE_UNKNOWN", { timeout: 5000 }, () => {
let raised: GraphqlError | undefined;
try {
lookupDirective(HOST_DIRECTIVES, "nonExistent");
} catch (e) {
raised = e as GraphqlError;
}
expect(raised).toBeInstanceOf(GraphqlError);
expect(raised?.graphqlCode).toBe("GRAPHQL.DIRECTIVE_UNKNOWN");
expect(raised?.context).toEqual({ directive: "nonExistent" });
});
it("lookupLoader returns the registered factory", { timeout: 5000 }, () => {
const reg = createGraphqlRegistry();
reg.addLoader<string, number>("k", async (keys) => keys.map(() => 1));
const snap = reg.freeze();
const f = lookupLoader(snap, "k");
expect(f).toBeDefined();
});
it("lookupLoader raises LOADER_NOT_REGISTERED", { timeout: 5000 }, () => {
const snap = createGraphqlRegistry().freeze();
let raised: GraphqlError | undefined;
try {
lookupLoader(snap, "missing");
} catch (e) {
raised = e as GraphqlError;
}
expect(raised?.graphqlCode).toBe("GRAPHQL.LOADER_NOT_REGISTERED");
});
});

View File

@@ -0,0 +1,97 @@
import type { DirectiveDescriptor } from "../directives/directives.types.js";
import { GraphqlError } from "../error-codes/error-codes.js";
import { parseSdl } from "../federation/parse-sdl.js";
import type { FrozenRegistry } from "../registry/registry.types.js";
/**
* Pre-build validators engines call after `registry.freeze()` and before
* `buildSchema`. The contract layer owns these checks so every concrete
* engine produces the same error codes for the same misconfiguration.
*/
/**
* Verify every SDL field declared in `registry.typeDefs` has a corresponding
* resolver entry in one of the contributed `ResolverMap`s. Missing entries
* raise `GRAPHQL.RESOLVER_MISSING`.
*
* Scalar leaves on root operation types are required; entity fields whose
* value can be inferred from `parent` (e.g. `id` on a value type) may
* delegate to GraphQL's default resolver and are not flagged.
*/
export function validateResolvers(snapshot: FrozenRegistry): void {
const declared = new Map<string, Set<string>>();
for (const sdl of snapshot.typeDefs) {
for (const block of parseSdl(sdl)) {
const set = declared.get(block.typeName) ?? new Set<string>();
for (const field of block.fields) set.add(field);
declared.set(block.typeName, set);
}
}
const provided = new Map<string, Set<string>>();
for (const map of snapshot.resolvers) {
for (const [type, fields] of Object.entries(map)) {
const set = provided.get(type) ?? new Set<string>();
for (const field of Object.keys(fields)) set.add(field);
provided.set(type, set);
}
}
const rootTypes = new Set(["Query", "Mutation", "Subscription"]);
for (const [type, fields] of declared.entries()) {
if (!rootTypes.has(type)) continue;
const givenSet = provided.get(type) ?? new Set<string>();
for (const field of fields) {
if (!givenSet.has(field)) {
throw new GraphqlError(
"GRAPHQL.RESOLVER_MISSING",
`no resolver for ${type}.${field}`,
{ context: { type, field } }
);
}
}
}
}
/**
* Look up a directive descriptor by name from a host-supplied map; raise
* `GRAPHQL.DIRECTIVE_UNKNOWN` when the name isn't installed.
*
* Engines call this once per directive reference encountered while walking
* the SDL at schema-build time.
*/
export function lookupDirective(
map: Readonly<Record<string, DirectiveDescriptor>>,
name: string
): DirectiveDescriptor {
const descriptor = map[name];
if (descriptor === undefined) {
throw new GraphqlError(
"GRAPHQL.DIRECTIVE_UNKNOWN",
`no host directive registered with name "${name}"`,
{ context: { directive: name } }
);
}
return descriptor;
}
/**
* Look up a registered loader factory by name; raise
* `GRAPHQL.LOADER_NOT_REGISTERED` when the request context references an
* unknown loader.
*
* Engines call this from the per-request loader-binding step (step 7 of
* the request pipeline).
*/
export function lookupLoader(
snapshot: FrozenRegistry,
name: string
): NonNullable<ReturnType<FrozenRegistry["loaders"]["get"]>> {
const factory = snapshot.loaders.get(name);
if (factory === undefined) {
throw new GraphqlError(
"GRAPHQL.LOADER_NOT_REGISTERED",
`no loader registered with name "${name}"`,
{ context: { loader: name } }
);
}
return factory;
}

View File

@@ -0,0 +1,153 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "@suaveplan/testing/runner";
const PKG_ROOT = new URL("..", import.meta.url).pathname;
const PKG_JSON = JSON.parse(
readFileSync(join(PKG_ROOT, "package.json"), "utf8")
) as {
readonly dependencies: Record<string, string>;
readonly exports: Record<string, unknown>;
};
const RUNTIME_ALLOWLIST = new Set([
"@suaveplan/api-types",
"@suaveplan/error",
"tslib",
"zod",
]);
const SRC_ROOT = join(PKG_ROOT, "src");
function walk(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const p = join(dir, entry);
const st = statSync(p);
if (st.isDirectory()) out.push(...walk(p));
else out.push(p);
}
return out;
}
const FEATURE_MODULES = [
"registry",
"resolver",
"loader",
"subscription",
"federation",
"directives",
"engine",
"capability-id",
"error-codes",
"testing",
"validators",
];
describe("dep-graph", () => {
it("dependencies match the contract-layer allowlist", {
timeout: 5000,
}, () => {
for (const dep of Object.keys(PKG_JSON.dependencies)) {
expect(RUNTIME_ALLOWLIST.has(dep)).toBe(true);
}
});
it("subpath exports cover every feature module", { timeout: 5000 }, () => {
const exports = PKG_JSON.exports;
for (const m of FEATURE_MODULES) {
expect(exports[`./${m}`]).toBeDefined();
}
expect(exports["."]).toBeDefined();
});
it("no biome-ignore suppressions in src or tests", {
timeout: 5000,
}, () => {
const files = walk(SRC_ROOT)
.concat(walk(join(PKG_ROOT, "tests")))
.filter((f) => f.endsWith(".ts"));
const needle = ["biome", "ignore"].join("-");
for (const f of files) {
if (f.endsWith("dep-graph.test.ts")) continue;
const text = readFileSync(f, "utf8");
expect({ file: f, has: text.includes(needle) }).toEqual({
file: f,
has: false,
});
}
});
it("no direct vitest/bun:test imports", { timeout: 5000 }, () => {
const files = walk(SRC_ROOT)
.concat(walk(join(PKG_ROOT, "tests")))
.filter((f) => f.endsWith(".ts"));
const banned =
/from\s+['"](vitest|bun:test)['"]|require\(\s*['"](vitest|bun:test)['"]\s*\)/;
for (const f of files) {
const text = readFileSync(f, "utf8");
if (banned.test(text)) {
throw new Error(`${f} imports vitest or bun:test directly`);
}
}
expect(true).toBe(true);
});
it("no `export *` in feature barrels", { timeout: 5000 }, () => {
for (const m of FEATURE_MODULES) {
const idx = join(SRC_ROOT, m, "index.ts");
const text = readFileSync(idx, "utf8");
expect(text.includes("export *")).toBe(false);
}
});
it("every src .ts uses default-import zod", { timeout: 5000 }, () => {
const files = walk(SRC_ROOT).filter(
(f) => f.endsWith(".ts") && !f.endsWith(".d.ts")
);
const named = /from\s+["']zod["']/;
const importLine =
/^\s*import\s+\{[^}]*\bz\b[^}]*\}\s+from\s+["']zod["']/m;
for (const f of files) {
const text = readFileSync(f, "utf8");
if (!named.test(text)) continue;
// If file imports zod, it must use `import z from "zod"` form.
const hasDefault = /^\s*import\s+z\s+from\s+["']zod["']/m.test(
text
);
const hasNamedZ = importLine.test(text);
expect({ file: f, hasDefault, hasNamedZ }).toEqual({
file: f,
hasDefault: true,
hasNamedZ: false,
});
}
});
it("every implementation .ts has a sibling .md", {
timeout: 5000,
}, () => {
const files = walk(SRC_ROOT).filter(
(f) =>
f.endsWith(".ts") &&
!f.endsWith(".test.ts") &&
!f.endsWith(".types.ts") &&
!f.endsWith(".spec-conformance.test.ts") &&
!/[/\\]index\.ts$/.test(f)
);
for (const f of files) {
const md = f.replace(/\.ts$/, ".md");
let has = false;
try {
statSync(md);
has = true;
} catch {
has = false;
}
expect({ file: f, hasMd: has }).toEqual({
file: f,
hasMd: true,
});
}
});
});

View File

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

View File

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

View File

@@ -0,0 +1,30 @@
import { defineConfig } from "vite";
import dts from "vite-plugin-dts";
export default defineConfig({
root: __dirname,
cacheDir: "../../../../node_modules/.vite/packages/api-graphql",
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\//, "tslib", "zod"],
},
},
});

View File

@@ -0,0 +1,30 @@
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/index.ts",
"tests/**",
],
thresholds: {
statements: 100,
branches: 100,
functions: 100,
lines: 100,
},
},
testTimeout: 30000,
},
});