Expose first_pool_ts in cached token list API and add it as a sort key

Adds the already-backfilled cached_token_metrics.first_pool_ts column to
the TokenListItemCached response (nullable, unix seconds) in all three
query paths (list_cached, search_cached, list_cached_by_ids), and accepts
by=first_pool_ts on the cached list endpoints so a frontend can fetch
"newest tokens" in one call.

NULLs (and the 0 "not backfilled" sentinel, via NULLIF) always sort last
regardless of order, so tokens without a timestamp never pollute newest
results. Includes a matching expression index and sort/serialization
tests for both the list and search paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jakobsn 2026-07-28 14:20:37 +02:00
parent 04f6607470
commit f05d4c6c34
6 changed files with 171 additions and 3 deletions

View file

@ -32,6 +32,8 @@ pub enum CachedSort {
Change7dUsdAsc, Change7dUsdAsc,
Apy30dDesc, Apy30dDesc,
Apy30dAsc, Apy30dAsc,
FirstPoolTsDesc,
FirstPoolTsAsc,
} }
pub fn order_clause(sort: CachedSort) -> &'static str { pub fn order_clause(sort: CachedSort) -> &'static str {
@ -90,6 +92,14 @@ pub fn order_clause(sort: CachedSort) -> &'static str {
} }
CachedSort::Apy30dDesc => "ORDER BY apy_30d_bp IS NULL, apy_30d_bp DESC, token_id ASC", CachedSort::Apy30dDesc => "ORDER BY apy_30d_bp IS NULL, apy_30d_bp DESC, token_id ASC",
CachedSort::Apy30dAsc => "ORDER BY apy_30d_bp IS NULL, apy_30d_bp ASC, token_id ASC", CachedSort::Apy30dAsc => "ORDER BY apy_30d_bp IS NULL, apy_30d_bp ASC, token_id ASC",
// NULLIF(...,0): 0 is the "not backfilled yet" sentinel — sort it with the NULLs,
// last, so un-backfilled tokens never pollute "newest" results.
CachedSort::FirstPoolTsDesc => {
"ORDER BY NULLIF(first_pool_ts,0) IS NULL, NULLIF(first_pool_ts,0) DESC, token_id ASC"
}
CachedSort::FirstPoolTsAsc => {
"ORDER BY NULLIF(first_pool_ts,0) IS NULL, NULLIF(first_pool_ts,0) ASC, token_id ASC"
}
} }
} }
@ -131,6 +141,8 @@ ON cached_token_metrics(score, trade_volume, token_id);
.execute(pool).await?; .execute(pool).await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_ctm_score_rank ON cached_token_metrics(score_rank, token_id);") sqlx::query("CREATE INDEX IF NOT EXISTS idx_ctm_score_rank ON cached_token_metrics(score_rank, token_id);")
.execute(pool).await?; .execute(pool).await?;
sqlx::query("CREATE INDEX IF NOT EXISTS idx_ctm_first_pool_ts_ord ON cached_token_metrics((NULLIF(first_pool_ts,0) IS NULL), NULLIF(first_pool_ts,0), token_id);")
.execute(pool).await?;
sqlx::query("ANALYZE;").execute(pool).await?; sqlx::query("ANALYZE;").execute(pool).await?;
sqlx::query("PRAGMA optimize;").execute(pool).await?; sqlx::query("PRAGMA optimize;").execute(pool).await?;
Ok(()) Ok(())

View file

