Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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
6 changes: 4 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
NEXT_PUBLIC_SITE_URL=https://agentscore.fern.dev

# RDS Postgres connection for the `scores` table (agent_score DB on fern-prod-enc).
# Replaces the old SUPABASE_URL / SUPABASE_SECRET_KEY PostgREST data path (FER-11415).
# Data path for the score store (FER-11415).
# NOTE: the old SUPABASE_URL / SUPABASE_SECRET_KEY PostgREST vars are no longer
# read - remove any SUPABASE_* vars from deployed environments.
AGENT_SCORE_DATABASE_URL=postgres://USER:PASSWORD@fern-prod-enc.cihbconq6tcp.us-east-1.rds.amazonaws.com:5432/agent_score?sslmode=require

# S3 bucket for OG images (replaces Supabase Storage `og-images`).
# S3 bucket for OG images.
# Provisioned by the fern-platform agent-score-deploy CDK stack.
OG_S3_BUCKET=agent-score-prod-og-images
OG_S3_REGION=us-east-1
Expand Down
8 changes: 4 additions & 4 deletions app/api/score/[jobId]/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import fs from "fs";
import { getScoreBySlug } from "@/lib/supabase";
import { getScoreBySlug } from "@/lib/database";

export const runtime = "nodejs";

Expand All @@ -13,13 +13,13 @@ export async function GET(req: Request, { params }: { params: { jobId: string }
try {
const raw = fs.readFileSync(`/tmp/score-${jobId}.json`, "utf-8");
const data = JSON.parse(raw);
// Only return terminal states from file — if still "running", fall through to Supabase
// Only return terminal states from file — if still "running", fall through to RDS
if (data.status === 'complete' || data.status === 'error') {
return NextResponse.json(data);
}
} catch { /* file not on this instance */ }

// Supabase fallback — handles cross-instance polling on Vercel (not needed in dev)
// RDS fallback — handles cross-instance polling on Vercel (not needed in dev)
if (slug && process.env.NODE_ENV !== 'development') {
try {
const company = await getScoreBySlug(slug);
Expand All @@ -37,7 +37,7 @@ export async function GET(req: Request, { params }: { params: { jobId: string }
});
}
}
} catch { /* Supabase unavailable, keep polling */ }
} catch { /* RDS unavailable, keep polling */ }
}

return NextResponse.json({ status: "running" });
Expand Down
10 changes: 5 additions & 5 deletions app/api/score/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import fs from "fs";
import { waitUntil } from "@vercel/functions";
import { upsertScore, getScoreBySlug } from "@/lib/supabase";
import { upsertScore, getScoreBySlug } from "@/lib/database";
import { fetchOgName, domainToName } from "@/lib/og-name";
import { computeScore } from "afdocs";
import { AFDOCS_VERSION } from "@/lib/scoring";
Expand Down Expand Up @@ -195,7 +195,7 @@ async function runJob(jobId: string, url: string, slug?: string, name?: string,

try {
await upsertScore(companyData);
console.log("[score] Supabase upsert complete for:", effectiveSlug);
console.log("[score] scores upsert complete for:", effectiveSlug);
const webhookUrl = process.env.SLACK_DEMO_WEBHOOK_URL;
if (webhookUrl) {
const scoredPageUrl = `https://fern-agent-score.vercel.app/agent-score/company/${effectiveSlug}`;
Expand All @@ -207,12 +207,12 @@ async function runJob(jobId: string, url: string, slug?: string, name?: string,
}).catch(() => {});
}
} catch (dbErr) {
console.error("[score] Supabase upsert failed:", dbErr instanceof Error ? dbErr.message : dbErr);
console.error("[score] scores upsert failed:", dbErr instanceof Error ? dbErr.message : dbErr);
}

try {
const { generateOgImageBuffer } = await import("@/lib/og-image-generator");
const { uploadOgImage } = await import("@/lib/supabase");
const { uploadOgImage } = await import("@/lib/database");
const buffer = await generateOgImageBuffer(companyData);
await uploadOgImage(effectiveSlug, buffer);
console.log("[score] OG image uploaded for:", effectiveSlug);
Expand Down Expand Up @@ -388,7 +388,7 @@ export async function POST(request: Request) {
});
return NextResponse.json({ jobId, slug: existing.slug, cached: true });
}
} catch { /* Supabase check failed — proceed with scoring */ }
} catch { /* scores lookup failed — proceed with scoring */ }
}

