diff --git a/README.md b/README.md index 5e1e48bd..13334c3b 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,17 @@ query { } ``` +#### Schema Definition & SDL Artifact +The GraphQL schema is exported as a canonical Schema Definition Language (SDL) artifact committed at [`schema.graphql`](schema.graphql). + +- **Location**: [`schema.graphql`](schema.graphql) +- **Regenerate / Refresh**: + ```bash + npm run graphql:schema:gen + ``` +- **Drift Guard & Validation**: CI runs `src/__tests__/graphqlSchemaDrift.test.ts` to assert that `schema.graphql` matches the code-defined schema in `src/api/graphql.ts`, guarantees deterministic output, validates SDL syntax for client generators, and detects breaking schema changes. + + ## Observability Prometheus metrics are exposed on `GET /metrics` (public, no API key required). diff --git a/docs/graphql.md b/docs/graphql.md index b4385131..88abfd0d 100644 --- a/docs/graphql.md +++ b/docs/graphql.md @@ -37,6 +37,9 @@ Lens exposes a high-performance GraphQL API alongside its REST interface, powere ## Schema & Types +The complete, canonical Schema Definition Language (SDL) artifact is committed at [`schema.graphql`](../schema.graphql). +It can be regenerated from code definitions using `npm run graphql:schema:gen`, and is continuously validated against drift in CI. + ### Root Operation Types ```graphql diff --git a/package.json b/package.json index 59b11a34..a56deda9 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "smoke": "node scripts/deployed-smoke.mjs", "test:smoke": "node --test scripts/test-deployed-smoke.mjs", "openapi:gen": "tsx scripts/generate-openapi.ts", + "graphql:schema:gen": "tsx scripts/generate-graphql-schema.ts", "changeset": "changeset", "version-packages": "changeset version", "release": "changeset tag", diff --git a/schema.graphql b/schema.graphql new file mode 100644 index 00000000..68955141 --- /dev/null +++ b/schema.graphql @@ -0,0 +1,132 @@ +type AggregatedPrice { + assetA: String! + assetB: String! + pairKey: String! + price: Float! + sdexPrice: Float! + ammPrice: Float! + bestRoute: String! + volume24h: Float! + sdexVolume24h: Float! + ammVolume24h: Float! + vwap1m: Float! + vwap5m: Float! + vwap1h: Float! + vwap24h: Float! + priceChange24h: Float! + lastUpdated: String! + sources: Int! + confidence: String! + lastTradeAgeSeconds: Int +} + +type RouteInfo { + route: String! + sdexPrice: Float! + ammPrice: Float! + estimatedOutput: Float! + slippagePct: Float! + recommendation: String! +} + +type PriceBucket { + bucket: String! + window: String! + vwap: Float! + sdexVwap: Float + ammVwap: Float + volume: Float! + tradeCount: Int! + open: Float + close: Float + high: Float + low: Float +} + +type PriceUpdate { + pair: String! + price: Float! + ts: String! + + """Which chain the price came from — testnet or mainnet.""" + network: String! +} + +enum OracleSourceStatus { + NOT_CONFIGURED + AVAILABLE + UNREACHABLE + EMPTY +} + +type OracleSource { + name: String! + price: Float + timestamp: String + deviationPct: Float + + """ + UNREACHABLE means the adapter failed; EMPTY means it is configured but + returned no quote. + """ + status: OracleSourceStatus! + ageSeconds: Int +} + +type OracleComparison { + asset: String! + network: String! + lens: Float + reflector: Float + deviationPct: Float + status: String! + fetchedAt: String! + oracles: [OracleSource!]! +} + +type Query { + """ + Aggregated price for a pair. Three outcomes are kept distinct: + + - Malformed asset input (an asset that is not CODE or CODE:ISSUER) fails + with a GraphQL error whose extensions.code is INVALID_INPUT. + - A well-formed pair that is not tracked on the active network fails with + a GraphQL error whose extensions.code is PAIR_NOT_FOUND. + - A tracked pair with no trade data yet is not an error: it resolves to an + AggregatedPrice with zeroed figures rather than null or an error. + + The result is only null if the resolver itself cannot produce a payload; + clients should branch on extensions.code, not on null. + """ + getPrice(assetA: String!, assetB: String!): AggregatedPrice + getBestRoute(assetA: String!, assetB: String!, amount: Float!): RouteInfo + + """ + Returns historical price aggregate buckets for a pair over an optional [from, to] time range. + + The time range is inclusive at both ends: a bucket is included if bucket >= from and bucket <= to. + Because both endpoints are inclusive, querying adjacent windows (e.g. [t0, t1] and [t1, t2]) will + return the boundary bucket at t1 in both results. + """ + getPriceHistory(assetA: String!, assetB: String!, window: String!, limit: Int, from: String, to: String): [PriceBucket] + listPairs: [String]! + + """ + Compares the Lens price for an asset against every configured oracle. + The network argument defaults to the active network. + """ + oracle(asset: String!, network: String): OracleComparison +} + +type Subscription { + """ + Streams a PriceUpdate every time an ingester records a new price for the + given pair. + + The network argument narrows the stream to one chain. It is optional, and + omitting it delivers every enabled network — the right default only if you + read the network field on each message, since a dual-network deployment + otherwise interleaves two chains prices on one stream. + """ + priceUpdated(pair: String!, network: String): PriceUpdate! +} diff --git a/scripts/generate-graphql-schema.ts b/scripts/generate-graphql-schema.ts new file mode 100644 index 00000000..352179e3 --- /dev/null +++ b/scripts/generate-graphql-schema.ts @@ -0,0 +1,30 @@ +import { writeFileSync } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { buildSchema, printSchema } from 'graphql' +import { schema as rawSchema } from '../src/api/graphql' + +export const schemaPath = fileURLToPath(new URL('../schema.graphql', import.meta.url)) + +/** + * Normalizes and formats the GraphQL schema string into canonical SDL format. + * + * Deterministic by construction: `graphql.buildSchema` parses the SDL and + * `graphql.printSchema` normalizes type order, whitespace, and descriptions, + * guaranteeing byte-for-byte identical output regardless of source formatting. + */ +export function renderGraphQLSchema(raw?: string): string { + const source = raw ?? rawSchema + const schemaObj = buildSchema(source) + return printSchema(schemaObj).trim() + '\n' +} + +/** Regenerates `schema.graphql` from code definition in place. */ +export function generateGraphQLSchema(): void { + writeFileSync(schemaPath, renderGraphQLSchema()) +} + +// Run only when invoked directly (`npm run graphql:schema:gen`), not when imported by tests. +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + generateGraphQLSchema() + console.log('Generated schema.graphql') +} diff --git a/src/__tests__/graphqlSchemaDrift.test.ts b/src/__tests__/graphqlSchemaDrift.test.ts new file mode 100644 index 00000000..1479cd98 --- /dev/null +++ b/src/__tests__/graphqlSchemaDrift.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest' +import { readFileSync } from 'node:fs' +import { buildSchema, findBreakingChanges, findDangerousChanges } from 'graphql' +import { schema as codeSchema } from '../api/graphql' +import { renderGraphQLSchema, schemaPath } from '../../scripts/generate-graphql-schema' + +describe('GraphQL SDL schema artifact and drift guard', () => { + it('committed schema.graphql matches the code-defined schema exactly', () => { + const committedSchema = readFileSync(schemaPath, 'utf8') + const generatedSchema = renderGraphQLSchema(codeSchema) + + if (committedSchema !== generatedSchema) { + expect.fail( + 'schema.graphql is out of sync with src/api/graphql.ts schema definition.\n' + + 'Run `npm run graphql:schema:gen` to regenerate and commit schema.graphql.' + ) + } + + expect(committedSchema).toBe(generatedSchema) + }) + + it('regenerates deterministically', () => { + expect(renderGraphQLSchema(codeSchema)).toBe(renderGraphQLSchema(codeSchema)) + }) + + it('produces valid GraphQL SDL that client generators and validators can parse', () => { + const committedSchema = readFileSync(schemaPath, 'utf8') + const parsedSchema = buildSchema(committedSchema) + + expect(parsedSchema.getQueryType()).toBeDefined() + expect(parsedSchema.getQueryType()?.name).toBe('Query') + expect(parsedSchema.getSubscriptionType()).toBeDefined() + expect(parsedSchema.getSubscriptionType()?.name).toBe('Subscription') + + const queryFields = parsedSchema.getQueryType()?.getFields() + expect(queryFields?.getPrice).toBeDefined() + expect(queryFields?.getBestRoute).toBeDefined() + expect(queryFields?.getPriceHistory).toBeDefined() + expect(queryFields?.listPairs).toBeDefined() + + const subscriptionFields = parsedSchema.getSubscriptionType()?.getFields() + expect(subscriptionFields?.priceUpdated).toBeDefined() + }) + + it('fails drift guard when a field is added to code without refreshing schema.graphql', () => { + const driftingCodeSchema = `${codeSchema}\nextend type Query { driftProbeTestField: String }` + const committedSchema = readFileSync(schemaPath, 'utf8') + const newlyGenerated = renderGraphQLSchema(driftingCodeSchema) + + expect(committedSchema).not.toBe(newlyGenerated) + }) + + it('detects and flags breaking changes when fields or arguments are removed or altered', () => { + const originalSchemaObj = buildSchema(readFileSync(schemaPath, 'utf8')) + + // Simulate a breaking change: removing a field from AggregatedPrice + const breakingSchemaString = codeSchema.replace('sdexPrice: Float!', '') + const breakingSchemaObj = buildSchema(breakingSchemaString) + + const breakingChanges = findBreakingChanges(originalSchemaObj, breakingSchemaObj) + expect(breakingChanges.length).toBeGreaterThan(0) + expect(breakingChanges.some(c => c.description.includes('sdexPrice'))).toBe(true) + }) + + it('detects non-breaking (safe or dangerous) additions cleanly', () => { + const originalSchemaObj = buildSchema(readFileSync(schemaPath, 'utf8')) + + // Non-breaking addition of a nullable field to Query + const backwardCompatibleSchemaString = `${codeSchema}\nextend type Query { ping: String }` + const newSchemaObj = buildSchema(backwardCompatibleSchemaString) + + const breakingChanges = findBreakingChanges(originalSchemaObj, newSchemaObj) + expect(breakingChanges).toHaveLength(0) + + const dangerousChanges = findDangerousChanges(originalSchemaObj, newSchemaObj) + // Adding an optional query field is safe + expect(Array.isArray(dangerousChanges)).toBe(true) + }) +})