diff --git a/packages/graphql/package.json b/packages/graphql/package.json
index 23e32a7590c..487bcf39087 100644
--- a/packages/graphql/package.json
+++ b/packages/graphql/package.json
@@ -30,15 +30,16 @@
"node": ">=20.0.0"
},
"dependencies": {
- "@alloy-js/core": "^0.11.0",
- "@alloy-js/typescript": "^0.11.0",
+ "@alloy-js/core": "^0.22.0",
+ "@alloy-js/graphql": "link:../../alloy/packages/graphql",
+ "@alloy-js/typescript": "^0.22.0",
"change-case": "^5.4.4",
"graphql": "^16.9.0"
},
"scripts": {
"clean": "rimraf ./dist ./temp",
- "build": "tsc -p .",
- "watch": "tsc --watch",
+ "build": "alloy build",
+ "watch": "alloy build --watch",
"test": "vitest run",
"test:watch": "vitest -w",
"lint": "eslint . --max-warnings=0",
@@ -56,6 +57,8 @@
"@typespec/mutator-framework": "workspace:~"
},
"devDependencies": {
+ "@alloy-js/cli": "^0.22.0",
+ "@alloy-js/rollup-plugin": "^0.1.0",
"@types/node": "~22.13.13",
"@typespec/compiler": "workspace:~",
"@typespec/emitter-framework": "workspace:~",
diff --git a/packages/graphql/src/components/fields/field.tsx b/packages/graphql/src/components/fields/field.tsx
new file mode 100644
index 00000000000..ff475b07f92
--- /dev/null
+++ b/packages/graphql/src/components/fields/field.tsx
@@ -0,0 +1,72 @@
+import { type ModelProperty, getDeprecationDetails, isArrayModelType } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+import { resolveGraphQLTypeName } from "../../lib/graphql-type-name.js";
+import { hasNullableElements, isNullable } from "../../lib/nullable.js";
+
+export interface FieldProps {
+ property: ModelProperty;
+ isInput: boolean;
+}
+
+export function Field(props: FieldProps) {
+ const { $, program } = useTsp();
+
+ const doc = $.type.getDoc(props.property);
+ const deprecation = getDeprecationDetails(program, props.property);
+ const nullable = isNullable(props.property) || props.property.optional;
+ const type = props.property.type;
+
+ if (type.kind === "Model" && isArrayModelType(type)) {
+ const elemNullable = hasNullableElements(props.property);
+ const typeName = resolveGraphQLTypeName(type.indexer.value);
+
+ if (props.isInput) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+ }
+
+ if (props.isInput) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/graphql/src/components/fields/index.ts b/packages/graphql/src/components/fields/index.ts
new file mode 100644
index 00000000000..0cec3b46b26
--- /dev/null
+++ b/packages/graphql/src/components/fields/index.ts
@@ -0,0 +1,2 @@
+export { Field, type FieldProps } from "./field.js";
+export { OperationField, type OperationFieldProps } from "./operation-field.js";
diff --git a/packages/graphql/src/components/fields/operation-field.tsx b/packages/graphql/src/components/fields/operation-field.tsx
new file mode 100644
index 00000000000..f6af1194b60
--- /dev/null
+++ b/packages/graphql/src/components/fields/operation-field.tsx
@@ -0,0 +1,58 @@
+import { type Operation, getDeprecationDetails, isArrayModelType } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+import { resolveGraphQLTypeName } from "../../lib/graphql-type-name.js";
+import { hasNullableElements, isNullable } from "../../lib/nullable.js";
+
+export interface OperationFieldProps {
+ operation: Operation;
+}
+
+export function OperationField(props: OperationFieldProps) {
+ const { $, program } = useTsp();
+
+ const doc = $.type.getDoc(props.operation);
+ const deprecation = getDeprecationDetails(program, props.operation);
+ const returnType = props.operation.returnType;
+ const nullable = isNullable(props.operation);
+ const params = Array.from(props.operation.parameters.properties.values());
+
+ const isList = returnType.kind === "Model" && isArrayModelType(returnType);
+ const typeName = isList
+ ? resolveGraphQLTypeName(returnType.indexer.value)
+ : resolveGraphQLTypeName(returnType);
+ const elemNullable = isList && hasNullableElements(props.operation);
+
+ return (
+
+ {isList ? : undefined}
+ {params.map((param) => {
+ const paramNullable = isNullable(param) || param.optional;
+ const paramType = param.type;
+ const paramIsList = paramType.kind === "Model" && isArrayModelType(paramType);
+ const paramElemNullable = paramIsList && hasNullableElements(param);
+ const paramTypeName = paramIsList
+ ? resolveGraphQLTypeName(paramType.indexer.value)
+ : resolveGraphQLTypeName(paramType);
+
+ return (
+
+ {paramIsList ? : undefined}
+
+ );
+ })}
+
+ );
+}
diff --git a/packages/graphql/src/components/types/enum-type.tsx b/packages/graphql/src/components/types/enum-type.tsx
new file mode 100644
index 00000000000..8b98089d2d1
--- /dev/null
+++ b/packages/graphql/src/components/types/enum-type.tsx
@@ -0,0 +1,25 @@
+import { type Enum, getDoc, getDeprecationDetails } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+
+export interface EnumTypeProps {
+ type: Enum;
+}
+
+export function EnumType(props: EnumTypeProps) {
+ const { program } = useTsp();
+ const doc = getDoc(program, props.type);
+ const members = [...props.type.members.values()];
+
+ return (
+
+ {members.map((member) => (
+
+ ))}
+
+ );
+}
diff --git a/packages/graphql/src/components/types/index.ts b/packages/graphql/src/components/types/index.ts
new file mode 100644
index 00000000000..00f4a951e33
--- /dev/null
+++ b/packages/graphql/src/components/types/index.ts
@@ -0,0 +1,3 @@
+export { EnumType, type EnumTypeProps } from "./enum-type.js";
+export { ScalarType, type ScalarTypeProps } from "./scalar-type.js";
+export { UnionType, type UnionTypeProps, type GraphQLUnion } from "./union-type.js";
diff --git a/packages/graphql/src/components/types/scalar-type.tsx b/packages/graphql/src/components/types/scalar-type.tsx
new file mode 100644
index 00000000000..4ea4410fbc6
--- /dev/null
+++ b/packages/graphql/src/components/types/scalar-type.tsx
@@ -0,0 +1,21 @@
+import { type Scalar, getDoc } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+
+export interface ScalarTypeProps {
+ type: Scalar;
+ specificationUrl?: string;
+}
+
+export function ScalarType(props: ScalarTypeProps) {
+ const { program } = useTsp();
+ const doc = getDoc(program, props.type);
+
+ return (
+
+ );
+}
diff --git a/packages/graphql/src/components/types/union-type.tsx b/packages/graphql/src/components/types/union-type.tsx
new file mode 100644
index 00000000000..387861f03b1
--- /dev/null
+++ b/packages/graphql/src/components/types/union-type.tsx
@@ -0,0 +1,24 @@
+import { type Model, type Union, getDoc } from "@typespec/compiler";
+import * as gql from "@alloy-js/graphql";
+import { useTsp } from "@typespec/emitter-framework";
+
+/**
+ * A Union guaranteed to have a name after mutation.
+ * The mutation engine ensures this: anonymous unions get derived names.
+ */
+export interface GraphQLUnion extends Union {
+ name: string;
+}
+
+export interface UnionTypeProps {
+ type: GraphQLUnion;
+}
+
+export function UnionType(props: UnionTypeProps) {
+ const { program } = useTsp();
+ const doc = getDoc(program, props.type);
+ const variants = [...props.type.variants.values()];
+ const members = variants.map((v) => (v.type as Model).name);
+
+ return ;
+}
diff --git a/packages/graphql/src/context/graphql-schema-context.tsx b/packages/graphql/src/context/graphql-schema-context.tsx
new file mode 100644
index 00000000000..f51826dcb89
--- /dev/null
+++ b/packages/graphql/src/context/graphql-schema-context.tsx
@@ -0,0 +1,21 @@
+import { type ComponentContext, createNamedContext, useContext } from "@alloy-js/core";
+import type { TypeGraph } from "../mutation-engine/type-graph.js";
+
+export interface GraphQLSchemaContextValue {
+ typeGraph: TypeGraph;
+}
+
+export const GraphQLSchemaContext: ComponentContext =
+ createNamedContext("GraphQLSchema");
+
+export function useGraphQLSchema(): GraphQLSchemaContextValue {
+ const context = useContext(GraphQLSchemaContext);
+
+ if (!context) {
+ throw new Error(
+ "useGraphQLSchema must be used within GraphQLSchemaContext.Provider.",
+ );
+ }
+
+ return context;
+}
diff --git a/packages/graphql/src/context/index.ts b/packages/graphql/src/context/index.ts
new file mode 100644
index 00000000000..cf8c0efcbbb
--- /dev/null
+++ b/packages/graphql/src/context/index.ts
@@ -0,0 +1,5 @@
+export {
+ GraphQLSchemaContext,
+ useGraphQLSchema,
+ type GraphQLSchemaContextValue,
+} from "./graphql-schema-context.js";
diff --git a/packages/graphql/src/emitter.ts b/packages/graphql/src/emitter.ts
index 6a34c0552df..03a3b77e3eb 100644
--- a/packages/graphql/src/emitter.ts
+++ b/packages/graphql/src/emitter.ts
@@ -1,37 +1,57 @@
-import type { EmitContext, NewLine } from "@typespec/compiler";
-import { resolvePath } from "@typespec/compiler";
-import { createGraphQLEmitter } from "./graphql-emitter.js";
-import type { GraphQLEmitterOptions } from "./lib.js";
-
-const defaultOptions = {
- "new-line": "lf",
- "omit-unreachable-types": false,
- strict: false,
-} as const;
+import { type EmitContext, type Namespace } from "@typespec/compiler";
+import { type GraphQLEmitterOptions, reportDiagnostic } from "./lib.js";
+import { listSchemas } from "./lib/schema.js";
+import { createGraphQLMutationEngine } from "./mutation-engine/index.js";
+import { mutateSchema } from "./mutation-engine/schema-mutator.js";
+import type { TypeGraph } from "./mutation-engine/type-graph.js";
+import { resolveTypeUsage } from "./type-usage.js";
+/**
+ * Main emitter entry point for GraphQL SDL generation.
+ *
+ * Pipeline: type-usage → mutation → buildTypeGraph → render.
+ * Rendering is a stub in this PR — will be implemented when the Schema
+ * orchestrator component is added.
+ */
export async function $onEmit(context: EmitContext) {
- const options = resolveOptions(context);
- const emitter = createGraphQLEmitter(context, options);
- await emitter.emitGraphQL();
-}
+ const schemas = listSchemas(context.program);
+ if (schemas.length === 0) {
+ schemas.push({ type: context.program.getGlobalNamespaceType() });
+ }
-export interface ResolvedGraphQLEmitterOptions {
- outputFile: string;
- newLine: NewLine;
- omitUnreachableTypes: boolean;
- strict: boolean;
+ for (const schema of schemas) {
+ const typeGraph = emitSchema(context, schema.type);
+ if (typeGraph) {
+ renderSchema(typeGraph, schema.name);
+ }
+ }
}
-export function resolveOptions(
+function emitSchema(
context: EmitContext,
-): ResolvedGraphQLEmitterOptions {
- const resolvedOptions = { ...defaultOptions, ...context.options };
- const outputFile = resolvedOptions["output-file"] ?? "{schema-name}.graphql";
+ schema: Namespace,
+): TypeGraph | undefined {
+ const program = context.program;
+ const omitUnreachable = context.options["omit-unreachable-types"] ?? false;
+
+ const typeUsage = resolveTypeUsage(schema, omitUnreachable);
+ const engine = createGraphQLMutationEngine(program);
+ const typeGraph = mutateSchema(program, engine, schema, typeUsage);
+
+ if (typeGraph.globalNamespace.operations.size === 0) {
+ reportDiagnostic(program, {
+ code: "empty-schema",
+ target: schema,
+ });
+ return undefined;
+ }
+
+ return typeGraph;
+}
- return {
- outputFile: resolvePath(context.emitterOutputDir, outputFile),
- newLine: resolvedOptions["new-line"],
- omitUnreachableTypes: resolvedOptions["omit-unreachable-types"],
- strict: resolvedOptions["strict"],
- };
+function renderSchema(_typeGraph: TypeGraph, _schemaName?: string): void {
+ // Stub — will be replaced with Alloy component rendering:
+ //
+ //
+ //
}
diff --git a/packages/graphql/src/graphql-emitter.ts b/packages/graphql/src/graphql-emitter.ts
deleted file mode 100644
index fdb3ffca332..00000000000
--- a/packages/graphql/src/graphql-emitter.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import { emitFile, interpolatePath, type EmitContext } from "@typespec/compiler";
-import { printSchema } from "graphql";
-import type { ResolvedGraphQLEmitterOptions } from "./emitter.js";
-import type { GraphQLEmitterOptions } from "./lib.js";
-import { listSchemas } from "./lib/schema.js";
-import { createSchemaEmitter } from "./schema-emitter.js";
-import type { GraphQLSchemaRecord } from "./types.js";
-
-export function createGraphQLEmitter(
- context: EmitContext,
- options: ResolvedGraphQLEmitterOptions,
-) {
- const program = context.program;
-
- return {
- emitGraphQL,
- };
-
- async function emitGraphQL() {
- if (!program.compilerOptions.noEmit) {
- const schemaRecords = await getGraphQL();
- // first, emit diagnostics
- for (const schemaRecord of schemaRecords) {
- program.reportDiagnostics(schemaRecord.diagnostics);
- }
- if (program.hasError()) {
- return;
- }
- for (const schemaRecord of schemaRecords) {
- const schemaName = schemaRecord.schema.name || "schema";
- const filePath = interpolatePath(options.outputFile, {
- "schema-name": schemaName,
- });
- await emitFile(program, {
- path: filePath,
- content: printSchema(schemaRecord.graphQLSchema),
- newLine: options.newLine,
- });
- }
- }
- }
-
- async function getGraphQL(): Promise {
- const schemaRecords: GraphQLSchemaRecord[] = [];
- const schemas = listSchemas(program);
- if (schemas.length === 0) {
- schemas.push({ type: program.getGlobalNamespaceType() });
- }
- for (const schema of schemas) {
- const schemaEmitter = createSchemaEmitter(schema, context, options);
- const document = await schemaEmitter.emitSchema();
- if (document === undefined) {
- continue;
- }
- schemaRecords.push({
- schema: schema,
- graphQLSchema: document[0],
- diagnostics: document[1],
- });
- }
- return schemaRecords;
- }
-}
diff --git a/packages/graphql/src/lib.ts b/packages/graphql/src/lib.ts
index 156ddba9127..4e4033a206a 100644
--- a/packages/graphql/src/lib.ts
+++ b/packages/graphql/src/lib.ts
@@ -162,6 +162,19 @@ export const libDef = {
default: paramMessage`Scalar "${"name"}" collides with GraphQL built-in type "${"builtinName"}". This may cause unexpected behavior. Consider renaming the scalar.`,
},
},
+ "type-name-collision": {
+ severity: "error",
+ messages: {
+ default: paramMessage`Type "${"name"}" collides with another type of the same name in the GraphQL schema. Consider renaming one of the types.`,
+ },
+ },
+ "empty-schema": {
+ severity: "warning",
+ messages: {
+ default:
+ "GraphQL schema has no operations. At minimum a Query root type is required.",
+ },
+ },
},
emitter: {
options: EmitterOptionsSchema as JSONSchemaType,
diff --git a/packages/graphql/src/lib/interface.ts b/packages/graphql/src/lib/interface.ts
index cf76213f2a2..bb97cfcd93b 100644
--- a/packages/graphql/src/lib/interface.ts
+++ b/packages/graphql/src/lib/interface.ts
@@ -10,9 +10,11 @@ import {
import { useStateMap, useStateSet } from "@typespec/compiler/utils";
import { GraphQLKeys, NAMESPACE, reportDiagnostic } from "../lib.js";
-import type { Tagged } from "../types.d.ts";
import { propertiesEqual } from "./utils.js";
+declare const tags: unique symbol;
+type Tagged = BaseType & { [tags]: { [K in Tag]: void } };
+
// This will set the namespace for decorators implemented in this file
export const namespace = NAMESPACE;
diff --git a/packages/graphql/src/mutation-engine/index.ts b/packages/graphql/src/mutation-engine/index.ts
index 131420ec0ed..e66e7ad38c9 100644
--- a/packages/graphql/src/mutation-engine/index.ts
+++ b/packages/graphql/src/mutation-engine/index.ts
@@ -9,3 +9,5 @@ export {
GraphQLUnionMutation,
} from "./mutations/index.js";
export { GraphQLMutationOptions, GraphQLTypeContext } from "./options.js";
+export { mutateSchema } from "./schema-mutator.js";
+export { buildTypeGraph, type TypeGraph } from "./type-graph.js";
diff --git a/packages/graphql/src/mutation-engine/mutations/union.ts b/packages/graphql/src/mutation-engine/mutations/union.ts
index e8ee273d9c2..ddc53652d6c 100644
--- a/packages/graphql/src/mutation-engine/mutations/union.ts
+++ b/packages/graphql/src/mutation-engine/mutations/union.ts
@@ -159,31 +159,8 @@ export class GraphQLUnionMutation extends UnionMutation {
- return tk.unionVariant.create({
- name: variantNameToString(variant.name),
- type: variant.type,
- });
- });
-
- const flattenedUnion = tk.union.create({
- name: unionName,
- variants: variantArray,
- });
-
- this.#flattenedUnion = flattenedUnion;
- } else {
- this.#mutationNode.mutate((union) => {
- union.name = unionName;
- });
- }
-
- if (hasNull) {
- setNullable(this.mutatedType);
- }
-
// GraphQL unions can only contain object types — wrap scalars in synthetic models
+ // and substitute the wrapper into the variant so the union is self-contained.
for (const variant of mutatedVariants) {
const isScalar = variant.type.kind === "Scalar" || variant.type.kind === "Intrinsic";
@@ -204,8 +181,37 @@ export class GraphQLUnionMutation extends UnionMutation 0) {
+ const variantArray = mutatedVariants.map((variant) => {
+ return tk.unionVariant.create({
+ name: variantNameToString(variant.name),
+ type: variant.type,
+ });
+ });
+
+ const flattenedUnion = tk.type.clone(this.sourceType);
+ flattenedUnion.name = unionName;
+ flattenedUnion.variants.clear();
+ for (const variant of variantArray) {
+ flattenedUnion.variants.set(variant.name, variant);
+ variant.union = flattenedUnion;
+ }
+ tk.type.finishType(flattenedUnion);
+
+ this.#flattenedUnion = flattenedUnion;
+ } else {
+ this.#mutationNode.mutate((union) => {
+ union.name = unionName;
+ });
+ }
+
+ if (hasNull) {
+ setNullable(this.mutatedType);
+ }
}
/**
diff --git a/packages/graphql/src/mutation-engine/schema-mutator.ts b/packages/graphql/src/mutation-engine/schema-mutator.ts
new file mode 100644
index 00000000000..cc0c6978f20
--- /dev/null
+++ b/packages/graphql/src/mutation-engine/schema-mutator.ts
@@ -0,0 +1,99 @@
+import {
+ isArrayModelType,
+ navigateTypesInNamespace,
+ type Enum,
+ type Model,
+ type Namespace,
+ type Operation,
+ type Program,
+ type Scalar,
+ type Type,
+ type Union,
+} from "@typespec/compiler";
+import { $ } from "@typespec/compiler/typekit";
+import { reportDiagnostic } from "../lib.js";
+import { GraphQLTypeUsage, type TypeUsageResolver } from "../type-usage.js";
+import type { GraphQLMutationEngine } from "./engine.js";
+import { GraphQLTypeContext } from "./options.js";
+import { buildTypeGraph, type TypeGraph } from "./type-graph.js";
+
+/**
+ * Walk every type in the schema namespace, mutate it through the GraphQL
+ * mutation engine, and package the results into a TypeGraph.
+ *
+ * Filtering (unreachable types, array models, nullable unions) happens here
+ * so the engine only processes types that belong in the schema.
+ *
+ * Models used as both input and output get two mutations (Output and Input),
+ * producing separate entries in the TypeGraph (e.g., `Book` and `BookInput`).
+ */
+export function mutateSchema(
+ program: Program,
+ engine: GraphQLMutationEngine,
+ schema: Namespace,
+ typeUsage: TypeUsageResolver,
+): TypeGraph {
+ const tk = $(program);
+ const mutatedTypes: Type[] = [];
+
+ navigateTypesInNamespace(schema, {
+ model: (node: Model) => {
+ if (isArrayModelType(node)) return;
+ if (typeUsage.isUnreachable(node)) return;
+
+ const usage = typeUsage.getUsage(node);
+ const usedAsOutput = usage?.has(GraphQLTypeUsage.Output) ?? false;
+ const usedAsInput = usage?.has(GraphQLTypeUsage.Input) ?? false;
+
+ if (usedAsOutput || !usage) {
+ const mutation = engine.mutateModel(node, GraphQLTypeContext.Output);
+ mutatedTypes.push(mutation.mutatedType);
+ }
+ if (usedAsInput) {
+ const mutation = engine.mutateModel(node, GraphQLTypeContext.Input);
+ mutatedTypes.push(mutation.mutatedType);
+ }
+ },
+ enum: (node: Enum) => {
+ if (typeUsage.isUnreachable(node)) return;
+
+ const mutation = engine.mutateEnum(node);
+ mutatedTypes.push(mutation.mutatedType);
+ },
+ scalar: (node: Scalar) => {
+ if (typeUsage.isUnreachable(node)) return;
+ const mutation = engine.mutateScalar(node);
+ mutatedTypes.push(mutation.mutatedType);
+ },
+ union: (node: Union) => {
+ if (typeUsage.isUnreachable(node)) return;
+
+ const mutation = engine.mutateUnion(node, GraphQLTypeContext.Output);
+ mutatedTypes.push(mutation.mutatedType);
+ for (const wrapper of mutation.wrapperModels) {
+ mutatedTypes.push(wrapper);
+ }
+ },
+ operation: (node: Operation) => {
+ const mutation = engine.mutateOperation(node);
+ mutatedTypes.push(mutation.mutatedType);
+ },
+ });
+
+ const seen = new Map();
+ for (const type of mutatedTypes) {
+ if (!("name" in type) || !type.name) continue;
+ const name = type.name as string;
+ if (seen.has(name)) {
+ reportDiagnostic(program, {
+ code: "type-name-collision",
+ format: { name },
+ target: type,
+ });
+ } else {
+ seen.set(name, type);
+ }
+ }
+
+ return buildTypeGraph(program, tk, mutatedTypes);
+}
diff --git a/packages/graphql/src/registry.ts b/packages/graphql/src/registry.ts
deleted file mode 100644
index 12e7d59ddd3..00000000000
--- a/packages/graphql/src/registry.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-import { UsageFlags, type Enum, type Model } from "@typespec/compiler";
-import {
- GraphQLBoolean,
- GraphQLEnumType,
- GraphQLObjectType,
- type GraphQLNamedType,
- type GraphQLSchemaConfig,
-} from "graphql";
-
-// The TSPTypeContext interface represents the intermediate TSP type information before materialization.
-// It stores the raw TSP type and any extracted metadata relevant for GraphQL generation.
-interface TSPTypeContext {
- tspType: Enum | Model; // Extend with other TSP types like Operation, Interface, TSP Union, etc.
- name: string;
- usageFlags?: Set;
- // TODO: Add any other TSP-specific metadata here.
-}
-/**
- * GraphQLTypeRegistry manages the registration and materialization of TypeSpec (TSP)
- * types into their corresponding GraphQL type definitions.
- *
- * The registry operates in a two-stage process:
- * 1. Registration: TSP types (like Enums, Models, etc.) are first registered
- * along with relevant metadata (e.g., name, usage flags). This stores an
- * intermediate representation (`TSPTypeContext`) without immediately creating
- * GraphQL types. This stage is typically performed while traversing the TSP AST.
- * Register type by calling the appropriate method (e.g., `addEnum`).
- *
- * 2. Materialization: When a GraphQL type is needed (e.g., to build the final
- * schema or resolve a field type), the registry can materialize the TSP type
- * into its GraphQL counterpart (e.g., `GraphQLEnumType`, `GraphQLObjectType`).
- * Materialize types by calling the appropriate method (e.g., `materializeEnum`).
- *
- * This approach helps in:
- * - Decoupling TSP AST traversal from GraphQL object instantiation.
- * - Caching materialized GraphQL types to avoid redundant work and ensure object identity.
- * - Handling forward references and circular dependencies, as types can be
- * registered first and materialized later when all dependencies are known or
- * by using thunks for fields/arguments.
- */
-export class GraphQLTypeRegistry {
- // Stores intermediate TSP type information, keyed by TSP type name.
- // TODO: make this more of a seen set
- private TSPTypeContextRegistry: Map = new Map();
-
- // Stores materialized GraphQL types, keyed by their GraphQL name.
- private materializedGraphQLTypes: Map = new Map();
-
- addEnum(tspEnum: Enum): void {
- const enumName = tspEnum.name;
- if (this.TSPTypeContextRegistry.has(enumName)) {
- // Optionally, log a warning or update if new information is more complete.
- return;
- }
-
- this.TSPTypeContextRegistry.set(enumName, {
- tspType: tspEnum,
- name: enumName,
- // TODO: Populate usageFlags based on TSP context and other decorator context.
- });
- }
-
- // Materializes a TSP Enum into a GraphQLEnumType.
- materializeEnum(enumName: string): GraphQLEnumType | undefined {
- // Check if the GraphQL type is already materialized.
- if (this.materializedGraphQLTypes.has(enumName)) {
- return this.materializedGraphQLTypes.get(enumName) as GraphQLEnumType;
- }
-
- const context = this.TSPTypeContextRegistry.get(enumName);
- if (!context || context.tspType.kind !== "Enum") {
- // TODO: Handle error or warning for missing context.
- return undefined;
- }
-
- const tspEnum = context.tspType as Enum;
-
- const gqlEnum = new GraphQLEnumType({
- name: context.name,
- values: Object.fromEntries(
- Array.from(tspEnum.members.values()).map((member) => [
- member.name,
- {
- value: member.value ?? member.name,
- },
- ]),
- ),
- });
-
- this.materializedGraphQLTypes.set(enumName, gqlEnum);
- return gqlEnum;
- }
-
- materializeSchemaConfig(): GraphQLSchemaConfig {
- const allMaterializedGqlTypes = Array.from(this.materializedGraphQLTypes.values());
- let queryType = this.materializedGraphQLTypes.get("Query") as GraphQLObjectType | undefined;
- if (!queryType) {
- queryType = new GraphQLObjectType({
- name: "Query",
- fields: {
- _: {
- type: GraphQLBoolean,
- description:
- "A placeholder field. If you are seeing this, it means no operations were defined that could be emitted.",
- },
- },
- });
- }
-
- return {
- query: queryType,
- types: allMaterializedGqlTypes.length > 0 ? allMaterializedGqlTypes : null,
- };
- }
-}
diff --git a/packages/graphql/src/schema-emitter.ts b/packages/graphql/src/schema-emitter.ts
deleted file mode 100644
index b922fe49b0a..00000000000
--- a/packages/graphql/src/schema-emitter.ts
+++ /dev/null
@@ -1,81 +0,0 @@
-import {
- createDiagnosticCollector,
- navigateTypesInNamespace,
- type Diagnostic,
- type DiagnosticCollector,
- type EmitContext,
- type Enum,
- type Model,
-} from "@typespec/compiler";
-import { GraphQLSchema, validateSchema } from "graphql";
-import { type GraphQLEmitterOptions } from "./lib.js";
-import type { Schema } from "./lib/schema.js";
-import { GraphQLTypeRegistry } from "./registry.js";
-
-class GraphQLSchemaEmitter {
- private tspSchema: Schema;
- private context: EmitContext;
- private options: GraphQLEmitterOptions;
- private diagnostics: DiagnosticCollector;
- private registry: GraphQLTypeRegistry;
- constructor(
- tspSchema: Schema,
- context: EmitContext,
- options: GraphQLEmitterOptions,
- ) {
- // Initialize any properties if needed, including the registry
- this.tspSchema = tspSchema;
- this.context = context;
- this.options = options;
- this.diagnostics = createDiagnosticCollector();
- this.registry = new GraphQLTypeRegistry();
- }
-
- async emitSchema(): Promise<[GraphQLSchema, Readonly] | undefined> {
- const schemaNamespace = this.tspSchema.type;
- // Logic to emit the GraphQL schema
- navigateTypesInNamespace(schemaNamespace, this.semanticNodeListener());
- const schemaConfig = this.registry.materializeSchemaConfig();
- const schema = new GraphQLSchema(schemaConfig);
- // validate the schema
- const validationErrors = validateSchema(schema);
- validationErrors.forEach((error) => {
- this.diagnostics.add({
- message: error.message,
- code: "GraphQLSchemaValidationError",
- target: this.tspSchema.type,
- severity: "error",
- });
- });
- return [schema, this.diagnostics.diagnostics];
- }
-
- semanticNodeListener() {
- // TODO: Add GraphQL types to registry as the TSP nodes are visited
- return {
- enum: (node: Enum) => {
- this.registry.addEnum(node);
- },
- model: (node: Model) => {
- // Add logic to handle the model node
- },
- exitEnum: (node: Enum) => {
- this.registry.materializeEnum(node.name);
- },
- exitModel: (node: Model) => {
- // Add logic to handle the exit of the model node
- },
- };
- }
-}
-
-export function createSchemaEmitter(
- schema: Schema,
- context: EmitContext,
- options: GraphQLEmitterOptions,
-): GraphQLSchemaEmitter {
- // Placeholder for creating a GraphQL schema emitter
- return new GraphQLSchemaEmitter(schema, context, options);
-}
-
-export type { GraphQLSchemaEmitter };
diff --git a/packages/graphql/src/type-maps.ts b/packages/graphql/src/type-maps.ts
deleted file mode 100644
index eefb2b8aa33..00000000000
--- a/packages/graphql/src/type-maps.ts
+++ /dev/null
@@ -1,102 +0,0 @@
-import { UsageFlags, type Type } from "@typespec/compiler";
-import type { GraphQLType } from "graphql";
-
-/**
- * TypeSpec context for type mapping
- * @template T - The TypeSpec type
- */
-export interface TSPContext {
- type: T; // The TypeSpec type
- usageFlag: UsageFlags; // How the type is being used (input, output, etc.)
- graphqlName?: string; // Optional GraphQL type name override (e.g., "ModelInput" for input types)
- metadata: Record; // Additional metadata
-}
-
-/**
- * Nominal type for keys in the TypeMap
- */
-type TypeKey = string & { __typeKey: any };
-
-/**
- * Base TypeMap for all GraphQL type mappings
- * @template T - The TypeSpec type constrained to TSP's Type
- * @template G - The GraphQL type constrained to GraphQL's GraphQLType
- */
-export abstract class TypeMap {
- // Map of materialized GraphQL types
- protected materializedMap = new Map();
-
- // Map of registration contexts
- protected registrationMap = new Map>();
-
- /**
- * Register a TypeSpec type with context for later materialization
- * @param context - The TypeSpec context
- * @returns The name used for registration as a TypeKey
- */
- register(context: TSPContext): TypeKey {
- // Check if the type is already registered
- const name = this.getNameFromContext(context);
- if (this.isRegistered(name)) {
- throw new Error(`Type ${name} is already registered`);
- }
-
- // Register the type
- this.registrationMap.set(name, context);
- return name;
- }
-
- /**
- * Get the materialized GraphQL type
- * @param name - The type name as a TypeKey
- * @returns The materialized GraphQL type or undefined
- */
- get(name: TypeKey): G | undefined {
- // Return already materialized type if available
- if (this.materializedMap.has(name)) {
- return this.materializedMap.get(name);
- }
-
- // Attempt to materialize if registered
- const context = this.registrationMap.get(name);
- if (context) {
- const materializedType = this.materialize(context);
- this.materializedMap.set(name, materializedType);
- return materializedType;
- }
-
- return undefined;
- }
-
- /**
- * Check if a type is registered
- */
- isRegistered(name: string): boolean {
- return this.registrationMap.has(name as TypeKey);
- }
-
- /**
- * Get all materialized types
- */
- getAllMaterialized(): MapIterator {
- return this.materializedMap.values();
- }
-
- /**
- * Reset the type map
- */
- reset(): void {
- this.materializedMap.clear();
- this.registrationMap.clear();
- }
-
- /**
- * Get a name from a context
- */
- protected abstract getNameFromContext(context: TSPContext): TypeKey;
-
- /**
- * Materialize a type from a context
- */
- protected abstract materialize(context: TSPContext): G;
-}
diff --git a/packages/graphql/src/types.d.ts b/packages/graphql/src/types.d.ts
deleted file mode 100644
index 651cc1bb81f..00000000000
--- a/packages/graphql/src/types.d.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { Diagnostic } from "@typespec/compiler";
-import type { GraphQLSchema } from "graphql";
-import type { Schema } from "./lib/schema.ts";
-
-/**
- * A record containing the GraphQL schema corresponding to
- * a particular schema definition.
- */
-export interface GraphQLSchemaRecord {
- /** The declared schema that generated this GraphQL schema */
- readonly schema: Schema;
-
- /** The GraphQLSchema */
- readonly graphQLSchema: GraphQLSchema;
-
- /** The diagnostics created for this schema */
- readonly diagnostics: readonly Diagnostic[];
-}
-
-declare const tags: unique symbol;
-
-type Tagged = BaseType & { [tags]: { [K in Tag]: void } };
diff --git a/packages/graphql/test/components/enum-type.test.tsx b/packages/graphql/test/components/enum-type.test.tsx
new file mode 100644
index 00000000000..dbc52639e86
--- /dev/null
+++ b/packages/graphql/test/components/enum-type.test.tsx
@@ -0,0 +1,102 @@
+import { t } from "@typespec/compiler/testing";
+import { beforeEach, describe, expect, it } from "vitest";
+import { EnumType } from "../../src/components/types/index.js";
+import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js";
+import { Tester } from "../test-host.js";
+import { renderToSDL } from "./test-utils.js";
+
+describe("EnumType component", () => {
+ let tester: Awaited>;
+ beforeEach(async () => {
+ tester = await Tester.createInstance();
+ });
+
+ it("renders a basic enum", async () => {
+ const { Color } = await tester.compile(
+ t.code`enum ${t.enum("Color")} { Red, Green, Blue }`,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateEnum(Color).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain("enum Color");
+ expect(sdl).toContain("RED");
+ expect(sdl).toContain("GREEN");
+ expect(sdl).toContain("BLUE");
+ });
+
+ it("renders enum with doc comment as description", async () => {
+ const { Role } = await tester.compile(
+ t.code`
+ /** The role a user can have */
+ enum ${t.enum("Role")} { Admin, User }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateEnum(Role).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain('"The role a user can have"');
+ expect(sdl).toContain("enum Role");
+ });
+
+ it("renders enum with member descriptions", async () => {
+ const { Status } = await tester.compile(
+ t.code`
+ enum ${t.enum("Status")} {
+ /** Currently active */
+ Active,
+ /** No longer active */
+ Inactive,
+ }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateEnum(Status).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain('"Currently active"');
+ expect(sdl).toContain('"No longer active"');
+ });
+
+ it("renders enum with deprecated members", async () => {
+ const { Status } = await tester.compile(
+ t.code`
+ enum ${t.enum("Status")} {
+ Active,
+ #deprecated "use Active instead"
+ Legacy,
+ }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateEnum(Status).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain("@deprecated");
+ expect(sdl).toContain("use Active instead");
+ });
+
+ it("renders enum with mutation-engine-sanitized member names", async () => {
+ const { E } = await tester.compile(
+ t.code`enum ${t.enum("E")} { \`$val1$\`, \`val-2\` }`,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateEnum(E).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ // Mutation engine: sanitize → CONSTANT_CASE
+ expect(sdl).toContain("_VAL_1");
+ expect(sdl).toContain("VAL_2");
+ });
+});
diff --git a/packages/graphql/test/components/scalar-type.test.tsx b/packages/graphql/test/components/scalar-type.test.tsx
new file mode 100644
index 00000000000..d71ed1b89bf
--- /dev/null
+++ b/packages/graphql/test/components/scalar-type.test.tsx
@@ -0,0 +1,79 @@
+import { t } from "@typespec/compiler/testing";
+import { beforeEach, describe, expect, it } from "vitest";
+import { ScalarType } from "../../src/components/types/index.js";
+import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js";
+import { getSpecifiedBy } from "../../src/lib/specified-by.js";
+import { Tester } from "../test-host.js";
+import { renderToSDL } from "./test-utils.js";
+
+describe("ScalarType component", () => {
+ let tester: Awaited>;
+ beforeEach(async () => {
+ tester = await Tester.createInstance();
+ });
+
+ it("renders a custom scalar", async () => {
+ const { DateTime } = await tester.compile(
+ t.code`scalar ${t.scalar("DateTime")} extends string;`,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateScalar(DateTime).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain("scalar DateTime");
+ });
+
+ it("renders a scalar with doc comment description", async () => {
+ const { JSON } = await tester.compile(
+ t.code`
+ /** Arbitrary JSON blob */
+ scalar ${t.scalar("JSON")} extends string;
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateScalar(JSON).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain('"Arbitrary JSON blob"');
+ expect(sdl).toContain("scalar JSON");
+ });
+
+ it("renders a scalar with @specifiedBy", async () => {
+ const { MyScalar } = await tester.compile(
+ t.code`
+ @specifiedBy("https://example.com/spec")
+ scalar ${t.scalar("MyScalar")} extends string;
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateScalar(MyScalar).mutatedType;
+ const specUrl = getSpecifiedBy(tester.program, mutated);
+
+ const sdl = renderToSDL(
+ tester.program,
+ ,
+ );
+
+ expect(sdl).toContain("@specifiedBy");
+ expect(sdl).toContain("https://example.com/spec");
+ });
+
+ it("renders a scalar without @specifiedBy when not present", async () => {
+ const { MyScalar } = await tester.compile(
+ t.code`scalar ${t.scalar("MyScalar")} extends string;`,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutated = engine.mutateScalar(MyScalar).mutatedType;
+
+ const sdl = renderToSDL(tester.program, );
+
+ expect(sdl).toContain("scalar MyScalar");
+ expect(sdl).not.toContain("@specifiedBy");
+ });
+});
diff --git a/packages/graphql/test/components/test-utils.tsx b/packages/graphql/test/components/test-utils.tsx
new file mode 100644
index 00000000000..e88af9200e5
--- /dev/null
+++ b/packages/graphql/test/components/test-utils.tsx
@@ -0,0 +1,35 @@
+import { type Children } from "@alloy-js/core";
+import * as gql from "@alloy-js/graphql";
+import { renderSchema } from "@alloy-js/graphql";
+import { printSchema } from "graphql";
+import type { Program } from "@typespec/compiler";
+import { TspContext } from "@typespec/emitter-framework";
+import { GraphQLSchemaContext } from "../../src/context/index.js";
+import type { TypeGraph } from "../../src/mutation-engine/type-graph.js";
+
+/**
+ * Render GraphQL components in isolation and return SDL string.
+ * Wraps children in required context providers and adds a placeholder Query
+ * (graphql-js requires at least one query field).
+ */
+export function renderToSDL(program: Program, children: Children): string {
+ const typeGraph: TypeGraph = {
+ globalNamespace: program.getGlobalNamespaceType(),
+ };
+
+ const schema = renderSchema(
+
+
+ {children}
+
+
+
+
+ ,
+ { namePolicy: null },
+ );
+
+ // Cast needed: alloy uses graphql@17-alpha internally, our package uses graphql@16.
+ // At runtime both are deduped via vitest config; the type mismatch is superficial.
+ return printSchema(schema as any);
+}
diff --git a/packages/graphql/test/components/union-type.test.tsx b/packages/graphql/test/components/union-type.test.tsx
new file mode 100644
index 00000000000..897bdd67d48
--- /dev/null
+++ b/packages/graphql/test/components/union-type.test.tsx
@@ -0,0 +1,140 @@
+import { t } from "@typespec/compiler/testing";
+import * as gql from "@alloy-js/graphql";
+import { beforeEach, describe, expect, it } from "vitest";
+import { UnionType, type GraphQLUnion } from "../../src/components/types/index.js";
+import {
+ createGraphQLMutationEngine,
+ GraphQLTypeContext,
+} from "../../src/mutation-engine/index.js";
+import { Tester } from "../test-host.js";
+import { renderToSDL } from "./test-utils.js";
+
+describe("UnionType component", () => {
+ let tester: Awaited>;
+ beforeEach(async () => {
+ tester = await Tester.createInstance();
+ });
+
+ it("renders a union of model types", async () => {
+ const { Pet } = await tester.compile(
+ t.code`
+ model ${t.model("Cat")} { name: string; }
+ model ${t.model("Dog")} { breed: string; }
+ union ${t.union("Pet")} { cat: Cat; dog: Dog; }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutation = engine.mutateUnion(Pet, GraphQLTypeContext.Output);
+ const mutatedUnion = mutation.mutatedType as GraphQLUnion;
+
+ // Union members must be registered for graphql-js to validate the schema
+ const sdl = renderToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain("union Pet = Cat | Dog");
+ });
+
+ it("renders a union with doc comment description", async () => {
+ const { Result } = await tester.compile(
+ t.code`
+ model ${t.model("Success")} { value: string; }
+ model ${t.model("Failure")} { message: string; }
+ /** The result of an operation */
+ union ${t.union("Result")} { success: Success; failure: Failure; }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutation = engine.mutateUnion(Result, GraphQLTypeContext.Output);
+ const mutatedUnion = mutation.mutatedType as GraphQLUnion;
+
+ const sdl = renderToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain('"The result of an operation"');
+ expect(sdl).toContain("union Result = Success | Failure");
+ });
+
+ it("references wrapper model names for scalar variants", async () => {
+ const { Mixed } = await tester.compile(
+ t.code`
+ model ${t.model("Cat")} { name: string; }
+ union ${t.union("Mixed")} { cat: Cat; text: string; }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output);
+ const mutatedUnion = mutation.mutatedType as GraphQLUnion;
+
+ // Register the wrapper model and Cat so graphql-js can validate
+ const sdl = renderToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain("union Mixed = Cat | MixedTextUnionVariant");
+ });
+
+ it("renders a union with three model members", async () => {
+ const { Shape } = await tester.compile(
+ t.code`
+ model ${t.model("Circle")} { radius: float32; }
+ model ${t.model("Square")} { side: float32; }
+ model ${t.model("Triangle")} { base: float32; }
+ union ${t.union("Shape")} { circle: Circle; square: Square; triangle: Triangle; }
+ `,
+ );
+
+ const engine = createGraphQLMutationEngine(tester.program);
+ const mutation = engine.mutateUnion(Shape, GraphQLTypeContext.Output);
+ const mutatedUnion = mutation.mutatedType as GraphQLUnion;
+
+ const sdl = renderToSDL(
+ tester.program,
+ <>
+
+
+
+
+
+
+
+
+
+
+ >,
+ );
+
+ expect(sdl).toContain("union Shape = Circle | Square | Triangle");
+ });
+});
diff --git a/packages/graphql/test/emitter.test.ts b/packages/graphql/test/emitter.test.ts
index bd0b1a74566..b43c7817f88 100644
--- a/packages/graphql/test/emitter.test.ts
+++ b/packages/graphql/test/emitter.test.ts
@@ -1,18 +1,8 @@
-import { strictEqual } from "node:assert";
import { describe, it } from "vitest";
-import { emitSingleSchema } from "./test-host.js";
+import { emitSingleSchemaWithDiagnostics } from "./test-host.js";
-// For now, the expected output is a placeholder string.
-// In the future, this should be replaced with the actual GraphQL schema output.
-const expectedGraphQLSchema = `type Query {
- """
- A placeholder field. If you are seeing this, it means no operations were defined that could be emitted.
- """
- _: Boolean
-}`;
-
-describe("name", () => {
- it("Emits a schema.graphql file with placeholder text", async () => {
+describe("emitter", () => {
+ it("runs the mutation pipeline without errors", async () => {
const code = `
@schema
namespace TestNamespace {
@@ -30,7 +20,10 @@ describe("name", () => {
op getAuthors(): Author[];
}
`;
- const results = await emitSingleSchema(code, {});
- strictEqual(results, expectedGraphQLSchema);
+ const result = await emitSingleSchemaWithDiagnostics(code, {});
+ // Rendering is a stub — no output expected yet.
+ // This test verifies the pipeline (type-usage → mutation → buildTypeGraph) completes.
+ // Output assertions will be added when the Schema orchestrator component lands.
+ result; // no-op, just ensure no throw
});
});
diff --git a/packages/graphql/test/mutation-engine/schema-mutator.test.ts b/packages/graphql/test/mutation-engine/schema-mutator.test.ts
new file mode 100644
index 00000000000..5fd9684c287
--- /dev/null
+++ b/packages/graphql/test/mutation-engine/schema-mutator.test.ts
@@ -0,0 +1,253 @@
+import { t } from "@typespec/compiler/testing";
+import { beforeEach, describe, expect, it } from "vitest";
+import { createGraphQLMutationEngine } from "../../src/mutation-engine/index.js";
+import { mutateSchema } from "../../src/mutation-engine/schema-mutator.js";
+import { resolveTypeUsage } from "../../src/type-usage.js";
+import { Tester } from "../test-host.js";
+
+describe("mutateSchema", () => {
+ let tester: Awaited>;
+ beforeEach(async () => {
+ tester = await Tester.createInstance();
+ });
+
+ it("produces a TypeGraph with mutated models", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("ad_account")} { id: int32; }
+ op ${t.op("getAccount")}(): ad_account;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ expect(typeGraph.globalNamespace.models.has("AdAccount")).toBe(true);
+ });
+
+ it("produces a TypeGraph with mutated operations", async () => {
+ await tester.compile(
+ t.code`
+ op ${t.op("get_items")}(): string;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ expect(typeGraph.globalNamespace.operations.has("getItems")).toBe(true);
+ });
+
+ it("produces a TypeGraph with mutated enums", async () => {
+ await tester.compile(
+ t.code`
+ enum ${t.enum("status")} { active, inactive }
+ model ${t.model("Foo")} { s: status; }
+ op ${t.op("getFoo")}(): Foo;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ expect(typeGraph.globalNamespace.enums.has("Status")).toBe(true);
+ });
+
+ it("produces a TypeGraph with mutated unions", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Cat")} { name: string; }
+ model ${t.model("Dog")} { breed: string; }
+ union ${t.union("Pet")} { cat: Cat; dog: Dog; }
+ op ${t.op("getPet")}(): Pet;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ expect(typeGraph.globalNamespace.unions.has("Pet")).toBe(true);
+ });
+
+ it("skips unreachable types when omitUnreachableTypes is true", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Reachable")} { x: int32; }
+ model ${t.model("Unreachable")} { y: string; }
+ op ${t.op("getReachable")}(): Reachable;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, true);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ expect(typeGraph.globalNamespace.models.has("Reachable")).toBe(true);
+ expect(typeGraph.globalNamespace.models.has("Unreachable")).toBe(false);
+ });
+
+ it("includes all declared types when omitUnreachableTypes is false", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Reachable")} { x: int32; }
+ model ${t.model("Unreachable")} { y: string; }
+ op ${t.op("getReachable")}(): Reachable;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ expect(typeGraph.globalNamespace.models.has("Reachable")).toBe(true);
+ expect(typeGraph.globalNamespace.models.has("Unreachable")).toBe(true);
+ });
+
+ it("T | null unions do not appear in the TypeGraph (engine replaces with inner type)", async () => {
+ await tester.compile(
+ t.code`
+ union ${t.union("MaybeString")} { string, null }
+ model ${t.model("Foo")} { x: int32; }
+ op ${t.op("getFoo")}(): Foo;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ // MaybeString is T | null — should NOT appear as a union in the TypeGraph
+ expect(typeGraph.globalNamespace.unions.has("MaybeString")).toBe(false);
+ });
+
+ it("includes wrapper models from union scalar variants", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Cat")} { name: string; }
+ union ${t.union("Mixed")} { cat: Cat; text: string; num: int32; }
+ op ${t.op("getMixed")}(): Mixed;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ // Scalar variants get wrapper models registered in the TypeGraph
+ expect(typeGraph.globalNamespace.models.has("MixedTextUnionVariant")).toBe(true);
+ expect(typeGraph.globalNamespace.models.has("MixedNumUnionVariant")).toBe(true);
+ });
+
+ it("produces Input variant for models used as operation parameters", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Book")} { title: string; }
+ op ${t.op("getBooks")}(): Book[];
+ op ${t.op("createBook")}(input: Book): Book;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ // Book is used as both output (return) and input (parameter),
+ // so both variants should appear in the TypeGraph
+ expect(typeGraph.globalNamespace.models.has("Book")).toBe(true);
+ expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(true);
+ });
+
+ it("does not produce Input variant for output-only models", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Book")} { title: string; }
+ op ${t.op("getBooks")}(): Book[];
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ expect(typeGraph.globalNamespace.models.has("Book")).toBe(true);
+ expect(typeGraph.globalNamespace.models.has("BookInput")).toBe(false);
+ });
+
+ it("does not produce Output variant for input-only models", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Book")} { title: string; }
+ model ${t.model("Payload")} { title: string; }
+ op ${t.op("getBooks")}(): Book[];
+ op ${t.op("createBook")}(input: Payload): Book;
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, true);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ // Payload is only used as input — should only appear as Input variant (PayloadInput)
+ expect(typeGraph.globalNamespace.models.has("PayloadInput")).toBe(true);
+ // Should NOT have an Output variant
+ expect(typeGraph.globalNamespace.models.has("Payload")).toBe(false);
+ });
+
+ it("reports diagnostic when two types produce the same GraphQL name", async () => {
+ const [_, diagnostics] = await tester.compileAndDiagnose(
+ t.code`
+ model ${t.model("BookInput")} { x: int32; }
+ model ${t.model("Book")} { title: string; }
+ op ${t.op("getBooks")}(): Book[];
+ op ${t.op("createBook")}(input: Book): Book;
+ `,
+ );
+
+ // Book used as input → Input mutation → "BookInput"
+ // BookInput declared explicitly → Output mutation → "BookInput"
+ // This should produce a collision diagnostic
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ mutateSchema(tester.program, engine, ns, typeUsage);
+
+ const collisions = tester.program.diagnostics.filter(
+ (d) => d.code === "@typespec/graphql/type-name-collision",
+ );
+ expect(collisions.length).toBeGreaterThan(0);
+ });
+
+ it("skips array models (they are list types, not object types)", async () => {
+ await tester.compile(
+ t.code`
+ model ${t.model("Item")} { name: string; }
+ op ${t.op("getItems")}(): Item[];
+ `,
+ );
+
+ const ns = tester.program.getGlobalNamespaceType();
+ const typeUsage = resolveTypeUsage(ns, false);
+ const engine = createGraphQLMutationEngine(tester.program);
+ const typeGraph = mutateSchema(tester.program, engine, ns, typeUsage);
+
+ // Item should be in the graph, but the Array- model should NOT
+ expect(typeGraph.globalNamespace.models.has("Item")).toBe(true);
+ const modelNames = [...typeGraph.globalNamespace.models.keys()];
+ expect(modelNames.every((n) => !n.includes("Array"))).toBe(true);
+ });
+});
diff --git a/packages/graphql/test/mutation-engine/unions.test.ts b/packages/graphql/test/mutation-engine/unions.test.ts
index 08c2ba8d4df..723d2d01637 100644
--- a/packages/graphql/test/mutation-engine/unions.test.ts
+++ b/packages/graphql/test/mutation-engine/unions.test.ts
@@ -1,4 +1,4 @@
-import type { Model, Union } from "@typespec/compiler";
+import { getDoc, type Model, type Union } from "@typespec/compiler";
import { t } from "@typespec/compiler/testing";
import { beforeEach, describe, expect, it } from "vitest";
import { isNullable } from "../../src/lib/nullable.js";
@@ -71,6 +71,32 @@ describe("GraphQL Mutation Engine - Unions", () => {
expect(mutation.wrapperModels[0].name).toBe("MixedTextUnionVariant");
});
+ it("substitutes wrapper models into union variant types", async () => {
+ const { Mixed } = await tester.compile(
+ t.code`
+ model ${t.model("Cat")} { name: string; }
+ union ${t.union("Mixed")} { cat: Cat; text: string; }
+ `,
+ );
+
+ const engine = createTestEngine(tester.program);
+ const mutation = engine.mutateUnion(Mixed, GraphQLTypeContext.Output);
+ const mutatedUnion = mutation.mutatedType as Union;
+
+ const variants = [...mutatedUnion.variants.values()];
+ expect(variants).toHaveLength(2);
+
+ // Model variant points to the mutated model
+ const catVariant = variants.find((v) => v.name === "cat")!;
+ expect(catVariant.type.kind).toBe("Model");
+ expect((catVariant.type as Model).name).toBe("Cat");
+
+ // Scalar variant points to the wrapper model, not the raw scalar
+ const textVariant = variants.find((v) => v.name === "text")!;
+ expect(textVariant.type.kind).toBe("Model");
+ expect((textVariant.type as Model).name).toBe("MixedTextUnionVariant");
+ });
+
it("does not create wrappers for model-only unions", async () => {
const { Pet } = await tester.compile(
t.code`
@@ -111,6 +137,13 @@ describe("GraphQL Mutation Engine - Unions", () => {
expect(mutation.wrapperModels).toHaveLength(2);
const names = mutation.wrapperModels.map((m) => m.name).sort();
expect(names).toEqual(["MixedCountUnionVariant", "MixedTextUnionVariant"]);
+
+ // All union variants point to Models (originals or wrappers)
+ const mutatedUnion = mutation.mutatedType as Union;
+ const variants = [...mutatedUnion.variants.values()];
+ for (const variant of variants) {
+ expect(variant.type.kind).toBe("Model");
+ }
});
it("names anonymous return type union as OperationUnion", async () => {
@@ -185,6 +218,22 @@ describe("GraphQL Mutation Engine - Unions", () => {
expect(variantNames).toEqual(["AdAccount", "Board"]);
});
+ it("preserves decorator state (e.g. @doc) on flattened unions", async () => {
+ const { MaybePet } = await tester.compile(
+ t.code`
+ model ${t.model("Cat")} { name: string; }
+ model ${t.model("Dog")} { breed: string; }
+ /** A pet or nothing */
+ union ${t.union("MaybePet")} { cat: Cat; dog: Dog; null; }
+ `,
+ );
+
+ const engine = createTestEngine(tester.program);
+ const mutation = engine.mutateUnion(MaybePet, GraphQLTypeContext.Output);
+
+ expect(getDoc(tester.program, mutation.mutatedType)).toBe("A pet or nothing");
+ });
+
it("T | null replacement gets its mutation pipeline applied", async () => {
await tester.compile(
t.code`
diff --git a/packages/graphql/tsconfig.json b/packages/graphql/tsconfig.json
index ad68b784463..d2f18d1ce33 100644
--- a/packages/graphql/tsconfig.json
+++ b/packages/graphql/tsconfig.json
@@ -1,10 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
- "useDefineForClassFields": true,
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "target": "es2022",
+ "skipLibCheck": true,
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+ "jsx": "preserve",
+ "jsxImportSource": "@alloy-js/core",
+ "emitDeclarationOnly": true,
"rootDir": ".",
- "outDir": "dist",
- "verbatimModuleSyntax": true
+ "outDir": "dist"
},
- "include": ["src", "test"]
+ "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"],
+ "exclude": ["node_modules", "dist"]
}
diff --git a/packages/graphql/vitest.config.ts b/packages/graphql/vitest.config.ts
index 63cad767f57..c35108ef7bb 100644
--- a/packages/graphql/vitest.config.ts
+++ b/packages/graphql/vitest.config.ts
@@ -1,4 +1,18 @@
+import alloyPlugin from "@alloy-js/rollup-plugin";
import { defineConfig, mergeConfig } from "vitest/config";
import { defaultTypeSpecVitestConfig } from "../../vitest.config.js";
-export default mergeConfig(defaultTypeSpecVitestConfig, defineConfig({}));
+export default mergeConfig(
+ defaultTypeSpecVitestConfig,
+ defineConfig({
+ esbuild: {
+ jsx: "preserve",
+ sourcemap: "both",
+ },
+ plugins: [alloyPlugin()],
+ resolve: {
+ conditions: ["development"],
+ dedupe: ["@alloy-js/core", "graphql"],
+ },
+ }),
+);
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 99e5c06ef65..2ae0ad4c390 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -540,6 +540,58 @@ importers:
specifier: ^4.0.18
version: 4.0.18(@types/node@25.3.0)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2)
+ packages/graphql:
+ dependencies:
+ '@alloy-js/core':
+ specifier: ^0.22.0
+ version: 0.22.0
+ '@alloy-js/graphql':
+ specifier: link:/home/swatikumar/code/alloy/packages/graphql
+ version: link:../../../alloy/packages/graphql
+ '@alloy-js/typescript':
+ specifier: ^0.22.0
+ version: 0.22.0
+ change-case:
+ specifier: ^5.4.4
+ version: 5.4.4
+ graphql:
+ specifier: ^16.9.0
+ version: 16.14.1
+ devDependencies:
+ '@alloy-js/cli':
+ specifier: ^0.22.0
+ version: 0.22.0
+ '@alloy-js/rollup-plugin':
+ specifier: ^0.1.0
+ version: 0.1.0(@babel/core@7.29.0)(@types/babel__core@7.20.5)(rollup@4.49.0)
+ '@types/node':
+ specifier: ~22.13.13
+ version: 22.13.17
+ '@typespec/compiler':
+ specifier: workspace:~
+ version: link:../compiler
+ '@typespec/emitter-framework':
+ specifier: workspace:~
+ version: link:../emitter-framework
+ '@typespec/http':
+ specifier: workspace:~
+ version: link:../http
+ '@typespec/mutator-framework':
+ specifier: workspace:~
+ version: link:../mutator-framework
+ rimraf:
+ specifier: ~6.0.1
+ version: 6.0.1
+ source-map-support:
+ specifier: ~0.5.21
+ version: 0.5.21
+ typescript:
+ specifier: ~5.8.2
+ version: 5.8.2
+ vitest:
+ specifier: ^3.0.9
+ version: 3.2.6(@types/debug@4.1.12)(@types/node@22.13.17)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2)
+
packages/html-program-viewer:
dependencies:
'@fluentui/react-components':
@@ -1544,6 +1596,9 @@ importers:
'@typespec/events':
specifier: workspace:^
version: link:../events
+ '@typespec/graphql':
+ specifier: workspace:^
+ version: link:../graphql
'@typespec/html-program-viewer':
specifier: workspace:^
version: link:../html-program-viewer
@@ -6315,6 +6370,9 @@ packages:
'@types/node@18.19.130':
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
+ '@types/node@22.13.17':
+ resolution: {integrity: sha512-nAJuQXoyPj04uLgu+obZcSmsfOenUg6DxPKogeUy6yNCFwWaj5sBF8/G/pNo8EtBJjAfSVgfIlugR/BCOleO+g==}
+
'@types/node@25.3.0':
resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==}
@@ -6522,9 +6580,23 @@ packages:
'@vitest/expect@3.2.4':
resolution: {integrity: sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==}
+ '@vitest/expect@3.2.6':
+ resolution: {integrity: sha512-1+7q9BtaKzEmO+fmNT3kYvoNn5Y71XWAx2Q5HRim4tTVRQVRv4uJFAQ5FbK0OPUeNP/WmVCpxYxoJdvuHVjzBQ==}
+
'@vitest/expect@4.0.18':
resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==}
+ '@vitest/mocker@3.2.6':
+ resolution: {integrity: sha512-EZOrpDbkKotFAP7wPAQV1UIyoGOk4oX7ynWhBhLB7v+meMHbQhU16oPpIYGTTe4oFlhpryGpgpcZP/sin3hYuw==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
'@vitest/mocker@4.0.18':
resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==}
peerDependencies:
@@ -6539,18 +6611,30 @@ packages:
'@vitest/pretty-format@3.2.4':
resolution: {integrity: sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==}
+ '@vitest/pretty-format@3.2.6':
+ resolution: {integrity: sha512-lb7XXXzmm2h2ASzFnRvQpDo6onT1NmMJA3tkGTWiBFtRJ9lxGY3d3mm/Apt36gej2bkkOVLL/yTOtufDaFa/jA==}
+
'@vitest/pretty-format@4.0.18':
resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==}
+ '@vitest/runner@3.2.6':
+ resolution: {integrity: sha512-HYcoSj1w5tcgUnzoF0HcyaAQjpA1gj9ftUJ7iSJSuipc02jW9gKkigwZbjFldAfYHA1fa8UZVRftdMY5msWM9Q==}
+
'@vitest/runner@4.0.18':
resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==}
+ '@vitest/snapshot@3.2.6':
+ resolution: {integrity: sha512-H+ZjNTWGpObenh0YnlBctAPnJSI20P81PL8BPzWpx54YXLLTm8hEsWawtcYLMrwvpK48hGxLLbCS+1KRXhsKhw==}
+
'@vitest/snapshot@4.0.18':
resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==}
'@vitest/spy@3.2.4':
resolution: {integrity: sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==}
+ '@vitest/spy@3.2.6':
+ resolution: {integrity: sha512-oq6BbH68WzcWmwtBrU9nqLeaXTR4XwJF7FSLkKEZo4i6eoXcrxjcwSuTvWBIRUTC6VC72nXYunzqgZA+IKdtxg==}
+
'@vitest/spy@4.0.18':
resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==}
@@ -6562,6 +6646,9 @@ packages:
'@vitest/utils@3.2.4':
resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==}
+ '@vitest/utils@3.2.6':
+ resolution: {integrity: sha512-lI23nIs4bnT3T8NIoh+vFaz5s2/DdP0Jgt2jxwgWljvwn82cLJtyi/If+fjFyoLMGIOz0U/fKvWE0d4jsNQEfg==}
+
'@vitest/utils@4.0.18':
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
@@ -7426,6 +7513,10 @@ packages:
monocart-coverage-reports:
optional: true
+ cac@6.7.14:
+ resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
+ engines: {node: '>=8'}
+
cacache@19.0.1:
resolution: {integrity: sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==}
engines: {node: ^18.17.0 || >=20.5.0}
@@ -9120,6 +9211,10 @@ packages:
grapheme-splitter@1.0.4:
resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==}
+ graphql@16.14.1:
+ resolution: {integrity: sha512-cQOsSMS/IrDz82PVyRDvf/Q1F/bRbBVjJlh+xYOkI1qw2bWRvWGiWc+m2O0d6l4Bt1fyY+8kzJ8JFWGJqNeDBg==}
+ engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0}
+
gray-matter@4.0.3:
resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==}
engines: {node: '>=6.0'}
@@ -9794,6 +9889,9 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
+ js-tokens@9.0.1:
+ resolution: {integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==}
+
js-yaml@3.14.2:
resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==}
hasBin: true
@@ -11706,6 +11804,11 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
+ rimraf@6.0.1:
+ resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==}
+ engines: {node: 20 || >=22}
+ hasBin: true
+
rimraf@6.1.3:
resolution: {integrity: sha512-LKg+Cr2ZF61fkcaK1UdkH2yEBBKnYjTyWzTJT6KNPcSPaiT7HSdhtMXQuN5wkTX0Xu72KQ1l8S42rlmexS2hSA==}
engines: {node: 20 || >=22}
@@ -12232,6 +12335,9 @@ packages:
resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==}
engines: {node: '>=14.16'}
+ strip-literal@3.1.0:
+ resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==}
+
strnum@2.1.2:
resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==}
@@ -12395,6 +12501,9 @@ packages:
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
+ tinyexec@0.3.2:
+ resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
+
tinyexec@1.0.2:
resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==}
engines: {node: '>=18'}
@@ -12406,6 +12515,10 @@ packages:
tinylogic@2.0.0:
resolution: {integrity: sha512-dljTkiLLITtsjqBvTA1MRZQK/sGP4kI3UJKc3yA9fMzYbMF2RhcN04SeROVqJBIYYOoJMM8u0WDnhFwMSFQotw==}
+ tinypool@1.1.1:
+ resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==}
+ engines: {node: ^18.0.0 || >=20.0.0}
+
tinyrainbow@2.0.0:
resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==}
engines: {node: '>=14.0.0'}
@@ -12724,6 +12837,9 @@ packages:
undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
+ undici-types@6.20.0:
+ resolution: {integrity: sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==}
+
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
@@ -12976,6 +13092,11 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
+ vite-node@3.2.4:
+ resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+
vite-plugin-checker@0.12.0:
resolution: {integrity: sha512-CmdZdDOGss7kdQwv73UyVgLPv0FVYe5czAgnmRX2oKljgEvSrODGuClaV3PDR2+3ou7N/OKGauDDBjy2MB07Rg==}
engines: {node: '>=16.11'}
@@ -13115,6 +13236,34 @@ packages:
vite:
optional: true
+ vitest@3.2.6:
+ resolution: {integrity: sha512-xejya+bT/j/+R/AGa1XOfRxLmNUlLtlwjRsFUILF+xHfzElmGcmFydy2gqqIrd62ptIEfwVMofd19uNWD9L7Nw==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@types/debug': ^4.1.12
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ '@vitest/browser': 3.2.6
+ '@vitest/ui': 3.2.6
+ happy-dom: '*'
+ jsdom: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@types/debug':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
vitest@4.0.18:
resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
@@ -18624,6 +18773,10 @@ snapshots:
dependencies:
undici-types: 5.26.5
+ '@types/node@22.13.17':
+ dependencies:
+ undici-types: 6.20.0
+
'@types/node@25.3.0':
dependencies:
undici-types: 7.18.2
@@ -18892,6 +19045,14 @@ snapshots:
chai: 5.3.3
tinyrainbow: 2.0.0
+ '@vitest/expect@3.2.6':
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/spy': 3.2.6
+ '@vitest/utils': 3.2.6
+ chai: 5.3.3
+ tinyrainbow: 2.0.0
+
'@vitest/expect@4.0.18':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -18901,6 +19062,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
+ '@vitest/mocker@3.2.6(vite@7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2))':
+ dependencies:
+ '@vitest/spy': 3.2.6
+ estree-walker: 3.0.3
+ magic-string: 0.30.21
+ optionalDependencies:
+ vite: 7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2)
+
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.18
@@ -18913,15 +19082,31 @@ snapshots:
dependencies:
tinyrainbow: 2.0.0
+ '@vitest/pretty-format@3.2.6':
+ dependencies:
+ tinyrainbow: 2.0.0
+
'@vitest/pretty-format@4.0.18':
dependencies:
tinyrainbow: 3.0.3
+ '@vitest/runner@3.2.6':
+ dependencies:
+ '@vitest/utils': 3.2.6
+ pathe: 2.0.3
+ strip-literal: 3.1.0
+
'@vitest/runner@4.0.18':
dependencies:
'@vitest/utils': 4.0.18
pathe: 2.0.3
+ '@vitest/snapshot@3.2.6':
+ dependencies:
+ '@vitest/pretty-format': 3.2.6
+ magic-string: 0.30.21
+ pathe: 2.0.3
+
'@vitest/snapshot@4.0.18':
dependencies:
'@vitest/pretty-format': 4.0.18
@@ -18932,6 +19117,10 @@ snapshots:
dependencies:
tinyspy: 4.0.4
+ '@vitest/spy@3.2.6':
+ dependencies:
+ tinyspy: 4.0.4
+
'@vitest/spy@4.0.18': {}
'@vitest/ui@4.0.18(vitest@4.0.18)':
@@ -18951,6 +19140,12 @@ snapshots:
loupe: 3.2.1
tinyrainbow: 2.0.0
+ '@vitest/utils@3.2.6':
+ dependencies:
+ '@vitest/pretty-format': 3.2.6
+ loupe: 3.2.1
+ tinyrainbow: 2.0.0
+
'@vitest/utils@4.0.18':
dependencies:
'@vitest/pretty-format': 4.0.18
@@ -20252,6 +20447,8 @@ snapshots:
yargs: 17.7.2
yargs-parser: 21.1.1
+ cac@6.7.14: {}
+
cacache@19.0.1:
dependencies:
'@npmcli/fs': 4.0.0
@@ -22299,6 +22496,8 @@ snapshots:
grapheme-splitter@1.0.4: {}
+ graphql@16.14.1: {}
+
gray-matter@4.0.3:
dependencies:
js-yaml: 3.14.2
@@ -23123,6 +23322,8 @@ snapshots:
js-tokens@4.0.0: {}
+ js-tokens@9.0.1: {}
+
js-yaml@3.14.2:
dependencies:
argparse: 1.0.10
@@ -25637,6 +25838,11 @@ snapshots:
dependencies:
glob: 7.2.3
+ rimraf@6.0.1:
+ dependencies:
+ glob: 11.1.0
+ package-json-from-dist: 1.0.1
+
rimraf@6.1.3:
dependencies:
glob: 13.0.6
@@ -26300,6 +26506,10 @@ snapshots:
strip-json-comments@5.0.3: {}
+ strip-literal@3.1.0:
+ dependencies:
+ js-tokens: 9.0.1
+
strnum@2.1.2: {}
structured-source@4.0.0:
@@ -26541,6 +26751,8 @@ snapshots:
tinybench@2.9.0: {}
+ tinyexec@0.3.2: {}
+
tinyexec@1.0.2: {}
tinyglobby@0.2.15:
@@ -26550,6 +26762,8 @@ snapshots:
tinylogic@2.0.0: {}
+ tinypool@1.1.1: {}
+
tinyrainbow@2.0.0: {}
tinyrainbow@3.0.3: {}
@@ -26829,6 +27043,8 @@ snapshots:
undici-types@5.26.5: {}
+ undici-types@6.20.0: {}
+
undici-types@7.18.2: {}
undici@7.22.0: {}
@@ -27047,6 +27263,27 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
+ vite-node@3.2.4(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.3(supports-color@8.1.1)
+ es-module-lexer: 1.7.0
+ pathe: 2.0.3
+ vite: 7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2)
+ transitivePeerDependencies:
+ - '@types/node'
+ - jiti
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - yaml
+
vite-plugin-checker@0.12.0(eslint@10.0.2)(optionator@0.9.4)(typescript@5.9.3)(vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2)):
dependencies:
'@babel/code-frame': 7.29.0
@@ -27104,6 +27341,20 @@ snapshots:
tsx: 4.21.0
yaml: 2.8.2
+ vite@7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2):
+ dependencies:
+ esbuild: 0.27.3
+ fdir: 6.5.0(picomatch@4.0.3)
+ picomatch: 4.0.3
+ postcss: 8.5.6
+ rollup: 4.49.0
+ tinyglobby: 0.2.15
+ optionalDependencies:
+ '@types/node': 22.13.17
+ fsevents: 2.3.3
+ tsx: 4.21.0
+ yaml: 2.8.2
+
vite@7.3.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
esbuild: 0.27.3
@@ -27122,6 +27373,51 @@ snapshots:
optionalDependencies:
vite: 6.4.1(@types/node@25.3.0)(tsx@4.21.0)(yaml@2.8.2)
+ vitest@3.2.6(@types/debug@4.1.12)(@types/node@22.13.17)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2):
+ dependencies:
+ '@types/chai': 5.2.3
+ '@vitest/expect': 3.2.6
+ '@vitest/mocker': 3.2.6(vite@7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2))
+ '@vitest/pretty-format': 3.2.6
+ '@vitest/runner': 3.2.6
+ '@vitest/snapshot': 3.2.6
+ '@vitest/spy': 3.2.6
+ '@vitest/utils': 3.2.6
+ chai: 5.3.3
+ debug: 4.4.3(supports-color@8.1.1)
+ expect-type: 1.3.0
+ magic-string: 0.30.21
+ pathe: 2.0.3
+ picomatch: 4.0.3
+ std-env: 3.10.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinyglobby: 0.2.15
+ tinypool: 1.1.1
+ tinyrainbow: 2.0.0
+ vite: 7.3.1(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2)
+ vite-node: 3.2.4(@types/node@22.13.17)(tsx@4.21.0)(yaml@2.8.2)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/debug': 4.1.12
+ '@types/node': 22.13.17
+ '@vitest/ui': 4.0.18(vitest@4.0.18)
+ happy-dom: 20.7.0
+ jsdom: 25.0.1
+ transitivePeerDependencies:
+ - jiti
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - yaml
+
vitest@4.0.18(@types/node@25.3.0)(@vitest/ui@4.0.18)(happy-dom@20.7.0)(jsdom@25.0.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18