Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
3 changes: 3 additions & 0 deletions docs/graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
132 changes: 132 additions & 0 deletions schema.graphql
Original file line number Diff line number Diff line change
@@ -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!
}
30 changes: 30 additions & 0 deletions scripts/generate-graphql-schema.ts
Original file line number Diff line number Diff line change
@@ -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')
}
79 changes: 79 additions & 0 deletions src/__tests__/graphqlSchemaDrift.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})