@ -35,6 +35,7 @@ pub struct TokenListItemCached {
pub change_24h_usd_bp: Option<i64>, pub change_24h_usd_bp: Option<i64>,
pub change_7d_usd_bp: Option<i64>, pub change_7d_usd_bp: Option<i64>,
pub apy_30d_bp: Option<i64>, pub apy_30d_bp: Option<i64>,
pub first_pool_ts: Option<i64>,
} }
const TOKEN_METRICS_COLUMNS: &str = r#" const TOKEN_METRICS_COLUMNS: &str = r#"
@ -56,6 +57,7 @@ const TOKEN_METRICS_COLUMNS: &str = r#"
change_24h_usd_bp, change_24h_usd_bp,
change_7d_usd_bp, change_7d_usd_bp,
apy_30d_bp, apy_30d_bp,
first_pool_ts,
score_rank, score_rank,
bcmr_json, bcmr_json,
bcmr_well_known_json bcmr_well_known_json
@ -92,6 +94,10 @@ fn parse_row(row: &sqlx::sqlite::SqliteRow) -> Result<TokenListItemCached> {
let change_24h_usd_bp: Option<i64> = row.get("change_24h_usd_bp"); let change_24h_usd_bp: Option<i64> = row.get("change_24h_usd_bp");
let change_7d_usd_bp: Option<i64> = row.get("change_7d_usd_bp"); let change_7d_usd_bp: Option<i64> = row.get("change_7d_usd_bp");
let apy_30d_bp: Option<i64> = row.get("apy_30d_bp"); let apy_30d_bp: Option<i64> = row.get("apy_30d_bp");
// 0 is the "not backfilled yet" sentinel — treat it as unknown.
let first_pool_ts: Option<i64> = row
.get::<Option<i64>, _>("first_pool_ts")
.filter(|&ts| ts != 0);
let score_rank: i64 = row.get("score_rank"); let score_rank: i64 = row.get("score_rank");
let bcmr_json_str: Option<String> = row.get("bcmr_json"); let bcmr_json_str: Option<String> = row.get("bcmr_json");
let bcmr_wk_json_str: Option<String> = row.get("bcmr_well_known_json"); let bcmr_wk_json_str: Option<String> = row.get("bcmr_well_known_json");
@ -120,6 +126,7 @@ fn parse_row(row: &sqlx::sqlite::SqliteRow) -> Result<TokenListItemCached> {
change_24h_usd_bp, change_24h_usd_bp,
change_7d_usd_bp, change_7d_usd_bp,
apy_30d_bp, apy_30d_bp,
first_pool_ts,
}) })
} }

View file

@ -471,6 +471,70 @@ mod tests {
assert!(v.is_empty()); assert!(v.is_empty());
} }
#[tokio::test]
async fn test_db_list_tokens_cached_first_pool_ts_sort_nulls_last() {
let mock = mock_db_pool(|pool| async move { setup_basic_schemas(pool).await }).await;
let pool = &mock.cauldron_w;
let t_old = TokenID::from_byte_array([0x61; 32]).to_string();
let t_new = TokenID::from_byte_array([0x62; 32]).to_string();
let t_null = TokenID::from_byte_array([0x63; 32]).to_string();
let t_zero = TokenID::from_byte_array([0x64; 32]).to_string();
seed_cached_row(
pool, &t_old, 1, 1, 10, 10, "Old", "OLD", 1.0, 1.0, 1.0, 0, 0, 1.0, 1.0, 1.0, 0, 0, 0,
)
.await;
seed_cached_row(
pool, &t_new, 1, 1, 20, 20, "New", "NEW", 1.0, 1.0, 1.0, 0, 0, 1.0, 1.0, 1.0, 0, 0, 0,
)
.await;
seed_cached_row(
pool, &t_null, 1, 1, 30, 30, "Null", "NUL", 1.0, 1.0, 1.0, 0, 0, 1.0, 1.0, 1.0, 0, 0,
0,
)
.await;
seed_cached_row(
pool, &t_zero, 1, 1, 40, 40, "Zero", "ZER", 1.0, 1.0, 1.0, 0, 0, 1.0, 1.0, 1.0, 0, 0,
0,
)
.await;
// Old at t=1000, New at t=2000; Null stays NULL, Zero has the 0 sentinel.
for (token_hex, ts) in [(&t_old, 1_000i64), (&t_new, 2_000i64), (&t_zero, 0i64)] {
sqlx::query("UPDATE cached_token_metrics SET first_pool_ts = ? WHERE token_id = ?")
.bind(ts)
.bind(hex::decode(token_hex).expect("valid hex"))
.execute(pool)
.await
.unwrap();
}
// desc → newest first; NULL and 0 sentinel sort last
let v = db_list_tokens_cached(pool, 10, 0, CachedSort::FirstPoolTsDesc)
.await
.unwrap();
assert_eq!(v.len(), 4);
assert_eq!(v[0].display_name.as_deref(), Some("New"));
assert_eq!(v[0].first_pool_ts, Some(2_000));
assert_eq!(v[1].display_name.as_deref(), Some("Old"));
assert_eq!(v[1].first_pool_ts, Some(1_000));
assert_eq!(v[2].display_name.as_deref(), Some("Null"));
assert_eq!(v[2].first_pool_ts, None);
assert_eq!(v[3].display_name.as_deref(), Some("Zero"));
assert_eq!(v[3].first_pool_ts, None);
// asc → oldest first; NULL and 0 sentinel still last
let v = db_list_tokens_cached(pool, 10, 0, CachedSort::FirstPoolTsAsc)
.await
.unwrap();
let names: Vec<Option<&str>> = v.iter().map(|x| x.display_name.as_deref()).collect();
assert_eq!(
names,
vec![Some("Old"), Some("New"), Some("Null"), Some("Zero")]
);
}
// ---------- APY calculator guard (no data → 0) ---------- // ---------- APY calculator guard (no data → 0) ----------
#[tokio::test] #[tokio::test]

