Initial Commit
This commit is contained in:
81
packages/api-stack/api/api-graphql/README.md
Normal file
81
packages/api-stack/api/api-graphql/README.md
Normal 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.
|
||||
|
||||
105
packages/api-stack/api/api-graphql/package.json
Normal file
105
packages/api-stack/api/api-graphql/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
export {
|
||||
GRAPHQL_CAPABILITY_ID,
|
||||
type GraphqlCapabilityId,
|
||||
} from "./capability-id.types.js";
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>>;
|
||||
@@ -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;
|
||||
}
|
||||
10
packages/api-stack/api/api-graphql/src/directives/index.ts
Normal file
10
packages/api-stack/api/api-graphql/src/directives/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export {
|
||||
authDirective,
|
||||
HOST_DIRECTIVES,
|
||||
rateLimitDirective,
|
||||
} from "./directives.js";
|
||||
export type {
|
||||
DirectiveArgSpec,
|
||||
DirectiveDescriptor,
|
||||
DirectiveLocation,
|
||||
} from "./directives.types.js";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>;
|
||||
6
packages/api-stack/api/api-graphql/src/engine/index.ts
Normal file
6
packages/api-stack/api/api-graphql/src/engine/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
GraphqlEngine,
|
||||
GraphqlEngineFactory,
|
||||
GraphqlRequest,
|
||||
GraphqlResponse,
|
||||
} from "./engine.types.js";
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
GRAPHQL_CODES,
|
||||
GraphqlError,
|
||||
type GraphqlErrorCode,
|
||||
} from "./error-codes.js";
|
||||
@@ -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;
|
||||
}
|
||||
13
packages/api-stack/api/api-graphql/src/federation/index.ts
Normal file
13
packages/api-stack/api/api-graphql/src/federation/index.ts
Normal 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";
|
||||
101
packages/api-stack/api/api-graphql/src/federation/merge-sdl.md
Normal file
101
packages/api-stack/api/api-graphql/src/federation/merge-sdl.md
Normal 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.
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
105
packages/api-stack/api/api-graphql/src/federation/merge-sdl.ts
Normal file
105
packages/api-stack/api/api-graphql/src/federation/merge-sdl.ts
Normal 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);
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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!");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
90
packages/api-stack/api/api-graphql/src/index.ts
Normal file
90
packages/api-stack/api/api-graphql/src/index.ts
Normal 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";
|
||||
2
packages/api-stack/api/api-graphql/src/loader/index.ts
Normal file
2
packages/api-stack/api/api-graphql/src/loader/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { LoaderFactory } from "@suaveplan/api-types/graphql";
|
||||
export type { BatchFn } from "./loader.types.js";
|
||||
@@ -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[]>;
|
||||
6
packages/api-stack/api/api-graphql/src/registry/index.ts
Normal file
6
packages/api-stack/api/api-graphql/src/registry/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { createGraphqlRegistry } from "./registry.js";
|
||||
export type {
|
||||
CreateRegistryOptions,
|
||||
FrozenRegistry,
|
||||
GraphqlRegistry,
|
||||
} from "./registry.types.js";
|
||||
84
packages/api-stack/api/api-graphql/src/registry/registry.md
Normal file
84
packages/api-stack/api/api-graphql/src/registry/registry.md
Normal 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.
|
||||
129
packages/api-stack/api/api-graphql/src/registry/registry.test.ts
Normal file
129
packages/api-stack/api/api-graphql/src/registry/registry.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
138
packages/api-stack/api/api-graphql/src/registry/registry.ts
Normal file
138
packages/api-stack/api/api-graphql/src/registry/registry.ts
Normal 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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
2
packages/api-stack/api/api-graphql/src/resolver/index.ts
Normal file
2
packages/api-stack/api/api-graphql/src/resolver/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export type { Resolver } from "@suaveplan/api-types/graphql";
|
||||
export type { ResolverFieldMap, ResolverMap } from "./resolver.types.js";
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>>>;
|
||||
10
packages/api-stack/api/api-graphql/src/subscription/index.ts
Normal file
10
packages/api-stack/api/api-graphql/src/subscription/index.ts
Normal 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";
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
6
packages/api-stack/api/api-graphql/src/testing/index.ts
Normal file
6
packages/api-stack/api/api-graphql/src/testing/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export { runGraphqlEngineConformance } from "./conformance.js";
|
||||
export type {
|
||||
ConformanceContext,
|
||||
ConformanceFactory,
|
||||
RunConformanceOptions,
|
||||
} from "./conformance.types.js";
|
||||
@@ -0,0 +1,5 @@
|
||||
export {
|
||||
lookupDirective,
|
||||
lookupLoader,
|
||||
validateResolvers,
|
||||
} from "./validators.js";
|
||||
@@ -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.
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
153
packages/api-stack/api/api-graphql/tests/dep-graph.test.ts
Normal file
153
packages/api-stack/api/api-graphql/tests/dep-graph.test.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
12
packages/api-stack/api/api-graphql/tsconfig.json
Normal file
12
packages/api-stack/api/api-graphql/tsconfig.json
Normal 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"]
|
||||
}
|
||||
12
packages/api-stack/api/api-graphql/tsconfig.lib.json
Normal file
12
packages/api-stack/api/api-graphql/tsconfig.lib.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/tsconfig",
|
||||
"extends": "../../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts", "dist", "node_modules"]
|
||||
}
|
||||
30
packages/api-stack/api/api-graphql/vite.config.ts
Normal file
30
packages/api-stack/api/api-graphql/vite.config.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
});
|
||||
30
packages/api-stack/api/api-graphql/vitest.config.ts
Normal file
30
packages/api-stack/api/api-graphql/vitest.config.ts
Normal 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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user