From c1c9aac0b656fb28faca2e584e157dc6795f9671 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:17:08 +0800 Subject: [PATCH 1/2] feat(spec)!: remove the never-implemented GraphQL surface (#2462 follow-on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Product decision: GraphQL is not on the plan. It was schema-only from day one — 20+ spec config schemas, an unconditionally-501 handleGraphQL (kernel.graphql never assigned in the monorepo), and THREE separate mounts (dispatcher if-chain, dispatcher-plugin hono route, hono adapter) advertising the dead endpoint: the "declared ≠ mounted ≠ implemented" seam disease in one picture. Removed: spec schemas/contracts/enum members (CoreServiceName, ApiProtocolType, query dialects, graphql-playground), capability booleans, discovery/router fields, all three runtime mounts, the discovery advertisement, plugin-dev stubs, the now-dead resolveRequestExecutionContext, qa conformance rows + ratchet pins + dogfood surface cases, generated JSON-schema manifest keys + reference docs, and handwritten doc mentions. Kept deliberately: external-datasource 'graphql' protocol option (third parties may speak GraphQL to us as a CLIENT) and cloud's reserved slug. objectui's own dead 'graphql' union member noted for cross-repo cleanup. Verified: full monorepo build+test forced, 131 tasks green (spec 6675, core 386, runtime 647, dogfood 60, conformance 41 among them); api-surface + json-schema manifest + reference docs regenerated. Co-Authored-By: Claude Fable 5 --- .changeset/remove-graphql-surface.md | 31 + content/docs/plugins/anatomy.mdx | 2 +- content/docs/plugins/development.mdx | 2 +- content/docs/references/api/discovery.mdx | 3 +- content/docs/references/api/dispatcher.mdx | 4 +- content/docs/references/api/documentation.mdx | 9 +- content/docs/references/api/graphql.mdx | 498 ------- content/docs/references/api/index.mdx | 1 - content/docs/references/api/meta.json | 1 - content/docs/references/api/protocol.mdx | 2 +- content/docs/references/api/query-adapter.mdx | 29 +- content/docs/references/api/registry.mdx | 17 +- content/docs/references/api/rest-server.mdx | 2 +- .../docs/references/system/core-services.mdx | 5 +- .../docs/releases/implementation-status.mdx | 6 +- packages/adapters/hono/src/hono.test.ts | 30 - packages/adapters/hono/src/index.ts | 11 - packages/client/src/index.ts | 1 - .../core/examples/api-registry-example.ts | 28 - packages/core/src/api-registry-plugin.test.ts | 8 +- packages/core/src/api-registry.test.ts | 62 +- packages/metadata-protocol/src/protocol.ts | 1 - .../plugins/plugin-dev/src/dev-plugin.test.ts | 6 - packages/plugins/plugin-dev/src/dev-plugin.ts | 13 +- .../dogfood/test/authz-conformance.matrix.ts | 9 - .../qa/dogfood/test/authz-conformance.test.ts | 38 +- .../test/expression-conformance.ledger.ts | 3 +- ...se-anonymous-deny-surfaces.dogfood.test.ts | 17 - packages/runtime/src/dispatcher-plugin.ts | 26 +- .../src/http-dispatcher.requireauth.test.ts | 88 -- packages/runtime/src/http-dispatcher.ts | 108 +- packages/spec/PROTOCOL_MAP.md | 1 - packages/spec/api-surface.json | 61 - packages/spec/json-schema.manifest.json | 22 - packages/spec/scripts/build-docs.ts | 2 +- packages/spec/src/api/discovery.test.ts | 36 - packages/spec/src/api/discovery.zod.ts | 3 - packages/spec/src/api/documentation.test.ts | 3 +- packages/spec/src/api/documentation.zod.ts | 4 +- packages/spec/src/api/graphql.test.ts | 1255 ----------------- packages/spec/src/api/graphql.zod.ts | 1131 --------------- packages/spec/src/api/index.ts | 1 - packages/spec/src/api/query-adapter.test.ts | 55 +- packages/spec/src/api/query-adapter.zod.ts | 53 +- packages/spec/src/api/registry.test.ts | 7 +- packages/spec/src/api/registry.zod.ts | 6 +- packages/spec/src/api/rest-server.zod.ts | 5 +- packages/spec/src/api/router.test.ts | 4 - packages/spec/src/api/router.zod.ts | 2 - .../src/contracts/graphql-service.test.ts | 82 -- .../spec/src/contracts/graphql-service.ts | 74 - packages/spec/src/contracts/index.ts | 1 - .../spec/src/integration/connector.zod.ts | 2 +- packages/spec/src/stack.test.ts | 5 +- packages/spec/src/stack.zod.ts | 4 +- .../spec/src/system/core-services.test.ts | 3 +- packages/spec/src/system/core-services.zod.ts | 2 - 57 files changed, 129 insertions(+), 3756 deletions(-) create mode 100644 .changeset/remove-graphql-surface.md delete mode 100644 content/docs/references/api/graphql.mdx delete mode 100644 packages/spec/src/api/graphql.test.ts delete mode 100644 packages/spec/src/api/graphql.zod.ts delete mode 100644 packages/spec/src/contracts/graphql-service.test.ts delete mode 100644 packages/spec/src/contracts/graphql-service.ts diff --git a/.changeset/remove-graphql-surface.md b/.changeset/remove-graphql-surface.md new file mode 100644 index 0000000000..c37578ecbb --- /dev/null +++ b/.changeset/remove-graphql-surface.md @@ -0,0 +1,31 @@ +--- +"@objectstack/spec": major +--- + +feat(spec)!: remove the never-implemented GraphQL surface from the product plan (#2462 follow-on) + +GraphQL was schema-only from day one: the spec shipped 20+ config schemas +(`GraphQLTypeConfig`, federation, persisted queries, …), the dispatcher's +`handleGraphQL` answered 501 unconditionally (`kernel.graphql` was never +assigned in the monorepo), and THREE separate mounts advertised the dead +endpoint. Per the product decision, the surface is deleted rather than +maintained: + +- **spec**: `api/graphql.zod.ts` + `contracts/graphql-service.ts` deleted; + `graphql` removed from `CoreServiceName`, `ApiProtocolType`, the + query-adapter dialects, `graphql-playground` from testing-UI types; the + `graphqlApi`/network capability booleans, discovery/router route fields + dropped. BREAKING for consumers referencing those exports/enum members. +- **runtime**: `handleGraphQL`, the if-chain branch, the dispatcher-plugin + and hono-adapter mounts, discovery advertisement, and the now-dead + `resolveRequestExecutionContext` helper removed. +- **plugin-dev**: the graphql stub family removed. +- **qa**: authz-conformance matrix rows, ratchet high-risk id, discover + patterns and identity pins for the GraphQL surface retired; expression + ledger covers updated. +- **NOT removed**: the `'graphql'` protocol option on external datasource + lookups (third-party systems may speak GraphQL) and cloud's reserved + slug — those are not our API surface. + +`/graphql` now 404s (was an unconditional 501); the anonymous-deny posture +matrix shrinks by the two GraphQL rows. diff --git a/content/docs/plugins/anatomy.mdx b/content/docs/plugins/anatomy.mdx index 0c9cd95ee4..7ea9469a29 100644 --- a/content/docs/plugins/anatomy.mdx +++ b/content/docs/plugins/anatomy.mdx @@ -90,7 +90,7 @@ ObjectStack uses `type` discrimination to optimize runtime behavior, allowing th ### 5. Server Plugin (`server`) * **Role:** Protocol Gateway. -* **Use Cases:** REST API (`plugin-hono-server`), GraphQL, WebSocket. +* **Use Cases:** REST API (`plugin-hono-server`), WebSocket. * **Behavior:** Responsible for binding ports and mapping incoming traffic. ### 6. Theme Plugin (`theme`) diff --git a/content/docs/plugins/development.mdx b/content/docs/plugins/development.mdx index 1f3f607fb8..340f141255 100644 --- a/content/docs/plugins/development.mdx +++ b/content/docs/plugins/development.mdx @@ -21,7 +21,7 @@ A plugin is a self-contained module that extends the ObjectStack kernel with: - **Hooks** — React to lifecycle events (before/after record create, update, delete) - **Objects** — Register new data objects and fields - **UI Components** — Add custom views, widgets, or actions -- **API Endpoints** — Expose new REST or GraphQL endpoints +- **API Endpoints** — Expose new REST endpoints --- diff --git a/content/docs/references/api/discovery.mdx b/content/docs/references/api/discovery.mdx index 92b753b1b2..3b1d36b48b 100644 --- a/content/docs/references/api/discovery.mdx +++ b/content/docs/references/api/discovery.mdx @@ -51,7 +51,6 @@ const result = ApiRoutes.parse(data); | **automation** | `string` | optional | e.g. /api/v1/automation | | **storage** | `string` | optional | e.g. /api/v1/storage | | **analytics** | `string` | optional | e.g. /api/v1/analytics | -| **graphql** | `string` | optional | e.g. /graphql | | **packages** | `string` | optional | e.g. /api/v1/packages | | **workflow** | `string` | optional | e.g. /api/v1/workflow | | **approvals** | `string` | optional | e.g. /api/v1/approvals | @@ -76,7 +75,7 @@ const result = ApiRoutes.parse(data); | **locale** | `{ default: string; supported: string[]; timezone: string }` | ✅ | | | **services** | `Record; handlerReady?: boolean; route?: string; … }>` | ✅ | Per-service availability map keyed by CoreServiceName | | **capabilities** | `Record; description?: string }>` | optional | Hierarchical capability descriptors for frontend intelligent adaptation | -| **schemaDiscovery** | `{ openapi?: string; graphql?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration | +| **schemaDiscovery** | `{ openapi?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration | | **metadata** | `Record` | optional | Custom metadata key-value pairs for extensibility | diff --git a/content/docs/references/api/dispatcher.mdx b/content/docs/references/api/dispatcher.mdx index 37daae854c..4aae69ddf5 100644 --- a/content/docs/references/api/dispatcher.mdx +++ b/content/docs/references/api/dispatcher.mdx @@ -51,7 +51,7 @@ const result = DispatcherConfig.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **routes** | `{ prefix: string; service: Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'graphql' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>; authRequired: boolean; criticality: Enum<'required' \| 'core' \| 'optional'>; … }[]` | ✅ | Route-to-service mappings | +| **routes** | `{ prefix: string; service: Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>; authRequired: boolean; criticality: Enum<'required' \| 'core' \| 'optional'>; … }[]` | ✅ | Route-to-service mappings | | **fallback** | `Enum<'404' \| 'proxy' \| 'custom'>` | ✅ | Behavior when no route matches | | **proxyTarget** | `string` | optional | Proxy target URL when fallback is "proxy" | @@ -91,7 +91,7 @@ const result = DispatcherConfig.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **prefix** | `string` | ✅ | URL path prefix for routing (e.g. /api/v1/data) | -| **service** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'graphql' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | Target core service name | +| **service** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | Target core service name | | **authRequired** | `boolean` | ✅ | Whether authentication is required | | **criticality** | `Enum<'required' \| 'core' \| 'optional'>` | ✅ | Service criticality level for unavailability handling | | **permissions** | `string[]` | optional | Required permissions for this route namespace | diff --git a/content/docs/references/api/documentation.mdx b/content/docs/references/api/documentation.mdx index ca2f8c254d..b0c71535af 100644 --- a/content/docs/references/api/documentation.mdx +++ b/content/docs/references/api/documentation.mdx @@ -9,7 +9,7 @@ API Documentation & Testing Interface Protocol Provides schemas for generating interactive API documentation and testing -interfaces similar to Swagger UI, GraphQL Playground, Postman, etc. +interfaces similar to Swagger UI, Postman, etc. Features: @@ -29,8 +29,6 @@ Architecture Alignment: - Postman: API testing collections -- GraphQL Playground: GraphQL-specific testing - - Redoc: Documentation rendering @example Documentation Config @@ -102,7 +100,7 @@ const result = ApiChangelogEntry.parse(data); | **version** | `string` | ✅ | API version | | **description** | `string` | optional | API description | | **servers** | `{ url: string; description?: string; variables?: Record }[]` | ✅ | API server URLs | -| **ui** | `{ type: Enum<'swagger-ui' \| 'redoc' \| 'rapidoc' \| 'stoplight' \| 'scalar' \| 'graphql-playground' \| 'graphiql' \| 'postman' \| 'custom'>; path: string; theme: Enum<'light' \| 'dark' \| 'auto'>; enableTryItOut: boolean; … }` | optional | Testing UI configuration | +| **ui** | `{ type: Enum<'swagger-ui' \| 'redoc' \| 'rapidoc' \| 'stoplight' \| 'scalar' \| 'graphiql' \| 'postman' \| 'custom'>; path: string; theme: Enum<'light' \| 'dark' \| 'auto'>; enableTryItOut: boolean; … }` | optional | Testing UI configuration | | **generateOpenApi** | `boolean` | ✅ | Generate OpenAPI 3.0 specification | | **generateTestCollections** | `boolean` | ✅ | Generate API test collections | | **testCollections** | `{ name: string; description?: string; variables: Record; requests: { name: string; description?: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE' \| 'HEAD' \| 'OPTIONS'>; url: string; … }[]; … }[]` | ✅ | Predefined test collections | @@ -158,7 +156,7 @@ const result = ApiChangelogEntry.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'swagger-ui' \| 'redoc' \| 'rapidoc' \| 'stoplight' \| 'scalar' \| 'graphql-playground' \| 'graphiql' \| 'postman' \| 'custom'>` | ✅ | Testing UI implementation | +| **type** | `Enum<'swagger-ui' \| 'redoc' \| 'rapidoc' \| 'stoplight' \| 'scalar' \| 'graphiql' \| 'postman' \| 'custom'>` | ✅ | Testing UI implementation | | **path** | `string` | ✅ | URL path for documentation UI | | **theme** | `Enum<'light' \| 'dark' \| 'auto'>` | ✅ | UI color theme | | **enableTryItOut** | `boolean` | ✅ | Enable interactive API testing | @@ -183,7 +181,6 @@ const result = ApiChangelogEntry.parse(data); * `rapidoc` * `stoplight` * `scalar` -* `graphql-playground` * `graphiql` * `postman` * `custom` diff --git a/content/docs/references/api/graphql.mdx b/content/docs/references/api/graphql.mdx deleted file mode 100644 index 6610fb4bb8..0000000000 --- a/content/docs/references/api/graphql.mdx +++ /dev/null @@ -1,498 +0,0 @@ ---- -title: Graphql -description: Graphql protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -GraphQL Protocol Support - -GraphQL is a query language for APIs and a runtime for executing those queries. - -It provides a complete and understandable description of the data in your API, - -gives clients the power to ask for exactly what they need, and enables powerful - -developer tools. - -## Overview - -GraphQL provides: - -- Type-safe schema definition - -- Precise data fetching (no over/under-fetching) - -- Introspection and documentation - -- Real-time subscriptions - -- Batched queries with DataLoader - -## Use Cases - -1. **Modern API Development** - -- Mobile and web applications - -- Microservices federation - -- Real-time dashboards - -2. **Data Aggregation** - -- Multi-source data integration - -- Complex nested queries - -- Efficient data loading - -3. **Developer Experience** - -- Self-documenting API - -- Type safety and validation - -- GraphQL playground - -See also: https://graphql.org/ - -See also: https://spec.graphql.org/ - -@example GraphQL Query - -```graphql - -query GetCustomer($id: ID!) \{ - -customer(id: $id) \{ - -id - -name - -email - -orders(limit: 10, status: "active") \{ - -id - -total - -items \{ - -product \{ - -name - -price - -\} - -\} - -\} - -\} - -\} - -``` - -@example GraphQL Mutation - -```graphql - -mutation CreateOrder($input: CreateOrderInput!) \{ - -createOrder(input: $input) \{ - -id - -orderNumber - -status - -\} - -\} - -``` - - -**Source:** `packages/spec/src/api/graphql.zod.ts` - - -## TypeScript Usage - -```typescript -import { FederationEntity, FederationEntityKey, FederationExternalField, FederationGateway, FederationProvides, FederationRequires, GraphQLConfig, GraphQLDataLoaderConfig, GraphQLDirectiveConfig, GraphQLDirectiveLocation, GraphQLMutationConfig, GraphQLPersistedQuery, GraphQLQueryComplexity, GraphQLQueryConfig, GraphQLQueryDepthLimit, GraphQLRateLimit, GraphQLResolverConfig, GraphQLScalarType, GraphQLSubscriptionConfig, GraphQLTypeConfig, SubgraphConfig } from '@objectstack/spec/api'; -import type { FederationEntity, FederationEntityKey, FederationExternalField, FederationGateway, FederationProvides, FederationRequires, GraphQLConfig, GraphQLDataLoaderConfig, GraphQLDirectiveConfig, GraphQLDirectiveLocation, GraphQLMutationConfig, GraphQLPersistedQuery, GraphQLQueryComplexity, GraphQLQueryConfig, GraphQLQueryDepthLimit, GraphQLRateLimit, GraphQLResolverConfig, GraphQLScalarType, GraphQLSubscriptionConfig, GraphQLTypeConfig, SubgraphConfig } from '@objectstack/spec/api'; - -// Validate data -const result = FederationEntity.parse(data); -``` - ---- - -## FederationEntity - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **typeName** | `string` | ✅ | GraphQL type name for this entity | -| **keys** | `{ fields: string; resolvable: boolean }[]` | ✅ | Entity key definitions | -| **externalFields** | `{ field: string; ownerSubgraph?: string }[]` | optional | Fields owned by other subgraphs | -| **requires** | `{ field: string; fields: string }[]` | optional | Required external fields for computed fields | -| **provides** | `{ field: string; fields: string }[]` | optional | Fields provided during resolution | -| **owner** | `boolean` | ✅ | Whether this subgraph is the owner of this entity | - - ---- - -## FederationEntityKey - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **fields** | `string` | ✅ | Selection set of fields composing the entity key | -| **resolvable** | `boolean` | ✅ | Whether entities can be resolved from this subgraph | - - ---- - -## FederationExternalField - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field name marked as external | -| **ownerSubgraph** | `string` | optional | Subgraph that owns this field | - - ---- - -## FederationGateway - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable GraphQL Federation gateway mode | -| **version** | `Enum<'v1' \| 'v2'>` | ✅ | Federation specification version | -| **subgraphs** | `{ name: string; url: string; schemaSource: Enum<'introspection' \| 'file' \| 'registry'>; schemaPath?: string; … }[]` | ✅ | Subgraph configurations | -| **serviceDiscovery** | `{ type: Enum<'static' \| 'dns' \| 'consul' \| 'kubernetes'>; pollIntervalMs?: integer; namespace?: string }` | optional | Service discovery configuration | -| **queryPlanning** | `{ strategy: Enum<'parallel' \| 'sequential' \| 'adaptive'>; maxDepth?: integer; dryRun: boolean }` | optional | Query planning configuration | -| **composition** | `{ conflictResolution: Enum<'error' \| 'first_wins' \| 'last_wins'>; validate: boolean }` | optional | Schema composition configuration | -| **errorHandling** | `{ includeSubgraphName: boolean; partialErrors: Enum<'propagate' \| 'nullify' \| 'reject'> }` | optional | Error handling configuration | - - ---- - -## FederationProvides - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field that provides additional entity fields | -| **fields** | `string` | ✅ | Selection set of provided fields (e.g., "name price") | - - ---- - -## FederationRequires - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **field** | `string` | ✅ | Field with the requirement | -| **fields** | `string` | ✅ | Selection set of required fields (e.g., "price weight") | - - ---- - -## GraphQLConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | optional | Enable GraphQL API | -| **path** | `string` | optional | GraphQL endpoint path | -| **playground** | `{ enabled?: boolean; path?: string }` | optional | GraphQL Playground configuration | -| **schema** | `{ autoGenerateTypes?: boolean; types?: { name: string; object: string; description?: string; fields?: object; … }[]; queries?: { name: string; object: string; type: Enum<'get' \| 'list' \| 'search'>; description?: string; … }[]; mutations?: { name: string; object: string; type: Enum<'create' \| 'update' \| 'delete' \| 'upsert' \| 'custom'>; description?: string; … }[]; … }` | optional | Schema generation configuration | -| **dataLoaders** | `{ name: string; source: string; batchFunction: object; cache?: object; … }[]` | optional | DataLoader configurations | -| **security** | `{ depthLimit?: object; complexity?: object; rateLimit?: object; persistedQueries?: object }` | optional | Security configuration | -| **federation** | `{ enabled?: boolean; version?: Enum<'v1' \| 'v2'>; subgraphs: { name: string; url: string; schemaSource?: Enum<'introspection' \| 'file' \| 'registry'>; schemaPath?: string; … }[]; serviceDiscovery?: object; … }` | optional | GraphQL Federation gateway configuration | - - ---- - -## GraphQLDataLoaderConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | DataLoader name | -| **source** | `string` | ✅ | Source object or datasource | -| **batchFunction** | `{ type: Enum<'findByIds' \| 'query' \| 'script' \| 'custom'>; keyField?: string; query?: string; script?: string; … }` | ✅ | Batch function configuration | -| **cache** | `{ enabled: boolean; keyFn?: string }` | optional | DataLoader caching | -| **options** | `{ batch: boolean; cache: boolean; maxCacheSize?: integer }` | optional | DataLoader options | - - ---- - -## GraphQLDirectiveConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Directive name (camelCase) | -| **description** | `string` | optional | Directive description | -| **locations** | `Enum<'QUERY' \| 'MUTATION' \| 'SUBSCRIPTION' \| 'FIELD' \| 'FRAGMENT_DEFINITION' \| 'FRAGMENT_SPREAD' \| 'INLINE_FRAGMENT' \| 'VARIABLE_DEFINITION' \| 'SCHEMA' \| 'SCALAR' \| 'OBJECT' \| 'FIELD_DEFINITION' \| 'ARGUMENT_DEFINITION' \| 'INTERFACE' \| 'UNION' \| 'ENUM' \| 'ENUM_VALUE' \| 'INPUT_OBJECT' \| 'INPUT_FIELD_DEFINITION'>[]` | ✅ | Directive locations | -| **args** | `Record` | optional | Directive arguments | -| **repeatable** | `boolean` | ✅ | Can be applied multiple times | -| **implementation** | `{ type: Enum<'auth' \| 'validation' \| 'transform' \| 'cache' \| 'deprecation' \| 'custom'>; handler?: string }` | optional | Directive implementation | - - ---- - -## GraphQLDirectiveLocation - -### Allowed Values - -* `QUERY` -* `MUTATION` -* `SUBSCRIPTION` -* `FIELD` -* `FRAGMENT_DEFINITION` -* `FRAGMENT_SPREAD` -* `INLINE_FRAGMENT` -* `VARIABLE_DEFINITION` -* `SCHEMA` -* `SCALAR` -* `OBJECT` -* `FIELD_DEFINITION` -* `ARGUMENT_DEFINITION` -* `INTERFACE` -* `UNION` -* `ENUM` -* `ENUM_VALUE` -* `INPUT_OBJECT` -* `INPUT_FIELD_DEFINITION` - - ---- - -## GraphQLMutationConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Mutation field name (camelCase recommended) | -| **object** | `string` | ✅ | Source ObjectQL object name | -| **type** | `Enum<'create' \| 'update' \| 'delete' \| 'upsert' \| 'custom'>` | ✅ | Mutation type | -| **description** | `string` | optional | Mutation description | -| **input** | `{ typeName?: string; fields?: object; validation?: object }` | optional | Input configuration | -| **output** | `{ type: Enum<'object' \| 'payload' \| 'boolean' \| 'custom'>; includeEnvelope: boolean; customType?: string }` | optional | Output configuration | -| **transaction** | `{ enabled: boolean; isolationLevel?: Enum<'read_uncommitted' \| 'read_committed' \| 'repeatable_read' \| 'serializable'> }` | optional | Transaction configuration | -| **authRequired** | `boolean` | ✅ | Require authentication | -| **permissions** | `string[]` | optional | Required permissions | -| **hooks** | `{ before?: string[]; after?: string[] }` | optional | Lifecycle hooks | - - ---- - -## GraphQLPersistedQuery - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable persisted queries | -| **mode** | `Enum<'optional' \| 'required'>` | ✅ | Persisted query mode (optional: allow both, required: only persisted) | -| **store** | `{ type: Enum<'memory' \| 'redis' \| 'database' \| 'file'>; connection?: string; ttl?: integer }` | optional | Query store configuration | -| **apq** | `{ enabled: boolean; hashAlgorithm: Enum<'sha256' \| 'sha1' \| 'md5'>; cache?: object }` | optional | Automatic Persisted Queries configuration | -| **allowlist** | `{ enabled: boolean; queries?: { id: string; operation?: string; query?: string }[]; source?: string }` | optional | Query allow list configuration | -| **security** | `{ maxQuerySize?: integer; rejectIntrospection: boolean }` | optional | Security configuration | - - ---- - -## GraphQLQueryComplexity - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable query complexity limiting | -| **maxComplexity** | `integer` | ✅ | Maximum query complexity | -| **defaultFieldComplexity** | `integer` | ✅ | Default complexity per field | -| **fieldComplexity** | `Record` | optional | Per-field complexity configuration | -| **listMultiplier** | `number` | ✅ | Multiplier for list fields | -| **onComplexityExceeded** | `Enum<'reject' \| 'log' \| 'warn'>` | ✅ | Action when complexity exceeded | -| **errorMessage** | `string` | optional | Custom error message for complexity violations | - - ---- - -## GraphQLQueryConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Query field name (camelCase recommended) | -| **object** | `string` | ✅ | Source ObjectQL object name | -| **type** | `Enum<'get' \| 'list' \| 'search'>` | ✅ | Query type | -| **description** | `string` | optional | Query description | -| **args** | `Record` | optional | Query arguments | -| **filtering** | `{ enabled?: boolean; fields?: string[]; operators?: Enum<'eq' \| 'ne' \| 'gt' \| 'gte' \| 'lt' \| 'lte' \| 'in' \| 'notIn' \| 'contains' \| 'startsWith' \| 'endsWith' \| 'isNull' \| 'isNotNull'>[] }` | optional | Filtering capabilities | -| **sorting** | `{ enabled?: boolean; fields?: string[]; defaultSort?: object }` | optional | Sorting capabilities | -| **pagination** | `{ enabled?: boolean; type?: Enum<'offset' \| 'cursor' \| 'relay'>; defaultLimit?: integer; maxLimit?: integer; … }` | optional | Pagination configuration | -| **fields** | `{ required?: string[]; selectable?: string[] }` | optional | Field selection configuration | -| **authRequired** | `boolean` | optional | Require authentication | -| **permissions** | `string[]` | optional | Required permissions | -| **cache** | `{ enabled?: boolean; ttl?: integer; key?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object } }` | optional | Query caching | - - ---- - -## GraphQLQueryDepthLimit - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable query depth limiting | -| **maxDepth** | `integer` | ✅ | Maximum query depth | -| **ignoreFields** | `string[]` | optional | Fields excluded from depth calculation | -| **onDepthExceeded** | `Enum<'reject' \| 'log' \| 'warn'>` | ✅ | Action when depth exceeded | -| **errorMessage** | `string` | optional | Custom error message for depth violations | - - ---- - -## GraphQLRateLimit - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **enabled** | `boolean` | ✅ | Enable rate limiting | -| **strategy** | `Enum<'token_bucket' \| 'fixed_window' \| 'sliding_window' \| 'cost_based'>` | ✅ | Rate limiting strategy | -| **global** | `{ maxRequests: integer; windowMs: integer }` | optional | Global rate limits | -| **perUser** | `{ maxRequests: integer; windowMs: integer }` | optional | Per-user rate limits | -| **costBased** | `{ enabled: boolean; maxCost: integer; windowMs: integer; useComplexityAsCost: boolean }` | optional | Cost-based rate limiting | -| **operations** | `Record` | optional | Per-operation rate limits | -| **onLimitExceeded** | `Enum<'reject' \| 'queue' \| 'log'>` | ✅ | Action when rate limit exceeded | -| **errorMessage** | `string` | optional | Custom error message for rate limit violations | -| **includeHeaders** | `boolean` | ✅ | Include rate limit headers in response | - - ---- - -## GraphQLResolverConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **path** | `string` | ✅ | Resolver path (Type.field) | -| **type** | `Enum<'datasource' \| 'computed' \| 'script' \| 'proxy'>` | ✅ | Resolver implementation type | -| **implementation** | `{ datasource?: string; query?: string; expression?: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; dependencies?: string[]; … }` | optional | Implementation configuration | -| **cache** | `{ enabled?: boolean; ttl?: integer; keyArgs?: string[] }` | optional | Resolver caching | - - ---- - -## GraphQLScalarType - -### Allowed Values - -* `ID` -* `String` -* `Int` -* `Float` -* `Boolean` -* `DateTime` -* `Date` -* `Time` -* `JSON` -* `JSONObject` -* `Upload` -* `URL` -* `Email` -* `PhoneNumber` -* `Currency` -* `Decimal` -* `BigInt` -* `Long` -* `UUID` -* `Base64` -* `Void` - - ---- - -## GraphQLSubscriptionConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Subscription field name (camelCase recommended) | -| **object** | `string` | ✅ | Source ObjectQL object name | -| **events** | `Enum<'created' \| 'updated' \| 'deleted' \| 'custom'>[]` | ✅ | Events to subscribe to (not yet implemented — no subscription transport exists; see #3197) | -| **description** | `string` | optional | Subscription description | -| **filter** | `{ enabled: boolean; fields?: string[] }` | optional | Subscription filtering | -| **payload** | `{ includeEntity: boolean; includePreviousValues: boolean; includeMeta: boolean }` | optional | Payload configuration | -| **authRequired** | `boolean` | ✅ | Require authentication | -| **permissions** | `string[]` | optional | Required permissions | -| **rateLimit** | `{ enabled: boolean; maxSubscriptionsPerUser: integer; throttleMs?: integer }` | optional | Subscription rate limiting | - - ---- - -## GraphQLTypeConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | GraphQL type name (PascalCase recommended) | -| **object** | `string` | ✅ | Source ObjectQL object name | -| **description** | `string` | optional | Type description | -| **fields** | `{ include?: string[]; exclude?: string[]; mappings?: Record }` | optional | Field configuration | -| **interfaces** | `string[]` | optional | GraphQL interface names | -| **isInterface** | `boolean` | ✅ | Define as GraphQL interface | -| **directives** | `{ name: string; args?: Record }[]` | optional | GraphQL directives | - - ---- - -## SubgraphConfig - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Unique subgraph identifier | -| **url** | `string` | ✅ | Subgraph endpoint URL | -| **schemaSource** | `Enum<'introspection' \| 'file' \| 'registry'>` | ✅ | How to obtain the subgraph schema | -| **schemaPath** | `string` | optional | Path to schema file (SDL format) | -| **entities** | `{ typeName: string; keys: { fields: string; resolvable: boolean }[]; externalFields?: { field: string; ownerSubgraph?: string }[]; requires?: { field: string; fields: string }[]; … }[]` | optional | Entity definitions for this subgraph | -| **healthCheck** | `{ enabled: boolean; path: string; intervalMs: integer }` | optional | Subgraph health check configuration | -| **forwardHeaders** | `string[]` | optional | HTTP headers to forward to this subgraph | - - ---- - diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx index 42d4680c3e..02f1bc3c07 100644 --- a/content/docs/references/api/index.mdx +++ b/content/docs/references/api/index.mdx @@ -19,7 +19,6 @@ This section contains all protocol schemas for the api layer of ObjectStack. - diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index 6561b8ad10..f2d3255ddd 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -14,7 +14,6 @@ "versioning", "---Transport & Realtime---", "dispatcher", - "graphql", "http", "http-cache", "odata", diff --git a/content/docs/references/api/protocol.mdx b/content/docs/references/api/protocol.mdx index d9e71e3548..ac7b9fab4c 100644 --- a/content/docs/references/api/protocol.mdx +++ b/content/docs/references/api/protocol.mdx @@ -459,7 +459,7 @@ const result = AiInsightsRequest.parse(data); | **locale** | `{ default: string; supported: string[]; timezone: string }` | optional | | | **services** | `Record; handlerReady?: boolean; route?: string; … }>` | optional | Per-service availability map keyed by CoreServiceName | | **capabilities** | `Record; description?: string }>` | optional | Hierarchical capability descriptors for frontend intelligent adaptation | -| **schemaDiscovery** | `{ openapi?: string; graphql?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration | +| **schemaDiscovery** | `{ openapi?: string; jsonSchema?: string }` | optional | Schema discovery endpoints for API toolchain integration | | **metadata** | `Record` | optional | Custom metadata key-value pairs for extensibility | | **apiName** | `string` | optional | API name (deprecated — use name) | diff --git a/content/docs/references/api/query-adapter.mdx b/content/docs/references/api/query-adapter.mdx index c383246eb5..8d1edc966b 100644 --- a/content/docs/references/api/query-adapter.mdx +++ b/content/docs/references/api/query-adapter.mdx @@ -11,7 +11,7 @@ Defines mapping rules between the internal unified query DSL (defined in `[data/query.zod.ts](/docs/references/data/query)`) and external API protocol formats: -REST, GraphQL, and OData. +REST and OData. This enables ObjectStack to expose a single internal query representation @@ -21,8 +21,6 @@ See also: [data/query.zod.ts](/docs/references/data/query) - Unified internal qu See also: [api/rest-server.zod.ts](/docs/references/api/rest-server) - REST API configuration -See also: [api/graphql.zod.ts](/docs/references/api/graphql) - GraphQL API configuration - See also: [api/odata.zod.ts](/docs/references/api/odata) - OData API configuration @@ -32,27 +30,13 @@ See also: [api/odata.zod.ts](/docs/references/api/odata) - OData API configurati ## TypeScript Usage ```typescript -import { GraphQLQueryAdapter, ODataQueryAdapter, OperatorMapping, QueryAdapterConfig, QueryAdapterTarget, RestQueryAdapter } from '@objectstack/spec/api'; -import type { GraphQLQueryAdapter, ODataQueryAdapter, OperatorMapping, QueryAdapterConfig, QueryAdapterTarget, RestQueryAdapter } from '@objectstack/spec/api'; +import { ODataQueryAdapter, OperatorMapping, QueryAdapterConfig, QueryAdapterTarget, RestQueryAdapter } from '@objectstack/spec/api'; +import type { ODataQueryAdapter, OperatorMapping, QueryAdapterConfig, QueryAdapterTarget, RestQueryAdapter } from '@objectstack/spec/api'; // Validate data -const result = GraphQLQueryAdapter.parse(data); +const result = ODataQueryAdapter.parse(data); ``` ---- - -## GraphQLQueryAdapter - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **filterArgName** | `string` | ✅ | GraphQL filter argument name | -| **filterStyle** | `Enum<'nested' \| 'flat' \| 'array'>` | ✅ | GraphQL filter nesting style | -| **pagination** | `{ limitArg: string; offsetArg: string; firstArg: string; afterArg: string }` | optional | Pagination argument name mappings | -| **sorting** | `{ argName: string; format: Enum<'enum' \| 'array'> }` | optional | Sort argument mapping | - - --- ## ODataQueryAdapter @@ -77,7 +61,6 @@ const result = GraphQLQueryAdapter.parse(data); | :--- | :--- | :--- | :--- | | **operator** | `string` | ✅ | Unified DSL operator | | **rest** | `string` | optional | REST query parameter template | -| **graphql** | `string` | optional | GraphQL where clause template | | **odata** | `string` | optional | OData $filter expression template | @@ -89,9 +72,8 @@ const result = GraphQLQueryAdapter.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **operatorMappings** | `{ operator: string; rest?: string; graphql?: string; odata?: string }[]` | optional | Custom operator mappings | +| **operatorMappings** | `{ operator: string; rest?: string; odata?: string }[]` | optional | Custom operator mappings | | **rest** | `{ filterStyle: Enum<'bracket' \| 'dot' \| 'flat' \| 'rsql'>; pagination?: object; sorting?: object; fieldsParam: string }` | optional | REST query adapter configuration | -| **graphql** | `{ filterArgName: string; filterStyle: Enum<'nested' \| 'flat' \| 'array'>; pagination?: object; sorting?: object }` | optional | GraphQL query adapter configuration | | **odata** | `{ version: Enum<'v2' \| 'v4'>; usePrefix: boolean; stringFunctions?: Enum<'contains' \| 'startswith' \| 'endswith' \| 'tolower' \| 'toupper' \| 'trim' \| 'concat' \| 'substring' \| 'length'>[]; expand?: object }` | optional | OData query adapter configuration | @@ -102,7 +84,6 @@ const result = GraphQLQueryAdapter.parse(data); ### Allowed Values * `rest` -* `graphql` * `odata` diff --git a/content/docs/references/api/registry.mdx b/content/docs/references/api/registry.mdx index f53f86ae24..daca7170fa 100644 --- a/content/docs/references/api/registry.mdx +++ b/content/docs/references/api/registry.mdx @@ -9,7 +9,7 @@ Unified API Registry Protocol Provides a centralized registry for managing all API endpoints across different -API types (REST, GraphQL, OData, WebSocket, Auth, File, Plugin-registered). +API types (REST, OData, WebSocket, Auth, File, Plugin-registered). This enables: @@ -83,7 +83,7 @@ const result = ApiDiscoveryQuery.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **type** | `Enum<'rest' \| 'graphql' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>` | optional | Filter by API protocol type | +| **type** | `Enum<'rest' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>` | optional | Filter by API protocol type | | **tags** | `string[]` | optional | Filter by tags (ANY match) | | **status** | `Enum<'active' \| 'deprecated' \| 'experimental' \| 'beta'>` | optional | Filter by lifecycle status | | **pluginSource** | `string` | optional | Filter by plugin name | @@ -99,9 +99,9 @@ const result = ApiDiscoveryQuery.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **apis** | `{ id: string; name: string; type: Enum<'rest' \| 'graphql' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>; version: string; … }[]` | ✅ | Matching API entries | +| **apis** | `{ id: string; name: string; type: Enum<'rest' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>; version: string; … }[]` | ✅ | Matching API entries | | **total** | `integer` | ✅ | Total matching APIs | -| **filters** | `{ type?: Enum<'rest' \| 'graphql' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>; tags?: string[]; status?: Enum<'active' \| 'deprecated' \| 'experimental' \| 'beta'>; pluginSource?: string; … }` | optional | Applied query filters | +| **filters** | `{ type?: Enum<'rest' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>; tags?: string[]; status?: Enum<'active' \| 'deprecated' \| 'experimental' \| 'beta'>; pluginSource?: string; … }` | optional | Applied query filters | --- @@ -169,7 +169,6 @@ const result = ApiDiscoveryQuery.parse(data); ### Allowed Values * `rest` -* `graphql` * `odata` * `websocket` * `file` @@ -190,11 +189,11 @@ const result = ApiDiscoveryQuery.parse(data); | :--- | :--- | :--- | :--- | | **version** | `string` | ✅ | Registry version | | **conflictResolution** | `Enum<'error' \| 'priority' \| 'first-wins' \| 'last-wins'>` | ✅ | Strategy for handling route conflicts | -| **apis** | `{ id: string; name: string; type: Enum<'rest' \| 'graphql' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>; version: string; … }[]` | ✅ | All registered APIs | +| **apis** | `{ id: string; name: string; type: Enum<'rest' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>; version: string; … }[]` | ✅ | All registered APIs | | **totalApis** | `integer` | ✅ | Total number of registered APIs | | **totalEndpoints** | `integer` | ✅ | Total number of endpoints | -| **byType** | `Record; version: string; … }[]>` | optional | APIs grouped by protocol type | -| **byStatus** | `Record; version: string; … }[]>` | optional | APIs grouped by status | +| **byType** | `Record; version: string; … }[]>` | optional | APIs grouped by protocol type | +| **byStatus** | `Record; version: string; … }[]>` | optional | APIs grouped by status | | **updatedAt** | `string` | optional | Last registry update time | @@ -208,7 +207,7 @@ const result = ApiDiscoveryQuery.parse(data); | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | Unique API identifier (snake_case) | | **name** | `string` | ✅ | API display name | -| **type** | `Enum<'rest' \| 'graphql' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>` | ✅ | API protocol type | +| **type** | `Enum<'rest' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>` | ✅ | API protocol type | | **version** | `string` | ✅ | API version (e.g., v1, 2024-01) | | **basePath** | `string` | ✅ | Base URL path for this API | | **description** | `string` | optional | API description | diff --git a/content/docs/references/api/rest-server.mdx b/content/docs/references/api/rest-server.mdx index 50d4921ffe..73976d56c9 100644 --- a/content/docs/references/api/rest-server.mdx +++ b/content/docs/references/api/rest-server.mdx @@ -192,7 +192,7 @@ const result = BatchEndpointsConfig.parse(data); | **enableOpenApi** | `boolean` | ✅ | Enable OpenAPI 3.1 spec & docs viewer endpoints | | **enableProjectScoping** | `boolean` | ✅ | Enable project-scoped routing for data/meta/AI APIs | | **projectResolution** | `Enum<'required' \| 'optional' \| 'auto'>` | ✅ | Project ID resolution strategy | -| **requireAuth** | `boolean` | ✅ | Reject anonymous requests on ALL HTTP surfaces that reach object data (REST /data, /meta, GraphQL, raw-hono /data) with HTTP 401 (secure-by-default; set false to serve them publicly) | +| **requireAuth** | `boolean` | ✅ | Reject anonymous requests on ALL HTTP surfaces that reach object data (REST /data, /meta, raw-hono /data) with HTTP 401 (secure-by-default; set false to serve them publicly) | | **documentation** | `{ enabled: boolean; title: string; description?: string; version?: string; … }` | optional | OpenAPI/Swagger documentation config | | **responseFormat** | `{ envelope: boolean; includeMetadata: boolean; includePagination: boolean }` | optional | Response format options | diff --git a/content/docs/references/system/core-services.mdx b/content/docs/references/system/core-services.mdx index 98a1707459..5a28e0fba2 100644 --- a/content/docs/references/system/core-services.mdx +++ b/content/docs/references/system/core-services.mdx @@ -45,7 +45,6 @@ const result = CoreServiceName.parse(data); * `cache` * `queue` * `automation` -* `graphql` * `analytics` * `realtime` * `job` @@ -68,7 +67,7 @@ const result = CoreServiceName.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **id** | `string` | ✅ | | -| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'graphql' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | | +| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | | | **options** | `Record` | optional | | @@ -91,7 +90,7 @@ const result = CoreServiceName.parse(data); | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'graphql' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | | +| **name** | `Enum<'metadata' \| 'data' \| 'auth' \| 'file-storage' \| 'search' \| 'cache' \| 'queue' \| 'automation' \| 'analytics' \| 'realtime' \| 'job' \| 'notification' \| 'ai' \| 'i18n' \| 'ui' \| 'workflow'>` | ✅ | | | **enabled** | `boolean` | ✅ | | | **status** | `Enum<'running' \| 'stopped' \| 'degraded' \| 'initializing'>` | ✅ | | | **version** | `string` | optional | | diff --git a/content/docs/releases/implementation-status.mdx b/content/docs/releases/implementation-status.mdx index 77016c08f7..4251daf795 100644 --- a/content/docs/releases/implementation-status.mdx +++ b/content/docs/releases/implementation-status.mdx @@ -192,7 +192,6 @@ The data (`/data`), metadata (`/meta`), and batch endpoints are implemented in ` |:---------|:-------------:|:------:|:------| | **Analytics** | ✅ | ✅ | ObjectQL aggregation plus `@objectstack/service-analytics` dataset execution; analytics read scope auto-bridges to `security.getReadFilter` for RLS-aware dashboards/reports | | **OData** | ❌ | 📋 | Protocol defined, not implemented | -| **GraphQL** | ❌ | 📋 | Protocol defined, not implemented | | **Realtime** | @objectstack/service-realtime | ⚠️ | Realtime service with in-memory adapter shipped; production adapters in progress | | **WebSocket** | @objectstack/service-realtime | ⚠️ | Transport provided via the realtime service in-memory adapter | @@ -393,7 +392,6 @@ describe that separate runtime. ### Phase 6: Advanced Features 🚧 **IN PROGRESS** - [x] Production Database Drivers — `@objectstack/driver-sql` (PostgreSQL/MySQL dialects), `@objectstack/driver-mongodb`, and `@objectstack/driver-sqlite-wasm` ship; additional dialect coverage ongoing -- [ ] GraphQL API - [ ] OData Support - [ ] Realtime Subscriptions - [ ] WebSocket Support @@ -450,7 +448,7 @@ describe that separate runtime. |:---------|:---------------:|:-------| | **Data** | 16 | Core modeling, hooks, and query engine fully implemented; document/mapping/external-lookup still pending | | **UI** | 10 | Studio/ObjectUI render most authored surfaces (🟡); full cross-surface renderer parity in progress | -| **API** | 14 | REST/HTTP/discovery/batch fully implemented; OData/GraphQL pending | +| **API** | 14 | REST/HTTP/discovery/batch fully implemented; OData pending; GraphQL removed from the plan | | **System** | 39 | Logging, audit, job, translation, metrics, notification implemented; several governance services pending | | **Auth** (plugin) | 10 | Permission + RLS live (`plugin-security`); identity/sharing/territory still plugin-pending | | **Automation** (plugin) | 7 | Flow, workflow, approval, webhook, and triggers implemented; ETL/Sync pending | @@ -460,7 +458,7 @@ describe that separate runtime. ### Implementation Coverage -Implementation spans every layer of the platform. Core infrastructure, data modeling, the REST API, client SDKs, security (Phase-1), automation, AI, and integration all have shipping implementations. Remaining gaps are concentrated in specific protocols (OData/GraphQL, cross-surface UI renderer parity, sharing/territory authorization, ETL/Sync, and a handful of governance and AI-cost services) rather than entire layers. Refer to the per-layer tables above for protocol-level status. +Implementation spans every layer of the platform. Core infrastructure, data modeling, the REST API, client SDKs, security (Phase-1), automation, AI, and integration all have shipping implementations. Remaining gaps are concentrated in specific protocols (OData, cross-surface UI renderer parity, sharing/territory authorization, ETL/Sync, and a handful of governance and AI-cost services) rather than entire layers. Refer to the per-layer tables above for protocol-level status. ### Core Functionality Status diff --git a/packages/adapters/hono/src/hono.test.ts b/packages/adapters/hono/src/hono.test.ts index 5be983e217..7323c10112 100644 --- a/packages/adapters/hono/src/hono.test.ts +++ b/packages/adapters/hono/src/hono.test.ts @@ -253,36 +253,6 @@ describe('createHonoApp', () => { }); }); - describe('GraphQL Endpoint', () => { - it('POST /api/graphql calls handleGraphQL', async () => { - const body = { query: '{ objects { name } }' }; - const res = await app.request('/api/graphql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - expect(res.status).toBe(200); - const json = await res.json(); - expect(json.data).toBeDefined(); - expect(mockDispatcher.handleGraphQL).toHaveBeenCalledWith( - body, - expect.objectContaining({ request: expect.anything() }), - ); - }); - - it('returns error on handleGraphQL exception', async () => { - mockDispatcher.handleGraphQL.mockRejectedValueOnce(new Error('Parse error')); - const res = await app.request('/api/graphql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query: 'bad' }), - }); - expect(res.status).toBe(500); - const json = await res.json(); - expect(json.success).toBe(false); - expect(json.error.message).toBe('Parse error'); - }); - }); describe('Catch-all Dispatch', () => { it('GET /api/meta/objects delegates to dispatch()', async () => { diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index ee1461746a..a060e70531 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -337,17 +337,6 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { } }); - // --- GraphQL (returns raw result, not HttpDispatcherResult) --- - app.post(`${prefix}/graphql`, async (c) => { - try { - const body = await c.req.json(); - const result = await dispatcher.handleGraphQL(body, { request: c.req.raw }); - return c.json(result); - } catch (err: any) { - return errorJson(c, err.message || 'Internal Server Error', err.statusCode || 500); - } - }); - // --- Storage (needs formData parsing) --- app.all(`${prefix}/storage/*`, async (c) => { try { diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 62b88672fc..c550f15916 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -3289,7 +3289,6 @@ export class ObjectStackClient { notifications: '/api/v1/notifications', ai: '/api/v1/ai', i18n: '/api/v1/i18n', - graphql: '/graphql', }; return routeMap[type] || `/api/v1/${type}`; diff --git a/packages/core/examples/api-registry-example.ts b/packages/core/examples/api-registry-example.ts index 2cb428b267..2bbf732af3 100644 --- a/packages/core/examples/api-registry-example.ts +++ b/packages/core/examples/api-registry-example.ts @@ -208,33 +208,6 @@ async function example2_MultiPluginDiscovery() { }, }; - // GraphQL Plugin - const graphqlPlugin: Plugin = { - name: 'graphql-plugin', - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - - registry.registerApi({ - id: 'graphql_api', - name: 'GraphQL API', - type: 'graphql', - version: 'v1', - basePath: '/graphql', - endpoints: [ - { - id: 'query', - path: '/graphql', - summary: 'GraphQL Query Endpoint', - responses: [], - }, - ], - metadata: { - status: 'active', - tags: ['query', 'flexible'], - }, - }); - }, - }; // Analytics Plugin - Beta API const analyticsPlugin: Plugin = { @@ -265,7 +238,6 @@ async function example2_MultiPluginDiscovery() { }; kernel.use(dataPlugin); - kernel.use(graphqlPlugin); kernel.use(analyticsPlugin); await kernel.bootstrap(); diff --git a/packages/core/src/api-registry-plugin.test.ts b/packages/core/src/api-registry-plugin.test.ts index 41a5e8edf7..95aac65d98 100644 --- a/packages/core/src/api-registry-plugin.test.ts +++ b/packages/core/src/api-registry-plugin.test.ts @@ -121,13 +121,13 @@ describe('API Registry Plugin', () => { registry.registerApi({ id: 'api2', name: 'API 2', - type: 'graphql', + type: 'odata', version: 'v1', - basePath: '/graphql', + basePath: '/odata', endpoints: [ { id: 'query', - path: '/graphql', + path: '/odata', responses: [], }, ], @@ -143,7 +143,7 @@ describe('API Registry Plugin', () => { const stats = registry.getStats(); expect(stats.totalApis).toBe(2); expect(stats.apisByType.rest).toBe(1); - expect(stats.apisByType.graphql).toBe(1); + expect(stats.apisByType.odata).toBe(1); await kernel.shutdown(); }); diff --git a/packages/core/src/api-registry.test.ts b/packages/core/src/api-registry.test.ts index a800a10dd7..1818bbc720 100644 --- a/packages/core/src/api-registry.test.ts +++ b/packages/core/src/api-registry.test.ts @@ -469,11 +469,11 @@ describe('ApiRegistry', () => { }); registry.registerApi({ - id: 'graphql_api', - name: 'GraphQL API', - type: 'graphql', + id: 'odata_api', + name: 'OData API', + type: 'odata', version: 'v1', - basePath: '/graphql', + basePath: '/odata', endpoints: [], metadata: { status: 'active', @@ -525,9 +525,9 @@ describe('ApiRegistry', () => { }); it('should search in name and description', () => { - const result = registry.findApis({ search: 'graphql' }); + const result = registry.findApis({ search: 'odata' }); expect(result.total).toBe(1); - expect(result.apis[0].id).toBe('graphql_api'); + expect(result.apis[0].id).toBe('odata_api'); }); it('should combine multiple filters', () => { @@ -650,15 +650,15 @@ describe('ApiRegistry', () => { registry.registerApi({ id: 'graphql1', name: 'GraphQL 1', - type: 'graphql', + type: 'odata', version: 'v1', - basePath: '/graphql', + basePath: '/odata', endpoints: [], }); const snapshot = registry.getRegistry(); expect(snapshot.byType?.rest?.length).toBe(2); - expect(snapshot.byType?.graphql?.length).toBe(1); + expect(snapshot.byType?.odata?.length).toBe(1); }); }); @@ -700,11 +700,11 @@ describe('ApiRegistry', () => { registry.registerApi({ id: 'api2', name: 'API 2', - type: 'graphql', + type: 'odata', version: 'v1', - basePath: '/graphql', + basePath: '/odata', endpoints: [ - { id: 'query', path: '/graphql', responses: [] }, + { id: 'query', path: '/odata', responses: [] }, ], }); @@ -713,24 +713,24 @@ describe('ApiRegistry', () => { expect(stats.totalEndpoints).toBe(3); expect(stats.totalRoutes).toBe(3); expect(stats.apisByType.rest).toBe(1); - expect(stats.apisByType.graphql).toBe(1); + expect(stats.apisByType.odata).toBe(1); expect(stats.endpointsByApi.api1).toBe(2); expect(stats.endpointsByApi.api2).toBe(1); }); }); describe('Multi-protocol Support', () => { - it('should register GraphQL API', () => { + it('should register OData API', () => { const api: ApiRegistryEntryInput = { - id: 'graphql', - name: 'GraphQL API', - type: 'graphql', + id: 'odata', + name: 'OData API', + type: 'odata', version: 'v1', - basePath: '/graphql', + basePath: '/odata', endpoints: [ { id: 'query', - path: '/graphql', + path: '/odata', summary: 'GraphQL Query', responses: [], }, @@ -738,7 +738,7 @@ describe('ApiRegistry', () => { }; registry.registerApi(api); - expect(registry.getApi('graphql')?.type).toBe('graphql'); + expect(registry.getApi('odata')?.type).toBe('odata'); }); it('should register WebSocket API', () => { @@ -818,12 +818,12 @@ describe('ApiRegistry', () => { }); registry.registerApi({ - id: 'graphql_api', - name: 'GraphQL API', - type: 'graphql', + id: 'odata_api', + name: 'OData API', + type: 'odata', version: 'v1', - basePath: '/graphql', - endpoints: [{ id: 'e3', path: '/graphql', responses: [] }], + basePath: '/odata', + endpoints: [{ id: 'e3', path: '/odata', responses: [] }], }); // Should efficiently find all REST APIs @@ -831,10 +831,10 @@ describe('ApiRegistry', () => { expect(restApis.total).toBe(2); expect(restApis.apis.every(api => api.type === 'rest')).toBe(true); - // Should efficiently find GraphQL APIs - const graphqlApis = registry.findApis({ type: 'graphql' }); + // Should efficiently find OData APIs + const graphqlApis = registry.findApis({ type: 'odata' }); expect(graphqlApis.total).toBe(1); - expect(graphqlApis.apis[0].id).toBe('graphql_api'); + expect(graphqlApis.apis[0].id).toBe('odata_api'); }); it('should use indices for fast tag-based lookups', () => { @@ -942,10 +942,10 @@ describe('ApiRegistry', () => { registry.registerApi({ id: 'graphql_crm_active', name: 'GraphQL CRM Active', - type: 'graphql', + type: 'odata', version: 'v1', - basePath: '/graphql', - endpoints: [{ id: 'e3', path: '/graphql', responses: [] }], + basePath: '/odata', + endpoints: [{ id: 'e3', path: '/odata', responses: [] }], metadata: { status: 'active', tags: ['crm'] }, }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 5ee049355a..8b3b3cd2e8 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -1436,7 +1436,6 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocol { notification: 'notifications', ai: 'ai', i18n: 'i18n', - graphql: 'graphql', 'file-storage': 'storage', }; diff --git a/packages/plugins/plugin-dev/src/dev-plugin.test.ts b/packages/plugins/plugin-dev/src/dev-plugin.test.ts index d7d096d849..d9a7550eec 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.test.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.test.ts @@ -159,12 +159,6 @@ describe('DevPlugin', () => { expect(execResult.success).toBe(true); expect(Array.isArray(await automation.listFlows())).toBe(true); - // ── Verify IGraphQLService contract ── - const gql = registeredServices.get('graphql'); - const gqlResult = await gql.execute({ query: '{ _dev }' }); - expect('data' in gqlResult || 'errors' in gqlResult).toBe(true); - expect(typeof gql.getSchema()).toBe('string'); - // ── Verify IAnalyticsService contract ── const analytics = registeredServices.get('analytics'); const analyticsResult = await analytics.query({ cube: 'test' }); diff --git a/packages/plugins/plugin-dev/src/dev-plugin.ts b/packages/plugins/plugin-dev/src/dev-plugin.ts index cd3cb48ad3..850c1a1c9c 100644 --- a/packages/plugins/plugin-dev/src/dev-plugin.ts +++ b/packages/plugins/plugin-dev/src/dev-plugin.ts @@ -10,7 +10,7 @@ import { resolveMultiOrgEnabled } from '@objectstack/types'; const CORE_SERVICE_NAMES = [ 'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue', - 'automation', 'graphql', 'analytics', 'realtime', + 'automation', 'analytics', 'realtime', 'job', 'notification', 'ai', 'i18n', 'ui', 'workflow', ] as const; @@ -96,14 +96,6 @@ function createAutomationStub() { }; } -/** IGraphQLService — dev stub returning empty data */ -function createGraphQLStub() { - return { - _dev: true, _serviceName: 'graphql', - async execute() { return { data: null, errors: [{ message: 'GraphQL not available in dev stub mode' }] }; }, - getSchema() { return 'type Query { _dev: Boolean }'; }, - }; -} /** IAnalyticsService — dev stub returning empty results */ function createAnalyticsStub() { @@ -274,7 +266,6 @@ const DEV_STUB_FACTORIES: Record Record> = { 'file-storage': createStorageStub, 'search': createSearchStub, 'automation': createAutomationStub, - 'graphql': createGraphQLStub, 'analytics': createAnalyticsStub, 'realtime': createRealtimeStub, 'notification': createNotificationStub, @@ -412,7 +403,7 @@ export interface DevPluginOptions { * * `cache` (Map-backed), `queue` (in-memory pub/sub), `job` (no-op scheduler), * `file-storage` (Map-backed), `search` (in-memory text search), - * `automation` (no-op flows), `graphql` (placeholder), `analytics` (empty results), + * `automation` (no-op flows), `analytics` (empty results), * `realtime` (in-memory pub/sub), `notification` (log), `ai` (placeholder), * `i18n` (Map-backed translations), `ui` (Map-backed views/dashboards), * `workflow` (Map-backed state machine) diff --git a/packages/qa/dogfood/test/authz-conformance.matrix.ts b/packages/qa/dogfood/test/authz-conformance.matrix.ts index dbe33b9b3c..a82c5790b0 100644 --- a/packages/qa/dogfood/test/authz-conformance.matrix.ts +++ b/packages/qa/dogfood/test/authz-conformance.matrix.ts @@ -65,11 +65,6 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ enforcement: 'rest/rest-server.ts registerMetadataEndpoints guarded registrar (enforceAuth → shouldDenyAnonymous) — every /meta route inherits the gate; runtime/http-dispatcher.ts handleMetadata mirrors it for the dispatcher metadata catch-all', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', covers: ['meta:rest-server.ts:registerMetadataEndpoints', 'meta:http-dispatcher.ts:handleMetadata'] }, - { id: 'anonymous-deny-graphql', summary: 'anonymous-deny on the dispatcher GraphQL endpoint (#2567 surface 2)', state: 'enforced', - enforcement: 'runtime/http-dispatcher.ts handleGraphQL (shouldDenyAnonymous, resolves identity for the direct /graphql route) + runtime/dispatcher-plugin.ts requireAuth default(true), mirroring rest-server.ts', - proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', - covers: ['graphql:http-dispatcher.ts:handleGraphQL', 'graphql:dispatcher-plugin.ts:POST /api/v1/graphql'], - note: 'GraphQL reaches the same object data as /data through kernel.graphql, whose security middleware falls OPEN for an anonymous context. Unit-proven in runtime/http-dispatcher.requireauth.test.ts (GraphQL block); e2e on the platform default in the surfaces proof.' }, { id: 'anonymous-deny-hono-data', summary: 'anonymous-deny on the raw-hono standard /data routes (#2567 surface 3)', state: 'enforced', enforcement: 'plugin-hono-server/hono-plugin.ts denyAnonymous gate (shouldDenyAnonymous) on the standard /data routes (requireAuth ?? true, mirroring rest-server.ts)', proof: 'showcase-anonymous-deny-surfaces.dogfood.test.ts', @@ -82,10 +77,6 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [ // transport tripwires in authz-conformance.test.ts) blocks wiring a client // transport without the identity story — in CI, not in an adversarial // review after the fact. - { id: 'graphql-identity-thread', summary: 'GraphQL entry point threads the caller identity to the engine (#2992 surface 1, ADR-0096 D1)', state: 'enforced', - enforcement: 'runtime/http-dispatcher.ts handleGraphQL — resolves the caller ExecutionContext (also on the direct dispatcher-plugin route, requireAuth on or off) and threads it as options.context on every kernel.graphql call; spec IGraphQLService.execute documents that implementations MUST forward it to ObjectQL as options.context', - covers: ['graphql:http-dispatcher.ts:kernel.graphql(context-threaded)'], - note: 'Surface posture: user (caller identity), latent — kernel.graphql is never assigned in the monorepo, so every POST /graphql 501s before an engine call; the only IGraphQLService is the plugin-dev stub. The threading exists so the FIRST real engine runs caller-scoped instead of context-less (the security middleware falls OPEN on a missing principal = full authority). Threading unit-proven in runtime/http-dispatcher.requireauth.test.ts (identity threading block); removing it goes STALE here and fails CI.' }, { id: 'realtime-delivery-authz', summary: 'realtime delivery fan-out has NO per-recipient authorization — trusted server-internal subscribers only (#2992 surface 2)', state: 'experimental', covers: ['realtime:in-memory-realtime-adapter.ts:publish(trusted-fan-out)'], note: 'Surface posture: system (trusted-implicit), pre-wiring — no end-user transport exists (handleUpgrade unimplemented, no REST subscribe route, client RealtimeAPI is a placeholder); the only subscribers are server-internal plugins (webhook auto-enqueuer, knowledge sync). Structural defect: Subscription carries no principal, matchesSubscription filters only by object+eventTypes (RealtimeSubscriptionOptions.filter is declared but never read), and the engine publishes the FULL after-row — so any future external subscriber would receive record bodies cross-tenant that its own find would hide. ADMISSION REQUIREMENT before any WebSocket/SSE/subscribe transport ships: per-recipient RLS/FLS/tenant re-check on delivery (subscription carries the subscriber ExecutionContext) OR id-only payload + client re-fetch. The transport tripwire probes in authz-conformance.test.ts turn a wired transport into an UNCLASSIFIED surface → red CI until this row is upgraded with the enforcement site.' }, diff --git a/packages/qa/dogfood/test/authz-conformance.test.ts b/packages/qa/dogfood/test/authz-conformance.test.ts index 7a3920490b..5f75275ee9 100644 --- a/packages/qa/dogfood/test/authz-conformance.test.ts +++ b/packages/qa/dogfood/test/authz-conformance.test.ts @@ -39,19 +39,12 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray re: /private\s+registerMetadataEndpoints\s*\(/g, key: () => 'meta:rest-server.ts:registerMetadataEndpoints', }, - // Dispatcher meta + graphql handlers — curated NAMES only (NOT handleAI / + // Dispatcher meta handler — curated NAME only (NOT handleAI / // handleData / handleSecurity, which are separate surfaces/rows). { file: 'packages/runtime/src/http-dispatcher.ts', - re: /async\s+(handleMetadata|handleGraphQL)\s*\(/g, - key: (m) => `${m[1] === 'handleGraphQL' ? 'graphql' : 'meta'}:http-dispatcher.ts:${m[1]}`, - }, - // Dispatcher-plugin direct GraphQL route (other server.post routes are - // control-plane / feature endpoints, deliberately not enumerated here). - { - file: 'packages/runtime/src/dispatcher-plugin.ts', - re: /server\.post\(\s*`\$\{prefix\}\/graphql`/g, - key: () => 'graphql:dispatcher-plugin.ts:POST /api/v1/graphql', + re: /async\s+(handleMetadata)\s*\(/g, + key: (m) => `meta:http-dispatcher.ts:${m[1]}`, }, // Raw-hono standard /data routes — genuinely pattern-based: ANY new // `rawApp.(`${prefix}/data...`)` → a new key → CI fails until a row covers it. @@ -62,14 +55,6 @@ const PROBES: ReadonlyArray<{ file: string; re: RegExp; key: (m: RegExpExecArray }, // ── #2992 / ADR-0096 D4 — latent-surface identity pins ───────────────── - // GraphQL identity threading: the ONLY kernel.graphql(...) call site must - // carry `context:` in its options. If a refactor drops the threading the key - // vanishes → the `graphql-identity-thread` row's covers goes STALE → red CI. - { - file: 'packages/runtime/src/http-dispatcher.ts', - re: /kernel\.graphql\([^)]*\bcontext:/g, - key: () => 'graphql:http-dispatcher.ts:kernel.graphql(context-threaded)', - }, // Realtime delivery fan-out: pins the trusted-internal-only posture of the // in-memory adapter's publish loop (`realtime-delivery-authz` row). { @@ -160,7 +145,7 @@ const HIGH_RISK = [ 'owd-private', 'owd-public-read', 'controlled-by-parent', 'anonymous-deny', 'default-profile', // #2567 — every anonymous-deny HTTP surface is high-risk: it guards the // same object data as REST `/data` through a sibling entry point. - 'anonymous-deny-meta', 'anonymous-deny-graphql', 'anonymous-deny-hono-data', + 'anonymous-deny-meta', 'anonymous-deny-hono-data', // #2948/#3003 — write-integrity face: without the strip, `readonly: true` // is false compliance (declared ≠ enforced) and approval/status columns are // one direct PATCH away from self-approval. @@ -216,8 +201,8 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => { it('(c) a covers key no longer in source → STALE covers failure', () => { const m = clone(); - const row = m.find((r) => r.id === 'anonymous-deny-graphql')!; - row.covers = [...(row.covers ?? []), 'graphql:http-dispatcher.ts:handleRemovedThing']; + const row = m.find((r) => r.id === 'anonymous-deny-meta')!; + row.covers = [...(row.covers ?? []), 'meta:http-dispatcher.ts:handleRemovedThing']; const problems = checkLedger(m, opts(() => discoverAnonymousDenySurfaces())); expect(problems.some((p) => /STALE covers/.test(p) && /handleRemovedThing/.test(p))).toBe(true); }); @@ -232,17 +217,6 @@ describe('#2567 — anonymous-deny surface ratchet bites', () => { expect(problems.some((p) => p.includes('UNCLASSIFIED surface') && p.includes(fake))).toBe(true); }); - it('(e) dropping the GraphQL context-thread → STALE covers failure (#2992)', () => { - const threaded = 'graphql:http-dispatcher.ts:kernel.graphql(context-threaded)'; - // Baseline sanity: the threading is discovered from source today. - expect(discoverAnonymousDenySurfaces().has(threaded)).toBe(true); - const problems = checkLedger( - AUTHZ_CONFORMANCE, - opts(() => new Set([...discoverAnonymousDenySurfaces()].filter((k) => k !== threaded))), - ); - expect(problems.some((p) => /STALE covers/.test(p) && p.includes(threaded))).toBe(true); - }); - // ── ADR-0096 / #3167 — the MCP identity pins bite too ────────────────── it('(f) dropping the MCP HTTP context-thread → STALE covers failure (#3167)', () => { const threaded = 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'; diff --git a/packages/qa/dogfood/test/expression-conformance.ledger.ts b/packages/qa/dogfood/test/expression-conformance.ledger.ts index f9351ecbd5..683daad319 100644 --- a/packages/qa/dogfood/test/expression-conformance.ledger.ts +++ b/packages/qa/dogfood/test/expression-conformance.ledger.ts @@ -80,13 +80,12 @@ export const EXPRESSION_SURFACE: ExprSurface[] = [ }, { id: 'cel-formula', - summary: 'computed / formula field + mapping / graphql / feature expressions', + summary: 'computed / formula field + mapping / feature expressions', dialect: 'cel', mode: 'interpret', state: 'enforced', failPolicy: 'fail-soft-log', enforcement: '@objectstack/formula celEngine (interpret)', covers: [ 'data/field.zod.ts:expression', 'shared/mapping.zod.ts:expression', - 'api/graphql.zod.ts:expression', 'kernel/feature.zod.ts:expression', ], }, diff --git a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts index 35375cde64..d08f4554d9 100644 --- a/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-anonymous-deny-surfaces.dogfood.test.ts @@ -5,7 +5,6 @@ // this fix, on a `requireAuth` deployment `/data/*` denied anonymous callers // while three sibling surfaces reached ObjectQL without the gate: // - the metadata endpoints (`/meta`) -// - the dispatcher GraphQL endpoint (`/graphql`) // - the raw-hono standard `/data` routes (order-dependent shadowing) // // This proof boots the real showcase HTTP stack ON THE PLATFORM DEFAULT (the @@ -42,22 +41,6 @@ describe('showcase: anonymous posture is uniform across surfaces (#2567)', () => expect(r.status, 'authenticated metadata read must clear the auth gate').not.toBe(401); }); - // ── /graphql ───────────────────────────────────────────────────────────── - it('anonymous POST /graphql is denied (401)', async () => { - const r = await stack.api('/graphql', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ query: '{ __typename }' }), - }); - expect(r.status, 'anonymous GraphQL query must be 401').toBe(401); - }); - - it('an authenticated member clears the /graphql gate (not 401)', async () => { - // Past the gate the query may 200 or 501 (depending on whether a GraphQL - // service is wired) — the point is it is NOT the anonymous 401. - const r = await stack.apiAs(memberToken, 'POST', '/graphql', { query: '{ __typename }' }); - expect(r.status, 'authenticated GraphQL must clear the auth gate').not.toBe(401); - }); // ── /data (surface-level; raw-hono handler proven in plugin-hono-server) ── it('anonymous READ of the data surface is denied (401)', async () => { diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 92ab66f83a..59f6350b5d 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -50,12 +50,11 @@ export interface DispatcherPluginConfig { /** * Reject anonymous requests to `auth: true` service routes (AI, etc.), the - * `/graphql` endpoint and the `/meta` catch-all with HTTP 401, mirroring the + * `/meta` catch-all with HTTP 401, mirroring the * REST API's `requireAuth` gate. Must match the REST plugin's - * `api.requireAuth` so `/ai`, `/graphql` and `/meta` stay in lockstep with + * `api.requireAuth` so `/ai` and `/meta` stay in lockstep with * `/data` — otherwise the AI routes' declared `auth: true` contract is never * enforced and anonymous callers reach adapter/model status routes or read - * object data over GraphQL that `/data/*` 401s (#2567). * * Defaults to `true` — secure-by-default, matching the REST plugin's * `api.requireAuth` default (ADR-0056 D2). Hosts pass their `api.requireAuth` @@ -380,7 +379,6 @@ function errorResponseBase(err: any, res: any, securityHeaders?: Record { - try { - const result = await dispatcher.handleGraphQL(req.body, { request: req }); - if (securityHeaders) { - for (const [k, v] of Object.entries(securityHeaders)) { - res.header(k, v); - } - } - res.json(result); - } catch (err: any) { - errorResponse(err, res); - } - }); // ── Analytics ─────────────────────────────────────────────── // Route via dispatch() (not handleAnalytics directly) so the host diff --git a/packages/runtime/src/http-dispatcher.requireauth.test.ts b/packages/runtime/src/http-dispatcher.requireauth.test.ts index 7ed14159c8..0875d6882e 100644 --- a/packages/runtime/src/http-dispatcher.requireauth.test.ts +++ b/packages/runtime/src/http-dispatcher.requireauth.test.ts @@ -66,95 +66,7 @@ describe('HttpDispatcher requireAuth gate — AI routes (handleAI)', () => { }); }); -describe('HttpDispatcher requireAuth gate — GraphQL (handleGraphQL)', () => { - // A valid-enough body so the missing-query 400 branch isn't hit first. - const gqlBody = { query: '{ __typename }' }; - it('401s an anonymous caller when requireAuth is on (#2567)', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); - await expect(d.handleGraphQL(gqlBody, anon)).rejects.toMatchObject({ statusCode: 401 }); - }); - - it('lets an authenticated caller PAST the gate', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); - // The mock kernel has no graphql service, so a caller that clears the - // gate hits 501 — which PROVES the gate passed (an anonymous caller - // throws 401 before this point). - await expect(d.handleGraphQL(gqlBody, authed)).rejects.toMatchObject({ statusCode: 501 }); - }); - - it('lets an internal system context past the gate', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); - await expect(d.handleGraphQL(gqlBody, system)).rejects.toMatchObject({ statusCode: 501 }); - }); - - it('serves anonymously when requireAuth is off (unchanged legacy behaviour)', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: false }); - // Gate is a no-op → reaches the (absent) graphql service → 501, NOT 401. - await expect(d.handleGraphQL(gqlBody, anon)).rejects.toMatchObject({ statusCode: 501 }); - }); - - it('400s a missing query before the auth gate is consulted', async () => { - const d = new HttpDispatcher(makeKernel(), undefined, { requireAuth: true }); - await expect(d.handleGraphQL({} as any, anon)).rejects.toMatchObject({ statusCode: 400 }); - }); -}); - -// #2992 / ADR-0096 D1 — the GraphQL entry point must THREAD the caller's -// identity to the engine, not just gate anonymity. kernel.graphql is a stub -// surface today (never assigned in the monorepo), but the moment a real -// engine lands it resolves objects through ObjectQL, whose security -// middleware falls OPEN on a missing principal — so the entry point passes -// the resolved ExecutionContext as `options.context` (the same key the REST -// callData path threads). Dropping it also goes STALE in the authz -// conformance matrix (dogfood/test/authz-conformance.test.ts). -describe('HttpDispatcher identity threading — GraphQL (handleGraphQL, #2992)', () => { - const gqlBody = { query: '{ __typename }', variables: { a: 1 } }; - - const makeGraphQLKernel = (calls: any[]) => - makeKernel({ - graphql: (query: string, variables: any, options: any) => { - calls.push({ query, variables, options }); - return { data: {} }; - }, - }); - - it('threads the resolved ExecutionContext as options.context', async () => { - const calls: any[] = []; - const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: true }); - await d.handleGraphQL(gqlBody, authed); - expect(calls).toHaveLength(1); - expect(calls[0].query).toBe(gqlBody.query); - expect(calls[0].variables).toEqual(gqlBody.variables); - expect(calls[0].options.context).toBe(authed.executionContext); - }); - - it('threads a system context unchanged', async () => { - const calls: any[] = []; - const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: true }); - await d.handleGraphQL(gqlBody, system); - expect(calls[0].options.context).toBe(system.executionContext); - }); - - it('threads the caller identity even when requireAuth is OFF (an authenticated caller on an open deployment still runs under their own authority)', async () => { - const calls: any[] = []; - const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: false }); - await d.handleGraphQL(gqlBody, authed); - expect(calls[0].options.context).toBe(authed.executionContext); - }); - - it('an anonymous caller on an open deployment carries NO authority (explicit guest principal or nothing — never a forged user/system identity)', async () => { - const calls: any[] = []; - const d = new HttpDispatcher(makeGraphQLKernel(calls), undefined, { requireAuth: false }); - // Fresh context object: handleGraphQL caches the resolved identity on it. - await d.handleGraphQL(gqlBody, { request: {}, executionContext: undefined } as any); - const threaded = calls[0].options.context; - // The resolver yields an explicit guest principal (mirroring dispatch()); - // whatever is threaded must carry no user and no system authority. - expect(threaded?.userId).toBeUndefined(); - expect(threaded?.isSystem ?? false).toBe(false); - }); -}); describe('HttpDispatcher requireAuth gate — metadata catch-all (handleMetadata)', () => { it('401s an anonymous caller when requireAuth is on', async () => { diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index ebd71981f3..001d8eaf7d 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -164,7 +164,7 @@ export interface HttpDispatcherOptions { * is intentionally NOT marked `@deprecated` while no working replacement exists. */ export class HttpDispatcher { - private kernel: any; // Casting to any to access dynamic props like services, graphql + private kernel: any; // Casting to any to access dynamic props like services private defaultKernel: ObjectKernel; private defaultProject?: { environmentId: string; orgId?: string }; private kernelResolver?: KernelResolver; @@ -1563,7 +1563,7 @@ export class HttpDispatcher { * ADR-0069 — returns a 403 response when the resolved session is blocked by * an auth-policy gate (expired password / required MFA) on a non-allow-listed * path, else null. Mirrors the REST `enforceAuth` seam so REST + dispatcher - * (MCP, GraphQL) enforce consistently. Fails open on any lookup error. + * (MCP) enforce consistently. Fails open on any lookup error. */ private async enforceAuthGate(context: any, cleanPath: string): Promise { try { @@ -1693,12 +1693,11 @@ export class HttpDispatcher { // Resolve all services through the same async fallback chain // that request handlers (handleI18n, handleAuth, …) use. const [ - authSvc, graphqlSvc, searchSvc, realtimeSvc, filesSvc, + authSvc, searchSvc, realtimeSvc, filesSvc, analyticsSvc, workflowSvc, aiSvc, notificationSvc, i18nSvc, uiSvc, automationSvc, cacheSvc, queueSvc, jobSvc, ] = await Promise.all([ this.resolveService(CoreServiceName.enum.auth), - this.resolveService(CoreServiceName.enum.graphql), this.resolveService(CoreServiceName.enum.search), this.resolveService(CoreServiceName.enum.realtime), this.resolveService(CoreServiceName.enum['file-storage']), @@ -1715,7 +1714,6 @@ export class HttpDispatcher { ]); const hasAuth = !!authSvc; - const hasGraphQL = !!(graphqlSvc || this.kernel.graphql); const hasSearch = !!searchSvc; const hasFiles = !!filesSvc; const hasAnalytics = !!analyticsSvc; @@ -1736,7 +1734,6 @@ export class HttpDispatcher { packages: `${prefix}/packages`, auth: hasAuth ? `${prefix}/auth` : undefined, ui: hasUi ? `${prefix}/ui` : undefined, - graphql: hasGraphQL ? `${prefix}/graphql` : undefined, storage: hasFiles ? `${prefix}/storage` : undefined, analytics: hasAnalytics ? `${prefix}/analytics` : undefined, automation: hasAutomation ? `${prefix}/automation` : undefined, @@ -1817,7 +1814,6 @@ export class HttpDispatcher { routes, endpoints: routes, // Alias for backward compatibility with some clients features: { - graphql: hasGraphQL, search: hasSearch, // No WS/HTTP realtime surface is mounted anywhere — a mere // in-process realtime service must not advertise websockets @@ -1858,7 +1854,6 @@ export class HttpDispatcher { notification: hasNotification ? svcAvailable(routes.notifications, undefined, notificationSvc) : svcUnavailable('notification'), ai: hasAi ? svcAvailable(routes.ai, undefined, aiSvc) : svcUnavailable('ai'), i18n: hasI18n ? svcAvailable(routes.i18n, undefined, i18nSvc) : svcUnavailable('i18n'), - graphql: hasGraphQL ? svcAvailable(routes.graphql, undefined, graphqlSvc) : svcUnavailable('graphql'), 'file-storage': hasFiles ? svcAvailable(routes.storage, undefined, filesSvc) : svcUnavailable('file-storage'), search: hasSearch ? svcAvailable(undefined, undefined, searchSvc) : svcUnavailable('search'), }, @@ -1866,97 +1861,7 @@ export class HttpDispatcher { }; } - /** - * Handles GraphQL requests - */ - async handleGraphQL(body: { query: string; variables?: any }, context: HttpProtocolContext) { - if (!body || !body.query) { - throw { statusCode: 400, message: 'Missing query in request body' }; - } - - // Anonymous-deny gate — the same `requireAuth` posture the REST `/data` - // and `/meta` surfaces enforce. GraphQL reaches ObjectQL through - // `kernel.graphql`, whose security middleware falls OPEN for an - // anonymous context (no userId/roles → next()), so without this gate an - // anonymous query could read exactly the object data the sibling - // `/data/*` 401 denies (#2567). Mirrors {@link handleMetadata}: no-op - // when `requireAuth` is off (demo / single-tenant), an authenticated or - // system caller passes exactly as on `/data`. - // - // The dispatcher-plugin's direct `/graphql` route calls us WITHOUT - // resolving identity first (unlike `dispatch()`, which populates - // `context.executionContext`), so resolve it here when absent — - // REGARDLESS of `requireAuth`: the resolved identity is also what we - // thread to the engine below (#2992), and an authenticated caller on a - // `requireAuth: false` deployment must still run under their own - // authority, not context-less. - let ec: any = context.executionContext; - if (!ec) { - ec = await this.resolveRequestExecutionContext(context); - if (ec) context.executionContext = ec; - } - // Body-routed seam: no meaningful request path, so pass none — the - // shared decision then denies anonymous unconditionally (see the - // `undefined`-path trap guard in `shouldDenyAnonymous`). - if (shouldDenyAnonymous({ requireAuth: this.requireAuth, userId: ec?.userId, isSystem: ec?.isSystem })) { - throw { - statusCode: ANONYMOUS_DENY_STATUS, - message: ANONYMOUS_DENY_MESSAGE, - code: ANONYMOUS_DENY_CODE, - }; - } - - if (typeof this.kernel.graphql !== 'function') { - throw { statusCode: 501, message: 'GraphQL service not available' }; - } - // ADR-0096 D1 / #2992 — thread the caller's identity to the engine. - // `kernel.graphql` is still unassigned everywhere (this call 501s - // above), but the moment a real engine lands it resolves objects - // through ObjectQL, whose security middleware falls OPEN on a missing - // principal — so the entry point must already carry the caller as - // `options.context` (the same key the REST `callData` path threads). - // An implementation MUST forward it to every data-engine call. - return this.kernel.graphql(body.query, body.variables, { - request: context.request, - context: ec, - }); - } - - /** - * Resolve the RBAC/RLS execution context for a request that did NOT flow - * through {@link dispatch} (which resolves and caches it on - * `context.executionContext`). The dispatcher-plugin's direct `/graphql` - * route is the current caller. Mirrors the identity resolution `dispatch()` - * performs so an anonymous-deny gate can tell an authenticated caller from - * an anonymous one, and so the caller's identity can be THREADED to the - * engine (#2992 / ADR-0096 D1) instead of dropped. Best-effort: returns - * `undefined` on failure (treated as anonymous, i.e. denied under - * `requireAuth`). - */ - private async resolveRequestExecutionContext( - context: HttpProtocolContext, - ): Promise { - try { - return await this.timedResolveExecutionContext({ - getService: (n: string) => this.resolveService(n, context.environmentId), - getQl: async () => { - const k: any = this.kernel; - if (k && typeof k.getServiceAsync === 'function') { - const ql = await k.getServiceAsync('objectql').catch(() => undefined); - if (ql && (ql.registry || typeof ql.find === 'function')) return ql; - } - return this.getObjectQLService(context.environmentId); - }, - request: context.request, - // OAuth access tokens are honoured only on the MCP surface - // (#2698); GraphQL enforces no per-scope tool gating. - acceptOAuthAccessToken: false, - }); - } catch { - return undefined; - } - } /** Thin delegate — body extracted to `./domains/auth.ts` (D11③ PR-7). */ async handleAuth(path: string, method: string, body: any, context: HttpProtocolContext): Promise { @@ -2942,7 +2847,7 @@ export class HttpDispatcher { // ── ADR-0069 Authentication-policy gate ── // Block a gated session (expired password / required MFA) from - // protected MCP/GraphQL/data routes, mirroring the REST seam. The core + // protected MCP/data routes, mirroring the REST seam. The core // allow-list keeps auth + remediation reachable. Skipped (no session // lookup) when no gate feature is active. const authGated = await this.enforceAuthGate(context, cleanPath); @@ -3018,10 +2923,7 @@ export class HttpDispatcher { // /keys moved to the domain registry (D11 step ③). - if (cleanPath.startsWith('/graphql')) { - if (method === 'POST') return this.handleGraphQL(body, context); - // GraphQL usually GET for Playground is handled by middleware but we can return 405 or handle it - } + // /graphql removed — GraphQL is not in the product plan (#2462 follow-on). // /storage and /ui moved to the domain registry (D11 step ③). diff --git a/packages/spec/PROTOCOL_MAP.md b/packages/spec/PROTOCOL_MAP.md index 713ddc250c..05f2b2063a 100644 --- a/packages/spec/PROTOCOL_MAP.md +++ b/packages/spec/PROTOCOL_MAP.md @@ -165,7 +165,6 @@ This document serves as the **Grand Map** of the ObjectStack specification. It l | [`dispatcher.zod.ts`](src/api/dispatcher.zod.ts) | ⭐ | **HttpDispatcher**. Route-to-service mapping for API routing. | | [`discovery.zod.ts`](src/api/discovery.zod.ts) | ⭐ | **Service Discovery**. Service registration and API routes discovery. | | [`endpoint.zod.ts`](src/api/endpoint.zod.ts) | | **API Endpoints**. REST API route definitions. | -| [`graphql.zod.ts`](src/api/graphql.zod.ts) | | **GraphQL**. Schema and resolver configuration. | | [`rest-server.zod.ts`](src/api/rest-server.zod.ts) | | **REST Server**. REST-specific server settings. | | [`auth.zod.ts`](src/api/auth.zod.ts) | | **API Auth**. Authentication schemes for APIs. | | [`analytics.zod.ts`](src/api/analytics.zod.ts) | | **API Analytics**. Usage tracking for APIs. | diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 78e8e1d617..d192026ff7 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2471,19 +2471,6 @@ "ExportJobSummarySchema (const)", "ExportRequest (type)", "ExportRequestSchema (const)", - "FederationEntity (type)", - "FederationEntityKey (type)", - "FederationEntityKeySchema (const)", - "FederationEntitySchema (const)", - "FederationExternalField (type)", - "FederationExternalFieldSchema (const)", - "FederationGateway (type)", - "FederationGatewayInput (type)", - "FederationGatewaySchema (const)", - "FederationProvides (type)", - "FederationProvidesSchema (const)", - "FederationRequires (type)", - "FederationRequiresSchema (const)", "FieldError (type)", "FieldErrorSchema (const)", "FieldMappingEntry (type)", @@ -2596,47 +2583,6 @@ "GetWorkflowStateRequestSchema (const)", "GetWorkflowStateResponse (type)", "GetWorkflowStateResponseSchema (const)", - "GraphQLConfig (type)", - "GraphQLConfigInput (type)", - "GraphQLConfigSchema (const)", - "GraphQLDataLoaderConfig (type)", - "GraphQLDataLoaderConfigInput (type)", - "GraphQLDataLoaderConfigSchema (const)", - "GraphQLDirectiveConfig (type)", - "GraphQLDirectiveConfigInput (type)", - "GraphQLDirectiveConfigSchema (const)", - "GraphQLDirectiveLocation (type)", - "GraphQLMutationConfig (type)", - "GraphQLMutationConfigInput (type)", - "GraphQLMutationConfigSchema (const)", - "GraphQLPersistedQuery (type)", - "GraphQLPersistedQueryInput (type)", - "GraphQLPersistedQuerySchema (const)", - "GraphQLQueryAdapter (type)", - "GraphQLQueryAdapterInput (type)", - "GraphQLQueryAdapterSchema (const)", - "GraphQLQueryComplexity (type)", - "GraphQLQueryComplexityInput (type)", - "GraphQLQueryComplexitySchema (const)", - "GraphQLQueryConfig (type)", - "GraphQLQueryConfigInput (type)", - "GraphQLQueryConfigSchema (const)", - "GraphQLQueryDepthLimit (type)", - "GraphQLQueryDepthLimitInput (type)", - "GraphQLQueryDepthLimitSchema (const)", - "GraphQLRateLimit (type)", - "GraphQLRateLimitInput (type)", - "GraphQLRateLimitSchema (const)", - "GraphQLResolverConfig (type)", - "GraphQLResolverConfigInput (type)", - "GraphQLResolverConfigSchema (const)", - "GraphQLScalarType (type)", - "GraphQLSubscriptionConfig (type)", - "GraphQLSubscriptionConfigInput (type)", - "GraphQLSubscriptionConfigSchema (const)", - "GraphQLTypeConfig (type)", - "GraphQLTypeConfigInput (type)", - "GraphQLTypeConfigSchema (const)", "HandlerStatus (type)", "HandlerStatusSchema (const)", "HttpFindQueryParamsSchema (const)", @@ -2979,9 +2925,6 @@ "StandardApiContracts (const)", "StandardErrorCode (type)", "StorageApiContracts (const)", - "SubgraphConfig (type)", - "SubgraphConfigInput (type)", - "SubgraphConfigSchema (const)", "SubscribeMessage (type)", "SubscribeMessageSchema (const)", "Subscription (type)", @@ -3086,7 +3029,6 @@ "WorkflowTransitionResponseSchema (const)", "getAuthEndpointUrl (function)", "getDefaultRouteRegistrations (function)", - "mapFieldTypeToGraphQL (const)", "readServiceSelfInfo (function)" ], "./ui": [ @@ -3532,8 +3474,6 @@ "GenerateDraftOpts (interface)", "GenerateObjectOptions (interface)", "GrantShareInput (interface)", - "GraphQLRequest (interface)", - "GraphQLResponse (interface)", "HealthStatus (interface)", "HierarchyScope (type)", "HierarchyScopeContext (interface)", @@ -3559,7 +3499,6 @@ "IEmbedder (interface)", "IExportService (interface)", "IExternalDatasourceService (interface)", - "IGraphQLService (interface)", "IHierarchyScopeResolver (interface)", "IHttpRequest (interface)", "IHttpResponse (interface)", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 14a2035397..cc6fbe5ed4 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -210,12 +210,6 @@ "api/ExportJobStatus", "api/ExportJobSummary", "api/ExportRequest", - "api/FederationEntity", - "api/FederationEntityKey", - "api/FederationExternalField", - "api/FederationGateway", - "api/FederationProvides", - "api/FederationRequires", "api/FieldError", "api/FieldMappingEntry", "api/FileTypeValidation", @@ -273,21 +267,6 @@ "api/GetWorkflowConfigResponse", "api/GetWorkflowStateRequest", "api/GetWorkflowStateResponse", - "api/GraphQLConfig", - "api/GraphQLDataLoaderConfig", - "api/GraphQLDirectiveConfig", - "api/GraphQLDirectiveLocation", - "api/GraphQLMutationConfig", - "api/GraphQLPersistedQuery", - "api/GraphQLQueryAdapter", - "api/GraphQLQueryComplexity", - "api/GraphQLQueryConfig", - "api/GraphQLQueryDepthLimit", - "api/GraphQLRateLimit", - "api/GraphQLResolverConfig", - "api/GraphQLScalarType", - "api/GraphQLSubscriptionConfig", - "api/GraphQLTypeConfig", "api/HandlerStatus", "api/HttpFindQueryParams", "api/HttpMethod", @@ -454,7 +433,6 @@ "api/SimplePresenceState", "api/SingleRecordResponse", "api/StandardErrorCode", - "api/SubgraphConfig", "api/SubscribeMessage", "api/Subscription", "api/SubscriptionEvent", diff --git a/packages/spec/scripts/build-docs.ts b/packages/spec/scripts/build-docs.ts index 67d63f1953..ba8c70b401 100644 --- a/packages/spec/scripts/build-docs.ts +++ b/packages/spec/scripts/build-docs.ts @@ -453,7 +453,7 @@ const SECTION_GROUPS: Record ], api: [ { section: 'Contract & Routing', pages: ['protocol', 'contract', 'endpoint', 'router', 'registry', 'discovery', 'documentation', 'versioning', 'errors', 'batch'] }, - { section: 'Transport & Realtime', pages: ['http', 'http-cache', 'rest-server', 'websocket', 'realtime', 'realtime-shared', 'graphql', 'odata', 'query-adapter', 'dispatcher'] }, + { section: 'Transport & Realtime', pages: ['http', 'http-cache', 'rest-server', 'websocket', 'realtime', 'realtime-shared', 'odata', 'query-adapter', 'dispatcher'] }, { section: 'Service APIs', pages: ['core-services', 'auth', 'auth-endpoints', 'identity', 'metadata', 'metadata-plugin', 'automation-api', 'analytics', 'export', 'storage', 'notification', 'events', 'connector', 'package-api', 'package-registry', 'plugin-rest-api'] }, ], automation: [ diff --git a/packages/spec/src/api/discovery.test.ts b/packages/spec/src/api/discovery.test.ts index 08a2d4aa51..9d918f9459 100644 --- a/packages/spec/src/api/discovery.test.ts +++ b/packages/spec/src/api/discovery.test.ts @@ -36,12 +36,10 @@ describe('ApiRoutesSchema', () => { auth: '/api/v1/auth', actions: '/api/v1/p', storage: '/api/v1/storage', - graphql: '/api/v1/graphql', discovery: '/api/v1/discovery', }); expect(routes.data).toBe('/api/v1/data'); - expect(routes.graphql).toBe('/api/v1/graphql'); expect(routes.discovery).toBe('/api/v1/discovery'); }); @@ -129,11 +127,9 @@ describe('DiscoverySchema', () => { auth: '/api/v1/auth', actions: '/api/v1/p', storage: '/api/v1/storage', - graphql: '/api/v1/graphql', }, services: { ...minimalServices, - graphql: { enabled: true, status: 'available' as const, route: '/api/v1/graphql', provider: 'plugin-graphql' }, search: { enabled: true, status: 'available' as const, route: '/api/v1/search', provider: 'plugin-search' }, }, locale: { @@ -202,7 +198,6 @@ describe('DiscoverySchema', () => { }, services: { ...minimalServices, - graphql: { enabled: true, status: 'available' as const, route: '/graphql', provider: 'plugin-graphql' }, search: { enabled: true, status: 'available' as const, route: '/api/v1/search', provider: 'plugin-search' }, }, locale: { @@ -343,32 +338,6 @@ describe('DiscoverySchema', () => { expect(discovery.locale.supported).toHaveLength(10); }); - it('should handle GraphQL-enabled instance via services', () => { - const discovery = DiscoverySchema.parse({ - name: 'ObjectStack', - version: '1.0.0', - environment: 'production', - routes: { - data: '/api/v1/data', - metadata: '/api/v1/meta', - auth: '/api/v1/auth', - graphql: '/api/v1/graphql', - }, - services: { - ...minimalServices, - graphql: { enabled: true, status: 'available' as const, route: '/api/v1/graphql', provider: 'plugin-graphql' }, - }, - locale: { - default: 'en-US', - supported: ['en-US'], - timezone: 'UTC', - }, - }); - - expect(discovery.services['graphql'].enabled).toBe(true); - expect(discovery.routes.graphql).toBe('/api/v1/graphql'); - }); - it('should reject discovery without required fields', () => { expect(() => DiscoverySchema.parse({ version: '1.0.0', @@ -538,13 +507,11 @@ describe('DiscoverySchema with services', () => { ...baseDiscovery, services: { ...minimalServices, - graphql: { enabled: true, status: 'available', route: '/graphql', provider: 'plugin-graphql' }, ai: { enabled: false, status: 'unavailable', message: 'Not installed' }, }, }); // Capability can be derived from service.enabled - expect(discovery.services['graphql'].enabled).toBe(true); expect(discovery.services['ai'].enabled).toBe(false); }); }); @@ -670,12 +637,10 @@ describe('DiscoverySchema (schemaDiscovery field)', () => { ...fixture, schemaDiscovery: { openapi: '/api/v1/openapi.json', - graphql: '/graphql', jsonSchema: '/api/v1/schemas', }, }); expect(discovery.schemaDiscovery?.openapi).toBe('/api/v1/openapi.json'); - expect(discovery.schemaDiscovery?.graphql).toBe('/graphql'); expect(discovery.schemaDiscovery?.jsonSchema).toBe('/api/v1/schemas'); }); @@ -692,7 +657,6 @@ describe('DiscoverySchema (schemaDiscovery field)', () => { }, }); expect(discovery.schemaDiscovery?.openapi).toBe('/api/v1/openapi.json'); - expect(discovery.schemaDiscovery?.graphql).toBeUndefined(); }); }); diff --git a/packages/spec/src/api/discovery.zod.ts b/packages/spec/src/api/discovery.zod.ts index 2598eaeb40..a88d53b217 100644 --- a/packages/spec/src/api/discovery.zod.ts +++ b/packages/spec/src/api/discovery.zod.ts @@ -178,8 +178,6 @@ export const ApiRoutesSchema = lazySchema(() => z.object({ /** Base URL for Analytics/BI operations */ analytics: z.string().optional().describe('e.g. /api/v1/analytics'), - /** GraphQL Endpoint (if enabled) */ - graphql: z.string().optional().describe('e.g. /graphql'), /** Base URL for Package Management */ packages: z.string().optional().describe('e.g. /api/v1/packages'), @@ -260,7 +258,6 @@ export const DiscoverySchema = lazySchema(() => z.object({ */ schemaDiscovery: z.object({ openapi: z.string().optional().describe('URL to OpenAPI (Swagger) specification (e.g., "/api/v1/openapi.json")'), - graphql: z.string().optional().describe('URL to GraphQL schema endpoint (e.g., "/graphql")'), jsonSchema: z.string().optional().describe('URL to JSON Schema definitions'), }).optional().describe('Schema discovery endpoints for API toolchain integration'), diff --git a/packages/spec/src/api/documentation.test.ts b/packages/spec/src/api/documentation.test.ts index db7d771fee..d534d70a31 100644 --- a/packages/spec/src/api/documentation.test.ts +++ b/packages/spec/src/api/documentation.test.ts @@ -195,8 +195,7 @@ describe('API Documentation Protocol', () => { expect(ApiTestingUiType.parse('rapidoc')).toBe('rapidoc'); expect(ApiTestingUiType.parse('stoplight')).toBe('stoplight'); expect(ApiTestingUiType.parse('scalar')).toBe('scalar'); - expect(ApiTestingUiType.parse('graphql-playground')).toBe('graphql-playground'); - expect(ApiTestingUiType.parse('graphiql')).toBe('graphiql'); + expect(ApiTestingUiType.parse('graphiql')).toBe('graphiql'); expect(ApiTestingUiType.parse('postman')).toBe('postman'); expect(ApiTestingUiType.parse('custom')).toBe('custom'); }); diff --git a/packages/spec/src/api/documentation.zod.ts b/packages/spec/src/api/documentation.zod.ts index 8b67b5e475..04af7423f5 100644 --- a/packages/spec/src/api/documentation.zod.ts +++ b/packages/spec/src/api/documentation.zod.ts @@ -6,7 +6,7 @@ import { z } from 'zod'; * API Documentation & Testing Interface Protocol * * Provides schemas for generating interactive API documentation and testing - * interfaces similar to Swagger UI, GraphQL Playground, Postman, etc. + * interfaces similar to Swagger UI, Postman, etc. * * Features: * - OpenAPI/Swagger specification generation @@ -18,7 +18,6 @@ import { z } from 'zod'; * Architecture Alignment: * - Swagger UI: Interactive API documentation * - Postman: API testing collections - * - GraphQL Playground: GraphQL-specific testing * - Redoc: Documentation rendering * * @example Documentation Config @@ -202,7 +201,6 @@ export const ApiTestingUiType = z.enum([ 'rapidoc', // RapiDoc 'stoplight', // Stoplight Elements 'scalar', // Scalar API Reference - 'graphql-playground', // GraphQL Playground 'graphiql', // GraphiQL 'postman', // Postman-like interface 'custom', // Custom implementation diff --git a/packages/spec/src/api/graphql.test.ts b/packages/spec/src/api/graphql.test.ts deleted file mode 100644 index b2f8a1b601..0000000000 --- a/packages/spec/src/api/graphql.test.ts +++ /dev/null @@ -1,1255 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - GraphQLScalarType, - GraphQLTypeConfigSchema, - GraphQLQueryConfigSchema, - GraphQLMutationConfigSchema, - GraphQLSubscriptionConfigSchema, - GraphQLResolverConfigSchema, - GraphQLDataLoaderConfigSchema, - GraphQLDirectiveConfigSchema, - GraphQLDirectiveLocation, - GraphQLQueryDepthLimitSchema, - GraphQLQueryComplexitySchema, - GraphQLRateLimitSchema, - GraphQLPersistedQuerySchema, - GraphQLConfigSchema, - GraphQLConfig, - mapFieldTypeToGraphQL, - FederationEntityKeySchema, - FederationEntitySchema, - SubgraphConfigSchema, - FederationGatewaySchema, - FederationExternalFieldSchema, - FederationRequiresSchema, - FederationProvidesSchema, -} from './graphql.zod'; - -describe('GraphQLScalarType', () => { - it('should accept built-in GraphQL scalar types', () => { - const builtInTypes = ['ID', 'String', 'Int', 'Float', 'Boolean']; - - builtInTypes.forEach(type => { - expect(() => GraphQLScalarType.parse(type)).not.toThrow(); - }); - }); - - it('should accept extended scalar types', () => { - const extendedTypes = ['DateTime', 'Date', 'JSON', 'URL', 'Email', 'UUID']; - - extendedTypes.forEach(type => { - expect(() => GraphQLScalarType.parse(type)).not.toThrow(); - }); - }); - - it('should reject invalid scalar types', () => { - expect(() => GraphQLScalarType.parse('CustomType')).toThrow(); - expect(() => GraphQLScalarType.parse('NotAScalar')).toThrow(); - }); -}); - -describe('GraphQLTypeConfigSchema', () => { - it('should accept minimal type configuration', () => { - const config = GraphQLTypeConfigSchema.parse({ - name: 'Customer', - object: 'customer', - }); - - expect(config.name).toBe('Customer'); - expect(config.object).toBe('customer'); - }); - - it('should accept type configuration with field mappings', () => { - const config = GraphQLTypeConfigSchema.parse({ - name: 'Customer', - object: 'customer', - description: 'Customer type', - fields: { - include: ['id', 'name', 'email'], - exclude: ['password', 'ssn'], - mappings: { - email: { - graphqlName: 'emailAddress', - description: 'Customer email', - }, - }, - }, - }); - - expect(config.fields?.include).toContain('email'); - expect(config.fields?.exclude).toContain('password'); - expect(config.fields?.mappings?.email?.graphqlName).toBe('emailAddress'); - }); - - it('should accept type with interfaces', () => { - const config = GraphQLTypeConfigSchema.parse({ - name: 'Customer', - object: 'customer', - interfaces: ['Node', 'Timestamped'], - }); - - expect(config.interfaces).toHaveLength(2); - }); - - it('should accept interface definition', () => { - const config = GraphQLTypeConfigSchema.parse({ - name: 'Node', - object: 'base_entity', - isInterface: true, - }); - - expect(config.isInterface).toBe(true); - }); - - it('should accept type with directives', () => { - const config = GraphQLTypeConfigSchema.parse({ - name: 'Customer', - object: 'customer', - directives: [ - { name: 'auth', args: { requires: 'ADMIN' } }, - { name: 'cache', args: { maxAge: 60 } }, - ], - }); - - expect(config.directives).toHaveLength(2); - }); -}); - -describe('GraphQLQueryConfigSchema', () => { - it('should accept minimal query configuration', () => { - const config = GraphQLQueryConfigSchema.parse({ - name: 'customer', - object: 'customer', - type: 'get', - }); - - expect(config.name).toBe('customer'); - expect(config.type).toBe('get'); - }); - - it('should accept list query with filtering', () => { - const config = GraphQLQueryConfigSchema.parse({ - name: 'customers', - object: 'customer', - type: 'list', - filtering: { - enabled: true, - fields: ['name', 'email', 'status'], - operators: ['eq', 'ne', 'contains', 'in'], - }, - }); - - expect(config.filtering?.enabled).toBe(true); - expect(config.filtering?.fields).toContain('name'); - }); - - it('should accept query with sorting', () => { - const config = GraphQLQueryConfigSchema.parse({ - name: 'customers', - object: 'customer', - type: 'list', - sorting: { - enabled: true, - fields: ['name', 'createdAt'], - defaultSort: { - field: 'createdAt', - direction: 'DESC', - }, - }, - }); - - expect(config.sorting?.defaultSort?.direction).toBe('DESC'); - }); - - it('should accept query with pagination', () => { - const config = GraphQLQueryConfigSchema.parse({ - name: 'customers', - object: 'customer', - type: 'list', - pagination: { - enabled: true, - type: 'cursor', - defaultLimit: 50, - maxLimit: 200, - }, - }); - - expect(config.pagination?.type).toBe('cursor'); - expect(config.pagination?.maxLimit).toBe(200); - }); - - it('should accept different pagination types', () => { - const types: Array<'offset' | 'cursor' | 'relay'> = ['offset', 'cursor', 'relay']; - - types.forEach(type => { - const config = GraphQLQueryConfigSchema.parse({ - name: 'test', - object: 'test', - type: 'list', - pagination: { enabled: true, type }, - }); - expect(config.pagination?.type).toBe(type); - }); - }); - - it('should accept query with arguments', () => { - const config = GraphQLQueryConfigSchema.parse({ - name: 'customer', - object: 'customer', - type: 'get', - args: { - id: { - type: 'ID!', - description: 'Customer ID', - }, - }, - }); - - expect(config.args?.id?.type).toBe('ID!'); - }); - - it('should accept query with caching', () => { - const config = GraphQLQueryConfigSchema.parse({ - name: 'customer', - object: 'customer', - type: 'get', - cache: { - enabled: true, - ttl: 300, - key: 'customer:{id}', - }, - }); - - expect(config.cache?.enabled).toBe(true); - expect(config.cache?.ttl).toBe(300); - }); - - it('should apply default authRequired', () => { - const config = GraphQLQueryConfigSchema.parse({ - name: 'test', - object: 'test', - type: 'get', - }); - - expect(config.authRequired).toBe(true); - }); -}); - -describe('GraphQLMutationConfigSchema', () => { - it('should accept minimal mutation configuration', () => { - const config = GraphQLMutationConfigSchema.parse({ - name: 'createCustomer', - object: 'customer', - type: 'create', - }); - - expect(config.name).toBe('createCustomer'); - expect(config.type).toBe('create'); - }); - - it('should accept different mutation types', () => { - const types: Array<'create' | 'update' | 'delete' | 'upsert' | 'custom'> = - ['create', 'update', 'delete', 'upsert', 'custom']; - - types.forEach(type => { - const config = GraphQLMutationConfigSchema.parse({ - name: 'testMutation', - object: 'test', - type, - }); - expect(config.type).toBe(type); - }); - }); - - it('should accept mutation with input configuration', () => { - const config = GraphQLMutationConfigSchema.parse({ - name: 'createCustomer', - object: 'customer', - type: 'create', - input: { - typeName: 'CreateCustomerInput', - fields: { - include: ['name', 'email', 'phone'], - exclude: ['id', 'createdAt'], - required: ['name', 'email'], - }, - validation: { - enabled: true, - rules: ['email_format', 'phone_format'], - }, - }, - }); - - expect(config.input?.typeName).toBe('CreateCustomerInput'); - expect(config.input?.fields?.required).toContain('email'); - }); - - it('should accept mutation with output configuration', () => { - const config = GraphQLMutationConfigSchema.parse({ - name: 'createCustomer', - object: 'customer', - type: 'create', - output: { - type: 'payload', - includeEnvelope: true, - }, - }); - - expect(config.output?.type).toBe('payload'); - expect(config.output?.includeEnvelope).toBe(true); - }); - - it('should accept mutation with transaction configuration', () => { - const config = GraphQLMutationConfigSchema.parse({ - name: 'createOrder', - object: 'order', - type: 'create', - transaction: { - enabled: true, - isolationLevel: 'serializable', - }, - }); - - expect(config.transaction?.enabled).toBe(true); - expect(config.transaction?.isolationLevel).toBe('serializable'); - }); - - it('should accept mutation with hooks', () => { - const config = GraphQLMutationConfigSchema.parse({ - name: 'createCustomer', - object: 'customer', - type: 'create', - hooks: { - before: ['validate_email', 'check_duplicates'], - after: ['send_welcome_email', 'create_audit_log'], - }, - }); - - expect(config.hooks?.before).toHaveLength(2); - expect(config.hooks?.after).toContain('send_welcome_email'); - }); -}); - -describe('GraphQLSubscriptionConfigSchema', () => { - it('should accept minimal subscription configuration', () => { - const config = GraphQLSubscriptionConfigSchema.parse({ - name: 'customerCreated', - object: 'customer', - events: ['created'], - }); - - expect(config.name).toBe('customerCreated'); - expect(config.events).toContain('created'); - }); - - it('should accept subscription with multiple events', () => { - const config = GraphQLSubscriptionConfigSchema.parse({ - name: 'customerChanged', - object: 'customer', - events: ['created', 'updated', 'deleted'], - }); - - expect(config.events).toHaveLength(3); - }); - - it('should accept subscription with filtering', () => { - const config = GraphQLSubscriptionConfigSchema.parse({ - name: 'customerUpdated', - object: 'customer', - events: ['updated'], - filter: { - enabled: true, - fields: ['status', 'priority'], - }, - }); - - expect(config.filter?.enabled).toBe(true); - }); - - it('should accept subscription with payload configuration', () => { - const config = GraphQLSubscriptionConfigSchema.parse({ - name: 'customerUpdated', - object: 'customer', - events: ['updated'], - payload: { - includeEntity: true, - includePreviousValues: true, - includeMeta: true, - }, - }); - - expect(config.payload?.includePreviousValues).toBe(true); - }); - - it('should accept subscription with rate limiting', () => { - const config = GraphQLSubscriptionConfigSchema.parse({ - name: 'customerUpdated', - object: 'customer', - events: ['updated'], - rateLimit: { - enabled: true, - maxSubscriptionsPerUser: 5, - throttleMs: 1000, - }, - }); - - expect(config.rateLimit?.maxSubscriptionsPerUser).toBe(5); - }); -}); - -describe('GraphQLResolverConfigSchema', () => { - it('should accept minimal resolver configuration', () => { - const config = GraphQLResolverConfigSchema.parse({ - path: 'Query.customers', - type: 'datasource', - }); - - expect(config.path).toBe('Query.customers'); - expect(config.type).toBe('datasource'); - }); - - it('should accept different resolver types', () => { - const types: Array<'datasource' | 'computed' | 'script' | 'proxy'> = - ['datasource', 'computed', 'script', 'proxy']; - - types.forEach(type => { - const config = GraphQLResolverConfigSchema.parse({ - path: 'Query.test', - type, - }); - expect(config.type).toBe(type); - }); - }); - - it('should accept datasource resolver', () => { - const config = GraphQLResolverConfigSchema.parse({ - path: 'Query.customers', - type: 'datasource', - implementation: { - datasource: 'external_db', - query: 'SELECT * FROM customers WHERE id = $1', - }, - }); - - expect(config.implementation?.datasource).toBe('external_db'); - }); - - it('should accept computed resolver', () => { - const config = GraphQLResolverConfigSchema.parse({ - path: 'Customer.fullName', - type: 'computed', - implementation: { - expression: 'concat(firstName, " ", lastName)', - dependencies: ['firstName', 'lastName'], - }, - }); - - expect(config.implementation?.dependencies).toContain('firstName'); - }); - - it('should accept resolver with caching', () => { - const config = GraphQLResolverConfigSchema.parse({ - path: 'Query.customer', - type: 'datasource', - cache: { - enabled: true, - ttl: 600, - keyArgs: ['id'], - }, - }); - - expect(config.cache?.enabled).toBe(true); - expect(config.cache?.keyArgs).toContain('id'); - }); -}); - -describe('GraphQLDataLoaderConfigSchema', () => { - it('should accept minimal DataLoader configuration', () => { - const config = GraphQLDataLoaderConfigSchema.parse({ - name: 'customerLoader', - source: 'customer', - batchFunction: { - type: 'findByIds', - keyField: 'id', - }, - }); - - expect(config.name).toBe('customerLoader'); - expect(config.batchFunction.type).toBe('findByIds'); - }); - - it('should accept different batch function types', () => { - const types: Array<'findByIds' | 'query' | 'script' | 'custom'> = - ['findByIds', 'query', 'script', 'custom']; - - types.forEach(type => { - const config = GraphQLDataLoaderConfigSchema.parse({ - name: 'testLoader', - source: 'test', - batchFunction: { type }, - }); - expect(config.batchFunction.type).toBe(type); - }); - }); - - it('should apply default maxBatchSize', () => { - const config = GraphQLDataLoaderConfigSchema.parse({ - name: 'testLoader', - source: 'test', - batchFunction: { - type: 'findByIds', - }, - }); - - expect(config.batchFunction.maxBatchSize).toBe(100); - }); - - it('should accept DataLoader with caching options', () => { - const config = GraphQLDataLoaderConfigSchema.parse({ - name: 'customerLoader', - source: 'customer', - batchFunction: { - type: 'findByIds', - }, - cache: { - enabled: true, - keyFn: 'customKeyFunction', - }, - options: { - batch: true, - cache: true, - maxCacheSize: 1000, - }, - }); - - expect(config.cache?.enabled).toBe(true); - expect(config.options?.maxCacheSize).toBe(1000); - }); -}); - -describe('GraphQLDirectiveConfigSchema', () => { - it('should accept minimal directive configuration', () => { - const config = GraphQLDirectiveConfigSchema.parse({ - name: 'auth', - locations: ['FIELD_DEFINITION'], - }); - - expect(config.name).toBe('auth'); - expect(config.locations).toContain('FIELD_DEFINITION'); - }); - - it('should accept directive with arguments', () => { - const config = GraphQLDirectiveConfigSchema.parse({ - name: 'auth', - locations: ['FIELD_DEFINITION', 'OBJECT'], - args: { - requires: { - type: 'String!', - description: 'Required permission', - }, - }, - }); - - expect(config.args?.requires?.type).toBe('String!'); - }); - - it('should accept repeatable directive', () => { - const config = GraphQLDirectiveConfigSchema.parse({ - name: 'validate', - locations: ['FIELD_DEFINITION'], - repeatable: true, - }); - - expect(config.repeatable).toBe(true); - }); - - it('should validate directive name format', () => { - expect(() => GraphQLDirectiveConfigSchema.parse({ - name: 'myDirective', - locations: ['FIELD'], - })).not.toThrow(); - - expect(() => GraphQLDirectiveConfigSchema.parse({ - name: 'MyDirective', - locations: ['FIELD'], - })).toThrow(); - - expect(() => GraphQLDirectiveConfigSchema.parse({ - name: 'my-directive', - locations: ['FIELD'], - })).toThrow(); - }); - - it('should accept directive with implementation', () => { - const config = GraphQLDirectiveConfigSchema.parse({ - name: 'cache', - locations: ['FIELD_DEFINITION'], - implementation: { - type: 'cache', - handler: 'cacheHandler', - }, - }); - - expect(config.implementation?.type).toBe('cache'); - }); -}); - -describe('GraphQLQueryDepthLimitSchema', () => { - it('should apply default values', () => { - const config = GraphQLQueryDepthLimitSchema.parse({}); - - expect(config.enabled).toBe(true); - expect(config.maxDepth).toBe(10); - expect(config.onDepthExceeded).toBe('reject'); - }); - - it('should accept custom depth limit', () => { - const config = GraphQLQueryDepthLimitSchema.parse({ - enabled: true, - maxDepth: 5, - ignoreFields: ['__typename', 'id'], - onDepthExceeded: 'log', - errorMessage: 'Query too deep', - }); - - expect(config.maxDepth).toBe(5); - expect(config.ignoreFields).toContain('__typename'); - expect(config.onDepthExceeded).toBe('log'); - }); -}); - -describe('GraphQLQueryComplexitySchema', () => { - it('should apply default values', () => { - const config = GraphQLQueryComplexitySchema.parse({}); - - expect(config.enabled).toBe(true); - expect(config.maxComplexity).toBe(1000); - expect(config.defaultFieldComplexity).toBe(1); - expect(config.listMultiplier).toBe(10); - }); - - it('should accept custom complexity configuration', () => { - const config = GraphQLQueryComplexitySchema.parse({ - enabled: true, - maxComplexity: 5000, - defaultFieldComplexity: 2, - listMultiplier: 20, - }); - - expect(config.maxComplexity).toBe(5000); - expect(config.listMultiplier).toBe(20); - }); - - it('should accept field-specific complexity as number', () => { - const config = GraphQLQueryComplexitySchema.parse({ - fieldComplexity: { - 'Query.customers': 10, - 'Customer.orders': 5, - }, - }); - - expect(config.fieldComplexity?.['Query.customers']).toBe(10); - }); - - it('should accept field-specific complexity as object', () => { - const config = GraphQLQueryComplexitySchema.parse({ - fieldComplexity: { - 'Query.customers': { - base: 10, - multiplier: 'limit', - calculator: 'customCalculator', - }, - }, - }); - - const complexityConfig = config.fieldComplexity?.['Query.customers']; - if (typeof complexityConfig === 'object' && 'base' in complexityConfig) { - expect(complexityConfig.base).toBe(10); - expect(complexityConfig.multiplier).toBe('limit'); - } - }); -}); - -describe('GraphQLRateLimitSchema', () => { - it('should apply default values', () => { - const config = GraphQLRateLimitSchema.parse({}); - - expect(config.enabled).toBe(true); - expect(config.strategy).toBe('token_bucket'); - expect(config.onLimitExceeded).toBe('reject'); - expect(config.includeHeaders).toBe(true); - }); - - it('should accept different strategies', () => { - const strategies: Array<'token_bucket' | 'fixed_window' | 'sliding_window' | 'cost_based'> = - ['token_bucket', 'fixed_window', 'sliding_window', 'cost_based']; - - strategies.forEach(strategy => { - const config = GraphQLRateLimitSchema.parse({ strategy }); - expect(config.strategy).toBe(strategy); - }); - }); - - it('should accept global rate limits', () => { - const config = GraphQLRateLimitSchema.parse({ - global: { - maxRequests: 5000, - windowMs: 3600000, - }, - }); - - expect(config.global?.maxRequests).toBe(5000); - }); - - it('should accept per-user rate limits', () => { - const config = GraphQLRateLimitSchema.parse({ - perUser: { - maxRequests: 100, - windowMs: 60000, - }, - }); - - expect(config.perUser?.maxRequests).toBe(100); - }); - - it('should accept cost-based rate limiting', () => { - const config = GraphQLRateLimitSchema.parse({ - costBased: { - enabled: true, - maxCost: 10000, - windowMs: 60000, - useComplexityAsCost: true, - }, - }); - - expect(config.costBased?.enabled).toBe(true); - expect(config.costBased?.useComplexityAsCost).toBe(true); - }); - - it('should accept operation-specific limits', () => { - const config = GraphQLRateLimitSchema.parse({ - operations: { - 'createOrder': { - maxRequests: 10, - windowMs: 60000, - }, - 'deleteCustomer': { - maxRequests: 5, - windowMs: 60000, - }, - }, - }); - - expect(config.operations?.createOrder?.maxRequests).toBe(10); - }); -}); - -describe('GraphQLPersistedQuerySchema', () => { - it('should apply default values', () => { - const config = GraphQLPersistedQuerySchema.parse({}); - - expect(config.enabled).toBe(false); - expect(config.mode).toBe('optional'); - }); - - it('should accept different modes', () => { - const modes: Array<'optional' | 'required'> = ['optional', 'required']; - - modes.forEach(mode => { - const config = GraphQLPersistedQuerySchema.parse({ mode }); - expect(config.mode).toBe(mode); - }); - }); - - it('should accept store configuration', () => { - const config = GraphQLPersistedQuerySchema.parse({ - store: { - type: 'redis', - connection: 'redis://localhost:6379', - ttl: 86400, - }, - }); - - expect(config.store.type).toBe('redis'); - expect(config.store.ttl).toBe(86400); - }); - - it('should accept APQ configuration', () => { - const config = GraphQLPersistedQuerySchema.parse({ - apq: { - enabled: true, - hashAlgorithm: 'sha256', - cache: { - ttl: 3600, - maxSize: 1000, - }, - }, - }); - - expect(config.apq?.enabled).toBe(true); - expect(config.apq?.hashAlgorithm).toBe('sha256'); - }); - - it('should accept allowlist configuration', () => { - const config = GraphQLPersistedQuerySchema.parse({ - allowlist: { - enabled: true, - queries: [ - { id: 'query1', operation: 'GetCustomer' }, - { id: 'query2', operation: 'ListOrders' }, - ], - source: '/path/to/allowlist.json', - }, - }); - - expect(config.allowlist?.enabled).toBe(true); - expect(config.allowlist?.queries).toHaveLength(2); - }); - - it('should accept security configuration', () => { - const config = GraphQLPersistedQuerySchema.parse({ - security: { - maxQuerySize: 10000, - rejectIntrospection: true, - }, - }); - - expect(config.security?.rejectIntrospection).toBe(true); - }); -}); - -describe('GraphQLConfigSchema', () => { - it('should apply default values', () => { - const config = GraphQLConfigSchema.parse({}); - - expect(config.enabled).toBe(true); - expect(config.path).toBe('/graphql'); - }); - - it('should accept complete GraphQL configuration', () => { - const config = GraphQLConfigSchema.parse({ - enabled: true, - path: '/api/graphql', - playground: { - enabled: true, - path: '/graphql-playground', - }, - schema: { - autoGenerateTypes: true, - types: [ - { - name: 'Customer', - object: 'customer', - }, - ], - queries: [ - { - name: 'customers', - object: 'customer', - type: 'list', - }, - ], - mutations: [ - { - name: 'createCustomer', - object: 'customer', - type: 'create', - }, - ], - }, - security: { - depthLimit: { - enabled: true, - maxDepth: 10, - }, - complexity: { - enabled: true, - maxComplexity: 1000, - }, - rateLimit: { - enabled: true, - strategy: 'token_bucket', - }, - persistedQueries: { - enabled: true, - mode: 'optional', - store: { - type: 'memory', - }, - }, - }, - }); - - expect(config.path).toBe('/api/graphql'); - expect(config.schema?.types).toHaveLength(1); - expect(config.security?.depthLimit?.enabled).toBe(true); - }); - - it('should use GraphQLConfig factory', () => { - const config = GraphQLConfig.create({ - enabled: true, - path: '/graphql', - }); - - expect(config.enabled).toBe(true); - }); -}); - -describe('mapFieldTypeToGraphQL', () => { - it('should map text types to String', () => { - expect(mapFieldTypeToGraphQL('text')).toBe('String'); - expect(mapFieldTypeToGraphQL('textarea')).toBe('String'); - expect(mapFieldTypeToGraphQL('markdown')).toBe('String'); - }); - - it('should map email to Email scalar', () => { - expect(mapFieldTypeToGraphQL('email')).toBe('Email'); - }); - - it('should map url to URL scalar', () => { - expect(mapFieldTypeToGraphQL('url')).toBe('URL'); - }); - - it('should map number types to Float', () => { - expect(mapFieldTypeToGraphQL('number')).toBe('Float'); - expect(mapFieldTypeToGraphQL('percent')).toBe('Float'); - }); - - it('should map currency to Currency scalar', () => { - expect(mapFieldTypeToGraphQL('currency')).toBe('Currency'); - }); - - it('should map date types correctly', () => { - expect(mapFieldTypeToGraphQL('date')).toBe('Date'); - expect(mapFieldTypeToGraphQL('datetime')).toBe('DateTime'); - expect(mapFieldTypeToGraphQL('time')).toBe('Time'); - }); - - it('should map boolean types to Boolean', () => { - expect(mapFieldTypeToGraphQL('boolean')).toBe('Boolean'); - expect(mapFieldTypeToGraphQL('toggle')).toBe('Boolean'); - }); - - it('should map select to String', () => { - expect(mapFieldTypeToGraphQL('select')).toBe('String'); - }); - - it('should map multiselect to [String]', () => { - expect(mapFieldTypeToGraphQL('multiselect')).toBe('[String]'); - }); - - it('should map lookup types to ID', () => { - expect(mapFieldTypeToGraphQL('lookup')).toBe('ID'); - expect(mapFieldTypeToGraphQL('master_detail')).toBe('ID'); - }); - - it('should map media types to URL', () => { - expect(mapFieldTypeToGraphQL('image')).toBe('URL'); - expect(mapFieldTypeToGraphQL('file')).toBe('URL'); - expect(mapFieldTypeToGraphQL('avatar')).toBe('URL'); - }); - - it('should map location and address to JSONObject', () => { - expect(mapFieldTypeToGraphQL('location')).toBe('JSONObject'); - expect(mapFieldTypeToGraphQL('address')).toBe('JSONObject'); - }); - - it('should map json to JSON scalar', () => { - expect(mapFieldTypeToGraphQL('json')).toBe('JSON'); - }); - - it('should map vector to [Float]', () => { - expect(mapFieldTypeToGraphQL('vector')).toBe('[Float]'); - }); - - it('should default to String for unknown types', () => { - // @ts-expect-error Testing unknown type - expect(mapFieldTypeToGraphQL('unknown_type')).toBe('String'); - }); -}); - -// ========================================== -// Federation Schema Tests -// ========================================== - -describe('FederationEntityKeySchema', () => { - it('should accept minimal entity key', () => { - const key = FederationEntityKeySchema.parse({ - fields: 'id', - }); - - expect(key.fields).toBe('id'); - expect(key.resolvable).toBe(true); - }); - - it('should accept composite key', () => { - const key = FederationEntityKeySchema.parse({ - fields: 'sku packageId', - resolvable: false, - }); - - expect(key.fields).toBe('sku packageId'); - expect(key.resolvable).toBe(false); - }); -}); - -describe('FederationExternalFieldSchema', () => { - it('should accept external field', () => { - const field = FederationExternalFieldSchema.parse({ - field: 'name', - ownerSubgraph: 'users', - }); - - expect(field.field).toBe('name'); - expect(field.ownerSubgraph).toBe('users'); - }); -}); - -describe('FederationRequiresSchema', () => { - it('should accept requires directive', () => { - const req = FederationRequiresSchema.parse({ - field: 'shippingCost', - fields: 'price weight', - }); - - expect(req.field).toBe('shippingCost'); - expect(req.fields).toBe('price weight'); - }); -}); - -describe('FederationProvidesSchema', () => { - it('should accept provides directive', () => { - const prov = FederationProvidesSchema.parse({ - field: 'reviews', - fields: 'title body', - }); - - expect(prov.field).toBe('reviews'); - expect(prov.fields).toBe('title body'); - }); -}); - -describe('FederationEntitySchema', () => { - it('should accept minimal entity', () => { - const entity = FederationEntitySchema.parse({ - typeName: 'Product', - keys: [{ fields: 'id' }], - }); - - expect(entity.typeName).toBe('Product'); - expect(entity.keys).toHaveLength(1); - expect(entity.owner).toBe(false); - }); - - it('should accept entity with all directives', () => { - const entity = FederationEntitySchema.parse({ - typeName: 'Product', - keys: [ - { fields: 'id' }, - { fields: 'sku', resolvable: false }, - ], - externalFields: [ - { field: 'weight', ownerSubgraph: 'inventory' }, - ], - requires: [ - { field: 'shippingCost', fields: 'price weight' }, - ], - provides: [ - { field: 'reviews', fields: 'author body' }, - ], - owner: true, - }); - - expect(entity.keys).toHaveLength(2); - expect(entity.externalFields).toHaveLength(1); - expect(entity.requires).toHaveLength(1); - expect(entity.provides).toHaveLength(1); - expect(entity.owner).toBe(true); - }); - - it('should require at least one key', () => { - expect(() => FederationEntitySchema.parse({ - typeName: 'Product', - keys: [], - })).toThrow(); - }); -}); - -describe('SubgraphConfigSchema', () => { - it('should accept minimal subgraph config', () => { - const subgraph = SubgraphConfigSchema.parse({ - name: 'products', - url: 'http://localhost:4001/graphql', - }); - - expect(subgraph.name).toBe('products'); - expect(subgraph.schemaSource).toBe('introspection'); - }); - - it('should accept subgraph with entities and health check', () => { - const subgraph = SubgraphConfigSchema.parse({ - name: 'users', - url: 'http://localhost:4002/graphql', - schemaSource: 'file', - schemaPath: './schemas/users.graphql', - entities: [ - { - typeName: 'User', - keys: [{ fields: 'id' }], - owner: true, - }, - ], - healthCheck: { - enabled: true, - path: '/.well-known/apollo/server-health', - intervalMs: 10000, - }, - forwardHeaders: ['Authorization', 'X-Tenant-ID'], - }); - - expect(subgraph.schemaSource).toBe('file'); - expect(subgraph.entities).toHaveLength(1); - expect(subgraph.healthCheck?.enabled).toBe(true); - expect(subgraph.forwardHeaders).toContain('Authorization'); - }); - - it('should accept all schema source types', () => { - const sources = ['introspection', 'file', 'registry'] as const; - - sources.forEach(source => { - const subgraph = SubgraphConfigSchema.parse({ - name: 'test', - url: 'http://localhost:4000/graphql', - schemaSource: source, - }); - expect(subgraph.schemaSource).toBe(source); - }); - }); -}); - -describe('FederationGatewaySchema', () => { - it('should apply default values', () => { - const gateway = FederationGatewaySchema.parse({ - subgraphs: [ - { name: 'products', url: 'http://localhost:4001/graphql' }, - ], - }); - - expect(gateway.enabled).toBe(false); - expect(gateway.version).toBe('v2'); - }); - - it('should accept complete gateway configuration', () => { - const gateway = FederationGatewaySchema.parse({ - enabled: true, - version: 'v2', - subgraphs: [ - { - name: 'products', - url: 'http://products:4001/graphql', - entities: [ - { typeName: 'Product', keys: [{ fields: 'id' }], owner: true }, - ], - }, - { - name: 'reviews', - url: 'http://reviews:4002/graphql', - entities: [ - { - typeName: 'Product', - keys: [{ fields: 'id' }], - externalFields: [{ field: 'name' }], - }, - { typeName: 'Review', keys: [{ fields: 'id' }], owner: true }, - ], - }, - ], - serviceDiscovery: { - type: 'kubernetes', - pollIntervalMs: 15000, - namespace: 'production', - }, - queryPlanning: { - strategy: 'parallel', - maxDepth: 8, - dryRun: false, - }, - composition: { - conflictResolution: 'error', - validate: true, - }, - errorHandling: { - includeSubgraphName: true, - partialErrors: 'propagate', - }, - }); - - expect(gateway.enabled).toBe(true); - expect(gateway.subgraphs).toHaveLength(2); - expect(gateway.serviceDiscovery?.type).toBe('kubernetes'); - expect(gateway.queryPlanning?.strategy).toBe('parallel'); - expect(gateway.composition?.conflictResolution).toBe('error'); - expect(gateway.errorHandling?.includeSubgraphName).toBe(true); - }); - - it('should accept all service discovery types', () => { - const types = ['static', 'dns', 'consul', 'kubernetes'] as const; - - types.forEach(type => { - const gateway = FederationGatewaySchema.parse({ - subgraphs: [{ name: 'test', url: 'http://localhost:4000/graphql' }], - serviceDiscovery: { type }, - }); - expect(gateway.serviceDiscovery?.type).toBe(type); - }); - }); - - it('should accept all query planning strategies', () => { - const strategies = ['parallel', 'sequential', 'adaptive'] as const; - - strategies.forEach(strategy => { - const gateway = FederationGatewaySchema.parse({ - subgraphs: [{ name: 'test', url: 'http://localhost:4000/graphql' }], - queryPlanning: { strategy }, - }); - expect(gateway.queryPlanning?.strategy).toBe(strategy); - }); - }); - - it('should accept all conflict resolution strategies', () => { - const strategies = ['error', 'first_wins', 'last_wins'] as const; - - strategies.forEach(conflictResolution => { - const gateway = FederationGatewaySchema.parse({ - subgraphs: [{ name: 'test', url: 'http://localhost:4000/graphql' }], - composition: { conflictResolution }, - }); - expect(gateway.composition?.conflictResolution).toBe(conflictResolution); - }); - }); -}); - -describe('GraphQLConfigSchema with Federation', () => { - it('should accept config with federation', () => { - const config = GraphQLConfigSchema.parse({ - enabled: true, - path: '/graphql', - federation: { - enabled: true, - version: 'v2', - subgraphs: [ - { - name: 'products', - url: 'http://products:4001/graphql', - entities: [ - { typeName: 'Product', keys: [{ fields: 'id' }] }, - ], - }, - ], - }, - }); - - expect(config.federation?.enabled).toBe(true); - expect(config.federation?.subgraphs).toHaveLength(1); - }); -}); diff --git a/packages/spec/src/api/graphql.zod.ts b/packages/spec/src/api/graphql.zod.ts deleted file mode 100644 index d296d4fc97..0000000000 --- a/packages/spec/src/api/graphql.zod.ts +++ /dev/null @@ -1,1131 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { FieldType } from '../data/field.zod'; -import { ExpressionInputSchema, TemplateExpressionInputSchema } from '../shared/expression.zod'; - -/** - * GraphQL Protocol Support - * - * GraphQL is a query language for APIs and a runtime for executing those queries. - * It provides a complete and understandable description of the data in your API, - * gives clients the power to ask for exactly what they need, and enables powerful - * developer tools. - * - * ## Overview - * - * GraphQL provides: - * - Type-safe schema definition - * - Precise data fetching (no over/under-fetching) - * - Introspection and documentation - * - Real-time subscriptions - * - Batched queries with DataLoader - * - * ## Use Cases - * - * 1. **Modern API Development** - * - Mobile and web applications - * - Microservices federation - * - Real-time dashboards - * - * 2. **Data Aggregation** - * - Multi-source data integration - * - Complex nested queries - * - Efficient data loading - * - * 3. **Developer Experience** - * - Self-documenting API - * - Type safety and validation - * - GraphQL playground - * - * @see https://graphql.org/ - * @see https://spec.graphql.org/ - * - * @example GraphQL Query - * ```graphql - * query GetCustomer($id: ID!) { - * customer(id: $id) { - * id - * name - * email - * orders(limit: 10, status: "active") { - * id - * total - * items { - * product { - * name - * price - * } - * } - * } - * } - * } - * ``` - * - * @example GraphQL Mutation - * ```graphql - * mutation CreateOrder($input: CreateOrderInput!) { - * createOrder(input: $input) { - * id - * orderNumber - * status - * } - * } - * ``` - */ - -// ========================================== -// 1. GraphQL Type System -// ========================================== - -/** - * GraphQL Scalar Types - * - * Built-in scalar types in GraphQL plus custom scalars. - */ -import { lazySchema } from '../shared/lazy-schema'; -export const GraphQLScalarType = z.enum([ - // Built-in GraphQL Scalars - 'ID', - 'String', - 'Int', - 'Float', - 'Boolean', - - // Extended Scalars (common custom types) - 'DateTime', - 'Date', - 'Time', - 'JSON', - 'JSONObject', - 'Upload', - 'URL', - 'Email', - 'PhoneNumber', - 'Currency', - 'Decimal', - 'BigInt', - 'Long', - 'UUID', - 'Base64', - 'Void', -]); - -export type GraphQLScalarType = z.infer; - -/** - * GraphQL Type Configuration - * - * Configuration for generating GraphQL types from Object definitions. - */ -export const GraphQLTypeConfigSchema = lazySchema(() => z.object({ - /** Type name in GraphQL schema */ - name: z.string().describe('GraphQL type name (PascalCase recommended)'), - - /** Source Object name */ - object: z.string().describe('Source ObjectQL object name'), - - /** Description for GraphQL schema documentation */ - description: z.string().optional().describe('Type description'), - - /** Fields to include/exclude */ - fields: z.object({ - /** Include only these fields (allow list) */ - include: z.array(z.string()).optional().describe('Fields to include'), - - /** Exclude these fields (deny list) */ - exclude: z.array(z.string()).optional().describe('Fields to exclude (e.g., sensitive fields)'), - - /** Custom field mappings */ - mappings: z.record(z.string(), z.object({ - graphqlName: z.string().optional().describe('Custom GraphQL field name'), - graphqlType: z.string().optional().describe('Override GraphQL type'), - description: z.string().optional().describe('Field description'), - deprecationReason: z.string().optional().describe('Why field is deprecated'), - nullable: z.boolean().optional().describe('Override nullable'), - })).optional().describe('Field-level customizations'), - }).optional().describe('Field configuration'), - - /** Interfaces this type implements */ - interfaces: z.array(z.string()).optional().describe('GraphQL interface names'), - - /** Whether this is an interface definition */ - isInterface: z.boolean().optional().default(false).describe('Define as GraphQL interface'), - - /** Custom directives */ - directives: z.array(z.object({ - name: z.string().describe('Directive name'), - args: z.record(z.string(), z.unknown()).optional().describe('Directive arguments'), - })).optional().describe('GraphQL directives'), -})); - -export type GraphQLTypeConfig = z.infer; -export type GraphQLTypeConfigInput = z.input; - -// ========================================== -// 2. Query Generation Configuration -// ========================================== - -/** - * GraphQL Query Configuration - * - * Configuration for auto-generating query fields from Objects. - */ -export const GraphQLQueryConfigSchema = lazySchema(() => z.object({ - /** Query name */ - name: z.string().describe('Query field name (camelCase recommended)'), - - /** Source Object */ - object: z.string().describe('Source ObjectQL object name'), - - /** Query type: single record or list */ - type: z.enum(['get', 'list', 'search']).describe('Query type'), - - /** Description */ - description: z.string().optional().describe('Query description'), - - /** Input arguments */ - args: z.record(z.string(), z.object({ - type: z.string().describe('GraphQL type (e.g., "ID!", "String", "Int")'), - description: z.string().optional().describe('Argument description'), - defaultValue: z.unknown().optional().describe('Default value'), - })).optional().describe('Query arguments'), - - /** Filtering configuration */ - filtering: z.object({ - enabled: z.boolean().default(true).describe('Allow filtering'), - fields: z.array(z.string()).optional().describe('Filterable fields'), - operators: z.array(z.enum([ - 'eq', 'ne', 'gt', 'gte', 'lt', 'lte', - 'in', 'notIn', 'contains', 'startsWith', 'endsWith', - 'isNull', 'isNotNull', - ])).optional().describe('Allowed filter operators'), - }).optional().describe('Filtering capabilities'), - - /** Sorting configuration */ - sorting: z.object({ - enabled: z.boolean().default(true).describe('Allow sorting'), - fields: z.array(z.string()).optional().describe('Sortable fields'), - defaultSort: z.object({ - field: z.string(), - direction: z.enum(['ASC', 'DESC']), - }).optional().describe('Default sort order'), - }).optional().describe('Sorting capabilities'), - - /** Pagination configuration */ - pagination: z.object({ - enabled: z.boolean().default(true).describe('Enable pagination'), - type: z.enum(['offset', 'cursor', 'relay']).default('offset').describe('Pagination style'), - defaultLimit: z.number().int().min(1).default(20).describe('Default page size'), - maxLimit: z.number().int().min(1).default(100).describe('Maximum page size'), - cursors: z.object({ - field: z.string().default('id').describe('Field to use for cursor pagination'), - }).optional(), - }).optional().describe('Pagination configuration'), - - /** Field selection */ - fields: z.object({ - /** Always include these fields */ - required: z.array(z.string()).optional().describe('Required fields (always returned)'), - - /** Allow selecting these fields */ - selectable: z.array(z.string()).optional().describe('Selectable fields'), - }).optional().describe('Field selection configuration'), - - /** Authorization */ - authRequired: z.boolean().default(true).describe('Require authentication'), - permissions: z.array(z.string()).optional().describe('Required permissions'), - - /** Caching */ - cache: z.object({ - enabled: z.boolean().default(false).describe('Enable caching'), - ttl: z.number().int().min(0).optional().describe('Cache TTL in seconds'), - key: TemplateExpressionInputSchema.optional().describe('Cache key template — supports {{var}} interpolation'), - }).optional().describe('Query caching'), -})); - -export type GraphQLQueryConfig = z.infer; -export type GraphQLQueryConfigInput = z.input; - -// ========================================== -// 3. Mutation Generation Configuration -// ========================================== - -/** - * GraphQL Mutation Configuration - * - * Configuration for auto-generating mutation fields from Objects. - */ -export const GraphQLMutationConfigSchema = lazySchema(() => z.object({ - /** Mutation name */ - name: z.string().describe('Mutation field name (camelCase recommended)'), - - /** Source Object */ - object: z.string().describe('Source ObjectQL object name'), - - /** Mutation type */ - type: z.enum(['create', 'update', 'delete', 'upsert', 'custom']).describe('Mutation type'), - - /** Description */ - description: z.string().optional().describe('Mutation description'), - - /** Input type configuration */ - input: z.object({ - /** Input type name */ - typeName: z.string().optional().describe('Custom input type name'), - - /** Fields to include in input */ - fields: z.object({ - include: z.array(z.string()).optional().describe('Fields to include'), - exclude: z.array(z.string()).optional().describe('Fields to exclude'), - required: z.array(z.string()).optional().describe('Required input fields'), - }).optional().describe('Input field configuration'), - - /** Validation */ - validation: z.object({ - enabled: z.boolean().default(true).describe('Enable input validation'), - rules: z.array(z.string()).optional().describe('Custom validation rules'), - }).optional().describe('Input validation'), - }).optional().describe('Input configuration'), - - /** Return type configuration */ - output: z.object({ - /** Type of output */ - type: z.enum(['object', 'payload', 'boolean', 'custom']).default('object').describe('Output type'), - - /** Include success/error envelope */ - includeEnvelope: z.boolean().optional().default(false).describe('Wrap in success/error payload'), - - /** Custom output type */ - customType: z.string().optional().describe('Custom output type name'), - }).optional().describe('Output configuration'), - - /** Transaction handling */ - transaction: z.object({ - enabled: z.boolean().default(true).describe('Use database transaction'), - isolationLevel: z.enum(['read_uncommitted', 'read_committed', 'repeatable_read', 'serializable']).optional().describe('Transaction isolation level'), - }).optional().describe('Transaction configuration'), - - /** Authorization */ - authRequired: z.boolean().default(true).describe('Require authentication'), - permissions: z.array(z.string()).optional().describe('Required permissions'), - - /** Hooks */ - hooks: z.object({ - before: z.array(z.string()).optional().describe('Pre-mutation hooks'), - after: z.array(z.string()).optional().describe('Post-mutation hooks'), - }).optional().describe('Lifecycle hooks'), -})); - -export type GraphQLMutationConfig = z.infer; -export type GraphQLMutationConfigInput = z.input; - -// ========================================== -// 4. Subscription Configuration -// ========================================== - -/** - * GraphQL Subscription Configuration - * - * Configuration for real-time GraphQL subscriptions. - * - * ⚠️ NOT YET IMPLEMENTED — declared but has no runtime consumer (#3197). The - * GraphQL HTTP entry serves query/mutation only and no subscription transport - * is mounted, so configs authored against this schema parse but are never - * served. Do not advertise GraphQL subscriptions to end users yet. - */ -export const GraphQLSubscriptionConfigSchema = lazySchema(() => z.object({ - /** Subscription name */ - name: z.string().describe('Subscription field name (camelCase recommended)'), - - /** Source Object */ - object: z.string().describe('Source ObjectQL object name'), - - /** Subscription trigger events */ - events: z.array(z.enum(['created', 'updated', 'deleted', 'custom'])).describe('Events to subscribe to (not yet implemented — no subscription transport exists; see #3197)'), - - /** Description */ - description: z.string().optional().describe('Subscription description'), - - /** Filtering */ - filter: z.object({ - enabled: z.boolean().default(true).describe('Allow filtering subscriptions'), - fields: z.array(z.string()).optional().describe('Filterable fields'), - }).optional().describe('Subscription filtering'), - - /** Payload configuration */ - payload: z.object({ - /** Include the modified entity */ - includeEntity: z.boolean().default(true).describe('Include entity in payload'), - - /** Include previous values (for updates) */ - includePreviousValues: z.boolean().optional().default(false).describe('Include previous field values'), - - /** Include mutation metadata */ - includeMeta: z.boolean().optional().default(true).describe('Include metadata (timestamp, user, etc.)'), - }).optional().describe('Payload configuration'), - - /** Authorization */ - authRequired: z.boolean().default(true).describe('Require authentication'), - permissions: z.array(z.string()).optional().describe('Required permissions'), - - /** Rate limiting for subscriptions */ - rateLimit: z.object({ - enabled: z.boolean().default(true).describe('Enable rate limiting'), - maxSubscriptionsPerUser: z.number().int().min(1).default(10).describe('Max concurrent subscriptions per user'), - throttleMs: z.number().int().min(0).optional().describe('Throttle interval in milliseconds'), - }).optional().describe('Subscription rate limiting'), -})); - -export type GraphQLSubscriptionConfig = z.infer; -export type GraphQLSubscriptionConfigInput = z.input; - -// ========================================== -// 5. Resolver Configuration -// ========================================== - -/** - * GraphQL Resolver Configuration - * - * Configuration for custom resolver logic. - */ -export const GraphQLResolverConfigSchema = lazySchema(() => z.object({ - /** Field path (e.g., "Query.users", "Mutation.createUser") */ - path: z.string().describe('Resolver path (Type.field)'), - - /** Resolver implementation type */ - type: z.enum(['datasource', 'computed', 'script', 'proxy']).describe('Resolver implementation type'), - - /** Implementation details */ - implementation: z.object({ - /** For datasource type */ - datasource: z.string().optional().describe('Datasource ID'), - query: z.string().optional().describe('Query/SQL to execute'), - - /** For computed type */ - expression: ExpressionInputSchema.optional().describe('Computation expression — CEL formula, e.g. F`record.firstName + " " + record.lastName`'), - dependencies: z.array(z.string()).optional().describe('Dependent fields'), - - /** For script type */ - script: z.string().optional().describe('Script ID or inline code'), - - /** For proxy type */ - url: z.string().optional().describe('Proxy URL'), - method: z.enum(['GET', 'POST', 'PUT', 'DELETE']).optional().describe('HTTP method'), - }).optional().describe('Implementation configuration'), - - /** Caching */ - cache: z.object({ - enabled: z.boolean().default(false).describe('Enable resolver caching'), - ttl: z.number().int().min(0).optional().describe('Cache TTL in seconds'), - keyArgs: z.array(z.string()).optional().describe('Arguments to include in cache key'), - }).optional().describe('Resolver caching'), -})); - -export type GraphQLResolverConfig = z.infer; -export type GraphQLResolverConfigInput = z.input; - -// ========================================== -// 6. DataLoader Configuration -// ========================================== - -/** - * GraphQL DataLoader Configuration - * - * Configuration for batching and caching with DataLoader pattern. - * Prevents N+1 query problems in GraphQL. - */ -export const GraphQLDataLoaderConfigSchema = lazySchema(() => z.object({ - /** Loader name */ - name: z.string().describe('DataLoader name'), - - /** Source Object or datasource */ - source: z.string().describe('Source object or datasource'), - - /** Batch function configuration */ - batchFunction: z.object({ - /** Type of batch operation */ - type: z.enum(['findByIds', 'query', 'script', 'custom']).describe('Batch function type'), - - /** For findByIds */ - keyField: z.string().optional().describe('Field to batch on (e.g., "id")'), - - /** For query */ - query: z.string().optional().describe('Query template'), - - /** For script */ - script: z.string().optional().describe('Script ID'), - - /** Maximum batch size */ - maxBatchSize: z.number().int().min(1).optional().default(100).describe('Maximum batch size'), - }).describe('Batch function configuration'), - - /** Caching */ - cache: z.object({ - enabled: z.boolean().default(true).describe('Enable per-request caching'), - - /** Cache key function */ - keyFn: z.string().optional().describe('Custom cache key function'), - }).optional().describe('DataLoader caching'), - - /** Options */ - options: z.object({ - /** Batch multiple requests in single tick */ - batch: z.boolean().default(true).describe('Enable batching'), - - /** Cache loaded values */ - cache: z.boolean().default(true).describe('Enable caching'), - - /** Maximum cache size */ - maxCacheSize: z.number().int().min(0).optional().describe('Max cache entries'), - }).optional().describe('DataLoader options'), -})); - -export type GraphQLDataLoaderConfig = z.infer; -export type GraphQLDataLoaderConfigInput = z.input; - -// ========================================== -// 7. GraphQL Directive Schema -// ========================================== - -/** - * GraphQL Directive Location - * - * Where a directive can be used in the schema. - */ -export const GraphQLDirectiveLocation = z.enum([ - // Executable Directive Locations - 'QUERY', - 'MUTATION', - 'SUBSCRIPTION', - 'FIELD', - 'FRAGMENT_DEFINITION', - 'FRAGMENT_SPREAD', - 'INLINE_FRAGMENT', - 'VARIABLE_DEFINITION', - - // Type System Directive Locations - 'SCHEMA', - 'SCALAR', - 'OBJECT', - 'FIELD_DEFINITION', - 'ARGUMENT_DEFINITION', - 'INTERFACE', - 'UNION', - 'ENUM', - 'ENUM_VALUE', - 'INPUT_OBJECT', - 'INPUT_FIELD_DEFINITION', -]); - -export type GraphQLDirectiveLocation = z.infer; - -/** - * GraphQL Directive Configuration - * - * Custom directives for schema metadata and behavior. - */ -export const GraphQLDirectiveConfigSchema = lazySchema(() => z.object({ - /** Directive name */ - name: z.string().regex(/^[a-z][a-zA-Z0-9]*$/).describe('Directive name (camelCase)'), - - /** Description */ - description: z.string().optional().describe('Directive description'), - - /** Where directive can be used */ - locations: z.array(GraphQLDirectiveLocation).describe('Directive locations'), - - /** Arguments */ - args: z.record(z.string(), z.object({ - type: z.string().describe('Argument type'), - description: z.string().optional().describe('Argument description'), - defaultValue: z.unknown().optional().describe('Default value'), - })).optional().describe('Directive arguments'), - - /** Is repeatable */ - repeatable: z.boolean().optional().default(false).describe('Can be applied multiple times'), - - /** Implementation */ - implementation: z.object({ - /** Directive behavior type */ - type: z.enum(['auth', 'validation', 'transform', 'cache', 'deprecation', 'custom']).describe('Directive type'), - - /** Handler function */ - handler: z.string().optional().describe('Handler function name or script'), - }).optional().describe('Directive implementation'), -})); - -export type GraphQLDirectiveConfig = z.infer; -export type GraphQLDirectiveConfigInput = z.input; - -// ========================================== -// 8. GraphQL Security - Query Depth Limiting -// ========================================== - -/** - * Query Depth Limiting Configuration - * - * Prevents deeply nested queries that could cause performance issues. - */ -export const GraphQLQueryDepthLimitSchema = lazySchema(() => z.object({ - /** Enable depth limiting */ - enabled: z.boolean().default(true).describe('Enable query depth limiting'), - - /** Maximum allowed depth */ - maxDepth: z.number().int().min(1).default(10).describe('Maximum query depth'), - - /** Fields to ignore in depth calculation */ - ignoreFields: z.array(z.string()).optional().describe('Fields excluded from depth calculation'), - - /** Callback on depth exceeded */ - onDepthExceeded: z.enum(['reject', 'log', 'warn']).default('reject').describe('Action when depth exceeded'), - - /** Custom error message */ - errorMessage: z.string().optional().describe('Custom error message for depth violations'), -})); - -export type GraphQLQueryDepthLimit = z.infer; -export type GraphQLQueryDepthLimitInput = z.input; - -// ========================================== -// 9. GraphQL Security - Query Complexity -// ========================================== - -/** - * Query Complexity Calculation Configuration - * - * Assigns complexity scores to fields and limits total query complexity. - * Prevents expensive queries from overloading the server. - */ -export const GraphQLQueryComplexitySchema = lazySchema(() => z.object({ - /** Enable complexity limiting */ - enabled: z.boolean().default(true).describe('Enable query complexity limiting'), - - /** Maximum allowed complexity score */ - maxComplexity: z.number().int().min(1).default(1000).describe('Maximum query complexity'), - - /** Default field complexity */ - defaultFieldComplexity: z.number().int().min(0).default(1).describe('Default complexity per field'), - - /** Field-specific complexity scores */ - fieldComplexity: z.record(z.string(), z.union([ - z.number().int().min(0), - z.object({ - /** Base complexity */ - base: z.number().int().min(0).describe('Base complexity'), - - /** Multiplier based on arguments */ - multiplier: z.string().optional().describe('Argument multiplier (e.g., "limit")'), - - /** Custom complexity calculation */ - calculator: z.string().optional().describe('Custom calculator function'), - }), - ])).optional().describe('Per-field complexity configuration'), - - /** List multiplier */ - listMultiplier: z.number().min(0).default(10).describe('Multiplier for list fields'), - - /** Callback on complexity exceeded */ - onComplexityExceeded: z.enum(['reject', 'log', 'warn']).default('reject').describe('Action when complexity exceeded'), - - /** Custom error message */ - errorMessage: z.string().optional().describe('Custom error message for complexity violations'), -})); - -export type GraphQLQueryComplexity = z.infer; -export type GraphQLQueryComplexityInput = z.input; - -// ========================================== -// 10. GraphQL Security - Rate Limiting -// ========================================== - -/** - * GraphQL Rate Limiting Configuration - * - * Rate limiting for GraphQL operations. - */ -export const GraphQLRateLimitSchema = lazySchema(() => z.object({ - /** Enable rate limiting */ - enabled: z.boolean().default(true).describe('Enable rate limiting'), - - /** Rate limit strategy */ - strategy: z.enum(['token_bucket', 'fixed_window', 'sliding_window', 'cost_based']).default('token_bucket').describe('Rate limiting strategy'), - - /** Global rate limits */ - global: z.object({ - /** Requests per time window */ - maxRequests: z.number().int().min(1).default(1000).describe('Maximum requests per window'), - - /** Time window in milliseconds */ - windowMs: z.number().int().min(1000).default(60000).describe('Time window in milliseconds'), - }).optional().describe('Global rate limits'), - - /** Per-user rate limits */ - perUser: z.object({ - /** Requests per time window */ - maxRequests: z.number().int().min(1).default(100).describe('Maximum requests per user per window'), - - /** Time window in milliseconds */ - windowMs: z.number().int().min(1000).default(60000).describe('Time window in milliseconds'), - }).optional().describe('Per-user rate limits'), - - /** Cost-based rate limiting */ - costBased: z.object({ - /** Enable cost-based limiting */ - enabled: z.boolean().default(false).describe('Enable cost-based rate limiting'), - - /** Maximum cost per time window */ - maxCost: z.number().int().min(1).default(10000).describe('Maximum cost per window'), - - /** Time window in milliseconds */ - windowMs: z.number().int().min(1000).default(60000).describe('Time window in milliseconds'), - - /** Use complexity as cost */ - useComplexityAsCost: z.boolean().default(true).describe('Use query complexity as cost'), - }).optional().describe('Cost-based rate limiting'), - - /** Operation-specific limits */ - operations: z.record(z.string(), z.object({ - maxRequests: z.number().int().min(1).describe('Max requests for this operation'), - windowMs: z.number().int().min(1000).describe('Time window'), - })).optional().describe('Per-operation rate limits'), - - /** Callback on limit exceeded */ - onLimitExceeded: z.enum(['reject', 'queue', 'log']).default('reject').describe('Action when rate limit exceeded'), - - /** Custom error message */ - errorMessage: z.string().optional().describe('Custom error message for rate limit violations'), - - /** Headers to include in response */ - includeHeaders: z.boolean().default(true).describe('Include rate limit headers in response'), -})); - -export type GraphQLRateLimit = z.infer; -export type GraphQLRateLimitInput = z.input; - -// ========================================== -// 11. GraphQL Security - Persisted Queries -// ========================================== - -/** - * Persisted Queries Configuration - * - * Only allow pre-registered queries to execute (allow list approach). - * Improves security and performance. - */ -export const GraphQLPersistedQuerySchema = lazySchema(() => z.object({ - /** Enable persisted queries */ - enabled: z.boolean().default(false).describe('Enable persisted queries'), - - /** Enforcement mode */ - mode: z.enum(['optional', 'required']).default('optional').describe('Persisted query mode (optional: allow both, required: only persisted)'), - - /** Query store configuration */ - store: z.object({ - /** Store type */ - type: z.enum(['memory', 'redis', 'database', 'file']).default('memory').describe('Query store type'), - - /** Store connection string */ - connection: z.string().optional().describe('Store connection string or path'), - - /** TTL for cached queries */ - ttl: z.number().int().min(0).optional().describe('TTL in seconds for stored queries'), - }).optional().describe('Query store configuration'), - - /** Automatic Persisted Queries (APQ) */ - apq: z.object({ - /** Enable APQ */ - enabled: z.boolean().default(true).describe('Enable Automatic Persisted Queries'), - - /** Hash algorithm */ - hashAlgorithm: z.enum(['sha256', 'sha1', 'md5']).default('sha256').describe('Hash algorithm for query IDs'), - - /** Cache control */ - cache: z.object({ - /** Cache TTL */ - ttl: z.number().int().min(0).default(3600).describe('Cache TTL in seconds'), - - /** Max cache size */ - maxSize: z.number().int().min(1).optional().describe('Maximum number of cached queries'), - }).optional().describe('APQ cache configuration'), - }).optional().describe('Automatic Persisted Queries configuration'), - - /** Query allow list */ - allowlist: z.object({ - /** Enable allow list mode */ - enabled: z.boolean().default(false).describe('Enable query allow list (reject queries not in list)'), - - /** Allowed query IDs */ - queries: z.array(z.object({ - id: z.string().describe('Query ID or hash'), - operation: z.string().optional().describe('Operation name'), - query: z.string().optional().describe('Query string'), - })).optional().describe('Allowed queries'), - - /** External allow list source */ - source: z.string().optional().describe('External allow list source (file path or URL)'), - }).optional().describe('Query allow list configuration'), - - /** Security */ - security: z.object({ - /** Maximum query size */ - maxQuerySize: z.number().int().min(1).optional().describe('Maximum query string size in bytes'), - - /** Reject introspection in production */ - rejectIntrospection: z.boolean().default(false).describe('Reject introspection queries'), - }).optional().describe('Security configuration'), -})); - -export type GraphQLPersistedQuery = z.infer; -export type GraphQLPersistedQueryInput = z.input; - -// ========================================== -// 12. GraphQL Federation -// ========================================== - -/** - * Federation Entity Key Definition - * - * Defines how entities are uniquely identified across subgraphs. - * Corresponds to the `@key` directive in Apollo Federation. - * - * @see https://www.apollographql.com/docs/federation/entities - */ -export const FederationEntityKeySchema = lazySchema(() => z.object({ - /** Fields composing the key (e.g., "id" or "sku packageId") */ - fields: z.string().describe('Selection set of fields composing the entity key'), - - /** Whether this key can be used for resolution across subgraphs */ - resolvable: z.boolean().optional().default(true).describe('Whether entities can be resolved from this subgraph'), -})); - -export type FederationEntityKey = z.infer; - -/** - * Federation External Field - * - * Marks a field as owned by another subgraph (`@external`). - */ -export const FederationExternalFieldSchema = lazySchema(() => z.object({ - /** Field name */ - field: z.string().describe('Field name marked as external'), - - /** The subgraph that owns this field */ - ownerSubgraph: z.string().optional().describe('Subgraph that owns this field'), -})); - -export type FederationExternalField = z.infer; - -/** - * Federation Requires Directive - * - * Specifies fields that must be fetched from other subgraphs - * before resolving a computed field. - * Corresponds to the `@requires` directive in Apollo Federation. - */ -export const FederationRequiresSchema = lazySchema(() => z.object({ - /** The field that has this requirement */ - field: z.string().describe('Field with the requirement'), - - /** Selection set of external fields required for resolution */ - fields: z.string().describe('Selection set of required fields (e.g., "price weight")'), -})); - -export type FederationRequires = z.infer; - -/** - * Federation Provides Directive - * - * Indicates that a field resolution provides additional fields - * of a returned entity type. - * Corresponds to the `@provides` directive in Apollo Federation. - */ -export const FederationProvidesSchema = lazySchema(() => z.object({ - /** The field that provides additional data */ - field: z.string().describe('Field that provides additional entity fields'), - - /** Selection set of fields provided during resolution */ - fields: z.string().describe('Selection set of provided fields (e.g., "name price")'), -})); - -export type FederationProvides = z.infer; - -/** - * Federation Entity Configuration - * - * Configures a type as a federated entity that can be referenced - * and extended across multiple subgraphs. - */ -export const FederationEntitySchema = lazySchema(() => z.object({ - /** Type/Object name */ - typeName: z.string().describe('GraphQL type name for this entity'), - - /** Entity keys (`@key` directive) */ - keys: z.array(FederationEntityKeySchema).min(1).describe('Entity key definitions'), - - /** External fields (`@external`) */ - externalFields: z.array(FederationExternalFieldSchema).optional().describe('Fields owned by other subgraphs'), - - /** Requires directives (`@requires`) */ - requires: z.array(FederationRequiresSchema).optional().describe('Required external fields for computed fields'), - - /** Provides directives (`@provides`) */ - provides: z.array(FederationProvidesSchema).optional().describe('Fields provided during resolution'), - - /** Whether this subgraph owns this entity */ - owner: z.boolean().optional().default(false).describe('Whether this subgraph is the owner of this entity'), -})); - -export type FederationEntity = z.infer; - -/** - * Subgraph Configuration - * - * Configuration for an individual subgraph in a federated architecture. - */ -export const SubgraphConfigSchema = lazySchema(() => z.object({ - /** Subgraph name */ - name: z.string().describe('Unique subgraph identifier'), - - /** Subgraph URL */ - url: z.string().describe('Subgraph endpoint URL'), - - /** Schema source */ - schemaSource: z.enum(['introspection', 'file', 'registry']).default('introspection').describe('How to obtain the subgraph schema'), - - /** Schema file path (when schemaSource is "file") */ - schemaPath: z.string().optional().describe('Path to schema file (SDL format)'), - - /** Federated entities defined by this subgraph */ - entities: z.array(FederationEntitySchema).optional().describe('Entity definitions for this subgraph'), - - /** Health check endpoint */ - healthCheck: z.object({ - enabled: z.boolean().default(true).describe('Enable health checking'), - path: z.string().default('/health').describe('Health check endpoint path'), - intervalMs: z.number().int().min(1000).default(30000).describe('Health check interval in milliseconds'), - }).optional().describe('Subgraph health check configuration'), - - /** Request headers to forward */ - forwardHeaders: z.array(z.string()).optional().describe('HTTP headers to forward to this subgraph'), -})); - -export type SubgraphConfig = z.infer; -export type SubgraphConfigInput = z.input; - -/** - * Federation Gateway Configuration - * - * Root-level gateway configuration for Apollo Federation or similar. - * Manages query planning, routing, and composition across subgraphs. - */ -export const FederationGatewaySchema = lazySchema(() => z.object({ - /** Enable federation mode */ - enabled: z.boolean().default(false).describe('Enable GraphQL Federation gateway mode'), - - /** Federation specification version */ - version: z.enum(['v1', 'v2']).default('v2').describe('Federation specification version'), - - /** Registered subgraphs */ - subgraphs: z.array(SubgraphConfigSchema).describe('Subgraph configurations'), - - /** Service discovery */ - serviceDiscovery: z.object({ - /** Discovery mode */ - type: z.enum(['static', 'dns', 'consul', 'kubernetes']).default('static').describe('Service discovery method'), - - /** Poll interval for dynamic discovery */ - pollIntervalMs: z.number().int().min(1000).optional().describe('Discovery poll interval in milliseconds'), - - /** Kubernetes namespace (when type is "kubernetes") */ - namespace: z.string().optional().describe('Kubernetes namespace for subgraph discovery'), - }).optional().describe('Service discovery configuration'), - - /** Query planning */ - queryPlanning: z.object({ - /** Execution strategy */ - strategy: z.enum(['parallel', 'sequential', 'adaptive']).default('parallel').describe('Query execution strategy across subgraphs'), - - /** Maximum query depth across subgraphs */ - maxDepth: z.number().int().min(1).optional().describe('Max query depth in federated execution'), - - /** Dry-run mode for debugging query plans */ - dryRun: z.boolean().optional().default(false).describe('Log query plans without executing'), - }).optional().describe('Query planning configuration'), - - /** Schema composition settings */ - composition: z.object({ - /** How schema conflicts are resolved */ - conflictResolution: z.enum(['error', 'first_wins', 'last_wins']).default('error').describe('Strategy for resolving schema conflicts'), - - /** Whether to validate composed schema */ - validate: z.boolean().default(true).describe('Validate composed supergraph schema'), - }).optional().describe('Schema composition configuration'), - - /** Gateway-level error handling */ - errorHandling: z.object({ - /** Whether to include subgraph names in errors */ - includeSubgraphName: z.boolean().default(false).describe('Include subgraph name in error responses'), - - /** Partial error behavior */ - partialErrors: z.enum(['propagate', 'nullify', 'reject']).default('propagate').describe('Behavior when a subgraph returns partial errors'), - }).optional().describe('Error handling configuration'), -})); - -export type FederationGateway = z.infer; -export type FederationGatewayInput = z.input; - -// ========================================== -// 13. Complete GraphQL Configuration -// ========================================== - -/** - * Complete GraphQL Configuration - * - * Root configuration for GraphQL API generation and security. - */ -export const GraphQLConfigSchema = lazySchema(() => z.object({ - /** Enable GraphQL API */ - enabled: z.boolean().default(true).describe('Enable GraphQL API'), - - /** GraphQL endpoint path */ - path: z.string().default('/graphql').describe('GraphQL endpoint path'), - - /** GraphQL Playground */ - playground: z.object({ - enabled: z.boolean().default(true).describe('Enable GraphQL Playground'), - path: z.string().default('/playground').describe('Playground path'), - }).optional().describe('GraphQL Playground configuration'), - - /** Schema generation */ - schema: z.object({ - /** Auto-generate types from Objects */ - autoGenerateTypes: z.boolean().default(true).describe('Auto-generate types from Objects'), - - /** Type configurations */ - types: z.array(GraphQLTypeConfigSchema).optional().describe('Type configurations'), - - /** Query configurations */ - queries: z.array(GraphQLQueryConfigSchema).optional().describe('Query configurations'), - - /** Mutation configurations */ - mutations: z.array(GraphQLMutationConfigSchema).optional().describe('Mutation configurations'), - - /** Subscription configurations */ - subscriptions: z.array(GraphQLSubscriptionConfigSchema).optional().describe('Subscription configurations'), - - /** Custom resolvers */ - resolvers: z.array(GraphQLResolverConfigSchema).optional().describe('Custom resolver configurations'), - - /** Custom directives */ - directives: z.array(GraphQLDirectiveConfigSchema).optional().describe('Custom directive configurations'), - }).optional().describe('Schema generation configuration'), - - /** DataLoader configurations */ - dataLoaders: z.array(GraphQLDataLoaderConfigSchema).optional().describe('DataLoader configurations'), - - /** Security configuration */ - security: z.object({ - /** Query depth limiting */ - depthLimit: GraphQLQueryDepthLimitSchema.optional().describe('Query depth limiting'), - - /** Query complexity */ - complexity: GraphQLQueryComplexitySchema.optional().describe('Query complexity calculation'), - - /** Rate limiting */ - rateLimit: GraphQLRateLimitSchema.optional().describe('Rate limiting'), - - /** Persisted queries */ - persistedQueries: GraphQLPersistedQuerySchema.optional().describe('Persisted queries'), - }).optional().describe('Security configuration'), - - /** Federation configuration */ - federation: FederationGatewaySchema.optional().describe('GraphQL Federation gateway configuration'), -})); - -export const GraphQLConfig = Object.assign(GraphQLConfigSchema, { - create: >(config: T) => config, -}); - -export type GraphQLConfig = z.infer; -export type GraphQLConfigInput = z.input; - -// ========================================== -// Helper Functions -// ========================================== - -/** - * Helper to map ObjectQL field type to GraphQL scalar type - */ -export const mapFieldTypeToGraphQL = (fieldType: z.infer): string => { - const mapping: Record = { - // Core Text - 'text': 'String', - 'textarea': 'String', - 'email': 'Email', - 'url': 'URL', - 'phone': 'PhoneNumber', - 'password': 'String', - - // Rich Content - 'markdown': 'String', - 'html': 'String', - 'richtext': 'String', - - // Numbers - 'number': 'Float', - 'currency': 'Currency', - 'percent': 'Float', - - // Date & Time - 'date': 'Date', - 'datetime': 'DateTime', - 'time': 'Time', - - // Logic - 'boolean': 'Boolean', - 'toggle': 'Boolean', - - // Selection - 'select': 'String', - 'multiselect': '[String]', - 'radio': 'String', - 'checkboxes': '[String]', - - // Relational - 'lookup': 'ID', - 'master_detail': 'ID', - 'user': 'ID', // lookup specialized to sys_user — id-valued, same as lookup - 'tree': 'ID', - - // Media - 'image': 'URL', - 'file': 'URL', - 'avatar': 'URL', - 'video': 'URL', - 'audio': 'URL', - - // Calculated - 'formula': 'String', - 'summary': 'Float', - 'autonumber': 'String', - - // Enhanced Types - 'location': 'JSONObject', - 'address': 'JSONObject', - 'code': 'String', - 'json': 'JSON', - 'color': 'String', - 'rating': 'Float', - 'slider': 'Float', - 'signature': 'String', - 'qrcode': 'String', - 'progress': 'Float', - 'tags': '[String]', - - // AI/ML - 'vector': '[Float]', - }; - - return mapping[fieldType] || 'String'; -}; diff --git a/packages/spec/src/api/index.ts b/packages/spec/src/api/index.ts index bbe6068f73..8353665473 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -22,7 +22,6 @@ export * from './realtime.zod'; export * from './websocket.zod'; export * from './router.zod'; export * from './odata.zod'; -export * from './graphql.zod'; export * from './batch.zod'; export * from './http-cache.zod'; export * from './errors.zod'; diff --git a/packages/spec/src/api/query-adapter.test.ts b/packages/spec/src/api/query-adapter.test.ts index 4a6f695966..32221a0cf5 100644 --- a/packages/spec/src/api/query-adapter.test.ts +++ b/packages/spec/src/api/query-adapter.test.ts @@ -3,14 +3,13 @@ import { QueryAdapterTargetSchema, OperatorMappingSchema, RestQueryAdapterSchema, - GraphQLQueryAdapterSchema, ODataQueryAdapterSchema, QueryAdapterConfigSchema, } from './query-adapter.zod'; describe('QueryAdapterTargetSchema', () => { it('should accept all target protocols', () => { - const targets = ['rest', 'graphql', 'odata'] as const; + const targets = ['rest', 'odata'] as const; targets.forEach(target => { expect(() => QueryAdapterTargetSchema.parse(target)).not.toThrow(); @@ -35,13 +34,11 @@ describe('OperatorMappingSchema', () => { const mapping = OperatorMappingSchema.parse({ operator: 'contains', rest: 'filter[{field}][contains]', - graphql: '{field}: { contains: $value }', odata: "contains({field}, '{value}')", }); expect(mapping.operator).toBe('contains'); expect(mapping.rest).toBeDefined(); - expect(mapping.graphql).toBeDefined(); expect(mapping.odata).toBeDefined(); }); }); @@ -101,48 +98,6 @@ describe('RestQueryAdapterSchema', () => { }); }); -describe('GraphQLQueryAdapterSchema', () => { - it('should apply default values', () => { - const adapter = GraphQLQueryAdapterSchema.parse({}); - - expect(adapter.filterArgName).toBe('where'); - expect(adapter.filterStyle).toBe('nested'); - }); - - it('should accept all filter styles', () => { - const styles = ['nested', 'flat', 'array'] as const; - - styles.forEach(style => { - const adapter = GraphQLQueryAdapterSchema.parse({ filterStyle: style }); - expect(adapter.filterStyle).toBe(style); - }); - }); - - it('should accept custom pagination arguments', () => { - const adapter = GraphQLQueryAdapterSchema.parse({ - pagination: { - limitArg: 'take', - offsetArg: 'skip', - firstArg: 'first', - afterArg: 'after', - }, - }); - - expect(adapter.pagination?.limitArg).toBe('take'); - }); - - it('should accept custom sort configuration', () => { - const adapter = GraphQLQueryAdapterSchema.parse({ - sorting: { - argName: 'sortBy', - format: 'array', - }, - }); - - expect(adapter.sorting?.argName).toBe('sortBy'); - expect(adapter.sorting?.format).toBe('array'); - }); -}); describe('ODataQueryAdapterSchema', () => { it('should apply default values', () => { @@ -196,7 +151,7 @@ describe('QueryAdapterConfigSchema', () => { it('should accept complete configuration', () => { const config = QueryAdapterConfigSchema.parse({ operatorMappings: [ - { operator: 'eq', rest: 'filter[{field}][eq]', graphql: '{field}: { eq: $value }', odata: '{field} eq {value}' }, + { operator: 'eq', rest: 'filter[{field}][eq]', odata: '{field} eq {value}' }, { operator: 'contains', rest: 'filter[{field}][contains]', odata: "contains({field}, '{value}')" }, ], rest: { @@ -204,11 +159,6 @@ describe('QueryAdapterConfigSchema', () => { pagination: { limitParam: 'limit', offsetParam: 'offset' }, sorting: { param: 'sort', format: 'comma' }, }, - graphql: { - filterArgName: 'where', - filterStyle: 'nested', - pagination: { limitArg: 'first', afterArg: 'after' }, - }, odata: { version: 'v4', usePrefix: true, @@ -218,7 +168,6 @@ describe('QueryAdapterConfigSchema', () => { expect(config.operatorMappings).toHaveLength(2); expect(config.rest?.filterStyle).toBe('bracket'); - expect(config.graphql?.filterArgName).toBe('where'); expect(config.odata?.version).toBe('v4'); }); diff --git a/packages/spec/src/api/query-adapter.zod.ts b/packages/spec/src/api/query-adapter.zod.ts index 380cc05044..58f1ec8439 100644 --- a/packages/spec/src/api/query-adapter.zod.ts +++ b/packages/spec/src/api/query-adapter.zod.ts @@ -7,14 +7,13 @@ import { z } from 'zod'; * * Defines mapping rules between the internal unified query DSL * (defined in `data/query.zod.ts`) and external API protocol formats: - * REST, GraphQL, and OData. + * REST and OData. * * This enables ObjectStack to expose a single internal query representation * while supporting multiple API standards for external consumers. * * @see data/query.zod.ts - Unified internal query DSL * @see api/rest-server.zod.ts - REST API configuration - * @see api/graphql.zod.ts - GraphQL API configuration * @see api/odata.zod.ts - OData API configuration */ @@ -28,7 +27,6 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; export const QueryAdapterTargetSchema = lazySchema(() => z.enum([ 'rest', // REST API (?filter[field][op]=value) - 'graphql', // GraphQL (where: \{ field: \{ op: value \}\}) 'odata', // OData ($filter=field op value) ])); @@ -46,8 +44,6 @@ export const OperatorMappingSchema = lazySchema(() => z.object({ /** REST query parameter format (e.g., 'filter[{field}][{op}]') */ rest: z.string().optional().describe('REST query parameter template'), - /** GraphQL where clause format (e.g., '{field}: { {op}: $value }') */ - graphql: z.string().optional().describe('GraphQL where clause template'), /** OData $filter expression format (e.g., '{field} {op} {value}') */ odata: z.string().optional().describe('OData $filter expression template'), @@ -112,51 +108,6 @@ export const RestQueryAdapterSchema = lazySchema(() => z.object({ export type RestQueryAdapter = z.infer; export type RestQueryAdapterInput = z.input; -// ========================================== -// 3. GraphQL Adapter Configuration -// ========================================== - -/** - * GraphQL Query Adapter Configuration - * - * Defines how unified query DSL maps to GraphQL arguments. - * - * @example - * Unified: { filters: [['status', '=', 'active']], top: 10, sort: [{ field: 'name', order: 'asc' }] } - * GraphQL: query { items(where: { status: { eq: "active" } }, limit: 10, orderBy: { name: ASC }) { ... } } - */ -export const GraphQLQueryAdapterSchema = lazySchema(() => z.object({ - /** Filter argument name in GraphQL queries */ - filterArgName: z.string().default('where').describe('GraphQL filter argument name'), - - /** Filter nesting style */ - filterStyle: z.enum([ - 'nested', // where: { field: { op: value } } (Prisma style) - 'flat', // where: { field_op: value } (Hasura style) - 'array', // where: [{ field, op, value }] (Array of conditions) - ]).default('nested').describe('GraphQL filter nesting style'), - - /** Pagination argument names */ - pagination: z.object({ - limitArg: z.string().default('limit').describe('Page size argument name'), - offsetArg: z.string().default('offset').describe('Offset argument name'), - firstArg: z.string().default('first').describe('Relay "first" argument name'), - afterArg: z.string().default('after').describe('Relay "after" cursor argument name'), - }).optional().describe('Pagination argument name mappings'), - - /** Sort argument configuration */ - sorting: z.object({ - argName: z.string().default('orderBy').describe('Sort argument name'), - format: z.enum([ - 'enum', // orderBy: { field: ASC } - 'array', // orderBy: [{ field: "name", direction: "ASC" }] - ]).default('enum').describe('Sort argument format'), - }).optional().describe('Sort argument mapping'), -})); - -export type GraphQLQueryAdapter = z.infer; -export type GraphQLQueryAdapterInput = z.input; - // ========================================== // 4. OData Adapter Configuration // ========================================== @@ -217,8 +168,6 @@ export const QueryAdapterConfigSchema = lazySchema(() => z.object({ /** REST adapter configuration */ rest: RestQueryAdapterSchema.optional().describe('REST query adapter configuration'), - /** GraphQL adapter configuration */ - graphql: GraphQLQueryAdapterSchema.optional().describe('GraphQL query adapter configuration'), /** OData adapter configuration */ odata: ODataQueryAdapterSchema.optional().describe('OData query adapter configuration'), diff --git a/packages/spec/src/api/registry.test.ts b/packages/spec/src/api/registry.test.ts index 77b68fa723..2c8266159b 100644 --- a/packages/spec/src/api/registry.test.ts +++ b/packages/spec/src/api/registry.test.ts @@ -21,8 +21,7 @@ describe('API Registry Protocol', () => { describe('ApiProtocolType', () => { it('should accept valid API protocol types', () => { expect(ApiProtocolType.parse('rest')).toBe('rest'); - expect(ApiProtocolType.parse('graphql')).toBe('graphql'); - expect(ApiProtocolType.parse('odata')).toBe('odata'); + expect(ApiProtocolType.parse('odata')).toBe('odata'); expect(ApiProtocolType.parse('websocket')).toBe('websocket'); expect(ApiProtocolType.parse('file')).toBe('file'); expect(ApiProtocolType.parse('auth')).toBe('auth'); @@ -417,7 +416,7 @@ describe('API Registry Protocol', () => { { id: 'graphql_api', name: 'GraphQL API', - type: 'graphql', + type: 'odata', version: 'v1', basePath: '/graphql', endpoints: [ @@ -462,7 +461,7 @@ describe('API Registry Protocol', () => { { id: 'graphql_api', name: 'GraphQL API', - type: 'graphql' as const, + type: 'odata' as const, version: 'v1', basePath: '/graphql', endpoints: [], diff --git a/packages/spec/src/api/registry.zod.ts b/packages/spec/src/api/registry.zod.ts index b6bad4316c..e82f810200 100644 --- a/packages/spec/src/api/registry.zod.ts +++ b/packages/spec/src/api/registry.zod.ts @@ -8,7 +8,7 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; * Unified API Registry Protocol * * Provides a centralized registry for managing all API endpoints across different - * API types (REST, GraphQL, OData, WebSocket, Auth, File, Plugin-registered). + * API types (REST, OData, WebSocket, Auth, File, Plugin-registered). * * This enables: * - Unified API discovery and documentation (similar to Swagger/OpenAPI) @@ -51,7 +51,6 @@ import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; import { lazySchema } from '../shared/lazy-schema'; export const ApiProtocolType = z.enum([ 'rest', // RESTful API (CRUD operations) - 'graphql', // GraphQL API (flexible queries) 'odata', // OData v4 API (enterprise integration) 'websocket', // WebSocket API (real-time) 'file', // File/Storage API (uploads/downloads) @@ -95,7 +94,6 @@ export type HttpStatusCode = z.infer; * Schema resolution (expanding references into actual JSON Schema) is performed by: * - **API Gateway**: For runtime request/response validation * - **OpenAPI Generator**: For Swagger/OpenAPI documentation - * - **GraphQL Schema Builder**: For GraphQL type generation * - **Documentation Tools**: For developer documentation * * This separation allows the Registry to remain lightweight and focused on @@ -621,7 +619,7 @@ export const ApiRegistryEntrySchema = lazySchema(() => z.object({ /** Endpoints in this API */ endpoints: z.array(ApiEndpointRegistrationSchema).describe('Registered endpoints'), - /** OpenAPI/GraphQL/OData specific configuration */ + /** OpenAPI/OData specific configuration */ config: z.record(z.string(), z.unknown()).optional().describe('Protocol-specific configuration'), /** API metadata */ diff --git a/packages/spec/src/api/rest-server.zod.ts b/packages/spec/src/api/rest-server.zod.ts index 480936e5e0..89b85594df 100644 --- a/packages/spec/src/api/rest-server.zod.ts +++ b/packages/spec/src/api/rest-server.zod.ts @@ -122,8 +122,7 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ * **Applies to every HTTP surface, not just REST `/data`** (#2567). The same * `requireAuth` value is threaded to every entry point that reaches object * data, so the anonymous-deny posture is UNIFORM by surface: REST `/data` - * CRUD + batch, the metadata endpoints (`/meta`), the dispatcher's GraphQL - * endpoint (`/graphql`), and the raw-hono standard `/data` routes. All share + * CRUD + batch, the metadata endpoints (`/meta`), and the raw-hono standard `/data` routes. All share * one decision (`shouldDenyAnonymous` in `@objectstack/core`). Before #2567 a * caller denied on `/data` could read the same rows through a sibling door. * @@ -139,7 +138,7 @@ export const RestApiConfigSchema = lazySchema(() => z.object({ * plugins) log a boot warning when they do. */ requireAuth: z.boolean().default(true) - .describe('Reject anonymous requests on ALL HTTP surfaces that reach object data (REST /data, /meta, GraphQL, raw-hono /data) with HTTP 401 (secure-by-default; set false to serve them publicly)'), + .describe('Reject anonymous requests on ALL HTTP surfaces that reach object data (REST /data, /meta, raw-hono /data) with HTTP 401 (secure-by-default; set false to serve them publicly)'), /** * API documentation configuration diff --git a/packages/spec/src/api/router.test.ts b/packages/spec/src/api/router.test.ts index e0ddefd47a..0c0ba5ffe4 100644 --- a/packages/spec/src/api/router.test.ts +++ b/packages/spec/src/api/router.test.ts @@ -328,7 +328,6 @@ describe('RouterConfigSchema', () => { expect(config.mounts.auth).toBe('/auth'); expect(config.mounts.automation).toBe('/automation'); expect(config.mounts.storage).toBe('/storage'); - expect(config.mounts.graphql).toBe('/graphql'); }); it('should accept custom mounts', () => { @@ -339,7 +338,6 @@ describe('RouterConfigSchema', () => { auth: '/api/auth', automation: '/api/automation', storage: '/api/storage', - graphql: '/api/graphql', }, }); @@ -432,7 +430,6 @@ describe('RouterConfigSchema', () => { auth: '/auth', automation: '/flows', storage: '/files', - graphql: '/gql', }, cors: { enabled: true, @@ -452,7 +449,6 @@ describe('RouterConfigSchema', () => { const result = RouterConfigSchema.parse(config); expect(result.basePath).toBe('/api/v1'); - expect(result.mounts.graphql).toBe('/gql'); expect(result.cors?.enabled).toBe(true); expect(result.staticMounts).toHaveLength(1); }); diff --git a/packages/spec/src/api/router.zod.ts b/packages/spec/src/api/router.zod.ts index 7d4b348608..ffadc9cb56 100644 --- a/packages/spec/src/api/router.zod.ts +++ b/packages/spec/src/api/router.zod.ts @@ -89,7 +89,6 @@ export const RouterConfigSchema = lazySchema(() => z.object({ automation: z.string().default('/automation').describe('Automation Protocol'), storage: z.string().default('/storage').describe('Storage Protocol'), analytics: z.string().default('/analytics').describe('Analytics Protocol'), - graphql: z.string().default('/graphql').describe('GraphQL Endpoint'), ui: z.string().default('/ui').describe('UI Metadata Protocol (Views, Layouts)'), workflow: z.string().default('/workflow').describe('Workflow Engine Protocol'), realtime: z.string().default('/realtime').describe('Realtime/WebSocket Protocol'), @@ -104,7 +103,6 @@ export const RouterConfigSchema = lazySchema(() => z.object({ automation: '/automation', storage: '/storage', analytics: '/analytics', - graphql: '/graphql', ui: '/ui', workflow: '/workflow', realtime: '/realtime', diff --git a/packages/spec/src/contracts/graphql-service.test.ts b/packages/spec/src/contracts/graphql-service.test.ts deleted file mode 100644 index e008160fbd..0000000000 --- a/packages/spec/src/contracts/graphql-service.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import type { IGraphQLService, GraphQLRequest, GraphQLResponse } from './graphql-service'; - -describe('GraphQL Service Contract', () => { - it('should allow a minimal IGraphQLService implementation with required methods', () => { - const service: IGraphQLService = { - execute: async (_request, _context?) => ({ data: null }), - }; - - expect(typeof service.execute).toBe('function'); - }); - - it('should allow a full implementation with optional methods', () => { - const service: IGraphQLService = { - execute: async () => ({ data: null }), - handleRequest: async (_request) => new Response('OK'), - getSchema: () => 'type Query { hello: String }', - }; - - expect(service.handleRequest).toBeDefined(); - expect(service.getSchema).toBeDefined(); - }); - - it('should execute a GraphQL query', async () => { - const service: IGraphQLService = { - execute: async (request): Promise => { - if (request.query.includes('hello')) { - return { data: { hello: 'world' } }; - } - return { data: null, errors: [{ message: 'Unknown query' }] }; - }, - }; - - const result = await service.execute({ query: '{ hello }' }); - expect(result.data).toEqual({ hello: 'world' }); - expect(result.errors).toBeUndefined(); - }); - - it('should return errors for invalid queries', async () => { - const service: IGraphQLService = { - execute: async (): Promise => ({ - data: null, - errors: [{ - message: 'Cannot query field "invalid"', - locations: [{ line: 1, column: 3 }], - }], - }), - }; - - const result = await service.execute({ query: '{ invalid }' }); - expect(result.errors).toHaveLength(1); - expect(result.errors![0].message).toContain('invalid'); - }); - - it('should support variables in queries', async () => { - const service: IGraphQLService = { - execute: async (request: GraphQLRequest): Promise => { - const id = request.variables?.id; - return { data: { user: { id, name: 'Alice' } } }; - }, - }; - - const result = await service.execute({ - query: 'query GetUser($id: ID!) { user(id: $id) { id name } }', - operationName: 'GetUser', - variables: { id: 'u1' }, - }); - - expect(result.data?.user).toEqual({ id: 'u1', name: 'Alice' }); - }); - - it('should return SDL schema', () => { - const service: IGraphQLService = { - execute: async () => ({ data: null }), - getSchema: () => 'type Query { users: [User] }\ntype User { id: ID! name: String }', - }; - - const schema = service.getSchema!(); - expect(schema).toContain('type Query'); - expect(schema).toContain('User'); - }); -}); diff --git a/packages/spec/src/contracts/graphql-service.ts b/packages/spec/src/contracts/graphql-service.ts deleted file mode 100644 index 1cb8e1dcd5..0000000000 --- a/packages/spec/src/contracts/graphql-service.ts +++ /dev/null @@ -1,74 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * IGraphQLService - GraphQL Service Contract - * - * Defines the interface for GraphQL schema and query execution in ObjectStack. - * Concrete implementations (Apollo, Yoga, Mercurius, etc.) - * should implement this interface. - * - * Follows Dependency Inversion Principle - plugins depend on this interface, - * not on concrete GraphQL server implementations. - * - * Aligned with CoreServiceName 'graphql' in core-services.zod.ts. - */ - -/** - * A GraphQL execution request - */ -export interface GraphQLRequest { - /** GraphQL query or mutation string */ - query: string; - /** Operation name (when document contains multiple operations) */ - operationName?: string; - /** Variables for the operation */ - variables?: Record; -} - -/** - * A GraphQL execution response - */ -export interface GraphQLResponse { - /** Query result data */ - data?: Record | null; - /** Errors encountered during execution */ - errors?: Array<{ - message: string; - locations?: Array<{ line: number; column: number }>; - path?: Array; - extensions?: Record; - }>; -} - -export interface IGraphQLService { - /** - * Execute a GraphQL query or mutation - * - * ⚠️ Identity admission (ADR-0096 D1, #2992): `context` carries the - * caller's resolved `ExecutionContext`. An implementation that resolves - * objects through the data engine (ObjectQL) MUST forward it on every - * engine call as `options.context` — the security middleware falls OPEN - * on a missing principal, so executing resolvers context-less silently - * grants full authority (no RLS/FLS/CRUD/tenant scoping). The dispatcher's - * `/graphql` entry point threads the caller identity for exactly this - * purpose; dropping it here is a defect, never an authorization. - * - * @param request - The GraphQL request - * @param context - The caller's execution context (auth user / principal) - * @returns GraphQL response with data and/or errors - */ - execute(request: GraphQLRequest, context?: Record): Promise; - - /** - * Handle an incoming HTTP request for GraphQL - * @param request - Standard Request object - * @returns Standard Response object - */ - handleRequest?(request: Request): Promise; - - /** - * Get the current GraphQL schema as SDL string - * @returns SDL schema string - */ - getSchema?(): string; -} diff --git a/packages/spec/src/contracts/index.ts b/packages/spec/src/contracts/index.ts index a1c5867f71..299ef408ef 100644 --- a/packages/spec/src/contracts/index.ts +++ b/packages/spec/src/contracts/index.ts @@ -24,7 +24,6 @@ export * from './storage-service.js'; export * from './metadata-service.js'; export * from './auth-service.js'; export * from './automation-service.js'; -export * from './graphql-service.js'; export * from './analytics-service.js'; export * from './realtime-service.js'; export * from './job-service.js'; diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index dde723ba26..2f4f985d74 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -512,7 +512,7 @@ export const ConnectorTypeSchema = lazySchema(() => z.enum([ 'database', // Database connector 'file_storage', // File storage connector 'message_queue', // Message queue connector - 'api', // Generic REST/GraphQL API + 'api', // Generic REST API 'custom', // Custom connector ]).describe('Connector type')); diff --git a/packages/spec/src/stack.test.ts b/packages/spec/src/stack.test.ts index 0981434d40..a9bdd0ef5e 100644 --- a/packages/spec/src/stack.test.ts +++ b/packages/spec/src/stack.test.ts @@ -159,7 +159,6 @@ describe('KernelCapabilitiesSchema', () => { version: '1.0.0', environment: 'production', restApi: true, - graphqlApi: true, odataApi: true, websockets: true, serverSentEvents: true, @@ -236,7 +235,6 @@ describe('KernelCapabilitiesSchema', () => { expect(result.restApi).toBe(true); expect(result.authentication).toBe(true); expect(result.fileStorage).toBe(true); - expect(result.graphqlApi).toBe(false); expect(result.multiTenant).toBe(false); }); @@ -309,8 +307,7 @@ describe('ObjectStackCapabilitiesSchema', () => { version: '1.0.0', environment: 'production', restApi: true, - graphqlApi: true, - odataApi: false, + odataApi: false, websockets: true, serverSentEvents: false, eventBus: true, diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index ecfa58df57..0842bd7aa7 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -1427,7 +1427,6 @@ export const KernelCapabilitiesSchema = lazySchema(() => z.object({ /** API Surface */ restApi: z.boolean().default(true).describe('REST API available'), - graphqlApi: z.boolean().default(false).describe('GraphQL API available'), odataApi: z.boolean().default(false).describe('OData API available'), /** Real-time & Events */ @@ -1477,7 +1476,6 @@ export const KernelCapabilitiesSchema = lazySchema(() => z.object({ /** Available APIs */ apis: z.array(ApiEndpointSchema).optional().describe('Available System & Business APIs'), network: z.object({ - graphql: z.boolean().default(false), search: z.boolean().default(false), websockets: z.boolean().default(false), files: z.boolean().default(true), @@ -1486,7 +1484,7 @@ export const KernelCapabilitiesSchema = lazySchema(() => z.object({ workflow: z.boolean().default(false).describe('Is the Workflow engine enabled?'), notifications: z.boolean().default(false).describe('Is the Notification service enabled?'), i18n: z.boolean().default(false).describe('Is the i18n service enabled?'), - }).optional().describe('Network Capabilities (GraphQL, WS, etc.)'), + }).optional().describe('Network Capabilities (WS, etc.)'), /** Introspection */ systemObjects: z.array(z.string()).optional().describe('List of globally available System Objects'), diff --git a/packages/spec/src/system/core-services.test.ts b/packages/spec/src/system/core-services.test.ts index c8a8c9c8f2..a25ae1a6af 100644 --- a/packages/spec/src/system/core-services.test.ts +++ b/packages/spec/src/system/core-services.test.ts @@ -13,7 +13,7 @@ describe('CoreServiceName', () => { const services = [ 'metadata', 'data', 'auth', 'file-storage', 'search', 'cache', 'queue', - 'automation', 'graphql', 'analytics', 'realtime', + 'automation', 'analytics', 'realtime', 'job', 'notification', 'ai', 'i18n', 'ui', 'workflow', ]; @@ -62,7 +62,6 @@ describe('ServiceRequirementDef', () => { expect(ServiceRequirementDef['file-storage']).toBe('optional'); expect(ServiceRequirementDef.search).toBe('optional'); expect(ServiceRequirementDef.automation).toBe('optional'); - expect(ServiceRequirementDef.graphql).toBe('optional'); expect(ServiceRequirementDef.analytics).toBe('optional'); expect(ServiceRequirementDef.realtime).toBe('optional'); expect(ServiceRequirementDef.notification).toBe('optional'); diff --git a/packages/spec/src/system/core-services.zod.ts b/packages/spec/src/system/core-services.zod.ts index 5360d12925..6ddf8e99d9 100644 --- a/packages/spec/src/system/core-services.zod.ts +++ b/packages/spec/src/system/core-services.zod.ts @@ -31,7 +31,6 @@ export const CoreServiceName = z.enum([ // Advanced Capabilities 'automation', // Flow & Script Engine - 'graphql', // GraphQL API Engine 'analytics', // BI & Semantic Layer 'realtime', // WebSocket & PubSub 'job', // Background Job Manager @@ -75,7 +74,6 @@ export const ServiceRequirementDef = { 'file-storage': 'optional', search: 'optional', automation: 'optional', - graphql: 'optional', analytics: 'optional', realtime: 'optional', notification: 'optional', From 3c8b095692b1e6932bfdd4ad2317c362b7a04c31 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Mon, 27 Jul 2026 13:33:20 +0800 Subject: [PATCH 2/2] fix: launch-window changeset level + regenerate skill references (GraphQL removal follow-through) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Changeset major → minor per the launch-window convention (the gate's own instruction; same treatment as #3486/#2377 — breaking rides minor without burning the whole fixed group's major). - Drop the api/graphql.zod.ts entry from the skill-references mapping and regenerate (check:skill-refs gate). Co-Authored-By: Claude Fable 5 --- .changeset/remove-graphql-surface.md | 4 ++-- packages/spec/scripts/build-skill-references.ts | 1 - skills/objectstack-api/references/_index.md | 3 --- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/.changeset/remove-graphql-surface.md b/.changeset/remove-graphql-surface.md index c37578ecbb..e687318784 100644 --- a/.changeset/remove-graphql-surface.md +++ b/.changeset/remove-graphql-surface.md @@ -1,5 +1,5 @@ --- -"@objectstack/spec": major +"@objectstack/spec": minor --- feat(spec)!: remove the never-implemented GraphQL surface from the product plan (#2462 follow-on) @@ -15,7 +15,7 @@ maintained: `graphql` removed from `CoreServiceName`, `ApiProtocolType`, the query-adapter dialects, `graphql-playground` from testing-UI types; the `graphqlApi`/network capability booleans, discovery/router route fields - dropped. BREAKING for consumers referencing those exports/enum members. + dropped. Breaking for consumers referencing those exports/enum members (shipped as minor per the launch-window convention, cf. #3486/#2377). - **runtime**: `handleGraphQL`, the if-chain branch, the dispatcher-plugin and hono-adapter mounts, discovery advertisement, and the now-dead `resolveRequestExecutionContext` helper removed. diff --git a/packages/spec/scripts/build-skill-references.ts b/packages/spec/scripts/build-skill-references.ts index 1106873585..bb0c715cda 100644 --- a/packages/spec/scripts/build-skill-references.ts +++ b/packages/spec/scripts/build-skill-references.ts @@ -72,7 +72,6 @@ const SKILL_MAP: Record = { 'api/auth.zod.ts', 'api/realtime.zod.ts', 'api/rest-server.zod.ts', - 'api/graphql.zod.ts', 'api/websocket.zod.ts', 'api/errors.zod.ts', 'api/batch.zod.ts', diff --git a/skills/objectstack-api/references/_index.md b/skills/objectstack-api/references/_index.md index 086344b223..caee18046c 100644 --- a/skills/objectstack-api/references/_index.md +++ b/skills/objectstack-api/references/_index.md @@ -13,7 +13,6 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/api/batch.zod.ts` — Batch Operations API - `node_modules/@objectstack/spec/src/api/endpoint.zod.ts` — API Mapping Schema - `node_modules/@objectstack/spec/src/api/errors.zod.ts` — Standardized Error Codes Protocol -- `node_modules/@objectstack/spec/src/api/graphql.zod.ts` — GraphQL Protocol Support - `node_modules/@objectstack/spec/src/api/realtime.zod.ts` — Transport Protocol Enum - `node_modules/@objectstack/spec/src/api/rest-server.zod.ts` — REST API Server Protocol - `node_modules/@objectstack/spec/src/api/versioning.zod.ts` — API Versioning Protocol @@ -24,12 +23,10 @@ from `node_modules` — there is no local copy in the skill bundle. - `node_modules/@objectstack/spec/src/api/contract.zod.ts` — Standard Create Request - `node_modules/@objectstack/spec/src/api/realtime-shared.zod.ts` — Realtime Shared Protocol - `node_modules/@objectstack/spec/src/data/data-engine.zod.ts` — Data Engine Protocol -- `node_modules/@objectstack/spec/src/data/field.zod.ts` — Field Type Enum - `node_modules/@objectstack/spec/src/data/filter.zod.ts` — Unified Query DSL Specification - `node_modules/@objectstack/spec/src/data/query.zod.ts` — Sort Node - `node_modules/@objectstack/spec/src/kernel/execution-context.zod.ts` — Execution Context Schema - `node_modules/@objectstack/spec/src/security/explain.zod.ts` — [ADR-0090 D6] Access-explanation contract — `explain(principal, object, -- `node_modules/@objectstack/spec/src/shared/expression.zod.ts` — Expression Protocol - `node_modules/@objectstack/spec/src/shared/http.zod.ts` — Shared HTTP Schemas - `node_modules/@objectstack/spec/src/shared/identifiers.zod.ts` — System Identifier Schema