StellarRoute uses PostgreSQL to store SDEX orderbook data, trading pairs, and historical snapshots. The schema is designed for high performance with proper normalization and strategic denormalization where needed.
To support a single read surface for routing and quoting, Phase 1.5 adds:
amm_pool_reserves: latest indexed AMM reserve state per pool and pairnormalized_liquidity(view):union allprojection oversdex_offersandamm_pool_reserves
The unified shape is:
venue_type(sdexoramm)venue_ref(offer ID or AMM pool address)selling_asset_idbuying_asset_idpriceavailable_amountsource_ledgerupdated_at
Backward compatibility:
- Existing orderbook reads can continue to query
sdex_offersunchanged. - Quote/routing reads can move to
normalized_liquiditywithout changing request/response contracts.
┌─────────────────────┐
│ assets │
├─────────────────────┤
│ id (PK) │
│ asset_type │
│ asset_code │
│ asset_issuer │
│ created_at │
└─────────────────────┘
│ 1
│
│ N
▼
┌─────────────────────────────────┐ ┌─────────────────────────┐
│ sdex_offers │ │ trading_pairs │
├─────────────────────────────────┤ ├─────────────────────────┤
│ offer_id (PK) │ │ id (PK) │
│ seller │ │ base_asset_id (FK) │───┐
│ selling_asset_id (FK) │───┐ │ counter_asset_id (FK) │───┤
│ buying_asset_id (FK) │───┤ │ is_active │ │
│ amount │ │ │ total_offers │ │
│ price │ │ │ total_volume │ │
│ price_n │ │ │ last_trade_at │ │
│ price_d │ │ │ created_at │ │
│ last_modified_ledger │ │ │ updated_at │ │
│ paging_token │ │ └─────────────────────────┘ │
│ updated_at │ │ │ 1 │
└─────────────────────────────────┘ │ │ │
│ │ │ N │
│ │ ▼ │
│ │ ┌─────────────────────────┐ │
│ └────▶│ orderbook_snapshots │ │
│ ├─────────────────────────┤ │
│ │ id (PK) │ │
│ │ trading_pair_id (FK) │ │
▼ │ snapshot_time │ │
┌─────────────────────────┐ │ bids (JSONB) │ │
│ archived_offers │ │ asks (JSONB) │ │
├─────────────────────────┤ │ bid_count │ │
│ offer_id (PK) │ │ ask_count │ │
│ seller │ │ spread │ │
│ selling_asset_type │ │ mid_price │ │
│ selling_asset_code │ │ total_bid_volume │ │
│ selling_asset_issuer │ │ total_ask_volume │ │
│ buying_asset_type │ │ ledger_sequence │ │
│ buying_asset_code │ │ created_at │ │
│ buying_asset_issuer │ └─────────────────────────┘ │
│ amount │ │
│ price │ │
│ price_n │ │
│ price_d │ │
│ last_modified_ledger │ │
│ archived_at │ │
│ archive_reason │ │
└─────────────────────────┘ │
│
┌─────────────────────────┐ │
│ ingestion_state │ │
├─────────────────────────┤ │
│ key (PK) │ │
│ value │ │
│ updated_at │ │
└─────────────────────────┘ │
│
┌─────────────────────────┐ │
│ db_health_metrics │ │
├─────────────────────────┤ │
│ id (PK) │ │
│ metric_name │ │
│ metric_value │ │
│ metric_unit │ │
│ metadata (JSONB) │ │
│ recorded_at │ │
└─────────────────────────┘ │
│
Referenced by all tables ──────────────────────┘
Stores normalized asset information for all assets traded on SDEX.
Columns:
id(UUID, PK): Unique identifierasset_type(TEXT): Type of asset - "native", "credit_alphanum4", or "credit_alphanum12"asset_code(TEXT, nullable): Asset code (e.g., "USDC", "BTC")asset_issuer(TEXT, nullable): Stellar address of asset issuercreated_at(TIMESTAMPTZ): Record creation timestamp
Constraints:
- Unique constraint on (asset_type, asset_code, asset_issuer)
Indexes:
idx_assets_type: On asset_typeidx_assets_code: On asset_code (partial, where not null)idx_assets_issuer: On asset_issuer (partial, where not null)
Main table storing active SDEX offers from Stellar Horizon.
Columns:
offer_id(BIGINT, PK): Horizon offer IDseller(TEXT): Stellar account address of sellerselling_asset_id(UUID, FK → assets): Asset being soldbuying_asset_id(UUID, FK → assets): Asset being boughtamount(NUMERIC(30,14)): Amount of selling assetprice(NUMERIC(30,14)): Price ratio (buying/selling)price_n(BIGINT): Price numeratorprice_d(BIGINT): Price denominatorlast_modified_ledger(BIGINT): Ledger sequence when offer was last modifiedpaging_token(TEXT, nullable): Horizon pagination tokenupdated_at(TIMESTAMPTZ): Last update timestamp
Foreign Keys:
selling_asset_id→ assets(id)buying_asset_id→ assets(id)
Indexes:
idx_sdex_offers_pair: On (selling_asset_id, buying_asset_id)idx_sdex_offers_seller: On selleridx_sdex_offers_ledger: On last_modified_ledger DESCidx_sdex_offers_updated_at: On updated_at DESCidx_sdex_offers_seller_pair: On (seller, selling_asset_id, buying_asset_id)idx_sdex_offers_price: On (selling_asset_id, buying_asset_id, price)
Query Patterns:
- Find offers for specific trading pair
- Get best price for pair
- List offers by seller
- Find recent offers by ledger or timestamp
Tracks active trading pairs with aggregated statistics.
Columns:
id(UUID, PK): Unique identifierbase_asset_id(UUID, FK → assets): Base asset in the paircounter_asset_id(UUID, FK → assets): Counter/quote assetis_active(BOOLEAN): Whether pair is currently activetotal_offers(INTEGER): Current number of active offerstotal_volume(NUMERIC(30,14)): Cumulative trading volumelast_trade_at(TIMESTAMPTZ, nullable): Last trade timestampcreated_at(TIMESTAMPTZ): Record creation timestampupdated_at(TIMESTAMPTZ): Last update timestamp
Constraints:
- Unique constraint on (base_asset_id, counter_asset_id)
- Check constraint: base_asset_id != counter_asset_id
Foreign Keys:
base_asset_id→ assets(id)counter_asset_id→ assets(id)
Indexes:
idx_trading_pairs_base: On base_asset_ididx_trading_pairs_counter: On counter_asset_ididx_trading_pairs_active: On (is_active, updated_at DESC)idx_trading_pairs_volume: On total_volume DESC (partial, where is_active)
Query Patterns:
- List all active trading pairs
- Find pairs by asset
- Get most traded pairs by volume
Historical point-in-time snapshots of orderbooks for analytics.
Columns:
id(UUID, PK): Unique identifiertrading_pair_id(UUID, FK → trading_pairs): Associated trading pairsnapshot_time(TIMESTAMPTZ): Time of snapshotbids(JSONB): Array of bid orders with price/amountasks(JSONB): Array of ask orders with price/amountbid_count(INTEGER): Number of bid ordersask_count(INTEGER): Number of ask ordersspread(NUMERIC(30,14), nullable): Bid-ask spreadmid_price(NUMERIC(30,14), nullable): Mid-market pricetotal_bid_volume(NUMERIC(30,14)): Total bid volumetotal_ask_volume(NUMERIC(30,14)): Total ask volumeledger_sequence(BIGINT): Stellar ledger sequence numbercreated_at(TIMESTAMPTZ): Record creation timestamp
Constraints:
- Check constraint: bid_count >= 0 AND ask_count >= 0
- ON DELETE CASCADE with trading_pairs
Foreign Keys:
trading_pair_id→ trading_pairs(id) ON DELETE CASCADE
Indexes:
idx_orderbook_snapshots_pair_time: On (trading_pair_id, snapshot_time DESC)idx_orderbook_snapshots_time: On snapshot_time DESCidx_orderbook_snapshots_ledger: On ledger_sequence DESC
JSONB Structure:
{
"bids": [
{ "price": "1.5000", "amount": "100.00", "offer_id": 12345 },
{ "price": "1.4900", "amount": "250.00", "offer_id": 12346 }
],
"asks": [
{ "price": "1.5100", "amount": "150.00", "offer_id": 12347 },
{ "price": "1.5200", "amount": "200.00", "offer_id": 12348 }
]
}Query Patterns:
- Get latest snapshot for trading pair
- Historical price analysis
- Volume trends over time
- Spread analysis
Price history contract:
- The frontend sparkline reads from
GET /api/v1/price-history/{base}/{quote}. - The API aggregates
orderbook_snapshots.mid_priceinto hourly buckets over the trailing 24 hours. - An empty
pointsarray means the pair exists but no usable historical snapshots were available in the window. - The contract intentionally favors compact payloads so the chart stays lightweight on low-end devices.
Archive table for old/inactive offers to maintain main table performance.
Purpose: Keeps sdex_offers table lean by moving historical data
Columns: Similar to sdex_offers but denormalized (includes asset types directly)
Indexes:
idx_archived_offers_archived_at: On archived_at DESCidx_archived_offers_seller: On seller
Tracks indexer state for resumable synchronization.
Columns:
key(TEXT, PK): State key (e.g., "last_cursor", "last_ledger")value(TEXT): State valueupdated_at(TIMESTAMPTZ): Last update timestamp
Stores database health and performance metrics over time.
Columns:
id(UUID, PK): Unique identifiermetric_name(TEXT): Name of metricmetric_value(NUMERIC): Metric valuemetric_unit(TEXT, nullable): Unit (count, bytes, ms)metadata(JSONB, nullable): Additional contextrecorded_at(TIMESTAMPTZ): Recording timestamp
Indexes:
idx_db_health_metrics_recorded_at: On recorded_at DESCidx_db_health_metrics_name: On (metric_name, recorded_at DESC)
Denormalized view joining offers with asset information for easier querying.
SELECT
o.offer_id, o.seller,
sa.asset_type as selling_asset_type,
sa.asset_code as selling_asset_code,
sa.asset_issuer as selling_asset_issuer,
ba.asset_type as buying_asset_type,
ba.asset_code as buying_asset_code,
ba.asset_issuer as buying_asset_issuer,
o.amount, o.price, o.price_n, o.price_d,
o.last_modified_ledger, o.updated_at
FROM sdex_offers o
JOIN assets sa ON o.selling_asset_id = sa.id
JOIN assets ba ON o.buying_asset_id = ba.id;Most recent snapshot for each trading pair.
SELECT DISTINCT ON (trading_pair_id)
s.*, tp.base_asset_id, tp.counter_asset_id
FROM orderbook_snapshots s
JOIN trading_pairs tp ON s.trading_pair_id = tp.id
ORDER BY trading_pair_id, snapshot_time DESC;Pre-aggregated orderbook statistics for fast queries.
Columns:
- selling_asset_id, buying_asset_id
- offer_count, min_price, max_price, avg_price
- total_amount, last_updated
Refresh: Call refresh_orderbook_summary() function or use REFRESH MATERIALIZED VIEW CONCURRENTLY
Captures a point-in-time snapshot of an orderbook.
Returns: UUID of created snapshot
Logic:
- Gets or creates trading_pair record
- Collects bids (base → counter offers)
- Collects asks (counter → base offers, inverted)
- Calculates spread and mid-price
- Inserts snapshot with JSONB data
- Updates trading_pair statistics
Archives offers older than specified days.
Returns: Count of archived offers
Default: 30 days
Removes old snapshot records to manage storage.
Returns: Count of deleted snapshots
Default: 7 days
Returns current database health metrics.
Returns Table:
- metric_name (TEXT)
- metric_value (NUMERIC)
- metric_unit (TEXT)
Metrics Provided:
- total_offers, total_assets, total_archived_offers
- Table sizes (bytes)
- Database size
- 0001_init.sql - Core schema (assets, sdex_offers, ingestion_state)
- 0002_performance_indexes.sql - Performance indexes, archival, health metrics
- 0003_trading_pairs_and_snapshots.sql - Trading pairs and orderbook snapshots
Run migrations:
sqlx migrate run --database-url postgresql://user:pass@localhost/stellarrouteAll Postgres schema changes must follow the zero-downtime expand/contract
pattern documented in
docs/deployment/migration-runbook.md.
Migrations live in crates/api/migrations and crates/indexer/migrations and
are applied with sqlx migrate run.
- Composite indexes for common multi-column queries
- Partial indexes on nullable columns to reduce size
- Descending indexes for time-series queries
- Covering indexes where beneficial
- Active offers: Keep in main table
- Old offers: Archive after 30 days (configurable)
- Snapshots: Keep 7 days (configurable)
- Metrics: Retain based on monitoring needs
- Use materialized views for expensive aggregations
- Leverage views for common denormalized queries
- JSONB for flexible nested data (orderbook snapshots)
- Numeric(30,14) for precise financial calculations
Regular tasks:
-- Refresh materialized view
SELECT refresh_orderbook_summary();
-- Archive old offers
SELECT archive_old_offers(30);
-- Clean old snapshots
SELECT cleanup_old_snapshots(7);
-- Check health
SELECT * FROM get_db_health_metrics();SELECT * FROM active_offers
WHERE selling_asset_id = 'uuid-of-xlm'
AND buying_asset_id = 'uuid-of-usdc'
ORDER BY price ASC;SELECT price, amount FROM sdex_offers
WHERE selling_asset_id = $1 AND buying_asset_id = $2
ORDER BY price ASC
LIMIT 1;SELECT * FROM latest_orderbook_snapshots
WHERE base_asset_id = $1 AND counter_asset_id = $2;SELECT
tp.*,
ba.asset_code as base_code,
ca.asset_code as counter_code
FROM trading_pairs tp
JOIN assets ba ON tp.base_asset_id = ba.id
JOIN assets ca ON tp.counter_asset_id = ca.id
WHERE tp.is_active = true
ORDER BY tp.total_volume DESC;