Skip to content
Merged
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
2 changes: 1 addition & 1 deletion config/release-versions.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"trackingIssue": null
},
"compatibility": {
"publicScoreReadOrder": ["v9", "v8"],
"publicScoreReadOrder": ["v9"],
"roastReplay": [
{
"score": "v9",
Expand Down
4 changes: 4 additions & 0 deletions docs/releases/v9-v9-v4-rollout.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ If the quick collector cannot complete, a verified `v5/v5/v3` artifact may be
served as a read-only emergency fallback. A successful quick scan always takes
precedence and overwrites the current profile atomically.

`v8` is not a public read fallback. Public pages, score APIs, search, rankings,
badges, and profile snapshots serve only v9 artifacts; a v5 response is allowed
only after the synchronous quick collector has failed.

## Deployment Checks

1. Run `pnpm versions:check`, `pnpm typecheck`, `pnpm lint`, and `pnpm test`.
Expand Down
90 changes: 89 additions & 1 deletion src/app/api/score/[username]/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { NextRequest } from "next/server";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AccountDetail } from "@/lib/db";
import type { ScanResult } from "@/lib/types";

const mocks = vi.hoisted(() => ({
Expand All @@ -13,6 +14,7 @@ const mocks = vi.hoisted(() => ({
publishCompleteQuickScan: vi.fn(),
rateLimitHeaders: vi.fn(),
recordAccountLookup: vi.fn(),
scanErrorResponse: vi.fn(),
}));

vi.mock("@/lib/db", () => ({
Expand All @@ -30,7 +32,10 @@ vi.mock("@/lib/redis", () => ({
getCachedScan: mocks.getCachedScan,
rateLimitHeaders: mocks.rateLimitHeaders,
}));
vi.mock("@/lib/scan-core", () => ({ buildScanResult: mocks.buildScanResult }));
vi.mock("@/lib/scan-core", () => ({
buildScanResult: mocks.buildScanResult,
scanErrorResponse: mocks.scanErrorResponse,
}));

import { GET } from "./route";

Expand All @@ -39,6 +44,42 @@ const quickScan = {
scoring: { final_score: 71, tier: "人上人", tier_label: "trusted", sub_scores: {}, base_score: 71, total_penalty: 0, red_flags: [] },
} as unknown as ScanResult;

const subScores = {
account_maturity: 10,
original_project_quality: 10,
contribution_quality: 10,
ecosystem_impact: 10,
community_influence: 10,
activity_authenticity: 10,
};

const legacyFallback = {
username: "fixture-user",
display_name: "Fixture User",
avatar_url: null,
profile_url: "https://github.com/fixture-user",
final_score: 64,
tier: "人上人",
tags: { zh: [], en: [] },
sub_scores: subScores,
roast_line: { zh: "", en: "" },
roast: "legacy report",
roast_en: "legacy report",
score_version: "v5",
legacy_read_fallback: true,
score_source_collection_version: null,
score_source_snapshot_hash: null,
scanned_at: 1,
prev_score: null,
prev_scanned_at: null,
} as AccountDetail;

const obsoleteV8Detail = {
...legacyFallback,
score_version: "v8",
legacy_read_fallback: false,
};

describe("GET /api/score immediate quick contract", () => {
beforeEach(() => {
mocks.getAccountDetail.mockResolvedValue(null);
Expand All @@ -51,6 +92,7 @@ describe("GET /api/score immediate quick contract", () => {
mocks.recordAccountLookup.mockResolvedValue(undefined);
mocks.getPercentileCached.mockResolvedValue(null);
mocks.getRankCached.mockResolvedValue(null);
mocks.scanErrorResponse.mockReturnValue({ error: "scan_failed", status: 500 });
});

it("materializes a cold account instead of returning 202", async () => {
Expand All @@ -62,4 +104,50 @@ describe("GET /api/score immediate quick contract", () => {
await expect(response.json()).resolves.toMatchObject({ source: "quick", coverage: "quick", final_score: 71 });
expect(mocks.publishCompleteQuickScan).toHaveBeenCalledWith(quickScan, expect.any(Number));
});

it("refreshes a v5 fallback with quick scan before serving it", async () => {
mocks.getAccountDetail.mockResolvedValue(legacyFallback);

const response = await GET(new NextRequest("https://example.test/api/score/fixture-user"), {
params: Promise.resolve({ username: "fixture-user" }),
});

expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
source: "quick",
coverage: "quick",
final_score: 71,
});
expect(mocks.buildScanResult).toHaveBeenCalledWith("fixture-user");
expect(mocks.publishCompleteQuickScan).toHaveBeenCalledWith(quickScan, expect.any(Number));
});

it("serves the verified v5 fallback only after quick scan fails", async () => {
mocks.getAccountDetail.mockResolvedValue(legacyFallback);
mocks.buildScanResult.mockRejectedValue(new Error("upstream unavailable"));

const response = await GET(new NextRequest("https://example.test/api/score/fixture-user"), {
params: Promise.resolve({ username: "fixture-user" }),
});

expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({
source: "legacy_v5_v5_v3",
coverage: "legacy",
stale: true,
final_score: 64,
});
});

it("never replays a v8 detail when quick scan fails", async () => {
mocks.getAccountDetail.mockResolvedValue(obsoleteV8Detail);
mocks.buildScanResult.mockRejectedValue(new Error("upstream unavailable"));

const response = await GET(new NextRequest("https://example.test/api/score/fixture-user"), {
params: Promise.resolve({ username: "fixture-user" }),
});

expect(response.status).toBe(500);
await expect(response.json()).resolves.toMatchObject({ error: "scan_failed" });
});
});
76 changes: 48 additions & 28 deletions src/app/api/score/[username]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getAccountDetail,
publishCompleteQuickScan,
recordAccountLookup,
type AccountDetail,
} from "@/lib/db";
import { getPercentileCached, getRankCached } from "@/lib/rank";
import { normalizeUsername } from "@/lib/username";
Expand Down Expand Up @@ -109,6 +110,44 @@ async function liveScoreResponse(scan: ScanResult, cached: boolean, headers: Rec
);
}

function isCanonicalDetail(detail: AccountDetail): boolean {
return (
detail.score_version === SCORE_CACHE_VERSION &&
detail.score_source_collection_version === PUBLIC_SCAN_COLLECTION_VERSION &&
typeof detail.score_source_snapshot_hash === "string" &&
/^[a-f0-9]{64}$/.test(detail.score_source_snapshot_hash)
);
}

async function persistedScoreResponse(
detail: AccountDetail,
options: { source: "indexed" | "legacy_v5_v5_v3"; current: boolean; headers?: Record<string, string> },
) {
return json(
{
source: options.source,
coverage: options.current ? "quick" : "legacy",
stale: !options.current,
username: detail.username,
display_name: detail.display_name,
avatar_url: detail.avatar_url,
profile_url: detail.profile_url ?? `https://github.com/${detail.username}`,
final_score: detail.final_score,
tier: detail.tier,
tier_key: TIER_KEY[detail.tier],
sub_scores: detail.sub_scores,
tags: detail.tags,
roast_line: detail.roast_line,
percentile: await percentileFor(detail.final_score),
scanned_at: detail.scanned_at,
profile: `${SITE_URL}/u/${detail.username}`,
},
200,
options.current ? RATED_CACHE : LIVE_CACHE,
options.headers,
);
}

/** Public deterministic score: bounded quick collection only, never queue work. */
export async function GET(
req: NextRequest,
Expand All @@ -129,34 +168,8 @@ export async function GET(
}

const detail = await getAccountDetail(handle);
if (detail) {
const currentScore =
detail.score_version === SCORE_CACHE_VERSION &&
detail.score_source_collection_version === PUBLIC_SCAN_COLLECTION_VERSION &&
typeof detail.score_source_snapshot_hash === "string" &&
/^[a-f0-9]{64}$/.test(detail.score_source_snapshot_hash);
return json(
{
source: "indexed",
coverage: "quick",
stale: !currentScore,
username: detail.username,
display_name: detail.display_name,
avatar_url: detail.avatar_url,
profile_url: detail.profile_url ?? `https://github.com/${detail.username}`,
final_score: detail.final_score,
tier: detail.tier,
tier_key: TIER_KEY[detail.tier],
sub_scores: detail.sub_scores,
tags: detail.tags,
roast_line: detail.roast_line,
percentile: await percentileFor(detail.final_score),
scanned_at: detail.scanned_at,
profile: `${SITE_URL}/u/${detail.username}`,
},
200,
currentScore ? RATED_CACHE : LIVE_CACHE,
);
if (detail && isCanonicalDetail(detail)) {
return persistedScoreResponse(detail, { source: "indexed", current: true });
}

const limit = await checkRateLimit(clientIp(req));
Expand Down Expand Up @@ -190,6 +203,13 @@ export async function GET(
}
} catch (error) {
if (error instanceof ScorePersistenceError) return scorePersistenceUnavailable(headers);
if (detail?.legacy_read_fallback) {
return persistedScoreResponse(detail, {
source: "legacy_v5_v5_v3",
current: false,
headers,
});
}
const { error: code, status, retry_after } = scanErrorResponse(error);
return json(
{ error: code, message: code.replace(/_/g, " "), ...(retry_after ? { retry_after } : {}) },
Expand Down
52 changes: 14 additions & 38 deletions src/lib/__tests__/db.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1993,7 +1993,7 @@ describe("profile snapshots", () => {
});
});

it("prefers v9, falls back to v8, and ignores non-release snapshots", async () => {
it("reads only v9 profile snapshots and ignores legacy snapshots", async () => {
const client = createClient({ url: process.env.TURSO_DATABASE_URL! });
const username = "profile-version-fixture";
const rows = [
Expand All @@ -2017,10 +2017,7 @@ describe("profile snapshots", () => {
sql: `DELETE FROM profile_snapshots WHERE id = ?`,
args: ["profile-v9"],
});
await expect(db.getProfileSnapshot(username)).resolves.toMatchObject({
metrics: { followers: 8 },
scanned_at: 300,
});
await expect(db.getProfileSnapshot(username)).resolves.toBeNull();

await client.execute({
sql: `INSERT INTO profile_snapshots (id, username, scanned_at, metrics, scan_version)
Expand Down Expand Up @@ -2370,8 +2367,8 @@ describe("getRepoOverview + filterExistingRepoKeys", () => {
});
});

describe("legacy public score release guardrail", () => {
it("keeps a synthetic v8 row on every passive public surface without creating work", async () => {
describe("legacy public score exclusion", () => {
it("never serves a v8 row on passive public surfaces or creates background work", async () => {
const username = "legacy-public-fixture";
const peer = "legacy-peer-fixture";
const repoKey = `${username}/public-fixture`;
Expand Down Expand Up @@ -2452,40 +2449,19 @@ describe("legacy public score release guardrail", () => {
db.getArchivedRoast(username, "zh"),
]);

expect(detail).toMatchObject({
username,
final_score: 86,
score_version: "v8",
tags: { zh: [], en: [] },
roast_line: { zh: "", en: "" },
roast: null,
roast_en: null,
});
expect(brief).toMatchObject({ username, final_score: 86 });
expect(suggestions).toEqual(expect.arrayContaining([expect.objectContaining({ username })]));
expect(detail).toBeNull();
expect(brief).toBeNull();
expect(suggestions.some((item) => item.username === username)).toBe(false);
for (const entries of [leaderboard, trending, heat]) {
expect(entries.some((item) => item.username === username)).toBe(true);
expect(entries.find((item) => item.username === username)?.tags).toEqual({ zh: [], en: [] });
expect(entries.some((item) => item.username === username)).toBe(false);
}
expect(facets).toEqual(
expect.arrayContaining([expect.objectContaining({ value: "FixtureLang" })]),
);
expect(facetDevelopers).toEqual(
expect.arrayContaining([
expect.objectContaining({ username, tags: { zh: [], en: [] } }),
]),
);
expect(sitemapProfiles).toEqual(
expect.arrayContaining([expect.objectContaining({ username })]),
);
expect(repo).toMatchObject({ owner: { username }, summary: { count: 1 } });
expect(similar).toEqual(
expect.arrayContaining([
expect.objectContaining({ username: peer, tags: { zh: [], en: [] } }),
]),
);
expect(facets.some((facet) => facet.value === "FixtureLang")).toBe(false);
expect(facetDevelopers.some((item) => item.username === username)).toBe(false);
expect(sitemapProfiles.some((item) => item.username === username)).toBe(false);
expect(repo).toMatchObject({ owner: null, summary: { count: 0 } });
expect(similar.some((item) => item.username === peer)).toBe(false);
expect(following).toEqual(
expect.arrayContaining([expect.objectContaining({ username, final_score: 86 })]),
expect.arrayContaining([expect.objectContaining({ username, final_score: null })]),
);
expect(archivedRoast).toBeNull();

Expand Down
Loading
Loading