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:
parent
04f6607470
commit
f05d4c6c34
6 changed files with 171 additions and 3 deletions
|
|
@ -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(())
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue