Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"test": "vitest run",
"test:watch": "vitest",
"cf-typegen": "npm run wrangler -- types",
"migrate": "set -a && . ./.env && set +a && npx tsx scripts/migrate-kv-to-do.ts"
"migrate": "set -a && . ./.env && set +a && npx tsx scripts/migrate-kv-to-do.ts",
"recon:correspondents": "npx tsx scripts/recon-correspondent-stats.ts"
},
"devDependencies": {
"@biomejs/biome": "2.4.5",
Expand Down
92 changes: 92 additions & 0 deletions scripts/recon-correspondent-stats.ts
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
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use Unix seconds for BTC_TIMESTAMP in recon script docs

The script documents BTC_TIMESTAMP as an ISO string and the usage example provides 2026-05-03T12:00:00Z, but auth verification parses the header with Number(timestamp) and requires a Unix-seconds value (verifyTimestamp in src/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 👍 / 👎.

*
* 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Compare repaired count against drifted addresses, not fields

The exit condition treats repaired === drift_count as success, but drift_count is the number of mismatched fields while repaired is the number of drifted addresses recomputed by the DO. If one address has multiple mismatched fields, repair can fully succeed yet the script exits with code 3, causing false failures in automation.

Useful? React with 👍 / 👎.

}

main().catch((err) => {
console.error(err);
process.exit(1);
});
222 changes: 222 additions & 0 deletions src/__tests__/correspondent-stats.test.ts
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();
});
});
Loading
Loading