// Docs-site detection
Expand Down
2 changes: 1 addition & 1 deletion components/ScoreChecker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export default function ScoreChecker() {
return;
}
jobIdRef.current = data.jobId;
// cached=true means slug already exists in Supabase — no since needed
// cached=true means slug already exists in the scores DB — no since needed
// new jobs pass since so the poll only accepts results scored after this run started
startPolling(data.jobId, data.slug, data.cached ? undefined : Date.now());
} catch (err) {
Expand Down
2 changes: 1 addition & 1 deletion lib/supabase.ts → lib/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { isBlockedDomain } from './blocked-domains';
import { query } from './db';

// OG-image storage moved from Supabase Storage to S3 (FER-11415). Re-exported
// here so existing callers (`@/lib/supabase`) keep working unchanged.
// here so existing callers (`@/lib/database`) keep working unchanged.
export { uploadOgImage, getOgImagePublicUrl } from './og-storage';

export interface ScoreRow {
Expand Down
4 changes: 2 additions & 2 deletions lib/scores.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@ export interface CompanyScore {
}

export async function getCompanyWithFallback(slug: string): Promise<CompanyScore | null> {
const { getScoreBySlug } = await import('./supabase');
const { getScoreBySlug } = await import('./database');
return getScoreBySlug(slug);
}

export async function getLeaderboard(category?: string): Promise<CompanyScore[]> {
const { getAllScores } = await import('./supabase');
const { getAllScores } = await import('./database');
const all = await getAllScores();
const entries = category
? all.filter((e) => e.category.toLowerCase() === category.toLowerCase())
Expand Down
3 changes: 1 addition & 2 deletions scripts/check-supabase.ts → scripts/check-scores.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* Quick health check against the agent-score RDS database (`agent_score`).
* Run with: export $(grep -v '^#' .env.local | xargs) && npx tsx scripts/check-supabase.ts
* (Name kept for muscle memory; this now talks to RDS, not Supabase — FER-11415.)
* Run with: export $(grep -v '^#' .env.local | xargs) && npx tsx scripts/check-scores.ts
*/
import { query } from '../lib/db';

Expand Down
4 changes: 2 additions & 2 deletions scripts/dedup-supabase.ts → scripts/dedup-scores.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Removes duplicate rows in the scores table, keeping the most recently scored
* row per slug. Talks to RDS (`agent_score`), not Supabase (FER-11415).
* Run with: export $(grep -v '^#' .env.local | xargs) && npx tsx scripts/dedup-supabase.ts
* row per slug. Talks to the agent-score RDS database (`agent_score`).
* Run with: export $(grep -v '^#' .env.local | xargs) && npx tsx scripts/dedup-scores.ts
*
* Note: on RDS `slug` is the conflict key for upserts, so duplicates by slug
* should not normally exist; this remains as a safety/cleanup utility.
Expand Down
37 changes: 0 additions & 37 deletions scripts/migrate-to-supabase.ts

This file was deleted.

4 changes: 2 additions & 2 deletions scripts/rerun-all.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
*/

import { computeScore } from '../lib/scoring';
import { upsertScore } from '../lib/supabase';
import { upsertScore } from '../lib/database';
import { query as dbQuery } from '../lib/db';

const DELAY_MS = parseInt(process.env.DELAY_MS ?? '1000', 10);
Expand Down Expand Up @@ -151,7 +151,7 @@ async function main() {

const scored = computeScore(scorableResults as Parameters<typeof computeScore>[0]);

const stopSaving = startSpinner('saving to Supabase');
const stopSaving = startSpinner('saving to RDS');
try {
await upsertScore({
name: company.name,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/**
* Re-scores all companies from companies.json, saving results to Supabase.
* Run with:
* npx tsx scripts/rescore-to-supabase.ts
* Re-scores all companies from companies.json, saving results to the RDS
* scores store (via lib/database).
* npx tsx scripts/rescore-to-database.ts
*/
import fs from "fs";
import path from "path";
import { upsertScore } from "../lib/supabase";
import { upsertScore } from "../lib/database";

interface Company {
name: string;
Expand Down
8 changes: 4 additions & 4 deletions scripts/score-single.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Score a single URL locally and upload the result to Supabase.
* Score a single URL locally and upload the result to the RDS scores table.
* Useful for sites that time out on the live scorer.
*
* Usage:
Expand All @@ -9,7 +9,7 @@
* npx tsx --env-file=.env.local scripts/score-single.ts https://developers.google.com/maps/ "Google Maps" google-maps "DevTools"
* npx tsx --env-file=.env.local scripts/score-single.ts https://developers.google.com/maps/ "Google Maps" google-maps "DevTools" --hidden
*/
import { upsertScore } from "../lib/supabase";
import { upsertScore } from "../lib/database";
import { inferCategory } from "../lib/categorize";
import { computeScore } from "afdocs";
import { AFDOCS_VERSION } from "../lib/scoring";
Expand Down Expand Up @@ -140,7 +140,7 @@ async function main() {
console.log(`\nScore: ${score} (${grade})`);
console.log(`Checks: ${result.summary.pass}/${result.summary.total} pass`);

process.stdout.write("Uploading to Supabase ... ");
process.stdout.write("Uploading to RDS ... ");
await upsertScore(companyData);
process.stdout.write(`done\n`);
console.log(`slug="${effectiveSlug}"`);
Expand All @@ -149,7 +149,7 @@ async function main() {
try {
process.stdout.write("Generating OG image ... ");
const { generateOgImageBuffer } = await import("../lib/og-image-generator");
const { uploadOgImage } = await import("../lib/supabase");
const { uploadOgImage } = await import("../lib/database");
const buffer = await generateOgImageBuffer(companyData);
await uploadOgImage(effectiveSlug, buffer);
process.stdout.write("uploaded\n");
Expand Down
77 changes: 77 additions & 0 deletions test/scores.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

// Mock the RDS query layer so we can exercise the pure transform/filter logic
// in lib/database.ts (rowToCompany normalisation + blocked-domain filtering)
// without a live database.
const query = vi.fn();
vi.mock("@/lib/db", () => ({
query: (...args: unknown[]) => query(...args),
}));

import { getScoreBySlug } from "@/lib/database";

function makeRow(overrides: Record<string, unknown> = {}) {
return {
slug: "stripe",
name: "Stripe",
category: "payments",
docs_url: "https://docs.stripe.com",
score: 90,
grade: "A+",
scored_at: "2024-01-01T00:00:00.000Z",
checks_total: 10,
checks_pass: 9,
checks_warn: 1,
checks_fail: 0,
results: [],
category_scores: null,
afdocs_version: null,
hidden: false,
is_fern: false,
...overrides,
};
}

describe("getScoreBySlug", () => {
beforeEach(() => {
query.mockReset();
});

it("returns null when no row is found", async () => {
query.mockResolvedValueOnce({ rows: [] });
expect(await getScoreBySlug("missing")).toBeNull();
});

it("maps a row into a CompanyScore", async () => {
query.mockResolvedValueOnce({ rows: [makeRow()] });
const company = await getScoreBySlug("stripe");
expect(company).toMatchObject({
slug: "stripe",
name: "Stripe",
docsUrl: "https://docs.stripe.com",
score: 90,
grade: "A+",
checks: { total: 10, pass: 9, warn: 1, fail: 0 },
});
});

it("normalises a Date scored_at into an ISO string", async () => {
query.mockResolvedValueOnce({
rows: [makeRow({ scored_at: new Date("2024-06-01T12:34:56.000Z") })],
});
const company = await getScoreBySlug("stripe");
expect(company?.scoredAt).toBe("2024-06-01T12:34:56.000Z");
});

it("treats blocked-domain records as not found", async () => {
query.mockResolvedValueOnce({
rows: [makeRow({ slug: "blocked", docs_url: "https://pornhub.com" })],
});
expect(await getScoreBySlug("blocked")).toBeNull();
});

it("returns null and swallows query errors", async () => {
query.mockRejectedValueOnce(new Error("db down"));
expect(await getScoreBySlug("stripe")).toBeNull();
});
});