How Lens actually behaves today: what runs, what breaks when it is unavailable, how to tell, and what to do. Every claim here is checkable against the code at this commit — the defining file or symbol is named next to each one. Where the system does the wrong thing, this document says so; it does not describe an aspirational system.
Lens runs as a single Node process (npm start → node dist/index.js,
CMD in Dockerfile). Everything below runs inside it — there is no separate
worker, ingester or scheduler process:
| Component | What it does | Where |
|---|---|---|
| HTTP API | REST routes, /graphql + GraphiQL, webhooks, x402 facilitator, admin/usage routes |
buildApp() in src/app.ts, routes under src/api/ and src/routes/ |
| WebSocket server | graphql-transport-ws subscriptions on /graphql; local fan-out plus cross-instance fan-out over Redis pub/sub |
src/ws/fanout.ts, src/ws/ |
| Venue ingesters | One infinite poll loop per (venue × enabled network) for SDEX, Horizon AMM, Soroswap, Aquarius | registry src/ingest/venues/index.ts, started in main() (src/index.ts) via startVenueAdapter (src/ingest/venues/runner.ts) |
| Snapshot ingester | Appends one price_snapshots row per active pair at every minute boundary, per network |
src/ingest/snapshot.ts |
| BullMQ workers | aggregate-refresh (one queue/worker per network), snapshot-retention (hourly), price_points partition maintenance (hourly) — in the same process |
src/jobs/aggregateRefresh.ts, src/jobs/snapshotRetention.ts, src/jobs/pricePointsPartitionJob.ts |
| Webhook dispatcher | Threshold alerts, delivered inline after each ingested price write | src/webhookDispatcher.ts, src/alerts/delivery.ts |
| Schema migration | prisma migrate deploy runs at every boot, before the API listens — committed migrations only, never a diff of the live schema (#233). On a database that predates the migration history the baseline is recorded as applied first, without running it |
applyMigrations() in src/db/migrate.ts, called from main() (src/index.ts) — doc: docs/database-migrations.md |
| Middleware | Network selector, API-key auth, per-key/IP rate limits, the per-instance global request ceiling (GLOBAL_RATE_LIMIT_MAX, sheds with 503), x402 payment gate, cache policy, HTTP metrics |
src/app.ts registration order |
| Health probes | GET /live (liveness) and GET /ready (readiness: boot state + Postgres/Redis), plus GET /snapshot, a cached whole-network freshness object |
src/health.ts, src/routes/health.ts, src/routes/snapshot.ts — doc: docs/health.md |
| Metrics | Prometheus registry exposed on GET /metrics |
src/metrics.ts, route in src/app.ts |
Startup order and failure behaviour (all in main(), src/index.ts):
applyMigrations()—prisma migrate deploy, preceded by a one-timemigrate resolve --applied <baseline>when the schema exists without a_prisma_migrationstable. Failure throws →fatal startup error, exit 1.redis.connect()— soft: on failure logsredis unavailable, continuing without cacheand continues.pgPool.connect()— hard: rejection →fatal startup error, exit 1.- Load persisted pairs (
loadPersistedPairs()). buildApp()+app.listen()— failure (e.g. port in use) → exit 1.fanOutManager.initialize()— soft, warnwebsocket fan-out init skipped.- Aggregate-refresh worker per enabled network — soft, warn
aggregate refresh worker skipped (redis unavailable). No fallback is installed for this one. - Snapshot retention: prune once, then schedule hourly — on Redis failure warn
falling back to an in-process hourly pruneand install an in-processsetInterval. - Price-points partition maintenance: same pattern, same in-process hourly fallback.
- Ingesters started per network through
restartIngester: a crash is logged (ingester crashed; restarting in 10s) and restarted after 10 s, isolated per(venue, network)— one dying ingester does not affect the others. markBootComplete()— last, and only once every ingester above is running: until that lineGET /readyanswers 503, keeping a starting instance out of the load balancer (src/index.ts,src/health.ts).
| Service | Notes |
|---|---|
lens |
Port 3002; healthcheck: curl -f http://localhost:3002/status, every 10 s, start_period: 20s |
postgres |
postgres:15-alpine, volume pgdata, healthcheck pg_isready |
redis |
redis:7-alpine, --appendonly yes (AOF persistence), volume redisdata |
examples/oracle-relay, examples/alert-bot, examples/mcp-server (stdio),
and scripts/* (smoke, seed, key issuance) are run manually via their npm
scripts. Nothing in src/index.ts depends on them.
| Workflow | Trigger | What it does |
|---|---|---|
ci.yml |
every PR and push to main |
npm ci → prisma generate → tsc --noEmit → npm run build → npm test |
release.yml |
push to main |
changesets: version PR or tag (tag-only release, no npm publish) |
openapi-publish.yml |
push to main |
regenerates openapi.json, publishes to gh-pages |
db-backup.yml |
daily 03:00 UTC | pg_dump per network, age-encrypted, 14-day artifact (see §5) |
load.yml |
daily 04:00 UTC | k6 load test, p95 < 80 ms @ 5k RPS — currently red on main, see §6 |
Used for: everything durable — price_points, price_snapshots,
pool_snapshots, price_aggregates, indexer_state, pairs, webhooks,
API keys/usage. It is the source of truth; Redis is only a cache
("Redis is a cache and a job broker, not a source of truth" — comment in src/index.ts).
| Behaviour | |
|---|---|
| Down at boot | pgPool.connect() rejects → fatal startup error → process exits 1. Nothing serves. |
| Down at runtime | Queries throw → affected routes return 5xx. /status itself queries the DB, so it fails too. |
| Where you see it | GET /status refused/500 and GET /ready → 503 with checks.postgres.status: "down" (the probe also asserts the schema exists via to_regclass('public.indexer_state'), so a reachable-but-unmigrated database reports not-ready too); GET /metrics → http_requests_total{status_class="5xx"} rising, db_query_duration_seconds tail; pino error logs. |
| Recovery | Restore the nightly dump (docs/backup-restore.md, produced by db-backup.yml), or re-ingest from chain — Lens data is re-derivable by ingesting again (docs/DEPLOY_MAINNET.md). |
Used for: the price cache (getCachedPrice/setCachedPrice, TTL
PRICE_CACHE_TTL, default 10 s), BullMQ queues/workers, cross-instance
WebSocket pub/sub.
| Behaviour | |
|---|---|
| Down at boot | Soft: redis unavailable, continuing without cache. The API still serves — every cache read falls back to Postgres (getCachedPrice catches and returns null), writes are best-effort (swallowed). Per-worker effects: aggregate refresh is skipped with no fallback; retention and partition maintenance fall back to hourly in-process timers; ws cross-instance fan-out is skipped. |
| Down at runtime | The client rejects commands immediately (enableOfflineQueue: false, src/redis.ts — no request hangs waiting for a connection), reconnects with backoff capped at 60 s, and logs at most one redis connection error per minute. Reads keep falling back to Postgres. BullMQ's own connections (separate ioredis instances) log [ioredis] Unhandled error event while away and pause the queue-driven jobs. |
| What breaks concretely | • No cache: every /price request recomputes from Postgres — correct, just slower.• price_aggregates freeze if the aggregate-refresh worker is not running (skipped at boot, or paused while BullMQ cannot reach Redis). Consumers: /price/:assetA/:assetB/history, /screener, and the GraphQL aggregate queries — their buckets stop advancing while /price/:assetA/:assetB keeps serving live values (it computes VWAP from price_points on a cache miss — getAggregatedPrice, src/aggregator/vwap.ts).• Multi-instance WebSocket fan-out stops: clients still receive events published by their own instance (the local priceEmitter subscription always works — src/ws/fanout.ts), but not events from other instances.• Readiness fails: GET /ready PINGs Redis and reports down (503) while Redis is away — the instance leaves rotation even though reads still work from Postgres (READINESS_REDIS_CHECK=false excludes Redis from readiness). |
| Where you see it | X-Cache: MISS on every /price response (src/api/rest.ts); /screener and /price/.../history stop moving; aggregate refresh worker skipped (redis unavailable) / redis connection error in logs; GET /ready → 503 with checks.redis.status: "down"; ws_messages_sent_total low on one instance while ingestion metrics are healthy. GET /metrics has no Redis gauge — the cache signal is the X-Cache response header, the readiness signal is /ready. |
| Note on #345 | The issue title says Redis is "noticed once and never again"; today's code reconnects forever with capped backoff and throttled logging (src/redis.ts). The genuinely unfixed part is the missing aggregate-refresh fallback (see §4). |
Used for: SDEX trade ingestion and Horizon AMM snapshots.
| Behaviour | |
|---|---|
| Down / rate-limited | No 429-specific handling anywhere in the ingest path (#348). SDEX fetch failures are caught inside fetchSdexTrades (src/ingest/venues/sdex.ts), logged as error fetching trades, and the cycle continues having ingested nothing. Failures that escape an adapter fail the cycle and trigger exponential backoff 10 s → 60 s cap: venue adapter cycle failed; retrying (src/ingest/venues/runner.ts). |
| Where you see it | GET /status → ingestLagSeconds grows; GET /metrics → trades_ingested_total flat, time() - last_trade_timestamp growing (staleness alert recipe: docs/ingest-metrics.md). |
| Recovery | Automatic: backoff retries forever; ingestion resumes from the persisted cursor in indexer_state (setIndexerCursor), so no trades are skipped once Horizon recovers — only the outage window is missing. |
Used for: AMM pool reserves/snapshots (Soroswap), Aquarius pool discovery, Reflector oracle reads (on demand, not a background loop).
| Behaviour | |
|---|---|
| Bad/missing config | config.ts warns naming the env var and disables the feature; the adapter logs venue adapter is disabled — ingester not started and returns. |
| Down at runtime | Cycle failures back off like above. Aquarius is quieter: if (!res.ok) return [] (src/ingest/venues/aquarius.ts) — a failing API returns empty results with no error. |
| Where you see it | GET /metrics → amm_snapshots_total flat; /pools pool timestamp goes stale; the ammPrice half of /price/:assetA/:assetB stops updating while sdexPrice keeps moving. |
Used for: verifying payments on the gated routes (/price, /pools,
/candles — GATED_ROUTES in src/middleware/x402.ts). Only active when
ORACLE_PAYMENT_ADDRESS is set; otherwise the plugin logs
[oracle] ORACLE_PAYMENT_ADDRESS not set — x402 gating disabled and the gated
routes are free.
| Behaviour | |
|---|---|
| Unreachable / verify throws | Caught in the preHandler → 402 {error: "Payment verification failed", reason: ...} (fails closed). |
| Settle fails | Settlement is async after the response is sent; failure is only logged [oracle] x402 settle error — the request was already served. |
| Where you see it | Paying clients suddenly receive 402s; GET /metrics → x402_payments_received_total stops incrementing; webhook-style pino logs from the oracle logger. |
Used for: threshold alerts registered via POST /webhooks.
| Behaviour | |
|---|---|
| Failing subscriber | 10 s timeout, 3 attempts with delays 1 s, 2 s (retryDelayMs, src/alerts/delivery.ts), no jitter (#341). A 4xx stops immediately; final failure is only logged webhook delivery failed after retries. Nothing is persisted or re-queued — the event is gone (#342). |
| Where you see it | The webhook pino logger only. GET /metrics exports no webhook series at all — silence on /metrics does not mean delivery worked. |
docs/DEPLOY_MAINNET.md: Render free services sleep after ~15 min idle, so an
UptimeRobot monitor pings GET /status every 5 minutes.
| Behaviour | |
|---|---|
| Monitor stops / misconfigured | The service sleeps → GET /status connection refused and ingestion sleeps with it — staleness follows. |
| Where you see it | /status refused while nothing else is wrong; UptimeRobot's own alerting. |
There is no central env validation (#338): WATCHED_PAIRS format throws at
startup (parseWatchedPairs, src/config.ts → fatal exit 1), everything else
is read directly from process.env at first use. Common misconfigurations and
their exact symptoms:
| Misconfiguration | Symptom | Signal |
|---|---|---|
Bad DATABASE_URL |
Process never starts | log fatal startup error, exit 1 |
Bad REDIS_URL |
Runs, uncached | boot warn redis unavailable, continuing without cache; X-Cache: MISS forever on /price |
ORACLE_PAYMENT_ADDRESS unset |
Payment gating silently off — gated data served free | log x402 gating disabled; no 402s from /price |
ADMIN_TOKEN unset |
Admin surface unusable (by design) | every POST/DELETE /admin/keys and /admin/usage* → 401 code: admin_unauthorized (isAdminAuthorized, src/api/auth.ts), counted in admin_auth_failures_total on GET /metrics |
REQUIRE_API_KEY=false |
API-key auth off entirely | log [auth] REQUIRE_API_KEY=false — API key authentication disabled |
GLOBAL_RATE_LIMIT_MAX invalid (not a positive integer) |
Process never starts | buildApp throws → fatal startup error, exit 1. Default ceiling when unset: 1000 req/min per instance, with expensive route templates shed at 80% and health probes exempt (src/middleware/globalRateLimit.ts) |
READINESS_REDIS_CHECK=false |
Redis dropped from readiness | GET /ready no longer fails when Redis is down |
Bad WATCHED_PAIRS |
Process never starts | log fatal startup error with the parse error, exit 1 |
Public (no API key), rate-limit exempt — /status, /live and /ready are
the PROBE_PATHS allow-list in src/app.ts, and all three are excluded from
http_requests_total so probes neither throttle nor pollute the traffic
metrics.
Answers for one network: pass ?network=mainnet or the x-network header.
{
"ok": true,
"network": "testnet",
"watchedPairs": ["XLM:native/USDC:GBBD47..."],
"lastIndexedLedger": 53842917,
"lastProcessedAt": "2026-10-19T12:00:00.000Z",
"ingestLagSeconds": 4
}Read it honestly (implementation: GET /status handler, src/api/rest.ts):
ok: trueonly means the DB query worked. The handler returnsok: trueunconditionally — it does not assert freshness.ingestLagSeconds= seconds since the most recentindexer_statewrite for that network. It isnulluntil the first write;lastIndexedLedgerisnulluntil the SDEX ingester has recorded a ledger.- Caveat, checkable in code: only the SDEX and AMM ingesters write
indexer_state(setIndexerCursorinsrc/ingest/venues/sdex.tsandsrc/ingest/venues/amm.ts), and only when a cycle actually ingested records — a network with no trades in the window shows a growing lag even when everything is healthy. Cross-check/metrics(below) before treating lag alone as an incident. The 1-minute snapshot ingester is a separate loop and never touchesindexer_state. - The compose healthcheck (
docker-compose.yml) and the Render keep-alive monitor both poll this endpoint.
Both public, rate-limit exempt, and excluded from http_requests_total;
rationale and semantics live in docs/health.md.
GET /live—200 {ok, uptimeSeconds}as long as the event loop is turning; it touches neither Postgres nor Redis. That is deliberate: a liveness probe that fails during a dependency outage would have the orchestrator restart every healthy instance at once.GET /ready—200only after boot completes (markBootComplete()is the last line ofmain()) and every checked dependency is up; otherwise503carryingbooted,checks.postgres,checks.redisandcheckedAt. Postgres is probed for reachability and migration state (to_regclass('public.indexer_state')), Redis withPING(2 s probe deadline, 2 s answer cache —READINESS_TIMEOUT_MS/READINESS_CACHE_MS;READINESS_REDIS_CHECK=falsedrops Redis from readiness). Reasons in the body are fixed phrases, never raw errors — the endpoint is public; the real error is logged instead.
GET /snapshot?network=… returns the whole network's shape — venues, TVL, 24h
volume, top pairs, peg readings and data freshness — in one cached, timestamped
object (src/routes/snapshot.ts, cached in Redis via getCachedSnapshot).
Public Prometheus exposition (src/metrics.ts, route in src/app.ts).
Ingestion health:
| Signal | Meaning / recipe |
|---|---|
rate(trades_ingested_total{network}[5m]) |
Flat = SDEX/AMM ingestion stopped for those pairs |
time() - last_trade_timestamp{pair,network} |
Per-pair staleness — the canonical alert; worked example in docs/ingest-metrics.md (labels are pair × network, bounded by config) |
rate(amm_snapshots_total{network}[5m]) |
AMM venue snapshots; flat = Soroswap/Aquarius/Horizon-AMM side stalled |
rate(price_snapshots_total{network}[5m]) |
Snapshot ingester; healthy ≈ (active pairs) rows/min per network |
ws_clients_total, ws_messages_sent_total, ws_messages_dropped_total, ws_clients_dropped_total, ws_buffered_bytes |
Subscriber count, delivery rate, backpressure drops, clients killed and in-memory buffer depth (labels are bounded: pair is the literal key, other, or aggregate — see docs/websocket-backpressure.md) |
API health:
| Signal | Meaning |
|---|---|
http_requests_total{method,route,status_class} |
Rate and 5xx ratio; route is the route template, status_class is 2xx/4xx/5xx — both deliberately bounded |
http_request_duration_seconds |
Latency histogram with Lens-specific buckets (5 ms … 10 s); p95 recipe in README.md and docs/http-metrics.md |
db_query_duration_seconds |
Postgres query latency (includes pool wait — the timer wraps pgPool.query) |
api_key_auth_failures_total{reason}, admin_auth_failures_total |
Auth failures by bounded reason (missing/malformed/unknown/revoked/expired; admin counted separately) |
rate_limit_rejections_total{scope}, global_shed_requests_total |
Per-key/IP limiter 429s (key/ip) and per-instance ceiling sheds (503) |
x402_payments_received_total, price_requests_total |
Payment/request counters |
Known blind spots: no gauge for Redis state, no gauge for the pg pool (see #339 in §4), no queue-lag metric, no webhook-delivery metric.
- Is the process answering?
curl -s localhost:3002/live, then/ready:/liveconnection refused / sleeping → process dead or host asleep (see §5 deploys, §2 keep-alive)./live200 but/ready503 → boot not finished, or a checked dependency is down — thechecksbody says which (Postgres down/unmigrated, Redis down; §2)./status500 / refused → PostgreSQL (§2).
- Is data fresh?
ingestLagSeconds+time() - last_trade_timestamp(§3 caveat above). - Which layer? grep the pino logs for the exact lines named in this
document:
fatal startup error,error fetching trades,venue adapter cycle failed; retrying,ingester crashed; restarting in 10s,redis connection error,aggregate refresh worker skipped,webhook delivery failed after retries,[oracle] x402 settle error. - Is it only the aggregates/cache?
/price/:assetA/:assetBfresh but/screeneror/price/.../historyfrozen → aggregate-refresh worker / Redis (§2). - Full deployed check:
SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https://<host>(scripts/deployed-smoke.mjs) — asserts/ready+/live(it fails hard if/ready404s or reportsok: false), then/status,/price, and a livegraphql-transport-wssubscription. On an x402-gated installation an unauthenticated/pricecall returns 402 and the smoke check fails by design; it never bypasses payment (README).
Each row states what the code does today, not what it should do, and names the endpoint, metric, or log line that shows it. Issue numbers are the open tracking issues for the batch this runbook was filed in.
| # | Failure mode | What happens today (code) | Where you see it |
|---|---|---|---|
| 1 | Unhandled rejection kills the process (#344) | There is no unhandledRejection/uncaughtException handler anywhere in src/; Node's default is to exit — with no application log line for the cause. |
Process gone: GET /status connection refused, compose healthcheck fails, http_requests_total flatlines while the container restarts. The cause is only inferable from the last log line before the exit. |
| 2 | Shutdown is not graceful (#347) | There is no SIGTERM/SIGINT handler anywhere in src/; the process exits immediately. In-flight requests are cut and WebSocket clients are dropped without a close frame. |
During any deploy/restart: WS clients see an abnormal closure (no 1000/1001 close frame), ws_clients_total disappears, http_requests_total stops mid-request. |
| 3 | Horizon rate limiting is not handled (#348) | No 429 branch exists in the ingest path. SDEX 429s surface as a caught error → log error fetching trades → null, and the loop retries on the normal poll interval without backoff or accounting. |
GET /status ingestLagSeconds grows; GET /metrics trades_ingested_total flat + stale last_trade_timestamp; repeating error fetching trades or venue adapter cycle failed; retrying in the logs. |
| 4 | A single ingester cycle has no deadline (#351) | startVenueAdapter runs while (true) and awaits every watched pair sequentially with no cycle-level timeout (src/ingest/venues/runner.ts); there is no per-cycle upper bound. |
The whole network's ingestion quietly spreads/stalls: last_trade_timestamp ages and /status.ingestLagSeconds grows while no venue adapter cycle failed line appears — no error, just lag. |
| 5 | Webhook retries have no jitter (#341) | Delay is exactly 2^(attempt-1) × 1000 ms (retryDelayMs, src/alerts/delivery.ts) — subscribers triggered together retry together. |
Bursts of webhook delivery failed after retries (logger webhook) arriving in lockstep after any upstream blip; GET /metrics has no webhook series to graph this. |
| 6 | A webhook that fails 3 times disappears (#342) | After 3 attempts (or any 4xx on the first) the failure is logged and dropped — no persistence, no queue, no dead-letter. | The subscriber silently never receives the threshold event; the only trace is the webhook log line. Nothing on /metrics moves. |
| 7 | DB pool is default-sized and unmeasured (#339) | new Pool({connectionString}) with pg defaults (max 10 connections); instrumentation is only the db_query_duration_seconds histogram — no pool gauge exists (src/db.ts, src/metrics.ts). |
Under saturation both db_query_duration_seconds and http_request_duration_seconds shift right together (the timer includes pool wait); GET /metrics offers no waiter/pool gauge to confirm it. |
| 8 | Env vars are not centrally validated (#338) | Only WATCHED_PAIRS parsing throws at startup; every other variable is consumed lazily at first use. |
Misconfiguration surfaces at first use — see the symptom table in §2 (bad REDIS_URL → X-Cache: MISS forever; unset ORACLE_PAYMENT_ADDRESS → gating silently off; unset ADMIN_TOKEN → admin routes always 401). |
| 9 | Redis away at boot: aggregate refresh has no fallback (the unfixed half of #345) | Retention and partition maintenance fall back to hourly in-process timers when their BullMQ schedule fails at boot; the aggregate-refresh worker does not — it is skipped and never retried for the life of the process (src/index.ts). |
aggregate refresh worker skipped (redis unavailable) at boot, then: /screener and /price/:assetA/:assetB/history (and GraphQL aggregate queries) freeze while /price/:assetA/:assetB keeps serving live prices from price_points. |
- Production (
docs/DEPLOY_MAINNET.md): Render web service built from this repo/image, env vars in the Render dashboard only, Neon Postgres, managed Redis, UptimeRobot keep-alive on/status. - Self-hosted / local:
docker compose up -d --build(image built by the multi-stageDockerfile,CMD npm start). - In both cases the boot sequence in §1 applies — including the automatic
prisma migrate deploy.
-
The schema changes to whatever the committed migrations say. Step 1 of boot runs
prisma migrate deploy(src/db/migrate.ts) before the API listens. Unlike thedb pushit replaced, it applies only migrations that are inprisma/migrationsand that a reviewer read — so a destructive change reaches production because someone merged the SQL, not because a diff decided it. It is still the one irreversible part of any deploy: read the pending migrations before deploying, and take the backup path (rollback step 3) first if any of them drops a column or a table.The corollary of dropping
db pushis that drift is no longer self-healing. A column present inschema.prismabut missing from the live database is not added at boot any more —migrate deployhas nothing pending to apply, andassertSchemaValid()only covers the five core tables inEXPECTED_SCHEMA. Fix it with a committed migration; see the one-time check indocs/database-migrations.md. -
The API is down until
listenreturns, and not ready until boot ends. Single instance ⇒ no rolling deploy; compose allows 20 s (start_period) before healthchecks fail.GET /readystays 503 from process start untilmarkBootComplete()— gate rollouts on/ready;/livealone only proves the event loop turns. -
No graceful shutdown (#347, §4): in-flight requests are cut and WS clients are dropped without a close frame.
-
Ingestion pauses for the restart, then resumes from persisted cursors. Cursor state lives in
indexer_state, writes are idempotent (skipDuplicatesinupsertPricePoints,ON CONFLICT DO NOTHINGon snapshots), so replay after restart is safe; the gap is the trades that happened while the process was down (visible as missingprice_pointsfor that window and a lag spike in/status). -
In-memory state is lost: rate-limit windows (429 counters reset), WS client list, the ingesters' last-price map (the webhook previous-price baseline restarts, so the first post-restart tick compares against itself). Redis, being external, keeps the price cache warm across a deploy.
-
Maintenance jobs re-run safely. Prune and partition maintenance run once at boot regardless, the hourly schedules are re-registered, and BullMQ jobs are at-least-once — safe here because every job is an upsert/prune.
- App code: redeploy the previous known-good revision —
Render: roll back to the previous deploy in the dashboard;
self-hosted:
git checkout <good-sha> && docker compose up -d --build. - Schema: migrations are forward-only — Prisma generates no down-migration
— so rolling back the code does not roll back the schema. Roll forward:
commit a migration that reverses the change and deploy it
(
npm run db:deploy). Do not reach forprisma db pushfrom the old checkout: it is gone frompackage.jsonfor a reason, and a push against a database that now has migration history leaves the two disagreeing. If the previous revision cannot run against the new schema, the honest options are a reversing migration or a restore from the dump — take the backup path (step 3) first if either direction removes data. - Data (if a bad deploy or its auto-migration destroyed rows): restore the
nightly dump per
docs/backup-restore.md— dumps are produced daily at 03:00 UTC by.github/workflows/db-backup.yml, age-encrypted, 14-day retention. Alternatively re-ingest: Lens data is re-derivable from chain (docs/DEPLOY_MAINNET.md), bounded only by how far back the cursors point. - Config: env vars live in the host (Render dashboard /
.env), not in the repo. Keep the previous env set alongside the previous image — a code rollback with the new env is only half a rollback. - Verify the rollback:
curl -s https://<host>/ready # 200 {"ok":true,...} curl -s https://<host>/status # ok:true, lag small curl -s -o /dev/null -w '%{http_code}\n' https://<host>/price/XLM/USDC # 200 (ungated) or 402 (x402-gated) — not 5xx curl -s https://<host>/metrics | grep -E 'trades_ingested_total|last_trade_timestamp' SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https://<host>
The PR gate is .github/workflows/ci.yml; run it locally before opening a PR
(identical to CONTRIBUTING's "Before opening a PR"):
npm ci
node node_modules/prisma/build/index.js generate
npx tsc --noEmit
npm run build
npm test --if-presentLoad test (prices) is failing on main and is a known, unrelated red.
It runs daily at 04:00 UTC (load.yml, k6 threshold p95 < 80 ms @ 5k RPS) and
has been failing continuously — it is not your change that broke it. Every
other check (CI build/typecheck/tests, release, OpenAPI publish, nightly
backup) should be green; treat those as the gate.
# Health
curl -s localhost:3002/ready | jq # readiness: boot + dependencies
curl -s localhost:3002/live | jq # liveness: event loop only
curl -s localhost:3002/status | jq # ingestion lag
curl -s localhost:3002/status?network=mainnet # the other network
curl -s localhost:3002/metrics | grep -E 'trades_ingested_total|last_trade_timestamp'
# Logs & restart (compose)
docker compose logs -f lens | grep -E 'fatal|failed|crashed|redis'
docker compose restart lens
# Deployed smoke check (see README for the x402 caveat)
SMOKE_MAX_AGE_SECONDS=120 npm run smoke -- https://<host>
# Tests
npm test # full vitest suite
npx vitest run src/__tests__/status.test.ts # one fileRelated docs: architecture.md ·
http-metrics.md (label reference, alert rules) ·
ingest-metrics.md (staleness alert, cardinality) ·
backup-restore.md (nightly dump, restore) ·
DEPLOY_MAINNET.md (Render/Neon production shape) ·
DUAL_NETWORK.md (testnet + mainnet side-by-side)