View file

@ -235,7 +235,7 @@ pub async fn db_search_tokens_cached(
price_now, price_24h, price_7d, change_24h_bp, change_7d_bp, price_now, price_24h, price_7d, change_24h_bp, change_7d_bp,
display_name, display_symbol, display_name, display_symbol,
price_now_usd, price_24h_usd, price_7d_usd, change_24h_usd_bp, change_7d_usd_bp, price_now_usd, price_24h_usd, price_7d_usd, change_24h_usd_bp, change_7d_usd_bp,
apy_30d_bp, score_rank, bcmr_json, bcmr_well_known_json apy_30d_bp, first_pool_ts, score_rank, bcmr_json, bcmr_well_known_json
FROM cached_token_metrics FROM cached_token_metrics
{where_sql} {where_sql}
{order_sql} {order_sql}
@ -292,6 +292,10 @@ pub async fn db_search_tokens_cached(
let change_24h_usd_bp: Option<i64> = row.get("change_24h_usd_bp"); let change_24h_usd_bp: Option<i64> = row.get("change_24h_usd_bp");
let change_7d_usd_bp: Option<i64> = row.get("change_7d_usd_bp"); let change_7d_usd_bp: Option<i64> = row.get("change_7d_usd_bp");
let apy_30d_bp: Option<i64> = row.get("apy_30d_bp"); let apy_30d_bp: Option<i64> = row.get("apy_30d_bp");
// 0 is the "not backfilled yet" sentinel — treat it as unknown.
let first_pool_ts: Option<i64> = row
.get::<Option<i64>, _>("first_pool_ts")
.filter(|&ts| ts != 0);
let bcmr_json_str: Option<String> = row.get("bcmr_json"); let bcmr_json_str: Option<String> = row.get("bcmr_json");
let bcmr_wk_json_str: Option<String> = row.get("bcmr_well_known_json"); let bcmr_wk_json_str: Option<String> = row.get("bcmr_well_known_json");
let bcmr: Option<ParsedBCMR> = bcmr_json_str.and_then(|s| serde_json::from_str(&s).ok()); let bcmr: Option<ParsedBCMR> = bcmr_json_str.and_then(|s| serde_json::from_str(&s).ok());
@ -322,6 +326,7 @@ pub async fn db_search_tokens_cached(
change_24h_usd_bp, change_24h_usd_bp,
change_7d_usd_bp, change_7d_usd_bp,
apy_30d_bp, apy_30d_bp,
first_pool_ts,
}); });
} }

View file

