From 004e829c87e87aefb8057fb7daaef9ae13835f44 Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:00:09 -0700 Subject: [PATCH 1/2] feat(data-api): add the account_events_daily rollup write path to Postgres Gap-closure from the #4832 completeness sweep: account_events_daily's Postgres table already existed (mirroring D1's schema) but nothing wrote to it, since indexer-rs writes account_events directly and continuously (unlike the daily neurons snapshot, there's no existing write request to piggyback a rollup onto). - POST /api/v1/internal/rollup-account-events-daily (data-api.mjs): re-rolls the two active UTC days from account_events into account_events_daily, mirroring D1's rollupAccountEventsDaily exactly. Proxied through the main Worker (api.mjs) the same way neurons-sync is; refactored the neurons-sync proxy into a shared proxyToDataApi helper since the two routes are otherwise identical. - New hourly GitHub Actions workflow (rollup-account-events-daily.yml) triggers it -- matches D1's own hourly cadence. - Adds the read route (GET /api/v1/accounts/:ss58/history) with the same cursor/date-range/netuid filtering as its D1 sibling, and wires tryPostgresTier into handleAccountHistory. Refs #4832 --- .../workflows/rollup-account-events-daily.yml | 38 +++++ tests/data-api.test.mjs | 147 +++++++++++++++++ tests/request-handlers-entities.test.mjs | 42 +++++ ...rollup-account-events-daily-proxy.test.mjs | 108 +++++++++++++ workers/api.mjs | 71 +++++--- workers/data-api.mjs | 153 +++++++++++++++++- workers/request-handlers/entities.mjs | 19 ++- 7 files changed, 546 insertions(+), 32 deletions(-) create mode 100644 .github/workflows/rollup-account-events-daily.yml create mode 100644 tests/rollup-account-events-daily-proxy.test.mjs diff --git a/.github/workflows/rollup-account-events-daily.yml b/.github/workflows/rollup-account-events-daily.yml new file mode 100644 index 000000000..5fe8994f9 --- /dev/null +++ b/.github/workflows/rollup-account-events-daily.yml @@ -0,0 +1,38 @@ +name: Rollup account_events_daily (Postgres) + +# Triggers the Postgres-side account_events_daily rollup (#4832 gap-closure): +# indexer-rs writes account_events continuously and directly into Postgres +# (not through any Worker route), so unlike refresh-metagraph.yml's +# neurons-sync step there is no existing write request to piggyback this on +# -- a bare hourly trigger is the whole job. Mirrors D1's own +# rollupAccountEventsDaily cadence (src/account-events.mjs, called from the +# HEALTH_PRUNE_CRON hourly tick) so the two tiers stay on the same schedule. +# See workers/data-api.mjs's handleRollupAccountEventsDaily for the actual +# rollup SQL. +on: + schedule: + - cron: "17 * * * *" # hourly, offset from the top of the hour + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: rollup-account-events-daily + cancel-in-progress: false + +jobs: + rollup: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - name: Trigger the Postgres rollup + run: | + if [ -z "$ROLLUP_SYNC_SECRET" ]; then + echo "ROLLUP_SYNC_SECRET not set; skipping" + exit 0 + fi + curl -fsS -X POST "https://api.metagraph.sh/api/v1/internal/rollup-account-events-daily" \ + -H "x-rollup-sync-token: ${ROLLUP_SYNC_SECRET}" + env: + ROLLUP_SYNC_SECRET: ${{ secrets.ROLLUP_SYNC_SECRET }} diff --git a/tests/data-api.test.mjs b/tests/data-api.test.mjs index 42fb35f28..8e61eeac6 100644 --- a/tests/data-api.test.mjs +++ b/tests/data-api.test.mjs @@ -3,6 +3,7 @@ // Hyperdrive→Railway path is validated separately. import { beforeEach, test, expect, vi } from "vitest"; import { BLOCK_PAGINATION, MAX_OFFSET } from "../workers/request-params.mjs"; +import { encodeCursor } from "../src/cursor.mjs"; const sqlCalls = vi.hoisted(() => []); const mockRows = vi.hoisted(() => ({ @@ -30,6 +31,8 @@ const mockQueue = vi.hoisted(() => ({ current: [] })); // every GET-route test above. const neuronsSyncFailure = vi.hoisted(() => ({ error: null })); const neuronsSyncPruneRows = vi.hoisted(() => ({ current: [] })); +// State for the account-events-daily rollup write route's tests only (#4832). +const rollupFailure = vi.hoisted(() => ({ error: null })); vi.mock("postgres", () => ({ default: () => { @@ -74,6 +77,12 @@ vi.mock("postgres", () => ({ if (neuronsSyncFailure.error && /INSERT INTO neurons\b/.test(text)) { return Promise.reject(neuronsSyncFailure.error); } + if ( + rollupFailure.error && + /INSERT INTO account_events_daily/.test(text) + ) { + return Promise.reject(rollupFailure.error); + } if (/DELETE FROM neurons/.test(text)) { return Promise.resolve(neuronsSyncPruneRows.current); } @@ -110,9 +119,11 @@ vi.mock("postgres", () => ({ const { default: worker } = await import("../workers/data-api.mjs"); const NEURONS_SYNC_SECRET = "test-neurons-sync-secret"; +const ROLLUP_SYNC_SECRET = "test-rollup-sync-secret"; const env = { HYPERDRIVE: { connectionString: "postgres://mock" }, NEURONS_SYNC_SECRET, + ROLLUP_SYNC_SECRET, }; const ctx = { waitUntil() {} }; const req = (path, init) => @@ -124,6 +135,7 @@ beforeEach(() => { mockQueue.current = []; neuronsSyncFailure.error = null; neuronsSyncPruneRows.current = []; + rollupFailure.error = null; mockRows.current = [ { block_number: "123", @@ -2606,3 +2618,138 @@ test("GET /api/v1/accounts/:ss58/counterparties?counterparty= returns the relati expect(body.counterparty).toBe("5Cold"); expect(queryText()).toContain("(hotkey = "); }); + +// #4832 gap-closure: POST /api/v1/internal/rollup-account-events-daily -- the +// account_events_daily write path account_events itself lacked (indexer-rs +// writes account_events continuously, but nothing rolled it into the daily +// summary table in Postgres), plus its read path, +// GET /api/v1/accounts/:ss58/history. +function postRollup({ secret } = {}) { + const headers = {}; + if (secret !== undefined) headers["x-rollup-sync-token"] = secret; + return req("/api/v1/internal/rollup-account-events-daily", { + method: "POST", + headers, + }); +} + +test("rollup-account-events-daily rejects a missing or wrong token (401)", async () => { + const wrong = await postRollup({ secret: "wrong" }); + expect(wrong.status).toBe(401); + const missing = await postRollup(); + expect(missing.status).toBe(401); +}); + +test("rollup-account-events-daily is disabled (503) when ROLLUP_SYNC_SECRET is not configured", async () => { + const res = await worker.fetch( + new Request("https://d/api/v1/internal/rollup-account-events-daily", { + method: "POST", + headers: { "x-rollup-sync-token": ROLLUP_SYNC_SECRET }, + }), + { HYPERDRIVE: { connectionString: "postgres://mock" } }, + ctx, + ); + expect(res.status).toBe(503); +}); + +test("rollup-account-events-daily returns 503 when the HYPERDRIVE binding is unavailable", async () => { + const res = await worker.fetch( + new Request("https://d/api/v1/internal/rollup-account-events-daily", { + method: "POST", + headers: { "x-rollup-sync-token": ROLLUP_SYNC_SECRET }, + }), + { ROLLUP_SYNC_SECRET }, + ctx, + ); + expect(res.status).toBe(503); +}); + +test("rollup-account-events-daily rolls up the two active UTC days and reports them", async () => { + const res = await postRollup({ secret: ROLLUP_SYNC_SECRET }); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ok).toBe(true); + expect(body.rolled).toHaveLength(2); + expect(queryText()).toMatch(/INSERT INTO account_events_daily/); + expect(queryText()).toMatch(/FROM account_events/); + expect(queryText()).toMatch(/string_agg\(DISTINCT event_kind/); +}); + +test("rollup-account-events-daily maps a DB failure to a clean 502 instead of throwing", async () => { + rollupFailure.error = new Error("connection reset"); + const res = await postRollup({ secret: ROLLUP_SYNC_SECRET }); + expect(res.status).toBe(502); + expect((await res.json()).error).toBe("rollup failed"); +}); + +test("GET /api/v1/accounts/:ss58/history shapes the durable per-day activity series", async () => { + mockRows.current = [ + { + day: "2026-07-01", + netuid: 7, + event_count: "3", + event_kinds: "StakeAdded,WeightsSet", + first_block: "100", + last_block: "200", + }, + ]; + const res = await req(`/api/v1/accounts/${SS58}/history`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.ss58).toBe(SS58); + expect(body.days[0].day).toBe("2026-07-01"); + expect(queryText()).toContain("FROM account_events_daily"); + expect(queryText()).toContain("WHERE hotkey ="); +}); + +test("GET /api/v1/accounts/:ss58/history?netuid= filters to one subnet", async () => { + mockRows.current = [ + { + day: "2026-07-01", + netuid: 7, + event_count: "1", + event_kinds: "StakeAdded", + first_block: "100", + last_block: "100", + }, + ]; + const res = await req(`/api/v1/accounts/${SS58}/history?netuid=7`); + expect(res.status).toBe(200); + expect(queryText()).toContain("AND netuid ="); +}); + +test("GET /api/v1/accounts/:ss58/history?from=&to= filters the day range", async () => { + mockRows.current = []; + const res = await req( + `/api/v1/accounts/${SS58}/history?from=2026-06-01&to=2026-06-30`, + ); + expect(res.status).toBe(200); + expect(queryText()).toContain("AND day >="); + expect(queryText()).toContain("AND day <="); +}); + +test("GET /api/v1/accounts/:ss58/history?cursor= seeks past the encoded (day, netuid) pair", async () => { + mockRows.current = []; + const cursor = encodeCursor([20260701, 7]); + const res = await req(`/api/v1/accounts/${SS58}/history?cursor=${cursor}`); + expect(res.status).toBe(200); + expect(queryText()).toContain("AND (day, netuid) <"); + expect(queryText()).not.toContain("OFFSET"); +}); + +test("GET /api/v1/accounts/:ss58/history?limit=1 emits a next_cursor when the page is full", async () => { + mockRows.current = [ + { + day: "2026-07-01", + netuid: 7, + event_count: "1", + event_kinds: "StakeAdded", + first_block: "100", + last_block: "100", + }, + ]; + const res = await req(`/api/v1/accounts/${SS58}/history?limit=1`); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.next_cursor).not.toBeNull(); +}); diff --git a/tests/request-handlers-entities.test.mjs b/tests/request-handlers-entities.test.mjs index 156bea96e..08c5f40a3 100644 --- a/tests/request-handlers-entities.test.mjs +++ b/tests/request-handlers-entities.test.mjs @@ -8539,6 +8539,48 @@ describe("D1 -> Postgres serving-cutover flag (#4656 followup)", () => { assert.equal(body.data.marker, undefined); assert.ok(captures.sql.length > 0); }); + + // #4832 gap-closure: handleAccountHistory (account_events_daily, now + // populated by a dedicated hourly Postgres-side rollup route). + + test("handleAccountHistory: flag=postgres uses Postgres data, D1 never queried", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_ACCOUNT_EVENTS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => + Response.json({ schema_version: 1, marker: "pg", days: [] }), + }; + const body = await json( + await handleAccountHistory( + req(`/api/v1/accounts/${SS58}/history`), + env, + SS58, + url(`/api/v1/accounts/${SS58}/history`), + ), + ); + assert.equal(body.data.marker, "pg"); + assert.deepEqual(captures.sql, []); + }); + + test("handleAccountHistory: flag=postgres falls back to D1 on failure", async () => { + const { env, captures } = dbWith({}); + env.METAGRAPH_ACCOUNT_EVENTS_SOURCE = "postgres"; + env.DATA_API = { + fetch: async () => { + throw new Error("boom"); + }, + }; + const body = await json( + await handleAccountHistory( + req(`/api/v1/accounts/${SS58}/history`), + env, + SS58, + url(`/api/v1/accounts/${SS58}/history`), + ), + ); + assert.equal(body.data.marker, undefined); + assert.ok(captures.sql.length > 0); + }); }); // ---- Cross-handler contract smoke tests ------------------------------------- diff --git a/tests/rollup-account-events-daily-proxy.test.mjs b/tests/rollup-account-events-daily-proxy.test.mjs new file mode 100644 index 000000000..7e366875d --- /dev/null +++ b/tests/rollup-account-events-daily-proxy.test.mjs @@ -0,0 +1,108 @@ +// Unit tests for the /api/v1/internal/rollup-account-events-daily proxy +// (workers/api.mjs's handleRollupAccountEventsDailyProxy, #4832), which +// forwards to workers/data-api.mjs's handleRollupAccountEventsDaily via the +// EXISTING DATA_API service binding (shares proxyToDataApi with +// handleNeuronsSyncProxy -- see tests/neurons-sync-proxy.test.mjs for that +// sibling route's equivalent coverage). The downstream rollup logic itself +// is covered by tests/data-api.test.mjs. +import assert from "node:assert/strict"; +import { test } from "vitest"; +import { handleRequest } from "../workers/api.mjs"; + +function post({ method = "POST" } = {}) { + return new Request( + "https://api.metagraph.sh/api/v1/internal/rollup-account-events-daily", + { method }, + ); +} + +test("rejects non-POST before reaching the binding (405)", async () => { + let calls = 0; + const res = await handleRequest( + post({ method: "GET" }), + { + DATA_API: { + fetch() { + calls += 1; + return new Response("{}", { status: 200 }); + }, + }, + }, + {}, + ); + assert.equal(res.status, 405); + assert.equal(calls, 0); +}); + +test("returns 503 when DATA_API is not bound", async () => { + const res = await handleRequest(post(), {}, {}); + assert.equal(res.status, 503); +}); + +test("forwards the request to DATA_API and relays its response body + status", async () => { + let receivedToken; + let receivedPath; + const res = await handleRequest( + new Request( + "https://api.metagraph.sh/api/v1/internal/rollup-account-events-daily", + { method: "POST", headers: { "x-rollup-sync-token": "shared-secret" } }, + ), + { + DATA_API: { + fetch(req) { + receivedToken = req.headers.get("x-rollup-sync-token"); + receivedPath = new URL(req.url).pathname; + return new Response( + JSON.stringify({ ok: true, rolled: ["2026-07-01", "2026-06-30"] }), + { status: 200 }, + ); + }, + }, + }, + {}, + ); + assert.equal(receivedToken, "shared-secret"); + assert.equal(receivedPath, "/api/v1/internal/rollup-account-events-daily"); + assert.equal(res.status, 200); + assert.deepEqual(await res.json(), { + ok: true, + rolled: ["2026-07-01", "2026-06-30"], + }); +}); + +test("relays a non-2xx upstream status (e.g. 401) unchanged", async () => { + const res = await handleRequest( + post(), + { + DATA_API: { + fetch() { + return new Response(JSON.stringify({ error: "unauthorized" }), { + status: 401, + }); + }, + }, + }, + {}, + ); + assert.equal(res.status, 401); + assert.deepEqual(await res.json(), { error: "unauthorized" }); +}); + +test("returns 502 when the upstream response body is unreadable", async () => { + const res = await handleRequest( + post(), + { + DATA_API: { + fetch() { + return new Response("not json", { status: 200 }); + }, + }, + }, + {}, + ); + assert.equal(res.status, 502); + assert.equal( + (await res.json()).error.code, + "rollup_account_events_daily_unavailable", + ); +}); diff --git a/workers/api.mjs b/workers/api.mjs index 52f7995d0..a2ea971a4 100644 --- a/workers/api.mjs +++ b/workers/api.mjs @@ -1161,38 +1161,32 @@ async function handleRegistrySyncProxy(request, env) { }); } -// Proxies POST /api/v1/internal/neurons-sync to the SAME DATA_API service -// binding the chain_events proxy above uses (#4771) -- the write path into -// the chain-indexer Postgres's neurons/neuron_daily tables lives inside -// workers/data-api.mjs itself, not a separate Worker: it's the identical -// Postgres instance/Hyperdrive origin data-api.mjs already reads from, so -// splitting read and write into two Workers (the way registry-sync-api.mjs -// is split, for a genuinely SEPARATE database) would only add a redundant -// deploy pipeline for zero bundle-budget benefit. Mirrors -// handleRegistrySyncProxy's shape otherwise: forwards the request as-is -// (including the x-neurons-sync-token header), the shared-secret check -// happens once, downstream in workers/data-api.mjs's handleNeuronsSync. -async function handleNeuronsSyncProxy(request, env) { +// Generic forwarder to the DATA_API service binding for the internal +// write/rollup routes that live inside workers/data-api.mjs itself rather +// than a dedicated Worker (#4771's neurons-sync pattern) -- splitting read +// and write into two Workers for the IDENTICAL Postgres instance/Hyperdrive +// origin data-api.mjs already reads from (the way registry-sync-api.mjs is +// split, for a genuinely SEPARATE database) would only add a redundant +// deploy pipeline for zero bundle-budget benefit. Forwards the request as-is +// (including any shared-secret header) -- the auth check happens once, +// downstream in data-api.mjs. +async function proxyToDataApi( + request, + env, + { code, notBoundMessage, unreadableMessage }, +) { if (request.method !== "POST") { return errorResponse("method_not_allowed", "Only POST is supported.", 405); } if (!env.DATA_API) { - return errorResponse( - "neurons_sync_unavailable", - "The neurons-sync tier is not bound to this deployment.", - 503, - ); + return errorResponse(code, notBoundMessage, 503); } const upstream = await env.DATA_API.fetch(request); let body; try { body = await upstream.json(); } catch { - return errorResponse( - "neurons_sync_unavailable", - "The neurons-sync tier returned an unreadable response.", - 502, - ); + return errorResponse(code, unreadableMessage, 502); } return new Response(JSON.stringify(body), { status: upstream.status, @@ -1200,6 +1194,32 @@ async function handleNeuronsSyncProxy(request, env) { }); } +// Proxies POST /api/v1/internal/neurons-sync -- the write path into the +// chain-indexer Postgres's neurons/neuron_daily tables (#4771). Mirrors +// handleRegistrySyncProxy's shape otherwise (forwards the request as-is, +// including the x-neurons-sync-token header). +async function handleNeuronsSyncProxy(request, env) { + return proxyToDataApi(request, env, { + code: "neurons_sync_unavailable", + notBoundMessage: "The neurons-sync tier is not bound to this deployment.", + unreadableMessage: "The neurons-sync tier returned an unreadable response.", + }); +} + +// Proxies POST /api/v1/internal/rollup-account-events-daily -- the write +// path into the chain-indexer Postgres's account_events_daily rollup +// (#4832 gap-closure). Same DATA_API service binding as neurons-sync above; +// see proxyToDataApi's comment for why this isn't a dedicated Worker. +async function handleRollupAccountEventsDailyProxy(request, env) { + return proxyToDataApi(request, env, { + code: "rollup_account_events_daily_unavailable", + notBoundMessage: + "The account-events-daily rollup tier is not bound to this deployment.", + unreadableMessage: + "The account-events-daily rollup tier returned an unreadable response.", + }); +} + export async function handleRequest(request, env = {}, ctx = {}) { let url = new URL(request.url); @@ -1314,6 +1334,13 @@ export async function handleRequest(request, env = {}, ctx = {}) { if (url.pathname === "/api/v1/internal/neurons-sync") { return handleNeuronsSyncProxy(request, env); } + // The write path into the chain-indexer Postgres's account_events_daily + // rollup (#4832 gap-closure) -- a dedicated hourly GitHub Actions workflow + // calls this (there is no daily snapshot job to piggyback on, unlike + // neurons-sync above). Same DATA_API service binding. + if (url.pathname === "/api/v1/internal/rollup-account-events-daily") { + return handleRollupAccountEventsDailyProxy(request, env); + } // GraphQL read-only query layer over existing artifacts (issue #751). Runs // before the read-only method gate because GraphQL accepts POST requests. diff --git a/workers/data-api.mjs b/workers/data-api.mjs index e6bfc785a..73a016956 100644 --- a/workers/data-api.mjs +++ b/workers/data-api.mjs @@ -37,6 +37,7 @@ import { buildSubnetEventSummary, buildAccountSummary, buildAccountSubnets, + buildAccountHistory, ACCOUNT_EVENT_SUMMARY_SCAN_CAP, SUBNET_EVENT_SUMMARY_WINDOWS, DEFAULT_SUBNET_EVENT_SUMMARY_WINDOW, @@ -273,6 +274,8 @@ function latestObservedIso(rows, field = "last_observed") { import { timingSafeEqual } from "../src/webhooks.mjs"; import { BLOCK_PAGINATION, + FEED_PAGINATION, + DAY_PATTERN, clampLimit as clampRequestLimit, clampOffset as clampRequestOffset, } from "./request-params.mjs"; @@ -661,6 +664,95 @@ async function handleNeuronsSync(request, env) { // the request/invocation ends (Cloudflare's documented pattern). } +// --- POST /api/v1/internal/rollup-account-events-daily (#4832 gap-closure) - +// +// account_events is written continuously by indexer-rs directly into this +// same Postgres instance (not through any Worker route), so unlike +// neurons-sync above there is no existing write request to piggyback the +// rollup onto -- a dedicated hourly GitHub Actions workflow +// (rollup-account-events-daily.yml) calls this instead, proxied through the +// main Worker the same way (workers/api.mjs's +// handleRollupAccountEventsDailyProxy). Mirrors D1's rollupAccountEventsDaily +// (src/account-events.mjs) exactly: re-roll the two active UTC days each +// run (past days are already finalized), upsert idempotently. No request +// body -- this is a trigger-only POST, not a data-carrying sync. +const ROLLUP_TOKEN_HEADER = "x-rollup-sync-token"; + +function utcDayBounds(ms) { + const d = new Date(ms); + const start = Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()); + return { + date: new Date(start).toISOString().slice(0, 10), + start, + end: start + 24 * 60 * 60 * 1000, + }; +} + +async function handleRollupAccountEventsDaily(request, env) { + if (!env.ROLLUP_SYNC_SECRET) { + return writeJson( + { + error: + "account-events-daily rollup is not provisioned on this deployment", + }, + 503, + ); + } + const provided = request.headers.get(ROLLUP_TOKEN_HEADER) || ""; + if (!provided || !timingSafeEqual(provided, env.ROLLUP_SYNC_SECRET)) { + return writeJson( + { error: `provide a valid ${ROLLUP_TOKEN_HEADER} header` }, + 401, + ); + } + if (!env.HYPERDRIVE?.connectionString) { + return writeJson({ error: "hyperdrive binding unavailable" }, 503); + } + + const runAt = Date.now(); + const days = [utcDayBounds(runAt), utcDayBounds(runAt - 24 * 60 * 60 * 1000)]; + const sql = postgres(env.HYPERDRIVE.connectionString, { + max: 5, + prepare: false, + fetch_types: false, + }); + + try { + return await sql.begin(async (sql) => { + await sql`SET statement_timeout = '20000ms'`; + const rolled = []; + for (const { date, start, end } of days) { + await sql` + INSERT INTO account_events_daily (hotkey, netuid, day, event_count, event_kinds, first_block, last_block, updated_at) + SELECT + hotkey, + netuid, + ${date}::date AS day, + COUNT(*) AS event_count, + string_agg(DISTINCT event_kind, ',') AS event_kinds, + MIN(block_number) AS first_block, + MAX(block_number) AS last_block, + ${runAt} AS updated_at + FROM account_events + WHERE hotkey IS NOT NULL AND netuid IS NOT NULL + AND observed_at >= ${start} AND observed_at < ${end} + GROUP BY hotkey, netuid + ON CONFLICT (hotkey, netuid, day) DO UPDATE SET + event_count = EXCLUDED.event_count, + event_kinds = EXCLUDED.event_kinds, + first_block = EXCLUDED.first_block, + last_block = EXCLUDED.last_block, + updated_at = EXCLUDED.updated_at`; + rolled.push(date); + } + return writeJson({ ok: true, rolled }); + }); + } catch (err) { + console.error("data-api account-events-daily rollup failed:", err); + return writeJson({ error: "rollup failed" }, 502); + } +} + function json(data, status = 200) { return new Response(JSON.stringify(data), { status, @@ -771,15 +863,21 @@ function coerceEvent(row) { export default { async fetch(request, env, _ctx) { const url = new URL(request.url); - // The one write route (#4771) -- checked before the GET-only gate below, - // same as how the main Worker's own POST-accepting routes (webhooks, MCP, - // ingest) run ahead of its read-only method gate. + // The write routes (#4771, #4832) -- checked before the GET-only gate + // below, same as how the main Worker's own POST-accepting routes + // (webhooks, MCP, ingest) run ahead of its read-only method gate. if ( request.method === "POST" && url.pathname === "/api/v1/internal/neurons-sync" ) { return handleNeuronsSync(request, env); } + if ( + request.method === "POST" && + url.pathname === "/api/v1/internal/rollup-account-events-daily" + ) { + return handleRollupAccountEventsDaily(request, env); + } if (request.method !== "GET") return json({ error: "method not allowed" }, 405); if (!env.HYPERDRIVE?.connectionString) { @@ -2736,6 +2834,55 @@ export default { ); } + // GET /api/v1/accounts/:ss58/history?netuid=&from=&to=&limit=&offset=&cursor= + // (#4832 gap-closure): the durable per-day activity series for an + // account, mirroring src/account-events.mjs's buildAccountHistory / + // ACCOUNT_DAY_COLUMNS. day is cast to ::text for the same reason + // snapshot_date is elsewhere in this file (postgres.js parses DATE + // columns to JS Date objects by default, which would break the + // string-keyset cursor comparison below). + const accountHistoryMatch = url.pathname.match( + /^\/api\/v1\/accounts\/([^/]+)\/history$/, + ); + if (accountHistoryMatch) { + const ss58 = decodeURIComponent(accountHistoryMatch[1]); + const limit = clampRequestLimit( + url.searchParams.get("limit"), + FEED_PAGINATION, + ); + const offset = clampRequestOffset(url.searchParams.get("offset")); + const netuid = nonNegativeIntegerParam(url.searchParams, "netuid"); + const from = url.searchParams.get("from") || null; + const to = url.searchParams.get("to") || null; + const cur = decodeCursor(url.searchParams.get("cursor"), 2); + const cursorDay = cur + ? String(cur[0]).replace(/^(\d{4})(\d{2})(\d{2})$/, "$1-$2-$3") + : null; + const useCursor = Boolean(cursorDay && DAY_PATTERN.test(cursorDay)); + const rows = await sql` + SELECT day::text AS day, netuid, event_count, event_kinds, first_block, last_block + FROM account_events_daily + WHERE hotkey = ${ss58} + ${netuid != null ? sql`AND netuid = ${netuid}` : sql``} + ${from ? sql`AND day >= ${from}` : sql``} + ${to ? sql`AND day <= ${to}` : sql``} + ${useCursor ? sql`AND (day, netuid) < (${cursorDay}::date, ${cur[1]})` : sql``} + ORDER BY day DESC, netuid DESC + LIMIT ${limit} + ${!useCursor ? sql`OFFSET ${offset}` : sql``}`; + const last = rows.length === limit ? rows[rows.length - 1] : null; + const nextCursor = + last && typeof last.day === "string" && DAY_PATTERN.test(last.day) + ? encodeCursor([ + Number(last.day.replaceAll("-", "")), + last.netuid, + ]) + : null; + return json( + buildAccountHistory(rows, ss58, { limit, offset, nextCursor }), + ); + } + return json({ error: "not found" }, 404); }); } catch (err) { diff --git a/workers/request-handlers/entities.mjs b/workers/request-handlers/entities.mjs index 4d86908ae..c954856ae 100644 --- a/workers/request-handlers/entities.mjs +++ b/workers/request-handlers/entities.mjs @@ -2916,13 +2916,18 @@ export async function handleAccountHistory(request, env, ss58, url) { sql += " OFFSET ?"; params.push(offset); } - const rows = await d1All(env, sql, params); - const last = rows.length === limit ? rows[rows.length - 1] : null; - const nextCursor = - last && typeof last.day === "string" && DAY_PATTERN.test(last.day) - ? encodeCursor([Number(last.day.replaceAll("-", "")), last.netuid]) - : null; - const data = buildAccountHistory(rows, ss58, { limit, offset, nextCursor }); + async function fromD1() { + const rows = await d1All(env, sql, params); + const last = rows.length === limit ? rows[rows.length - 1] : null; + const nextCursor = + last && typeof last.day === "string" && DAY_PATTERN.test(last.day) + ? encodeCursor([Number(last.day.replaceAll("-", "")), last.netuid]) + : null; + return buildAccountHistory(rows, ss58, { limit, offset, nextCursor }); + } + const data = + (await tryPostgresTier(env, request, "METAGRAPH_ACCOUNT_EVENTS_SOURCE")) ?? + (await fromD1()); return accountEnvelopeResponse( request, { From 874dd7c7935163ed0c26ace8a05f99d6f144591c Mon Sep 17 00:00:00 2001 From: JSONbored <49853598+JSONbored@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:04:26 -0700 Subject: [PATCH 2/2] fix(ci): add the required checkout step to the rollup workflow validate-workflows.mjs requires every workflow to include actions/checkout -- the new rollup-account-events-daily.yml didn't need the repo checked out for its bare curl trigger, but the gate doesn't special-case that, so add it like every other workflow. --- .github/workflows/rollup-account-events-daily.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/rollup-account-events-daily.yml b/.github/workflows/rollup-account-events-daily.yml index 5fe8994f9..b33e9ca57 100644 --- a/.github/workflows/rollup-account-events-daily.yml +++ b/.github/workflows/rollup-account-events-daily.yml @@ -26,6 +26,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 5 steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Trigger the Postgres rollup run: | if [ -z "$ROLLUP_SYNC_SECRET" ]; then