Skip to content
Closed
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
75 changes: 75 additions & 0 deletions docs/database-schema.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Database schema and startup verification

## Current state: `prisma db push`

`prisma/` contains `schema.prisma` but no `migrations/` directory. Schema
changes are applied with `prisma db push`; the container entrypoint in
`src/index.ts` also runs:

```bash
prisma db push --accept-data-loss
```

before the API starts listening. That keeps the deployed schema in step with
the Prisma model, but it leaves no versioned record of *which* schema a
database is running. A push that silently fails, is blocked by permissions, or
runs against the wrong database would leave the process starting against a
schema the code does not understand.

## Startup guard (#352)

`src/db/schemaVerify.ts` owns the expected physical schema (Prisma model names
mapped through `@@map` / `@map`) and checks it with two cheap queries:

1. `information_schema.tables` for every expected table.
2. `information_schema.columns` for the columns of the tables that exist.

`assertSchemaValid()` runs after the boot-time push and before
`loadPersistedPairs()` and `app.listen()`. On a mismatch it throws
`DatabaseSchemaMismatchError`, which names the missing tables and columns, and
the existing fatal-startup handler exits the process. A connection failure is
allowed to propagate as a connection failure, not reported as a schema
mismatch.

The successful result is cached, so `/ready` is a memory read rather than a
second pair of queries on every probe:

```json
{ "ready": true, "checks": { "schema": { "ok": true } }, "checkedAt": "..." }
```

A stale schema returns HTTP 503 with the missing entities:

```json
{
"ready": false,
"checks": {
"schema": {
"ok": false,
"missingTables": ["pool_snapshots"],
"missingColumns": ["price_points.network"]
}
}
}
```

`/status` remains the data-freshness endpoint (last indexed ledger and ingest
lag); `/ready` answers only "can this instance serve traffic against the
schema it was built for?".

## Why there is no migrations directory (and what comes next)

The project has used `db push` for rapid iteration while the ingestion,
partitioning, and x402 schemas were still moving. Adopting Prisma Migrate is a
deliberate operational change, not a drive-by refactor:

1. Produce a baseline from the current database:
`npx prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script > prisma/migrations/0_init/migration.sql`
2. Mark it applied: `npx prisma migrate resolve --applied 0_init`.
3. Replace the boot-time `db push` with `prisma migrate deploy` in the deploy
pipeline, where an operator can see and roll back a failed migration.
4. Keep this startup guard as the runtime barrier: migrations own the change,
and the guard refuses to serve traffic if the result is still incomplete.

