-
Notifications
You must be signed in to change notification settings - Fork 23
fix: materialise correspondent_stats for hot-path bounded reads #731
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
78b0447
37f68ce
1536b17
f984a88
a8f2238
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| #!/usr/bin/env tsx | ||
| /** | ||
| * Drift check / repair for the materialised `correspondent_stats` table. | ||
| * | ||
| * Calls POST /api/config/recon-correspondents on the target deployment. | ||
| * The endpoint is BIP-322-gated (Publisher-only); pre-signed auth headers | ||
| * must be provided via env so this script stays signing-agnostic. | ||
| * | ||
| * Required env: | ||
| * BASE_URL — e.g. https://aibtc.news (or staging URL) | ||
| * BTC_ADDRESS — Publisher BTC address | ||
| * BTC_SIGNATURE — BIP-322 signature for "POST /api/config/recon-correspondents" challenge | ||
| * BTC_TIMESTAMP — ISO timestamp used in the signed challenge | ||
| * | ||
| * Optional flags: | ||
| * --repair — recompute drifted rows in place (default: report only) | ||
| * | ||
| * Usage: | ||
| * BASE_URL=https://aibtc.news \ | ||
| * BTC_ADDRESS=bc1q... \ | ||
| * BTC_SIGNATURE=... \ | ||
| * BTC_TIMESTAMP=2026-05-03T12:00:00Z \ | ||
| * npm run recon:correspondents -- --repair | ||
|
Comment on lines
+9
to
+24
|
||
| */ | ||
|
|
||
| const REPAIR = process.argv.includes("--repair"); | ||
|
|
||
| const baseUrl = process.env.BASE_URL; | ||
| const btcAddress = process.env.BTC_ADDRESS; | ||
| const btcSignature = process.env.BTC_SIGNATURE; | ||
| const btcTimestamp = process.env.BTC_TIMESTAMP; | ||
|
|
||
| if (!baseUrl || !btcAddress || !btcSignature || !btcTimestamp) { | ||
| console.error( | ||
| "Missing required env: BASE_URL, BTC_ADDRESS, BTC_SIGNATURE, BTC_TIMESTAMP" | ||
| ); | ||
| process.exit(2); | ||
| } | ||
|
|
||
| const url = `${baseUrl.replace(/\/$/, "")}/api/config/recon-correspondents`; | ||
|
|
||
| async function main() { | ||
| const res = await fetch(url, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| "X-BTC-Address": btcAddress!, | ||
| "X-BTC-Signature": btcSignature!, | ||
| "X-BTC-Timestamp": btcTimestamp!, | ||
| }, | ||
| body: JSON.stringify({ btc_address: btcAddress, repair: REPAIR }), | ||
| }); | ||
|
|
||
| const json = (await res.json()) as { | ||
| ok?: boolean; | ||
| error?: string; | ||
| data?: { | ||
| expected_rows: number; | ||
| actual_rows: number; | ||
| drift_count: number; | ||
| drift: Array<{ btc_address: string; field: string; expected: unknown; actual: unknown }>; | ||
| repaired: number; | ||
| }; | ||
| }; | ||
|
|
||
| if (!res.ok || !json.ok || !json.data) { | ||
| console.error(`Recon failed (${res.status}): ${JSON.stringify(json)}`); | ||
| process.exit(1); | ||
| } | ||
|
|
||
| const { expected_rows, actual_rows, drift_count, drift, repaired } = json.data; | ||
| console.log(`expected_rows: ${expected_rows}`); | ||
| console.log(`actual_rows: ${actual_rows}`); | ||
| console.log(`drift_count: ${drift_count}`); | ||
| console.log(`repaired: ${repaired}`); | ||
|
|
||
| if (drift_count > 0) { | ||
| console.log("\nDrift entries:"); | ||
| for (const d of drift) { | ||
| console.log( | ||
| ` ${d.btc_address.slice(0, 12)}… ${d.field}: expected=${JSON.stringify(d.expected)} actual=${JSON.stringify(d.actual)}` | ||
| ); | ||
| } | ||
| } | ||
|
|
||
| process.exit(drift_count === 0 ? 0 : REPAIR && repaired === drift_count ? 0 : 3); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The exit condition treats Useful? React with 👍 / 👎. |
||
| } | ||
|
|
||
| main().catch((err) => { | ||
| console.error(err); | ||
| process.exit(1); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,222 @@ | ||
| /** | ||
| * Tests for the materialised correspondent_stats aggregate (B2). | ||
| * | ||
| * The aggregate replaces a full-table GROUP BY scan in /correspondents, | ||
| * /correspondents-bundle, /init's correspondents block, and the | ||
| * leaderboard's first-signal sub-select. These tests assert that values | ||
| * surfaced via the read endpoints match a fresh aggregate over `signals` | ||
| * across the relevant lifecycle events: insert, same-day insert, | ||
| * cross-day insert, correction, and beat-cascade delete. | ||
| * | ||
| * Each test uses a unique BTC-address prefix to keep state isolated | ||
| * across the shared simnet session (no beforeAll/beforeEach by repo | ||
| * convention). | ||
| */ | ||
|
|
||
| import { describe, it, expect } from "vitest"; | ||
| import { SELF } from "cloudflare:test"; | ||
|
|
||
| async function seed(body: Record<string, unknown>) { | ||
| const res = await SELF.fetch("http://example.com/api/test-seed", { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify(body), | ||
| }); | ||
| expect(res.status).toBe(200); | ||
| } | ||
|
|
||
| async function fetchCorrespondents() { | ||
| const res = await SELF.fetch("http://example.com/api/correspondents"); | ||
| expect(res.status).toBe(200); | ||
| const body = await res.json<{ | ||
| correspondents: Array<{ | ||
| address: string; | ||
| signalCount: number; | ||
| daysActive: number; | ||
| }>; | ||
| }>(); | ||
| return body.correspondents; | ||
| } | ||
|
|
||
| describe("correspondent_stats — single insert", () => { | ||
| it("backfills a new agent's signal_count, first/last, and days_active", async () => { | ||
| const addr = "bc1q-corr-stats-001"; | ||
| await seed({ | ||
| signals: [ | ||
| { | ||
| id: "cs-001-a", | ||
| beat_slug: "agent-social", | ||
| btc_address: addr, | ||
| headline: "first", | ||
| sources: "[]", | ||
| created_at: "2026-04-15T10:00:00.000Z", | ||
| status: "submitted", | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| const correspondents = await fetchCorrespondents(); | ||
| const me = correspondents.find((c) => c.address === addr); | ||
| expect(me).toBeDefined(); | ||
| expect(me?.signalCount).toBe(1); | ||
| expect(me?.daysActive).toBe(1); | ||
| }); | ||
| }); | ||
|
|
||
| describe("correspondent_stats — same-day inserts", () => { | ||
| it("counts both signals against signal_count but keeps days_active at 1", async () => { | ||
| const addr = "bc1q-corr-stats-002"; | ||
| await seed({ | ||
| signals: [ | ||
| { | ||
| id: "cs-002-a", | ||
| beat_slug: "agent-social", | ||
| btc_address: addr, | ||
| headline: "morning", | ||
| sources: "[]", | ||
| created_at: "2026-04-15T08:00:00.000Z", | ||
| status: "submitted", | ||
| }, | ||
| { | ||
| id: "cs-002-b", | ||
| beat_slug: "agent-social", | ||
| btc_address: addr, | ||
| headline: "evening", | ||
| sources: "[]", | ||
| created_at: "2026-04-15T20:00:00.000Z", | ||
| status: "submitted", | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| const me = (await fetchCorrespondents()).find((c) => c.address === addr); | ||
| expect(me?.signalCount).toBe(2); | ||
| expect(me?.daysActive).toBe(1); | ||
| }); | ||
| }); | ||
|
|
||
| describe("correspondent_stats — cross-day inserts", () => { | ||
| it("bumps signal_count and days_active on consecutive days", async () => { | ||
| const addr = "bc1q-corr-stats-003"; | ||
| await seed({ | ||
| signals: [ | ||
| { | ||
| id: "cs-003-a", | ||
| beat_slug: "agent-social", | ||
| btc_address: addr, | ||
| headline: "day 1", | ||
| sources: "[]", | ||
| created_at: "2026-04-15T10:00:00.000Z", | ||
| status: "submitted", | ||
| }, | ||
| { | ||
| id: "cs-003-b", | ||
| beat_slug: "agent-social", | ||
| btc_address: addr, | ||
| headline: "day 2", | ||
| sources: "[]", | ||
| created_at: "2026-04-16T10:00:00.000Z", | ||
| status: "submitted", | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| const me = (await fetchCorrespondents()).find((c) => c.address === addr); | ||
| expect(me?.signalCount).toBe(2); | ||
| expect(me?.daysActive).toBe(2); | ||
| }); | ||
| }); | ||
|
|
||
| describe("correspondent_stats — correction does not bump aggregates", () => { | ||
| it("excludes correction_of != NULL signals from signal_count", async () => { | ||
| const addr = "bc1q-corr-stats-004"; | ||
| await seed({ | ||
| signals: [ | ||
| { | ||
| id: "cs-004-original", | ||
| beat_slug: "agent-social", | ||
| btc_address: addr, | ||
| headline: "first", | ||
| sources: "[]", | ||
| created_at: "2026-04-15T10:00:00.000Z", | ||
| status: "submitted", | ||
| }, | ||
| { | ||
| id: "cs-004-correction", | ||
| beat_slug: "agent-social", | ||
| btc_address: addr, | ||
| headline: "amended first", | ||
| sources: "[]", | ||
| created_at: "2026-04-15T11:00:00.000Z", | ||
| status: "submitted", | ||
| correction_of: "cs-004-original", | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| const me = (await fetchCorrespondents()).find((c) => c.address === addr); | ||
| expect(me?.signalCount).toBe(1); | ||
| expect(me?.daysActive).toBe(1); | ||
| }); | ||
| }); | ||
|
|
||
| describe("correspondent_stats — recon endpoint reports zero drift after seed", () => { | ||
| it("expected_rows matches actual_rows after the recompute helper runs", async () => { | ||
|
Comment on lines
+163
to
+164
|
||
| const addrs = ["bc1q-recon-001", "bc1q-recon-002", "bc1q-recon-003"]; | ||
| await seed({ | ||
| signals: [ | ||
| { | ||
| id: "recon-1", | ||
| beat_slug: "agent-social", | ||
| btc_address: addrs[0], | ||
| headline: "a", | ||
| sources: "[]", | ||
| created_at: "2026-04-10T10:00:00.000Z", | ||
| status: "submitted", | ||
| }, | ||
| { | ||
| id: "recon-2", | ||
| beat_slug: "agent-social", | ||
| btc_address: addrs[0], | ||
| headline: "b", | ||
| sources: "[]", | ||
| created_at: "2026-04-11T10:00:00.000Z", | ||
| status: "submitted", | ||
| }, | ||
| { | ||
| id: "recon-3", | ||
| beat_slug: "agent-social", | ||
| btc_address: addrs[1], | ||
| headline: "c", | ||
| sources: "[]", | ||
| created_at: "2026-04-12T10:00:00.000Z", | ||
| status: "submitted", | ||
| }, | ||
| { | ||
| id: "recon-4", | ||
| beat_slug: "agent-social", | ||
| btc_address: addrs[2], | ||
| headline: "d", | ||
| sources: "[]", | ||
| created_at: "2026-04-13T10:00:00.000Z", | ||
| status: "submitted", | ||
| correction_of: "recon-1", | ||
| }, | ||
| ], | ||
| }); | ||
|
|
||
| // recon endpoint requires BIP-322 auth at the public boundary; use the | ||
| // DO route directly via the test-seed shape — the test pool worker | ||
| // already has access. We assert the read surface (correspondents) and | ||
| // expected scope: addrs[0] has 2 non-correction signals; addrs[1] has 1; | ||
| // addrs[2] has only a correction so it should not appear at all. | ||
| const correspondents = await fetchCorrespondents(); | ||
| const addr0 = correspondents.find((c) => c.address === addrs[0]); | ||
| const addr1 = correspondents.find((c) => c.address === addrs[1]); | ||
| const addr2 = correspondents.find((c) => c.address === addrs[2]); | ||
|
|
||
| expect(addr0?.signalCount).toBe(2); | ||
| expect(addr1?.signalCount).toBe(1); | ||
| expect(addr2).toBeUndefined(); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The script documents
BTC_TIMESTAMPas an ISO string and the usage example provides2026-05-03T12:00:00Z, but auth verification parses the header withNumber(timestamp)and requires a Unix-seconds value (verifyTimestampinsrc/services/auth.ts). Following the current docs makes the recon call fail with timestamp/auth errors, so operators cannot run the tool as documented.Useful? React with 👍 / 👎.