diff --git a/drizzle/0024_remove_reposted_work.sql b/drizzle/0024_remove_reposted_work.sql new file mode 100644 index 0000000..45719c6 --- /dev/null +++ b/drizzle/0024_remove_reposted_work.sql @@ -0,0 +1,29 @@ +SELECT pg_advisory_xact_lock(hashtext('github-work-unit-projection-v1')); +--> statement-breakpoint +-- Remove unchanged revisions reposted after daily-history publication began. +-- Compare only the immediately preceding day, preserving genuine A -> B -> A work. +WITH duplicates AS ( + SELECT newer.day, newer.identity_key + FROM github_activity_snapshots newer + CROSS JOIN LATERAL ( + SELECT * FROM github_activity_snapshots older + WHERE older.identity_key = newer.identity_key AND older.day < newer.day + ORDER BY older.day DESC LIMIT 1 + ) previous + WHERE newer.day >= DATE '2026-09-28' + AND newer.work_unit_id = previous.work_unit_id + AND newer.work_unit_revision = previous.work_unit_revision + AND newer.facts_digest = previous.facts_digest + AND newer.outcome_digest IS NOT DISTINCT FROM previous.outcome_digest + AND newer.attribution_mode = previous.attribution_mode + AND newer.payload->'facts' = previous.payload->'facts' +), removed AS ( + DELETE FROM github_activity_snapshots s USING duplicates d + WHERE s.day = d.day AND s.identity_key = d.identity_key + RETURNING s.day +) +UPDATE github_public_feed_head SET + feed_revision = feed_revision + 1, + head_content_revision = head_content_revision + 1, + last_published_at = clock_timestamp() +WHERE id = true AND EXISTS (SELECT 1 FROM removed); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 5f0b94f..13158b5 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -169,6 +169,13 @@ "when": 1790534206181, "tag": "0023_daily_history", "breakpoints": true + }, + { + "idx": 24, + "version": "7", + "when": 1790543384000, + "tag": "0024_remove_reposted_work", + "breakpoints": true } ] } diff --git a/src/app/(portfolio)/page.tsx b/src/app/(portfolio)/page.tsx index 823343b..12c8da7 100644 --- a/src/app/(portfolio)/page.tsx +++ b/src/app/(portfolio)/page.tsx @@ -25,7 +25,7 @@ import { buildPageMetadata } from "@/lib/page-metadata"; export const metadata = buildPageMetadata(pages.home); -export const dynamic = "force-dynamic"; +export const revalidate = 60; function OpenSource({ github }: Readonly<{ github: GitHubProfile }>) { const projects = github.projects diff --git a/src/app/(portfolio)/tokens/page.tsx b/src/app/(portfolio)/tokens/page.tsx index 01b352b..24dd973 100644 --- a/src/app/(portfolio)/tokens/page.tsx +++ b/src/app/(portfolio)/tokens/page.tsx @@ -13,7 +13,7 @@ import { buildPageMetadata } from "@/lib/page-metadata"; export const metadata = buildPageMetadata(pages.tokens); -export const dynamic = "force-dynamic"; +export const revalidate = 60; export default async function TokensPage() { if (!tokenPreferences.enabled) { diff --git a/src/app/(portfolio)/work/page.tsx b/src/app/(portfolio)/work/page.tsx index fe6c20d..a5bc779 100644 --- a/src/app/(portfolio)/work/page.tsx +++ b/src/app/(portfolio)/work/page.tsx @@ -7,7 +7,7 @@ import { buildPageMetadata } from "@/lib/page-metadata"; export const metadata = buildPageMetadata(pages.work); -export const dynamic = "force-dynamic"; +export const revalidate = 60; export default async function WorkLogPage() { const initialPage = await getInitialGitHubActivity(); diff --git a/src/app/api/cron/codex-stats/route.ts b/src/app/api/cron/codex-stats/route.ts index c3b4b2d..9fcd1ea 100644 --- a/src/app/api/cron/codex-stats/route.ts +++ b/src/app/api/cron/codex-stats/route.ts @@ -1,6 +1,8 @@ import { revalidateTag } from "next/cache"; +import { after } from "next/server"; import { env } from "@/env"; +import { getPublicCodexStats } from "@/lib/codex/public-stats"; import { syncCodexAccounts } from "@/lib/codex/sync"; import { reportOperationalError } from "@/lib/operational-error"; import { hasBearerSecret } from "@/lib/request-auth"; @@ -15,7 +17,10 @@ export async function POST(request: Request) { try { const result = await syncCodexAccounts(); if (result.updated > 0) { - revalidateTag("public-codex-stats", { expire: 0 }); + revalidateTag("public-codex-stats", "max"); + after(async () => { + await getPublicCodexStats(); + }); } return Response.json({ ok: true, result }); } catch (error) { diff --git a/src/app/api/cron/github-worker/route.ts b/src/app/api/cron/github-worker/route.ts index 4260af9..f7864af 100644 --- a/src/app/api/cron/github-worker/route.ts +++ b/src/app/api/cron/github-worker/route.ts @@ -1,4 +1,8 @@ +import { revalidateTag } from "next/cache"; +import { after } from "next/server"; + import { env } from "@/env"; +import { getInitialGitHubActivity } from "@/lib/github-activity-feed"; import { runGitHubActivityWorker } from "@/lib/github-activity-worker"; import { workerBatchSizeFrom } from "@/lib/github-activity-worker-core"; import { GITHUB_WORKER_EXECUTION_DURATION_MS } from "@/lib/github-cron-config"; @@ -38,6 +42,12 @@ export async function POST(request: Request) { refLimit: 1, }), }); + if (activity.projection?.feedRevisionChanged === true) { + revalidateTag("public-github-activity", "max"); + after(async () => { + await getInitialGitHubActivity(); + }); + } return Response.json({ activity, ok: true, diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx index f6264b0..686ff7d 100644 --- a/src/components/site-header.tsx +++ b/src/components/site-header.tsx @@ -33,9 +33,7 @@ export function SiteHeader({ }; return (
  • - - {item.label} - + {item.label}
  • ); }); @@ -48,7 +46,6 @@ export function SiteHeader({ > diff --git a/src/components/token-history.tsx b/src/components/token-history.tsx index 7618712..cfd0981 100644 --- a/src/components/token-history.tsx +++ b/src/components/token-history.tsx @@ -195,9 +195,7 @@ function HistoryPlot({

    {annotation} - {summary.partial - ? ". Partial history; gaps indicate unavailable data." - : ""} + {summary.partial ? ". Missing days are shown as zero usage." : ""}

    ); diff --git a/src/lib/codex/history.ts b/src/lib/codex/history.ts index 7af1d08..c9f5f31 100644 --- a/src/lib/codex/history.ts +++ b/src/lib/codex/history.ts @@ -4,18 +4,15 @@ export interface TokenHistory { } export function summarizeTokenHistory(history: TokenHistory, today: string) { - const rows = history.values.filter((row) => row.day <= today); + const reported = history.values.filter((row) => row.day <= today); + const rows = reported.map((row) => ({ ...row, tokens: row.tokens ?? 0 })); let total = 0; const cumulativeRows: TokenHistory["values"] = []; let peak: (typeof rows)[number] | undefined; for (const row of rows) { - if (row.tokens === null) { - cumulativeRows.push({ day: row.day, tokens: null }); - continue; - } total += row.tokens; cumulativeRows.push({ day: row.day, tokens: total }); - if (peak === undefined || row.tokens > (peak.tokens ?? 0)) { + if (row.tokens > 0 && (peak === undefined || row.tokens > peak.tokens)) { peak = row; } } @@ -24,6 +21,6 @@ export function summarizeTokenHistory(history: TokenHistory, today: string) { cumulativeRows, total, peak, - partial: history.partial || rows.some((row) => row.tokens === null), + partial: history.partial || reported.some((row) => row.tokens === null), }; } diff --git a/src/lib/github-activity-feed.ts b/src/lib/github-activity-feed.ts index fac61b2..f564948 100644 --- a/src/lib/github-activity-feed.ts +++ b/src/lib/github-activity-feed.ts @@ -16,7 +16,7 @@ const readCachedInitialGitHubActivity = unstable_cache( PUBLIC_GITHUB_ACTIVITY_DAY_PAGE_SIZE ), ["public-github-activity-initial-v3"], - { revalidate: 60 } + { revalidate: 60, tags: ["public-github-activity"] } ); export const getInitialGitHubActivity = async () => { diff --git a/src/lib/github-activity-snapshots.ts b/src/lib/github-activity-snapshots.ts index 805a411..5c96c8d 100644 --- a/src/lib/github-activity-snapshots.ts +++ b/src/lib/github-activity-snapshots.ts @@ -1,3 +1,5 @@ +import { isDeepStrictEqual } from "node:util"; + import { and, desc, eq, inArray, sql } from "drizzle-orm"; import { getDatabase } from "@/db/client"; @@ -47,8 +49,7 @@ export const githubSnapshotChanged = (previous: Saved, next: Saved) => { return ( "facts" in previous.payload && "facts" in next.payload && - JSON.stringify(previous.payload.facts) !== - JSON.stringify(next.payload.facts) + !isDeepStrictEqual(previous.payload.facts, next.payload.facts) ); }; diff --git a/tests/daily-history.test.ts b/tests/daily-history.test.ts index a17d6e8..b6b470c 100644 --- a/tests/daily-history.test.ts +++ b/tests/daily-history.test.ts @@ -151,6 +151,17 @@ test("work snapshots compare the latest outcome; rebases and first digest acquis expect(githubSnapshotChanged(saved(null), saved("A"))).toBe(false); expect(githubSnapshotChanged(saved("A"), saved(null, 240))).toBe(false); expect(githubSnapshotChanged(saved(null), saved(null, 240))).toBe(true); + const fromDatabase = saved(null); + if (!("facts" in fromDatabase.payload)) { + throw new Error("Expected a work card"); + } + // jsonb reorders object keys when a saved card is read back from PostgreSQL. + fromDatabase.payload.facts = Object.fromEntries( + Object.entries(fromDatabase.payload.facts).toReversed() + ) as typeof fromDatabase.payload.facts; + expect(githubSnapshotChanged(fromDatabase, saved(null))).toBe(false); + expect(githubSnapshotChanged(fromDatabase, saved("A"))).toBe(false); + expect(githubSnapshotChanged(fromDatabase, saved(null, 240))).toBe(true); expect( githubSnapshotChanged(saved("A"), { ...saved("A"), diff --git a/tests/hourly-publication-resilience.test.ts b/tests/hourly-publication-resilience.test.ts index 6b5825f..8adde53 100644 --- a/tests/hourly-publication-resilience.test.ts +++ b/tests/hourly-publication-resilience.test.ts @@ -9,14 +9,26 @@ const check = (source: string) => { expect(result.exitCode).toBe(0); }; -test("only the hourly invocation publishes, without changing ingestion limits or auth", () => { +test("only the hourly invocation publishes and warms changed activity, without changing ingestion limits or auth", () => { check(` import assert from "node:assert/strict"; import { mock } from "bun:test"; const calls = []; + const events = []; + const callbacks = []; + let changed = false; + let fail = false; + mock.module("next/cache", () => ({ revalidateTag: (...args) => events.push(args) })); + mock.module("next/server", () => ({ after: callback => callbacks.push(callback) })); + mock.module("./src/lib/github-activity-feed.ts", () => ({ getInitialGitHubActivity: async () => { events.push("warmed"); } })); + mock.module("./src/lib/operational-error.ts", () => ({ reportOperationalError: () => "Error" })); mock.module("./src/env.ts", () => ({ env: { CRON_SECRET: "hourly-publication-test-secret-32-characters" } })); mock.module("./src/lib/github-activity-worker.ts", () => ({ - runGitHubActivityWorker: async options => { calls.push(options); return {}; } + runGitHubActivityWorker: async options => { + if (fail) throw new Error("Publication failed"); + calls.push(options); + return { projection: options.includeProjection ? { feedRevisionChanged: changed } : null }; + } })); const { POST } = await import("./src/app/api/cron/github-worker/route.ts"); const request = (query, authorized = true) => POST(new Request( @@ -37,22 +49,39 @@ test("only the hourly invocation publishes, without changing ingestion limits or assert.equal(calls[2].includeProjection, true); assert.equal(calls[2].commitLimit, 2); assert.equal(calls[2].refLimit, 1); + assert.deepEqual(events, []); + assert.equal(callbacks.length, 0); + changed = true; + assert.equal((await request("?publish=1")).status, 200); + assert.deepEqual(events, [["public-github-activity", "max"]]); + assert.equal(callbacks.length, 1); + await callbacks.shift()(); + assert.deepEqual(events, [["public-github-activity", "max"], "warmed"]); + events.length = 0; + fail = true; + assert.equal((await request("?publish=1")).status, 503); + assert.deepEqual(events, []); + assert.equal(callbacks.length, 0); `); }); -test("Codex sync expires the public cache only after a successful save", () => { +test("Codex sync warms the public cache after a successful change, never after a failed or unchanged sync", () => { check(` import assert from "node:assert/strict"; import { mock } from "bun:test"; const events = []; + const callbacks = []; let fail = false; + let updated = 2; mock.module("next/cache", () => ({ revalidateTag: (...args) => events.push(args) })); + mock.module("next/server", () => ({ after: callback => callbacks.push(callback) })); + mock.module("./src/lib/codex/public-stats.ts", () => ({ getPublicCodexStats: async () => { events.push("warmed"); } })); mock.module("./src/env.ts", () => ({ env: { CRON_SECRET: "codex-cache-test-secret-32-characters" } })); mock.module("./src/lib/operational-error.ts", () => ({ reportOperationalError: () => "Error" })); mock.module("./src/lib/codex/sync.ts", () => ({ syncCodexAccounts: async () => { if (fail) throw new Error("Sync failed"); events.push("saved"); - return { updated: 2 }; + return { updated }; } })); const { POST } = await import("./src/app/api/cron/codex-stats/route.ts"); const request = (authorized = true) => POST(new Request("https://example.com/api/cron/codex-stats", { @@ -61,11 +90,20 @@ test("Codex sync expires the public cache only after a successful save", () => { assert.equal((await request(false)).status, 401); assert.deepEqual(events, []); assert.equal((await request()).status, 200); - assert.deepEqual(events, ["saved", ["public-codex-stats", { expire: 0 }]]); + assert.deepEqual(events, ["saved", ["public-codex-stats", "max"]]); + assert.equal(callbacks.length, 1); + await callbacks.shift()(); + assert.deepEqual(events, ["saved", ["public-codex-stats", "max"], "warmed"]); + events.length = 0; + updated = 0; + assert.equal((await request()).status, 200); + assert.deepEqual(events, ["saved"]); + assert.equal(callbacks.length, 0); events.length = 0; fail = true; assert.equal((await request()).status, 503); assert.deepEqual(events, []); + assert.equal(callbacks.length, 0); `); }); diff --git a/tests/token-history.test.ts b/tests/token-history.test.ts index 4129ce4..aa86fd4 100644 --- a/tests/token-history.test.ts +++ b/tests/token-history.test.ts @@ -38,7 +38,7 @@ test("history distinguishes unreported dates from zero and excludes future dates expect(make(10)?.history.values).toHaveLength(365); }); -test("daily history keeps gaps and finds the peak without counting future dates", () => { +test("missing days show zero daily usage and carry the cumulative total forward", () => { const history: TokenHistory = { partial: false, values: [ @@ -51,15 +51,11 @@ test("daily history keeps gaps and finds the peak without counting future dates" }; const result = summarizeTokenHistory(history, "2026-09-23"); expect(result.total).toBe(12); - expect(result.cumulativeRows.map((row) => row.tokens)).toEqual([ - 5, - null, - 5, - 12, - ]); + expect(result.cumulativeRows.map((row) => row.tokens)).toEqual([5, 5, 5, 12]); expect(result.peak).toEqual({ day: "2026-09-23", tokens: 7 }); expect(result.rows).toHaveLength(4); - expect(result.rows[1].tokens).toBeNull(); + expect(result.rows[1].tokens).toBe(0); + expect(history.values[1].tokens).toBeNull(); expect(result.partial).toBe(true); expect( summarizeTokenHistory(