Until that migration work happens, the guard is the safety net that turns a
missing column from a request-time crash into a named startup failure.
94 changes: 94 additions & 0 deletions openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,100 @@
}
}
},
"/ready": {
"get": {
"operationId": "getReady",
"summary": "Readiness probe (database schema)",
"description": "Returns 200 when this process booted against a database schema that matches the build, and 503 with the missing tables/columns otherwise. The result is cached from startup, so the probe does not re-query the database. Public and rate-limit exempt like `/status`.",
"security": [],
"responses": {
"200": {
"description": "Instance is ready to serve traffic",
"headers": {
"Cache-Control": {
"$ref": "#/components/headers/CacheControl"
}
},
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ready": {
"type": "boolean"
},
"checks": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
}
}
}
}
},
"checkedAt": {
"type": "string",
"format": "date-time"
}
}
}
}
}
},
"503": {
"description": "Schema is missing tables or columns, or the check could not reach the database",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"ready": {
"type": "boolean"
},
"checks": {
"type": "object",
"properties": {
"schema": {
"type": "object",
"properties": {
"ok": {
"type": "boolean"
},
"missingTables": {
"type": "array",
"items": {
"type": "string"
}
},
"missingColumns": {
"type": "array",
"items": {
"type": "string"
}
},
"error": {
"type": "string"
}
}
}
}
},
"checkedAt": {
"type": "string",
"format": "date-time"
}
}
}
}
}
}
}
}
},
"/snapshot": {
"get": {
"operationId": "getSnapshot",
Expand Down
65 changes: 65 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,71 @@ paths:
'429':
$ref: '#/components/responses/TooManyRequests'

/ready:
get:
operationId: getReady
summary: Readiness probe (database schema)
description: >-
Returns 200 when this process booted against a database schema that
matches the build, and 503 with the missing tables/columns otherwise.
The result is cached from startup, so the probe does not re-query the
database. Public and rate-limit exempt like `/status`.
security: []
responses:
'200':
description: Instance is ready to serve traffic
headers:
Cache-Control:
$ref: '#/components/headers/CacheControl'
content:
application/json:
schema:
type: object
properties:
ready:
type: boolean
checks:
type: object
properties:
schema:
type: object
properties:
ok:
type: boolean
checkedAt:
type: string
format: date-time
'503':
description: Schema is missing tables or columns, or the check could not reach the database
content:
application/json:
schema:
type: object
properties:
ready:
type: boolean
checks:
type: object
properties:
schema:
type: object
properties:
ok:
type: boolean
missingTables:
type: array
items:
type: string
missingColumns:
type: array
items:
type: string
error:
type: string
checkedAt:
type: string
format: date-time

/snapshot:
get:
operationId: getSnapshot
Expand Down
118 changes: 118 additions & 0 deletions src/__tests__/ready.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import Fastify from 'fastify'

const { mockQuery, mockGetCachedPrice, mockGetBestRoute } = vi.hoisted(() => ({
mockQuery: vi.fn(),
mockGetCachedPrice: vi.fn(),
mockGetBestRoute: vi.fn(),
}))

vi.mock('../db', () => ({
pgPool: { query: mockQuery },
}))
vi.mock('../redis', () => ({
getCachedPrice: mockGetCachedPrice,
setCachedPrice: vi.fn(),
}))
vi.mock('../aggregator/bestRoute', () => ({
getBestRoute: mockGetBestRoute,
}))
vi.mock('../config', () => ({
config: { pairs: [], cache: { priceTtl: 10 } },
activeNetwork: 'testnet',
getNetworkConfig: () => ({ pairs: [] }),
}))

import { registerRESTRoutes } from '../api/rest'
import { registerNetworkSelector } from '../middleware/network'
import { EXPECTED_SCHEMA, resetSchemaVerificationCache } from '../db/schemaVerify'

type Schema = Record<string, readonly string[]>

function completeSchema(): Schema {
return Object.fromEntries(
Object.entries(EXPECTED_SCHEMA).map(([table, columns]) => [table, [...columns]]),
)
}

function schemaQuery(schema: Schema) {
return async (sql: string) => {
if (sql.includes('information_schema.tables')) {
return { rows: Object.keys(schema).map(table_name => ({ table_name })) }
}
if (sql.includes('information_schema.columns')) {
return {
rows: Object.entries(schema).flatMap(([table_name, columns]) =>
columns.map(column_name => ({ table_name, column_name })),
),
}
}
return { rows: [] }
}
}

async function buildApp() {
const app = Fastify({ logger: false })
await app.register(registerNetworkSelector)
await registerRESTRoutes(app)
await app.ready()
return app
}

beforeEach(() => {
vi.clearAllMocks()
resetSchemaVerificationCache()
})

describe('GET /ready (#352)', () => {
it('reports ready when the database schema matches', async () => {
mockQuery.mockImplementation(schemaQuery(completeSchema()))
const app = await buildApp()

const res = await app.inject({ method: 'GET', url: '/ready' })

expect(res.statusCode).toBe(200)
expect(res.json()).toMatchObject({ ready: true, checks: { schema: { ok: true } } })
})

it('returns 503 and names the missing table and column', async () => {
const schema = {
...completeSchema(),
price_points: EXPECTED_SCHEMA.price_points.filter(column => column !== 'network'),
}
delete schema.pool_snapshots
mockQuery.mockImplementation(schemaQuery(schema))
const app = await buildApp()

const res = await app.inject({ method: 'GET', url: '/ready' })

expect(res.statusCode).toBe(503)
expect(res.json().ready).toBe(false)
expect(res.json().checks.schema.missingTables).toContain('pool_snapshots')
expect(res.json().checks.schema.missingColumns).toContain('price_points.network')
})

it('caches the check so repeated probes do not re-query', async () => {
mockQuery.mockImplementation(schemaQuery(completeSchema()))
const app = await buildApp()

await app.inject({ method: 'GET', url: '/ready' })
const callsAfterFirst = mockQuery.mock.calls.length
await app.inject({ method: 'GET', url: '/ready' })

expect(mockQuery.mock.calls.length).toBe(callsAfterFirst)
})

it('returns 503 instead of crashing when the schema check cannot reach the database', async () => {
mockQuery.mockRejectedValue(new Error('connection refused'))
const app = await buildApp()

const res = await app.inject({ method: 'GET', url: '/ready' })

expect(res.statusCode).toBe(503)
expect(res.json()).toMatchObject({
ready: false,
checks: { schema: { ok: false, error: 'database_unavailable' } },
})
})
})
Loading