first commit
This commit is contained in:
81
docs/api-stack/api/api-graphql/README.md
Normal file
81
docs/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.
|
||||
|
||||
84
docs/api-stack/api/api-graphql/src/directives/directives.md
Normal file
84
docs/api-stack/api/api-graphql/src/directives/directives.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# `directives`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Host-supplied GraphQL directive descriptors that engine adapters install at
|
||||
schema-build time. The contract layer ships two: `@auth(scope: String!)` for
|
||||
per-field authorisation and `@rateLimit(rpm: Int!)` for per-field rate
|
||||
limiting. Plugins reference them in SDL; engines wire the descriptor's
|
||||
`handler` into the resolver pipeline.
|
||||
|
||||
Keeping the descriptors here (rather than in each engine adapter) means a
|
||||
plugin author writes one piece of SDL and gets the same enforcement
|
||||
semantics from every concrete engine. Engine adapters install the SDL via
|
||||
the directive `sdl` string and dispatch to the descriptor's `handler` from
|
||||
their resolver wrapper.
|
||||
|
||||
## Features
|
||||
|
||||
- `authDirective` — checks `ctx.principal.scopes` against the declared scope
|
||||
and throws when missing. Locations: `FIELD_DEFINITION`.
|
||||
- `rateLimitDirective` — validates the `rpm` integer bound; the actual
|
||||
rate-limit accounting is the engine adapter's responsibility (the
|
||||
contract body asserts the argument shape).
|
||||
- `HOST_DIRECTIVES` — frozen map keyed by directive name for engine
|
||||
adapter consumption.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { authDirective } from "@suaveplan/api-graphql/directives";
|
||||
|
||||
// SDL declaration:
|
||||
// directive @auth(scope: String!) on FIELD_DEFINITION
|
||||
//
|
||||
// Used in a plugin's typeDefs:
|
||||
// type Query { invoices: [Invoice!]! @auth(scope: "billing:read") }
|
||||
console.log(authDirective.sdl);
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Engine adapters install every host directive in one pass:
|
||||
|
||||
```ts
|
||||
import { HOST_DIRECTIVES } from "@suaveplan/api-graphql/directives";
|
||||
|
||||
for (const directive of Object.values(HOST_DIRECTIVES)) {
|
||||
schemaBuilder.addDirective({
|
||||
name: directive.name,
|
||||
sdl: directive.sdl,
|
||||
run: directive.handler,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
The `handler` is the contract-layer enforcement body. The `@auth` handler
|
||||
reads `ctx.principal.scopes` (the typed `readonly Scope[]`) and raises a
|
||||
plain `Error` when the required scope is absent — engine adapters wrap that
|
||||
in the engine's GraphQL error type before returning it on the wire.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `authDirective: DirectiveDescriptor<{ scope: string }>` — descriptor for
|
||||
`@auth`.
|
||||
- `rateLimitDirective: DirectiveDescriptor<{ rpm: number }>` — descriptor
|
||||
for `@rateLimit`.
|
||||
- `HOST_DIRECTIVES` — frozen `{ auth, rateLimit }` lookup.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The descriptors carry **contract bodies**, not full implementations. A
|
||||
production engine adapter wraps each resolver in a check that runs the
|
||||
handler before invoking the resolver. For `@rateLimit`, the engine adapter
|
||||
also wires the per-tenant counter; the contract-layer handler only enforces
|
||||
the `rpm > 0` invariant so a misconfigured directive surfaces early.
|
||||
|
||||
Adding a new host directive: add a `DirectiveDescriptor`, append it to
|
||||
`HOST_DIRECTIVES`, and document the SDL in the package README. The
|
||||
`api-graphql-yoga` and `api-graphql-apollo` packages pick up new
|
||||
descriptors automatically through the frozen map.
|
||||
@@ -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.
|
||||
101
docs/api-stack/api/api-graphql/src/federation/merge-sdl.md
Normal file
101
docs/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.
|
||||
73
docs/api-stack/api/api-graphql/src/federation/namespace.md
Normal file
73
docs/api-stack/api/api-graphql/src/federation/namespace.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# `namespace`
|
||||
|
||||
## Purpose
|
||||
|
||||
Apply the `graphql.namespace` wrapper to a plugin's parsed SDL blocks.
|
||||
Plugins that declare `namespace: "Billing"` in their manifest get their
|
||||
root-operation extensions repackaged: `extend type Query { invoices }`
|
||||
becomes `type BillingQuery { invoices }` plus
|
||||
`extend type Query { billing: BillingQuery! }`. Non-root types pass
|
||||
through unchanged.
|
||||
|
||||
This is the second half of the federation story — `mergeSdl` calls
|
||||
`applyNamespace` on each plugin's contributions before unioning them, so
|
||||
namespaced plugins never collide on root fields.
|
||||
|
||||
## Features
|
||||
|
||||
- Wraps `Query`, `Mutation`, `Subscription` extensions in
|
||||
`<Ns>Query`/`<Ns>Mutation`/`<Ns>Subscription` types.
|
||||
- Emits exactly one root-extension per wrapped operation, even when the
|
||||
plugin contributed multiple `extend type Query` blocks.
|
||||
- Lower-cases the first character of the namespace label so it works as
|
||||
a field name (`Billing` → `billing`).
|
||||
- Leaves non-root types unchanged — entity types like `Invoice` stay at
|
||||
the top level.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { applyNamespace, parseSdl } from "@suaveplan/api-graphql/federation";
|
||||
|
||||
const blocks = parseSdl("extend type Query { invoices: [Invoice!]! }");
|
||||
const wrapped = applyNamespace(blocks, "Billing");
|
||||
// wrapped[0] is `type BillingQuery { invoices: [Invoice!]! }`
|
||||
// wrapped[1] is `extend type Query { billing: BillingQuery! }`
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Multiple root extensions in one plugin collapse to one wrapper root field:
|
||||
|
||||
```ts
|
||||
import { applyNamespace, parseSdl } from "@suaveplan/api-graphql/federation";
|
||||
|
||||
const blocks = parseSdl(
|
||||
"extend type Query { a: Int! } extend type Query { b: Int! }"
|
||||
);
|
||||
const wrapped = applyNamespace(blocks, "Demo");
|
||||
// Two `type DemoQuery` blocks (the engine merges them) but only one
|
||||
// `extend type Query { demo: DemoQuery! }` is emitted.
|
||||
```
|
||||
|
||||
The merger consolidates multiple `type DemoQuery` blocks via the field-list
|
||||
deduplication path; the single root-extension keeps `Query` clean.
|
||||
|
||||
## API Reference
|
||||
|
||||
- `applyNamespace(blocks, namespace) → TypeBlock[]` — wrap root-operation
|
||||
blocks; pass non-root blocks through.
|
||||
- `namespaceFieldName(namespace) → string` — `"Billing"` → `"billing"`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The wrapping is purely textual at the block level — the inner block has
|
||||
its `extend type Query` prefix rewritten to `type BillingQuery` and the
|
||||
generated root-extension is a short SDL string. The merger's collision
|
||||
detector still runs over the wrapped output, so two plugins that both
|
||||
declare `namespace: "Billing"` will collide on `Query.billing` and raise
|
||||
`GRAPHQL.SCHEMA_COLLISION` as expected.
|
||||
|
||||
Empty namespace strings short-circuit: the input blocks are returned
|
||||
verbatim. Callers MUST treat `namespace === ""` and `namespace === undefined`
|
||||
identically — the federation entry point already does so.
|
||||
73
docs/api-stack/api/api-graphql/src/federation/parse-sdl.md
Normal file
73
docs/api-stack/api/api-graphql/src/federation/parse-sdl.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# `parse-sdl`
|
||||
|
||||
## Purpose
|
||||
|
||||
Minimal SDL parser used by the federation merger to locate `type Name { ... }`
|
||||
and `extend type Name { ... }` blocks plus the field names inside each.
|
||||
This is **not** a full GraphQL AST parser — it is the contract-layer
|
||||
dependency-free helper that recognises the constructs the federation rules
|
||||
care about (design §10.2).
|
||||
|
||||
Engine adapters re-parse the merged SDL with their own AST machinery. The
|
||||
purpose of this parser is the cross-plugin invariant check before the
|
||||
engine ever sees the schema.
|
||||
|
||||
## Features
|
||||
|
||||
- Recognises `type Name { ... }` and `extend type Name { ... }` blocks.
|
||||
- Extracts field names (handles inline argument lists `field(a: Int): T`).
|
||||
- Strips line comments (`# ...`) and block string descriptions
|
||||
(`"""..."""`) before parsing.
|
||||
- Ignores union / interface / input / enum / scalar declarations — these
|
||||
pass through to the merged SDL but don't contribute to the field-level
|
||||
collision check.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { parseSdl } from "@suaveplan/api-graphql/federation";
|
||||
|
||||
const blocks = parseSdl(
|
||||
"type Invoice { id: ID! total: Int! } extend type Query { invoices: [Invoice!]! }"
|
||||
);
|
||||
console.log(blocks[0].typeName); // "Invoice"
|
||||
console.log(blocks[1].kind); // "extend"
|
||||
console.log(blocks[1].fields); // ["invoices"]
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Strip descriptions before parsing for downstream tooling that wants only
|
||||
the structural SDL:
|
||||
|
||||
```ts
|
||||
import { stripCommentsAndDescriptions } from "@suaveplan/api-graphql/federation";
|
||||
|
||||
const cleaned = stripCommentsAndDescriptions(`
|
||||
"""Invoice domain root"""
|
||||
type Invoice {
|
||||
# the canonical id
|
||||
id: ID!
|
||||
}
|
||||
`);
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `parseSdl(sdl) → TypeBlock[]` — extract structured blocks.
|
||||
- `stripCommentsAndDescriptions(sdl) → string` — comment + block-string
|
||||
removal.
|
||||
- `TypeBlock` — `{ kind: "type" | "extend"; typeName; fields; rawBlock }`.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The parser uses three regexes: one for block-string descriptions, one for
|
||||
line comments, and one for the type/extend block envelope. Field-name
|
||||
extraction is line-oriented; multi-line argument lists fall back to the
|
||||
inline-argument regex, which is `([a-z_][a-z0-9_]*)\s*(?:\([^)]*\))?\s*:`.
|
||||
|
||||
Pathological inputs (nested braces inside field type expressions, e.g.
|
||||
`field: { ... }`) are not supported — GraphQL SDL doesn't use them. If a
|
||||
future GraphQL spec extension introduces nested braces, the regex would
|
||||
need replacing with a proper tokeniser; that's a known limit and acceptable
|
||||
for the contract layer.
|
||||
84
docs/api-stack/api/api-graphql/src/registry/registry.md
Normal file
84
docs/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.
|
||||
@@ -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.
|
||||
83
docs/api-stack/api/api-graphql/src/testing/conformance.md
Normal file
83
docs/api-stack/api/api-graphql/src/testing/conformance.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# `conformance`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
`runGraphqlEngineConformance` is the cross-engine test harness every concrete
|
||||
GraphQL engine (`api-graphql-yoga`, `api-graphql-apollo`) MUST pass. It
|
||||
exercises the abstract `GraphqlEngine` contract from `@suaveplan/api-graphql/engine`
|
||||
against a caller-supplied factory: the harness instantiates the engine, runs
|
||||
a tiny SDL through `buildSchema`, exercises `executeQuery` /
|
||||
`executeSubscription`, and finally disposes the engine.
|
||||
|
||||
The harness lives in this package so the contract-layer interface and the
|
||||
test surface stay in lock-step. Adding a new lifecycle method to
|
||||
`GraphqlEngine` MUST be accompanied by a new scenario here.
|
||||
|
||||
## Features
|
||||
|
||||
- Four mandatory scenarios: `buildSchema`, `executeQuery`,
|
||||
`executeSubscription`, `dispose`.
|
||||
- Engine and request context are produced by the caller-supplied factory,
|
||||
so engines that need bespoke transport / capability wiring can supply it.
|
||||
- Uses `@suaveplan/testing/runner` for `describe` / `it` / `expect` —
|
||||
identical surface on Vitest and Bun's `bun:test`.
|
||||
- Every `it` carries the mandatory `{ timeout: 5000 }`.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { runGraphqlEngineConformance } from "@suaveplan/api-graphql/testing";
|
||||
import { createYogaEngine } from "@suaveplan/api-graphql-yoga";
|
||||
|
||||
runGraphqlEngineConformance({
|
||||
label: "yoga",
|
||||
factory: async () => ({
|
||||
engine: createYogaEngine({ /* engine config */ }),
|
||||
makeRequestContext: () => makeStubRequestContext(),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Pass an additional `engineFactory` when the engine has a parallel factory
|
||||
path the harness should sanity-check (e.g. a "test" factory and a "prod"
|
||||
factory that should produce structurally identical engines):
|
||||
|
||||
```ts
|
||||
import { runGraphqlEngineConformance } from "@suaveplan/api-graphql/testing";
|
||||
|
||||
runGraphqlEngineConformance({
|
||||
label: "apollo",
|
||||
factory: async () => ({
|
||||
engine: createApolloEngine({ mode: "test" }),
|
||||
makeRequestContext: () => testRequestCtx(),
|
||||
}),
|
||||
engineFactory: () => createApolloEngine({ mode: "prod" }),
|
||||
});
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `runGraphqlEngineConformance(options)` — entry point.
|
||||
- `RunConformanceOptions` — `{ label, factory, engineFactory? }`.
|
||||
- `ConformanceContext` — `{ engine, makeRequestContext }`.
|
||||
- `ConformanceFactory` — async producer of a fresh
|
||||
`ConformanceContext` per scenario.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
The harness is intentionally minimal: four scenarios that exercise the
|
||||
abstract `GraphqlEngine` surface end-to-end. Engine adapters add their own
|
||||
deeper test suites (custom directive handling, persisted queries, federation
|
||||
specifics) in their package's own `tests/` directory; the contract-layer
|
||||
harness only asserts the public interface contract.
|
||||
|
||||
Each scenario calls `factory()` to get a fresh engine — this matches how
|
||||
the host instantiates engines in production (one per host process). The
|
||||
factory is async so engine adapters that need to load WASM / open files at
|
||||
boot can do so without blocking the test runner.
|
||||
76
docs/api-stack/api/api-graphql/src/validators/validators.md
Normal file
76
docs/api-stack/api/api-graphql/src/validators/validators.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# `validators`
|
||||
|
||||
<!-- begin:auto-docs -->
|
||||
_(no submodules)_
|
||||
<!-- end:auto-docs -->
|
||||
|
||||
## Purpose
|
||||
|
||||
Pre-build validators concrete GraphQL engines call after
|
||||
`registry.freeze()` and before `buildSchema`. The contract layer owns these
|
||||
checks so every engine produces the same error codes for the same
|
||||
misconfiguration — a missing resolver fires `GRAPHQL.RESOLVER_MISSING` from
|
||||
Yoga and from Apollo with the same `details.type` / `details.field` payload.
|
||||
|
||||
Without these helpers each engine adapter would re-invent the same checks
|
||||
in slightly different ways; centralising them keeps the public failure
|
||||
surface stable across engine swaps (design §10.5).
|
||||
|
||||
## Features
|
||||
|
||||
- `validateResolvers(snapshot)` — every root-operation field declared in
|
||||
SDL must have a resolver entry; missing entries raise
|
||||
`GRAPHQL.RESOLVER_MISSING`.
|
||||
- `lookupDirective(map, name)` — typed lookup that raises
|
||||
`GRAPHQL.DIRECTIVE_UNKNOWN` when the directive isn't installed.
|
||||
- `lookupLoader(snapshot, name)` — typed lookup that raises
|
||||
`GRAPHQL.LOADER_NOT_REGISTERED` when the request context references an
|
||||
unknown loader.
|
||||
|
||||
## Usage: Basic
|
||||
|
||||
```ts
|
||||
import { validateResolvers } from "@suaveplan/api-graphql/validators";
|
||||
|
||||
const snapshot = registry.freeze();
|
||||
validateResolvers(snapshot); // throws GRAPHQL.RESOLVER_MISSING if any
|
||||
```
|
||||
|
||||
## Usage: Advanced
|
||||
|
||||
Engine adapters wire all three helpers into the pipeline:
|
||||
|
||||
```ts
|
||||
import {
|
||||
lookupDirective,
|
||||
lookupLoader,
|
||||
validateResolvers,
|
||||
} from "@suaveplan/api-graphql/validators";
|
||||
import { HOST_DIRECTIVES } from "@suaveplan/api-graphql/directives";
|
||||
|
||||
validateResolvers(snapshot);
|
||||
for (const ref of schemaDirectiveReferences) {
|
||||
lookupDirective(HOST_DIRECTIVES, ref); // GRAPHQL.DIRECTIVE_UNKNOWN
|
||||
}
|
||||
const userLoader = lookupLoader(snapshot, "userById"); // GRAPHQL.LOADER_NOT_REGISTERED
|
||||
```
|
||||
|
||||
## API Reference
|
||||
|
||||
- `validateResolvers(snapshot: FrozenRegistry): void` — root-field resolver
|
||||
check.
|
||||
- `lookupDirective(map, name): DirectiveDescriptor` — directive lookup.
|
||||
- `lookupLoader(snapshot, name): LoaderFactory` — loader lookup.
|
||||
|
||||
## Implementation Notes
|
||||
|
||||
`validateResolvers` only enforces resolver presence on root operation
|
||||
types (`Query`, `Mutation`, `Subscription`) because entity-type fields
|
||||
(e.g. `Invoice.total`) MAY rely on GraphQL's default resolver (the field
|
||||
name matches a property on `parent`). Engines that want stricter checks
|
||||
(`resolveType` on interfaces, e.g.) wire their own follow-up validators.
|
||||
|
||||
`lookupDirective` and `lookupLoader` are intentionally simple — they
|
||||
return the descriptor / factory or throw. Engines branch on
|
||||
`instanceof GraphqlError` to surface the failure to the developer (boot
|
||||
time) vs the caller (runtime) appropriately.
|
||||
Reference in New Issue
Block a user