From 213f62ff65d7944a99fc2cbd2f1ca21d52485596 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 20 Aug 2025 15:31:03 -0400 Subject: [PATCH 01/29] feat: set up initial AI packaged with configs --- .changeset/pre.json | 4 +- eslint.config.mjs | 2 + package-lock.json | 197 +++++++++++++++++++++- package.json | 6 +- packages/ai-vercel-adapter/jest.config.ts | 32 ++++ packages/ai-vercel-adapter/package.json | 56 ++++++ packages/ai-vercel-adapter/tsconfig.json | 46 +++++ packages/ai/jest.config.ts | 39 +++++ packages/ai/package.json | 53 ++++++ packages/ai/tsconfig.json | 34 ++++ 10 files changed, 466 insertions(+), 3 deletions(-) create mode 100644 packages/ai-vercel-adapter/jest.config.ts create mode 100644 packages/ai-vercel-adapter/package.json create mode 100644 packages/ai-vercel-adapter/tsconfig.json create mode 100644 packages/ai/jest.config.ts create mode 100644 packages/ai/package.json create mode 100644 packages/ai/tsconfig.json diff --git a/.changeset/pre.json b/.changeset/pre.json index 4d628cc4a6c..1b96d1c29c4 100644 --- a/.changeset/pre.json +++ b/.changeset/pre.json @@ -4,7 +4,9 @@ "initialVersions": { "@apollo/client": "3.12.2", "@apollo/client-codemod-migrate-3-to-4": "0.0.0", - "@apollo/client-graphql-codegen": "0.0.0" + "@apollo/client-graphql-codegen": "0.0.0", + "@apollo/client-ai": "0.0.0", + "@apollo/client-ai-vercel-adapter": "0.0.0" }, "changesets": [ "afraid-grapes-call", diff --git a/eslint.config.mjs b/eslint.config.mjs index 12d6ae0a0ed..016869f5d7b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -153,6 +153,8 @@ export default [ parserOptions: { project: [ "./tsconfig.json", + "./packages/ai/tsconfig.json", + "./packages/ai-vercel-adapter/tsconfig.json", "./codegen/tsconfig.json", "./config/tsconfig.json", "./docs/tsconfig.json", diff --git a/package-lock.json b/package-lock.json index 796da78298b..36de8e084c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,9 @@ "workspaces": [ ".", "codegen", - "scripts/codemods/ac3-to-ac4" + "scripts/codemods/ac3-to-ac4", + "packages/ai", + "packages/ai-vercel-adapter" ], "dependencies": { "@graphql-typed-document-node/core": "^3.1.1", @@ -145,6 +147,37 @@ } } }, + "ai": { + "name": "@apollo/client-ai", + "version": "0.1.0-alpha.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@apollo/client": "*" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "rimraf": "^5.0.9", + "ts-jest": "^29.2.3", + "typescript": "^5.8.3" + } + }, + "ai-vercel-adapter": { + "name": "@apollo/client-ai-vercel-adapter", + "version": "0.1.0-alpha.0", + "extraneous": true, + "license": "MIT", + "dependencies": { + "@apollo/client": "*", + "@apollo/client-ai": "*", + "ai": "^5.0.17" + }, + "devDependencies": { + "typescript": "^5.8.3" + } + }, "codegen": { "name": "@apollo/client-graphql-codegen", "version": "1.0.0-rc.0", @@ -316,6 +349,49 @@ "dev": true, "license": "MIT" }, + "node_modules/@ai-sdk/gateway": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.8.tgz", + "integrity": "sha512-yiHYz0bAHEvhL+fSUBI2dNmyj0LOI8zw5qrYpa4gp1ojPgZq/7T1WXoIWRmVdjQwvT4PzSmRKLtbMPfz+umgfw==", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", + "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.4.tgz", + "integrity": "sha512-/3Z6lfUp8r+ewFd9yzHkCmPlMOJUXup2Sx3aoUyrdXLhOmAfHRl6Z4lDbIdV0uvw/QYoBcVLJnvXN7ncYeS3uQ==", + "dependencies": { + "@ai-sdk/provider": "2.0.0", + "@standard-schema/spec": "^1.0.0", + "eventsource-parser": "^3.0.3", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, "node_modules/@ampproject/remapping": { "version": "2.2.1", "license": "Apache-2.0", @@ -335,6 +411,14 @@ "resolved": "", "link": true }, + "node_modules/@apollo/client-ai": { + "resolved": "packages/ai", + "link": true + }, + "node_modules/@apollo/client-ai-vercel-adapter": { + "resolved": "packages/ai-vercel-adapter", + "link": true + }, "node_modules/@apollo/client-codemod-migrate-3-to-4": { "resolved": "scripts/codemods/ac3-to-ac4", "link": true @@ -5470,6 +5554,14 @@ "@octokit/openapi-types": "^20.0.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "dev": true, @@ -6078,6 +6170,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@standard-schema/spec": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", + "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==" + }, "node_modules/@testing-library/dom": { "version": "10.4.0", "dev": true, @@ -7399,6 +7496,23 @@ "node": ">=8" } }, + "node_modules/ai": { + "version": "5.0.17", + "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.17.tgz", + "integrity": "sha512-DLZikqZZJdwSkRhFikw6Mt7pUmPZ7Ue38TjdOcw2U6iZtBbuiyWGIhHyJXlUpLcZrtBE5yqPTozyZri1lRjduw==", + "dependencies": { + "@ai-sdk/gateway": "1.0.8", + "@ai-sdk/provider": "2.0.0", + "@ai-sdk/provider-utils": "3.0.4", + "@opentelemetry/api": "1.9.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.25.76 || ^4" + } + }, "node_modules/ajv": { "version": "8.17.1", "dev": true, @@ -10580,6 +10694,14 @@ "dev": true, "license": "MIT" }, + "node_modules/eventsource-parser": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.5.tgz", + "integrity": "sha512-bSRG85ZrMdmWtm7qkF9He9TNRzc/Bm99gEJMaQoHJ9E6Kv9QBbsldh2oMj7iXmYNEAVvNgvv5vPorG6W+XtBhQ==", + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/execa": { "version": "5.1.1", "dev": true, @@ -13978,6 +14100,11 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "dev": true, @@ -21266,6 +21393,14 @@ "node": ">=20" } }, + "node_modules/zod-to-json-schema": { + "version": "3.24.6", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", + "integrity": "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg==", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/zod-validation-error": { "version": "3.3.0", "dev": true, @@ -21303,6 +21438,66 @@ "@types/node": ">=20" } }, + "packages/ai": { + "name": "@apollo/client-ai", + "version": "0.1.0-alpha.0", + "license": "MIT", + "dependencies": { + "@apollo/client": "*" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "rimraf": "^5.0.9", + "ts-jest": "^29.2.3", + "typescript": "^5.8.3" + } + }, + "packages/ai-vercel-adapter": { + "name": "@apollo/client-ai-vercel-adapter", + "version": "0.1.0-alpha.0", + "license": "MIT", + "dependencies": { + "@apollo/client": "*", + "@apollo/client-ai": "*", + "ai": "^5.0.17" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "rimraf": "^5.0.9", + "ts-jest": "^29.2.3", + "typescript": "^5.8.3" + } + }, + "packages/ai-vercel-adapter/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "packages/ai/node_modules/typescript": { + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", + "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "scripts/codemods/ac3-to-ac4": { "name": "@apollo/client-codemod-migrate-3-to-4", "version": "1.0.0-rc.3", diff --git a/package.json b/package.json index c7a073c255a..fda08e4b1ef 100644 --- a/package.json +++ b/package.json @@ -96,6 +96,8 @@ "test:debug": "node --inspect-brk node_modules/.bin/jest --config ./config/jest.config.ts --runInBand --testTimeout 99999 --logHeapUsage", "test:ci": "TEST_ENV=ci npm run test:coverage -- --logHeapUsage", "test:codegen": "npm run build -w codegen && graphql-codegen --config ./tests.codegen.ts", + "test:ai": "npm run test -w packages/ai", + "test:ai-vercel-adapter": "npm run test -w packages/ai-vercel-adapter", "test:watch": "jest --config ./config/jest.config.ts --watch", "test:memory": "npm i && npm run build && cd scripts/memory && npm i && npm test", "test:coverage": "npm run coverage -- --ci --runInBand --reporters=default --reporters=jest-junit", @@ -275,6 +277,8 @@ "workspaces": [ ".", "codegen", - "scripts/codemods/ac3-to-ac4" + "scripts/codemods/ac3-to-ac4", + "packages/ai", + "packages/ai-vercel-adapter" ] } diff --git a/packages/ai-vercel-adapter/jest.config.ts b/packages/ai-vercel-adapter/jest.config.ts new file mode 100644 index 00000000000..05922435dce --- /dev/null +++ b/packages/ai-vercel-adapter/jest.config.ts @@ -0,0 +1,32 @@ +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export default { + rootDir: "src", + preset: "ts-jest", + testEnvironment: fileURLToPath( + import.meta.resolve("../../config/FixJSDOMEnvironment.js") + ), + setupFilesAfterEnv: ["/../config/jest/setup.ts"], + testEnvironmentOptions: { + url: "http://localhost", + }, + snapshotFormat: { + escapeString: true, + printBasicPrototype: true, + }, + transform: { + "\\.tsx?$": [ + "ts-jest", + { + isolatedModules: true, + tsconfig: join(import.meta.dirname, "tsconfig.json"), + }, + ], + }, + transformIgnorePatterns: ["/node_modules/(?!(rxjs)/)"], + prettierPath: null, + testMatch: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.test.tsx"], + displayName: "AI Vercel Adapter Tests", + testPathIgnorePatterns: [".d.ts$"], +}; diff --git a/packages/ai-vercel-adapter/package.json b/packages/ai-vercel-adapter/package.json new file mode 100644 index 00000000000..77e51d9a869 --- /dev/null +++ b/packages/ai-vercel-adapter/package.json @@ -0,0 +1,56 @@ +{ + "name": "@apollo/client-ai-vercel-adapter", + "version": "0.1.0-alpha.0", + "description": "Apollo Client AI Tools Vercel Adapter", + "keywords": [ + "apollo", + "client", + "ai", + "mocking", + "vercel" + ], + "author": "packages@apollographql.com", + "license": "MIT", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/apollographql/apollo-client.git", + "directory": "ai-vercel-adapter" + }, + "bugs": { + "url": "https://github.com/apollographql/apollo-client/issues" + }, + "homepage": "https://www.apollographql.com/docs/react/", + "exports": { + ".": "./dist/index.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "prebuild": "npm run clean", + "clean": "rimraf dist", + "build": "tsc", + "prepack": "npm run build", + "publint": "publint run --strict .", + "test": "node --expose-gc --experimental-import-meta-resolve --disable-warning=ExperimentalWarning ../../node_modules/jest/bin/jest.js --config jest.config.ts", + "test:watch": "npm run test -- --watch", + "test:coverage": "npm run test -- --coverage --watchAll=false" + }, + "dependencies": { + "@apollo/client": "*", + "@apollo/client-ai": "*", + "ai": "^5.0.17" + }, + "devDependencies": { + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.2.3", + "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^29.5.12", + "rimraf": "^5.0.9" + } +} diff --git a/packages/ai-vercel-adapter/tsconfig.json b/packages/ai-vercel-adapter/tsconfig.json new file mode 100644 index 00000000000..022b8d9c99f --- /dev/null +++ b/packages/ai-vercel-adapter/tsconfig.json @@ -0,0 +1,46 @@ +{ + "compilerOptions": { + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "skipLibCheck": true, + "moduleResolution": "NodeNext", + "importHelpers": true, + "sourceMap": true, + "inlineSources": true, + "declaration": true, + "declarationMap": true, + "target": "ESNext", + "module": "NodeNext", + "esModuleInterop": true, + "experimentalDecorators": true, + "outDir": "./dist", + "rootDir": "./src", + "lib": ["DOM", "ES2023"], + "types": [ + "jest", + "node" + // "./src/testing/matchers/index.d.ts", + // "./src/testing/internal/declarations.d.ts", + // "@testing-library/react-render-stream/expect" + ], + "jsx": "react", + "strict": true, + "paths": { + // This entry point is not part of our public API, so we point it directly to the source. + "@apollo/client/testing/internal": ["./src/testing/internal/index.ts"], + "@apollo/client-ai": ["../ai/dist"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + // "references": [ + // { + // "path": "../tsconfig.json" + // } + // ], + "mdx": { + // Enable strict type checking in MDX files. + "checkMdx": true + } +} diff --git a/packages/ai/jest.config.ts b/packages/ai/jest.config.ts new file mode 100644 index 00000000000..d1ae6e6d881 --- /dev/null +++ b/packages/ai/jest.config.ts @@ -0,0 +1,39 @@ +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +export default { + rootDir: "src", + preset: "ts-jest", + testEnvironment: fileURLToPath( + import.meta.resolve("../../config/FixJSDOMEnvironment.js") + ), + setupFilesAfterEnv: ["/../config/jest/setup.ts"], + testEnvironmentOptions: { + url: "http://localhost", + }, + snapshotFormat: { + escapeString: true, + printBasicPrototype: true, + }, + transform: { + "\\.tsx?$": [ + "ts-jest", + { + isolatedModules: true, + tsconfig: join(import.meta.dirname, "tsconfig.json"), + }, + ], + }, + resolver: fileURLToPath( + import.meta.resolve("../../src/config/jest/resolver.ts") + ), + transformIgnorePatterns: ["/node_modules/(?!(rxjs)/)"], + prettierPath: null, + testMatch: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.test.tsx"], + moduleNameMapper: { + "^@apollo/client/testing/internal$": + "/../../src/testing/internal/index.ts", + }, + displayName: "AI Workspace Tests", + testPathIgnorePatterns: [".d.ts$"], +}; diff --git a/packages/ai/package.json b/packages/ai/package.json new file mode 100644 index 00000000000..ddf94250b2a --- /dev/null +++ b/packages/ai/package.json @@ -0,0 +1,53 @@ +{ + "name": "@apollo/client-ai", + "version": "0.1.0-alpha.0", + "description": "Apollo Client AI Tools", + "keywords": [ + "apollo", + "client", + "ai", + "mocking" + ], + "author": "packages@apollographql.com", + "license": "MIT", + "type": "module", + "sideEffects": false, + "repository": { + "type": "git", + "url": "git+https://github.com/apollographql/apollo-client.git", + "directory": "ai" + }, + "bugs": { + "url": "https://github.com/apollographql/apollo-client/issues" + }, + "homepage": "https://www.apollographql.com/docs/react/", + "exports": { + ".": "./dist/index.js" + }, + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "prebuild": "npm run clean", + "clean": "rimraf dist", + "build": "tsc", + "prepack": "npm run build", + "publint": "publint run --strict .", + "test": "node --expose-gc --experimental-import-meta-resolve --disable-warning=ExperimentalWarning ../../node_modules/jest/bin/jest.js --config jest.config.ts", + "test:watch": "npm run test -- --watch", + "test:coverage": "npm run test -- --coverage --watchAll=false" + }, + "dependencies": { + "@apollo/client": "*" + }, + "devDependencies": { + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.2.3", + "@testing-library/jest-dom": "^6.6.3", + "@types/jest": "^29.5.12", + "rimraf": "^5.0.9" + } +} diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json new file mode 100644 index 00000000000..ad91b12f611 --- /dev/null +++ b/packages/ai/tsconfig.json @@ -0,0 +1,34 @@ +{ + "compilerOptions": { + "noImplicitAny": true, + "strictNullChecks": true, + "noUnusedParameters": false, + "noUnusedLocals": true, + "skipLibCheck": true, + "moduleResolution": "NodeNext", + "importHelpers": true, + "sourceMap": true, + "inlineSources": true, + "declaration": true, + "declarationMap": true, + "target": "ESNext", + "module": "NodeNext", + "esModuleInterop": true, + "experimentalDecorators": true, + "outDir": "./dist", + "rootDir": "./src", + "lib": ["DOM", "ES2023"], + "types": ["jest", "node"], + "jsx": "react", + "strict": true, + "paths": { + // This entry point is not part of our public API, so we point it directly to the source. + "@apollo/client/testing/internal": ["./src/testing/internal/index.ts"] + } + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "mdx": { + // Enable strict type checking in MDX files. + "checkMdx": true + } +} From b8092d9ab6beca8e4a0bc100a3c9dd76ea79f86d Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Thu, 21 Aug 2025 09:36:58 -0400 Subject: [PATCH 02/29] build: add workspace build scripts --- package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/package.json b/package.json index fda08e4b1ef..286bb04c441 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,8 @@ "scripts": { "prebuild": "npm run clean", "build": "node config/build.ts", + "build:ai": "npm run build -w packages/ai", + "build:ai-vercel-adapter": "npm run build -w packages/ai-vercel-adapter", "typecheck": "tsc --project config/tsconfig.json; tsc --noEmit --project tsconfig.json", "postinstall": "patch-package", "extract-api": "npm run clean && node config/build.ts --step=prepareDist --step=addExports --step=typescript --step=inlineInheritDoc --step=deprecateInternals && npm run extract-api:only", From 5cfc9796e562d91eee0640f12324690f13921876 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 20 Aug 2025 15:32:10 -0400 Subject: [PATCH 03/29] feat(ai): add files for AI mocking --- packages/ai/src/index.ts | 3 + packages/ai/src/mocking/AIAdapter.ts | 66 +++++++++++++++++ packages/ai/src/mocking/AIMockLink.ts | 47 ++++++++++++ packages/ai/src/mocking/AIMockProvider.tsx | 85 ++++++++++++++++++++++ packages/ai/src/mocking/consts.ts | 16 ++++ 5 files changed, 217 insertions(+) create mode 100644 packages/ai/src/index.ts create mode 100644 packages/ai/src/mocking/AIAdapter.ts create mode 100644 packages/ai/src/mocking/AIMockLink.ts create mode 100644 packages/ai/src/mocking/AIMockProvider.tsx create mode 100644 packages/ai/src/mocking/consts.ts diff --git a/packages/ai/src/index.ts b/packages/ai/src/index.ts new file mode 100644 index 00000000000..91a2cb7a138 --- /dev/null +++ b/packages/ai/src/index.ts @@ -0,0 +1,3 @@ +export * from "./mocking/AIAdapter.js"; +export * from "./mocking/AIMockLink.js"; +export * from "./mocking/AIMockProvider.js"; diff --git a/packages/ai/src/mocking/AIAdapter.ts b/packages/ai/src/mocking/AIAdapter.ts new file mode 100644 index 00000000000..defdc02e946 --- /dev/null +++ b/packages/ai/src/mocking/AIAdapter.ts @@ -0,0 +1,66 @@ +import { ApolloLink } from "@apollo/client"; +import { print } from "graphql"; +import { BASE_SYSTEM_PROMPT } from "./consts.js"; + +export declare namespace AIAdapter { + export interface Options { + systemPrompt?: string; + } + + export type Result = ApolloLink.Result; +} + +export abstract class AIAdapter { + public providedSystemPrompt: string | undefined; + protected systemPrompt: string; + + constructor(options: AIAdapter.Options = {}) { + this.systemPrompt = BASE_SYSTEM_PROMPT; + if (options.systemPrompt) { + this.providedSystemPrompt = options.systemPrompt; + this.systemPrompt += `\n\n${this.providedSystemPrompt}`; + } + } + + public generateResponseForOperation( + operation: ApolloLink.Operation, + prompt: string + ): Promise { + return Promise.resolve({ data: null }); + } + + protected createPrompt( + { query, variables }: ApolloLink.Operation, + prompt: string + ): string { + // Try to get the GraphQL query document string + // from the AST location if available, otherwise + // use the `print` function to get the query string. + // + // The AST location may not be available if the query + // was parsed with the `noLocation: true` option. + // + // If the query document string is available through + // the AST location, it will save some processing time + // over the `print` function. + const queryString = query?.loc?.source?.body ?? print(query); + + let promptVariables = ""; + if (variables) { + promptVariables = ` + + With variables: + \`\`\`json + ${JSON.stringify(variables, null, 2)} + \`\`\``; + } + + return `Give me mock data that fulfills this query: + \`\`\`graphql + ${queryString} + \`\`\` + ${promptVariables} + ${prompt ? `\nAdditional instructions:\n${prompt}` : ""} + `; + } +} diff --git a/packages/ai/src/mocking/AIMockLink.ts b/packages/ai/src/mocking/AIMockLink.ts new file mode 100644 index 00000000000..486fde4e02f --- /dev/null +++ b/packages/ai/src/mocking/AIMockLink.ts @@ -0,0 +1,47 @@ +import { ApolloLink, Observable } from "@apollo/client"; +import { AIAdapter } from "./AIAdapter.js"; + +export declare namespace AIMockLink { + export type DefaultOptions = {}; + + export interface Options { + adapter: AIAdapter; + showWarnings?: boolean; + defaultOptions?: DefaultOptions; + } +} + +export class AIMockLink extends ApolloLink { + private adapter: AIAdapter; + public showWarnings: boolean = true; + + public static defaultOptions: AIMockLink.DefaultOptions = {}; + + constructor(options: AIMockLink.Options) { + super(); + + this.adapter = options.adapter; + this.showWarnings = options.showWarnings ?? true; + } + + public request( + operation: ApolloLink.Operation + ): Observable { + const prompt = operation.getContext().prompt; + + return new Observable((observer) => { + try { + this.adapter + .generateResponseForOperation(operation, prompt) + .then((result) => { + // Notify the observer with the generated response + observer.next(result); + observer.complete(); + }); + } catch (error) { + observer.error(error); + observer.complete(); + } + }); + } +} diff --git a/packages/ai/src/mocking/AIMockProvider.tsx b/packages/ai/src/mocking/AIMockProvider.tsx new file mode 100644 index 00000000000..d18ba660d35 --- /dev/null +++ b/packages/ai/src/mocking/AIMockProvider.tsx @@ -0,0 +1,85 @@ +import * as React from "react"; + +import { ApolloClient } from "@apollo/client"; +import type { ApolloCache } from "@apollo/client/cache"; +import { InMemoryCache as Cache } from "@apollo/client/cache"; +import type { ApolloLink } from "@apollo/client/link"; +import type { LocalState } from "@apollo/client/local-state"; +import { ApolloProvider } from "@apollo/client/react"; +import { MockLink } from "@apollo/client/testing"; +import { AIAdapter } from "./AIAdapter.js"; +import { AIMockLink } from "./AIMockLink.js"; + +export interface AIMockedProviderProps { + adapter: AIAdapter; + systemPrompt?: string; + defaultOptions?: ApolloClient.DefaultOptions; + cache?: ApolloCache; + localState?: LocalState; + childProps?: object; + children?: any; + link?: ApolloLink; + showWarnings?: boolean; + mockLinkDefaultOptions?: MockLink.DefaultOptions; + devtools?: ApolloClient.Options["devtools"]; +} + +interface AIMockedProviderState { + client: ApolloClient; +} + +export class AIMockedProvider extends React.Component< + AIMockedProviderProps, + AIMockedProviderState +> { + constructor(props: AIMockedProviderProps) { + super(props); + + const { + adapter, + defaultOptions, + cache, + localState, + link, + showWarnings, + mockLinkDefaultOptions, + devtools, + } = this.props; + + const client = new ApolloClient({ + cache: cache || new Cache(), + defaultOptions, + link: + link || + new AIMockLink({ + adapter, + showWarnings, + defaultOptions: mockLinkDefaultOptions, + }), + localState, + devtools, + }); + + this.state = { + client, + }; + } + + public render() { + const { children, childProps } = this.props; + const { client } = this.state; + + return React.isValidElement(children) ? + + {React.cloneElement(React.Children.only(children), { ...childProps })} + + : null; + } + + public componentWillUnmount() { + // Since this.state.client was created in the + // constructor, it's this MockedProvider's responsibility + // to terminate it. + this.state.client.stop(); + } +} diff --git a/packages/ai/src/mocking/consts.ts b/packages/ai/src/mocking/consts.ts new file mode 100644 index 00000000000..c9e5dcc3919 --- /dev/null +++ b/packages/ai/src/mocking/consts.ts @@ -0,0 +1,16 @@ +export const BASE_SYSTEM_PROMPT = ` +You are returning mock data for a GraphQL API. + +When generating image URLs, use these reliable placeholder services with unique identifiers: +- https://picsum.photos/[width]/[height]?random=[unique_identifier] (e.g., https://picsum.photos/400/300?random=asdf, ?random=ytal, etc.) +- https://via.placeholder.com/[width]x[height]/[color]/[text_color]?text=[context] (e.g., ?text=Product+asdf) +- https://placehold.co/[width]x[height]/[color]/[text_color]?text=[context] (e.g, ?text=User+Avatar) + +For list items, increment the random number or use contextual text to ensure unique images. + +Avoid using numbers for unique identifiers. Unique identifier and typename combinations should result in consistent data. + +For example, say something is named "Foobar", you should use a unique identifier like "foobar" and not a number. + +Remember context and data based on the unique identifier and typename so that data is consistent. +`; From 4e4031a998cfb2445f88e16f0b0ce2ff2e86910b Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 20 Aug 2025 15:32:56 -0400 Subject: [PATCH 04/29] test(ai): add jest config and simple test --- packages/ai/config/jest/setup.ts | 44 +++++++++++++++++++ .../src/mocking/__tests__/AIAdapter.test.ts | 23 ++++++++++ 2 files changed, 67 insertions(+) create mode 100644 packages/ai/config/jest/setup.ts create mode 100644 packages/ai/src/mocking/__tests__/AIAdapter.test.ts diff --git a/packages/ai/config/jest/setup.ts b/packages/ai/config/jest/setup.ts new file mode 100644 index 00000000000..3356011a229 --- /dev/null +++ b/packages/ai/config/jest/setup.ts @@ -0,0 +1,44 @@ +//@ts-ignore +globalThis.__DEV__ = true; + +import { TextDecoder, TextEncoder } from "util"; +import "@testing-library/jest-dom"; + +global.TextEncoder ??= TextEncoder; +// @ts-ignore +global.TextDecoder ??= TextDecoder; + +function fail(reason = "fail was called in a test.") { + expect(reason).toBe(undefined); +} + +// @ts-ignore +globalThis.fail = fail; + +if (!Symbol.dispose) { + Object.defineProperty(Symbol, "dispose", { + value: Symbol("dispose"), + }); +} +if (!Symbol.asyncDispose) { + Object.defineProperty(Symbol, "asyncDispose", { + value: Symbol("asyncDispose"), + }); +} + +// not available in JSDOM 🙄 +global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); +global.ReadableStream ||= require("stream/web").ReadableStream; +global.TransformStream ||= require("stream/web").TransformStream; + +AbortSignal.timeout = (ms) => { + const controller = new AbortController(); + setTimeout( + () => + controller.abort( + new DOMException("The operation timed out.", "TimeoutError") + ), + ms + ); + return controller.signal; +}; diff --git a/packages/ai/src/mocking/__tests__/AIAdapter.test.ts b/packages/ai/src/mocking/__tests__/AIAdapter.test.ts new file mode 100644 index 00000000000..36b2d3f394c --- /dev/null +++ b/packages/ai/src/mocking/__tests__/AIAdapter.test.ts @@ -0,0 +1,23 @@ +import { AIAdapter } from "../AIAdapter.js"; + +class DerivedAdapter extends AIAdapter { + constructor() { + super(); + } + + public generateObject(prompt: string): Promise { + return Promise.resolve({ + data: null, + }); + } +} + +describe("AIAdapter derived class", () => { + it("should be able to generate an object", async () => { + const adapter = new DerivedAdapter(); + const result = await adapter.generateObject("Hello, world!"); + expect(result).toEqual({ + data: null, + }); + }); +}); From 6bbc70c3a6e66ea4eea69a37dff53ab53a79318a Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 20 Aug 2025 16:42:54 -0400 Subject: [PATCH 05/29] build: install zod v4 --- package-lock.json | 45 ++++++++++++++++++++++--- packages/ai-vercel-adapter/package.json | 11 +++--- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 36de8e084c2..4a4d7a7d935 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10321,6 +10321,15 @@ "eslint": ">=7" } }, + "node_modules/eslint-plugin-react-compiler/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/eslint-plugin-react-hooks": { "version": "5.1.0", "dev": true, @@ -14359,6 +14368,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/knip/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/leven": { "version": "3.1.0", "dev": true, @@ -17309,6 +17327,15 @@ "node": ">=20" } }, + "node_modules/query-registry/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/query-string": { "version": "9.1.0", "dev": true, @@ -21375,9 +21402,9 @@ } }, "node_modules/zod": { - "version": "3.23.8", - "dev": true, - "license": "MIT", + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz", + "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==", "funding": { "url": "https://github.com/sponsors/colinhacks" } @@ -21393,6 +21420,15 @@ "node": ">=20" } }, + "node_modules/zod-package-json/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zod-to-json-schema": { "version": "3.24.6", "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.6.tgz", @@ -21461,7 +21497,8 @@ "dependencies": { "@apollo/client": "*", "@apollo/client-ai": "*", - "ai": "^5.0.17" + "ai": "^5.0.17", + "zod": "^4.0.17" }, "devDependencies": { "@testing-library/jest-dom": "^6.6.3", diff --git a/packages/ai-vercel-adapter/package.json b/packages/ai-vercel-adapter/package.json index 77e51d9a869..721058d970f 100644 --- a/packages/ai-vercel-adapter/package.json +++ b/packages/ai-vercel-adapter/package.json @@ -43,14 +43,15 @@ "dependencies": { "@apollo/client": "*", "@apollo/client-ai": "*", - "ai": "^5.0.17" + "ai": "^5.0.17", + "zod": "^4.0.17" }, "devDependencies": { - "typescript": "^5.8.3", - "jest": "^29.7.0", - "ts-jest": "^29.2.3", "@testing-library/jest-dom": "^6.6.3", "@types/jest": "^29.5.12", - "rimraf": "^5.0.9" + "jest": "^29.7.0", + "rimraf": "^5.0.9", + "ts-jest": "^29.2.3", + "typescript": "^5.8.3" } } From 5d7f6f2fd4c3bd3ac6ad83edabdb945d2abeb12b Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 20 Aug 2025 15:45:25 -0400 Subject: [PATCH 06/29] feat(ai-vercel-adapter): add Vercel AI adapter --- .../ai-vercel-adapter/src/VercelAIAdapter.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 packages/ai-vercel-adapter/src/VercelAIAdapter.ts diff --git a/packages/ai-vercel-adapter/src/VercelAIAdapter.ts b/packages/ai-vercel-adapter/src/VercelAIAdapter.ts new file mode 100644 index 00000000000..4ff5de6a795 --- /dev/null +++ b/packages/ai-vercel-adapter/src/VercelAIAdapter.ts @@ -0,0 +1,55 @@ +import { ApolloLink } from "@apollo/client"; +import { type LanguageModel, generateObject } from "ai"; +import { AIAdapter } from "@apollo/client-ai"; +import { isFormattedExecutionResult } from "@apollo/client/utilities"; + +namespace VercelAIAdapter { + export interface Options extends AIAdapter.Options { + model: LanguageModel; + } +} + +type GenerateObjectOptions = { + model: LanguageModel; + mode: "json"; + prompt: string; + system?: string; + output: "no-schema"; +}; + +export class VercelAIAdapter extends AIAdapter { + public model: LanguageModel; + + constructor(options: VercelAIAdapter.Options) { + super(options); + + this.model = options.model; + } + + public async generateResponseForOperation( + operation: ApolloLink.Operation, + prompt: string + ): Promise { + const promptOptions: GenerateObjectOptions = { + mode: "json", + model: this.model, + prompt: this.createPrompt(operation, prompt), + system: this.systemPrompt, + output: "no-schema", + }; + + return generateObject(promptOptions).then( + ({ object: result, finishReason, usage, warnings }) => { + if (!result || typeof result !== "object") { + return { data: null }; + } + // Type guard to ensure result is a valid FormattedExecutionResult + if (isFormattedExecutionResult(result)) { + return result; + } + // Fallback: wrap in data property if not a valid execution result + return { data: result }; + } + ); + } +} From fd23db662922ab1743952446c089af66ec288a29 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 20 Aug 2025 16:43:19 -0400 Subject: [PATCH 07/29] test(ai-vercel-adapter): add simple test --- .../ai-vercel-adapter/config/jest/setup.ts | 44 +++++++++++++++++++ packages/ai-vercel-adapter/jest.config.ts | 10 ++++- .../src/__tests__/VercelAIAdapter.test.ts | 10 +++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 packages/ai-vercel-adapter/config/jest/setup.ts create mode 100644 packages/ai-vercel-adapter/src/__tests__/VercelAIAdapter.test.ts diff --git a/packages/ai-vercel-adapter/config/jest/setup.ts b/packages/ai-vercel-adapter/config/jest/setup.ts new file mode 100644 index 00000000000..3356011a229 --- /dev/null +++ b/packages/ai-vercel-adapter/config/jest/setup.ts @@ -0,0 +1,44 @@ +//@ts-ignore +globalThis.__DEV__ = true; + +import { TextDecoder, TextEncoder } from "util"; +import "@testing-library/jest-dom"; + +global.TextEncoder ??= TextEncoder; +// @ts-ignore +global.TextDecoder ??= TextDecoder; + +function fail(reason = "fail was called in a test.") { + expect(reason).toBe(undefined); +} + +// @ts-ignore +globalThis.fail = fail; + +if (!Symbol.dispose) { + Object.defineProperty(Symbol, "dispose", { + value: Symbol("dispose"), + }); +} +if (!Symbol.asyncDispose) { + Object.defineProperty(Symbol, "asyncDispose", { + value: Symbol("asyncDispose"), + }); +} + +// not available in JSDOM 🙄 +global.structuredClone = (val) => JSON.parse(JSON.stringify(val)); +global.ReadableStream ||= require("stream/web").ReadableStream; +global.TransformStream ||= require("stream/web").TransformStream; + +AbortSignal.timeout = (ms) => { + const controller = new AbortController(); + setTimeout( + () => + controller.abort( + new DOMException("The operation timed out.", "TimeoutError") + ), + ms + ); + return controller.signal; +}; diff --git a/packages/ai-vercel-adapter/jest.config.ts b/packages/ai-vercel-adapter/jest.config.ts index 05922435dce..9795f5be0d3 100644 --- a/packages/ai-vercel-adapter/jest.config.ts +++ b/packages/ai-vercel-adapter/jest.config.ts @@ -3,7 +3,8 @@ import { fileURLToPath } from "node:url"; export default { rootDir: "src", - preset: "ts-jest", + preset: "ts-jest/presets/default-esm", + extensionsToTreatAsEsm: [".ts"], testEnvironment: fileURLToPath( import.meta.resolve("../../config/FixJSDOMEnvironment.js") ), @@ -21,9 +22,16 @@ export default { { isolatedModules: true, tsconfig: join(import.meta.dirname, "tsconfig.json"), + useESM: true, }, ], }, + resolver: fileURLToPath( + import.meta.resolve("../../src/config/jest/resolver.ts") + ), + moduleNameMapper: { + "^@apollo/client-ai$": join(import.meta.dirname, "../ai/src/index.ts"), + }, transformIgnorePatterns: ["/node_modules/(?!(rxjs)/)"], prettierPath: null, testMatch: ["**/__tests__/**/*.test.ts", "**/__tests__/**/*.test.tsx"], diff --git a/packages/ai-vercel-adapter/src/__tests__/VercelAIAdapter.test.ts b/packages/ai-vercel-adapter/src/__tests__/VercelAIAdapter.test.ts new file mode 100644 index 00000000000..a52bbf27fe1 --- /dev/null +++ b/packages/ai-vercel-adapter/src/__tests__/VercelAIAdapter.test.ts @@ -0,0 +1,10 @@ +import { VercelAIAdapter } from "../VercelAIAdapter.js"; + +describe("VercelAIAdapter", () => { + it("should be able to be instantiated", () => { + const adapter = new VercelAIAdapter({ + model: "gpt-4o-mini", + }); + expect(adapter).toBeInstanceOf(VercelAIAdapter); + }); +}); From 265081712a4086405c653b969554c19c04917868 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sat, 23 Aug 2025 00:59:43 -0400 Subject: [PATCH 08/29] test(ai): add jest-extended --- package-lock.json | 25 +++++++++++++++++++++++++ packages/ai/config/jest/setup.ts | 3 +++ packages/ai/package.json | 9 +++++---- packages/ai/src/global.d.ts | 1 + packages/ai/tsconfig.json | 1 + 5 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 packages/ai/src/global.d.ts diff --git a/package-lock.json b/package-lock.json index 4a4d7a7d935..26bcf54d00c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13495,6 +13495,30 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-extended": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/jest-extended/-/jest-extended-6.0.0.tgz", + "integrity": "sha512-SM249N/q33YQ9XE8E06qZSnFuuV4GQFx7WrrmIj4wQUAP43jAo6budLT482jdBhf8ASwUiEEfJNjej0UusYs5A==", + "dev": true, + "dependencies": { + "jest-diff": "^29.0.0" + }, + "engines": { + "node": "^18.12.0 || ^20.9.0 || ^22.11.0 || >=23.0.0" + }, + "peerDependencies": { + "jest": ">=27.2.5", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "jest": { + "optional": true + }, + "typescript": { + "optional": false + } + } + }, "node_modules/jest-get-type": { "version": "29.6.3", "dev": true, @@ -21485,6 +21509,7 @@ "@testing-library/jest-dom": "^6.6.3", "@types/jest": "^29.5.12", "jest": "^29.7.0", + "jest-extended": "^6.0.0", "rimraf": "^5.0.9", "ts-jest": "^29.2.3", "typescript": "^5.8.3" diff --git a/packages/ai/config/jest/setup.ts b/packages/ai/config/jest/setup.ts index 3356011a229..b9f894c181d 100644 --- a/packages/ai/config/jest/setup.ts +++ b/packages/ai/config/jest/setup.ts @@ -1,6 +1,9 @@ //@ts-ignore globalThis.__DEV__ = true; +import * as matchers from "jest-extended"; +expect.extend(matchers); + import { TextDecoder, TextEncoder } from "util"; import "@testing-library/jest-dom"; diff --git a/packages/ai/package.json b/packages/ai/package.json index ddf94250b2a..401972c803c 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -43,11 +43,12 @@ "@apollo/client": "*" }, "devDependencies": { - "typescript": "^5.8.3", - "jest": "^29.7.0", - "ts-jest": "^29.2.3", "@testing-library/jest-dom": "^6.6.3", "@types/jest": "^29.5.12", - "rimraf": "^5.0.9" + "jest": "^29.7.0", + "jest-extended": "^6.0.0", + "rimraf": "^5.0.9", + "ts-jest": "^29.2.3", + "typescript": "^5.8.3" } } diff --git a/packages/ai/src/global.d.ts b/packages/ai/src/global.d.ts new file mode 100644 index 00000000000..3b47093f482 --- /dev/null +++ b/packages/ai/src/global.d.ts @@ -0,0 +1 @@ +import "jest-extended"; diff --git a/packages/ai/tsconfig.json b/packages/ai/tsconfig.json index ad91b12f611..3e8a9432bc8 100644 --- a/packages/ai/tsconfig.json +++ b/packages/ai/tsconfig.json @@ -26,6 +26,7 @@ "@apollo/client/testing/internal": ["./src/testing/internal/index.ts"] } }, + "files": ["src/global.d.ts"], "include": ["src/**/*.ts", "src/**/*.tsx"], "mdx": { // Enable strict type checking in MDX files. From 58399e3f44de44644abf0e85ffd6c6cb1fa57aac Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sat, 23 Aug 2025 01:00:44 -0400 Subject: [PATCH 09/29] feat(ai): add initial growing schema implementation --- packages/ai/src/mocking/GrowingSchema.ts | 406 ++++++++++++ .../mocking/__tests__/GrowingSchema.test.ts | 602 ++++++++++++++++++ 2 files changed, 1008 insertions(+) create mode 100644 packages/ai/src/mocking/GrowingSchema.ts create mode 100644 packages/ai/src/mocking/__tests__/GrowingSchema.test.ts diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts new file mode 100644 index 00000000000..3eaf8337de8 --- /dev/null +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -0,0 +1,406 @@ +import type { + DefinitionNode, + DocumentNode, + FieldDefinitionNode, + FieldNode, + GraphQLCompositeType, + InputValueDefinitionNode, + TypeNode, +} from "graphql"; +import { + extendSchema, + FieldsOnCorrectTypeRule, + GraphQLError, + GraphQLObjectType, + GraphQLSchema, + Kind, + printSchema, + specifiedRules, + TypeInfo, + validate, + visit, + visitWithTypeInfo, +} from "graphql"; +import { Maybe } from "graphql/jsutils/Maybe.js"; +import { AIAdapter } from "./AIAdapter.js"; + +const rulesToIgnore = [ + FieldsOnCorrectTypeRule, + // KnownArgumentNamesOnDirectivesRule, + // KnownArgumentNamesRule, + // KnownDirectivesRule, + // KnownFragmentNamesRule, + // KnownTypeNamesRule, +]; + +const enforcedRules = specifiedRules.filter( + (rule) => !rulesToIgnore.includes(rule) +); + +export class GrowingSchema { + public schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: "Query", + fields: {}, + }), + }); + + public validateQuery(query: DocumentNode) { + const errors = validate(this.schema, query, enforcedRules); + if (errors.length > 0) { + throw new Error( + `Query is inconsistent with existing schema: ${errors + .map((e) => e.message) + .join(", ")}` + ); + } + } + + public add( + operation: { + query: DocumentNode; + variables?: Record; + }, + response: AIAdapter.Result + ) { + const query = operation.query; + // @todo handle variables + // const variables = operation.variables; + + const typeInfo = new TypeInfo(this.schema); + const responsePath = [response.data]; + + let accumulatedExtensions: { + kind: Kind.DOCUMENT; + definitions: DefinitionNode[]; + } = { + kind: Kind.DOCUMENT, + definitions: [], + } satisfies DocumentNode; + + const mergeExtensions = () => { + this.schema = extendSchema(this.schema, accumulatedExtensions, { + assumeValidSDL: true, + }); + accumulatedExtensions = { + kind: Kind.DOCUMENT, + definitions: [], + }; + return this.schema; + }; + + visit( + query, + visitWithTypeInfo(typeInfo, { + Field: { + leave() { + responsePath.pop(); + }, + enter: (node, key, parent, path, ancestors) => { + const valueAtPath = + responsePath.at(-1)![node.alias?.value || node.name.value]; + const isList = Array.isArray(valueAtPath); + const actualValue = isList ? valueAtPath[0] : valueAtPath; + const typename = actualValue?.__typename; + responsePath.push(actualValue); + + const type = typeInfo.getParentType(); + if (!type) { + throw new GraphQLError( + `No parent type found for field ${node.name.value}` + ); + } + + let newFieldDef = this.getFieldDefinition( + node, + isList, + actualValue, + typename, + type + ); + + const existingFieldDef = typeInfo.getFieldDef(); + if (existingFieldDef) { + if ( + this.newFieldDefinitionMatchesExistingFieldDefinition( + newFieldDef, + existingFieldDef.astNode + ) + ) { + // The new and existing field definitions match, so we + // can skip adding the new field definition to the schema. + return; + } + // The new and existing field definitions don't match, so we + // need to attempt to merge them. + newFieldDef = this.mergeFieldDefinitions( + newFieldDef, + existingFieldDef.astNode, + type.name + ); + } + + if (node.name.value === "__typename") { + return; + } + + accumulatedExtensions.definitions.push({ + kind: Kind.OBJECT_TYPE_EXTENSION, + name: { kind: Kind.NAME, value: type.name }, + fields: [newFieldDef], + }); + + // field not in schema + if (node.selectionSet) { + if (!this.schema.getType(typename)) { + accumulatedExtensions.definitions.push({ + kind: Kind.OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: typename }, + fields: [], + }); + } + // this selection set couldn't be entered correctly before, so we + // need to merge the schema now, and have the type info start + // from the top to navigate to the current node + mergeExtensions(); + Object.assign(typeInfo, new TypeInfo(this.schema)); + path.reduce((node: any, key: any) => { + const child = node[key]; + typeInfo.enter(child); + return child; + }, query); + } + }, + }, + }) + ); + mergeExtensions(); + } + + private getFieldArguments(node: FieldNode): InputValueDefinitionNode[] { + // @todo we need to handle named input object arguments + // For now, we'll only handle build-in scalar arguments + return ( + node.arguments?.map((arg) => { + let valueType: string; + switch (arg.value.kind) { + case Kind.STRING: + valueType = "String"; + break; + case Kind.INT: + valueType = "Int"; + break; + case Kind.FLOAT: + valueType = "Float"; + break; + case Kind.BOOLEAN: + valueType = "Boolean"; + break; + default: + throw new GraphQLError( + `Scalar responses are not supported for field ${ + node.name.value + } on type ${node.name.value} - received ${JSON.stringify( + arg.value.kind + )}` + ); + } + return { + kind: Kind.INPUT_VALUE_DEFINITION, + name: arg.name, + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: valueType }, + }, + }; + }) ?? [] + ); + } + + private getFieldDefinition( + node: FieldNode, + isList: boolean, + actualValue: any, + typename: string, + type: GraphQLCompositeType + ): FieldDefinitionNode { + const args = this.getFieldArguments(node); + + // field not in schema + if (node.selectionSet) { + // either an object or a list type + if (!typename) { + throw new GraphQLError( + `Field ${node.name.value} on type ${type.name} is missing __typename in response data` + ); + } + let fieldReturnType: TypeNode = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: typename }, + }; + if (isList) { + fieldReturnType = { + kind: Kind.LIST_TYPE, + type: fieldReturnType, + }; + } + return { + kind: Kind.FIELD_DEFINITION, + name: { kind: Kind.NAME, value: node.name.value }, + type: fieldReturnType, + arguments: args, + }; + } else { + // scalar type + let valueType: string; + switch (typeof actualValue) { + case "string": + valueType = "String"; + break; + case "number": + valueType = "Float"; + break; + case "boolean": + valueType = "Boolean"; + break; + default: + throw new GraphQLError( + `Scalar responses are not supported for field ${ + node.name.value + } on type ${type.name} - received ${JSON.stringify(actualValue)}` + ); + } + return { + kind: Kind.FIELD_DEFINITION, + name: { kind: Kind.NAME, value: node.name.value }, + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: valueType }, + }, + }; + } + } + + /** + * Helper function to compare two TypeNode objects for equality + */ + private typeNodesEqual(type1: TypeNode, type2: TypeNode): boolean { + if (type1.kind !== type2.kind) { + return false; + } + + switch (type1.kind) { + case Kind.NAMED_TYPE: + return type1.name.value === (type2 as typeof type1).name.value; + case Kind.LIST_TYPE: + return this.typeNodesEqual(type1.type, (type2 as typeof type1).type); + case Kind.NON_NULL_TYPE: + return this.typeNodesEqual(type1.type, (type2 as typeof type1).type); + default: + return false; + } + } + + /** + * Helper function to convert TypeNode to human-readable string + */ + private static typeNodeToString(type: TypeNode): string { + switch (type.kind) { + case Kind.NAMED_TYPE: + return type.name.value; + case Kind.LIST_TYPE: + return `[${GrowingSchema.typeNodeToString(type.type)}]`; + case Kind.NON_NULL_TYPE: + return `${GrowingSchema.typeNodeToString(type.type)}!`; + default: + return "Unknown"; + } + } + + private newFieldDefinitionMatchesExistingFieldDefinition( + newFieldDef: FieldDefinitionNode, + existingFieldDef: Maybe + ): boolean { + if (!existingFieldDef) { + return false; + } + if (existingFieldDef.name.value !== newFieldDef.name.value) { + return false; + } + + // Check arguments + const newArgs = newFieldDef.arguments || []; + const existingArgs = existingFieldDef.arguments || []; + + // Check argument count + if (newArgs.length !== existingArgs.length) { + return false; + } + + // Check each argument by name and type + for (const newArg of newArgs) { + const existingArg = existingArgs.find( + (arg) => arg.name.value === newArg.name.value + ); + + if (!existingArg) { + return false; // Argument name not found + } + + // Check argument types + if (!this.typeNodesEqual(newArg.type, existingArg.type)) { + return false; + } + } + + // Check field return types + if (!this.typeNodesEqual(newFieldDef.type, existingFieldDef.type)) { + return false; + } + + return true; + } + + /** + * @todo handle existing field definition that doesn't match the new field definition + * We need to: + * + * - merge arguments + * - check return type + * - If the return type is different, we need to throw an error + */ + private mergeFieldDefinitions( + newFieldDef: FieldDefinitionNode, + existingFieldDef: Maybe, + parentTypeName: string + ): FieldDefinitionNode { + if (!existingFieldDef) { + return newFieldDef; + } + + if (!this.typeNodesEqual(newFieldDef.type, existingFieldDef.type)) { + const existingReturnTypeString = GrowingSchema.typeNodeToString( + existingFieldDef.type + ); + const newReturnTypeString = GrowingSchema.typeNodeToString( + newFieldDef.type + ); + throw new GraphQLError( + `Field \`${parentTypeName}.${newFieldDef.name.value}\` return type mismatch. Previously defined return type: \`${existingReturnTypeString}\`, new return type: \`${newReturnTypeString}\`` + ); + } + + const newArgs = newFieldDef.arguments || []; + const existingArgs = existingFieldDef.arguments || []; + const mergedArgs = [...existingArgs, ...newArgs]; + + return { + ...existingFieldDef, + arguments: mergedArgs, + }; + } + + public toString() { + return printSchema(this.schema); + } +} diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts new file mode 100644 index 00000000000..3dcc8aacf7b --- /dev/null +++ b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts @@ -0,0 +1,602 @@ +import { gql } from "@apollo/client"; +import { GrowingSchema } from "../GrowingSchema.js"; +import { GraphQLError } from "graphql"; + +describe("GrowingSchema", () => { + it("creates a base schema when instantiated", () => { + const schema = new GrowingSchema(); + expect(schema.toString()).toEqualIgnoringWhitespace(/* GraphQL */ ` + type Query + `); + }); + + describe(".add()", () => { + it("should create a schema with the correct fields", () => { + const query = gql` + query GetUser { + user { + __typename + id + name + emails { + __typename + id + kind + value + } + } + } + `; + const response = { + data: { + user: { + __typename: "User", + id: "1", + name: "John Doe", + emails: [ + { __typename: "Email", id: "1", kind: "work", value: "qd" }, + { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, + ], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + user: User + } + + type User { + id: String + name: String + emails: [Email] + } + + type Email { + id: String + kind: String + value: String + } + `; + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("extends an existing schema based on a new query", () => { + const query = gql` + query GetUser { + user { + __typename + id + name + emails { + __typename + id + kind + value + } + } + } + `; + const response = { + data: { + user: { + __typename: "User", + id: "1", + name: "John Doe", + emails: [ + { __typename: "Email", id: "1", kind: "work", value: "qd" }, + { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, + ], + }, + }, + }; + const query2 = gql` + query GetUser2 { + user { + __typename + lastName + emails { + __typename + foo + } + } + } + `; + const response2 = { + data: { + user: { + __typename: "User", + lastName: "John Doe", + emails: [{ __typename: "Email", foo: 1 }], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + user: User + } + + type User { + id: String + name: String + emails: [Email] + lastName: String + } + + type Email { + id: String + kind: String + value: String + foo: Float + } + `; + const schema = new GrowingSchema(); + schema.add({ query }, response); + schema.add({ query: query2 }, response2); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("throws an error when a query that is incompatible with previous queries is added", () => { + const query = gql` + query GetUsers { + users(limit: 2) { + __typename + id + name + } + } + `; + const response = { + data: { + users: [ + { __typename: "User", id: "1", name: "John Smith" }, + { __typename: "User", id: "2", name: "Sarah Jane Smith" }, + ], + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + users(limit: Int): [User] + } + + type User { + id: String + name: String + } + `; + const query2 = gql` + query GetUser2 { + users(first: 2, after: "ASDF") { + __typename + edges { + __typename + node { + __typename + lastName + } + } + pageInfo { + __typename + hasNextPage + nextCursor + } + } + } + `; + const response2 = { + data: { + users: { + __typename: "UserConnection", + edges: [ + { + __typename: "UserEdge", + node: { __typename: "User", lastName: "Smith" }, + }, + { + __typename: "UserEdge", + node: { __typename: "User", lastName: "Smith" }, + }, + ], + pageInfo: { + __typename: "PageInfo", + hasNextPage: true, + nextCursor: "QWERTY", + }, + }, + }, + }; + + const schema = new GrowingSchema(); + + // Add the initial schema + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + + // Attempt to add the incompatible schema + let error: Error | undefined; + try { + schema.add({ query: query2 }, response2); + } catch (err) { + error = err as Error; + } + + expect(error).toBeInstanceOf(GraphQLError); + expect(error?.message).toEqual( + "Field `Query.users` return type mismatch. Previously defined return type: `[User]`, new return type: `UserConnection`" + ); + }); + + it.skip("handles variables", () => { + const query = gql` + query Search($bookId: ID!, $arg: String!) { + book(id: $bookId) { + __typename + title + anotherField(arg: $arg) + } + } + `; + const variables = { + bookId: "ASDF", + arg: "QWERTY", + }; + const response = { + data: { + book: { + __typename: "Book", + title: "Moby Dick", + anotherField: true, + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + } + `; + + const schema = new GrowingSchema(); + schema.add({ query, variables }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it.skip("handles inline fragments", () => { + const query = gql` + query Search { + book { + ... on Book { + __typename + title + } + } + } + `; + const response = { + data: { + book: { + __typename: "Book", + title: "Moby Dick", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + } + `; + + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it.skip("handles union types", () => { + const query = gql` + query Search { + search(term: "Smith", first: 2, after: "ASDF") { + __typename + pageInfo { + __typename + hasNextPage + nextCursor + } + edges { + __typename + node { + # The inline fragments imply that this is a union. + ... on Author { + __typename + name + } + ... on Book { + __typename + title + } + } + } + } + } + `; + const response = { + data: { + search: { + __typename: "SearchConnection", + pageInfo: { + __typename: "PageInfo", + hasNextPage: true, + nextCursor: "eyJvZmZzZXQiOjJ9", + }, + edges: [ + // The inconsistent `__typename` values + // imply that this is a union. + { + __typename: "SearchEdge", + node: { + __typename: "Author", + name: "John Smith", + }, + }, + { + __typename: "SearchEdge", + node: { + __typename: "Book", + title: "The Art of Blacksmithing", + }, + }, + ], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + } + `; + + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it.skip("handles interfaces", () => { + const query = gql` + query Search($term: String!, $first: Int, $after: String) { + search(term: $term, first: $first, after: $after) { + __typename + pageInfo { + __typename + hasNextPage + nextCursor + } + edges { + __typename + node { + __typename + # The root field should imply that this + # is an interface, not a union. + id + ... on Author { + __typename + name + } + ... on Book { + __typename + title + } + } + } + } + } + `; + const response = { + data: { + search: { + __typename: "SearchConnection", + pageInfo: { + __typename: "PageInfo", + hasNextPage: true, + nextCursor: "eyJvZmZzZXQiOjJ9", + }, + edges: [ + // The inconsistent `__typename` values + // could imply that this is a union, but when + // paired with the root `id` field, it instead + // implies that this is an interface. + { + __typename: "SearchEdge", + node: { + __typename: "Author", + name: "John Smith", + }, + }, + { + __typename: "SearchEdge", + node: { + __typename: "Book", + title: "The Art of Blacksmithing", + }, + }, + ], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + } + `; + + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it.skip("handles named fragments", () => { + const query = gql` + query Search { + book { + ...BookFragment + } + } + + fragment BookFragment on Book { + __typename + title + } + `; + const response = { + data: { + book: { + __typename: "Book", + title: "Moby Dick", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + } + `; + + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it.skip("handles union types with named fragments", () => { + const query = gql` + query Search { + search(term: "Smith", first: 2, after: "ASDF") { + __typename + pageInfo { + __typename + hasNextPage + nextCursor + } + edges { + __typename + node { + ... AuthorFragment + ... BookFragment + } + } + } + } + + fragment AuthorFragment on Author { + __typename + name + } + + fragment BookFragment on Book { + __typename + title + } + `; + const response = { + data: { + search: { + __typename: "SearchConnection", + pageInfo: { + __typename: "PageInfo", + hasNextPage: true, + nextCursor: "eyJvZmZzZXQiOjJ9", + }, + edges: [ + { + __typename: "SearchEdge", + node: { + __typename: "Author", + name: "John Smith", + }, + }, + { + __typename: "SearchEdge", + node: { + __typename: "Book", + title: "The Art of Blacksmithing", + }, + }, + ], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + } + `; + + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it.skip("handles interfaces with named fragments", () => { + const query = gql` + query Search($term: String!, $first: Int, $after: String) { + search(term: $term, first: $first, after: $after) { + __typename + pageInfo { + __typename + hasNextPage + nextCursor + } + edges { + __typename + node { + __typename + # The root field should imply that this + # is an interface, not a union. + id + ... AuthorFragment + ... BookFragment + } + } + } + } + + fragment AuthorFragment on Author { + __typename + name + } + + fragment BookFragment on Book { + __typename + title + } + `; + const response = { + data: { + search: { + __typename: "SearchConnection", + pageInfo: { + __typename: "PageInfo", + hasNextPage: true, + nextCursor: "eyJvZmZzZXQiOjJ9", + }, + edges: [ + { + __typename: "SearchEdge", + node: { + __typename: "Author", + name: "John Smith", + }, + }, + { + __typename: "SearchEdge", + node: { + __typename: "Book", + title: "The Art of Blacksmithing", + }, + }, + ], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + } + `; + + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + }); +}); From 4d73af5cc40b2ddacb3622b4e6da4115d80d2817 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sat, 23 Aug 2025 01:01:07 -0400 Subject: [PATCH 10/29] feat(ai): refactor adapter and add growing schema --- packages/ai/src/mocking/AIAdapter.ts | 58 ++---------- packages/ai/src/mocking/AIMockLink.ts | 19 ++-- packages/ai/src/mocking/BaseAIAdapter.ts | 93 +++++++++++++++++++ .../src/mocking/__tests__/AIAdapter.test.ts | 2 +- 4 files changed, 109 insertions(+), 63 deletions(-) create mode 100644 packages/ai/src/mocking/BaseAIAdapter.ts diff --git a/packages/ai/src/mocking/AIAdapter.ts b/packages/ai/src/mocking/AIAdapter.ts index defdc02e946..34145d6e4cc 100644 --- a/packages/ai/src/mocking/AIAdapter.ts +++ b/packages/ai/src/mocking/AIAdapter.ts @@ -1,6 +1,4 @@ import { ApolloLink } from "@apollo/client"; -import { print } from "graphql"; -import { BASE_SYSTEM_PROMPT } from "./consts.js"; export declare namespace AIAdapter { export interface Options { @@ -11,56 +9,14 @@ export declare namespace AIAdapter { } export abstract class AIAdapter { - public providedSystemPrompt: string | undefined; - protected systemPrompt: string; + public systemPrompt?: string; - constructor(options: AIAdapter.Options = {}) { - this.systemPrompt = BASE_SYSTEM_PROMPT; - if (options.systemPrompt) { - this.providedSystemPrompt = options.systemPrompt; - this.systemPrompt += `\n\n${this.providedSystemPrompt}`; - } + constructor(options?: AIAdapter.Options) { + this.systemPrompt = options?.systemPrompt; } - public generateResponseForOperation( - operation: ApolloLink.Operation, - prompt: string - ): Promise { - return Promise.resolve({ data: null }); - } - - protected createPrompt( - { query, variables }: ApolloLink.Operation, - prompt: string - ): string { - // Try to get the GraphQL query document string - // from the AST location if available, otherwise - // use the `print` function to get the query string. - // - // The AST location may not be available if the query - // was parsed with the `noLocation: true` option. - // - // If the query document string is available through - // the AST location, it will save some processing time - // over the `print` function. - const queryString = query?.loc?.source?.body ?? print(query); - - let promptVariables = ""; - if (variables) { - promptVariables = ` - - With variables: - \`\`\`json - ${JSON.stringify(variables, null, 2)} - \`\`\``; - } - - return `Give me mock data that fulfills this query: - \`\`\`graphql - ${queryString} - \`\`\` - ${promptVariables} - ${prompt ? `\nAdditional instructions:\n${prompt}` : ""} - `; - } + public abstract generateObject( + prompt: string, + systemPrompt: string + ): Promise; } diff --git a/packages/ai/src/mocking/AIMockLink.ts b/packages/ai/src/mocking/AIMockLink.ts index 486fde4e02f..d9fc3c6e830 100644 --- a/packages/ai/src/mocking/AIMockLink.ts +++ b/packages/ai/src/mocking/AIMockLink.ts @@ -1,5 +1,6 @@ import { ApolloLink, Observable } from "@apollo/client"; import { AIAdapter } from "./AIAdapter.js"; +import { BaseAIAdapter } from "./BaseAIAdapter.js"; export declare namespace AIMockLink { export type DefaultOptions = {}; @@ -12,7 +13,7 @@ export declare namespace AIMockLink { } export class AIMockLink extends ApolloLink { - private adapter: AIAdapter; + private adapter: BaseAIAdapter; public showWarnings: boolean = true; public static defaultOptions: AIMockLink.DefaultOptions = {}; @@ -20,24 +21,20 @@ export class AIMockLink extends ApolloLink { constructor(options: AIMockLink.Options) { super(); - this.adapter = options.adapter; + this.adapter = new BaseAIAdapter(options.adapter); this.showWarnings = options.showWarnings ?? true; } public request( operation: ApolloLink.Operation ): Observable { - const prompt = operation.getContext().prompt; - return new Observable((observer) => { try { - this.adapter - .generateResponseForOperation(operation, prompt) - .then((result) => { - // Notify the observer with the generated response - observer.next(result); - observer.complete(); - }); + this.adapter.performQuery(operation).then((result) => { + // Notify the observer with the generated response + observer.next(result); + observer.complete(); + }); } catch (error) { observer.error(error); observer.complete(); diff --git a/packages/ai/src/mocking/BaseAIAdapter.ts b/packages/ai/src/mocking/BaseAIAdapter.ts new file mode 100644 index 00000000000..e649062d913 --- /dev/null +++ b/packages/ai/src/mocking/BaseAIAdapter.ts @@ -0,0 +1,93 @@ +import { ApolloLink } from "@apollo/client"; +import { AIAdapter } from "./AIAdapter.js"; +import { print } from "graphql"; +import { BASE_SYSTEM_PROMPT } from "./consts.js"; +import { GrowingSchema } from "./GrowingSchema.js"; + +export class BaseAIAdapter { + private static baseSystemPrompt = BASE_SYSTEM_PROMPT; + private schema: GrowingSchema; + + constructor(private implementation: AIAdapter) { + this.schema = new GrowingSchema(); + } + + /** + * Performs a query using the implementation adapter. + * @param operation - The operation to perform. + * @returns The result of the query. + */ + public async performQuery( + operation: ApolloLink.Operation + ): Promise { + const systemPrompt = BaseAIAdapter.createSystemPrompt( + this.implementation.systemPrompt + ); + + const prompt = BaseAIAdapter.createPrompt(operation); + + const result = await this.implementation.generateObject( + prompt, + systemPrompt + ); + + this.schema.add(operation, result); + + return result; + } + + /** + * Creates a system prompt from the base system prompt and the provided prompt. + * @param prompt - The prompt to add to the base system prompt. + * @returns The system prompt. + */ + private static createSystemPrompt(prompt?: string) { + return [BaseAIAdapter.baseSystemPrompt, prompt] + .filter(Boolean) + .join("\n\n"); + } + + /** + * Creates a prompt from the operation. + * @param operation - The operation to create a prompt from. + * @returns The prompt. + */ + private static createPrompt(operation: ApolloLink.Operation): string { + const { query, variables } = operation; + const providedPrompt = operation.getContext().prompt; + + // Try to get the GraphQL query document string + // from the AST location if available, otherwise + // use the `print` function to get the query string. + // + // The AST location may not be available if the query + // was parsed with the `noLocation: true` option. + // + // If the query document string is available through + // the AST location, it will save some processing time + // over the `print` function. + const queryString = query?.loc?.source?.body ?? print(query); + + const promptParts = [ + "Give me mock data that fulfills this query:", + "```graphql", + queryString, + "```", + ]; + + if (variables) { + promptParts.push( + "\n", + "```json", + JSON.stringify(variables, null, 2), + "```" + ); + } + + if (providedPrompt) { + promptParts.push("\n", "Additional instructions:", providedPrompt); + } + + return promptParts.join("\n"); + } +} diff --git a/packages/ai/src/mocking/__tests__/AIAdapter.test.ts b/packages/ai/src/mocking/__tests__/AIAdapter.test.ts index 36b2d3f394c..1c08de621ae 100644 --- a/packages/ai/src/mocking/__tests__/AIAdapter.test.ts +++ b/packages/ai/src/mocking/__tests__/AIAdapter.test.ts @@ -2,7 +2,7 @@ import { AIAdapter } from "../AIAdapter.js"; class DerivedAdapter extends AIAdapter { constructor() { - super(); + super({}); } public generateObject(prompt: string): Promise { From 418df18b3a4a4c9c5f52b840b39166858bb6c589 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Sat, 23 Aug 2025 01:09:59 -0400 Subject: [PATCH 11/29] feat(ai-vercel-adapter): update vercel adapter to use new ai adapter interface --- packages/ai-vercel-adapter/src/VercelAIAdapter.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/ai-vercel-adapter/src/VercelAIAdapter.ts b/packages/ai-vercel-adapter/src/VercelAIAdapter.ts index 4ff5de6a795..77be26649a7 100644 --- a/packages/ai-vercel-adapter/src/VercelAIAdapter.ts +++ b/packages/ai-vercel-adapter/src/VercelAIAdapter.ts @@ -1,4 +1,3 @@ -import { ApolloLink } from "@apollo/client"; import { type LanguageModel, generateObject } from "ai"; import { AIAdapter } from "@apollo/client-ai"; import { isFormattedExecutionResult } from "@apollo/client/utilities"; @@ -26,15 +25,15 @@ export class VercelAIAdapter extends AIAdapter { this.model = options.model; } - public async generateResponseForOperation( - operation: ApolloLink.Operation, - prompt: string + public async generateObject( + prompt: string, + systemPrompt: string ): Promise { const promptOptions: GenerateObjectOptions = { mode: "json", model: this.model, - prompt: this.createPrompt(operation, prompt), - system: this.systemPrompt, + prompt, + system: systemPrompt, output: "no-schema", }; From fa7f4e5d21e89054ade4245ac6ef33b0df8936dc Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 25 Aug 2025 11:03:50 -0400 Subject: [PATCH 12/29] test(ai): update growing schema tests --- .../mocking/__tests__/GrowingSchema.test.ts | 144 +++++------------- 1 file changed, 36 insertions(+), 108 deletions(-) diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts index 3dcc8aacf7b..88c1f808323 100644 --- a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts @@ -260,36 +260,7 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it.skip("handles inline fragments", () => { - const query = gql` - query Search { - book { - ... on Book { - __typename - title - } - } - } - `; - const response = { - data: { - book: { - __typename: "Book", - title: "Moby Dick", - }, - }, - }; - const expectedSchema = /* GraphQL */ ` - type Query { - } - `; - - const schema = new GrowingSchema(); - schema.add({ query }, response); - expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); - }); - - it.skip("handles union types", () => { + it.skip("handles union types with inline fragments", () => { const query = gql` query Search { search(term: "Smith", first: 2, after: "ASDF") { @@ -356,7 +327,36 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it.skip("handles interfaces", () => { + it.skip("handles a single inline fragment as a union", () => { + const query = gql` + query Search { + book { + ... on Book { + __typename + title + } + } + } + `; + const response = { + data: { + book: { + __typename: "Book", + title: "Moby Dick", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + } + `; + + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it.skip("handles a selection set with root fields and inline fragments as a union, contributing the root fields to all union members", () => { const query = gql` query Search($term: String!, $first: Int, $after: String) { search(term: $term, first: $first, after: $after) { @@ -372,14 +372,14 @@ describe("GrowingSchema", () => { __typename # The root field should imply that this # is an interface, not a union. - id - ... on Author { + title + ... on Movie { __typename - name + someField } ... on Book { __typename - title + someOtherField } } } @@ -428,7 +428,7 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it.skip("handles named fragments", () => { + it.skip("handles named fragments on a type", () => { const query = gql` query Search { book { @@ -526,77 +526,5 @@ describe("GrowingSchema", () => { schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - - it.skip("handles interfaces with named fragments", () => { - const query = gql` - query Search($term: String!, $first: Int, $after: String) { - search(term: $term, first: $first, after: $after) { - __typename - pageInfo { - __typename - hasNextPage - nextCursor - } - edges { - __typename - node { - __typename - # The root field should imply that this - # is an interface, not a union. - id - ... AuthorFragment - ... BookFragment - } - } - } - } - - fragment AuthorFragment on Author { - __typename - name - } - - fragment BookFragment on Book { - __typename - title - } - `; - const response = { - data: { - search: { - __typename: "SearchConnection", - pageInfo: { - __typename: "PageInfo", - hasNextPage: true, - nextCursor: "eyJvZmZzZXQiOjJ9", - }, - edges: [ - { - __typename: "SearchEdge", - node: { - __typename: "Author", - name: "John Smith", - }, - }, - { - __typename: "SearchEdge", - node: { - __typename: "Book", - title: "The Art of Blacksmithing", - }, - }, - ], - }, - }, - }; - const expectedSchema = /* GraphQL */ ` - type Query { - } - `; - - const schema = new GrowingSchema(); - schema.add({ query }, response); - expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); - }); }); }); From 47a1a53b8343afcc06576c7955e5c870f30a90c3 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 25 Aug 2025 12:07:33 +0200 Subject: [PATCH 13/29] split schema growing and response validation --- packages/ai/src/mocking/GrowingSchema.ts | 257 ++++++++++------------- 1 file changed, 113 insertions(+), 144 deletions(-) diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts index 3eaf8337de8..91553452c43 100644 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -3,11 +3,13 @@ import type { DocumentNode, FieldDefinitionNode, FieldNode, + FormattedExecutionResult, GraphQLCompositeType, InputValueDefinitionNode, TypeNode, } from "graphql"; import { + execute, extendSchema, FieldsOnCorrectTypeRule, GraphQLError, @@ -21,8 +23,8 @@ import { visit, visitWithTypeInfo, } from "graphql"; -import { Maybe } from "graphql/jsutils/Maybe.js"; -import { AIAdapter } from "./AIAdapter.js"; + +import type { AIAdapter } from "./AIAdapter.js"; const rulesToIgnore = [ FieldsOnCorrectTypeRule, @@ -45,6 +47,8 @@ export class GrowingSchema { }), }); + private seenQueries = new WeakSet(); + public validateQuery(query: DocumentNode) { const errors = validate(this.schema, query, enforcedRules); if (errors.length > 0) { @@ -64,9 +68,26 @@ export class GrowingSchema { response: AIAdapter.Result ) { const query = operation.query; + + if (!this.seenQueries.has(query)) { + this.seenQueries.add(query); + this.mergeQueryIntoSchema(operation, response); + } // @todo handle variables // const variables = operation.variables; + this.validateResponseAgainstSchema(query, operation, response); + } + + public mergeQueryIntoSchema( + operation: { + query: DocumentNode; + variables?: Record; + }, + response: AIAdapter.Result + ) { + const query = operation.query; + const typeInfo = new TypeInfo(this.schema); const responsePath = [response.data]; @@ -78,10 +99,26 @@ export class GrowingSchema { definitions: [], } satisfies DocumentNode; - const mergeExtensions = () => { + const mergeExtensions = ({ + assumeValidSDL = false, + revisitAtPath, + }: { + assumeValidSDL?: boolean; + revisitAtPath?: ReadonlyArray; + } = {}) => { this.schema = extendSchema(this.schema, accumulatedExtensions, { - assumeValidSDL: true, + assumeValidSDL, }); + + if (revisitAtPath) { + Object.assign(typeInfo, new TypeInfo(this.schema)); + revisitAtPath.reduce((node: any, key: any) => { + const child = node[key]; + typeInfo.enter(child); + return child; + }, query); + } + accumulatedExtensions = { kind: Kind.DOCUMENT, definitions: [], @@ -119,25 +156,37 @@ export class GrowingSchema { type ); - const existingFieldDef = typeInfo.getFieldDef(); + const existingFieldDef = typeInfo.getFieldDef()?.astNode; if (existingFieldDef) { - if ( - this.newFieldDefinitionMatchesExistingFieldDefinition( - newFieldDef, - existingFieldDef.astNode - ) - ) { - // The new and existing field definitions match, so we + const existingArguments = new Map( + existingFieldDef.arguments?.map((arg) => [arg.name.value, arg]) + ); + const additionalArgs = + newFieldDef.arguments?.filter( + (arg) => !existingArguments.has(arg.name.value) + ) || []; + + if (!additionalArgs.length) { + // The existing field definition is sufficient, so we // can skip adding the new field definition to the schema. return; } - // The new and existing field definitions don't match, so we - // need to attempt to merge them. - newFieldDef = this.mergeFieldDefinitions( - newFieldDef, - existingFieldDef.astNode, - type.name - ); + + accumulatedExtensions.definitions.push({ + kind: Kind.OBJECT_TYPE_EXTENSION, + name: { kind: Kind.NAME, value: type.name }, + fields: [ + { + ...existingFieldDef, + arguments: [ + ...(existingFieldDef.arguments || []), + ...additionalArgs, + ], + }, + ], + }); + mergeExtensions({ assumeValidSDL: true, revisitAtPath: path }); + return; } if (node.name.value === "__typename") { @@ -162,13 +211,9 @@ export class GrowingSchema { // this selection set couldn't be entered correctly before, so we // need to merge the schema now, and have the type info start // from the top to navigate to the current node + mergeExtensions({ revisitAtPath: path }); + } else { mergeExtensions(); - Object.assign(typeInfo, new TypeInfo(this.schema)); - path.reduce((node: any, key: any) => { - const child = node[key]; - typeInfo.enter(child); - return child; - }, query); } }, }, @@ -177,6 +222,49 @@ export class GrowingSchema { mergeExtensions(); } + private validateResponseAgainstSchema( + query: DocumentNode, + operation: { query: DocumentNode; variables?: Record }, + response: FormattedExecutionResult, Record> + ) { + const result = execute({ + schema: this.schema, + document: query, + variableValues: operation.variables, + fieldResolver: (source, args, context, info) => { + const value = source[info.fieldName]; + switch (info.returnType.toString()) { + case "String": + if (typeof value !== "string") { + throw new TypeError(`Value is not string: ${value}`); + } + break; + case "Float": + if (typeof value !== "number") { + throw new TypeError(`Value is not number: ${value}`); + } + break; + case "Boolean": + if (typeof value !== "boolean") { + throw new TypeError(`Value is not boolean: ${value}`); + } + break; + } + + return value; + }, + rootValue: response.data, + }) as FormattedExecutionResult; + + if (result.errors?.length) { + throw new GraphQLError( + `Error executing query against grown schema: ${result.errors + .map((e) => e.message) + .join(", ")}` + ); + } + } + private getFieldArguments(node: FieldNode): InputValueDefinitionNode[] { // @todo we need to handle named input object arguments // For now, we'll only handle build-in scalar arguments @@ -281,125 +369,6 @@ export class GrowingSchema { } } - /** - * Helper function to compare two TypeNode objects for equality - */ - private typeNodesEqual(type1: TypeNode, type2: TypeNode): boolean { - if (type1.kind !== type2.kind) { - return false; - } - - switch (type1.kind) { - case Kind.NAMED_TYPE: - return type1.name.value === (type2 as typeof type1).name.value; - case Kind.LIST_TYPE: - return this.typeNodesEqual(type1.type, (type2 as typeof type1).type); - case Kind.NON_NULL_TYPE: - return this.typeNodesEqual(type1.type, (type2 as typeof type1).type); - default: - return false; - } - } - - /** - * Helper function to convert TypeNode to human-readable string - */ - private static typeNodeToString(type: TypeNode): string { - switch (type.kind) { - case Kind.NAMED_TYPE: - return type.name.value; - case Kind.LIST_TYPE: - return `[${GrowingSchema.typeNodeToString(type.type)}]`; - case Kind.NON_NULL_TYPE: - return `${GrowingSchema.typeNodeToString(type.type)}!`; - default: - return "Unknown"; - } - } - - private newFieldDefinitionMatchesExistingFieldDefinition( - newFieldDef: FieldDefinitionNode, - existingFieldDef: Maybe - ): boolean { - if (!existingFieldDef) { - return false; - } - if (existingFieldDef.name.value !== newFieldDef.name.value) { - return false; - } - - // Check arguments - const newArgs = newFieldDef.arguments || []; - const existingArgs = existingFieldDef.arguments || []; - - // Check argument count - if (newArgs.length !== existingArgs.length) { - return false; - } - - // Check each argument by name and type - for (const newArg of newArgs) { - const existingArg = existingArgs.find( - (arg) => arg.name.value === newArg.name.value - ); - - if (!existingArg) { - return false; // Argument name not found - } - - // Check argument types - if (!this.typeNodesEqual(newArg.type, existingArg.type)) { - return false; - } - } - - // Check field return types - if (!this.typeNodesEqual(newFieldDef.type, existingFieldDef.type)) { - return false; - } - - return true; - } - - /** - * @todo handle existing field definition that doesn't match the new field definition - * We need to: - * - * - merge arguments - * - check return type - * - If the return type is different, we need to throw an error - */ - private mergeFieldDefinitions( - newFieldDef: FieldDefinitionNode, - existingFieldDef: Maybe, - parentTypeName: string - ): FieldDefinitionNode { - if (!existingFieldDef) { - return newFieldDef; - } - - if (!this.typeNodesEqual(newFieldDef.type, existingFieldDef.type)) { - const existingReturnTypeString = GrowingSchema.typeNodeToString( - existingFieldDef.type - ); - const newReturnTypeString = GrowingSchema.typeNodeToString( - newFieldDef.type - ); - throw new GraphQLError( - `Field \`${parentTypeName}.${newFieldDef.name.value}\` return type mismatch. Previously defined return type: \`${existingReturnTypeString}\`, new return type: \`${newReturnTypeString}\`` - ); - } - - const newArgs = newFieldDef.arguments || []; - const existingArgs = existingFieldDef.arguments || []; - const mergedArgs = [...existingArgs, ...newArgs]; - - return { - ...existingFieldDef, - arguments: mergedArgs, - }; - } - public toString() { return printSchema(this.schema); } From 86c44cb3a6b87875b47919fe076b9a2c2767c4d3 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 25 Aug 2025 12:22:53 +0200 Subject: [PATCH 14/29] keep old schema in case of error --- packages/ai/src/mocking/GrowingSchema.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts index 91553452c43..efd030bc916 100644 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -69,14 +69,19 @@ export class GrowingSchema { ) { const query = operation.query; - if (!this.seenQueries.has(query)) { - this.seenQueries.add(query); - this.mergeQueryIntoSchema(operation, response); - } - // @todo handle variables - // const variables = operation.variables; + const previousSchema = this.schema; + + try { + if (!this.seenQueries.has(query)) { + this.seenQueries.add(query); + this.mergeQueryIntoSchema(operation, response); + } - this.validateResponseAgainstSchema(query, operation, response); + this.validateResponseAgainstSchema(query, operation, response); + } catch (e) { + this.schema = previousSchema; + throw e; + } } public mergeQueryIntoSchema( @@ -88,6 +93,8 @@ export class GrowingSchema { ) { const query = operation.query; + // @todo handle variables + // const variables = operation.variables; const typeInfo = new TypeInfo(this.schema); const responsePath = [response.data]; From 0e71eb88cbb6d3336a096d001c5ca0e82deb1def Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Mon, 25 Aug 2025 12:24:37 +0200 Subject: [PATCH 15/29] track seenQueries later --- packages/ai/src/mocking/GrowingSchema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts index efd030bc916..1ab6142fa3d 100644 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -73,11 +73,11 @@ export class GrowingSchema { try { if (!this.seenQueries.has(query)) { - this.seenQueries.add(query); this.mergeQueryIntoSchema(operation, response); } this.validateResponseAgainstSchema(query, operation, response); + this.seenQueries.add(query); } catch (e) { this.schema = previousSchema; throw e; From 15b33ab05a1fc52c60e3b1f1936db0e395f547b5 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 25 Aug 2025 11:10:23 -0400 Subject: [PATCH 16/29] test(ai): update growing schema tests --- packages/ai/src/mocking/__tests__/GrowingSchema.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts index 88c1f808323..a5232e22291 100644 --- a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts @@ -223,7 +223,7 @@ describe("GrowingSchema", () => { expect(error).toBeInstanceOf(GraphQLError); expect(error?.message).toEqual( - "Field `Query.users` return type mismatch. Previously defined return type: `[User]`, new return type: `UserConnection`" + 'Error executing query against grown schema: Expected Iterable, but did not find one for field "Query.users".' ); }); From 3356c60c96ede4ba9e4d534623e6f5969e3d7f0c Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 25 Aug 2025 11:14:12 -0400 Subject: [PATCH 17/29] feat(ai): assign ID scalar to id fields --- packages/ai/src/mocking/GrowingSchema.ts | 13 +++++++------ .../ai/src/mocking/__tests__/GrowingSchema.test.ts | 10 +++++----- 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts index 1ab6142fa3d..756c1e4808e 100644 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -319,6 +319,7 @@ export class GrowingSchema { typename: string, type: GraphQLCompositeType ): FieldDefinitionNode { + const name = node.name.value; const args = this.getFieldArguments(node); // field not in schema @@ -341,7 +342,7 @@ export class GrowingSchema { } return { kind: Kind.FIELD_DEFINITION, - name: { kind: Kind.NAME, value: node.name.value }, + name: { kind: Kind.NAME, value: name }, type: fieldReturnType, arguments: args, }; @@ -350,7 +351,7 @@ export class GrowingSchema { let valueType: string; switch (typeof actualValue) { case "string": - valueType = "String"; + valueType = name === "id" ? "ID" : "String"; break; case "number": valueType = "Float"; @@ -360,14 +361,14 @@ export class GrowingSchema { break; default: throw new GraphQLError( - `Scalar responses are not supported for field ${ - node.name.value - } on type ${type.name} - received ${JSON.stringify(actualValue)}` + `Scalar responses are not supported for field ${name} on type ${ + type.name + } - received ${JSON.stringify(actualValue)}` ); } return { kind: Kind.FIELD_DEFINITION, - name: { kind: Kind.NAME, value: node.name.value }, + name: { kind: Kind.NAME, value: name }, type: { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: valueType }, diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts index a5232e22291..d86947d9eef 100644 --- a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts @@ -46,13 +46,13 @@ describe("GrowingSchema", () => { } type User { - id: String + id: ID name: String emails: [Email] } type Email { - id: String + id: ID kind: String value: String } @@ -118,14 +118,14 @@ describe("GrowingSchema", () => { } type User { - id: String + id: ID name: String emails: [Email] lastName: String } type Email { - id: String + id: ID kind: String value: String foo: Float @@ -161,7 +161,7 @@ describe("GrowingSchema", () => { } type User { - id: String + id: ID name: String } `; From dc3be40cf2236d4ce18171624a792cc2a8c17a1d Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Mon, 25 Aug 2025 22:51:40 -0400 Subject: [PATCH 18/29] feat(ai): support scalar variables in GrowingSchema --- packages/ai/src/mocking/GrowingSchema.ts | 143 ++++++++++++------ .../mocking/__tests__/GrowingSchema.test.ts | 108 ++++++++++++- 2 files changed, 205 insertions(+), 46 deletions(-) diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts index 756c1e4808e..d17feadf5ab 100644 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -1,4 +1,5 @@ import type { + ASTNode, DefinitionNode, DocumentNode, FieldDefinitionNode, @@ -7,6 +8,7 @@ import type { GraphQLCompositeType, InputValueDefinitionNode, TypeNode, + VariableDefinitionNode, } from "graphql"; import { execute, @@ -39,6 +41,10 @@ const enforcedRules = specifiedRules.filter( (rule) => !rulesToIgnore.includes(rule) ); +const isSingle = (item: T | readonly T[]): item is T => !Array.isArray(item); + +type OperationVariableDefinitions = Record; + export class GrowingSchema { public schema = new GraphQLSchema({ query: new GraphQLObjectType({ @@ -155,12 +161,16 @@ export class GrowingSchema { ); } + const operationVariableDefinitions = + this.getVariableDefinitionsFromAncestors(ancestors); + let newFieldDef = this.getFieldDefinition( node, isList, actualValue, typename, - type + type, + operationVariableDefinitions ); const existingFieldDef = typeInfo.getFieldDef()?.astNode; @@ -272,24 +282,68 @@ export class GrowingSchema { } } - private getFieldArguments(node: FieldNode): InputValueDefinitionNode[] { - // @todo we need to handle named input object arguments - // For now, we'll only handle build-in scalar arguments + private getVariableDefinitionsFromAncestors( + ancestors: readonly (ASTNode | readonly ASTNode[])[] + ): OperationVariableDefinitions { + const operationDefinition = ancestors.find( + (ancestor) => + isSingle(ancestor) && ancestor.kind === Kind.OPERATION_DEFINITION + ); + if (!operationDefinition) { + return {}; + } + return ( + operationDefinition.variableDefinitions?.reduce( + (acc, variable) => ({ + ...acc, + [variable.variable.name.value]: variable.type, + }), + {} + ) ?? {} + ); + } + + private getFieldArguments( + node: FieldNode, + operationVariableDefinitions: OperationVariableDefinitions + ): InputValueDefinitionNode[] { return ( node.arguments?.map((arg) => { - let valueType: string; + let valueType: TypeNode; switch (arg.value.kind) { + case Kind.VARIABLE: + const variableDefinition = + operationVariableDefinitions[arg.value.name.value]; + if (!variableDefinition) { + throw new GraphQLError( + `Variable \`${arg.value.name.value}\` is not defined` + ); + } + valueType = variableDefinition; + break; case Kind.STRING: - valueType = "String"; + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: "String" }, + }; break; case Kind.INT: - valueType = "Int"; + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: "Int" }, + }; break; case Kind.FLOAT: - valueType = "Float"; + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: "Float" }, + }; break; case Kind.BOOLEAN: - valueType = "Boolean"; + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: "Boolean" }, + }; break; default: throw new GraphQLError( @@ -303,10 +357,7 @@ export class GrowingSchema { return { kind: Kind.INPUT_VALUE_DEFINITION, name: arg.name, - type: { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: valueType }, - }, + type: valueType, }; }) ?? [] ); @@ -317,14 +368,15 @@ export class GrowingSchema { isList: boolean, actualValue: any, typename: string, - type: GraphQLCompositeType + type: GraphQLCompositeType, + operationVariableDefinitions: OperationVariableDefinitions ): FieldDefinitionNode { const name = node.name.value; - const args = this.getFieldArguments(node); + const args = this.getFieldArguments(node, operationVariableDefinitions); - // field not in schema + // Handle fields not in schema if (node.selectionSet) { - // either an object or a list type + // Handle object or list types if (!typename) { throw new GraphQLError( `Field ${node.name.value} on type ${type.name} is missing __typename in response data` @@ -346,35 +398,36 @@ export class GrowingSchema { type: fieldReturnType, arguments: args, }; - } else { - // scalar type - let valueType: string; - switch (typeof actualValue) { - case "string": - valueType = name === "id" ? "ID" : "String"; - break; - case "number": - valueType = "Float"; - break; - case "boolean": - valueType = "Boolean"; - break; - default: - throw new GraphQLError( - `Scalar responses are not supported for field ${name} on type ${ - type.name - } - received ${JSON.stringify(actualValue)}` - ); - } - return { - kind: Kind.FIELD_DEFINITION, - name: { kind: Kind.NAME, value: name }, - type: { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: valueType }, - }, - }; } + + // Handle scalar types + let valueType: string; + switch (typeof actualValue) { + case "string": + valueType = name === "id" ? "ID" : "String"; + break; + case "number": + valueType = "Float"; + break; + case "boolean": + valueType = "Boolean"; + break; + default: + throw new GraphQLError( + `Scalar responses are not supported for field ${name} on type ${ + type.name + } - received ${JSON.stringify(actualValue)}` + ); + } + return { + kind: Kind.FIELD_DEFINITION, + name: { kind: Kind.NAME, value: name }, + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: valueType }, + }, + arguments: args, + }; } public toString() { diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts index d86947d9eef..96ec74482a3 100644 --- a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts @@ -227,7 +227,7 @@ describe("GrowingSchema", () => { ); }); - it.skip("handles variables", () => { + it("handles scalar variables", () => { const query = gql` query Search($bookId: ID!, $arg: String!) { book(id: $bookId) { @@ -252,6 +252,112 @@ describe("GrowingSchema", () => { }; const expectedSchema = /* GraphQL */ ` type Query { + book(id: ID!): Book + } + + type Book { + title: String + anotherField(arg: String!): Boolean + } + `; + + const schema = new GrowingSchema(); + schema.add({ query, variables }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it.skip("handles input object variables", () => { + const query = gql` + query SearchByAuthor($author: AuthorInput!, $arg: SomeArgInput!) { + bookByAuthor(author: $author) { + __typename + title + anotherField(arg: $arg) + } + } + `; + const variables = { + author: { + name: "John Smith", + }, + arg: { + foo: "bar", + }, + }; + const response = { + data: { + bookByAuthor: { + __typename: "Book", + title: "Moby Dick", + anotherField: true, + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(author: AuthorInput!): Book + } + + input AuthorInput { + name: String + } + + type Book { + title: String + anotherField(arg: SomeArgInput!): Boolean + } + + input SomeArgInput { + foo: String + } + `; + + const schema = new GrowingSchema(); + schema.add({ query, variables }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it.skip("handles nested input object variables", () => { + const query = gql` + query SearchByAuthor($author: AuthorInput!) { + bookByAuthor(author: $author) { + __typename + title + } + } + `; + const variables = { + author: { + name: { + firstName: "John", + lastName: "Smith", + }, + }, + }; + const response = { + data: { + bookByAuthor: { + __typename: "Book", + title: "Moby Dick", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(author: AuthorInput!): Book + } + + input AuthorInput { + name: NameInput + } + + input NameInput { + firstName: String + lastName: String + } + + type Book { + title: String } `; From 9674c916ed001811bc6aa70fa74d4f99e2bd8c14 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 29 Aug 2025 10:02:57 -0400 Subject: [PATCH 19/29] feat(ai): support nested input object variables --- packages/ai/src/mocking/GrowingSchema.ts | 169 ++++++++++++++++-- .../mocking/__tests__/GrowingSchema.test.ts | 24 ++- 2 files changed, 177 insertions(+), 16 deletions(-) diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts index d17feadf5ab..056d89f9707 100644 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -6,7 +6,10 @@ import type { FieldNode, FormattedExecutionResult, GraphQLCompositeType, + InputObjectTypeDefinitionNode, + InputObjectTypeExtensionNode, InputValueDefinitionNode, + NamedTypeNode, TypeNode, VariableDefinitionNode, } from "graphql"; @@ -28,14 +31,7 @@ import { import type { AIAdapter } from "./AIAdapter.js"; -const rulesToIgnore = [ - FieldsOnCorrectTypeRule, - // KnownArgumentNamesOnDirectivesRule, - // KnownArgumentNamesRule, - // KnownDirectivesRule, - // KnownFragmentNamesRule, - // KnownTypeNamesRule, -]; +const rulesToIgnore = [FieldsOnCorrectTypeRule]; const enforcedRules = specifiedRules.filter( (rule) => !rulesToIgnore.includes(rule) @@ -43,7 +39,29 @@ const enforcedRules = specifiedRules.filter( const isSingle = (item: T | readonly T[]): item is T => !Array.isArray(item); -type OperationVariableDefinitions = Record; +const getLeafType = (typeNode: TypeNode): NamedTypeNode => { + return typeNode.kind === Kind.NAMED_TYPE ? + typeNode + : getLeafType(typeNode.type); +}; + +const ucFirst = (str: string) => { + if (!str) { + return ""; + } + return str.charAt(0).toUpperCase() + str.slice(1); +}; + +function isFloat(num: number) { + return typeof num === "number" && !Number.isInteger(num); +} + +const ScalarTypes = ["String", "Int", "Float", "Boolean", "ID"]; + +export type OperationVariableDefinitions = Record; + +type InputObject = InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode; +type InputObjectsList = InputObject[]; export class GrowingSchema { public schema = new GraphQLSchema({ @@ -100,7 +118,7 @@ export class GrowingSchema { const query = operation.query; // @todo handle variables - // const variables = operation.variables; + const variables = operation.variables; const typeInfo = new TypeInfo(this.schema); const responsePath = [response.data]; @@ -139,6 +157,47 @@ export class GrowingSchema { return this.schema; }; + const variableDefinitions: VariableDefinitionNode[] = []; + + query.definitions.forEach((definition) => { + if (definition.kind !== Kind.OPERATION_DEFINITION) { + return; + } + variableDefinitions.push(...(definition.variableDefinitions ?? [])); + }); + + // Create all input objects from the operation's variable definitions. + // By doing this here, we _may_ create unused input objects, but this + // helps us avoid complexity in tying input objects to field definitions. + const inputObjects = + variableDefinitions.reduce( + (acc, variableDefinition) => { + const leafType = getLeafType(variableDefinition.type); + const relatedVariable = + variables?.[variableDefinition.variable.name.value]; + + if (!relatedVariable) { + throw new GraphQLError( + `Variable \`${variableDefinition.variable.name.value}\` is not defined` + ); + } + + if (!ScalarTypes.includes(leafType.name.value)) { + // Create the input object for this variable and any other + // input objects from its fields. + const inputObjects = this.getInputObjectsForVariableValue( + leafType.name.value, + relatedVariable + ); + acc.push(...inputObjects); + } + return acc; + }, + [] as (InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode)[] + ) || []; + + accumulatedExtensions.definitions.push(...inputObjects); + visit( query, visitWithTypeInfo(typeInfo, { @@ -430,6 +489,96 @@ export class GrowingSchema { }; } + private getInputObjectsForVariableValue( + name: string, + variableValue: any + ): InputObjectsList { + const { fields, inputObjects } = + this.getInputValueDefinitionsFromVariables(variableValue); + // Return this input object and any other input objects + // created from its fields. + return [ + { + kind: Kind.INPUT_OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: name }, + fields, + }, + ...inputObjects, + ]; + } + + getInputValueDefinitionsFromVariables(valuesInScope: any): { + fields: InputValueDefinitionNode[]; + inputObjects: InputObjectsList; + } { + const inputObjects: InputObjectsList = []; + const fields = Object.entries(valuesInScope).map( + ([fieldName, fieldVariableValue]) => { + let valueType: TypeNode; + switch (typeof fieldVariableValue) { + case "object": + if (fieldVariableValue === null) { + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: "String" }, + }; + } else { + // If a variable field is a key/value object, then it is + // an input object and we need to create it and any other + // input objects from its fields. + const inputObjectName = `${ucFirst(fieldName)}Input`; + const inputObject = this.getInputObjectsForVariableValue( + inputObjectName, + fieldVariableValue + ); + inputObjects.push(...inputObject); + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: inputObjectName }, + }; + } + break; + case "string": + valueType = { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: fieldName === "id" ? "ID" : "String", + }, + }; + break; + case "number": + valueType = { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: isFloat(fieldVariableValue) ? "Float" : "Int", + }, + }; + break; + case "boolean": + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: "Boolean" }, + }; + break; + default: + throw new GraphQLError( + `Scalar responses are not supported for field ${fieldName} - received ${JSON.stringify( + fieldVariableValue + )}` + ); + } + return { + kind: Kind.INPUT_VALUE_DEFINITION, + name: { kind: Kind.NAME, value: fieldName }, + type: valueType, + } as InputValueDefinitionNode; + } + ); + return { fields, inputObjects }; + } + public toString() { return printSchema(this.schema); } diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts index 96ec74482a3..45605f12ca3 100644 --- a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts @@ -266,7 +266,7 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it.skip("handles input object variables", () => { + it("handles input object variables", () => { const query = gql` query SearchByAuthor($author: AuthorInput!, $arg: SomeArgInput!) { bookByAuthor(author: $author) { @@ -302,14 +302,14 @@ describe("GrowingSchema", () => { name: String } + input SomeArgInput { + foo: String + } + type Book { title: String anotherField(arg: SomeArgInput!): Boolean } - - input SomeArgInput { - foo: String - } `; const schema = new GrowingSchema(); @@ -317,7 +317,7 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it.skip("handles nested input object variables", () => { + it("handles nested input object variables", () => { const query = gql` query SearchByAuthor($author: AuthorInput!) { bookByAuthor(author: $author) { @@ -331,6 +331,11 @@ describe("GrowingSchema", () => { name: { firstName: "John", lastName: "Smith", + nickName: { + full: "The Doctor", + short: "Dr.", + }, + age: 2000, }, }, }; @@ -354,6 +359,13 @@ describe("GrowingSchema", () => { input NameInput { firstName: String lastName: String + nickName: NickNameInput + age: Int + } + + input NickNameInput { + full: String + short: String } type Book { From dfce928035d5f0c256eb5024a8208f3a53ea0890 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 29 Aug 2025 10:48:59 -0400 Subject: [PATCH 20/29] feat(ai): support repeated input objects --- packages/ai/src/mocking/GrowingSchema.ts | 88 ++++++--- .../mocking/__tests__/GrowingSchema.test.ts | 184 +++++++++++++++++- 2 files changed, 240 insertions(+), 32 deletions(-) diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts index 056d89f9707..5a6d3ca90e5 100644 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -18,6 +18,7 @@ import { extendSchema, FieldsOnCorrectTypeRule, GraphQLError, + GraphQLInputObjectType, GraphQLObjectType, GraphQLSchema, Kind, @@ -60,6 +61,11 @@ const ScalarTypes = ["String", "Int", "Float", "Boolean", "ID"]; export type OperationVariableDefinitions = Record; +type GraphQLOperation = { + query: DocumentNode; + variables?: Record; +}; + type InputObject = InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode; type InputObjectsList = InputObject[]; @@ -71,7 +77,16 @@ export class GrowingSchema { }), }); - private seenQueries = new WeakSet(); + // We need to track the seen queries with their variables to + // accommodate changes to the input objects defined via the + // variables. + // + // This will likely result in extra schema building attempts + // that are mostly "skipped" but are necessary to ensure that + // the input objects are correct. + private seenQueries = new WeakSet(); + + private seenInputObjects: Record = {}; public validateQuery(query: DocumentNode) { const errors = validate(this.schema, query, enforcedRules); @@ -84,24 +99,27 @@ export class GrowingSchema { } } - public add( - operation: { - query: DocumentNode; - variables?: Record; - }, - response: AIAdapter.Result - ) { - const query = operation.query; - + public add(operation: GraphQLOperation, response: AIAdapter.Result) { const previousSchema = this.schema; try { - if (!this.seenQueries.has(query)) { + if (!this.seenQueries.has(operation)) { this.mergeQueryIntoSchema(operation, response); } - this.validateResponseAgainstSchema(query, operation, response); - this.seenQueries.add(query); + this.validateResponseAgainstSchema(operation, response); + this.seenQueries.add(operation); + + // Track the fields of each input object so we can avoid + // creating duplicate input objects. + Object.entries(this.schema.getTypeMap()).forEach(([name, node]) => { + if (node instanceof GraphQLInputObjectType) { + this.seenInputObjects[name] = + node.astNode?.fields?.map((field) => { + return field.name.value; + }) || []; + } + }); } catch (e) { this.schema = previousSchema; throw e; @@ -109,10 +127,7 @@ export class GrowingSchema { } public mergeQueryIntoSchema( - operation: { - query: DocumentNode; - variables?: Record; - }, + operation: GraphQLOperation, response: AIAdapter.Result ) { const query = operation.query; @@ -299,13 +314,12 @@ export class GrowingSchema { } private validateResponseAgainstSchema( - query: DocumentNode, - operation: { query: DocumentNode; variables?: Record }, + operation: GraphQLOperation, response: FormattedExecutionResult, Record> ) { const result = execute({ schema: this.schema, - document: query, + document: operation.query, variableValues: operation.variables, fieldResolver: (source, args, context, info) => { const value = source[info.fieldName]; @@ -333,8 +347,13 @@ export class GrowingSchema { }) as FormattedExecutionResult; if (result.errors?.length) { + const operationName = operation.query.definitions.find( + (def) => def.kind === Kind.OPERATION_DEFINITION + )?.name?.value; throw new GraphQLError( - `Error executing query against grown schema: ${result.errors + `Error executing query \`${ + operationName ? operationName : "unnamed query" + }\` against grown schema: ${result.errors .map((e) => e.message) .join(", ")}` ); @@ -493,13 +512,18 @@ export class GrowingSchema { name: string, variableValue: any ): InputObjectsList { - const { fields, inputObjects } = - this.getInputValueDefinitionsFromVariables(variableValue); + const { fields, inputObjects } = this.getInputValueDefinitionsFromVariables( + name, + variableValue + ); // Return this input object and any other input objects // created from its fields. return [ { - kind: Kind.INPUT_OBJECT_TYPE_DEFINITION, + kind: + this.seenInputObjects[name] ? + Kind.INPUT_OBJECT_TYPE_EXTENSION + : Kind.INPUT_OBJECT_TYPE_DEFINITION, name: { kind: Kind.NAME, value: name }, fields, }, @@ -507,13 +531,16 @@ export class GrowingSchema { ]; } - getInputValueDefinitionsFromVariables(valuesInScope: any): { + getInputValueDefinitionsFromVariables( + inputObjectName: string, + valuesInScope: any + ): { fields: InputValueDefinitionNode[]; inputObjects: InputObjectsList; } { const inputObjects: InputObjectsList = []; - const fields = Object.entries(valuesInScope).map( - ([fieldName, fieldVariableValue]) => { + const fields = Object.entries(valuesInScope) + .map(([fieldName, fieldVariableValue]) => { let valueType: TypeNode; switch (typeof fieldVariableValue) { case "object": @@ -574,8 +601,11 @@ export class GrowingSchema { name: { kind: Kind.NAME, value: fieldName }, type: valueType, } as InputValueDefinitionNode; - } - ); + }) + .filter( + (field) => + !this.seenInputObjects[inputObjectName]?.includes(field.name.value) + ); return { fields, inputObjects }; } diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts index 45605f12ca3..c955510a52a 100644 --- a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts @@ -1,6 +1,7 @@ import { gql } from "@apollo/client"; import { GrowingSchema } from "../GrowingSchema.js"; import { GraphQLError } from "graphql"; +import { first } from "rxjs"; describe("GrowingSchema", () => { it("creates a base schema when instantiated", () => { @@ -223,7 +224,7 @@ describe("GrowingSchema", () => { expect(error).toBeInstanceOf(GraphQLError); expect(error?.message).toEqual( - 'Error executing query against grown schema: Expected Iterable, but did not find one for field "Query.users".' + 'Error executing query `GetUser2` against grown schema: Expected Iterable, but did not find one for field "Query.users".' ); }); @@ -288,7 +289,7 @@ describe("GrowingSchema", () => { data: { bookByAuthor: { __typename: "Book", - title: "Moby Dick", + title: "The Tardis", anotherField: true, }, }, @@ -343,7 +344,70 @@ describe("GrowingSchema", () => { data: { bookByAuthor: { __typename: "Book", - title: "Moby Dick", + title: "The Tardis", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(author: AuthorInput!): Book + } + + input AuthorInput { + name: NameInput + } + + input NameInput { + firstName: String + lastName: String + nickName: NickNameInput + age: Int + } + + input NickNameInput { + full: String + short: String + } + + type Book { + title: String + } + `; + + const schema = new GrowingSchema(); + schema.add({ query, variables }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles repeated input object variables for a single query", () => { + const query = gql` + query SearchByAuthor($author: AuthorInput!) { + bookByAuthor(author: $author) { + __typename + title + anotherField(author: $author) + } + } + `; + const variables = { + author: { + name: { + firstName: "John", + lastName: "Smith", + nickName: { + full: "The Doctor", + short: "Dr.", + }, + age: 2000, + }, + }, + }; + const response = { + data: { + bookByAuthor: { + __typename: "Book", + title: "The Tardis", + anotherField: true, }, }, }; @@ -370,6 +434,7 @@ describe("GrowingSchema", () => { type Book { title: String + anotherField(author: AuthorInput!): Boolean } `; @@ -378,6 +443,119 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); + it("handles repeated input object variables across multiple queries", () => { + const firstQuery = gql` + query SearchByAuthor($author: AuthorInput!) { + bookByAuthor(author: $author) { + __typename + title + } + } + `; + const firstVariables = { + author: { + name: { + nickName: { + full: "The Doctor", + }, + }, + }, + }; + const firstResponse = { + data: { + bookByAuthor: { + __typename: "Book", + title: "The Tardis", + }, + }, + }; + const firstExpectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(author: AuthorInput!): Book + } + + input AuthorInput { + name: NameInput + } + + input NameInput { + nickName: NickNameInput + } + + input NickNameInput { + full: String + } + + type Book { + title: String + } + `; + const secondQuery = gql` + query SearchByAuthor($author: AuthorInput!) { + bookByAuthor(author: $author) { + __typename + title + } + } + `; + const secondVariables = { + author: { + name: { + firstName: "John", + lastName: "Smith", + nickName: { + short: "Dr.", + }, + }, + }, + }; + const secondResponse = { + data: { + bookByAuthor: { + __typename: "Book", + title: "The Tardis", + }, + }, + }; + const secondExpectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(author: AuthorInput!): Book + } + + input AuthorInput { + name: NameInput + } + + input NameInput { + nickName: NickNameInput + firstName: String + lastName: String + } + + input NickNameInput { + full: String + short: String + } + + type Book { + title: String + } + `; + + const schema = new GrowingSchema(); + schema.add( + { query: firstQuery, variables: firstVariables }, + firstResponse + ); + expect(schema.toString()).toEqualIgnoringWhitespace(firstExpectedSchema); + + schema.add( + { query: secondQuery, variables: secondVariables }, + secondResponse + ); + expect(schema.toString()).toEqualIgnoringWhitespace(secondExpectedSchema); + }); + it.skip("handles union types with inline fragments", () => { const query = gql` query Search { From ae672167645b87f568d2c1a925853d0fdd28d4bc Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 29 Aug 2025 10:53:37 -0400 Subject: [PATCH 21/29] feat(ai): support nullable variables --- packages/ai/src/mocking/GrowingSchema.ts | 3 +-- packages/ai/src/mocking/__tests__/GrowingSchema.test.ts | 9 +++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts index 5a6d3ca90e5..131d75e4e1d 100644 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -132,7 +132,6 @@ export class GrowingSchema { ) { const query = operation.query; - // @todo handle variables const variables = operation.variables; const typeInfo = new TypeInfo(this.schema); const responsePath = [response.data]; @@ -191,7 +190,7 @@ export class GrowingSchema { const relatedVariable = variables?.[variableDefinition.variable.name.value]; - if (!relatedVariable) { + if (relatedVariable === undefined) { throw new GraphQLError( `Variable \`${variableDefinition.variable.name.value}\` is not defined` ); diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts index c955510a52a..b199bb280e7 100644 --- a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts @@ -230,17 +230,18 @@ describe("GrowingSchema", () => { it("handles scalar variables", () => { const query = gql` - query Search($bookId: ID!, $arg: String!) { + query Search($bookId: ID!, $arg: String!, $nullable: String) { book(id: $bookId) { __typename title - anotherField(arg: $arg) + anotherField(arg: $arg, nullable: $nullable) } } `; const variables = { bookId: "ASDF", arg: "QWERTY", + nullable: null, }; const response = { data: { @@ -258,7 +259,7 @@ describe("GrowingSchema", () => { type Book { title: String - anotherField(arg: String!): Boolean + anotherField(arg: String!, nullable: String): Boolean } `; @@ -282,7 +283,7 @@ describe("GrowingSchema", () => { name: "John Smith", }, arg: { - foo: "bar", + foo: null, }, }; const response = { From 09738e8e7d5fe2fd16daef0204793bde8a15e451 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 29 Aug 2025 10:54:01 -0400 Subject: [PATCH 22/29] test(ai): remove unused import --- packages/ai/src/mocking/__tests__/GrowingSchema.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts index b199bb280e7..c7c4d9a0c76 100644 --- a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts @@ -1,7 +1,6 @@ import { gql } from "@apollo/client"; import { GrowingSchema } from "../GrowingSchema.js"; import { GraphQLError } from "graphql"; -import { first } from "rxjs"; describe("GrowingSchema", () => { it("creates a base schema when instantiated", () => { From 3cbe6a69fe8c6ebefa352acef59d55146b52622e Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 29 Aug 2025 13:48:08 -0400 Subject: [PATCH 23/29] feat(ai): add support for list variables --- packages/ai/src/mocking/GrowingSchema.ts | 172 ++++++++++++++++-- .../mocking/__tests__/GrowingSchema.test.ts | 67 +++++++ 2 files changed, 227 insertions(+), 12 deletions(-) diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts index 131d75e4e1d..3ae327bf600 100644 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -38,14 +38,32 @@ const enforcedRules = specifiedRules.filter( (rule) => !rulesToIgnore.includes(rule) ); +/** + * Type guard for checking if an item is a single item. + * + * @param item - The item to check. + * @returns True if the item is a single item, false otherwise. + */ const isSingle = (item: T | readonly T[]): item is T => !Array.isArray(item); +/** + * Get the leaf type of a type node. + * + * @param typeNode - The type node to get the leaf type of. + * @returns The leaf type of the type node. + */ const getLeafType = (typeNode: TypeNode): NamedTypeNode => { return typeNode.kind === Kind.NAMED_TYPE ? typeNode : getLeafType(typeNode.type); }; +/** + * Convert the first letter of a string to uppercase. + * + * @param str - The string to convert. + * @returns The string with the first letter capitalized. + */ const ucFirst = (str: string) => { if (!str) { return ""; @@ -53,10 +71,87 @@ const ucFirst = (str: string) => { return str.charAt(0).toUpperCase() + str.slice(1); }; +/** + * Convert a plural word to its singular form. + * + * @param str - The plural word to convert. + * @returns The singular form of the word. + */ +const singularize = (str: string) => { + if (!str) { + return ""; + } + + // Handle common pluralization patterns + if (str.endsWith("ies")) { + return str.slice(0, -3) + "y"; + } else if (str.endsWith("ves")) { + return str.slice(0, -3) + "f"; + } else if (str.endsWith("es")) { + // Special cases for -es endings + if (str.endsWith("ches") || str.endsWith("shes") || str.endsWith("xes")) { + return str.slice(0, -2); + } else if (str.endsWith("ses")) { + return str.slice(0, -2); + } else { + return str.slice(0, -1); + } + } else if (str.endsWith("s") && str.length > 1) { + return str.slice(0, -1); + } + + return str; +}; + +/** + * Check if a number is a float (i.e. 9.5). + * + * @param num - The number to check. + * @returns True if the number is a float, false otherwise. + */ function isFloat(num: number) { return typeof num === "number" && !Number.isInteger(num); } +/** + * Deep merge utility function to preserve nested properties. + * + * @param target - The target object to merge into. + * @param source - The source object to merge from. + * @returns The merged object. + */ +function deepMerge(target: any, source: any): any { + if (source === null || typeof source !== "object") { + return source; + } + + if (Array.isArray(source)) { + return source; + } + + if (target === null || typeof target !== "object" || Array.isArray(target)) { + target = {}; + } + + const result = { ...target }; + + for (const key in source) { + if (source.hasOwnProperty(key)) { + if ( + typeof source[key] === "object" && + source[key] !== null && + !Array.isArray(source[key]) + ) { + result[key] = deepMerge(result[key], source[key]); + } else { + result[key] = source[key]; + } + } + } + + return result; +} + const ScalarTypes = ["String", "Int", "Float", "Boolean", "ID"]; export type OperationVariableDefinitions = Record; @@ -183,7 +278,7 @@ export class GrowingSchema { // Create all input objects from the operation's variable definitions. // By doing this here, we _may_ create unused input objects, but this // helps us avoid complexity in tying input objects to field definitions. - const inputObjects = + const inputObjects = this.mergeRepeatedInputObjects( variableDefinitions.reduce( (acc, variableDefinition) => { const leafType = getLeafType(variableDefinition.type); @@ -208,7 +303,8 @@ export class GrowingSchema { return acc; }, [] as (InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode)[] - ) || []; + ) || [] + ); accumulatedExtensions.definitions.push(...inputObjects); @@ -538,7 +634,15 @@ export class GrowingSchema { inputObjects: InputObjectsList; } { const inputObjects: InputObjectsList = []; - const fields = Object.entries(valuesInScope) + + let valuesToHandle = valuesInScope; + if (Array.isArray(valuesInScope)) { + valuesToHandle = valuesInScope.reduce((acc, item) => { + return deepMerge(acc, item); + }, {}); + } + + const fields = Object.entries(valuesToHandle) .map(([fieldName, fieldVariableValue]) => { let valueType: TypeNode; switch (typeof fieldVariableValue) { @@ -549,19 +653,40 @@ export class GrowingSchema { name: { kind: Kind.NAME, value: "String" }, }; } else { - // If a variable field is a key/value object, then it is - // an input object and we need to create it and any other - // input objects from its fields. - const inputObjectName = `${ucFirst(fieldName)}Input`; - const inputObject = this.getInputObjectsForVariableValue( - inputObjectName, - fieldVariableValue - ); - inputObjects.push(...inputObject); + // Create a name for the input object based on the singular + // form of the field name + "Input". + const inputObjectName = `${ucFirst(singularize(fieldName))}Input`; + + // Create a type node for the input object. valueType = { kind: Kind.NAMED_TYPE, name: { kind: Kind.NAME, value: inputObjectName }, }; + + // If the field value is an array, then we need to create a list + // type node for the input object and merge the array items + // into a single object for creating the input object. + let variableValueToHandle = fieldVariableValue; + if (Array.isArray(fieldVariableValue)) { + valueType = { + kind: Kind.LIST_TYPE, + type: valueType, + }; + variableValueToHandle = fieldVariableValue.reduce( + (acc, item) => { + return deepMerge(acc, item); + }, + {} + ); + } + + // Create the input object and any other input objects from its + // fields. + const inputObject = this.getInputObjectsForVariableValue( + inputObjectName, + variableValueToHandle + ); + inputObjects.push(...inputObject); } break; case "string": @@ -608,6 +733,29 @@ export class GrowingSchema { return { fields, inputObjects }; } + mergeRepeatedInputObjects(inputObjects: InputObjectsList): InputObjectsList { + return Object.values( + inputObjects.reduce( + (acc, inputObject) => { + const existingInputObject = acc[inputObject.name.value]; + if (existingInputObject) { + acc[inputObject.name.value] = { + ...existingInputObject, + fields: [ + ...(existingInputObject?.fields || []), + ...(inputObject.fields || []), + ], + }; + } else { + acc[inputObject.name.value] = inputObject; + } + return acc; + }, + {} as Record + ) + ); + } + public toString() { return printSchema(this.schema); } diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts index c7c4d9a0c76..f764123bb6b 100644 --- a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts @@ -556,6 +556,73 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(secondExpectedSchema); }); + it("handles list variables", () => { + const query = gql` + query SearchByAuthor($authors: [AuthorInput!]!) { + bookByAuthor(authors: $authors) { + __typename + title + } + } + `; + const variables = { + authors: [ + { + name: { + nickNames: [ + { + full: "The Doctor", + }, + ], + }, + }, + { + name: { + firstName: "Sarah", + middleName: "Jane", + lastName: "Smith", + }, + }, + ], + }; + const response = { + data: { + bookByAuthor: { + __typename: "Book", + title: "The Tardis", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + bookByAuthor(authors: [AuthorInput!]!): Book + } + + input AuthorInput { + name: NameInput + } + + input NameInput { + nickNames: [NickNameInput] + firstName: String + middleName: String + lastName: String + } + + input NickNameInput { + full: String + } + + type Book { + title: String + } + `; + + const schema = new GrowingSchema(); + schema.add({ query, variables }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + it.skip("handles union types with inline fragments", () => { const query = gql` query Search { From a6ca40f38172f2d28277d6658d0675146a28bfef Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 3 Sep 2025 09:13:13 -0400 Subject: [PATCH 24/29] refactor(ai): remove seenInputObjects --- packages/ai/src/mocking/GrowingSchema.ts | 31 ++++++++++++------------ 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts index 3ae327bf600..c197c9b99a2 100644 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema.ts @@ -6,6 +6,7 @@ import type { FieldNode, FormattedExecutionResult, GraphQLCompositeType, + GraphQLNamedType, InputObjectTypeDefinitionNode, InputObjectTypeExtensionNode, InputValueDefinitionNode, @@ -181,8 +182,6 @@ export class GrowingSchema { // the input objects are correct. private seenQueries = new WeakSet(); - private seenInputObjects: Record = {}; - public validateQuery(query: DocumentNode) { const errors = validate(this.schema, query, enforcedRules); if (errors.length > 0) { @@ -204,17 +203,6 @@ export class GrowingSchema { this.validateResponseAgainstSchema(operation, response); this.seenQueries.add(operation); - - // Track the fields of each input object so we can avoid - // creating duplicate input objects. - Object.entries(this.schema.getTypeMap()).forEach(([name, node]) => { - if (node instanceof GraphQLInputObjectType) { - this.seenInputObjects[name] = - node.astNode?.fields?.map((field) => { - return field.name.value; - }) || []; - } - }); } catch (e) { this.schema = previousSchema; throw e; @@ -455,6 +443,13 @@ export class GrowingSchema { } } + private getType(name: string): T | undefined { + if (!name) { + return undefined; + } + return this.schema.getType(name) as T; + } + private getVariableDefinitionsFromAncestors( ancestors: readonly (ASTNode | readonly ASTNode[])[] ): OperationVariableDefinitions { @@ -616,7 +611,7 @@ export class GrowingSchema { return [ { kind: - this.seenInputObjects[name] ? + this.getType(name) ? Kind.INPUT_OBJECT_TYPE_EXTENSION : Kind.INPUT_OBJECT_TYPE_DEFINITION, name: { kind: Kind.NAME, value: name }, @@ -633,6 +628,11 @@ export class GrowingSchema { fields: InputValueDefinitionNode[]; inputObjects: InputObjectsList; } { + const existingInputObject = + this.getType(inputObjectName); + const existingInputObjectFields = + existingInputObject?.astNode?.fields?.map((field) => field.name.value) || + []; const inputObjects: InputObjectsList = []; let valuesToHandle = valuesInScope; @@ -727,8 +727,7 @@ export class GrowingSchema { } as InputValueDefinitionNode; }) .filter( - (field) => - !this.seenInputObjects[inputObjectName]?.includes(field.name.value) + (field) => !existingInputObjectFields?.includes(field.name.value) ); return { fields, inputObjects }; } From 977172f49190995296763ff2a800e0aebf15225a Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Wed, 10 Sep 2025 20:32:40 -0400 Subject: [PATCH 25/29] feat(ai): rewrite GrowingSchema to create complex schemas --- packages/ai/src/mocking/GrowingSchema.ts | 761 ---------- .../mocking/GrowingSchema/GrowingSchema.ts | 504 +++++++ .../mocking/GrowingSchema/OperationSchema.ts | 1304 +++++++++++++++++ .../__tests__/GrowingSchema.test.ts | 462 ++++-- packages/ai/src/utils.ts | 311 ++++ 5 files changed, 2473 insertions(+), 869 deletions(-) delete mode 100644 packages/ai/src/mocking/GrowingSchema.ts create mode 100644 packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts create mode 100644 packages/ai/src/mocking/GrowingSchema/OperationSchema.ts rename packages/ai/src/mocking/{ => GrowingSchema}/__tests__/GrowingSchema.test.ts (71%) create mode 100644 packages/ai/src/utils.ts diff --git a/packages/ai/src/mocking/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema.ts deleted file mode 100644 index c197c9b99a2..00000000000 --- a/packages/ai/src/mocking/GrowingSchema.ts +++ /dev/null @@ -1,761 +0,0 @@ -import type { - ASTNode, - DefinitionNode, - DocumentNode, - FieldDefinitionNode, - FieldNode, - FormattedExecutionResult, - GraphQLCompositeType, - GraphQLNamedType, - InputObjectTypeDefinitionNode, - InputObjectTypeExtensionNode, - InputValueDefinitionNode, - NamedTypeNode, - TypeNode, - VariableDefinitionNode, -} from "graphql"; -import { - execute, - extendSchema, - FieldsOnCorrectTypeRule, - GraphQLError, - GraphQLInputObjectType, - GraphQLObjectType, - GraphQLSchema, - Kind, - printSchema, - specifiedRules, - TypeInfo, - validate, - visit, - visitWithTypeInfo, -} from "graphql"; - -import type { AIAdapter } from "./AIAdapter.js"; - -const rulesToIgnore = [FieldsOnCorrectTypeRule]; - -const enforcedRules = specifiedRules.filter( - (rule) => !rulesToIgnore.includes(rule) -); - -/** - * Type guard for checking if an item is a single item. - * - * @param item - The item to check. - * @returns True if the item is a single item, false otherwise. - */ -const isSingle = (item: T | readonly T[]): item is T => !Array.isArray(item); - -/** - * Get the leaf type of a type node. - * - * @param typeNode - The type node to get the leaf type of. - * @returns The leaf type of the type node. - */ -const getLeafType = (typeNode: TypeNode): NamedTypeNode => { - return typeNode.kind === Kind.NAMED_TYPE ? - typeNode - : getLeafType(typeNode.type); -}; - -/** - * Convert the first letter of a string to uppercase. - * - * @param str - The string to convert. - * @returns The string with the first letter capitalized. - */ -const ucFirst = (str: string) => { - if (!str) { - return ""; - } - return str.charAt(0).toUpperCase() + str.slice(1); -}; - -/** - * Convert a plural word to its singular form. - * - * @param str - The plural word to convert. - * @returns The singular form of the word. - */ -const singularize = (str: string) => { - if (!str) { - return ""; - } - - // Handle common pluralization patterns - if (str.endsWith("ies")) { - return str.slice(0, -3) + "y"; - } else if (str.endsWith("ves")) { - return str.slice(0, -3) + "f"; - } else if (str.endsWith("es")) { - // Special cases for -es endings - if (str.endsWith("ches") || str.endsWith("shes") || str.endsWith("xes")) { - return str.slice(0, -2); - } else if (str.endsWith("ses")) { - return str.slice(0, -2); - } else { - return str.slice(0, -1); - } - } else if (str.endsWith("s") && str.length > 1) { - return str.slice(0, -1); - } - - return str; -}; - -/** - * Check if a number is a float (i.e. 9.5). - * - * @param num - The number to check. - * @returns True if the number is a float, false otherwise. - */ -function isFloat(num: number) { - return typeof num === "number" && !Number.isInteger(num); -} - -/** - * Deep merge utility function to preserve nested properties. - * - * @param target - The target object to merge into. - * @param source - The source object to merge from. - * @returns The merged object. - */ -function deepMerge(target: any, source: any): any { - if (source === null || typeof source !== "object") { - return source; - } - - if (Array.isArray(source)) { - return source; - } - - if (target === null || typeof target !== "object" || Array.isArray(target)) { - target = {}; - } - - const result = { ...target }; - - for (const key in source) { - if (source.hasOwnProperty(key)) { - if ( - typeof source[key] === "object" && - source[key] !== null && - !Array.isArray(source[key]) - ) { - result[key] = deepMerge(result[key], source[key]); - } else { - result[key] = source[key]; - } - } - } - - return result; -} - -const ScalarTypes = ["String", "Int", "Float", "Boolean", "ID"]; - -export type OperationVariableDefinitions = Record; - -type GraphQLOperation = { - query: DocumentNode; - variables?: Record; -}; - -type InputObject = InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode; -type InputObjectsList = InputObject[]; - -export class GrowingSchema { - public schema = new GraphQLSchema({ - query: new GraphQLObjectType({ - name: "Query", - fields: {}, - }), - }); - - // We need to track the seen queries with their variables to - // accommodate changes to the input objects defined via the - // variables. - // - // This will likely result in extra schema building attempts - // that are mostly "skipped" but are necessary to ensure that - // the input objects are correct. - private seenQueries = new WeakSet(); - - public validateQuery(query: DocumentNode) { - const errors = validate(this.schema, query, enforcedRules); - if (errors.length > 0) { - throw new Error( - `Query is inconsistent with existing schema: ${errors - .map((e) => e.message) - .join(", ")}` - ); - } - } - - public add(operation: GraphQLOperation, response: AIAdapter.Result) { - const previousSchema = this.schema; - - try { - if (!this.seenQueries.has(operation)) { - this.mergeQueryIntoSchema(operation, response); - } - - this.validateResponseAgainstSchema(operation, response); - this.seenQueries.add(operation); - } catch (e) { - this.schema = previousSchema; - throw e; - } - } - - public mergeQueryIntoSchema( - operation: GraphQLOperation, - response: AIAdapter.Result - ) { - const query = operation.query; - - const variables = operation.variables; - const typeInfo = new TypeInfo(this.schema); - const responsePath = [response.data]; - - let accumulatedExtensions: { - kind: Kind.DOCUMENT; - definitions: DefinitionNode[]; - } = { - kind: Kind.DOCUMENT, - definitions: [], - } satisfies DocumentNode; - - const mergeExtensions = ({ - assumeValidSDL = false, - revisitAtPath, - }: { - assumeValidSDL?: boolean; - revisitAtPath?: ReadonlyArray; - } = {}) => { - this.schema = extendSchema(this.schema, accumulatedExtensions, { - assumeValidSDL, - }); - - if (revisitAtPath) { - Object.assign(typeInfo, new TypeInfo(this.schema)); - revisitAtPath.reduce((node: any, key: any) => { - const child = node[key]; - typeInfo.enter(child); - return child; - }, query); - } - - accumulatedExtensions = { - kind: Kind.DOCUMENT, - definitions: [], - }; - return this.schema; - }; - - const variableDefinitions: VariableDefinitionNode[] = []; - - query.definitions.forEach((definition) => { - if (definition.kind !== Kind.OPERATION_DEFINITION) { - return; - } - variableDefinitions.push(...(definition.variableDefinitions ?? [])); - }); - - // Create all input objects from the operation's variable definitions. - // By doing this here, we _may_ create unused input objects, but this - // helps us avoid complexity in tying input objects to field definitions. - const inputObjects = this.mergeRepeatedInputObjects( - variableDefinitions.reduce( - (acc, variableDefinition) => { - const leafType = getLeafType(variableDefinition.type); - const relatedVariable = - variables?.[variableDefinition.variable.name.value]; - - if (relatedVariable === undefined) { - throw new GraphQLError( - `Variable \`${variableDefinition.variable.name.value}\` is not defined` - ); - } - - if (!ScalarTypes.includes(leafType.name.value)) { - // Create the input object for this variable and any other - // input objects from its fields. - const inputObjects = this.getInputObjectsForVariableValue( - leafType.name.value, - relatedVariable - ); - acc.push(...inputObjects); - } - return acc; - }, - [] as (InputObjectTypeDefinitionNode | InputObjectTypeExtensionNode)[] - ) || [] - ); - - accumulatedExtensions.definitions.push(...inputObjects); - - visit( - query, - visitWithTypeInfo(typeInfo, { - Field: { - leave() { - responsePath.pop(); - }, - enter: (node, key, parent, path, ancestors) => { - const valueAtPath = - responsePath.at(-1)![node.alias?.value || node.name.value]; - const isList = Array.isArray(valueAtPath); - const actualValue = isList ? valueAtPath[0] : valueAtPath; - const typename = actualValue?.__typename; - responsePath.push(actualValue); - - const type = typeInfo.getParentType(); - if (!type) { - throw new GraphQLError( - `No parent type found for field ${node.name.value}` - ); - } - - const operationVariableDefinitions = - this.getVariableDefinitionsFromAncestors(ancestors); - - let newFieldDef = this.getFieldDefinition( - node, - isList, - actualValue, - typename, - type, - operationVariableDefinitions - ); - - const existingFieldDef = typeInfo.getFieldDef()?.astNode; - if (existingFieldDef) { - const existingArguments = new Map( - existingFieldDef.arguments?.map((arg) => [arg.name.value, arg]) - ); - const additionalArgs = - newFieldDef.arguments?.filter( - (arg) => !existingArguments.has(arg.name.value) - ) || []; - - if (!additionalArgs.length) { - // The existing field definition is sufficient, so we - // can skip adding the new field definition to the schema. - return; - } - - accumulatedExtensions.definitions.push({ - kind: Kind.OBJECT_TYPE_EXTENSION, - name: { kind: Kind.NAME, value: type.name }, - fields: [ - { - ...existingFieldDef, - arguments: [ - ...(existingFieldDef.arguments || []), - ...additionalArgs, - ], - }, - ], - }); - mergeExtensions({ assumeValidSDL: true, revisitAtPath: path }); - return; - } - - if (node.name.value === "__typename") { - return; - } - - accumulatedExtensions.definitions.push({ - kind: Kind.OBJECT_TYPE_EXTENSION, - name: { kind: Kind.NAME, value: type.name }, - fields: [newFieldDef], - }); - - // field not in schema - if (node.selectionSet) { - if (!this.schema.getType(typename)) { - accumulatedExtensions.definitions.push({ - kind: Kind.OBJECT_TYPE_DEFINITION, - name: { kind: Kind.NAME, value: typename }, - fields: [], - }); - } - // this selection set couldn't be entered correctly before, so we - // need to merge the schema now, and have the type info start - // from the top to navigate to the current node - mergeExtensions({ revisitAtPath: path }); - } else { - mergeExtensions(); - } - }, - }, - }) - ); - mergeExtensions(); - } - - private validateResponseAgainstSchema( - operation: GraphQLOperation, - response: FormattedExecutionResult, Record> - ) { - const result = execute({ - schema: this.schema, - document: operation.query, - variableValues: operation.variables, - fieldResolver: (source, args, context, info) => { - const value = source[info.fieldName]; - switch (info.returnType.toString()) { - case "String": - if (typeof value !== "string") { - throw new TypeError(`Value is not string: ${value}`); - } - break; - case "Float": - if (typeof value !== "number") { - throw new TypeError(`Value is not number: ${value}`); - } - break; - case "Boolean": - if (typeof value !== "boolean") { - throw new TypeError(`Value is not boolean: ${value}`); - } - break; - } - - return value; - }, - rootValue: response.data, - }) as FormattedExecutionResult; - - if (result.errors?.length) { - const operationName = operation.query.definitions.find( - (def) => def.kind === Kind.OPERATION_DEFINITION - )?.name?.value; - throw new GraphQLError( - `Error executing query \`${ - operationName ? operationName : "unnamed query" - }\` against grown schema: ${result.errors - .map((e) => e.message) - .join(", ")}` - ); - } - } - - private getType(name: string): T | undefined { - if (!name) { - return undefined; - } - return this.schema.getType(name) as T; - } - - private getVariableDefinitionsFromAncestors( - ancestors: readonly (ASTNode | readonly ASTNode[])[] - ): OperationVariableDefinitions { - const operationDefinition = ancestors.find( - (ancestor) => - isSingle(ancestor) && ancestor.kind === Kind.OPERATION_DEFINITION - ); - if (!operationDefinition) { - return {}; - } - return ( - operationDefinition.variableDefinitions?.reduce( - (acc, variable) => ({ - ...acc, - [variable.variable.name.value]: variable.type, - }), - {} - ) ?? {} - ); - } - - private getFieldArguments( - node: FieldNode, - operationVariableDefinitions: OperationVariableDefinitions - ): InputValueDefinitionNode[] { - return ( - node.arguments?.map((arg) => { - let valueType: TypeNode; - switch (arg.value.kind) { - case Kind.VARIABLE: - const variableDefinition = - operationVariableDefinitions[arg.value.name.value]; - if (!variableDefinition) { - throw new GraphQLError( - `Variable \`${arg.value.name.value}\` is not defined` - ); - } - valueType = variableDefinition; - break; - case Kind.STRING: - valueType = { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: "String" }, - }; - break; - case Kind.INT: - valueType = { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: "Int" }, - }; - break; - case Kind.FLOAT: - valueType = { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: "Float" }, - }; - break; - case Kind.BOOLEAN: - valueType = { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: "Boolean" }, - }; - break; - default: - throw new GraphQLError( - `Scalar responses are not supported for field ${ - node.name.value - } on type ${node.name.value} - received ${JSON.stringify( - arg.value.kind - )}` - ); - } - return { - kind: Kind.INPUT_VALUE_DEFINITION, - name: arg.name, - type: valueType, - }; - }) ?? [] - ); - } - - private getFieldDefinition( - node: FieldNode, - isList: boolean, - actualValue: any, - typename: string, - type: GraphQLCompositeType, - operationVariableDefinitions: OperationVariableDefinitions - ): FieldDefinitionNode { - const name = node.name.value; - const args = this.getFieldArguments(node, operationVariableDefinitions); - - // Handle fields not in schema - if (node.selectionSet) { - // Handle object or list types - if (!typename) { - throw new GraphQLError( - `Field ${node.name.value} on type ${type.name} is missing __typename in response data` - ); - } - let fieldReturnType: TypeNode = { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: typename }, - }; - if (isList) { - fieldReturnType = { - kind: Kind.LIST_TYPE, - type: fieldReturnType, - }; - } - return { - kind: Kind.FIELD_DEFINITION, - name: { kind: Kind.NAME, value: name }, - type: fieldReturnType, - arguments: args, - }; - } - - // Handle scalar types - let valueType: string; - switch (typeof actualValue) { - case "string": - valueType = name === "id" ? "ID" : "String"; - break; - case "number": - valueType = "Float"; - break; - case "boolean": - valueType = "Boolean"; - break; - default: - throw new GraphQLError( - `Scalar responses are not supported for field ${name} on type ${ - type.name - } - received ${JSON.stringify(actualValue)}` - ); - } - return { - kind: Kind.FIELD_DEFINITION, - name: { kind: Kind.NAME, value: name }, - type: { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: valueType }, - }, - arguments: args, - }; - } - - private getInputObjectsForVariableValue( - name: string, - variableValue: any - ): InputObjectsList { - const { fields, inputObjects } = this.getInputValueDefinitionsFromVariables( - name, - variableValue - ); - // Return this input object and any other input objects - // created from its fields. - return [ - { - kind: - this.getType(name) ? - Kind.INPUT_OBJECT_TYPE_EXTENSION - : Kind.INPUT_OBJECT_TYPE_DEFINITION, - name: { kind: Kind.NAME, value: name }, - fields, - }, - ...inputObjects, - ]; - } - - getInputValueDefinitionsFromVariables( - inputObjectName: string, - valuesInScope: any - ): { - fields: InputValueDefinitionNode[]; - inputObjects: InputObjectsList; - } { - const existingInputObject = - this.getType(inputObjectName); - const existingInputObjectFields = - existingInputObject?.astNode?.fields?.map((field) => field.name.value) || - []; - const inputObjects: InputObjectsList = []; - - let valuesToHandle = valuesInScope; - if (Array.isArray(valuesInScope)) { - valuesToHandle = valuesInScope.reduce((acc, item) => { - return deepMerge(acc, item); - }, {}); - } - - const fields = Object.entries(valuesToHandle) - .map(([fieldName, fieldVariableValue]) => { - let valueType: TypeNode; - switch (typeof fieldVariableValue) { - case "object": - if (fieldVariableValue === null) { - valueType = { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: "String" }, - }; - } else { - // Create a name for the input object based on the singular - // form of the field name + "Input". - const inputObjectName = `${ucFirst(singularize(fieldName))}Input`; - - // Create a type node for the input object. - valueType = { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: inputObjectName }, - }; - - // If the field value is an array, then we need to create a list - // type node for the input object and merge the array items - // into a single object for creating the input object. - let variableValueToHandle = fieldVariableValue; - if (Array.isArray(fieldVariableValue)) { - valueType = { - kind: Kind.LIST_TYPE, - type: valueType, - }; - variableValueToHandle = fieldVariableValue.reduce( - (acc, item) => { - return deepMerge(acc, item); - }, - {} - ); - } - - // Create the input object and any other input objects from its - // fields. - const inputObject = this.getInputObjectsForVariableValue( - inputObjectName, - variableValueToHandle - ); - inputObjects.push(...inputObject); - } - break; - case "string": - valueType = { - kind: Kind.NAMED_TYPE, - name: { - kind: Kind.NAME, - value: fieldName === "id" ? "ID" : "String", - }, - }; - break; - case "number": - valueType = { - kind: Kind.NAMED_TYPE, - name: { - kind: Kind.NAME, - value: isFloat(fieldVariableValue) ? "Float" : "Int", - }, - }; - break; - case "boolean": - valueType = { - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: "Boolean" }, - }; - break; - default: - throw new GraphQLError( - `Scalar responses are not supported for field ${fieldName} - received ${JSON.stringify( - fieldVariableValue - )}` - ); - } - return { - kind: Kind.INPUT_VALUE_DEFINITION, - name: { kind: Kind.NAME, value: fieldName }, - type: valueType, - } as InputValueDefinitionNode; - }) - .filter( - (field) => !existingInputObjectFields?.includes(field.name.value) - ); - return { fields, inputObjects }; - } - - mergeRepeatedInputObjects(inputObjects: InputObjectsList): InputObjectsList { - return Object.values( - inputObjects.reduce( - (acc, inputObject) => { - const existingInputObject = acc[inputObject.name.value]; - if (existingInputObject) { - acc[inputObject.name.value] = { - ...existingInputObject, - fields: [ - ...(existingInputObject?.fields || []), - ...(inputObject.fields || []), - ], - }; - } else { - acc[inputObject.name.value] = inputObject; - } - return acc; - }, - {} as Record - ) - ); - } - - public toString() { - return printSchema(this.schema); - } -} diff --git a/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts new file mode 100644 index 00000000000..70e2d7dbfd1 --- /dev/null +++ b/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts @@ -0,0 +1,504 @@ +import { + buildASTSchema, + execute, + FieldDefinitionNode, + FormattedExecutionResult, + GraphQLBoolean, + GraphQLError, + GraphQLInputObjectType, + GraphQLObjectType, + GraphQLSchema, + GraphQLUnionType, + InputObjectTypeDefinitionNode, + InputValueDefinitionNode, + Kind, + NamedTypeNode, + ObjectTypeDefinitionNode, + printSchema, + UnionTypeDefinitionNode, + visit, +} from "graphql"; +import { AIAdapter } from "../AIAdapter.js"; +import { + BuiltInScalarType, + GraphQLOperation, + OperationSchema, +} from "./OperationSchema.js"; +import { + graphQLInputObjectTypeToInputObjectDefinitionNode, + graphQLObjectTypeToObjectTypeDefinitionNode, + graphQLUnionTypeToUnionTypeDefinitionNode, + RootTypeName, + sortASTNodes, +} from "../../utils.js"; + +/** + * A schema that is progressively built as operations are added. + */ +export class GrowingSchema { + /** + * This is a special field name that is used to provide a placeholder query + * field when the root query type has no fields. + */ + private static placeholderQueryName = "_placeholder_query_"; + + /** + * The schema that is progressively built as operations are added. + * + * We start with a schema containing an empty query type. + * We will build the schema up as we go. + */ + public schema = new GraphQLSchema({ + query: new GraphQLObjectType({ + name: RootTypeName.QUERY, + fields: { + // We always include a placeholder query field in the initial schema. + // This is because a schema is invalid if: + // 1. There is no query type + // 2. There is a query type with no fields + // + // This placeholder query field is removed when the schema is updated + // with the first real query field. + [GrowingSchema.placeholderQueryName]: { + type: GraphQLBoolean, + }, + }, + }), + }); + + // We need to track the seen queries with their variables to + // accommodate changes to the input objects defined via the + // variables. + // + // This will likely result in extra schema building attempts + // that are mostly "skipped" but are necessary to ensure that + // the input objects are correct. + private seenQueries = new WeakSet(); + + /** + * Adds an operation to the schema. + * @param operationDocument — The operation to add to the schema. + * @param response — The response to the operation. + */ + public add( + operationDocument: GraphQLOperation, + response: AIAdapter.Result + ): void { + // Save the previous schema to restore it if the operation fails + const previousSchema = this.schema; + + try { + if (!this.seenQueries.has(operationDocument)) { + // If this is a new operation, merge it into the schema + this.mergeOperationIntoSchema(operationDocument, response); + } + + // Validate that the operation and response are valid against the schema + this.validateOperationAndResponseAgainstSchema( + operationDocument, + response + ); + + // Mark the operation as seen + this.seenQueries.add(operationDocument); + } catch (e) { + // Restore the previous schema if the operation fails + this.schema = previousSchema; + throw e; + } + } + + /** + * Merges an operation into the schema. + * @param operationDocument — The operation to merge into the schema. + * @param response — The response to the operation. + */ + private mergeOperationIntoSchema( + operationDocument: GraphQLOperation, + response: AIAdapter.Result + ) { + // Create a schema for the operation + const operationSchema = new OperationSchema( + operationDocument, + response, + this.schema + ); + + // Merge the operation schema into the main schema + const finalAst = visit(operationSchema.ast, { + [Kind.OBJECT_TYPE_DEFINITION]: (node) => { + const updatedNode = this.mergeObjectTypeDefinition(node); + return updatedNode; + }, + [Kind.UNION_TYPE_DEFINITION]: (node) => { + return this.mergeUnionTypeDefinition(node); + }, + [Kind.INPUT_OBJECT_TYPE_DEFINITION]: (node) => { + return this.mergeInputObjectTypeDefinition(node); + }, + }); + + // Update the main schema with the merged operation schema + this.schema = buildASTSchema(finalAst); + } + + /** + * Merges a field definition into the schema. + * + * A field has been updated if: + * + * 1. There is a new field + * 2. The arguments of the field have changed + * + * A change to a field return type should be skipped. + * @param newField — The field definition to merge into the schema. + * @param fields — The fields to merge into the schema. + */ + private mergeFieldDefinition( + newField: FieldDefinitionNode, + fields: Map + ) { + const existingField = fields.get(newField.name.value); + + // If the field is new, add it to the fields map + if (!existingField) { + fields.set(newField.name.value, newField); + return; + } + + // Merge the arguments of the field + const existingArgs = new Map( + existingField.arguments?.map((arg) => [arg.name.value, arg]) || [] + ); + const mergedArgs = + newField.arguments?.reduce((acc, arg) => { + acc.set(arg.name.value, arg); + return acc; + }, new Map(existingArgs)) || existingArgs; + + if (mergedArgs.size > existingArgs.size) { + // If the field has new arguments, update the field definition + fields.set(newField.name.value, { + ...existingField, + arguments: [...mergedArgs.values()], + } as FieldDefinitionNode); + } + + // Return the updated fields map + return fields; + } + + /** + * Merges an input value definition into the schema. + * @param newField — The input value definition to merge into the schema. + * @param fields — The input value definitions to merge into the schema. + */ + private mergeInputValueDefinition( + newField: InputValueDefinitionNode, + fields: Map + ) { + // A field is updated if it is a new field. A change to a field + // return type should be skipped. + const existingField = fields.get(newField.name.value); + + // If the field is new, add it to the fields map + if (!existingField) { + fields.set(newField.name.value, newField); + return; + } + + // Return the updated fields map + return fields; + } + + /** + * Merges an input object type definition into the schema. + * @param node — The input object type definition to merge into the schema. + * @returns The merged input object type definition. + */ + private mergeInputObjectTypeDefinition(node: InputObjectTypeDefinitionNode) { + const existingType = this.schema.getType(node.name.value); + + // If the type does not exist, return the node + if (!existingType) { + return node; + } + + // If the type is not an input object type, throw an error + if (!(existingType instanceof GraphQLInputObjectType)) { + throw new Error( + `Expected ${ + node.name.value + } to be an input object type. encountered ${existingType.toString()}` + ); + } + + // Get the existing input object type definition AST node + const existingTypeAstNode = + existingType.astNode || + graphQLInputObjectTypeToInputObjectDefinitionNode(existingType); + const updatedFields = this.collectUpdatedInputValueDefinitions( + node, + existingType + ); + + // If there are no updated fields, return the existing input object type + // definition AST node + if (updatedFields.length === 0) { + return existingTypeAstNode; + } + + // Return the updated input object type definition AST node + return { + ...existingTypeAstNode, + fields: [...updatedFields], + }; + } + + /** + * Merges an object type definition into the schema. + * @param node — The object type definition to merge into the schema. + * @returns The merged object type definition. + */ + private mergeObjectTypeDefinition(node: ObjectTypeDefinitionNode) { + const existingType = this.schema.getType(node.name.value); + + // If the type does not exist, return the node + if (!existingType) { + return node; + } + + // If the type is not an object type definition, throw an error + if (!(existingType instanceof GraphQLObjectType)) { + throw new Error( + `Expected ${ + node.name.value + } to be an object type. encountered ${existingType.toString()}` + ); + } + + // Get the existing object type definition AST node + const existingTypeAstNode = + existingType.astNode || + graphQLObjectTypeToObjectTypeDefinitionNode(existingType); + const updatedFields = this.collectUpdatedFieldDefinitions( + node, + existingType + ); + + // If there are no updated fields, return the existing object type + // definition AST node + if (updatedFields.length === 0) { + return existingTypeAstNode; + } + + // Return the updated object type definition AST node + return { + ...existingTypeAstNode, + fields: [...updatedFields], + }; + } + + /** + * Merges a union type definition into the schema. + * @param node — The union type definition to merge into the schema. + * @returns The merged union type definition. + */ + private mergeUnionTypeDefinition(node: UnionTypeDefinitionNode) { + const existingType = this.schema.getType(node.name.value); + + // If the type does not exist, return the node + if (!existingType) { + return node; + } + + // If the type is not a union type, throw an error + if (!(existingType instanceof GraphQLUnionType)) { + throw new Error( + `Expected ${ + node.name.value + } to be a union type. encountered ${existingType.toString()}` + ); + } + + // Get the existing union type definition AST node + const existingTypeAstNode = + existingType.astNode || + graphQLUnionTypeToUnionTypeDefinitionNode(existingType); + const updatedMembers = this.collectUpdatedUnionMembers(node, existingType); + + // Return the updated union type definition AST node + return { + ...existingTypeAstNode, + types: [...updatedMembers], + }; + } + + /** + * Collects the updated field definitions for an object type definition. + * @param node — The object type definition to collect the updated field + * definitions for. + * @param existingType — The existing object type definition. + * @returns The updated field definitions. + */ + private collectUpdatedFieldDefinitions( + node: ObjectTypeDefinitionNode, + existingType: GraphQLObjectType + ): FieldDefinitionNode[] { + // Create a map of the existing fields + let fields = new Map( + Object.values(existingType.getFields()).map((field) => [ + field.name, + field.astNode as FieldDefinitionNode, + ]) + ); + + // Merge the field definitions from the new node with the existing fields + node.fields?.forEach((field) => { + this.mergeFieldDefinition(field, fields); + }); + + if (node.name.value === RootTypeName.QUERY) { + // Remove the placeholder query field from the fields map if there are + // real query fields present. + // + // The placeholder query field is only necessary when there are no real + // query fields in the schema. + const fieldsWithoutPlaceholder = new Map(fields); + fieldsWithoutPlaceholder.delete(GrowingSchema.placeholderQueryName); + if (fieldsWithoutPlaceholder.size > 0) { + fields = fieldsWithoutPlaceholder; + } + } + + // Return the updated field definitions + return sortASTNodes([...fields.values()]) as FieldDefinitionNode[]; + } + + /** + * Collects the updated input value definitions for an input object type + * definition. + * @param node — The input object type definition to collect the updated input + * value definitions for. + * @param existingType — The existing input object type definition. + * @returns The updated input value definitions. + */ + private collectUpdatedInputValueDefinitions( + node: InputObjectTypeDefinitionNode, + existingType: GraphQLInputObjectType + ): InputValueDefinitionNode[] { + // Create a map of the existing fields + const fields = new Map( + Object.values(existingType.getFields()).map((field) => [ + field.name, + field.astNode as InputValueDefinitionNode, + ]) + ); + + // Merge the input value definitions from the new node with the existing + // fields + node.fields?.forEach((field) => { + this.mergeInputValueDefinition(field, fields); + }); + + // Return the updated input value definitions + return sortASTNodes([...fields.values()]) as InputValueDefinitionNode[]; + } + + /** + * Collects the updated union members for a union type definition. + * @param node — The union type definition to collect the updated union + * members for. + * @param existingType — The existing union type definition. + * @returns The updated union members. + */ + private collectUpdatedUnionMembers( + node: UnionTypeDefinitionNode, + existingType: GraphQLUnionType + ): NamedTypeNode[] { + // Return the updated union members + return sortASTNodes([ + ...new Set([ + ...(existingType.astNode?.types || []), + ...(node.types || []), + ]), + ]); + } + + /** + * Validates an operation and response against the schema. + * @param operation — The operation to validate. + * @param response — The response to the operation. + */ + private validateOperationAndResponseAgainstSchema( + operation: GraphQLOperation, + response: FormattedExecutionResult, Record> + ) { + // Execute the operation against the schema + const result = execute({ + schema: this.schema, + document: operation.query, + variableValues: operation.variables, + fieldResolver: (source, args, context, info) => { + const value = source[info.fieldName]; + + // We use field resolvers to be more strict with the value types that + // were returned by the AI. + switch (info.returnType.toString()) { + case BuiltInScalarType.STRING: + if (typeof value !== "string") { + throw new TypeError( + `Value for scalar type ${BuiltInScalarType.STRING} is not string: ${value}` + ); + } + break; + case BuiltInScalarType.FLOAT: + if (typeof value !== "number") { + throw new TypeError( + `Value for scalar type ${BuiltInScalarType.FLOAT} is not number: ${value}` + ); + } + break; + case BuiltInScalarType.INT: + if (typeof value !== "number") { + throw new TypeError( + `Value for scalar type ${BuiltInScalarType.INT} is not number: ${value}` + ); + } + break; + case BuiltInScalarType.BOOLEAN: + if (typeof value !== "boolean") { + throw new TypeError( + `Value for scalar type ${BuiltInScalarType.BOOLEAN} is not boolean: ${value}` + ); + } + break; + } + + return value; + }, + rootValue: response.data, + }) as FormattedExecutionResult; + + if (result.errors?.length) { + const operationName = operation.query.definitions.find( + (def) => def.kind === Kind.OPERATION_DEFINITION + )?.name?.value; + throw new GraphQLError( + `Error executing query \`${ + operationName ? operationName : "unnamed query" + }\` against grown schema: ${result.errors + .map((e) => e.message) + .join(", ")}` + ); + } + } + + /** + * Returns a string representation of the schema. + * @returns The string representation of the schema. + */ + public toString(): string { + return printSchema(this.schema); + } +} diff --git a/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts new file mode 100644 index 00000000000..6934460fceb --- /dev/null +++ b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts @@ -0,0 +1,1304 @@ +import { + DocumentNode, + OperationDefinitionNode, + OperationTypeNode, + Kind, + GraphQLError, + GraphQLErrorOptions, + TypeNode, + ObjectTypeDefinitionNode, + UnionTypeDefinitionNode, + InputObjectTypeDefinitionNode, + SelectionSetNode, + FieldDefinitionNode, + InputValueDefinitionNode, + GraphQLSchema, + buildASTSchema, + printSchema, + ValueNode, + VariableDefinitionNode, + NamedTypeNode, + ObjectFieldNode, + FieldNode, + InlineFragmentNode, + FragmentDefinitionNode, + isIntrospectionType, + isObjectType, + isUnionType, + isInputObjectType, +} from "graphql"; +import { AIAdapter } from "../AIAdapter.js"; +import { + deepMerge, + graphQLInputObjectTypeToInputObjectDefinitionNode, + graphQLObjectTypeToObjectTypeDefinitionNode, + graphQLUnionTypeToUnionTypeDefinitionNode, + isFloat, + RootTypeName, + singularize, + sortASTNodes, + sortObjectASTNodes, + sortUnionMembers, + ucFirst, +} from "../../utils.js"; + +/** + * The mapping of the operation field name to the root type name. + */ +const OPERATION_FIELD_TO_TYPE_NAME = { + [OperationTypeNode.MUTATION]: RootTypeName.MUTATION, + [OperationTypeNode.QUERY]: RootTypeName.QUERY, + [OperationTypeNode.SUBSCRIPTION]: RootTypeName.SUBSCRIPTION, +}; + +export type GraphQLOperation = { + query: DocumentNode; + variables?: Record; +}; + +export enum BuiltInScalarType { + ID = "ID", + INT = "Int", + FLOAT = "Float", + BOOLEAN = "Boolean", + STRING = "String", +} + +enum FieldReturnType { + OBJECT = "object", + UNION = "union", + SCALAR = "scalar", +} + +type ObjectReturnType = { + type: TypeNode; + kind: FieldReturnType.OBJECT; + typeName: string; +}; + +type UnionReturnType = { + type: TypeNode; + kind: FieldReturnType.UNION; + typeNames: string[]; +}; + +type ScalarReturnType = { + type: TypeNode; + kind: FieldReturnType.SCALAR; + scalarType: BuiltInScalarType; +}; + +type ReturnType = ObjectReturnType | UnionReturnType | ScalarReturnType; + +export const BUILT_IN_SCALAR_TYPES = Object.values(BuiltInScalarType); + +/** + * A schema that is built from an operation and response. + */ +export class OperationSchema { + /** + * The type of the operation. + */ + public readonly type: OperationTypeNode; + /** + * The name of the type of the operation. + */ + public readonly typeName: string; + /** + * The name of the operation. + */ + public readonly operationName: string; + /** + * The operation document's AST node. + */ + private readonly operationNode: OperationDefinitionNode; + /** + * The variable definitions extracted from the operation document. + */ + private readonly variableDefinitions = new Map< + string, + VariableDefinitionNode + >(); + /** + * The operation's variables. + */ + private readonly variables: Record; + /** + * The response provided for the operation. + */ + private readonly response: AIAdapter.Result; + /** + * A map of paths to return types metadata. + */ + private paths = new Map(); + /** + * A map of fragment definitions extracted from the operation document. + */ + private readonly fragmentDefinitions = new Map< + string, + FragmentDefinitionNode + >(); + /** + * A map of object type definitions extracted from the operation document. + */ + public objectTypeDefinitions = new Map(); + /** + * A map of union type definitions extracted from the operation document. + */ + public unionTypeDefinitions = new Map(); + /** + * A map of input object type definitions extracted from the operation + * document. + */ + public inputObjectTypeDefinitions = new Map< + string, + InputObjectTypeDefinitionNode + >(); + /** + * The schema that has been built for the operation. + */ + private _schema: GraphQLSchema | undefined; + /** + * The AST of the built schema. + */ + private _ast: DocumentNode | undefined; + /** + * The string representation of the built schema. + */ + private _schemaString: string | undefined; + + constructor( + operationDocument: GraphQLOperation, + response: AIAdapter.Result, + previousSchema: GraphQLSchema + ) { + const { query: queryDocument, variables } = operationDocument; + const operationNodes = queryDocument.definitions.filter( + (definition) => definition.kind === Kind.OPERATION_DEFINITION + ); + + if (operationNodes.length === 0) { + this.throwError("No operation definition found in operation document", { + nodes: queryDocument, + }); + } + + if (operationNodes.length > 1) { + this.throwError( + "No operation definition found in operation document. Only single operation definitions are supported.", + { + nodes: queryDocument, + } + ); + } + + this.operationNode = operationNodes[0]; + this.variableDefinitions = new Map( + this.operationNode.variableDefinitions?.map((variable) => [ + variable.variable.name.value, + variable, + ]) + ); + this.variables = variables || {}; + this.response = response; + this.type = this.operationNode.operation; + this.typeName = OPERATION_FIELD_TO_TYPE_NAME[this.type]; + this.operationName = this.operationNode.name?.value ?? "Unnamed Operation"; + + this.seedSchema(previousSchema); + + // Collect all the fragment definitions + queryDocument.definitions.forEach((selection) => { + if (selection.kind === Kind.FRAGMENT_DEFINITION) { + this.fragmentDefinitions.set(selection.name.value, selection); + } + }); + + // Crawl the variables, response, and selection set + // to create the schema + this.crawlVariables(); + this.crawlResponse(); + this.crawlSelectionSet( + [this.typeName], + this.typeName, + this.operationNode.selectionSet + ); + } + + /** + * Seed the schema with the previous schema. + * This allows us to build with some level of consistency with the previous + * schema. + * @param previousSchema — The previous schema to seed the schema with. + */ + private seedSchema(previousSchema: GraphQLSchema) { + Object.values(previousSchema.getTypeMap()).forEach((type) => { + if (isIntrospectionType(type)) { + // Skip introspection types like __Schema, __Type, __TypeKind, etc. + // These types are not relevant to the schema we are building and will + // be added when we do the final build of the schema. + return; + } + if (isObjectType(type)) { + this.objectTypeDefinitions.set( + type.name, + type.astNode || graphQLObjectTypeToObjectTypeDefinitionNode(type) + ); + } else if (isUnionType(type)) { + this.unionTypeDefinitions.set( + type.name, + type.astNode || graphQLUnionTypeToUnionTypeDefinitionNode(type) + ); + } else if (isInputObjectType(type)) { + this.inputObjectTypeDefinitions.set( + type.name, + type.astNode || + graphQLInputObjectTypeToInputObjectDefinitionNode(type) + ); + } + }); + } + + /** + * Crawl the variable definitions to create the input objects. + */ + private crawlVariables() { + // Create all input objects from the operation's variable definitions. + // By doing this here, we _may_ create unused input objects, but this + // helps us avoid complexity in tying input objects to field definitions. + this.variableDefinitions.forEach((variableDefinition, variableName) => { + // Find the variable value that is related to the variable definition. + const relatedVariableValue = this.variables[variableName]; + + if (relatedVariableValue === undefined) { + // If the variable value is not defined, then we have an error. + this.throwError(`Variable '${variableName}' is not defined`); + } + + // Get the leaf type of the variable definition. + const variableType = OperationSchema.getLeafType(variableDefinition.type); + + // If the variable type is not a built-in scalar type, then we need to + // create an input object for it. + // + // The user _may_ mean to use a custom scalar, but we don't support that + // yet. + if ( + !BUILT_IN_SCALAR_TYPES.includes( + variableType.name.value as BuiltInScalarType + ) + ) { + // Create the input object for this variable and any other + // input objects from its fields. + this.getInputObjectsForVariableValue( + variableType.name.value, + relatedVariableValue + ); + } + }); + } + + /** + * Crawl the response to create the return types. + */ + private crawlResponse() { + if (!this.response.data) { + throw new GraphQLError("No 'data' found in operation response"); + } + Object.entries(this.response.data).forEach(([key, value]) => { + this.crawlValue([this.typeName], key, value); + }); + } + + /** + * Crawl the value to create the return types. + * @param previousPath — The path to the parent of the current value. + * @param name — The name of the value. + * @param value — The value to crawl. + * @returns The return type. + */ + private crawlValue(previousPath: string[], name: string, value: any) { + if (name === "__typename") { + // Skip __typename fields. They're implicit in the schema. + return; + } + // Create the path to the current value. + const currentPath = [...previousPath, name]; + + // Add the path the paths map along with its return type metadata. + this.addPath(currentPath, name, value); + + if (typeof value === "object") { + // If the value is an object, then we need to crawl it. + if (Array.isArray(value)) { + // If the value is an array, then we need to crawl it. + this.crawlResponseArray(currentPath, name, value); + return; + } + // Otherwise, this is an object and we need to crawl it another way. + this.crawlResponseObject(currentPath, value); + } + } + + /** + * Crawl the array to create the return types. + * @param currentPath — The path to the parent of the current value. + * @param name — The name of the current value. + * @param value — The array to crawl. + */ + private crawlResponseArray( + currentPath: string[], + name: string, + value: any[] + ) { + // Get the type of all the items in the array so we can handle divergence. + value.forEach((member) => { + // Get the return type name for the member. + this.getFieldReturnTypeName(name, member); + if (Array.isArray(member)) { + // If the member is an array, then we need to crawl it. + this.crawlResponseArray(currentPath, name, member); + return; + } + if (typeof member === "object") { + // If the member is an object, then we need to crawl it. + this.crawlResponseObject(currentPath, member); + return; + } + }); + } + + /** + * Crawl the object to create the return types. + * @param currentPath — The path to the parent of the current value. + * @param value — The object to crawl. + */ + private crawlResponseObject(currentPath: string[], value: any) { + Object.entries(value).forEach(([childKey, childValue]) => + this.crawlValue(currentPath, childKey, childValue) + ); + } + + /** + * Crawl the selection set to create the return types. + * @param previousPath — The path to the parent of the current value. + * @param currentTypeName — The name of the current type. + * @param selectionSet — The selection set to crawl. + */ + private crawlSelectionSet( + previousPath: string[], + currentTypeName: string, + selectionSet: SelectionSetNode + ) { + // Either get the existing object type definition so we can update it if + // it already exists, or create a new one. + let objectTypeDefinition = this.objectTypeDefinitions.get( + currentTypeName + ) || { + kind: Kind.OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: currentTypeName }, + fields: [], + }; + + // Get the fields from the object type definition. + const fields = new Map( + objectTypeDefinition.fields?.map((field) => [field.name.value, field]) + ); + + // Crawl the selection set. + selectionSet.selections.forEach((selection) => { + switch (selection.kind) { + case Kind.FIELD: + const field = this.handleFieldSelection( + selection, + previousPath, + objectTypeDefinition + ); + if (field) { + fields.set(selection.name.value, field); + } + break; + case Kind.INLINE_FRAGMENT: + this.handleInlineFragmentSelection(selection, previousPath); + break; + case Kind.FRAGMENT_SPREAD: + const fragmentDefinition = this.fragmentDefinitions.get( + selection.name.value + ); + if (fragmentDefinition) { + this.crawlSelectionSet( + previousPath, + fragmentDefinition.typeCondition.name.value, + fragmentDefinition.selectionSet + ); + } + break; + } + }); + + if (fields.size === 0) { + // If there are no fields, then we don't need to add anything to the schema + return; + } + + // Add or update the object type definition + this.objectTypeDefinitions.set(objectTypeDefinition.name.value, { + ...objectTypeDefinition, + fields: sortASTNodes([...fields.values()]), + }); + } + + /** + * Handle the field selection. + * @param selection — The field selection. + * @param previousPath — The path to the parent of the current value. + * @param objectTypeDefinition — The object type definition. + * @returns The field definition. + */ + private handleFieldSelection( + selection: FieldNode, + previousPath: string[], + objectTypeDefinition: ObjectTypeDefinitionNode + ): FieldDefinitionNode | undefined { + // skip __typename fields. They're implicit in the schema. + if (selection.name.value === "__typename") { + return; + } + + // Get the existing field definition + const existingField = objectTypeDefinition?.fields?.find( + (field) => field.name.value === selection.name.value + ); + + // Get the existing field arguments or create an empty map for tracking + // the field's arguments + const fieldArgs = new Map( + existingField?.arguments?.map((obj) => [obj.name.value, obj]) + ); + + // Collect the arguments from the field selection + if (selection.arguments?.length) { + selection.arguments?.forEach((arg) => { + if (fieldArgs.has(arg.name.value)) { + return; + } + fieldArgs.set(arg.name.value, { + kind: Kind.INPUT_VALUE_DEFINITION, + name: { + kind: Kind.NAME, + value: arg.name.value, + }, + type: this.getArgumentTypeNode(arg.name.value, arg.value), + }); + }); + } + + // Get the return type for the field + let returnType = existingField?.type; + + // Create the path to the current value. + const currentPath = [...previousPath, selection.name.value]; + + // Get the return type from the paths map. + let matchedReturnType = this.paths.get(currentPath.join(".")); + + // If there is no return type for the field, we have an error + if (!matchedReturnType) { + this.throwError( + `No return type found for field '${selection.name.value}'`, + { + extensions: { + name: selection.name.value, + matchedReturnType, + }, + } + ); + } + + // If the return type is an array, we need to create a union type for it. + // + // This happens when a field is nested in a list — a field on a type in + // the list — that has inconsistent return types. For example: + // [ + // { + // __typename: "User", + // name: "John", + // occupation: { __typename: "Doctor", type: "Dentist"} + // }, + // { + // __typename: "User", + // name: "Jane", + // occupation: { __typename: "Finance", type: "Banker"} + // }, + // ] + // This would handle the "occupation" field on the "User" type. + if (Array.isArray(matchedReturnType)) { + if (selection.selectionSet) { + const typeNames = [ + ...new Set( + matchedReturnType.flatMap((type) => { + if (type.kind === FieldReturnType.UNION) { + return type.typeNames; + } + if (type.kind === FieldReturnType.OBJECT) { + return [type.typeName]; + } + return []; + }) + ), + ]; + matchedReturnType = this.createUnionReturnType(typeNames); + } + } + + if (Array.isArray(matchedReturnType)) { + // If the return type is still an array, we have an error. + this.throwError("Matched return type is an array.", { + extensions: { + matchedReturnType, + }, + }); + } + + if ( + matchedReturnType.kind === FieldReturnType.UNION && + selection.selectionSet + ) { + // If the field return type is a union, we need to crawl its selection set + // to add any additional fields to the schema. + matchedReturnType.typeNames.forEach((typeName) => { + this.crawlSelectionSet(currentPath, typeName, selection.selectionSet!); + }); + } + + if ( + matchedReturnType.kind === FieldReturnType.OBJECT && + selection.selectionSet + ) { + // If the field return type is an object, we need to crawl its + // selection set to add any additional fields to the schema + this.crawlSelectionSet( + currentPath, + matchedReturnType.typeName, + selection.selectionSet + ); + } + + // Get the return type from the matched return type. + returnType = matchedReturnType.type; + + // Create the field definition. + return { + kind: Kind.FIELD_DEFINITION, + name: { kind: Kind.NAME, value: selection.name.value }, + type: returnType, + arguments: sortASTNodes([...fieldArgs.values()]), + }; + } + + /** + * Handle the inline fragment selection. + * @param selection — The inline fragment selection. + * @param previousPath — The path to the parent of the current value. + * @returns The field definition. + */ + private handleInlineFragmentSelection( + selection: InlineFragmentNode, + previousPath: string[] + ): FieldDefinitionNode | undefined { + if (!selection.typeCondition) { + this.throwError("Inline fragment must have a type condition", { + extensions: { + selection, + }, + }); + } + const typeCondition = selection.typeCondition.name.value; + this.crawlSelectionSet(previousPath, typeCondition, selection.selectionSet); + return; + } + + /** + * Add a path to the paths map. + * @param path — The path to the parent of the current value. + * @param name — The name of the current value. + * @param value — The value to add to the paths map. + * @returns The field definition. + */ + private addPath(path: string[], name: string, value: any) { + const typeNode = this.getFieldReturnTypeNode(name, value); + const pathString = path.join("."); + const existingPathEntry = this.paths.get(pathString); + if (!existingPathEntry || typeNode.kind === FieldReturnType.SCALAR) { + // If there is no existing path entry or the type node is a scalar, + // then we can just set the path entry to the type node. + this.paths.set(pathString, typeNode); + return; + } + if (Array.isArray(existingPathEntry)) { + // If the existing path entry is an array, then we can just push the type + // node to the array. + existingPathEntry.push(typeNode); + return; + } + // If there is an existing path entry, then we can create a new array with + // the existing path entry and the new type node. + this.paths.set(pathString, [existingPathEntry, typeNode]); + } + + /** + * Get the return type node for the field. + * @param name — The name of the field. + * @param value — The value of the field. + * @returns The return type node. + */ + private getFieldReturnTypeNode(name: string, value: any): ReturnType { + if (typeof value === "undefined") { + this.throwError(`No value provided for ${name}`, { + extensions: { + name, + value, + }, + }); + } + + if (typeof value === "object") { + // If the value is an object, we need to determine the return type node. + if (Array.isArray(value)) { + // If the value is an array, we need to determine whether the return + // type is a consistent list or a union of types. + const uniqueMembers = this.getUniqueMembers(name, value); + + // If there are multiple unique members, we need to create a union type. + if (uniqueMembers.size > 1) { + const unionName = this.createUnionTypeDefinition([ + ...uniqueMembers.keys(), + ]); + const unionReturnType = this.createUnionReturnType( + [...uniqueMembers.keys()], + unionName + ); + return { + ...unionReturnType, + type: { + kind: Kind.LIST_TYPE, + type: unionReturnType.type, + }, + }; + } + // If there are no multiple unique members, we can just return the + // return type for the first member. + return { + type: { + kind: Kind.LIST_TYPE, + type: this.getFieldReturnTypeNode(name, value[0]).type, + }, + kind: FieldReturnType.OBJECT, + typeName: value[0].__typename, + }; + } + + if (!value.__typename) { + // If the object does not have a __typename, we have an error. + this.throwError("Objects must include a __typename", { + extensions: { + value, + }, + }); + } + + // Create the named type (object reference) return type. + return { + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: value.__typename }, + }, + kind: FieldReturnType.OBJECT, + typeName: value.__typename, + }; + } + + if (name === "id") { + // If the name is "id", we need to return the ID scalar type. + return { + type: { + kind: Kind.NON_NULL_TYPE, + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: BuiltInScalarType.ID }, + }, + }, + kind: FieldReturnType.SCALAR, + scalarType: BuiltInScalarType.ID, + }; + } + + // Determine the return type based on the value type. + switch (typeof value) { + case "number": + const scalarType = + isFloat(value) ? BuiltInScalarType.FLOAT : BuiltInScalarType.INT; + return { + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: scalarType }, + }, + kind: FieldReturnType.SCALAR, + scalarType, + }; + case "boolean": + return { + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: BuiltInScalarType.BOOLEAN }, + }, + kind: FieldReturnType.SCALAR, + scalarType: BuiltInScalarType.BOOLEAN, + }; + case "string": + return { + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: BuiltInScalarType.STRING }, + }, + kind: FieldReturnType.SCALAR, + scalarType: BuiltInScalarType.STRING, + }; + default: + this.throwError( + `Custom scalar responses are not supported for field ${name}`, + { + extensions: value, + } + ); + } + } + + /** + * Get the return type name for the field. + * @param name — The name of the field. + * @param value — The value of the field. + * @returns The return type name. + */ + private getFieldReturnTypeName(name: string, value: any): string { + if (!value) { + this.throwError("No value provided"); + } + if (typeof value === "object") { + // If the value is an object, we need to determine the return type name. + if (Array.isArray(value)) { + // If the value is an array, we have an error. + this.throwError("Array of objects is not supported", { + extensions: { + name, + value, + }, + }); + } + if (!value.__typename) { + // If the object does not have a __typename, we have an error. + this.throwError("Objects must include a __typename", { + extensions: { + name, + value, + }, + }); + } + // Return the __typename of the object. + return value.__typename; + } + if (name === "id") { + // If the name is "id", we need to return the ID scalar type. + return BuiltInScalarType.ID; + } + + // Determine the return type name based on the value type. + switch (typeof value) { + case "number": + return isFloat(value) ? BuiltInScalarType.FLOAT : BuiltInScalarType.INT; + case "boolean": + return BuiltInScalarType.BOOLEAN; + case "string": + return BuiltInScalarType.STRING; + default: + this.throwError( + `Custom scalar responses are not supported for field ${name}`, + { + extensions: { + value, + }, + } + ); + } + } + + /** + * Get the argument type node for the field. + * @param name — The name of the field. + * @param valueNode — The value node. + * @returns The argument type node. + */ + private getArgumentTypeNode(name: string, valueNode: ValueNode): TypeNode { + if (!valueNode) { + this.throwError("No value provided"); + } + + // Determine the argument type based on the value node kind. + switch (valueNode.kind) { + case Kind.LIST: + // If the value node is a list, we need to create a list type node for + // the argument. + let typeNode; + + // It's possible for each value in the list to be an input object of the + // same type, but with different fields. To accommodate this, we need to + // generate the type node for each value in the list to ensure we add + // all the fields to the input object. We only use the last type node + // generated because they will all be the same (a named type node or a + // scalar type node). + valueNode.values.forEach((value) => { + typeNode = this.getArgumentTypeNode(name, value); + }); + + if (!typeNode) { + this.throwError(`No type node created for list argument ${name}`, { + nodes: valueNode, + extensions: { + name, + }, + }); + } + + // Create the list type node. + return { + kind: Kind.LIST_TYPE, + type: typeNode, + }; + case Kind.OBJECT: + // If the value node is an object, we need to create an input object + // type node for the argument. + const inputObjectName = OperationSchema.createInputObjectName(name); + this.createInputObject( + inputObjectName, + this.getInputValueDefinitionsFromObjectFieldNodes( + inputObjectName, + valueNode.fields + ) + ); + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: inputObjectName, + }, + }; + case Kind.ENUM: + // We can't tell the difference between an enum and a string, so we + // have to throw an error. + this.throwError("Enums are not supported for argument", { + extensions: { + name, + valueNode, + }, + }); + case Kind.VARIABLE: + // If the value node is a variable, we need to get the related variable + // definition so we can use it as the argument type. + const variableDefinition = this.variableDefinitions.get( + valueNode.name.value + ); + if (!variableDefinition) { + this.throwError( + `No definition found for variable "${valueNode.name.value}.` + ); + } + return variableDefinition.type; + // Handle all the scalar types + case Kind.BOOLEAN: + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: BuiltInScalarType.BOOLEAN, + }, + }; + case Kind.FLOAT: + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: BuiltInScalarType.FLOAT, + }, + }; + case Kind.INT: + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: BuiltInScalarType.INT, + }, + }; + case Kind.STRING: + case Kind.NULL: + return { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: + name === "id" ? BuiltInScalarType.ID : BuiltInScalarType.STRING, + }, + }; + default: + this.throwError( + `Custom scalar responses are not supported for field ${name}`, + { + nodes: valueNode, + } + ); + } + } + + /** + * Get the unique members of a value array. + * @param name — The name of the field. + * @param value — The value array. + * @returns The unique members of the value array. + */ + private getUniqueMembers(name: string, value: any[]): Map { + return new Map( + value.map((item) => [this.getFieldReturnTypeName(name, item), item]) + ); + } + + /** + * Create a union type definition. + * @param memberTypeNames — The names of the member types. + * @returns The name of the union type definition. + */ + private createUnionTypeDefinition(memberTypeNames: string[]): string { + const name = OperationSchema.createUnionTypeDefinitionName(memberTypeNames); + this.unionTypeDefinitions.set(name, { + kind: Kind.UNION_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: name }, + types: memberTypeNames.map((item) => ({ + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: item }, + })), + }); + return name; + } + + /** + * Create a union return type. + * @param memberTypeNames — The names of the member types. + * @param unionName — The name of the union type definition. If not provided, + * a union name will be generated from the member types. + * @returns The union return type. + */ + private createUnionReturnType( + memberTypeNames: string[], + unionName?: string + ): UnionReturnType { + return { + type: { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: unionName || this.createUnionTypeDefinition(memberTypeNames), + }, + }, + kind: FieldReturnType.UNION, + typeNames: memberTypeNames, + }; + } + + /** + * Create a name for the union type definition. + * @param memberTypes — The names of the member types. + * @returns The name of the union type definition. + */ + private static createUnionTypeDefinitionName(memberTypes: string[]) { + return `${sortUnionMembers([...new Set(memberTypes)]).join("")}Union`; + } + + /** + * Create a name for the input object based on the singular form of the + * argument name + "Input". + * @param argName — The argument name + * @returns The input object name + */ + private static createInputObjectName(argName: string) { + return `${ucFirst(singularize(argName))}Input`; + } + + /** + * Get the input objects for a variable value. + * @param name — The name of the input object. + * @param variableValue — The variable value. + * @returns The input objects for the variable value. + */ + private getInputObjectsForVariableValue( + name: string, + variableValue: any + ): void { + this.createInputObject( + name, + this.getInputValueDefinitionsFromVariables(name, variableValue) + ); + } + + /** + * Create an input object type definition. + * @param name — The name of the input object. + * @param fields — The fields of the input object. + * @returns The input object type definition. + */ + private createInputObject( + name: string, + fields: InputValueDefinitionNode[] + ): void { + this.inputObjectTypeDefinitions.set(name, { + kind: Kind.INPUT_OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: name }, + fields: sortASTNodes(fields), + }); + } + + /** + * Get the input value definitions from variables. + * @param inputObjectName — The name of the input object. + * @param valuesInScope — The values in scope. + * @returns The input value definitions. + */ + getInputValueDefinitionsFromVariables( + inputObjectName: string, + valuesInScope: any + ): InputValueDefinitionNode[] { + // Get the existing input object type definition. + const existingInputObject = + this.inputObjectTypeDefinitions.get(inputObjectName); + + // Get the fields from the existing input object type definition. + const fields = new Map( + existingInputObject?.fields?.map((field) => [field.name.value, field]) + ); + + // Initialize the values to handle. + let valuesToHandle = valuesInScope; + if (Array.isArray(valuesInScope)) { + // If the values in scope is an array, then we need to merge the values + // into a single object. + valuesToHandle = valuesInScope.reduce((acc, item) => { + return deepMerge(acc, item); + }, {}); + } + + // Iterate over the values to handle and create the input value definitions. + Object.entries(valuesToHandle).forEach( + ([fieldName, fieldVariableValue]) => { + let valueType: TypeNode; + + // Determine the value type based on the field variable value type. + switch (typeof fieldVariableValue) { + case "object": + // If the field variable value is an object, then we need to create + // a named type node for the input object. + if (fieldVariableValue === null) { + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: "String" }, + }; + } else { + // Create the input object name. + const inputObjectName = + OperationSchema.createInputObjectName(fieldName); + + // Create a type node for the input object. + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: inputObjectName }, + }; + + // If the field value is an array, then we need to create a list + // type node for the input object and merge the array items + // into a single object for creating the input object. + let variableValueToHandle = fieldVariableValue; + if (Array.isArray(fieldVariableValue)) { + valueType = { + kind: Kind.LIST_TYPE, + type: valueType, + }; + variableValueToHandle = fieldVariableValue.reduce( + (acc, item) => { + return deepMerge(acc, item); + }, + {} + ); + } + + // Create the input object and any other input objects from its + // fields. + this.getInputObjectsForVariableValue( + inputObjectName, + variableValueToHandle + ); + } + break; + case "string": + valueType = { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: + fieldName === "id" ? + BuiltInScalarType.ID + : BuiltInScalarType.STRING, + }, + }; + break; + case "number": + valueType = { + kind: Kind.NAMED_TYPE, + name: { + kind: Kind.NAME, + value: + isFloat(fieldVariableValue) ? + BuiltInScalarType.FLOAT + : BuiltInScalarType.INT, + }, + }; + break; + case "boolean": + valueType = { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: BuiltInScalarType.BOOLEAN }, + }; + break; + default: + this.throwError( + `Scalar responses are not supported for field ${fieldName}`, + { + extensions: { + fieldVariableValue, + }, + } + ); + } + fields.set(fieldName, { + kind: Kind.INPUT_VALUE_DEFINITION, + name: { kind: Kind.NAME, value: fieldName }, + type: valueType, + }); + } + ); + return [...fields.values()]; + } + + /** + * Get the input value definitions from object field nodes. + * @param inputObjectName — The name of the input object. + * @param objectFieldNodes — The object field nodes. + * @returns The input value definitions. + */ + getInputValueDefinitionsFromObjectFieldNodes( + inputObjectName: string, + objectFieldNodes: readonly ObjectFieldNode[] + ): InputValueDefinitionNode[] { + // Get the existing input object type definition. + const existingInputObject = + this.inputObjectTypeDefinitions.get(inputObjectName); + + // Get the fields from the existing input object type definition. + const fields = new Map( + existingInputObject?.fields?.map((field) => [field.name.value, field]) + ); + + // Iterate over the object field nodes and create the input value + // definitions. + objectFieldNodes.forEach((node) => { + const name = node.name.value; + fields.set(name, { + kind: Kind.INPUT_VALUE_DEFINITION, + name: { kind: Kind.NAME, value: name }, + type: this.getArgumentTypeNode(node.name.value, node.value), + }); + }); + return [...fields.values()]; + } + + /** + * Get the leaf type of a type node. + * + * @param typeNode - The type node to get the leaf type of. + * @returns The leaf type of the type node. + */ + private static getLeafType(typeNode: TypeNode): NamedTypeNode { + return typeNode.kind === Kind.NAMED_TYPE ? + typeNode + : OperationSchema.getLeafType(typeNode.type); + } + + /** + * Throw a GraphQL error. + * @param message — The error message. + * @param options — The error options. + */ + private throwError( + message: string, + options: GraphQLErrorOptions = {} + ): never { + const nodes = options.nodes || this.operationNode; + throw new GraphQLError(message, { + ...options, + nodes, + extensions: { + ...(options.extensions || {}), + variables: this.variables, + response: this.response, + }, + }); + } + + /** + * Get the GraphQL schema. + * @returns The GraphQL schema. + */ + public get schema(): GraphQLSchema { + if (this._schema) { + return this._schema; + } + this._schema = buildASTSchema(this.ast); + return this._schema; + } + + /** + * Get the GraphQL AST. + * @returns The GraphQL AST. + */ + public get ast(): DocumentNode { + if (this._ast) { + return this._ast; + } + this._ast = { + kind: Kind.DOCUMENT, + definitions: sortObjectASTNodes([ + ...this.objectTypeDefinitions.values(), + ...this.unionTypeDefinitions.values(), + ...this.inputObjectTypeDefinitions.values(), + ]), + }; + return this._ast; + } + + /** + * Get the GraphQL schema string. + * @returns The GraphQL schema string. + */ + public get schemaString(): string { + if (this._schemaString) { + return this._schemaString; + } + this._schemaString = printSchema(this.schema); + return this._schemaString; + } +} diff --git a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts similarity index 71% rename from packages/ai/src/mocking/__tests__/GrowingSchema.test.ts rename to packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts index f764123bb6b..d856c03d519 100644 --- a/packages/ai/src/mocking/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts @@ -1,17 +1,19 @@ import { gql } from "@apollo/client"; import { GrowingSchema } from "../GrowingSchema.js"; -import { GraphQLError } from "graphql"; describe("GrowingSchema", () => { - it("creates a base schema when instantiated", () => { + it("creates an empty base schema when instantiated", () => { + const expectedSchema = /* GraphQL */ ` + type Query { + _placeholder_query_: Boolean + } + `; const schema = new GrowingSchema(); - expect(schema.toString()).toEqualIgnoringWhitespace(/* GraphQL */ ` - type Query - `); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); describe(".add()", () => { - it("should create a schema with the correct fields", () => { + it("creates a query schema with the correct fields", () => { const query = gql` query GetUser { user { @@ -24,6 +26,7 @@ describe("GrowingSchema", () => { kind value } + aliases } } `; @@ -37,6 +40,7 @@ describe("GrowingSchema", () => { { __typename: "Email", id: "1", kind: "work", value: "qd" }, { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, ], + aliases: ["John Smith", "Who Knows"], }, }, }; @@ -45,17 +49,73 @@ describe("GrowingSchema", () => { user: User } + type Email { + id: ID! + kind: String + value: String + } + type User { - id: ID - name: String + aliases: [String] emails: [Email] + id: ID! + name: String + } + `; + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("creates a mutation schema with the correct fields", () => { + const query = gql` + mutation CreateUser { + createUser { + __typename + id + name + emails { + __typename + id + kind + value + } + } + } + `; + const response = { + data: { + createUser: { + __typename: "User", + id: "1", + name: "John Doe", + emails: [ + { __typename: "Email", id: "1", kind: "work", value: "qd" }, + { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, + ], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + _placeholder_query_: Boolean + } + + type Mutation { + createUser: User } type Email { - id: ID + id: ID! kind: String value: String } + + type User { + emails: [Email] + id: ID! + name: String + } `; const schema = new GrowingSchema(); schema.add({ query }, response); @@ -117,18 +177,18 @@ describe("GrowingSchema", () => { user: User } - type User { - id: ID - name: String - emails: [Email] - lastName: String - } - type Email { - id: ID + foo: Int + id: ID! kind: String value: String - foo: Float + } + + type User { + emails: [Email] + id: ID! + lastName: String + name: String } `; const schema = new GrowingSchema(); @@ -161,7 +221,7 @@ describe("GrowingSchema", () => { } type User { - id: ID + id: ID! name: String } `; @@ -221,12 +281,107 @@ describe("GrowingSchema", () => { error = err as Error; } - expect(error).toBeInstanceOf(GraphQLError); + expect(error).toBeInstanceOf(Error); expect(error?.message).toEqual( 'Error executing query `GetUser2` against grown schema: Expected Iterable, but did not find one for field "Query.users".' ); }); + it("handles inline arguments", () => { + const query = gql` + query Search { + book( + id: "asdf" + nullArg: null + stringArg: "Hi" + boolArg: false + intArg: 2 + floatArg: 3.6 + listArg: ["string1", "string2"] + nestedListArg: [["nested1"], ["nested2, nested3"]] + objectArg: { + prop1: true, + prop2: 5, + prop3: 9.7, + prop4: "Hello", + prop5: null, + prop6: {value: "Yep"} + prop7: ["Yep"], + prop8: [[7]], + prop9: [{value1: "Nope"}, {value2: true}, {value2: false}] + } + objectArgs: [{prop1: true}, {prop1: false}, {prop2: 5}] + nestedObjectArgs: [[{prop1: true}], [{prop1: false}], [{prop2: 5}]] + ) { + __typename + title + anotherField(number: 1, bool: true) + } + } + `; + const response = { + data: { + book: { + __typename: "Book", + title: "Moby Dick", + anotherField: true, + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + book( + boolArg: Boolean, + floatArg: Float, + id: ID, + intArg: Int, + listArg: [String], + nestedListArg: [[String]], + nestedObjectArgs: [[NestedObjectArgInput]], + nullArg: String, + objectArg: ObjectArgInput, + objectArgs: [ObjectArgInput], + stringArg: String + ): Book + } + + type Book { + anotherField(bool: Boolean, number: Int): Boolean + title: String + } + + input NestedObjectArgInput { + prop1: Boolean + prop2: Int + } + + input ObjectArgInput { + prop1: Boolean + prop2: Int + prop3: Float + prop4: String + prop5: String + prop6: Prop6Input + prop7: [String] + prop8: [[Int]] + prop9: [Prop9Input] + } + + input Prop6Input { + value: String + } + + input Prop9Input { + value1: String + value2: Boolean + } + `; + + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + it("handles scalar variables", () => { const query = gql` query Search($bookId: ID!, $arg: String!, $nullable: String) { @@ -257,8 +412,8 @@ describe("GrowingSchema", () => { } type Book { - title: String anotherField(arg: String!, nullable: String): Boolean + title: String } `; @@ -303,13 +458,13 @@ describe("GrowingSchema", () => { name: String } - input SomeArgInput { - foo: String - } - type Book { - title: String anotherField(arg: SomeArgInput!): Boolean + title: String + } + + input SomeArgInput { + foo: String } `; @@ -357,21 +512,21 @@ describe("GrowingSchema", () => { name: NameInput } + type Book { + title: String + } + input NameInput { + age: Int firstName: String lastName: String nickName: NickNameInput - age: Int } input NickNameInput { full: String short: String } - - type Book { - title: String - } `; const schema = new GrowingSchema(); @@ -420,22 +575,22 @@ describe("GrowingSchema", () => { name: NameInput } + type Book { + anotherField(author: AuthorInput!): Boolean + title: String + } + input NameInput { + age: Int firstName: String lastName: String nickName: NickNameInput - age: Int } input NickNameInput { full: String short: String } - - type Book { - title: String - anotherField(author: AuthorInput!): Boolean - } `; const schema = new GrowingSchema(); @@ -478,6 +633,10 @@ describe("GrowingSchema", () => { name: NameInput } + type Book { + title: String + } + input NameInput { nickName: NickNameInput } @@ -485,10 +644,6 @@ describe("GrowingSchema", () => { input NickNameInput { full: String } - - type Book { - title: String - } `; const secondQuery = gql` query SearchByAuthor($author: AuthorInput!) { @@ -526,20 +681,20 @@ describe("GrowingSchema", () => { name: NameInput } + type Book { + title: String + } + input NameInput { - nickName: NickNameInput firstName: String lastName: String + nickName: NickNameInput } input NickNameInput { full: String short: String } - - type Book { - title: String - } `; const schema = new GrowingSchema(); @@ -602,20 +757,20 @@ describe("GrowingSchema", () => { name: NameInput } + type Book { + title: String + } + input NameInput { - nickNames: [NickNameInput] firstName: String - middleName: String lastName: String + middleName: String + nickNames: [NickNameInput] } input NickNameInput { full: String } - - type Book { - title: String - } `; const schema = new GrowingSchema(); @@ -623,65 +778,72 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it.skip("handles union types with inline fragments", () => { + it("handles a single inline fragment as a type, not a union", () => { const query = gql` query Search { - search(term: "Smith", first: 2, after: "ASDF") { - __typename - pageInfo { + book { + ... on Book { __typename - hasNextPage - nextCursor + title } - edges { + } + } + `; + const response = { + data: { + book: { + __typename: "Book", + title: "Moby Dick", + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + book: Book + } + + type Book { + title: String + } + `; + + const schema = new GrowingSchema(); + schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("handles a single inline fragment as a type, not a union, when the return data is a list of matching types", () => { + const query = gql` + query Search { + books { + ... on Book { __typename - node { - # The inline fragments imply that this is a union. - ... on Author { - __typename - name - } - ... on Book { - __typename - title - } - } + title } } } `; const response = { data: { - search: { - __typename: "SearchConnection", - pageInfo: { - __typename: "PageInfo", - hasNextPage: true, - nextCursor: "eyJvZmZzZXQiOjJ9", + books: [ + { + __typename: "Book", + title: "Moby Dick", }, - edges: [ - // The inconsistent `__typename` values - // imply that this is a union. - { - __typename: "SearchEdge", - node: { - __typename: "Author", - name: "John Smith", - }, - }, - { - __typename: "SearchEdge", - node: { - __typename: "Book", - title: "The Art of Blacksmithing", - }, - }, - ], - }, + { + __typename: "Book", + title: "The Martian", + }, + ], }, }; const expectedSchema = /* GraphQL */ ` type Query { + books: [Book] + } + + type Book { + title: String } `; @@ -690,10 +852,16 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it.skip("handles a single inline fragment as a union", () => { + it("handles union types with inline fragments", () => { const query = gql` query Search { - book { + search(term: "Smith", first: 2, after: "ASDF") { + __typename + # The inline fragments imply that this is a union + ... on Author { + __typename + name + } ... on Book { __typename title @@ -703,14 +871,33 @@ describe("GrowingSchema", () => { `; const response = { data: { - book: { - __typename: "Book", - title: "Moby Dick", - }, + search: [ + // The inconsistent `__typename` values + // imply that this is a union. + { + __typename: "Book", + title: "The Art of Blacksmithing", + }, + { + __typename: "Author", + name: "John Smith", + }, + ], }, }; const expectedSchema = /* GraphQL */ ` type Query { + search(after: String, first: Int, term: String): [AuthorBookUnion] + } + + type Author { + name: String + } + + union AuthorBookUnion = Author | Book + + type Book { + title: String } `; @@ -719,10 +906,10 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it.skip("handles a selection set with root fields and inline fragments as a union, contributing the root fields to all union members", () => { + it("handles a selection set with root fields and inline fragments as a union, contributing the root fields to all union members", () => { const query = gql` - query Search($term: String!, $first: Int, $after: String) { - search(term: $term, first: $first, after: $after) { + query Search { + search { __typename pageInfo { __typename @@ -766,8 +953,9 @@ describe("GrowingSchema", () => { { __typename: "SearchEdge", node: { - __typename: "Author", - name: "John Smith", + __typename: "Movie", + title: "The Matrix", + someField: true, }, }, { @@ -775,6 +963,7 @@ describe("GrowingSchema", () => { node: { __typename: "Book", title: "The Art of Blacksmithing", + someOtherField: false, }, }, ], @@ -783,6 +972,33 @@ describe("GrowingSchema", () => { }; const expectedSchema = /* GraphQL */ ` type Query { + search: SearchConnection + } + + type Book { + someOtherField: Boolean + title: String + } + + union BookMovieUnion = Book | Movie + + type Movie { + someField: Boolean + title: String + } + + type PageInfo { + hasNextPage: Boolean + nextCursor: String + } + + type SearchConnection { + edges: [SearchEdge] + pageInfo: PageInfo + } + + type SearchEdge { + node: BookMovieUnion } `; @@ -791,7 +1007,7 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it.skip("handles named fragments on a type", () => { + it("handles named fragments on a type", () => { const query = gql` query Search { book { @@ -814,6 +1030,11 @@ describe("GrowingSchema", () => { }; const expectedSchema = /* GraphQL */ ` type Query { + book: Book + } + + type Book { + title: String } `; @@ -822,7 +1043,7 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it.skip("handles union types with named fragments", () => { + it("handles union types with named fragments", () => { const query = gql` query Search { search(term: "Smith", first: 2, after: "ASDF") { @@ -882,6 +1103,31 @@ describe("GrowingSchema", () => { }; const expectedSchema = /* GraphQL */ ` type Query { + search(after: String, first: Int, term: String): SearchConnection + } + + type Author { + name: String + } + + union AuthorBookUnion = Author | Book + + type Book { + title: String + } + + type PageInfo { + hasNextPage: Boolean + nextCursor: String + } + + type SearchConnection { + edges: [SearchEdge] + pageInfo: PageInfo + } + + type SearchEdge { + node: AuthorBookUnion } `; diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts new file mode 100644 index 00000000000..04c0d26cd23 --- /dev/null +++ b/packages/ai/src/utils.ts @@ -0,0 +1,311 @@ +import { + GraphQLInputObjectType, + GraphQLInputType, + GraphQLObjectType, + GraphQLOutputType, + GraphQLUnionType, + InputObjectTypeDefinitionNode, + isListType, + isWrappingType, + Kind, + ObjectTypeDefinitionNode, + TypeNode, + UnionTypeDefinitionNode, +} from "graphql"; + +export type NamedNode = { name: { value: string } }; + +/** + * The names of the root types in the GraphQLschema. + */ +export enum RootTypeName { + MUTATION = "Mutation", + QUERY = "Query", + SUBSCRIPTION = "Subscription", +} + +/** + * The sort order of the root types in the schema. + */ +const ROOT_TYPE_ORDER: { [key: string]: number } = { + [RootTypeName.QUERY]: 0, + [RootTypeName.MUTATION]: 1, + [RootTypeName.SUBSCRIPTION]: 2, +}; + +/** + * Check if a number is a float (i.e. 9.5). + * + * @param num - The number to check. + * @returns True if the number is a float, false otherwise. + */ +export function isFloat(num: number) { + return typeof num === "number" && !Number.isInteger(num); +} + +/** + * Convert a plural word to its singular form. + * + * @param str - The plural word to convert. + * @returns The singular form of the word. + */ +export function singularize(str: string) { + if (!str) { + return ""; + } + + // Handle common pluralization patterns + if (str.endsWith("ies")) { + return str.slice(0, -3) + "y"; + } else if (str.endsWith("ves")) { + return str.slice(0, -3) + "f"; + } else if (str.endsWith("es")) { + // Special cases for -es endings + if (str.endsWith("ches") || str.endsWith("shes") || str.endsWith("xes")) { + return str.slice(0, -2); + } else if (str.endsWith("ses")) { + return str.slice(0, -2); + } else { + return str.slice(0, -1); + } + } else if (str.endsWith("s") && str.length > 1) { + return str.slice(0, -1); + } + + return str; +} + +/** + * Convert the first letter of a string to uppercase. + * + * @param str - The string to convert. + * @returns The string with the first letter capitalized. + */ +export function ucFirst(str: string) { + if (!str) { + return ""; + } + return str.charAt(0).toUpperCase() + str.slice(1); +} + +/** + * Sorts object-level AST nodes by their name. + * + * AST Nodes named after a root type (like Query, Mutation, Subscription) are + * sorted to the beginning of the list based on their defined order. + * + * All other AST Nodes are sorted alphabetically regardless of kind. + * @param list — The list of AST Nodes to sort. + * @returns The sorted list of AST Nodes. + */ +export function sortObjectASTNodes(list: T[]): T[] { + return list.sort((a, b) => { + const aName = a.name.value; + const bName = b.name.value; + + const aOrder = ROOT_TYPE_ORDER[aName]; + const bOrder = ROOT_TYPE_ORDER[bName]; + + // If both are root types, sort by their defined order + if (aOrder !== undefined && bOrder !== undefined) { + return aOrder - bOrder; + } + + // If only a is a root type, it goes first + if (aOrder !== undefined) { + return -1; + } + + // If only b is a root type, it goes first + if (bOrder !== undefined) { + return 1; + } + + // Neither is a root type, sort alphabetically + return aName.localeCompare(bName); + }); +} + +/** + * Sorts union member names alphabetically. + * @param members — The list of union member names to sort. + * @returns The sorted list of union member names. + */ +export function sortUnionMembers(members: string[]): string[] { + return members.sort((a, b) => { + return a.localeCompare(b); + }); +} + +/** + * Sorts AST nodes by their name alphabetically. + * + * Unlike `sortObjectASTNodes`, this function sorts alphabetically regardless + * of kind or name. + * @param nodes — The list of AST nodes to sort. + * @returns The sorted list of AST nodes. + */ +export function sortASTNodes(nodes: T[]): T[] { + return nodes.sort((a, b) => { + return a.name.value.localeCompare(b.name.value); + }); +} + +/** + * Transforms a GraphQL Input/Output type to an AST TypeNode. + * @param type — The GraphQL Input/Output type to transform + * @returns The AST TypeNode + */ +export function graphQLTypeToTypeNode( + type: GraphQLOutputType | GraphQLInputType +): TypeNode { + if (isWrappingType(type)) { + // Recursively transform the wrapped type + const returnType = graphQLTypeToTypeNode(type.ofType); + + // If the type is a non-null type, return the non-null type to avoid + // unnecessary wrapping (but this also shouldn't actually happen) + if (returnType.kind === Kind.NON_NULL_TYPE) { + return returnType; + } + + // Return the wrapped type + return { + kind: isListType(type) ? Kind.LIST_TYPE : Kind.NON_NULL_TYPE, + type: returnType, + }; + } + return { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: type.name }, + }; +} + +/** + * Transforms a GraphQL Object type to an AST ObjectTypeDefinitionNode. + * @param type — The GraphQL Object type to transform + * @returns The AST ObjectTypeDefinitionNode + */ +export function graphQLObjectTypeToObjectTypeDefinitionNode( + type: GraphQLObjectType +): ObjectTypeDefinitionNode { + return { + kind: Kind.OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: type.name }, + fields: sortASTNodes( + Object.values(type.getFields()).map((field) => ({ + kind: Kind.FIELD_DEFINITION, + name: { kind: Kind.NAME, value: field.name }, + type: graphQLTypeToTypeNode(field.type), + })) + ), + }; +} + +/** + * Transforms a GraphQL Input type to an AST InputObjectTypeDefinitionNode. + * @param type — The GraphQL Input type to transform + * @returns The AST InputObjectTypeDefinitionNode + */ +export function graphQLInputObjectTypeToInputObjectDefinitionNode( + type: GraphQLInputObjectType +): InputObjectTypeDefinitionNode { + return { + kind: Kind.INPUT_OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: type.name }, + fields: sortASTNodes( + Object.values(type.getFields()).map((field) => ({ + kind: Kind.INPUT_VALUE_DEFINITION, + name: { kind: Kind.NAME, value: field.name }, + type: graphQLTypeToTypeNode(field.type), + })) + ), + }; +} + +/** + * Transforms a GraphQL Union type to an AST UnionTypeDefinitionNode. + * @param type — The GraphQL Union type to transform + * @returns The AST UnionTypeDefinitionNode + */ +export function graphQLUnionTypeToUnionTypeDefinitionNode( + type: GraphQLUnionType +): UnionTypeDefinitionNode { + return { + kind: Kind.UNION_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: type.name }, + types: sortASTNodes( + Object.values(type.getTypes()).map((memberType) => ({ + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: memberType.name }, + })) + ), + }; +} + +/** + * Deep merge utility function to preserve nested properties. + * + * @param target - The target object to merge into. + * @param source - The source object to merge from. + * @returns The merged object. + */ +export function deepMerge(target: any, source: any): any { + if (source === null || typeof source !== "object") { + return source; + } + + if (Array.isArray(source)) { + return source; + } + + if (target === null || typeof target !== "object" || Array.isArray(target)) { + target = {}; + } + + const result = { ...target }; + + for (const key in source) { + if (source.hasOwnProperty(key)) { + if ( + typeof source[key] === "object" && + source[key] !== null && + !Array.isArray(source[key]) + ) { + result[key] = deepMerge(result[key], source[key]); + } else { + result[key] = source[key]; + } + } + } + + return result; +} + +/** + * Converts a value to a formattedJSON string. + * This handles Map and Set instances by converting them to objects/arrays. + * @param value — The value to convert to a JSON string + * @returns The JSON string + */ +export function toJSON(value: any) { + return JSON.stringify( + value, + (key: string, value: any) => { + if (value instanceof Map) { + return { + dataType: "Map", + value: Object.fromEntries(value.entries()), + }; + } else if (value instanceof Set) { + return { + dataType: "Set", + value: [...value], + }; + } else { + return value; + } + }, + 2 + ); +} From f154b76182771fffad8a1dd411b5d0257cb92b14 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Thu, 11 Sep 2025 11:09:07 -0400 Subject: [PATCH 26/29] feat(ai): fix some import/sorting issues --- packages/ai/src/mocking/BaseAIAdapter.ts | 2 +- .../ai/src/mocking/GrowingSchema/OperationSchema.ts | 10 ++++++---- packages/ai/src/mocking/GrowingSchema/index.ts | 1 + packages/ai/src/utils.ts | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) create mode 100644 packages/ai/src/mocking/GrowingSchema/index.ts diff --git a/packages/ai/src/mocking/BaseAIAdapter.ts b/packages/ai/src/mocking/BaseAIAdapter.ts index e649062d913..b40c897c5db 100644 --- a/packages/ai/src/mocking/BaseAIAdapter.ts +++ b/packages/ai/src/mocking/BaseAIAdapter.ts @@ -2,7 +2,7 @@ import { ApolloLink } from "@apollo/client"; import { AIAdapter } from "./AIAdapter.js"; import { print } from "graphql"; import { BASE_SYSTEM_PROMPT } from "./consts.js"; -import { GrowingSchema } from "./GrowingSchema.js"; +import { GrowingSchema } from "./GrowingSchema/index.js"; export class BaseAIAdapter { private static baseSystemPrompt = BASE_SYSTEM_PROMPT; diff --git a/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts index 6934460fceb..15d8d069fee 100644 --- a/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts @@ -980,10 +980,12 @@ export class OperationSchema { this.unionTypeDefinitions.set(name, { kind: Kind.UNION_TYPE_DEFINITION, name: { kind: Kind.NAME, value: name }, - types: memberTypeNames.map((item) => ({ - kind: Kind.NAMED_TYPE, - name: { kind: Kind.NAME, value: item }, - })), + types: sortASTNodes( + memberTypeNames.map((item) => ({ + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: item }, + })) + ), }); return name; } diff --git a/packages/ai/src/mocking/GrowingSchema/index.ts b/packages/ai/src/mocking/GrowingSchema/index.ts new file mode 100644 index 00000000000..250c1403e12 --- /dev/null +++ b/packages/ai/src/mocking/GrowingSchema/index.ts @@ -0,0 +1 @@ +export * from "./GrowingSchema.js"; diff --git a/packages/ai/src/utils.ts b/packages/ai/src/utils.ts index 04c0d26cd23..f357da84a1f 100644 --- a/packages/ai/src/utils.ts +++ b/packages/ai/src/utils.ts @@ -16,7 +16,7 @@ import { export type NamedNode = { name: { value: string } }; /** - * The names of the root types in the GraphQLschema. + * The names of the root types in the GraphQLSchema. */ export enum RootTypeName { MUTATION = "Mutation", From 1497ba1ca622ab444ff4375a89825598bde031ec Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Thu, 11 Sep 2025 22:48:19 -0400 Subject: [PATCH 27/29] feat(ai): avoid schema merge race conditions with a task queue --- packages/ai/src/mocking/BaseAIAdapter.ts | 3 +- .../mocking/GrowingSchema/GrowingSchema.ts | 186 +++++++++++------- .../mocking/GrowingSchema/OperationSchema.ts | 63 ++---- .../__tests__/GrowingSchema.test.ts | 154 +++++++++++---- packages/ai/src/mocking/consts.ts | 6 + 5 files changed, 262 insertions(+), 150 deletions(-) diff --git a/packages/ai/src/mocking/BaseAIAdapter.ts b/packages/ai/src/mocking/BaseAIAdapter.ts index b40c897c5db..25e2fd8ce26 100644 --- a/packages/ai/src/mocking/BaseAIAdapter.ts +++ b/packages/ai/src/mocking/BaseAIAdapter.ts @@ -31,7 +31,8 @@ export class BaseAIAdapter { systemPrompt ); - this.schema.add(operation, result); + // Add the operation to the schema. + await this.schema.add(operation, result); return result; } diff --git a/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts index 70e2d7dbfd1..0aa7cac93a6 100644 --- a/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts @@ -31,40 +31,19 @@ import { RootTypeName, sortASTNodes, } from "../../utils.js"; +import { PLACEHOLDER_QUERY_NAME } from "../consts.js"; /** * A schema that is progressively built as operations are added. */ export class GrowingSchema { - /** - * This is a special field name that is used to provide a placeholder query - * field when the root query type has no fields. - */ - private static placeholderQueryName = "_placeholder_query_"; - /** * The schema that is progressively built as operations are added. * * We start with a schema containing an empty query type. * We will build the schema up as we go. */ - public schema = new GraphQLSchema({ - query: new GraphQLObjectType({ - name: RootTypeName.QUERY, - fields: { - // We always include a placeholder query field in the initial schema. - // This is because a schema is invalid if: - // 1. There is no query type - // 2. There is a query type with no fields - // - // This placeholder query field is removed when the schema is updated - // with the first real query field. - [GrowingSchema.placeholderQueryName]: { - type: GraphQLBoolean, - }, - }, - }), - }); + public schema = new GraphQLSchema({}); // We need to track the seen queries with their variables to // accommodate changes to the input objects defined via the @@ -75,32 +54,139 @@ export class GrowingSchema { // the input objects are correct. private seenQueries = new WeakSet(); + /** + * The queue of schema merge tasks. + * They must be treated as a queue to avoid race conditions. + */ + private mergeTaskQueue: { + operationSchema: OperationSchema; + resolve: (value: void) => void; + reject: (error: Error) => void; + }[] = []; + + /** + * Whether an operation is currently being merged into the schema. + */ + private mergingOperation = false; + /** * Adds an operation to the schema. * @param operationDocument — The operation to add to the schema. * @param response — The response to the operation. */ - public add( + public async add( operationDocument: GraphQLOperation, response: AIAdapter.Result - ): void { + ): Promise { + if (!this.seenQueries.has(operationDocument)) { + // Return a promise that will be resolved when the operation is merged + // into the schema + return this.mergeOperationIntoSchema(operationDocument, response); + } + // If the operation has already been seen, resolve immediately + return Promise.resolve(); + } + + /** + * Merges an operation into the schema. + * @param operationDocument — The operation to merge into the schema. + * @param response — The response to the operation. + */ + private async mergeOperationIntoSchema( + operationDocument: GraphQLOperation, + response: AIAdapter.Result + ): Promise { + // Create a schema for the operation + const operationSchema = new OperationSchema(operationDocument, response); + + return new Promise((resolve, reject) => { + // Add to the merge task queue with its own promise handlers + this.mergeTaskQueue.push({ + operationSchema, + resolve, + reject, + }); + + // Start processing the merge task queue if it is not already running + this.processMergeTaskQueue(); + }); + } + + /** + * Processes the merge task queue. + */ + private processMergeTaskQueue() { + // If already processing, return. + // the queue will continue to be processed automatically. + if (this.mergingOperation) { + return; + } + + // Process the next merge task in the queue + const nextMergeTask = this.mergeTaskQueue.shift(); + if (!nextMergeTask) { + // If the queue is empty, return + return; + } + + // Start merging the operation into the schema + this.mergingOperation = true; + this.mergeAndUpdateSchema(nextMergeTask.operationSchema) + .then(() => { + // Merging the operation into the schema succeeded :) + nextMergeTask.resolve(); + }) + .catch((error) => { + // Merging the operation into the schema failed :( + nextMergeTask.reject(error); + }) + .finally(() => { + // Move on to the next merge task + this.mergingOperation = false; + + // Process the next merge task in the queue + if (this.mergeTaskQueue.length > 0) { + this.processMergeTaskQueue(); + } + }); + } + + /** + * Merges an operation schema into the main schema. + * @param operationSchema + */ + private async mergeAndUpdateSchema( + operationSchema: OperationSchema + ): Promise { // Save the previous schema to restore it if the operation fails const previousSchema = this.schema; try { - if (!this.seenQueries.has(operationDocument)) { - // If this is a new operation, merge it into the schema - this.mergeOperationIntoSchema(operationDocument, response); - } + // Merge the operation schema into the main schema + const finalAst = visit(operationSchema.ast, { + [Kind.OBJECT_TYPE_DEFINITION]: (node) => { + const updatedNode = this.mergeObjectTypeDefinition(node); + return updatedNode; + }, + [Kind.UNION_TYPE_DEFINITION]: (node) => { + return this.mergeUnionTypeDefinition(node); + }, + [Kind.INPUT_OBJECT_TYPE_DEFINITION]: (node) => { + return this.mergeInputObjectTypeDefinition(node); + }, + }); + + // Update the main schema with the merged operation schema + this.schema = buildASTSchema(finalAst); // Validate that the operation and response are valid against the schema this.validateOperationAndResponseAgainstSchema( - operationDocument, - response + operationSchema.operationDocument, + operationSchema.response ); // Mark the operation as seen - this.seenQueries.add(operationDocument); + this.seenQueries.add(operationSchema.operationDocument); } catch (e) { // Restore the previous schema if the operation fails this.schema = previousSchema; @@ -108,40 +194,6 @@ export class GrowingSchema { } } - /** - * Merges an operation into the schema. - * @param operationDocument — The operation to merge into the schema. - * @param response — The response to the operation. - */ - private mergeOperationIntoSchema( - operationDocument: GraphQLOperation, - response: AIAdapter.Result - ) { - // Create a schema for the operation - const operationSchema = new OperationSchema( - operationDocument, - response, - this.schema - ); - - // Merge the operation schema into the main schema - const finalAst = visit(operationSchema.ast, { - [Kind.OBJECT_TYPE_DEFINITION]: (node) => { - const updatedNode = this.mergeObjectTypeDefinition(node); - return updatedNode; - }, - [Kind.UNION_TYPE_DEFINITION]: (node) => { - return this.mergeUnionTypeDefinition(node); - }, - [Kind.INPUT_OBJECT_TYPE_DEFINITION]: (node) => { - return this.mergeInputObjectTypeDefinition(node); - }, - }); - - // Update the main schema with the merged operation schema - this.schema = buildASTSchema(finalAst); - } - /** * Merges a field definition into the schema. * @@ -365,7 +417,7 @@ export class GrowingSchema { // The placeholder query field is only necessary when there are no real // query fields in the schema. const fieldsWithoutPlaceholder = new Map(fields); - fieldsWithoutPlaceholder.delete(GrowingSchema.placeholderQueryName); + fieldsWithoutPlaceholder.delete(PLACEHOLDER_QUERY_NAME); if (fieldsWithoutPlaceholder.size > 0) { fields = fieldsWithoutPlaceholder; } diff --git a/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts index 15d8d069fee..a656244bb53 100644 --- a/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts @@ -41,6 +41,7 @@ import { sortUnionMembers, ucFirst, } from "../../utils.js"; +import { PLACEHOLDER_QUERY_NAME } from "../consts.js"; /** * The mapping of the operation field name to the root type name. @@ -123,10 +124,6 @@ export class OperationSchema { * The operation's variables. */ private readonly variables: Record; - /** - * The response provided for the operation. - */ - private readonly response: AIAdapter.Result; /** * A map of paths to return types metadata. */ @@ -168,9 +165,8 @@ export class OperationSchema { private _schemaString: string | undefined; constructor( - operationDocument: GraphQLOperation, - response: AIAdapter.Result, - previousSchema: GraphQLSchema + public readonly operationDocument: GraphQLOperation, + public readonly response: AIAdapter.Result ) { const { query: queryDocument, variables } = operationDocument; const operationNodes = queryDocument.definitions.filter( @@ -200,13 +196,10 @@ export class OperationSchema { ]) ); this.variables = variables || {}; - this.response = response; this.type = this.operationNode.operation; this.typeName = OPERATION_FIELD_TO_TYPE_NAME[this.type]; this.operationName = this.operationNode.name?.value ?? "Unnamed Operation"; - this.seedSchema(previousSchema); - // Collect all the fragment definitions queryDocument.definitions.forEach((selection) => { if (selection.kind === Kind.FRAGMENT_DEFINITION) { @@ -223,40 +216,26 @@ export class OperationSchema { this.typeName, this.operationNode.selectionSet ); + this.ensureQueryExists(); } - /** - * Seed the schema with the previous schema. - * This allows us to build with some level of consistency with the previous - * schema. - * @param previousSchema — The previous schema to seed the schema with. - */ - private seedSchema(previousSchema: GraphQLSchema) { - Object.values(previousSchema.getTypeMap()).forEach((type) => { - if (isIntrospectionType(type)) { - // Skip introspection types like __Schema, __Type, __TypeKind, etc. - // These types are not relevant to the schema we are building and will - // be added when we do the final build of the schema. - return; - } - if (isObjectType(type)) { - this.objectTypeDefinitions.set( - type.name, - type.astNode || graphQLObjectTypeToObjectTypeDefinitionNode(type) - ); - } else if (isUnionType(type)) { - this.unionTypeDefinitions.set( - type.name, - type.astNode || graphQLUnionTypeToUnionTypeDefinitionNode(type) - ); - } else if (isInputObjectType(type)) { - this.inputObjectTypeDefinitions.set( - type.name, - type.astNode || - graphQLInputObjectTypeToInputObjectDefinitionNode(type) - ); - } - }); + private ensureQueryExists() { + if (!this.objectTypeDefinitions.has(RootTypeName.QUERY)) { + this.objectTypeDefinitions.set(RootTypeName.QUERY, { + kind: Kind.OBJECT_TYPE_DEFINITION, + name: { kind: Kind.NAME, value: RootTypeName.QUERY }, + fields: [ + { + type: { + kind: Kind.NAMED_TYPE, + name: { kind: Kind.NAME, value: BuiltInScalarType.BOOLEAN }, + }, + kind: Kind.FIELD_DEFINITION, + name: { kind: Kind.NAME, value: PLACEHOLDER_QUERY_NAME }, + }, + ], + }); + } } /** diff --git a/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts index d856c03d519..179014fa66b 100644 --- a/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts @@ -3,17 +3,13 @@ import { GrowingSchema } from "../GrowingSchema.js"; describe("GrowingSchema", () => { it("creates an empty base schema when instantiated", () => { - const expectedSchema = /* GraphQL */ ` - type Query { - _placeholder_query_: Boolean - } - `; + const expectedSchema = /* GraphQL */ ``; const schema = new GrowingSchema(); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); describe(".add()", () => { - it("creates a query schema with the correct fields", () => { + it("creates a query schema with the correct fields", async () => { const query = gql` query GetUser { user { @@ -63,11 +59,11 @@ describe("GrowingSchema", () => { } `; const schema = new GrowingSchema(); - schema.add({ query }, response); + await schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("creates a mutation schema with the correct fields", () => { + it("creates a mutation schema with the correct fields", async () => { const query = gql` mutation CreateUser { createUser { @@ -118,11 +114,11 @@ describe("GrowingSchema", () => { } `; const schema = new GrowingSchema(); - schema.add({ query }, response); + await schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("extends an existing schema based on a new query", () => { + it("extends an existing schema based on a new query", async () => { const query = gql` query GetUser { user { @@ -192,12 +188,90 @@ describe("GrowingSchema", () => { } `; const schema = new GrowingSchema(); - schema.add({ query }, response); + await schema.add({ query }, response); schema.add({ query: query2 }, response2); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("throws an error when a query that is incompatible with previous queries is added", () => { + it("avoids errors due to race conditions when adding multiple queries simultaneously", async () => { + const query = gql` + query GetUser { + user { + __typename + id + name + emails { + __typename + id + kind + value + } + } + } + `; + const response = { + data: { + user: { + __typename: "User", + id: "1", + name: "John Doe", + emails: [ + { __typename: "Email", id: "1", kind: "work", value: "qd" }, + { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, + ], + }, + }, + }; + const query2 = gql` + query GetUser2 { + user { + __typename + lastName + emails { + __typename + foo + } + } + } + `; + const response2 = { + data: { + user: { + __typename: "User", + lastName: "John Doe", + emails: [{ __typename: "Email", foo: 1 }], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + user: User + } + + type Email { + foo: Int + id: ID! + kind: String + value: String + } + + type User { + emails: [Email] + id: ID! + lastName: String + name: String + } + `; + const schema = new GrowingSchema(); + const promises = [ + schema.add({ query: query2 }, response2), + schema.add({ query }, response), + ]; + await Promise.all(promises); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + + it("throws an error when a query that is incompatible with previous queries is added", async () => { const query = gql` query GetUsers { users(limit: 2) { @@ -270,13 +344,13 @@ describe("GrowingSchema", () => { const schema = new GrowingSchema(); // Add the initial schema - schema.add({ query }, response); + await schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); // Attempt to add the incompatible schema let error: Error | undefined; try { - schema.add({ query: query2 }, response2); + await schema.add({ query: query2 }, response2); } catch (err) { error = err as Error; } @@ -287,7 +361,7 @@ describe("GrowingSchema", () => { ); }); - it("handles inline arguments", () => { + it("handles inline arguments", async () => { const query = gql` query Search { book( @@ -378,11 +452,11 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query }, response); + await schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles scalar variables", () => { + it("handles scalar variables", async () => { const query = gql` query Search($bookId: ID!, $arg: String!, $nullable: String) { book(id: $bookId) { @@ -418,11 +492,11 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query, variables }, response); + await schema.add({ query, variables }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles input object variables", () => { + it("handles input object variables", async () => { const query = gql` query SearchByAuthor($author: AuthorInput!, $arg: SomeArgInput!) { bookByAuthor(author: $author) { @@ -469,11 +543,11 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query, variables }, response); + await schema.add({ query, variables }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles nested input object variables", () => { + it("handles nested input object variables", async () => { const query = gql` query SearchByAuthor($author: AuthorInput!) { bookByAuthor(author: $author) { @@ -534,7 +608,7 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles repeated input object variables for a single query", () => { + it("handles repeated input object variables for a single query", async () => { const query = gql` query SearchByAuthor($author: AuthorInput!) { bookByAuthor(author: $author) { @@ -594,11 +668,11 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query, variables }, response); + await schema.add({ query, variables }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles repeated input object variables across multiple queries", () => { + it("handles repeated input object variables across multiple queries", async () => { const firstQuery = gql` query SearchByAuthor($author: AuthorInput!) { bookByAuthor(author: $author) { @@ -698,20 +772,20 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add( + await schema.add( { query: firstQuery, variables: firstVariables }, firstResponse ); expect(schema.toString()).toEqualIgnoringWhitespace(firstExpectedSchema); - schema.add( + await schema.add( { query: secondQuery, variables: secondVariables }, secondResponse ); expect(schema.toString()).toEqualIgnoringWhitespace(secondExpectedSchema); }); - it("handles list variables", () => { + it("handles list variables", async () => { const query = gql` query SearchByAuthor($authors: [AuthorInput!]!) { bookByAuthor(authors: $authors) { @@ -774,11 +848,11 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query, variables }, response); + await schema.add({ query, variables }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles a single inline fragment as a type, not a union", () => { + it("handles a single inline fragment as a type, not a union", async () => { const query = gql` query Search { book { @@ -808,11 +882,11 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query }, response); + await schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles a single inline fragment as a type, not a union, when the return data is a list of matching types", () => { + it("handles a single inline fragment as a type, not a union, when the return data is a list of matching types", async () => { const query = gql` query Search { books { @@ -848,11 +922,11 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query }, response); + await schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles union types with inline fragments", () => { + it("handles union types with inline fragments", async () => { const query = gql` query Search { search(term: "Smith", first: 2, after: "ASDF") { @@ -902,11 +976,11 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query }, response); + await schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles a selection set with root fields and inline fragments as a union, contributing the root fields to all union members", () => { + it("handles a selection set with root fields and inline fragments as a union, contributing the root fields to all union members", async () => { const query = gql` query Search { search { @@ -1003,11 +1077,11 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query }, response); + await schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles named fragments on a type", () => { + it("handles named fragments on a type", async () => { const query = gql` query Search { book { @@ -1039,11 +1113,11 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query }, response); + await schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); - it("handles union types with named fragments", () => { + it("handles union types with named fragments", async () => { const query = gql` query Search { search(term: "Smith", first: 2, after: "ASDF") { @@ -1132,7 +1206,7 @@ describe("GrowingSchema", () => { `; const schema = new GrowingSchema(); - schema.add({ query }, response); + await schema.add({ query }, response); expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); }); diff --git a/packages/ai/src/mocking/consts.ts b/packages/ai/src/mocking/consts.ts index c9e5dcc3919..baf4ae3e9c0 100644 --- a/packages/ai/src/mocking/consts.ts +++ b/packages/ai/src/mocking/consts.ts @@ -14,3 +14,9 @@ For example, say something is named "Foobar", you should use a unique identifier Remember context and data based on the unique identifier and typename so that data is consistent. `; + +/** + * This is a special field name that is used to provide a placeholder query + * field when the root query type has no fields. + */ +export const PLACEHOLDER_QUERY_NAME = "_placeholder_query_"; From c9a53d645bc071c84ff8cd1a5d19c53f2bb04f38 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 12 Sep 2025 09:41:07 -0400 Subject: [PATCH 28/29] fix(ai): remove unused imports --- packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts | 1 - packages/ai/src/mocking/GrowingSchema/OperationSchema.ts | 7 ------- 2 files changed, 8 deletions(-) diff --git a/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts index 0aa7cac93a6..1eb1b5e1221 100644 --- a/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts @@ -3,7 +3,6 @@ import { execute, FieldDefinitionNode, FormattedExecutionResult, - GraphQLBoolean, GraphQLError, GraphQLInputObjectType, GraphQLObjectType, diff --git a/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts index a656244bb53..35060f6150f 100644 --- a/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts @@ -22,17 +22,10 @@ import { FieldNode, InlineFragmentNode, FragmentDefinitionNode, - isIntrospectionType, - isObjectType, - isUnionType, - isInputObjectType, } from "graphql"; import { AIAdapter } from "../AIAdapter.js"; import { deepMerge, - graphQLInputObjectTypeToInputObjectDefinitionNode, - graphQLObjectTypeToObjectTypeDefinitionNode, - graphQLUnionTypeToUnionTypeDefinitionNode, isFloat, RootTypeName, singularize, From 5bac744b99dd4ba19d1b97b75a3df0c5bb384f24 Mon Sep 17 00:00:00 2001 From: Greg Wardwell Date: Fri, 12 Sep 2025 15:06:38 -0400 Subject: [PATCH 29/29] feat(ai): add base schema to growing schema GrowingSchema now accepts a base schema to build on top of. --- packages/ai/src/mocking/AIMockLink.ts | 6 +- packages/ai/src/mocking/AIMockProvider.tsx | 3 + packages/ai/src/mocking/BaseAIAdapter.ts | 13 +++- .../mocking/GrowingSchema/GrowingSchema.ts | 19 +++++- .../mocking/GrowingSchema/OperationSchema.ts | 44 ++++++++++++- .../__tests__/GrowingSchema.test.ts | 64 +++++++++++++++++++ 6 files changed, 143 insertions(+), 6 deletions(-) diff --git a/packages/ai/src/mocking/AIMockLink.ts b/packages/ai/src/mocking/AIMockLink.ts index d9fc3c6e830..38a876fdde2 100644 --- a/packages/ai/src/mocking/AIMockLink.ts +++ b/packages/ai/src/mocking/AIMockLink.ts @@ -8,6 +8,7 @@ export declare namespace AIMockLink { export interface Options { adapter: AIAdapter; showWarnings?: boolean; + schema?: string; defaultOptions?: DefaultOptions; } } @@ -15,13 +16,14 @@ export declare namespace AIMockLink { export class AIMockLink extends ApolloLink { private adapter: BaseAIAdapter; public showWarnings: boolean = true; - + public schema: string | undefined; public static defaultOptions: AIMockLink.DefaultOptions = {}; constructor(options: AIMockLink.Options) { super(); - this.adapter = new BaseAIAdapter(options.adapter); + this.schema = options.schema; + this.adapter = new BaseAIAdapter(options.adapter, { schema: this.schema }); this.showWarnings = options.showWarnings ?? true; } diff --git a/packages/ai/src/mocking/AIMockProvider.tsx b/packages/ai/src/mocking/AIMockProvider.tsx index d18ba660d35..e8601b2c373 100644 --- a/packages/ai/src/mocking/AIMockProvider.tsx +++ b/packages/ai/src/mocking/AIMockProvider.tsx @@ -13,6 +13,7 @@ import { AIMockLink } from "./AIMockLink.js"; export interface AIMockedProviderProps { adapter: AIAdapter; systemPrompt?: string; + schema?: string; defaultOptions?: ApolloClient.DefaultOptions; cache?: ApolloCache; localState?: LocalState; @@ -37,6 +38,7 @@ export class AIMockedProvider extends React.Component< const { adapter, + schema, defaultOptions, cache, localState, @@ -53,6 +55,7 @@ export class AIMockedProvider extends React.Component< link || new AIMockLink({ adapter, + schema, showWarnings, defaultOptions: mockLinkDefaultOptions, }), diff --git a/packages/ai/src/mocking/BaseAIAdapter.ts b/packages/ai/src/mocking/BaseAIAdapter.ts index 25e2fd8ce26..92b7c61e8ff 100644 --- a/packages/ai/src/mocking/BaseAIAdapter.ts +++ b/packages/ai/src/mocking/BaseAIAdapter.ts @@ -4,12 +4,21 @@ import { print } from "graphql"; import { BASE_SYSTEM_PROMPT } from "./consts.js"; import { GrowingSchema } from "./GrowingSchema/index.js"; +export declare namespace BaseAIAdapter { + export interface Options { + schema?: string; + } +} + export class BaseAIAdapter { private static baseSystemPrompt = BASE_SYSTEM_PROMPT; private schema: GrowingSchema; - constructor(private implementation: AIAdapter) { - this.schema = new GrowingSchema(); + constructor( + private implementation: AIAdapter, + options: BaseAIAdapter.Options + ) { + this.schema = new GrowingSchema({ schema: options.schema }); } /** diff --git a/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts b/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts index 1eb1b5e1221..ad3890b3072 100644 --- a/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema/GrowingSchema.ts @@ -1,5 +1,6 @@ import { buildASTSchema, + DocumentNode, execute, FieldDefinitionNode, FormattedExecutionResult, @@ -13,6 +14,7 @@ import { Kind, NamedTypeNode, ObjectTypeDefinitionNode, + parse, printSchema, UnionTypeDefinitionNode, visit, @@ -44,6 +46,11 @@ export class GrowingSchema { */ public schema = new GraphQLSchema({}); + /** + * The base schema that is used to build the schema. + */ + private baseSchema: DocumentNode | null = null; + // We need to track the seen queries with their variables to // accommodate changes to the input objects defined via the // variables. @@ -68,6 +75,12 @@ export class GrowingSchema { */ private mergingOperation = false; + constructor(options: { schema?: string } = {}) { + if (options.schema) { + this.baseSchema = parse(options.schema); + } + } + /** * Adds an operation to the schema. * @param operationDocument — The operation to add to the schema. @@ -96,7 +109,11 @@ export class GrowingSchema { response: AIAdapter.Result ): Promise { // Create a schema for the operation - const operationSchema = new OperationSchema(operationDocument, response); + const operationSchema = new OperationSchema( + operationDocument, + response, + this.baseSchema + ); return new Promise((resolve, reject) => { // Add to the merge task queue with its own promise handlers diff --git a/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts index 35060f6150f..ba1bc7fa370 100644 --- a/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts +++ b/packages/ai/src/mocking/GrowingSchema/OperationSchema.ts @@ -22,6 +22,9 @@ import { FieldNode, InlineFragmentNode, FragmentDefinitionNode, + visit, + ScalarTypeDefinitionNode, + DirectiveDefinitionNode, } from "graphql"; import { AIAdapter } from "../AIAdapter.js"; import { @@ -144,6 +147,14 @@ export class OperationSchema { string, InputObjectTypeDefinitionNode >(); + /** + * A map of scalar type definitions extracted from the operation document. + */ + public scalarTypeDefinitions = new Map(); + /** + * A map of directive definitions extracted from the operation document. + */ + public directiveDefinitions = new Map(); /** * The schema that has been built for the operation. */ @@ -159,7 +170,8 @@ export class OperationSchema { constructor( public readonly operationDocument: GraphQLOperation, - public readonly response: AIAdapter.Result + public readonly response: AIAdapter.Result, + baseSchema?: DocumentNode | null ) { const { query: queryDocument, variables } = operationDocument; const operationNodes = queryDocument.definitions.filter( @@ -181,6 +193,10 @@ export class OperationSchema { ); } + if (baseSchema) { + this.seedSchema(baseSchema); + } + this.operationNode = operationNodes[0]; this.variableDefinitions = new Map( this.operationNode.variableDefinitions?.map((variable) => [ @@ -212,6 +228,30 @@ export class OperationSchema { this.ensureQueryExists(); } + /** + * Seeds the schema with the base schema. + * @param baseSchema + */ + private seedSchema(baseSchema: DocumentNode) { + visit(baseSchema, { + [Kind.OBJECT_TYPE_DEFINITION]: (node) => { + this.objectTypeDefinitions.set(node.name.value, node); + }, + [Kind.UNION_TYPE_DEFINITION]: (node) => { + this.unionTypeDefinitions.set(node.name.value, node); + }, + [Kind.INPUT_OBJECT_TYPE_DEFINITION]: (node) => { + this.inputObjectTypeDefinitions.set(node.name.value, node); + }, + [Kind.SCALAR_TYPE_DEFINITION]: (node) => { + this.scalarTypeDefinitions.set(node.name.value, node); + }, + [Kind.DIRECTIVE_DEFINITION]: (node) => { + this.directiveDefinitions.set(node.name.value, node); + }, + }); + } + private ensureQueryExists() { if (!this.objectTypeDefinitions.has(RootTypeName.QUERY)) { this.objectTypeDefinitions.set(RootTypeName.QUERY, { @@ -1259,6 +1299,8 @@ export class OperationSchema { ...this.objectTypeDefinitions.values(), ...this.unionTypeDefinitions.values(), ...this.inputObjectTypeDefinitions.values(), + ...this.scalarTypeDefinitions.values(), + ...this.directiveDefinitions.values(), ]), }; return this._ast; diff --git a/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts b/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts index 179014fa66b..afd5a77184d 100644 --- a/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts +++ b/packages/ai/src/mocking/GrowingSchema/__tests__/GrowingSchema.test.ts @@ -63,6 +63,70 @@ describe("GrowingSchema", () => { expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); }); + it("creates a query schema with the correct fields from a base schema", async () => { + const baseSchema = /* GraphQL */ ` + scalar DateTime + + type Query { + now: DateTime + } + `; + const query = gql` + query GetUser { + user { + __typename + id + name + emails { + __typename + id + kind + value + } + aliases + } + } + `; + const response = { + data: { + user: { + __typename: "User", + id: "1", + name: "John Doe", + emails: [ + { __typename: "Email", id: "1", kind: "work", value: "qd" }, + { __typename: "Email", id: "2", kind: "personal", value: "qwe" }, + ], + aliases: ["John Smith", "Who Knows"], + }, + }, + }; + const expectedSchema = /* GraphQL */ ` + type Query { + now: DateTime + user: User + } + + scalar DateTime + + type Email { + id: ID! + kind: String + value: String + } + + type User { + aliases: [String] + emails: [Email] + id: ID! + name: String + } + `; + const schema = new GrowingSchema({ schema: baseSchema }); + await schema.add({ query }, response); + expect(schema.toString()).toEqualIgnoringWhitespace(expectedSchema); + }); + it("creates a mutation schema with the correct fields", async () => { const query = gql` mutation CreateUser {