@ -395,6 +395,77 @@ mod tests {
assert_eq!(names, vec![Some("HighUSD"), Some("MidUSD"), Some("LowUSD")]); assert_eq!(names, vec![Some("HighUSD"), Some("MidUSD"), Some("LowUSD")]);
} }
#[tokio::test]
async fn test_db_search_tokens_cached_first_pool_ts_sort_nulls_last() {
let mock = mock_db_pool(|pool: SqlitePool| async move {
cauldron_prepare_tables(&pool).await;
bcmr_prepare_tables(&pool).await;
crc20_prepare_tables(&pool).await;
setup_cached_tables(&pool).await;
})
.await;
let t_old = TokenID::from_byte_array([0x66; 32]).to_string();
let t_new = TokenID::from_byte_array([0x77; 32]).to_string();
let t_null = TokenID::from_byte_array([0x88; 32]).to_string();
for (token_hex, name, sym) in [
(&t_old, "Old", "OLD"),
(&t_new, "New", "NEW"),
(&t_null, "Null", "NUL"),
] {
seed_cached_row_simple(
&mock.cauldron_w,
token_hex,
name,
sym,
1_000,
100,
1,
1,
1.0,
1.0,
1.0,
0,
0,
1.0,
1.0,
1.0,
0,
0,
0,
)
.await;
}
// Old at t=1000, New at t=2000; Null has no first_pool_ts.
for (token_hex, ts) in [(&t_old, 1_000i64), (&t_new, 2_000i64)] {
sqlx::query("UPDATE cached_token_metrics SET first_pool_ts = ? WHERE token_id = ?")
.bind(ts)
.bind(hex_to_blob(token_hex))
.execute(&mock.cauldron_w)
.await
.unwrap();
}
// first_pool_ts desc → newest first, NULL last
let v = db_search_tokens_cached(&mock.cauldron_r, "", CachedSort::FirstPoolTsDesc, 10, 0)
.await
.unwrap();
let names: Vec<Option<&str>> = v.iter().map(|x| x.display_name.as_deref()).collect();
assert_eq!(names, vec![Some("New"), Some("Old"), Some("Null")]);
assert_eq!(v[0].first_pool_ts, Some(2_000));
assert_eq!(v[1].first_pool_ts, Some(1_000));
assert_eq!(v[2].first_pool_ts, None);
// first_pool_ts asc → oldest first, NULL still last
let v = db_search_tokens_cached(&mock.cauldron_r, "", CachedSort::FirstPoolTsAsc, 10, 0)
.await
.unwrap();
let names: Vec<Option<&str>> = v.iter().map(|x| x.display_name.as_deref()).collect();
assert_eq!(names, vec![Some("Old"), Some("New"), Some("Null")]);
}
#[tokio::test] #[tokio::test]
// Ensure that we are NOT looking for token_id when using incomplete hex. // Ensure that we are NOT looking for token_id when using incomplete hex.
async fn test_search_token_by_inexact_hex_id() { async fn test_search_token_by_inexact_hex_id() {

View file

@ -75,7 +75,7 @@ pub async fn search_by_volume(search_query: &str, db: &State<DB>) -> CachedApiRe
/// - q: Search query string /// - q: Search query string
/// - limit: Maximum number of results (default: 250) /// - limit: Maximum number of results (default: 250)
/// - offset: Pagination offset (default: 0) /// - offset: Pagination offset (default: 0)
/// - by: Sort field (`name`, `symbol`, `tvl`, `volume`) /// - by: Sort field (`name`, `symbol`, `tvl`, `volume`, `first_pool_ts`)
/// - order: Sort direction (`asc`, `desc`) /// - order: Sort direction (`asc`, `desc`)
/// ///
/// **Response:** A direct JSON array. /// **Response:** A direct JSON array.
@ -191,6 +191,15 @@ fn parse_cached_sort(by: Option<String>, order: Option<String>) -> CachedSort {
} }
} }
// First pool creation timestamp (desc = newest tokens first)
Some("first_pool_ts") => {
if desc {
CachedSort::FirstPoolTsDesc
} else {
CachedSort::FirstPoolTsAsc
}
}
// default // default
_ => { _ => {
if desc { if desc {
@ -209,7 +218,7 @@ fn parse_cached_sort(by: Option<String>, order: Option<String>) -> CachedSort {
/// ///
/// - limit: Maximum number of results (default: 250) /// - limit: Maximum number of results (default: 250)
/// - offset: Pagination offset (default: 0) /// - offset: Pagination offset (default: 0)
/// - by: Sort field (`name`, `symbol`, `tvl`, `volume`, `score`) /// - by: Sort field (`name`, `symbol`, `tvl`, `volume`, `score`, `first_pool_ts`)
/// - order: Sort direction (`asc`, `desc`) /// - order: Sort direction (`asc`, `desc`)
/// ///
/// **Response:** A direct JSON array. /// **Response:** A direct JSON array.