feat(db): add SQL query wrapper for PostgreSQL placeholder conversion

- Created query_wrapper module with functions to automatically convert
  SQLite ? placeholders to PostgreSQL $1, $2, ... format
- Updated build.rs to handle mutually exclusive feature flags
- Modified one query in mixnodes.rs as proof of concept
- Added type conversions for PostgreSQL compatibility (u32->i64, u16->i32)

This is a checkpoint commit before converting all queries to use the wrapper.
This commit is contained in:
durch
2025-07-03 22:49:22 +02:00
parent 84e10a654c
commit 76d749baaf
77 changed files with 1074 additions and 1006 deletions
@@ -0,0 +1,32 @@
# Example environment variables for nym-node-status-api
# Database configuration
# For SQLite:
# DATABASE_URL=sqlite://nym-node-status-api.sqlite
# For PostgreSQL:
# DATABASE_URL=postgres://testuser:testpass@localhost:5433/nym_node_status_api_test
# Network configuration
NETWORK_NAME=sandbox
NYM_API=https://sandbox-nym-api1.nymtech.net/api
NYXD=https://rpc.sandbox.nymtech.net
# API configuration
NYM_NODE_STATUS_API_HTTP_PORT=8000
NYM_API_CLIENT_TIMEOUT=15
SQLX_BUSY_TIMEOUT_S=5
# Monitoring intervals
NODE_STATUS_API_MONITOR_REFRESH_INTERVAL=300
NODE_STATUS_API_TESTRUN_REFRESH_INTERVAL=300
NODE_STATUS_API_GEODATA_TTL=86400
# Agent keys (comma-separated list)
NODE_STATUS_API_AGENT_KEY_LIST=
# External service tokens
IPINFO_API_TOKEN=your_token_here
# MixNodes (Optional)
DATA_PROVIDER_DELEGATION_LIST=
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO mixnode_daily_stats (\n mix_id, date_utc,\n total_stake, packets_received,\n packets_sent, packets_dropped\n ) VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(mix_id, date_utc) DO UPDATE SET\n total_stake = excluded.total_stake,\n packets_received = mixnode_daily_stats.packets_received + excluded.packets_received,\n packets_sent = mixnode_daily_stats.packets_sent + excluded.packets_sent,\n packets_dropped = mixnode_daily_stats.packets_dropped + excluded.packets_dropped\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "01ee4a30bc3104712e5bc371a45d614a89d88adf02358800433e06100c13c548"
}
@@ -1,26 +1,26 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "\n SELECT mix_id as node_id, host, http_api_port\n FROM mixnodes\n WHERE bonded = true\n ",
"describe": {
"columns": [
{
"name": "node_id",
"ordinal": 0,
"type_info": "Integer"
"name": "node_id",
"type_info": "Int8"
},
{
"name": "host",
"ordinal": 1,
"type_info": "Text"
"name": "host",
"type_info": "Varchar"
},
{
"name": "http_api_port",
"ordinal": 2,
"type_info": "Integer"
"name": "http_api_port",
"type_info": "Int8"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false,
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO mixnode_description (\n mix_id, moniker, website, security_contact, details, last_updated_utc\n ) VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT (mix_id) DO UPDATE SET\n moniker = excluded.moniker,\n website = excluded.website,\n security_contact = excluded.security_contact,\n details = excluded.details,\n last_updated_utc = excluded.last_updated_utc\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "06065394c157927e4002ddd5c7c1af626ae15728d615f539470cd7c189312385"
}
@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT\n gateway_identity_key\n FROM\n gateways\n WHERE\n id = ?",
"describe": {
"columns": [
{
"name": "gateway_identity_key",
"ordinal": 0,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "06b17d1e5f61201a1b7542896ba55c69cd5c1a7e7d87073c94600c783a0a3984"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE\n testruns\n SET\n status = ?\n WHERE\n status = ?\n AND\n last_assigned_utc < ?\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "0cf0e4d4f30e90caecffd6255ef85dab12730e538be194438f19ed7f198bd50e"
}
@@ -1,43 +1,43 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "\n SELECT\n date_utc as \"date_utc!\",\n SUM(total_stake) as \"total_stake!: i64\",\n SUM(packets_received) as \"total_packets_received!: i64\",\n SUM(packets_sent) as \"total_packets_sent!: i64\",\n SUM(packets_dropped) as \"total_packets_dropped!: i64\"\n FROM (\n SELECT\n date_utc,\n n.total_stake,\n n.packets_received,\n n.packets_sent,\n n.packets_dropped\n FROM nym_node_daily_mixing_stats n\n UNION ALL\n SELECT\n m.date_utc,\n m.total_stake,\n m.packets_received,\n m.packets_sent,\n m.packets_dropped\n FROM mixnode_daily_stats m\n LEFT JOIN nym_node_daily_mixing_stats ON m.mix_id = nym_node_daily_mixing_stats.node_id\n WHERE nym_node_daily_mixing_stats.node_id IS NULL\n )\n GROUP BY date_utc\n ORDER BY date_utc ASC\n ",
"describe": {
"columns": [
{
"name": "date_utc!",
"ordinal": 0,
"type_info": "Text"
"name": "date_utc!",
"type_info": "Varchar"
},
{
"name": "total_stake!: i64",
"ordinal": 1,
"type_info": "Integer"
"name": "total_stake!: i64",
"type_info": "Numeric"
},
{
"name": "total_packets_received!: i64",
"ordinal": 2,
"type_info": "Integer"
"name": "total_packets_received!: i64",
"type_info": "Int8"
},
{
"name": "total_packets_sent!: i64",
"ordinal": 3,
"type_info": "Integer"
"name": "total_packets_sent!: i64",
"type_info": "Int8"
},
{
"name": "total_packets_dropped!: i64",
"ordinal": 4,
"type_info": "Integer"
"name": "total_packets_dropped!: i64",
"type_info": "Int8"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false,
false,
true,
true,
true
null,
null,
null,
null,
null
]
},
"hash": "124d45b9604439584650f401607c46bdbd162c7c689f74fe9ddfdfd48f5ddc07"
@@ -1,29 +1,29 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "SELECT\n key as \"key!\",\n value_json as \"value_json!\",\n last_updated_utc as \"last_updated_utc!\"\n FROM summary",
"describe": {
"columns": [
{
"name": "key!",
"ordinal": 0,
"type_info": "Text"
"name": "key!",
"type_info": "Varchar"
},
{
"name": "value_json!",
"ordinal": 1,
"type_info": "Text"
"name": "value_json!",
"type_info": "Varchar"
},
{
"name": "last_updated_utc!",
"ordinal": 2,
"type_info": "Integer"
"name": "last_updated_utc!",
"type_info": "Int8"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
true,
false,
true,
false
]
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO mixnode_packet_stats_raw (\n mix_id, timestamp_utc, packets_received, packets_sent, packets_dropped\n ) VALUES (?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "21e44766729777756f6eb04bf3b81df3e591008a1e3fd664ed83ca86ac51bd8c"
}
@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n id,\n gateway_identity_key\n FROM gateways\n WHERE id = ?\n LIMIT 1",
"describe": {
"columns": [
{
"name": "id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "gateway_identity_key",
"ordinal": 1,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false
]
},
"hash": "2236299f9f691376db54cbd58ec5ceb89b9925cba46efcf4ed79ef0759a01129"
}
@@ -1,21 +1,21 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "SELECT\n node_id,\n bond_info as \"bond_info: serde_json::Value\"\n FROM\n nym_nodes\n WHERE\n bond_info IS NOT NULL\n AND\n self_described IS NOT NULL\n ",
"describe": {
"columns": [
{
"name": "node_id",
"ordinal": 0,
"type_info": "Integer"
"name": "node_id",
"type_info": "Int4"
},
{
"name": "bond_info: serde_json::Value",
"ordinal": 1,
"name": "bond_info: serde_json::Value",
"type_info": "Text"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false,
@@ -1,16 +1,16 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "\n SELECT gateway_identity_key\n FROM gateways\n WHERE bonded = true\n ",
"describe": {
"columns": [
{
"name": "gateway_identity_key",
"ordinal": 0,
"type_info": "Text"
"name": "gateway_identity_key",
"type_info": "Varchar"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false
@@ -1,71 +1,71 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "SELECT\n node_id,\n ed25519_identity_pubkey,\n total_stake,\n ip_addresses as \"ip_addresses!: serde_json::Value\",\n mix_port,\n x25519_sphinx_pubkey,\n node_role as \"node_role: serde_json::Value\",\n supported_roles as \"supported_roles: serde_json::Value\",\n entry as \"entry: serde_json::Value\",\n performance,\n self_described as \"self_described: serde_json::Value\",\n bond_info as \"bond_info: serde_json::Value\"\n FROM\n nym_nodes\n WHERE\n self_described IS NOT NULL\n AND\n bond_info IS NOT NULL\n ",
"describe": {
"columns": [
{
"name": "node_id",
"ordinal": 0,
"type_info": "Integer"
"name": "node_id",
"type_info": "Int4"
},
{
"name": "ed25519_identity_pubkey",
"ordinal": 1,
"type_info": "Text"
"name": "ed25519_identity_pubkey",
"type_info": "Varchar"
},
{
"name": "total_stake",
"ordinal": 2,
"type_info": "Integer"
"name": "total_stake",
"type_info": "Int8"
},
{
"name": "ip_addresses!: serde_json::Value",
"ordinal": 3,
"name": "ip_addresses!: serde_json::Value",
"type_info": "Text"
},
{
"name": "mix_port",
"ordinal": 4,
"type_info": "Integer"
"name": "mix_port",
"type_info": "Int4"
},
{
"name": "x25519_sphinx_pubkey",
"ordinal": 5,
"type_info": "Text"
"name": "x25519_sphinx_pubkey",
"type_info": "Varchar"
},
{
"name": "node_role: serde_json::Value",
"ordinal": 6,
"name": "node_role: serde_json::Value",
"type_info": "Text"
},
{
"name": "supported_roles: serde_json::Value",
"ordinal": 7,
"name": "supported_roles: serde_json::Value",
"type_info": "Text"
},
{
"name": "entry: serde_json::Value",
"ordinal": 8,
"name": "entry: serde_json::Value",
"type_info": "Text"
},
{
"name": "performance",
"ordinal": 9,
"type_info": "Text"
"name": "performance",
"type_info": "Varchar"
},
{
"name": "self_described: serde_json::Value",
"ordinal": 10,
"name": "self_described: serde_json::Value",
"type_info": "Text"
},
{
"name": "bond_info: serde_json::Value",
"ordinal": 11,
"name": "bond_info: serde_json::Value",
"type_info": "Text"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false,
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT OR IGNORE INTO gateway_session_stats\n (gateway_identity_key, node_id, day,\n unique_active_clients, session_started, users_hashes,\n vpn_sessions, mixnet_sessions, unknown_sessions)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 9
},
"nullable": []
},
"hash": "3243cf5646255a9430d1e6710970505d0dbcc62703f40e090e80ff48c77723c4"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO mixnodes\n (mix_id, identity_key, bonded, total_stake,\n host, http_api_port, full_details,\n self_described, last_updated_utc, is_dp_delegatee)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(mix_id) DO UPDATE SET\n bonded=excluded.bonded,\n total_stake=excluded.total_stake, host=excluded.host,\n http_api_port=excluded.http_api_port,\n full_details=excluded.full_details,self_described=excluded.self_described,\n last_updated_utc=excluded.last_updated_utc,\n is_dp_delegatee = excluded.is_dp_delegatee;",
"describe": {
"columns": [],
"parameters": {
"Right": 10
},
"nullable": []
},
"hash": "3cd5cb4bfca4243925da4ddbccd811e842090e98982e1032670df77961870b32"
}
@@ -1,38 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT\n id as \"id!\",\n gateway_identity_key as \"gateway_identity_key!\",\n self_described as \"self_described?\",\n explorer_pretty_bond as \"explorer_pretty_bond?\"\n FROM gateways\n WHERE gateway_identity_key = ?\n AND bonded = true\n ORDER BY gateway_identity_key\n LIMIT 1",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "gateway_identity_key!",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "self_described?",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "explorer_pretty_bond?",
"ordinal": 3,
"type_info": "Text"
}
],
"parameters": {
"Right": 1
},
"nullable": [
true,
false,
false,
true
]
},
"hash": "3e7e987780937873cdb393b157d7708c9f01047b0689eb0d4f7a973b328c609d"
}
@@ -1,92 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT\n gw.gateway_identity_key as \"gateway_identity_key!\",\n gw.bonded as \"bonded: bool\",\n gw.performance as \"performance!\",\n gw.self_described as \"self_described?\",\n gw.explorer_pretty_bond as \"explorer_pretty_bond?\",\n gw.last_probe_result as \"last_probe_result?\",\n gw.last_probe_log as \"last_probe_log?\",\n gw.last_testrun_utc as \"last_testrun_utc?\",\n gw.last_updated_utc as \"last_updated_utc!\",\n COALESCE(gd.moniker, \"NA\") as \"moniker!\",\n COALESCE(gd.website, \"NA\") as \"website!\",\n COALESCE(gd.security_contact, \"NA\") as \"security_contact!\",\n COALESCE(gd.details, \"NA\") as \"details!\"\n FROM gateways gw\n LEFT JOIN gateway_description gd\n ON gw.gateway_identity_key = gd.gateway_identity_key\n ORDER BY gw.gateway_identity_key",
"describe": {
"columns": [
{
"name": "gateway_identity_key!",
"ordinal": 0,
"type_info": "Text"
},
{
"name": "bonded: bool",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "performance!",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "self_described?",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "explorer_pretty_bond?",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "last_probe_result?",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "last_probe_log?",
"ordinal": 6,
"type_info": "Text"
},
{
"name": "last_testrun_utc?",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "last_updated_utc!",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "moniker!",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "website!",
"ordinal": 10,
"type_info": "Text"
},
{
"name": "security_contact!",
"ordinal": 11,
"type_info": "Text"
},
{
"name": "details!",
"ordinal": 12,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
true,
true,
true,
true,
false,
false,
false,
false,
false
]
},
"hash": "3eb1d8491bda3c1d6e071b6eb364b9a979f4bdb11ea81b2d0f022555bab51ecb"
}
@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n total_stake\n FROM mixnodes\n WHERE mix_id = ?\n ",
"describe": {
"columns": [
{
"name": "total_stake",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "3fc2baabf194b147b20be2a49401cc0c100a1d7a7c347393adde2410fa6f4dfe"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE testruns SET status = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "418944f2eccb838cb3882f34469203c8569f03fdd39ce09d7b74177896e52a8c"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE gateways SET last_probe_log = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "4afcc6673890f795c2793f1e2f8570ee787fc7daf00fcb916f18d1cb7d6c8f08"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO testruns (gateway_id, status, ip_address, created_utc, log) VALUES (?, ?, ?, ?, ?)",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "4d865e873c9cb133883da94db72dcdebd4969e1f240def9fb1bf946f4a1f342f"
}
@@ -0,0 +1,22 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO gateway_session_stats\n (gateway_identity_key, node_id, day,\n unique_active_clients, session_started, users_hashes,\n vpn_sessions, mixnet_sessions, unknown_sessions)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ON CONFLICT DO NOTHING",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Int8",
"Date",
"Int8",
"Int8",
"Varchar",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "4fca38abbb416d9457c34a8ba4faf481a837eda4f3e1bee1d430a4eb102a5b3d"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO nym_node_daily_mixing_stats (\n node_id, date_utc,\n total_stake, packets_received,\n packets_sent, packets_dropped\n ) VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(node_id, date_utc) DO UPDATE SET\n total_stake = excluded.total_stake,\n packets_received = nym_node_daily_mixing_stats.packets_received + excluded.packets_received,\n packets_sent = nym_node_daily_mixing_stats.packets_sent + excluded.packets_sent,\n packets_dropped = nym_node_daily_mixing_stats.packets_dropped + excluded.packets_dropped\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "5912ea335a957d217f5e2b3a63a25b31715c2098310fe7a9db688bc2fd36aad4"
}
@@ -1,26 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE testruns\n SET\n status = ?,\n last_assigned_utc = ?\n WHERE rowid =\n (\n SELECT rowid\n FROM testruns\n WHERE status = ?\n ORDER BY created_utc asc\n LIMIT 1\n )\n RETURNING\n id as \"id!\",\n gateway_id\n ",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "gateway_id",
"ordinal": 1,
"type_info": "Integer"
}
],
"parameters": {
"Right": 3
},
"nullable": [
false,
false
]
},
"hash": "5e9cbb39f5fb53774803270f422989e199aac4d4a71913c7074359b4bd676b02"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO nym_nodes\n (node_id, ed25519_identity_pubkey,\n total_stake,\n ip_addresses, mix_port,\n x25519_sphinx_pubkey, node_role,\n supported_roles, entry,\n self_described,\n bond_info,\n performance, last_updated_utc\n )\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT(node_id) DO UPDATE SET\n ed25519_identity_pubkey=excluded.ed25519_identity_pubkey,\n ip_addresses=excluded.ip_addresses,\n mix_port=excluded.mix_port,\n x25519_sphinx_pubkey=excluded.x25519_sphinx_pubkey,\n node_role=excluded.node_role,\n supported_roles=excluded.supported_roles,\n entry=excluded.entry,\n self_described=excluded.self_described,\n bond_info=excluded.bond_info,\n performance=excluded.performance,\n last_updated_utc=excluded.last_updated_utc\n ;",
"describe": {
"columns": [],
"parameters": {
"Right": 13
},
"nullable": []
},
"hash": "664e059ac2c58e1115fe214376a6b326b31c93298f20019772cce2e277a194f8"
}
@@ -1,19 +1,19 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "SELECT count(id) FROM mixnodes",
"describe": {
"columns": [
{
"name": "count(id)",
"ordinal": 0,
"type_info": "Integer"
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false
null
]
},
"hash": "670b7ed7d57a6986181b24be24ca667e8cacdf677ccb906415b3fe92be0c436b"
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO nym_node_descriptions (\n node_id, moniker, website, security_contact, details, last_updated_utc\n ) VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT (node_id) DO UPDATE SET\n moniker = excluded.moniker,\n website = excluded.website,\n security_contact = excluded.security_contact,\n details = excluded.details,\n last_updated_utc = excluded.last_updated_utc\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "6a9780aad1f2f0c8ef780e51fe856679d5e28f95143f4e2a2b409009dc0f55ba"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE gateways SET last_probe_result = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 2
},
"nullable": []
},
"hash": "6ef3efde571d46961244cd90420f3de5949a5ff2083453cb879af8a1689efe2f"
}
@@ -1,34 +1,34 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "SELECT\n id as \"id!\",\n date as \"date!\",\n timestamp_utc as \"timestamp_utc!\",\n value_json as \"value_json!\"\n FROM summary_history\n ORDER BY date DESC\n LIMIT 30",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Integer"
"name": "id!",
"type_info": "Int4"
},
{
"name": "date!",
"ordinal": 1,
"type_info": "Text"
"name": "date!",
"type_info": "Varchar"
},
{
"name": "timestamp_utc!",
"ordinal": 2,
"type_info": "Integer"
"name": "timestamp_utc!",
"type_info": "Int8"
},
{
"name": "value_json!",
"ordinal": 3,
"type_info": "Text"
"name": "value_json!",
"type_info": "Varchar"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
true,
false,
false,
false,
true
@@ -1,32 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n COALESCE(packets_received, 0) as \"packets_received!: _\",\n COALESCE(packets_sent, 0) as \"packets_sent!: _\",\n COALESCE(packets_dropped, 0) as \"packets_dropped!: _\"\n FROM mixnode_packet_stats_raw\n WHERE mix_id = ?\n ORDER BY timestamp_utc DESC\n LIMIT 1 OFFSET 1\n ",
"describe": {
"columns": [
{
"name": "packets_received!: _",
"ordinal": 0,
"type_info": "Null"
},
{
"name": "packets_sent!: _",
"ordinal": 1,
"type_info": "Null"
},
{
"name": "packets_dropped!: _",
"ordinal": 2,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
null,
null,
null
]
},
"hash": "786b6a5d954e38b204cebf322711c74c8cf1c08e5e2896a1d6d5b85c91991753"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO summary_history\n (date, timestamp_utc, value_json)\n VALUES (?, ?, ?)\n ON CONFLICT(date) DO UPDATE SET\n timestamp_utc=excluded.timestamp_utc,\n value_json=excluded.value_json;",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "788515c34588aec352773df4b6e6c5e41f3c0bb56a27648b5e25466b8634a578"
}
@@ -1,19 +1,19 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "SELECT count(id) FROM gateways",
"describe": {
"columns": [
{
"name": "count(id)",
"ordinal": 0,
"type_info": "Integer"
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false
null
]
},
"hash": "86ff64db477a1d6235179b0b88d86b86d1b9be62336c9eac0eef44987a5451b5"
@@ -1,32 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n COALESCE(packets_received, 0) as \"packets_received!: _\",\n COALESCE(packets_sent, 0) as \"packets_sent!: _\",\n COALESCE(packets_dropped, 0) as \"packets_dropped!: _\"\n FROM nym_nodes_packet_stats_raw\n WHERE node_id = ?\n ORDER BY timestamp_utc DESC\n LIMIT 1 OFFSET 1\n ",
"describe": {
"columns": [
{
"name": "packets_received!: _",
"ordinal": 0,
"type_info": "Null"
},
{
"name": "packets_sent!: _",
"ordinal": 1,
"type_info": "Null"
},
{
"name": "packets_dropped!: _",
"ordinal": 2,
"type_info": "Null"
}
],
"parameters": {
"Right": 1
},
"nullable": [
null,
null,
null
]
},
"hash": "8bdf85a61e443fa5f4835bffd0bffc8ed1011f56714fde6007e50951e569854b"
}
@@ -1,21 +1,21 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "SELECT\n gateway_identity_key as \"gateway_identity_key!\",\n bonded as \"bonded: bool\"\n FROM gateways\n ORDER BY last_testrun_utc",
"describe": {
"columns": [
{
"name": "gateway_identity_key!",
"ordinal": 0,
"type_info": "Text"
"name": "gateway_identity_key!",
"type_info": "Varchar"
},
{
"name": "bonded: bool",
"ordinal": 1,
"type_info": "Integer"
"name": "bonded: bool",
"type_info": "Bool"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false,
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO gateways\n (gateway_identity_key, bonded,\n self_described, explorer_pretty_bond,\n last_updated_utc, performance)\n VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT(gateway_identity_key) DO UPDATE SET\n bonded=excluded.bonded,\n self_described=excluded.self_described,\n explorer_pretty_bond=excluded.explorer_pretty_bond,\n last_updated_utc=excluded.last_updated_utc,\n performance = excluded.performance;",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "9796d354ae075eab4cbd3438839c39da94025494395ec7b093aefef696f2d0c5"
}
@@ -1,56 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT\n id as \"id!\",\n gateway_id as \"gateway_id!\",\n status as \"status!\",\n created_utc as \"created_utc!\",\n ip_address as \"ip_address!\",\n log as \"log!\",\n last_assigned_utc\n FROM testruns\n WHERE gateway_id = ? AND status != 2\n ORDER BY id DESC\n LIMIT 1",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "gateway_id!",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "status!",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "created_utc!",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "ip_address!",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "log!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "last_assigned_utc",
"ordinal": 6,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "a1d9eb816acd1a91ed0975c801c9295c01a78861a2a0597ad28b1579a14bf008"
}
@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT\n COUNT(id) as \"count: i64\"\n FROM testruns\n WHERE\n status = ?\n ",
"describe": {
"columns": [
{
"name": "count: i64",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "a79a61b87325f3f1d9c5a4fb386ccd585be0641e5878acb6283b879f22ed2b4c"
}
@@ -1,44 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT\n nd.node_id,\n moniker,\n website,\n security_contact,\n details\n FROM\n nym_node_descriptions nd\n INNER JOIN\n nym_nodes\n WHERE\n bond_info IS NOT NULL\n ",
"describe": {
"columns": [
{
"name": "node_id",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "moniker",
"ordinal": 1,
"type_info": "Text"
},
{
"name": "website",
"ordinal": 2,
"type_info": "Text"
},
{
"name": "security_contact",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "details",
"ordinal": 4,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
true,
true,
true,
true
]
},
"hash": "aa602604c0e7b6eef41ea3cd83c16610e15be2d7ee3f6c4d3debf23f95fb9c2e"
}
@@ -1,10 +1,10 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "UPDATE nym_nodes\n SET\n self_described = NULL,\n bond_info = NULL",
"describe": {
"columns": [],
"parameters": {
"Right": 0
"Left": []
},
"nullable": []
},
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "UPDATE gateways SET last_testrun_utc = ?, last_updated_utc = ? WHERE id = ?",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "c214c001acbbf79fa499816f36ec586c4c29c03efb4cf0c40b73a5c76159cf5c"
}
@@ -1,10 +1,10 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "UPDATE\n mixnodes\n SET\n bonded = false\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
"Left": []
},
"nullable": []
},
@@ -1,71 +1,71 @@
{
"db_name": "SQLite",
"query": "SELECT\n node_id,\n ed25519_identity_pubkey,\n total_stake,\n ip_addresses as \"ip_addresses!: serde_json::Value\",\n mix_port,\n x25519_sphinx_pubkey,\n node_role as \"node_role: serde_json::Value\",\n supported_roles as \"supported_roles: serde_json::Value\",\n entry as \"entry: serde_json::Value\",\n performance,\n self_described as \"self_described: serde_json::Value\",\n bond_info as \"bond_info: serde_json::Value\"\n FROM\n nym_nodes\n ",
"db_name": "PostgreSQL",
"query": "SELECT\n node_id,\n ed25519_identity_pubkey,\n total_stake,\n ip_addresses as \"ip_addresses!: serde_json::Value\",\n mix_port,\n x25519_sphinx_pubkey,\n node_role as \"node_role: serde_json::Value\",\n supported_roles as \"supported_roles: serde_json::Value\",\n entry as \"entry: serde_json::Value\",\n performance,\n self_described as \"self_described: serde_json::Value\",\n bond_info as \"bond_info: serde_json::Value\"\n FROM\n nym_nodes\n ORDER BY\n node_id\n ",
"describe": {
"columns": [
{
"name": "node_id",
"ordinal": 0,
"type_info": "Integer"
"name": "node_id",
"type_info": "Int4"
},
{
"name": "ed25519_identity_pubkey",
"ordinal": 1,
"type_info": "Text"
"name": "ed25519_identity_pubkey",
"type_info": "Varchar"
},
{
"name": "total_stake",
"ordinal": 2,
"type_info": "Integer"
"name": "total_stake",
"type_info": "Int8"
},
{
"name": "ip_addresses!: serde_json::Value",
"ordinal": 3,
"name": "ip_addresses!: serde_json::Value",
"type_info": "Text"
},
{
"name": "mix_port",
"ordinal": 4,
"type_info": "Integer"
"name": "mix_port",
"type_info": "Int4"
},
{
"name": "x25519_sphinx_pubkey",
"ordinal": 5,
"type_info": "Text"
"name": "x25519_sphinx_pubkey",
"type_info": "Varchar"
},
{
"name": "node_role: serde_json::Value",
"ordinal": 6,
"name": "node_role: serde_json::Value",
"type_info": "Text"
},
{
"name": "supported_roles: serde_json::Value",
"ordinal": 7,
"name": "supported_roles: serde_json::Value",
"type_info": "Text"
},
{
"name": "entry: serde_json::Value",
"ordinal": 8,
"name": "entry: serde_json::Value",
"type_info": "Text"
},
{
"name": "performance",
"ordinal": 9,
"type_info": "Text"
"name": "performance",
"type_info": "Varchar"
},
{
"name": "self_described: serde_json::Value",
"ordinal": 10,
"name": "self_described: serde_json::Value",
"type_info": "Text"
},
{
"name": "bond_info: serde_json::Value",
"ordinal": 11,
"name": "bond_info: serde_json::Value",
"type_info": "Text"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false,
@@ -82,5 +82,5 @@
true
]
},
"hash": "9334f0c91252fcd7ec72558a271222615bb282e5334665700709ae475a5daea2"
"hash": "c48d04fc3de59dd484f0a63d40336ced54e08785f77e9ef85f3157d004ec85dc"
}
@@ -1,21 +1,21 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "SELECT\n node_id,\n self_described as \"self_described: serde_json::Value\"\n FROM\n nym_nodes\n WHERE\n self_described IS NOT NULL\n ORDER BY\n node_id\n ",
"describe": {
"columns": [
{
"name": "node_id",
"ordinal": 0,
"type_info": "Integer"
"name": "node_id",
"type_info": "Int4"
},
{
"name": "self_described: serde_json::Value",
"ordinal": 1,
"name": "self_described: serde_json::Value",
"type_info": "Text"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false,
@@ -1,16 +1,16 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "\n SELECT mix_id\n FROM mixnodes\n WHERE bonded = true\n ",
"describe": {
"columns": [
{
"name": "mix_id",
"ordinal": 0,
"type_info": "Integer"
"name": "mix_id",
"type_info": "Int8"
}
],
"parameters": {
"Right": 0
"Left": []
},
"nullable": [
false
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "DELETE FROM gateway_session_stats WHERE day <= ?",
"describe": {
"columns": [],
"parameters": {
"Right": 1
},
"nullable": []
},
"hash": "c9e61180ec35dfab8623333fafa3b216b93440d0fddc0a37dd1b6c1813741f6a"
}
@@ -1,20 +0,0 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n total_stake\n FROM nym_nodes\n WHERE node_id = ?\n ",
"describe": {
"columns": [
{
"name": "total_stake",
"ordinal": 0,
"type_info": "Integer"
}
],
"parameters": {
"Right": 1
},
"nullable": [
false
]
},
"hash": "d2e07d44594ca5b44a6100482ff432c39d761f2a0ac1d6515cf73416f2eb6c61"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "INSERT INTO summary\n (key, value_json, last_updated_utc)\n VALUES (?, ?, ?)\n ON CONFLICT(key) DO UPDATE SET\n value_json=excluded.value_json,\n last_updated_utc=excluded.last_updated_utc;",
"describe": {
"columns": [],
"parameters": {
"Right": 3
},
"nullable": []
},
"hash": "e0c76a959276e3b0f44c720af9c74a5bf4912ee73468e62e7d0d96b1d9074cbe"
}
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO gateway_description (\n gateway_identity_key,\n moniker,\n website,\n security_contact,\n details,\n last_updated_utc\n ) VALUES (?, ?, ?, ?, ?, ?)\n ON CONFLICT (gateway_identity_key) DO UPDATE SET\n moniker = excluded.moniker,\n website = excluded.website,\n security_contact = excluded.security_contact,\n details = excluded.details,\n last_updated_utc = excluded.last_updated_utc\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 6
},
"nullable": []
},
"hash": "e9790b63ebe4bff5172bb8cb7bfc288364855003cf0e4d63e95047e7b502c650"
}
@@ -1,56 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT\n id as \"id!\",\n gateway_id as \"gateway_id!\",\n status as \"status!\",\n created_utc as \"created_utc!\",\n ip_address as \"ip_address!\",\n log as \"log!\",\n last_assigned_utc\n FROM testruns\n WHERE\n id = ?\n AND\n status = ?\n ORDER BY created_utc\n LIMIT 1",
"describe": {
"columns": [
{
"name": "id!",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "gateway_id!",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "status!",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "created_utc!",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "ip_address!",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "log!",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "last_assigned_utc",
"ordinal": 6,
"type_info": "Integer"
}
],
"parameters": {
"Right": 2
},
"nullable": [
false,
false,
false,
false,
false,
false,
true
]
},
"hash": "f0c64794cbaed87a1d3166251d8e6720c9a9fc929144188460849be85d915004"
}
@@ -1,80 +0,0 @@
{
"db_name": "SQLite",
"query": "SELECT\n mn.mix_id as \"mix_id!\",\n mn.bonded as \"bonded: bool\",\n mn.is_dp_delegatee as \"is_dp_delegatee: bool\",\n mn.total_stake as \"total_stake!\",\n mn.full_details as \"full_details!\",\n mn.self_described as \"self_described\",\n mn.last_updated_utc as \"last_updated_utc!\",\n COALESCE(md.moniker, \"NA\") as \"moniker!\",\n COALESCE(md.website, \"NA\") as \"website!\",\n COALESCE(md.security_contact, \"NA\") as \"security_contact!\",\n COALESCE(md.details, \"NA\") as \"details!\"\n FROM mixnodes mn\n LEFT JOIN mixnode_description md ON mn.mix_id = md.mix_id\n ORDER BY mn.mix_id",
"describe": {
"columns": [
{
"name": "mix_id!",
"ordinal": 0,
"type_info": "Integer"
},
{
"name": "bonded: bool",
"ordinal": 1,
"type_info": "Integer"
},
{
"name": "is_dp_delegatee: bool",
"ordinal": 2,
"type_info": "Integer"
},
{
"name": "total_stake!",
"ordinal": 3,
"type_info": "Integer"
},
{
"name": "full_details!",
"ordinal": 4,
"type_info": "Text"
},
{
"name": "self_described",
"ordinal": 5,
"type_info": "Text"
},
{
"name": "last_updated_utc!",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "moniker!",
"ordinal": 7,
"type_info": "Text"
},
{
"name": "website!",
"ordinal": 8,
"type_info": "Text"
},
{
"name": "security_contact!",
"ordinal": 9,
"type_info": "Text"
},
{
"name": "details!",
"ordinal": 10,
"type_info": "Text"
}
],
"parameters": {
"Right": 0
},
"nullable": [
false,
false,
false,
false,
true,
true,
false,
false,
false,
false,
false
]
},
"hash": "f75af377da33db1455c6e0f612e0fa9583888f343b8b59faf37fc6799b244379"
}
@@ -1,10 +1,10 @@
{
"db_name": "SQLite",
"db_name": "PostgreSQL",
"query": "UPDATE\n gateways\n SET\n bonded = false\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 0
"Left": []
},
"nullable": []
},
@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n INSERT INTO nym_nodes_packet_stats_raw (\n node_id, timestamp_utc, packets_received, packets_sent, packets_dropped\n ) VALUES (?, ?, ?, ?, ?)\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 5
},
"nullable": []
},
"hash": "fcb1698d9e0e3a14524c92e7c99a811588c2bbc50d4975487a0464321a1b18c9"
}
@@ -28,11 +28,15 @@ moka = { workspace = true, features = ["future"] }
# Nym API: revert after Cheddar is out
nym-contracts-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-mixnet-contract-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-bin-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar", features = ["openapi"]}
nym-bin-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar", features = [
"openapi",
] }
nym-node-status-client = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-crypto = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-http-api-client = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-http-api-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar", features = ["middleware"]}
nym-http-api-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar", features = [
"middleware",
] }
nym-network-defaults = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-serde-helpers = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
nym-statistics-common = { git = "https://github.com/nymtech/nym.git", branch = "release/2025.11-cheddar" }
@@ -62,7 +66,7 @@ serde_json = { workspace = true }
serde_json_path = { workspace = true }
strum = { workspace = true }
strum_macros = { workspace = true }
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite", "time"] }
sqlx = { workspace = true, features = ["runtime-tokio-rustls", "time"] }
thiserror = { workspace = true }
time = { workspace = true, features = ["formatting"] }
tokio = { workspace = true, features = ["rt-multi-thread"] }
@@ -77,13 +81,16 @@ utoipauto = { workspace = true }
nym-node-metrics = { path = "../../nym-node/nym-node-metrics" }
[features]
default = ["pg"]
sqlite = ["sqlx/sqlite"]
pg = ["sqlx/postgres"]
[build-dependencies]
anyhow = { workspace = true }
tokio = { workspace = true, features = ["macros"] }
sqlx = { workspace = true, features = [
"runtime-tokio-rustls",
"sqlite",
"macros",
"migrate",
] }
@@ -0,0 +1,104 @@
# Makefile for nym-node-status-api database management
# --- Configuration ---
TEST_DATABASE_URL := postgres://testuser:testpass@localhost:5433/nym_node_status_api_test
# Docker compose service names
DB_SERVICE_NAME := postgres-test
DB_CONTAINER_NAME := nym_node_status_api_postgres_test
# Default target
.PHONY: default
default: help
# --- Main Targets ---
.PHONY: prepare-pg
prepare-pg: test-db-up test-db-wait test-db-migrate test-db-prepare test-db-down ## Setup PostgreSQL and prepare SQLx offline cache
.PHONY: test-db
test-db: test-db-up test-db-wait test-db-migrate test-db-run test-db-down ## Run tests with PostgreSQL database
.PHONY: dev-db
dev-db: test-db-up test-db-wait test-db-migrate ## Start PostgreSQL for development (keeps running)
@echo "PostgreSQL is running on port 5433"
@echo "Connection string: $(TEST_DATABASE_URL)"
# --- Docker Compose Targets ---
.PHONY: test-db-up
test-db-up: ## Start the PostgreSQL test database in the background
@echo "Starting PostgreSQL test database..."
docker compose up -d $(DB_SERVICE_NAME)
.PHONY: test-db-wait
test-db-wait: ## Wait for the PostgreSQL database to be healthy
@echo "Waiting for PostgreSQL database..."
@while ! docker inspect --format='{{.State.Health.Status}}' $(DB_CONTAINER_NAME) 2>/dev/null | grep -q 'healthy'; do \
echo -n "."; \
sleep 1; \
done; \
echo " Database is healthy!"
.PHONY: test-db-down
test-db-down: ## Stop and remove the test database
@echo "Stopping PostgreSQL test database..."
docker compose down
# --- SQLx Targets ---
.PHONY: test-db-migrate
test-db-migrate: ## Run database migrations against PostgreSQL
@echo "Running PostgreSQL migrations..."
DATABASE_URL="$(TEST_DATABASE_URL)" sqlx migrate run --source migrations_pg
.PHONY: test-db-prepare
test-db-prepare: ## Run sqlx prepare for compile-time query verification
@echo "Running sqlx prepare for PostgreSQL..."
DATABASE_URL="$(TEST_DATABASE_URL)" cargo sqlx prepare -- --features pg
# --- Build and Test Targets ---
.PHONY: test-db-run
test-db-run: ## Run tests with PostgreSQL feature
@echo "Running tests with PostgreSQL..."
DATABASE_URL="$(TEST_DATABASE_URL)" cargo test --features pg --no-default-features
.PHONY: build-pg
build-pg: ## Build with PostgreSQL feature
@echo "Building with PostgreSQL feature..."
cargo build --features pg --no-default-features
.PHONY: build-sqlite
build-sqlite: ## Build with SQLite feature (default)
@echo "Building with SQLite feature..."
cargo build --features sqlite --no-default-features
.PHONY: check-pg
check-pg: ## Check code with PostgreSQL feature
@echo "Checking code with PostgreSQL feature..."
cargo check --features pg --no-default-features
.PHONY: check-sqlite
check-sqlite: ## Check code with SQLite feature
@echo "Checking code with SQLite feature..."
cargo check --features sqlite --no-default-features
# --- Cleanup Targets ---
.PHONY: clean
clean: ## Clean build artifacts and SQLx cache
cargo clean
rm -rf .sqlx
.PHONY: clean-db
clean-db: test-db-down ## Stop database and clean volumes
docker volume rm -f nym-node-status-api_postgres_test_data 2>/dev/null || true
# --- Utility Targets ---
.PHONY: sqlx-cli
sqlx-cli: ## Install sqlx-cli if not already installed
@command -v sqlx >/dev/null 2>&1 || cargo install sqlx-cli --features postgres,sqlite
.PHONY: psql
psql: ## Connect to the running PostgreSQL database with psql
@docker exec -it $(DB_CONTAINER_NAME) psql -U testuser -d nym_node_status_api_test
.PHONY: help
help: ## Show help for Makefile targets
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}'
@@ -0,0 +1,104 @@
# PostgreSQL Support for nym-node-status-api
This project now supports both SQLite (default) and PostgreSQL databases.
## Quick Start with PostgreSQL
### 1. Install Prerequisites
```bash
# Install sqlx-cli if not already installed
make sqlx-cli
```
### 2. Prepare PostgreSQL for Development
```bash
# This will:
# - Start PostgreSQL in Docker
# - Run migrations
# - Generate SQLx offline query cache
# - Stop the database
make prepare-pg
```
### 3. Build with PostgreSQL
```bash
# Build with PostgreSQL feature
make build-pg
# Or manually:
cargo build --features pg --no-default-features
```
### 4. Run with PostgreSQL
```bash
# Start PostgreSQL for development (keeps running)
make dev-db
# In another terminal, run the application
DATABASE_URL=postgres://testuser:testpass@localhost:5433/nym_node_status_api_test \
cargo run --features pg --no-default-features
```
## Database Features
- `sqlite` (default): Uses SQLite database
- `pg`: Uses PostgreSQL database
Only one database feature can be active at a time.
## Migration Differences
SQLite migrations are in `migrations/`, PostgreSQL migrations are in `migrations_pg/`.
Key differences:
- **AUTOINCREMENT** → **SERIAL**
- **INTEGER CHECK (0,1)** → **BOOLEAN**
- **REAL** → **DOUBLE PRECISION**
- No table recreation needed for constraint changes in PostgreSQL
## Makefile Targets
```bash
make help # Show all available targets
make prepare-pg # Setup PostgreSQL and prepare SQLx cache
make dev-db # Start PostgreSQL for development
make test-db # Run tests with PostgreSQL
make build-pg # Build with PostgreSQL
make build-sqlite # Build with SQLite
make psql # Connect to running PostgreSQL
make clean # Clean build artifacts
make clean-db # Stop database and clean volumes
```
## Environment Variables
See `.env.example` for all configuration options. Key variable:
```bash
# For PostgreSQL:
DATABASE_URL=postgres://testuser:testpass@localhost:5433/nym_node_status_api_test
# For SQLite:
DATABASE_URL=sqlite://nym-node-status-api.sqlite
```
## Troubleshooting
### SQLx Offline Mode
If you see "no cached data for this query" errors:
1. Ensure PostgreSQL is running: `make dev-db`
2. Run: `make test-db-prepare`
### Connection Refused
If you see "Connection refused" errors:
1. Check Docker is running: `docker ps`
2. Check PostgreSQL container: `docker ps | grep nym_node_status_api_postgres_test`
3. Restart database: `make test-db-down && make dev-db`
@@ -1,17 +1,20 @@
use anyhow::{anyhow, Result};
use anyhow::Result;
#[cfg(feature = "sqlite")]
use sqlx::{Connection, SqliteConnection};
#[cfg(feature = "sqlite")]
#[cfg(target_family = "unix")]
use std::fs::Permissions;
#[cfg(feature = "sqlite")]
#[cfg(target_family = "unix")]
use std::os::unix::fs::PermissionsExt;
#[cfg(feature = "sqlite")]
use tokio::{fs::File, io::AsyncWriteExt};
#[cfg(feature = "sqlite")]
const SQLITE_DB_FILENAME: &str = "nym-node-status-api.sqlite";
/// If you need to re-run migrations or reset the db, just run
/// cargo clean -p nym-node-status-api
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
#[cfg(feature = "sqlite")]
async fn init_db() -> Result<()> {
let out_dir = read_env_var("OUT_DIR")?;
let database_path = format!("{out_dir}/{SQLITE_DB_FILENAME}?mode=rwc");
@@ -30,11 +33,30 @@ async fn main() -> Result<()> {
Ok(())
}
#[cfg(all(feature = "pg", not(feature = "sqlite")))]
async fn init_db() -> Result<()> {
// PostgreSQL doesn't need build-time initialization
// Just ensure DATABASE_URL is available for runtime
if let Ok(database_url) = std::env::var("DATABASE_URL") {
println!("cargo::rustc-env=DATABASE_URL={database_url}");
}
Ok(())
}
/// If you need to re-run migrations or reset the db, just run
/// cargo clean -p nym-node-status-api
#[tokio::main(flavor = "current_thread")]
async fn main() -> Result<()> {
init_db().await
}
#[cfg(feature = "sqlite")]
fn read_env_var(var: &str) -> Result<String> {
std::env::var(var).map_err(|_| anyhow!("You need to set {} env var", var))
std::env::var(var).map_err(|_| anyhow::anyhow!("You need to set {} env var", var))
}
/// use `./enter_db.sh` to inspect DB
#[cfg(feature = "sqlite")]
async fn write_db_path_to_file(out_dir: &str, db_filename: &str) -> anyhow::Result<()> {
let mut file = File::create("settings.sql").await?;
let settings = ".mode columns
@@ -0,0 +1,21 @@
services:
postgres-test:
image: postgres:16-alpine
container_name: nym_node_status_api_postgres_test
environment:
POSTGRES_DB: nym_node_status_api_test
POSTGRES_USER: testuser
POSTGRES_PASSWORD: testpass
ports:
- '5433:5432' # Map to 5433 to avoid conflicts with default PostgreSQL
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U testuser -d nym_node_status_api_test']
interval: 5s
timeout: 5s
retries: 5
# Optional: Add volume for persistent data during development
# volumes:
# - postgres_test_data:/var/lib/postgresql/data
# volumes:
# postgres_test_data:
@@ -0,0 +1,113 @@
CREATE TABLE gateways
(
id SERIAL PRIMARY KEY,
gateway_identity_key VARCHAR NOT NULL UNIQUE,
self_described VARCHAR NOT NULL,
explorer_pretty_bond VARCHAR,
last_probe_result VARCHAR,
last_probe_log VARCHAR,
config_score INTEGER NOT NULL DEFAULT 0,
config_score_successes DOUBLE PRECISION NOT NULL DEFAULT 0,
config_score_samples DOUBLE PRECISION NOT NULL DEFAULT 0,
routing_score INTEGER NOT NULL DEFAULT 0,
routing_score_successes DOUBLE PRECISION NOT NULL DEFAULT 0,
routing_score_samples DOUBLE PRECISION NOT NULL DEFAULT 0,
test_run_samples DOUBLE PRECISION NOT NULL DEFAULT 0,
last_testrun_utc BIGINT,
last_updated_utc BIGINT NOT NULL,
bonded BOOLEAN NOT NULL DEFAULT FALSE,
blacklisted BOOLEAN NOT NULL DEFAULT FALSE,
performance INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX idx_gateway_description_gateway_identity_key ON gateways (gateway_identity_key);
CREATE TABLE mixnodes (
id SERIAL PRIMARY KEY,
identity_key VARCHAR NOT NULL UNIQUE,
mix_id BIGINT NOT NULL UNIQUE,
bonded BOOLEAN NOT NULL DEFAULT FALSE,
total_stake BIGINT NOT NULL,
host VARCHAR NOT NULL,
http_api_port BIGINT NOT NULL,
blacklisted BOOLEAN NOT NULL DEFAULT FALSE,
full_details VARCHAR,
self_described VARCHAR,
last_updated_utc BIGINT NOT NULL,
is_dp_delegatee BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX idx_mixnodes_mix_id ON mixnodes (mix_id);
CREATE INDEX idx_mixnodes_identity_key ON mixnodes (identity_key);
CREATE TABLE mixnode_description (
id SERIAL PRIMARY KEY,
mix_id BIGINT UNIQUE NOT NULL,
moniker VARCHAR,
website VARCHAR,
security_contact VARCHAR,
details VARCHAR,
last_updated_utc BIGINT NOT NULL,
FOREIGN KEY (mix_id) REFERENCES mixnodes (mix_id)
);
-- Indexes for description table
CREATE INDEX idx_mixnode_description_mix_id ON mixnode_description (mix_id);
CREATE TABLE summary
(
key VARCHAR PRIMARY KEY,
value_json VARCHAR,
last_updated_utc BIGINT NOT NULL
);
CREATE TABLE summary_history
(
id SERIAL PRIMARY KEY,
date VARCHAR UNIQUE NOT NULL,
timestamp_utc BIGINT NOT NULL,
value_json VARCHAR
);
CREATE INDEX idx_summary_history_timestamp_utc ON summary_history (timestamp_utc);
CREATE INDEX idx_summary_history_date ON summary_history (date);
CREATE TABLE gateway_description (
id SERIAL PRIMARY KEY,
gateway_identity_key VARCHAR UNIQUE NOT NULL,
moniker VARCHAR,
website VARCHAR,
security_contact VARCHAR,
details VARCHAR,
last_updated_utc BIGINT NOT NULL,
FOREIGN KEY (gateway_identity_key) REFERENCES gateways (gateway_identity_key)
);
CREATE TABLE mixnode_daily_stats (
id SERIAL PRIMARY KEY,
mix_id BIGINT NOT NULL,
total_stake BIGINT NOT NULL,
date_utc VARCHAR NOT NULL,
packets_received INTEGER DEFAULT 0,
packets_sent INTEGER DEFAULT 0,
packets_dropped INTEGER DEFAULT 0,
FOREIGN KEY (mix_id) REFERENCES mixnodes (mix_id),
UNIQUE (mix_id, date_utc) -- This constraint automatically creates an index
);
CREATE TABLE testruns
(
id SERIAL PRIMARY KEY,
gateway_id INTEGER NOT NULL,
status INTEGER NOT NULL, -- 0=pending, 1=in-progress, 2=complete
timestamp_utc BIGINT NOT NULL,
ip_address VARCHAR NOT NULL,
log VARCHAR NOT NULL,
FOREIGN KEY (gateway_id) REFERENCES gateways (id)
);
@@ -0,0 +1,5 @@
ALTER TABLE testruns
RENAME COLUMN timestamp_utc TO created_utc;
ALTER TABLE testruns
ADD COLUMN last_assigned_utc BIGINT;
@@ -0,0 +1,16 @@
CREATE TABLE gateway_session_stats (
id SERIAL PRIMARY KEY,
gateway_identity_key VARCHAR NOT NULL,
node_id BIGINT NOT NULL,
day DATE NOT NULL,
unique_active_clients BIGINT NOT NULL,
session_started BIGINT NOT NULL,
users_hashes VARCHAR,
vpn_sessions VARCHAR,
mixnet_sessions VARCHAR,
unknown_sessions VARCHAR,
UNIQUE (node_id, day) -- This constraint automatically creates an index
);
CREATE INDEX idx_gateway_session_stats_identity_key ON gateway_session_stats (gateway_identity_key);
CREATE INDEX idx_gateway_session_stats_day ON gateway_session_stats (day);
@@ -0,0 +1,11 @@
CREATE TABLE mixnode_packet_stats_raw (
id SERIAL PRIMARY KEY,
mix_id BIGINT NOT NULL,
timestamp_utc BIGINT NOT NULL,
packets_received INTEGER,
packets_sent INTEGER,
packets_dropped INTEGER,
FOREIGN KEY (mix_id) REFERENCES mixnodes (mix_id)
);
CREATE INDEX idx_mixnode_packet_stats_raw_mix_id_timestamp_utc ON mixnode_packet_stats_raw (mix_id, timestamp_utc);
@@ -0,0 +1,54 @@
ALTER TABLE mixnodes DROP COLUMN blacklisted;
ALTER TABLE gateways DROP COLUMN blacklisted;
CREATE TABLE nym_nodes (
node_id INTEGER PRIMARY KEY,
ed25519_identity_pubkey VARCHAR NOT NULL UNIQUE,
total_stake BIGINT NOT NULL,
ip_addresses TEXT NOT NULL,
mix_port INTEGER NOT NULL,
x25519_sphinx_pubkey VARCHAR NOT NULL UNIQUE,
node_role TEXT NOT NULL,
supported_roles TEXT NOT NULL,
performance VARCHAR NOT NULL,
entry TEXT,
last_updated_utc INTEGER NOT NULL
);
CREATE INDEX idx_nym_nodes_node_id ON nym_nodes (node_id);
CREATE INDEX idx_nym_nodes_ed25519_identity_pubkey ON nym_nodes (ed25519_identity_pubkey);
CREATE TABLE nym_node_descriptions (
id SERIAL PRIMARY KEY,
node_id INTEGER UNIQUE NOT NULL,
moniker VARCHAR,
website VARCHAR,
security_contact VARCHAR,
details VARCHAR,
last_updated_utc INTEGER NOT NULL,
FOREIGN KEY (node_id) REFERENCES nym_nodes (node_id)
);
CREATE TABLE nym_nodes_packet_stats_raw (
id SERIAL PRIMARY KEY,
node_id INTEGER NOT NULL,
timestamp_utc INTEGER NOT NULL,
packets_received INTEGER,
packets_sent INTEGER,
packets_dropped INTEGER,
FOREIGN KEY (node_id) REFERENCES nym_nodes (node_id)
);
CREATE INDEX idx_nym_nodes_packet_stats_raw_node_id_timestamp_utc ON nym_nodes_packet_stats_raw (node_id, timestamp_utc);
CREATE TABLE nym_node_daily_mixing_stats (
id SERIAL PRIMARY KEY,
node_id INTEGER NOT NULL,
total_stake BIGINT NOT NULL,
date_utc VARCHAR NOT NULL,
packets_received INTEGER DEFAULT 0,
packets_sent INTEGER DEFAULT 0,
packets_dropped INTEGER DEFAULT 0,
FOREIGN KEY (node_id) REFERENCES nym_nodes (node_id),
UNIQUE (node_id, date_utc) -- This constraint automatically creates an index
);
@@ -0,0 +1,23 @@
ALTER TABLE nym_nodes ADD COLUMN self_described TEXT;
ALTER TABLE nym_nodes ADD COLUMN bond_info TEXT;
-- PostgreSQL doesn't need table recreation for adding ON DELETE CASCADE
-- We can drop and recreate the foreign key constraints directly
-- Drop existing foreign key constraints
ALTER TABLE nym_node_descriptions DROP CONSTRAINT IF EXISTS nym_node_descriptions_node_id_fkey;
ALTER TABLE nym_nodes_packet_stats_raw DROP CONSTRAINT IF EXISTS nym_nodes_packet_stats_raw_node_id_fkey;
ALTER TABLE nym_node_daily_mixing_stats DROP CONSTRAINT IF EXISTS nym_node_daily_mixing_stats_node_id_fkey;
-- Add foreign key constraints with ON DELETE CASCADE
ALTER TABLE nym_node_descriptions
ADD CONSTRAINT nym_node_descriptions_node_id_fkey
FOREIGN KEY (node_id) REFERENCES nym_nodes (node_id) ON DELETE CASCADE;
ALTER TABLE nym_nodes_packet_stats_raw
ADD CONSTRAINT nym_nodes_packet_stats_raw_node_id_fkey
FOREIGN KEY (node_id) REFERENCES nym_nodes (node_id) ON DELETE CASCADE;
ALTER TABLE nym_node_daily_mixing_stats
ADD CONSTRAINT nym_node_daily_mixing_stats_node_id_fkey
FOREIGN KEY (node_id) REFERENCES nym_nodes (node_id) ON DELETE CASCADE;
@@ -0,0 +1,9 @@
-- Removing UNIQUE constraints on nym_nodes
-- In PostgreSQL, we can drop constraints directly without recreating the table
-- Drop the unique constraints
ALTER TABLE nym_nodes DROP CONSTRAINT IF EXISTS nym_nodes_ed25519_identity_pubkey_key;
ALTER TABLE nym_nodes DROP CONSTRAINT IF EXISTS nym_nodes_x25519_sphinx_pubkey_key;
-- The columns and indexes remain, only the unique constraints are removed
-- The existing indexes idx_nym_nodes_node_id and idx_nym_nodes_ed25519_identity_pubkey remain unchanged
@@ -0,0 +1,112 @@
-- for a couple of days after migrating chrono -> time, we stored dates as
-- 2025-June-DD instead of 2025-06-DD. This migration fixes those entries.
--
-- Because of a UNIQUE constraint on (node_id, date_utc), we can't just rename in-place.
-- - merge (add) node stats back to the original table where conflict (node_id, date_utc) would exist
-- - delete invalid records from original table (those stats were merged into correct rows above)
-- - insert rows that did not have a conflicting (node_Id, date_utc) combo
-- Conflicts affect only the date which has both kinds of entries,
-- e.g. 2025-06-05 and 2025-June-05 (date when this change was deployed)
--
-- This applies to both affected tables.
-- ----------------------------------------
-- mixnode_daily_stats
-- ----------------------------------------
-- First, copy over rows with invalid date to a temp table (in the correct date format)
CREATE TEMP TABLE tmp_mix AS
SELECT
mix_id,
REPLACE(date_utc,'June','06') AS new_date,
SUM(total_stake) AS total_stake_sum,
SUM(packets_received) AS packets_received_sum,
SUM(packets_sent) AS packets_sent_sum,
SUM(packets_dropped) AS packets_dropped_sum
FROM mixnode_daily_stats
WHERE date_utc LIKE '%June%'
GROUP BY mix_id, new_date;
UPDATE mixnode_daily_stats AS m
SET
total_stake = m.total_stake,
packets_received = m.packets_received + (SELECT packets_received_sum FROM tmp_mix WHERE mix_id = m.mix_id AND new_date = m.date_utc),
packets_sent = m.packets_sent + (SELECT packets_sent_sum FROM tmp_mix WHERE mix_id = m.mix_id AND new_date = m.date_utc),
packets_dropped = m.packets_dropped + (SELECT packets_dropped_sum FROM tmp_mix WHERE mix_id = m.mix_id AND new_date = m.date_utc)
WHERE EXISTS (
SELECT 1 FROM tmp_mix
WHERE mix_id = m.mix_id
AND new_date = m.date_utc
);
DELETE FROM mixnode_daily_stats
WHERE date_utc LIKE '%June%';
INSERT INTO mixnode_daily_stats
(mix_id, date_utc, total_stake, packets_received, packets_sent, packets_dropped)
SELECT
mix_id,
new_date,
total_stake_sum,
packets_received_sum,
packets_sent_sum,
packets_dropped_sum
FROM tmp_mix AS t
-- only those whose new_date did _not_ already exist
WHERE NOT EXISTS (
SELECT 1 FROM mixnode_daily_stats AS m
WHERE m.mix_id = t.mix_id
AND m.date_utc = t.new_date
);
DROP TABLE tmp_mix;
-- ----------------------------------------
-- nym_node_daily_mixing_stats
-- ----------------------------------------
CREATE TEMP TABLE tmp_nym_node_stats AS
SELECT
node_id,
REPLACE(date_utc,'June','06') AS new_date,
SUM(total_stake) AS total_stake_sum,
SUM(packets_received) AS packets_received_sum,
SUM(packets_sent) AS packets_sent_sum,
SUM(packets_dropped) AS packets_dropped_sum
FROM nym_node_daily_mixing_stats
WHERE date_utc LIKE '%June%'
GROUP BY node_id, new_date;
UPDATE nym_node_daily_mixing_stats AS m
SET
total_stake = m.total_stake,
packets_received = m.packets_received + (SELECT packets_received_sum FROM tmp_nym_node_stats WHERE node_id = m.node_id AND new_date = m.date_utc),
packets_sent = m.packets_sent + (SELECT packets_sent_sum FROM tmp_nym_node_stats WHERE node_id = m.node_id AND new_date = m.date_utc),
packets_dropped = m.packets_dropped + (SELECT packets_dropped_sum FROM tmp_nym_node_stats WHERE node_id = m.node_id AND new_date = m.date_utc)
WHERE EXISTS (
SELECT 1 FROM tmp_nym_node_stats
WHERE node_id = m.node_id
AND new_date = m.date_utc
);
DELETE FROM nym_node_daily_mixing_stats
WHERE date_utc LIKE '%June%';
INSERT INTO nym_node_daily_mixing_stats
(node_id, date_utc, total_stake, packets_received, packets_sent, packets_dropped)
SELECT
node_id,
new_date,
total_stake_sum,
packets_received_sum,
packets_sent_sum,
packets_dropped_sum
FROM tmp_nym_node_stats AS t
WHERE NOT EXISTS (
SELECT 1 FROM nym_node_daily_mixing_stats AS m
WHERE m.node_id = t.node_id
AND m.date_utc = t.new_date
);
DROP TABLE tmp_nym_node_stats;
@@ -1,24 +1,53 @@
use anyhow::{anyhow, Result};
use sqlx::{
migrate::Migrator,
query,
sqlite::{SqliteAutoVacuum, SqliteConnectOptions, SqliteSynchronous},
ConnectOptions, SqlitePool,
};
use std::{str::FromStr, time::Duration};
pub(crate) mod models;
pub(crate) mod queries;
pub(crate) mod query_wrapper;
// Re-export the query wrapper functions for easier access
pub(crate) use query_wrapper::query;
#[allow(unused_imports)]
pub(crate) use query_wrapper::{query_as, query_scalar};
#[cfg(feature = "sqlite")]
use sqlx::{
migrate::Migrator,
sqlite::{SqliteAutoVacuum, SqliteConnectOptions, SqliteSynchronous},
ConnectOptions, SqlitePool,
};
#[cfg(feature = "pg")]
use sqlx::{
migrate::Migrator,
postgres::PgConnectOptions,
ConnectOptions, PgPool,
};
#[cfg(feature = "sqlite")]
static MIGRATOR: Migrator = sqlx::migrate!("./migrations");
#[cfg(feature = "pg")]
static MIGRATOR: Migrator = sqlx::migrate!("./migrations_pg");
#[cfg(feature = "sqlite")]
pub(crate) type DbPool = SqlitePool;
#[cfg(feature = "pg")]
pub(crate) type DbPool = PgPool;
#[cfg(feature = "sqlite")]
pub(crate) type DbConnection = sqlx::pool::PoolConnection<sqlx::Sqlite>;
#[cfg(feature = "pg")]
pub(crate) type DbConnection = sqlx::pool::PoolConnection<sqlx::Postgres>;
pub(crate) struct Storage {
pool: DbPool,
}
impl Storage {
#[cfg(feature = "sqlite")]
pub async fn init(connection_url: String, busy_timeout: Duration) -> Result<Self> {
let connect_options = SqliteConnectOptions::from_str(&connection_url)?
.journal_mode(sqlx::sqlite::SqliteJournalMode::Wal)
@@ -41,16 +70,31 @@ impl Storage {
Ok(Storage { pool })
}
#[cfg(feature = "pg")]
pub async fn init(connection_url: String, _busy_timeout: Duration) -> Result<Self> {
let connect_options = PgConnectOptions::from_str(&connection_url)?
.disable_statement_logging();
let pool = sqlx::PgPool::connect_with(connect_options)
.await
.map_err(|err| anyhow!("Failed to connect to {}: {}", &connection_url, err))?;
MIGRATOR.run(&pool).await?;
Ok(Storage { pool })
}
/// Cloning pool is cheap, it's the same underlying set of connections
pub fn pool_owned(&self) -> DbPool {
self.pool.clone()
}
#[cfg(feature = "sqlite")]
async fn assert_busy_timeout(pool: DbPool, expected_busy_timeout_s: i64) -> Result<()> {
let mut conn = pool.acquire().await?;
// Sqlite stores this value as miliseconds
// https://www.sqlite.org/pragma.html#pragma_busy_timeout
let busy_timeout_db = query!("PRAGMA busy_timeout;")
let busy_timeout_db = sqlx::query!("PRAGMA busy_timeout;")
.fetch_one(conn.as_mut())
.await?;
@@ -68,4 +112,4 @@ impl Storage {
Ok(())
}
}
}
@@ -3,17 +3,16 @@ use std::collections::HashSet;
use crate::{
db::{
models::{GatewayDto, GatewayInsertRecord},
DbPool,
DbConnection, DbPool,
},
http::models::Gateway,
mixnet_scraper::helpers::NodeDescriptionResponse,
};
use futures_util::TryStreamExt;
use sqlx::{pool::PoolConnection, Sqlite};
use tracing::error;
pub(crate) async fn select_gateway_identity(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
gateway_pk: i64,
) -> anyhow::Result<String> {
let record = sqlx::query!(
@@ -131,7 +130,7 @@ pub(crate) async fn get_bonded_gateway_id_keys(pool: &DbPool) -> anyhow::Result<
}
pub(crate) async fn insert_gateway_description(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
identity_key: &str,
description: &NodeDescriptionResponse,
timestamp: i64,
@@ -6,6 +6,7 @@ use futures_util::TryStreamExt;
use time::Date;
use tracing::error;
#[cfg(feature = "sqlite")]
pub(crate) async fn insert_session_records(
pool: &DbPool,
records: Vec<GatewaySessionsRecord>,
@@ -36,6 +37,38 @@ pub(crate) async fn insert_session_records(
Ok(())
}
#[cfg(feature = "pg")]
pub(crate) async fn insert_session_records(
pool: &DbPool,
records: Vec<GatewaySessionsRecord>,
) -> anyhow::Result<()> {
let mut tx = pool.begin().await?;
for record in records {
sqlx::query!(
"INSERT INTO gateway_session_stats
(gateway_identity_key, node_id, day,
unique_active_clients, session_started, users_hashes,
vpn_sessions, mixnet_sessions, unknown_sessions)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
ON CONFLICT DO NOTHING",
record.gateway_identity_key,
record.node_id,
record.day,
record.unique_active_clients,
record.session_started,
record.users_hashes,
record.vpn_sessions,
record.mixnet_sessions,
record.unknown_sessions,
)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(())
}
pub(crate) async fn get_sessions_stats(pool: &DbPool) -> anyhow::Result<Vec<SessionStats>> {
let mut conn = pool.acquire().await?;
let items = sqlx::query_as(
@@ -1,13 +1,12 @@
use std::collections::HashSet;
use futures_util::TryStreamExt;
use sqlx::{pool::PoolConnection, Sqlite};
use tracing::error;
use crate::{
db::{
models::{MixnodeDto, MixnodeRecord},
DbPool,
DbConnection, DbPool,
},
http::models::{DailyStats, Mixnode},
mixnet_scraper::helpers::NodeDescriptionResponse,
@@ -31,9 +30,9 @@ pub(crate) async fn update_mixnodes(
.await?;
// existing nodes get updated on insert
for record in mixnodes.iter() {
for record in mixnodes.into_iter() {
// https://www.sqlite.org/lang_upsert.html
sqlx::query!(
crate::db::query(
"INSERT INTO mixnodes
(mix_id, identity_key, bonded, total_stake,
host, http_api_port, full_details,
@@ -46,17 +45,17 @@ pub(crate) async fn update_mixnodes(
full_details=excluded.full_details,self_described=excluded.self_described,
last_updated_utc=excluded.last_updated_utc,
is_dp_delegatee = excluded.is_dp_delegatee;",
record.mix_id,
record.identity_key,
record.bonded,
record.total_stake,
record.host,
record.http_port,
record.full_details,
record.self_described,
record.last_updated_utc,
record.is_dp_delegatee
)
.bind(record.mix_id as i64)
.bind(record.identity_key)
.bind(record.bonded)
.bind(record.total_stake)
.bind(record.host)
.bind(record.http_port as i32)
.bind(record.full_details)
.bind(record.self_described)
.bind(record.last_updated_utc)
.bind(record.is_dp_delegatee)
.execute(&mut *tx)
.await?;
}
@@ -161,7 +160,7 @@ pub(crate) async fn get_bonded_mix_ids(pool: &DbPool) -> anyhow::Result<HashSet<
}
pub(crate) async fn insert_mixnode_description(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
mix_id: &i64,
description: &NodeDescriptionResponse,
timestamp: i64,
@@ -4,14 +4,13 @@ use nym_validator_client::{
client::{NodeId, NymNodeDetails},
models::NymNodeDescription,
};
use sqlx::{pool::PoolConnection, Sqlite};
use std::collections::HashMap;
use tracing::instrument;
use crate::{
db::{
models::{NymNodeDto, NymNodeInsertRecord},
DbPool,
DbConnection, DbPool,
},
mixnet_scraper::helpers::NodeDescriptionResponse,
};
@@ -266,7 +265,7 @@ pub(crate) async fn get_bonded_node_description(
}
pub(crate) async fn insert_nym_node_description(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
node_id: &i64,
description: &NodeDescriptionResponse,
timestamp: i64,
@@ -1,29 +1,27 @@
use crate::db::models::{TestRunDto, TestRunStatus};
use crate::db::DbConnection;
use crate::db::DbPool;
use crate::http::models::TestrunAssignment;
use crate::utils::now_utc;
use sqlx::{pool::PoolConnection, Sqlite};
use time::Duration;
pub(crate) async fn count_testruns_in_progress(
conn: &mut PoolConnection<Sqlite>,
) -> anyhow::Result<i64> {
sqlx::query_scalar!(
r#"SELECT
COUNT(id) as "count: i64"
FROM testruns
WHERE
status = ?
"#,
TestRunStatus::InProgress as i64,
)
.fetch_one(conn.as_mut())
.await
.map_err(anyhow::Error::from)
pub(crate) async fn count_testruns_in_progress(conn: &mut DbConnection) -> anyhow::Result<i64> {
#[cfg(feature = "sqlite")]
let sql = "SELECT COUNT(id) FROM testruns WHERE status = ?";
#[cfg(feature = "pg")]
let sql = "SELECT COUNT(id) FROM testruns WHERE status = $1";
let count: i64 = sqlx::query_scalar(sql)
.bind(TestRunStatus::InProgress as i64)
.fetch_one(conn.as_mut())
.await?;
Ok(count)
}
pub(crate) async fn get_in_progress_testrun_by_id(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
testrun_id: i64,
) -> anyhow::Result<TestRunDto> {
sqlx::query_as!(
@@ -89,7 +87,7 @@ pub(crate) async fn update_testruns_assigned_before(
}
pub(crate) async fn assign_oldest_testrun(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
) -> anyhow::Result<Option<TestrunAssignment>> {
let now = now_utc().unix_timestamp();
// find & mark as "In progress" in the same transaction to avoid race conditions
@@ -142,7 +140,7 @@ pub(crate) async fn assign_oldest_testrun(
}
pub(crate) async fn update_testrun_status(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
testrun_id: i64,
status: TestRunStatus,
) -> anyhow::Result<()> {
@@ -159,7 +157,7 @@ pub(crate) async fn update_testrun_status(
}
pub(crate) async fn update_gateway_last_probe_log(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
gateway_pk: i64,
log: &str,
) -> anyhow::Result<()> {
@@ -175,7 +173,7 @@ pub(crate) async fn update_gateway_last_probe_log(
}
pub(crate) async fn update_gateway_last_probe_result(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
gateway_pk: i64,
result: &str,
) -> anyhow::Result<()> {
@@ -191,7 +189,7 @@ pub(crate) async fn update_gateway_last_probe_result(
}
pub(crate) async fn update_gateway_score(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
gateway_pk: i64,
) -> anyhow::Result<()> {
let now = now_utc().unix_timestamp();
@@ -0,0 +1,122 @@
use sqlx::Database;
/// Converts SQLite-style ? placeholders to PostgreSQL $N format
#[cfg(feature = "pg")]
fn convert_placeholders(query: &str) -> String {
let mut result = String::with_capacity(query.len() + 10);
let mut placeholder_count = 0;
let mut chars = query.chars().peekable();
let mut in_string = false;
let mut escape_next = false;
while let Some(ch) = chars.next() {
if escape_next {
result.push(ch);
escape_next = false;
continue;
}
match ch {
'\\' => {
result.push(ch);
escape_next = true;
}
'\'' => {
result.push(ch);
in_string = !in_string;
}
'?' if !in_string => {
placeholder_count += 1;
result.push_str(&format!("${}", placeholder_count));
}
_ => {
result.push(ch);
}
}
}
result
}
/// Creates a query that automatically handles placeholder conversion
#[cfg(feature = "sqlite")]
pub fn query(sql: &str) -> sqlx::query::Query<'_, sqlx::Sqlite, <sqlx::Sqlite as Database>::Arguments<'_>> {
sqlx::query(sql)
}
#[cfg(feature = "pg")]
pub fn query(sql: &str) -> sqlx::query::Query<'static, sqlx::Postgres, <sqlx::Postgres as Database>::Arguments<'static>> {
let converted = convert_placeholders(sql);
sqlx::query(Box::leak(converted.into_boxed_str()))
}
/// Creates a query_as that automatically handles placeholder conversion
#[cfg(feature = "sqlite")]
pub fn query_as<O>(sql: &str) -> sqlx::query::QueryAs<'_, sqlx::Sqlite, O, <sqlx::Sqlite as Database>::Arguments<'_>>
where
O: for<'r> sqlx::FromRow<'r, <sqlx::Sqlite as Database>::Row>,
{
sqlx::query_as(sql)
}
#[cfg(feature = "pg")]
pub fn query_as<O>(sql: &str) -> sqlx::query::QueryAs<'static, sqlx::Postgres, O, <sqlx::Postgres as Database>::Arguments<'static>>
where
O: for<'r> sqlx::FromRow<'r, <sqlx::Postgres as Database>::Row>,
{
let converted = convert_placeholders(sql);
sqlx::query_as(Box::leak(converted.into_boxed_str()))
}
/// Creates a query_scalar that automatically handles placeholder conversion
#[cfg(feature = "sqlite")]
pub fn query_scalar<O>(sql: &str) -> sqlx::query::QueryScalar<'_, sqlx::Sqlite, O, <sqlx::Sqlite as Database>::Arguments<'_>>
where
(O,): for<'r> sqlx::FromRow<'r, <sqlx::Sqlite as Database>::Row>,
{
sqlx::query_scalar(sql)
}
#[cfg(feature = "pg")]
pub fn query_scalar<O>(sql: &str) -> sqlx::query::QueryScalar<'static, sqlx::Postgres, O, <sqlx::Postgres as Database>::Arguments<'static>>
where
(O,): for<'r> sqlx::FromRow<'r, <sqlx::Postgres as Database>::Row>,
{
let converted = convert_placeholders(sql);
sqlx::query_scalar(Box::leak(converted.into_boxed_str()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(feature = "pg")]
fn test_convert_placeholders() {
assert_eq!(
convert_placeholders("SELECT * FROM table WHERE id = ?"),
"SELECT * FROM table WHERE id = $1"
);
assert_eq!(
convert_placeholders("INSERT INTO table (a, b, c) VALUES (?, ?, ?)"),
"INSERT INTO table (a, b, c) VALUES ($1, $2, $3)"
);
assert_eq!(
convert_placeholders("SELECT * FROM table WHERE name = 'test?' AND id = ?"),
"SELECT * FROM table WHERE name = 'test?' AND id = $1"
);
assert_eq!(
convert_placeholders("UPDATE table SET a = ?, b = ? WHERE id = ?"),
"UPDATE table SET a = $1, b = $2 WHERE id = $3"
);
// Test with 10 placeholders (like in update_mixnodes)
assert_eq!(
convert_placeholders("INSERT INTO t VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"),
"INSERT INTO t VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)"
);
}
}
@@ -5,6 +5,12 @@ use nym_task::signal::wait_for_signal;
use nym_validator_client::nyxd::NyxdClient;
use std::sync::Arc;
#[cfg(all(feature = "sqlite", feature = "pg"))]
compile_error!("Features 'sqlite' and 'pg' are mutually exclusive");
#[cfg(not(any(feature = "sqlite", feature = "pg")))]
compile_error!("Either 'sqlite' or 'pg' feature must be enabled");
mod cli;
mod db;
mod http;
@@ -5,6 +5,7 @@ use crate::{
get_raw_node_stats, insert_daily_node_stats, insert_node_packet_stats,
insert_scraped_node_description,
},
DbPool,
},
utils::{generate_node_name, now_utc},
};
@@ -12,7 +13,6 @@ use ammonia::Builder;
use anyhow::{anyhow, Result};
use reqwest;
use serde::{Deserialize, Serialize};
use sqlx::SqlitePool;
use std::time::Duration;
use time::UtcDateTime;
@@ -116,7 +116,7 @@ pub fn sanitize_description(
}
}
pub async fn scrape_and_store_description(pool: &SqlitePool, node: &ScraperNodeInfo) -> Result<()> {
pub async fn scrape_and_store_description(pool: &DbPool, node: &ScraperNodeInfo) -> Result<()> {
let client = build_client()?;
let urls = node.contact_addresses();
@@ -157,7 +157,7 @@ pub async fn scrape_and_store_description(pool: &SqlitePool, node: &ScraperNodeI
}
pub async fn scrape_and_store_packet_stats(
pool: &SqlitePool,
pool: &DbPool,
node: &ScraperNodeInfo,
) -> Result<()> {
let client = build_client()?;
@@ -198,7 +198,7 @@ pub async fn scrape_and_store_packet_stats(
}
pub async fn update_daily_stats(
pool: &SqlitePool,
pool: &DbPool,
node: &ScraperNodeInfo,
timestamp: UtcDateTime,
current_stats: &NodeStats,
@@ -4,7 +4,7 @@ use std::time::Duration;
pub mod helpers;
use anyhow::Result;
use helpers::{scrape_and_store_description, scrape_and_store_packet_stats};
use sqlx::SqlitePool;
use crate::db::DbPool;
use tracing::{debug, error, instrument, warn};
use crate::db::models::ScraperNodeInfo;
@@ -19,13 +19,13 @@ static TASK_COUNTER: AtomicUsize = AtomicUsize::new(0);
static TASK_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub struct Scraper {
pool: SqlitePool,
pool: DbPool,
description_queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
packet_queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
}
impl Scraper {
pub fn new(pool: SqlitePool) -> Self {
pub fn new(pool: DbPool) -> Self {
Self {
pool,
description_queue: Arc::new(Mutex::new(Vec::new())),
@@ -71,7 +71,7 @@ impl Scraper {
#[instrument(level = "info", name = "description_scraper", skip_all)]
async fn run_description_scraper(
pool: &SqlitePool,
pool: &DbPool,
queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
) -> Result<()> {
let nodes = get_nodes_for_scraping(pool).await?;
@@ -88,7 +88,7 @@ impl Scraper {
#[instrument(level = "info", name = "packet_scraper", skip_all)]
async fn run_packet_scraper(
pool: &SqlitePool,
pool: &DbPool,
queue: Arc<Mutex<Vec<ScraperNodeInfo>>>,
) -> Result<()> {
let nodes = get_nodes_for_scraping(pool).await?;
@@ -104,7 +104,7 @@ impl Scraper {
Ok(())
}
async fn process_description_queue(pool: &SqlitePool, queue: Arc<Mutex<Vec<ScraperNodeInfo>>>) {
async fn process_description_queue(pool: &DbPool, queue: Arc<Mutex<Vec<ScraperNodeInfo>>>) {
loop {
let running_tasks = TASK_COUNTER.load(Ordering::Relaxed);
@@ -149,7 +149,7 @@ impl Scraper {
}
}
async fn process_packet_queue(pool: &SqlitePool, queue: Arc<Mutex<Vec<ScraperNodeInfo>>>) {
async fn process_packet_queue(pool: &DbPool, queue: Arc<Mutex<Vec<ScraperNodeInfo>>>) {
loop {
let running_tasks = TASK_COUNTER.load(Ordering::Relaxed);
@@ -495,15 +495,31 @@ impl Monitor {
async fn historical_count(pool: &DbPool) -> anyhow::Result<(usize, usize)> {
let mut conn = pool.acquire().await?;
#[cfg(feature = "sqlite")]
let all_historical_gateways = sqlx::query_scalar!(r#"SELECT count(id) FROM gateways"#)
.fetch_one(&mut *conn)
.await?
.cast_checked()?;
#[cfg(feature = "pg")]
let all_historical_gateways = sqlx::query_scalar!(r#"SELECT count(id) FROM gateways"#)
.fetch_one(&mut *conn)
.await?
.unwrap_or(0)
.cast_checked()?;
#[cfg(feature = "sqlite")]
let all_historical_mixnodes = sqlx::query_scalar!(r#"SELECT count(id) FROM mixnodes"#)
.fetch_one(&mut *conn)
.await?
.cast_checked()?;
#[cfg(feature = "pg")]
let all_historical_mixnodes = sqlx::query_scalar!(r#"SELECT count(id) FROM mixnodes"#)
.fetch_one(&mut *conn)
.await?
.unwrap_or(0)
.cast_checked()?;
Ok((all_historical_gateways, all_historical_mixnodes))
}
@@ -1,13 +1,12 @@
use crate::db::models::{GatewayInfoDto, TestRunDto, TestRunStatus};
use crate::db::DbConnection;
use crate::testruns::models::TestRun;
use crate::utils::now_utc;
use anyhow::anyhow;
use futures_util::TryStreamExt;
use sqlx::pool::PoolConnection;
use sqlx::Sqlite;
pub(crate) async fn try_queue_testrun(
conn: &mut PoolConnection<Sqlite>,
conn: &mut DbConnection,
identity_key: String,
ip_address: String,
) -> anyhow::Result<TestRun> {
@@ -88,7 +87,7 @@ pub(crate) async fn try_queue_testrun(
let log = format!("Test for {identity_key} requested at {timestamp_pretty} UTC\n\n");
let id = sqlx::query!(
"INSERT INTO testruns (gateway_id, status, ip_address, created_utc, log) VALUES (?, ?, ?, ?, ?)",
"INSERT INTO testruns (gateway_id, status, ip_address, created_utc, log) VALUES ($1, $2, $3, $4, $5)",
gateway_id,
status,
ip_address,