diff --git a/src/concentration.mjs b/src/concentration.mjs index f2f243174..11204e2d8 100644 --- a/src/concentration.mjs +++ b/src/concentration.mjs @@ -331,8 +331,8 @@ export async function loadChainConcentration(d1) { // bounded to a chartable range because each day needs its full per-UID // distribution (concentration can't be a cheap SQL GROUP BY like the structural // history) — a row cap then guards an unexpectedly large subnet. -const CONCENTRATION_HISTORY_WINDOWS = { "7d": 7, "30d": 30, "90d": 90 }; -const DEFAULT_CONCENTRATION_HISTORY_WINDOW = "30d"; +export const CONCENTRATION_HISTORY_WINDOWS = { "7d": 7, "30d": 30, "90d": 90 }; +export const DEFAULT_CONCENTRATION_HISTORY_WINDOW = "30d"; // Safety valve on the raw per-UID read (≈256 UIDs × 90d ≈ 23k; this leaves head // room and the builder drops a truncated oldest day so every point is complete). export const CONCENTRATION_HISTORY_ROW_CAP = 50_000; diff --git a/src/subnet-performance.mjs b/src/subnet-performance.mjs index e3c64a50f..502e4a970 100644 --- a/src/subnet-performance.mjs +++ b/src/subnet-performance.mjs @@ -183,8 +183,8 @@ export async function loadSubnetPerformance(d1, netuid) { // bounded by a row cap that then drops a truncated oldest day. const DAY_MS = 24 * 60 * 60 * 1000; -const PERFORMANCE_HISTORY_WINDOWS = { "7d": 7, "30d": 30, "90d": 90 }; -const DEFAULT_PERFORMANCE_HISTORY_WINDOW = "30d"; +export const PERFORMANCE_HISTORY_WINDOWS = { "7d": 7, "30d": 30, "90d": 90 }; +export const DEFAULT_PERFORMANCE_HISTORY_WINDOW = "30d"; // Safety valve on the raw per-UID read (≈256 UIDs × 90d ≈ 23k; leaves head room // and the builder drops a truncated oldest day so every point is complete). export const PERFORMANCE_HISTORY_ROW_CAP = 50_000; diff --git a/src/subnet-yield.mjs b/src/subnet-yield.mjs index 3dbaac93b..10af78063 100644 --- a/src/subnet-yield.mjs +++ b/src/subnet-yield.mjs @@ -239,8 +239,8 @@ export async function loadSubnetYield(d1, netuid) { // oldest day. const DAY_MS = 24 * 60 * 60 * 1000; -const YIELD_HISTORY_WINDOWS = { "7d": 7, "30d": 30, "90d": 90 }; -const DEFAULT_YIELD_HISTORY_WINDOW = "30d"; +export const YIELD_HISTORY_WINDOWS = { "7d": 7, "30d": 30, "90d": 90 }; +export const DEFAULT_YIELD_HISTORY_WINDOW = "30d"; // Safety valve on the raw per-UID read (≈256 UIDs × 90d ≈ 23k; leaves head room // and the builder drops a truncated oldest day so every point is complete). export const YIELD_HISTORY_ROW_CAP = 50_000; diff --git a/src/turnover.mjs b/src/turnover.mjs index 0d531e4ee..e3db18b4e 100644 --- a/src/turnover.mjs +++ b/src/turnover.mjs @@ -272,7 +272,7 @@ export function buildTurnoverChanges( }; } -function turnoverChangeDetail(changes) { +export function turnoverChangeDetail(changes) { return { validators_entered_count: changes.validators_entered_count, validators_exited_count: changes.validators_exited_count, diff --git a/tests/data-api.test.mjs b/tests/data-api.test.mjs index 9d502f673..22a7110bf 100644 --- a/tests/data-api.test.mjs +++ b/tests/data-api.test.mjs @@ -1259,6 +1259,407 @@ test("GET /api/v1/accounts respects an explicit sort/limit", async () => { expect(body.limit).toBe(5); }); +// #4832 Tier 2b: the neuron_daily-history routes -- structural history, +// concentration/performance/yield history, and chain/subnet turnover + movers +// (the boundary-snapshot routes translate SQLite's date(MAX(snapshot_date), +// '-N days') to Postgres's native `MAX(snapshot_date) - N::int`). + +test("GET /api/v1/validators/:hotkey/history shapes the validator's cross-subnet daily trend", async () => { + mockRows.current = [ + { + snapshot_date: "2026-07-01", + subnet_count: "2", + total_stake_tao: "10", + total_emission_tao: "1", + }, + ]; + const res = await req(`/api/v1/validators/${SS58}/history`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.hotkey).toBe(SS58); + expect(body.points[0].snapshot_date).toBe("2026-07-01"); + expect(queryText()).toContain("FROM neuron_daily"); + expect(queryText()).toContain("validator_permit = TRUE"); +}); + +test("GET /api/v1/subnets/:netuid/neurons/:uid/history shapes the per-UID daily snapshot trend", async () => { + mockRows.current = [{ ...NEURON_ROW, snapshot_date: "2026-07-01" }]; + const res = await req("/api/v1/subnets/7/neurons/3/history"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.netuid).toBe(7); + expect(body.uid).toBe(3); + expect(body.points[0].snapshot_date).toBe("2026-07-01"); + expect(queryText()).toContain("FROM neuron_daily"); + expect(queryText()).toContain("WHERE netuid ="); +}); + +test("GET /api/v1/subnets/:netuid/history shapes the daily neuron/validator/stake trend", async () => { + mockRows.current = [ + { + snapshot_date: "2026-07-01", + neuron_count: "5", + validator_count: "2", + total_stake_tao: "10", + total_emission_tao: "1", + }, + ]; + const res = await req("/api/v1/subnets/7/history"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.netuid).toBe(7); + expect(body.points[0].neuron_count).toBe(5); + expect(queryText()).toContain("SUM(validator_permit::int)"); +}); + +test("GET /api/v1/subnets/:netuid/concentration/history shapes the per-day concentration trend", async () => { + mockRows.current = [ + { snapshot_date: "2026-07-01", stake_tao: "10", emission_tao: "1" }, + ]; + const res = await req("/api/v1/subnets/7/concentration/history"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.netuid).toBe(7); + expect(body.points[0].snapshot_date).toBe("2026-07-01"); + expect(queryText()).toContain("FROM neuron_daily"); +}); + +test("GET /api/v1/subnets/:netuid/performance/history shapes the per-day reward-flow trend", async () => { + mockRows.current = [ + { + snapshot_date: "2026-07-01", + incentive: "0.5", + dividends: "0.3", + trust: "0.9", + consensus: "0.8", + validator_permit: true, + active: true, + }, + ]; + const res = await req("/api/v1/subnets/7/performance/history"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.netuid).toBe(7); + expect(body.points[0].snapshot_date).toBe("2026-07-01"); +}); + +test("GET /api/v1/subnets/:netuid/yield/history shapes the per-day yield trend", async () => { + mockRows.current = [ + { + snapshot_date: "2026-07-01", + validator_permit: true, + stake_tao: "10", + emission_tao: "1", + }, + ]; + const res = await req("/api/v1/subnets/7/yield/history"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.netuid).toBe(7); + expect(body.points[0].snapshot_date).toBe("2026-07-01"); +}); + +test("GET /api/v1/chain/turnover shapes network-wide validator-set churn between boundary snapshots", async () => { + // Queue slot 0 is the unconditional `SET statement_timeout` call, slot 1 the + // MIN/MAX boundary-date bounds query, slot 2 the two boundary snapshots' rows. + mockQueue.current = [ + [], + [{ start_date: "2026-06-01", end_date: "2026-07-01" }], + [ + { + snapshot_date: "2026-06-01", + netuid: 7, + hotkey: "5Hot", + validator_permit: true, + }, + { + snapshot_date: "2026-07-01", + netuid: 7, + hotkey: "5Hot2", + validator_permit: true, + }, + ], + ]; + const res = await req("/api/v1/chain/turnover"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.comparable).toBe(true); + expect(body.start_date).toBe("2026-06-01"); + expect(body.end_date).toBe("2026-07-01"); + expect(queryText()).toContain("MAX(snapshot_date)"); +}); + +test("GET /api/v1/subnets/:netuid/turnover shapes one subnet's validator-set churn", async () => { + mockQueue.current = [ + [], + [{ start_date: "2026-06-01", end_date: "2026-07-01" }], + [ + { + snapshot_date: "2026-06-01", + uid: 3, + hotkey: "5Hot", + validator_permit: true, + }, + { + snapshot_date: "2026-07-01", + uid: 3, + hotkey: "5Hot", + validator_permit: true, + }, + ], + ]; + const res = await req("/api/v1/subnets/7/turnover"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.netuid).toBe(7); + expect(body.comparable).toBe(true); +}); + +test("GET /api/v1/subnets/:netuid/turnover?changes=true includes the entered/exited detail", async () => { + mockQueue.current = [ + [], + [{ start_date: "2026-06-01", end_date: "2026-07-01" }], + [ + { + snapshot_date: "2026-06-01", + uid: 3, + hotkey: "5Hot", + validator_permit: true, + }, + { + snapshot_date: "2026-07-01", + uid: 4, + hotkey: "5Hot2", + validator_permit: true, + }, + ], + ]; + const res = await req("/api/v1/subnets/7/turnover?changes=true"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.changes).toBeDefined(); + expect(Array.isArray(body.changes.validators_entered)).toBe(true); +}); + +test("GET /api/v1/subnets/movers shapes every subnet ranked by its stake/emission/validator change", async () => { + mockQueue.current = [ + [], + [{ start_date: "2026-06-01", end_date: "2026-07-01" }], + [ + { + netuid: 7, + snapshot_date: "2026-06-01", + neuron_count: "5", + validator_count: "2", + total_stake_tao: "10", + total_emission_tao: "1", + }, + { + netuid: 7, + snapshot_date: "2026-07-01", + neuron_count: "6", + validator_count: "3", + total_stake_tao: "20", + total_emission_tao: "2", + }, + ], + ]; + const res = await req("/api/v1/subnets/movers"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.subnet_count).toBe(1); + expect(body.movers[0].netuid).toBe(7); + expect(queryText()).toContain("SUM(validator_permit::int)"); +}); + +test("GET /api/v1/validators/:hotkey/history falls back to the default window on an unrecognized value", async () => { + mockRows.current = [ + { + snapshot_date: "2026-07-01", + subnet_count: "1", + total_stake_tao: "5", + total_emission_tao: "0.5", + }, + ]; + const res = await req(`/api/v1/validators/${SS58}/history?window=bogus`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.window).toBe("30d"); + expect(queryText()).toContain("snapshot_date >="); +}); + +test("GET /api/v1/validators/:hotkey/history?window=all skips the snapshot_date cutoff", async () => { + mockRows.current = [ + { + snapshot_date: "2026-01-01", + subnet_count: "1", + total_stake_tao: "5", + total_emission_tao: "0.5", + }, + ]; + const res = await req(`/api/v1/validators/${SS58}/history?window=all`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.window).toBe("all"); + expect(queryText()).not.toContain("snapshot_date >="); +}); + +test("GET /api/v1/subnets/:netuid/neurons/:uid/history?window=all skips the snapshot_date cutoff", async () => { + mockRows.current = [{ ...NEURON_ROW, snapshot_date: "2026-01-01" }]; + const res = await req("/api/v1/subnets/7/neurons/3/history?window=all"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.window).toBe("all"); + expect(queryText()).not.toContain("snapshot_date >="); +}); + +test("GET /api/v1/subnets/:netuid/history?window=all skips the snapshot_date cutoff", async () => { + mockRows.current = [ + { + snapshot_date: "2026-01-01", + neuron_count: "5", + validator_count: "2", + total_stake_tao: "10", + total_emission_tao: "1", + }, + ]; + const res = await req("/api/v1/subnets/7/history?window=all"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.window).toBe("all"); + expect(queryText()).not.toContain("snapshot_date >="); +}); + +test("GET /api/v1/chain/turnover falls back on an unrecognized window and respects an explicit limit", async () => { + mockQueue.current = [ + [], + [{ start_date: "2026-06-01", end_date: "2026-07-01" }], + [ + { + snapshot_date: "2026-06-01", + netuid: 7, + hotkey: "5Hot", + validator_permit: true, + }, + { + snapshot_date: "2026-07-01", + netuid: 7, + hotkey: "5Hot2", + validator_permit: true, + }, + ], + ]; + const res = await req("/api/v1/chain/turnover?window=bogus&limit=5"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.window).toBe("30d"); +}); + +test("GET /api/v1/chain/turnover reports comparable:false on a cold store (no boundary snapshots)", async () => { + mockQueue.current = [[], []]; + const res = await req("/api/v1/chain/turnover"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.comparable).toBe(false); + expect(body.start_date).toBeNull(); +}); + +test("GET /api/v1/subnets/:netuid/turnover falls back on an unrecognized window", async () => { + mockQueue.current = [ + [], + [{ start_date: "2026-06-01", end_date: "2026-07-01" }], + [ + { + snapshot_date: "2026-06-01", + uid: 3, + hotkey: "5Hot", + validator_permit: true, + }, + { + snapshot_date: "2026-07-01", + uid: 3, + hotkey: "5Hot", + validator_permit: true, + }, + ], + ]; + const res = await req("/api/v1/subnets/7/turnover?window=bogus"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.window).toBe("30d"); +}); + +test("GET /api/v1/subnets/:netuid/turnover?window=all skips the newest-snapshot anchor", async () => { + mockQueue.current = [ + [], + [{ start_date: "2026-01-01", end_date: "2026-07-01" }], + [ + { + snapshot_date: "2026-01-01", + uid: 3, + hotkey: "5Hot", + validator_permit: true, + }, + { + snapshot_date: "2026-07-01", + uid: 3, + hotkey: "5Hot", + validator_permit: true, + }, + ], + ]; + const res = await req("/api/v1/subnets/7/turnover?window=all"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.window).toBe("all"); + expect(queryText()).not.toContain("MAX(snapshot_date) -"); +}); + +test("GET /api/v1/subnets/:netuid/turnover reports comparable:false on a cold store (no boundary snapshots)", async () => { + mockQueue.current = [[], []]; + const res = await req("/api/v1/subnets/7/turnover"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.comparable).toBe(false); +}); + +test("GET /api/v1/subnets/movers falls back on an unrecognized window and respects an explicit limit", async () => { + mockQueue.current = [ + [], + [{ start_date: "2026-06-01", end_date: "2026-07-01" }], + [ + { + netuid: 7, + snapshot_date: "2026-06-01", + neuron_count: "5", + validator_count: "2", + total_stake_tao: "10", + total_emission_tao: "1", + }, + { + netuid: 7, + snapshot_date: "2026-07-01", + neuron_count: "6", + validator_count: "3", + total_stake_tao: "20", + total_emission_tao: "2", + }, + ], + ]; + const res = await req("/api/v1/subnets/movers?window=bogus&limit=5"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.window).toBe("30d"); +}); + +test("GET /api/v1/subnets/movers reports subnet_count:0 on a cold store (no boundary snapshots)", async () => { + mockQueue.current = [[], []]; + const res = await req("/api/v1/subnets/movers"); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.subnet_count).toBe(0); +}); + // #4771: POST /api/v1/internal/neurons-sync -- the one write route in this // otherwise-read-only Worker (see workers/data-api.mjs's handleNeuronsSync). function neuronSyncRow(overrides = {}) { diff --git a/tests/request-handlers-entities.test.mjs b/tests/request-handlers-entities.test.mjs index 898542904..afacd2d45 100644 --- a/tests/request-handlers-entities.test.mjs +++ b/tests/request-handlers-entities.test.mjs @@ -29,6 +29,7 @@ import { handleAccountAxonRemovals, handleAccountPrometheus, handleAccountDeregistrations, + handleValidatorHistory, handleNeuronHistory, handleSubnetHistory, handleSubnetIdentityHistory, @@ -43,6 +44,7 @@ import { handleSubnetConcentrationHistory, handleSubnetPerformanceHistory, handleSubnetYieldHistory, + handleChainTurnover, handleSubnetTurnover, handleSubnetStakeFlow, handleSubnetWeights, @@ -8140,6 +8142,358 @@ describe("D1 -> Postgres serving-cutover flag (#4656 followup)", () => { assert.equal(body.data.marker, undefined); assert.ok(captures.sql.length > 0); }); + + // #4832 Tier 2b: the 9 neuron_daily-history handlers (structural history, + // concentration/performance/yield history, chain/subnet turnover, movers). + + test("handleValidatorHistory: flag=postgres uses Postgres data, D1 never queried", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => + Response.json({ schema_version: 1, marker: "pg", points: [] }), + }; + const body = await json( + await handleValidatorHistory( + req(`/api/v1/validators/${SS58}/history`), + env, + SS58, + url(`/api/v1/validators/${SS58}/history`), + ), + ); + assert.equal(body.data.marker, "pg"); + assert.deepEqual(captures.sql, []); + }); + + test("handleValidatorHistory: flag=postgres falls back to D1 on failure", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => { + throw new Error("boom"); + }, + }; + const body = await json( + await handleValidatorHistory( + req(`/api/v1/validators/${SS58}/history`), + env, + SS58, + url(`/api/v1/validators/${SS58}/history`), + ), + ); + assert.equal(body.data.marker, undefined); + assert.ok(captures.sql.length > 0); + }); + + test("handleNeuronHistory: flag=postgres uses Postgres data, D1 never queried", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => + Response.json({ schema_version: 1, marker: "pg", points: [] }), + }; + const body = await json( + await handleNeuronHistory( + req(`/api/v1/subnets/${NETUID}/neurons/1/history`), + env, + NETUID, + 1, + url(`/api/v1/subnets/${NETUID}/neurons/1/history`), + ), + ); + assert.equal(body.data.marker, "pg"); + assert.deepEqual(captures.sql, []); + }); + + test("handleNeuronHistory: flag=postgres falls back to D1 on failure", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => { + throw new Error("boom"); + }, + }; + const body = await json( + await handleNeuronHistory( + req(`/api/v1/subnets/${NETUID}/neurons/1/history`), + env, + NETUID, + 1, + url(`/api/v1/subnets/${NETUID}/neurons/1/history`), + ), + ); + assert.equal(body.data.marker, undefined); + assert.ok(captures.sql.length > 0); + }); + + test("handleSubnetHistory: flag=postgres uses Postgres data, D1 never queried", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => + Response.json({ schema_version: 1, marker: "pg", points: [] }), + }; + const body = await json( + await handleSubnetHistory( + req(`/api/v1/subnets/${NETUID}/history`), + env, + NETUID, + url(`/api/v1/subnets/${NETUID}/history`), + ), + ); + assert.equal(body.data.marker, "pg"); + assert.deepEqual(captures.sql, []); + }); + + test("handleSubnetHistory: flag=postgres falls back to D1 on failure", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => { + throw new Error("boom"); + }, + }; + const body = await json( + await handleSubnetHistory( + req(`/api/v1/subnets/${NETUID}/history`), + env, + NETUID, + url(`/api/v1/subnets/${NETUID}/history`), + ), + ); + assert.equal(body.data.marker, undefined); + assert.ok(captures.sql.length > 0); + }); + + test("handleSubnetConcentrationHistory: flag=postgres uses Postgres data, D1 never queried", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => + Response.json({ schema_version: 1, marker: "pg", points: [] }), + }; + const body = await json( + await handleSubnetConcentrationHistory( + req(`/api/v1/subnets/${NETUID}/concentration/history`), + env, + NETUID, + url(`/api/v1/subnets/${NETUID}/concentration/history`), + ), + ); + assert.equal(body.data.marker, "pg"); + assert.deepEqual(captures.sql, []); + }); + + test("handleSubnetConcentrationHistory: flag=postgres falls back to D1 on failure", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => { + throw new Error("boom"); + }, + }; + const body = await json( + await handleSubnetConcentrationHistory( + req(`/api/v1/subnets/${NETUID}/concentration/history`), + env, + NETUID, + url(`/api/v1/subnets/${NETUID}/concentration/history`), + ), + ); + assert.equal(body.data.marker, undefined); + assert.ok(captures.sql.length > 0); + }); + + test("handleSubnetPerformanceHistory: flag=postgres uses Postgres data, D1 never queried", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => + Response.json({ schema_version: 1, marker: "pg", points: [] }), + }; + const body = await json( + await handleSubnetPerformanceHistory( + req(`/api/v1/subnets/${NETUID}/performance/history`), + env, + NETUID, + url(`/api/v1/subnets/${NETUID}/performance/history`), + ), + ); + assert.equal(body.data.marker, "pg"); + assert.deepEqual(captures.sql, []); + }); + + test("handleSubnetPerformanceHistory: flag=postgres falls back to D1 on failure", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => { + throw new Error("boom"); + }, + }; + const body = await json( + await handleSubnetPerformanceHistory( + req(`/api/v1/subnets/${NETUID}/performance/history`), + env, + NETUID, + url(`/api/v1/subnets/${NETUID}/performance/history`), + ), + ); + assert.equal(body.data.marker, undefined); + assert.ok(captures.sql.length > 0); + }); + + test("handleSubnetYieldHistory: flag=postgres uses Postgres data, D1 never queried", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => + Response.json({ schema_version: 1, marker: "pg", points: [] }), + }; + const body = await json( + await handleSubnetYieldHistory( + req(`/api/v1/subnets/${NETUID}/yield/history`), + env, + NETUID, + url(`/api/v1/subnets/${NETUID}/yield/history`), + ), + ); + assert.equal(body.data.marker, "pg"); + assert.deepEqual(captures.sql, []); + }); + + test("handleSubnetYieldHistory: flag=postgres falls back to D1 on failure", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => { + throw new Error("boom"); + }, + }; + const body = await json( + await handleSubnetYieldHistory( + req(`/api/v1/subnets/${NETUID}/yield/history`), + env, + NETUID, + url(`/api/v1/subnets/${NETUID}/yield/history`), + ), + ); + assert.equal(body.data.marker, undefined); + assert.ok(captures.sql.length > 0); + }); + + test("handleChainTurnover: flag=postgres uses Postgres data, D1 never queried", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => + Response.json({ schema_version: 1, marker: "pg", subnets: [] }), + }; + const body = await json( + await handleChainTurnover( + req("/api/v1/chain/turnover"), + env, + url("/api/v1/chain/turnover"), + ), + ); + assert.equal(body.data.marker, "pg"); + assert.deepEqual(captures.sql, []); + }); + + test("handleChainTurnover: flag=postgres falls back to D1 on failure", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => { + throw new Error("boom"); + }, + }; + const body = await json( + await handleChainTurnover( + req("/api/v1/chain/turnover"), + env, + url("/api/v1/chain/turnover"), + ), + ); + assert.equal(body.data.marker, undefined); + assert.ok(captures.sql.length > 0); + }); + + test("handleSubnetTurnover: flag=postgres uses Postgres data, D1 never queried", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => + Response.json({ schema_version: 1, marker: "pg", subnets: [] }), + }; + const body = await json( + await handleSubnetTurnover( + req(`/api/v1/subnets/${NETUID}/turnover`), + env, + NETUID, + url(`/api/v1/subnets/${NETUID}/turnover`), + ), + ); + assert.equal(body.data.marker, "pg"); + assert.deepEqual(captures.sql, []); + }); + + test("handleSubnetTurnover: flag=postgres falls back to D1 on failure", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => { + throw new Error("boom"); + }, + }; + const body = await json( + await handleSubnetTurnover( + req(`/api/v1/subnets/${NETUID}/turnover`), + env, + NETUID, + url(`/api/v1/subnets/${NETUID}/turnover`), + ), + ); + assert.equal(body.data.marker, undefined); + assert.ok(captures.sql.length > 0); + }); + + test("handleSubnetMovers: flag=postgres uses Postgres data, D1 never queried", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => + Response.json({ schema_version: 1, marker: "pg", movers: [] }), + }; + const body = await json( + await handleSubnetMovers( + req("/api/v1/subnets/movers"), + env, + url("/api/v1/subnets/movers"), + ), + ); + assert.equal(body.data.marker, "pg"); + assert.deepEqual(captures.sql, []); + }); + + test("handleSubnetMovers: flag=postgres falls back to D1 on failure", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_NEURONS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => { + throw new Error("boom"); + }, + }; + const body = await json( + await handleSubnetMovers( + req("/api/v1/subnets/movers"), + env, + url("/api/v1/subnets/movers"), + ), + ); + assert.equal(body.data.marker, undefined); + assert.ok(captures.sql.length > 0); + }); }); // ---- Cross-handler contract smoke tests ------------------------------------- diff --git a/workers/data-api.mjs b/workers/data-api.mjs index 69e723241..948dca3bc 100644 --- a/workers/data-api.mjs +++ b/workers/data-api.mjs @@ -52,12 +52,54 @@ import { buildRuntimeVersionHistory } from "../src/runtime-versions.mjs"; import { buildConcentration, buildChainConcentration, + buildConcentrationHistory, + CONCENTRATION_HISTORY_ROW_CAP, + CONCENTRATION_HISTORY_WINDOWS, + DEFAULT_CONCENTRATION_HISTORY_WINDOW, } from "../src/concentration.mjs"; -import { buildSubnetPerformance } from "../src/subnet-performance.mjs"; +import { + buildSubnetPerformance, + buildSubnetPerformanceHistory, + PERFORMANCE_HISTORY_ROW_CAP, + PERFORMANCE_HISTORY_WINDOWS, + DEFAULT_PERFORMANCE_HISTORY_WINDOW, +} from "../src/subnet-performance.mjs"; import { buildChainPerformance } from "../src/chain-performance.mjs"; import { buildChainYield } from "../src/chain-yield.mjs"; -import { buildSubnetYield } from "../src/subnet-yield.mjs"; +import { + buildSubnetYield, + buildSubnetYieldHistory, + YIELD_HISTORY_ROW_CAP, + YIELD_HISTORY_WINDOWS, + DEFAULT_YIELD_HISTORY_WINDOW, +} from "../src/subnet-yield.mjs"; import { buildAccountPortfolio } from "../src/account-portfolio.mjs"; +import { + buildNeuronHistory, + buildSubnetHistory, + HISTORY_WINDOWS, + DEFAULT_HISTORY_WINDOW, + MAX_HISTORY_POINTS, +} from "../src/neuron-history.mjs"; +import { buildValidatorHistory } from "../src/validator-history.mjs"; +import { + buildTurnover, + buildTurnoverChanges, + turnoverChangeDetail, +} from "../src/turnover.mjs"; +import { + buildChainTurnover, + CHAIN_TURNOVER_WINDOWS, + DEFAULT_CHAIN_TURNOVER_WINDOW, + CHAIN_TURNOVER_LIMIT_DEFAULT, +} from "../src/chain-turnover.mjs"; +import { + buildMovers, + MOVERS_WINDOWS, + DEFAULT_MOVERS_WINDOW, + DEFAULT_MOVERS_SORT, + MOVERS_LIMIT_DEFAULT, +} from "../src/movers.mjs"; import { buildAccountsList, DEFAULT_ACCOUNTS_LIST_SORT, @@ -195,6 +237,21 @@ function windowLabelFor(url, windows, defaultLabel) { return Object.hasOwn(windows, label) ? label : defaultLabel; } +// Resolve a ?window= label to a YYYY-MM-DD cutoff date for a neuron_daily +// `snapshot_date` (a native DATE column, not an epoch-ms timestamp), matching +// the D1 loaders' `new Date(Date.now() - days*DAY_MS).toISOString().slice(0,10)` +// exactly. A `null` day value (e.g. HISTORY_WINDOWS.all) means no lower bound. +function windowCutoffDate(url, windows, defaultLabel) { + const label = url.searchParams.get("window") || defaultLabel; + const days = Object.hasOwn(windows, label) + ? windows[label] + : windows[defaultLabel]; + if (days == null) return null; + return new Date(Date.now() - days * ANALYTICS_DAY_MS) + .toISOString() + .slice(0, 10); +} + // The newest `last_observed` epoch-ms across a row set, as an ISO string (or // null for an empty/cold result) -- matches every account-level D1 loader's // own `{ data, generatedAt }` contract (loadValidatorNominators, @@ -2248,6 +2305,347 @@ export default { ); } + // GET /api/v1/validators/:hotkey/history?window= (#4832 Tier 2b): one + // validator's staked-subnet-count + stake/emission totals over time, + // mirroring src/validator-history.mjs's buildValidatorHistory. + const validatorHistoryMatch = url.pathname.match( + /^\/api\/v1\/validators\/([^/]+)\/history$/, + ); + if (validatorHistoryMatch) { + const hotkey = decodeURIComponent(validatorHistoryMatch[1]); + const cutoff = windowCutoffDate( + url, + HISTORY_WINDOWS, + DEFAULT_HISTORY_WINDOW, + ); + const rows = cutoff + ? await sql` + SELECT snapshot_date::text AS snapshot_date, COUNT(DISTINCT netuid) AS subnet_count, + SUM(stake_tao) AS total_stake_tao, SUM(emission_tao) AS total_emission_tao + FROM neuron_daily + WHERE hotkey = ${hotkey} AND validator_permit = TRUE AND snapshot_date >= ${cutoff} + GROUP BY snapshot_date ORDER BY snapshot_date DESC LIMIT ${MAX_HISTORY_POINTS}` + : await sql` + SELECT snapshot_date::text AS snapshot_date, COUNT(DISTINCT netuid) AS subnet_count, + SUM(stake_tao) AS total_stake_tao, SUM(emission_tao) AS total_emission_tao + FROM neuron_daily + WHERE hotkey = ${hotkey} AND validator_permit = TRUE + GROUP BY snapshot_date ORDER BY snapshot_date DESC LIMIT ${MAX_HISTORY_POINTS}`; + return json( + buildValidatorHistory(rows, hotkey, { + window: windowLabelFor( + url, + HISTORY_WINDOWS, + DEFAULT_HISTORY_WINDOW, + ), + }), + ); + } + + // GET /api/v1/subnets/:netuid/neurons/:uid/history?window= (#4832 + // Tier 2b): one UID's daily metagraph snapshot over time, mirroring + // src/neuron-history.mjs's buildNeuronHistory. + const neuronHistoryMatch = url.pathname.match( + /^\/api\/v1\/subnets\/(\d+)\/neurons\/(\d+)\/history$/, + ); + if (neuronHistoryMatch) { + const netuid = Number(neuronHistoryMatch[1]); + const uid = Number(neuronHistoryMatch[2]); + const cutoff = windowCutoffDate( + url, + HISTORY_WINDOWS, + DEFAULT_HISTORY_WINDOW, + ); + const rows = cutoff + ? await sql` + SELECT snapshot_date::text AS snapshot_date, uid, hotkey, coldkey, active, validator_permit, rank, trust, validator_trust, consensus, incentive, dividends, emission_tao, stake_tao, registered_at_block, is_immunity_period, axon, block_number, captured_at + FROM neuron_daily + WHERE netuid = ${netuid} AND uid = ${uid} AND snapshot_date >= ${cutoff} + ORDER BY snapshot_date DESC LIMIT ${MAX_HISTORY_POINTS}` + : await sql` + SELECT snapshot_date::text AS snapshot_date, uid, hotkey, coldkey, active, validator_permit, rank, trust, validator_trust, consensus, incentive, dividends, emission_tao, stake_tao, registered_at_block, is_immunity_period, axon, block_number, captured_at + FROM neuron_daily + WHERE netuid = ${netuid} AND uid = ${uid} + ORDER BY snapshot_date DESC LIMIT ${MAX_HISTORY_POINTS}`; + return json( + buildNeuronHistory(rows, netuid, uid, { + window: windowLabelFor( + url, + HISTORY_WINDOWS, + DEFAULT_HISTORY_WINDOW, + ), + }), + ); + } + + // GET /api/v1/subnets/:netuid/history?window= (#4832 Tier 2b): daily + // neuron/validator counts + stake/emission totals for one subnet, + // mirroring src/neuron-history.mjs's buildSubnetHistory. + const subnetHistoryMatch = url.pathname.match( + /^\/api\/v1\/subnets\/(\d+)\/history$/, + ); + if (subnetHistoryMatch) { + const netuid = Number(subnetHistoryMatch[1]); + const cutoff = windowCutoffDate( + url, + HISTORY_WINDOWS, + DEFAULT_HISTORY_WINDOW, + ); + // validator_permit is BOOLEAN in Postgres (INTEGER 0/1 in D1/SQLite) -- + // SUM() over a boolean is a Postgres type error, so cast to int first. + const rows = cutoff + ? await sql` + SELECT snapshot_date::text AS snapshot_date, COUNT(*) AS neuron_count, + SUM(validator_permit::int) AS validator_count, + SUM(stake_tao) AS total_stake_tao, SUM(emission_tao) AS total_emission_tao + FROM neuron_daily + WHERE netuid = ${netuid} AND snapshot_date >= ${cutoff} + GROUP BY snapshot_date ORDER BY snapshot_date DESC LIMIT ${MAX_HISTORY_POINTS}` + : await sql` + SELECT snapshot_date::text AS snapshot_date, COUNT(*) AS neuron_count, + SUM(validator_permit::int) AS validator_count, + SUM(stake_tao) AS total_stake_tao, SUM(emission_tao) AS total_emission_tao + FROM neuron_daily + WHERE netuid = ${netuid} + GROUP BY snapshot_date ORDER BY snapshot_date DESC LIMIT ${MAX_HISTORY_POINTS}`; + return json( + buildSubnetHistory(rows, netuid, { + window: windowLabelFor( + url, + HISTORY_WINDOWS, + DEFAULT_HISTORY_WINDOW, + ), + }), + ); + } + + // GET /api/v1/subnets/:netuid/concentration/history?window= (#4832 + // Tier 2b): per-day stake & emission concentration trend, mirroring + // src/concentration.mjs's buildConcentrationHistory. + const concentrationHistoryMatch = url.pathname.match( + /^\/api\/v1\/subnets\/(\d+)\/concentration\/history$/, + ); + if (concentrationHistoryMatch) { + const netuid = Number(concentrationHistoryMatch[1]); + const cutoff = windowCutoffDate( + url, + CONCENTRATION_HISTORY_WINDOWS, + DEFAULT_CONCENTRATION_HISTORY_WINDOW, + ); + const rows = await sql` + SELECT snapshot_date::text AS snapshot_date, stake_tao, emission_tao + FROM neuron_daily + WHERE netuid = ${netuid} AND snapshot_date >= ${cutoff} + ORDER BY snapshot_date DESC LIMIT ${CONCENTRATION_HISTORY_ROW_CAP}`; + return json( + buildConcentrationHistory(rows, netuid, { + window: windowLabelFor( + url, + CONCENTRATION_HISTORY_WINDOWS, + DEFAULT_CONCENTRATION_HISTORY_WINDOW, + ), + capped: rows.length >= CONCENTRATION_HISTORY_ROW_CAP, + }), + ); + } + + // GET /api/v1/subnets/:netuid/performance/history?window= (#4832 + // Tier 2b): per-day reward-flow & trust trend, mirroring + // src/subnet-performance.mjs's buildSubnetPerformanceHistory. + const performanceHistoryMatch = url.pathname.match( + /^\/api\/v1\/subnets\/(\d+)\/performance\/history$/, + ); + if (performanceHistoryMatch) { + const netuid = Number(performanceHistoryMatch[1]); + const cutoff = windowCutoffDate( + url, + PERFORMANCE_HISTORY_WINDOWS, + DEFAULT_PERFORMANCE_HISTORY_WINDOW, + ); + const rows = await sql` + SELECT snapshot_date::text AS snapshot_date, incentive, dividends, trust, consensus, validator_permit, active + FROM neuron_daily + WHERE netuid = ${netuid} AND snapshot_date >= ${cutoff} + ORDER BY snapshot_date DESC LIMIT ${PERFORMANCE_HISTORY_ROW_CAP}`; + return json( + buildSubnetPerformanceHistory(rows, netuid, { + window: windowLabelFor( + url, + PERFORMANCE_HISTORY_WINDOWS, + DEFAULT_PERFORMANCE_HISTORY_WINDOW, + ), + capped: rows.length >= PERFORMANCE_HISTORY_ROW_CAP, + }), + ); + } + + // GET /api/v1/subnets/:netuid/yield/history?window= (#4832 Tier 2b): + // per-day emission-yield distribution trend, mirroring + // src/subnet-yield.mjs's buildSubnetYieldHistory. + const yieldHistoryMatch = url.pathname.match( + /^\/api\/v1\/subnets\/(\d+)\/yield\/history$/, + ); + if (yieldHistoryMatch) { + const netuid = Number(yieldHistoryMatch[1]); + const cutoff = windowCutoffDate( + url, + YIELD_HISTORY_WINDOWS, + DEFAULT_YIELD_HISTORY_WINDOW, + ); + const rows = await sql` + SELECT snapshot_date::text AS snapshot_date, validator_permit, stake_tao, emission_tao + FROM neuron_daily + WHERE netuid = ${netuid} AND snapshot_date >= ${cutoff} + ORDER BY snapshot_date DESC LIMIT ${YIELD_HISTORY_ROW_CAP}`; + return json( + buildSubnetYieldHistory(rows, netuid, { + window: windowLabelFor( + url, + YIELD_HISTORY_WINDOWS, + DEFAULT_YIELD_HISTORY_WINDOW, + ), + capped: rows.length >= YIELD_HISTORY_ROW_CAP, + }), + ); + } + + // GET /api/v1/chain/turnover?window=&limit= (#4832 Tier 2b): + // network-wide validator-set turnover across every subnet between the + // window's boundary snapshots, mirroring + // src/chain-turnover.mjs's loadChainTurnover. + if (url.pathname === "/api/v1/chain/turnover") { + const windowParam = + url.searchParams.get("window") || DEFAULT_CHAIN_TURNOVER_WINDOW; + const windowLabel = Object.hasOwn(CHAIN_TURNOVER_WINDOWS, windowParam) + ? windowParam + : DEFAULT_CHAIN_TURNOVER_WINDOW; + const days = CHAIN_TURNOVER_WINDOWS[windowLabel]; + const limitRaw = url.searchParams.get("limit"); + const limit = + limitRaw == null || limitRaw === "" + ? CHAIN_TURNOVER_LIMIT_DEFAULT + : Number(limitRaw); + // Anchor the window to the newest STORED snapshot (not the Worker's + // wall clock) -- native DATE minus an integer day count is Postgres's + // direct equivalent of SQLite's date(MAX(snapshot_date), '-N days'). + const bounds = await sql` + SELECT MIN(snapshot_date)::text AS start_date, MAX(snapshot_date)::text AS end_date + FROM neuron_daily + WHERE snapshot_date >= (SELECT MAX(snapshot_date) - ${days}::int FROM neuron_daily)`; + const startDate = bounds[0]?.start_date ?? null; + const endDate = bounds[0]?.end_date ?? null; + let rows = []; + if (startDate != null && endDate != null && startDate !== endDate) { + rows = await sql` + SELECT snapshot_date::text AS snapshot_date, netuid, hotkey, validator_permit + FROM neuron_daily + WHERE validator_permit = TRUE AND snapshot_date IN (${startDate}, ${endDate})`; + } + return json( + buildChainTurnover(rows, { + window: windowLabel, + startDate, + endDate, + limit, + }), + ); + } + + // GET /api/v1/subnets/:netuid/turnover?window=&changes= (#4832 Tier + // 2b): validator-set & registration churn between one subnet's window + // boundary snapshots, mirroring src/turnover.mjs's loadSubnetTurnover. + const turnoverMatch = url.pathname.match( + /^\/api\/v1\/subnets\/(\d+)\/turnover$/, + ); + if (turnoverMatch) { + const netuid = Number(turnoverMatch[1]); + const windowParam = + url.searchParams.get("window") || DEFAULT_HISTORY_WINDOW; + const windowLabel = Object.hasOwn(HISTORY_WINDOWS, windowParam) + ? windowParam + : DEFAULT_HISTORY_WINDOW; + const windowDays = HISTORY_WINDOWS[windowLabel]; + const includeChanges = url.searchParams.get("changes") === "true"; + const bounds = + windowDays == null + ? await sql` + SELECT MIN(snapshot_date)::text AS start_date, MAX(snapshot_date)::text AS end_date + FROM neuron_daily WHERE netuid = ${netuid}` + : await sql` + SELECT MIN(snapshot_date)::text AS start_date, MAX(snapshot_date)::text AS end_date + FROM neuron_daily + WHERE netuid = ${netuid} + AND snapshot_date >= (SELECT MAX(snapshot_date) - ${windowDays}::int FROM neuron_daily WHERE netuid = ${netuid})`; + const startDate = bounds[0]?.start_date ?? null; + const endDate = bounds[0]?.end_date ?? null; + const rows = + startDate == null || endDate == null + ? [] + : await sql` + SELECT snapshot_date::text AS snapshot_date, uid, hotkey, validator_permit + FROM neuron_daily + WHERE netuid = ${netuid} AND snapshot_date IN (${startDate}, ${endDate}) + ORDER BY snapshot_date ASC, uid ASC`; + const turnoverOptions = { window: windowLabel, startDate, endDate }; + const data = buildTurnover(rows, netuid, turnoverOptions); + return json( + includeChanges + ? { + ...data, + changes: turnoverChangeDetail( + buildTurnoverChanges(rows, netuid, turnoverOptions), + ), + } + : data, + ); + } + + // GET /api/v1/subnets/movers?window=&sort=&limit= (#4832 Tier 2b): + // every subnet ranked by its stake/emission/validator change over the + // window, mirroring src/movers.mjs's loadSubnetMovers. + if (url.pathname === "/api/v1/subnets/movers") { + const windowParam = + url.searchParams.get("window") || DEFAULT_MOVERS_WINDOW; + const windowLabel = Object.hasOwn(MOVERS_WINDOWS, windowParam) + ? windowParam + : DEFAULT_MOVERS_WINDOW; + const days = MOVERS_WINDOWS[windowLabel]; + const sortParam = url.searchParams.get("sort") || DEFAULT_MOVERS_SORT; + const limitRaw = url.searchParams.get("limit"); + const limit = + limitRaw == null || limitRaw === "" + ? MOVERS_LIMIT_DEFAULT + : Number(limitRaw); + const bounds = await sql` + SELECT MIN(snapshot_date)::text AS start_date, MAX(snapshot_date)::text AS end_date + FROM neuron_daily + WHERE snapshot_date >= (SELECT MAX(snapshot_date) - ${days}::int FROM neuron_daily)`; + const startDate = bounds[0]?.start_date ?? null; + const endDate = bounds[0]?.end_date ?? null; + let startRows = []; + let endRows = []; + if (startDate != null && endDate != null && startDate !== endDate) { + const rows = await sql` + SELECT netuid, snapshot_date::text AS snapshot_date, COUNT(*) AS neuron_count, + SUM(validator_permit::int) AS validator_count, + SUM(stake_tao) AS total_stake_tao, SUM(emission_tao) AS total_emission_tao + FROM neuron_daily + WHERE snapshot_date IN (${startDate}, ${endDate}) + GROUP BY netuid, snapshot_date`; + startRows = rows.filter((row) => row.snapshot_date === startDate); + endRows = rows.filter((row) => row.snapshot_date === endDate); + } + return json( + buildMovers(startRows, endRows, { + window: windowLabel, + startDate, + endDate, + sort: sortParam, + limit, + }), + ); + } + return json({ error: "not found" }, 404); }); } catch (err) { diff --git a/workers/request-handlers/entities.mjs b/workers/request-handlers/entities.mjs index 6e70ca9bb..8f6b9afed 100644 --- a/workers/request-handlers/entities.mjs +++ b/workers/request-handlers/entities.mjs @@ -965,8 +965,11 @@ export async function handleValidatorHistory(request, env, hotkey, url) { } sql += " GROUP BY snapshot_date ORDER BY snapshot_date DESC LIMIT ?"; params.push(MAX_HISTORY_POINTS); - const rows = await d1All(env, sql, params); - const data = buildValidatorHistory(rows, hotkey, { window: label }); + const data = + (await tryPostgresTier(env, request, "METAGRAPH_NEURONS_SOURCE")) ?? + buildValidatorHistory(await d1All(env, sql, params), hotkey, { + window: label, + }); return envelopeResponse( request, { @@ -1006,8 +1009,11 @@ export async function handleNeuronHistory(request, env, netuid, uid, url) { } sql += " ORDER BY snapshot_date DESC LIMIT ?"; params.push(MAX_HISTORY_POINTS); - const rows = await d1All(env, sql, params); - const data = buildNeuronHistory(rows, netuid, uid, { window: label }); + const data = + (await tryPostgresTier(env, request, "METAGRAPH_NEURONS_SOURCE")) ?? + buildNeuronHistory(await d1All(env, sql, params), netuid, uid, { + window: label, + }); return envelopeResponse( request, { @@ -1047,8 +1053,11 @@ export async function handleSubnetHistory(request, env, netuid, url) { } sql += " GROUP BY snapshot_date ORDER BY snapshot_date DESC LIMIT ?"; params.push(MAX_HISTORY_POINTS); - const rows = await d1All(env, sql, params); - const data = buildSubnetHistory(rows, netuid, { window: label }); + const data = + (await tryPostgresTier(env, request, "METAGRAPH_NEURONS_SOURCE")) ?? + buildSubnetHistory(await d1All(env, sql, params), netuid, { + window: label, + }); return envelopeResponse( request, { @@ -1464,10 +1473,12 @@ export async function handleChainTurnover(request, env, url) { max: CHAIN_TURNOVER_LIMIT_MAX, }); if (limit.error) return analyticsQueryError(limit.error); - const data = await loadChainTurnover(d1Runner(env), { - windowLabel: windowParam, - limit: limit.value, - }); + const data = + (await tryPostgresTier(env, request, "METAGRAPH_NEURONS_SOURCE")) ?? + (await loadChainTurnover(d1Runner(env), { + windowLabel: windowParam, + limit: limit.value, + })); // CSV exports the row-shaped per-subnet churn leaderboard; the network rollup + // stability distribution stay JSON-only (mirrors the chain-analytics exports). if (csvRequested(url, request)) { @@ -1548,15 +1559,20 @@ export async function handleSubnetConcentrationHistory( const cutoff = new Date(Date.now() - days * DAY_MS) .toISOString() .slice(0, 10); - const rows = await d1All( - env, - "SELECT snapshot_date, stake_tao, emission_tao FROM neuron_daily WHERE netuid = ? AND snapshot_date >= ? ORDER BY snapshot_date DESC LIMIT ?", - [netuid, cutoff, CONCENTRATION_HISTORY_ROW_CAP], - ); - const data = buildConcentrationHistory(rows, netuid, { - window: label, - capped: rows.length >= CONCENTRATION_HISTORY_ROW_CAP, - }); + async function fromD1() { + const rows = await d1All( + env, + "SELECT snapshot_date, stake_tao, emission_tao FROM neuron_daily WHERE netuid = ? AND snapshot_date >= ? ORDER BY snapshot_date DESC LIMIT ?", + [netuid, cutoff, CONCENTRATION_HISTORY_ROW_CAP], + ); + return buildConcentrationHistory(rows, netuid, { + window: label, + capped: rows.length >= CONCENTRATION_HISTORY_ROW_CAP, + }); + } + const data = + (await tryPostgresTier(env, request, "METAGRAPH_NEURONS_SOURCE")) ?? + (await fromD1()); if (csvRequested(url, request)) { const points = [...data.points].sort((a, b) => String(a.snapshot_date).localeCompare(String(b.snapshot_date)), @@ -1605,15 +1621,20 @@ export async function handleSubnetPerformanceHistory( const cutoff = new Date(Date.now() - days * DAY_MS) .toISOString() .slice(0, 10); - const rows = await d1All( - env, - `SELECT ${PERFORMANCE_HISTORY_READ_COLUMNS} FROM neuron_daily WHERE netuid = ? AND snapshot_date >= ? ORDER BY snapshot_date DESC LIMIT ?`, - [netuid, cutoff, PERFORMANCE_HISTORY_ROW_CAP], - ); - const data = buildSubnetPerformanceHistory(rows, netuid, { - window: label, - capped: rows.length >= PERFORMANCE_HISTORY_ROW_CAP, - }); + async function fromD1() { + const rows = await d1All( + env, + `SELECT ${PERFORMANCE_HISTORY_READ_COLUMNS} FROM neuron_daily WHERE netuid = ? AND snapshot_date >= ? ORDER BY snapshot_date DESC LIMIT ?`, + [netuid, cutoff, PERFORMANCE_HISTORY_ROW_CAP], + ); + return buildSubnetPerformanceHistory(rows, netuid, { + window: label, + capped: rows.length >= PERFORMANCE_HISTORY_ROW_CAP, + }); + } + const data = + (await tryPostgresTier(env, request, "METAGRAPH_NEURONS_SOURCE")) ?? + (await fromD1()); return envelopeResponse( request, { @@ -1647,15 +1668,20 @@ export async function handleSubnetYieldHistory(request, env, netuid, url) { const cutoff = new Date(Date.now() - days * DAY_MS) .toISOString() .slice(0, 10); - const rows = await d1All( - env, - `SELECT ${YIELD_HISTORY_READ_COLUMNS} FROM neuron_daily WHERE netuid = ? AND snapshot_date >= ? ORDER BY snapshot_date DESC LIMIT ?`, - [netuid, cutoff, YIELD_HISTORY_ROW_CAP], - ); - const data = buildSubnetYieldHistory(rows, netuid, { - window: label, - capped: rows.length >= YIELD_HISTORY_ROW_CAP, - }); + async function fromD1() { + const rows = await d1All( + env, + `SELECT ${YIELD_HISTORY_READ_COLUMNS} FROM neuron_daily WHERE netuid = ? AND snapshot_date >= ? ORDER BY snapshot_date DESC LIMIT ?`, + [netuid, cutoff, YIELD_HISTORY_ROW_CAP], + ); + return buildSubnetYieldHistory(rows, netuid, { + window: label, + capped: rows.length >= YIELD_HISTORY_ROW_CAP, + }); + } + const data = + (await tryPostgresTier(env, request, "METAGRAPH_NEURONS_SOURCE")) ?? + (await fromD1()); if (csvRequested(url, request)) { const points = [...data.points].sort((a, b) => String(a.snapshot_date).localeCompare(String(b.snapshot_date)), @@ -1701,11 +1727,13 @@ export async function handleSubnetTurnover(request, env, netuid, url) { message: `"${changes}" is not a valid changes flag. Supported: true.`, }); } - const data = await loadSubnetTurnover(d1Runner(env), netuid, { - windowLabel: label, - windowDays: days, - includeChanges: changes === "true", - }); + const data = + (await tryPostgresTier(env, request, "METAGRAPH_NEURONS_SOURCE")) ?? + (await loadSubnetTurnover(d1Runner(env), netuid, { + windowLabel: label, + windowDays: days, + includeChanges: changes === "true", + })); return envelopeResponse( request, { @@ -2336,11 +2364,13 @@ export async function handleSubnetMovers(request, env, url) { max: MOVERS_LIMIT_MAX, }); if (limit.error) return analyticsQueryError(limit.error); - const data = await loadSubnetMovers(d1Runner(env), { - windowLabel: windowParam, - sort: sortParam, - limit: limit.value, - }); + const data = + (await tryPostgresTier(env, request, "METAGRAPH_NEURONS_SOURCE")) ?? + (await loadSubnetMovers(d1Runner(env), { + windowLabel: windowParam, + sort: sortParam, + limit: limit.value, + })); if (csvRequested(url, request)) { return csvResponse( data.movers,