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
24 changes: 24 additions & 0 deletions apps/web/src/api/lib/uncertainty/orphan-sweep.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { and, eq, lt } from "drizzle-orm";
import type { Database } from "../../../db/client";
import { uncertaintyPrediction } from "../../../db/schema/uncertainty";

export type SweepResult = {
swept: number;
cutoffMs: number;
};

export async function sweepOrphans(db: Database, now: Date = new Date()): Promise<SweepResult> {
const cutoff = now;
const result = await db
.update(uncertaintyPrediction)
.set({ state: "orphaned" })
.where(
and(
eq(uncertaintyPrediction.state, "emitted"),
lt(uncertaintyPrediction.orphanAfter, cutoff),
),
)
.returning({ id: uncertaintyPrediction.id });

return { swept: result.length, cutoffMs: cutoff.getTime() };
}
23 changes: 23 additions & 0 deletions apps/web/src/api/routes/uncertainty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,21 @@ import { createDb } from "../../db/client";
import { uncertaintyCalibrationSnapshot, uncertaintyPrediction } from "../../db/schema/uncertainty";
import { outcomeLabelSchema, surfaceSchema } from "../../db/schema/uncertainty.zod";
import { cohortKey, DAY_MS, DEFAULT_ORPHAN_TTL_DAYS } from "../lib/uncertainty/cohort";
import { sweepOrphans } from "../lib/uncertainty/orphan-sweep";
import { requireAuth } from "../middleware/auth";
import type { HonoEnv } from "../types";

function authorizeCron(c: {
req: { header: (n: string) => string | undefined };
env: Env;
}): boolean {
const secret = c.env.CRON_SECRET;
if (!secret) return false;
const header = c.req.header("authorization");
if (!header) return false;
return header === `Bearer ${secret}`;
}

const emitBodySchema = z.object({
surface: surfaceSchema,
feature_key: z.string().min(1).max(128),
Expand Down Expand Up @@ -130,4 +142,15 @@ const uncertaintyRoutes = new Hono<HonoEnv>()
return c.json({ surfaces: rows });
});

const internalRoutes = new Hono<HonoEnv>().post("/orphan-sweep", async (c) => {
if (!authorizeCron({ req: { header: (n) => c.req.header(n) }, env: c.env })) {
return c.json({ error: "Unauthorized" }, 401);
}
const db = createDb(c.env.DB);
const result = await sweepOrphans(db);
return c.json(result);
});

uncertaintyRoutes.route("/internal", internalRoutes);

export { uncertaintyRoutes };
18 changes: 18 additions & 0 deletions apps/web/tests/api/lib/uncertainty/orphan-sweep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { and, eq, lt } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { uncertaintyPrediction } from "../../../../src/db/schema/uncertainty";

describe("orphan sweep WHERE clause", () => {
it("filters on state='emitted' AND orphan_after < cutoff", () => {
// Documents the predicate shape the sweep relies on. If either side
// shifts, the index (state, orphan_after) needs to be revisited.
const cutoff = new Date(1_700_000_000_000);
const clause = and(
eq(uncertaintyPrediction.state, "emitted"),
lt(uncertaintyPrediction.orphanAfter, cutoff),
);
expect(clause).toBeDefined();
expect(uncertaintyPrediction.state.name).toBe("state");
expect(uncertaintyPrediction.orphanAfter.name).toBe("orphan_after");
});
});
6 changes: 5 additions & 1 deletion apps/web/worker-configuration.d.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types` (hash: ebdf5e7161d935f565772396f56f7889)
// Generated by Wrangler by running `wrangler types` (hash: 17589c13ed4b5ad2f4cd96970db37c40)
// Runtime types generated with workerd@1.20260317.1 2026-03-18 nodejs_compat
declare namespace Cloudflare {
interface Env {
rafters_session: KVNamespace;
rafters_email: R2Bucket;
rafters_logs: R2Bucket;
DB: D1Database;
CRON_SECRET: "";
BETTER_AUTH_SECRET: string;
BETTER_AUTH_URL: string;
GITHUB_CLIENT_ID: string;
GITHUB_CLIENT_SECRET: string;
POLAR_ACCESS_TOKEN: string;
POLAR_WEBHOOK_SECRET: string;
CF_WORKER_AI_KEY: string;
}
}
interface Env extends Cloudflare.Env {}
Expand All @@ -23,12 +25,14 @@ declare namespace NodeJS {
interface ProcessEnv extends StringifyValues<
Pick<
Cloudflare.Env,
| "CRON_SECRET"
| "BETTER_AUTH_SECRET"
| "BETTER_AUTH_URL"
| "GITHUB_CLIENT_ID"
| "GITHUB_CLIENT_SECRET"
| "POLAR_ACCESS_TOKEN"
| "POLAR_WEBHOOK_SECRET"
| "CF_WORKER_AI_KEY"
>
> {}
}
Expand Down
12 changes: 12 additions & 0 deletions apps/web/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,16 @@
"binding": "rafters_logs",
},
],
// CRON_SECRET is a runtime secret. Declared here only so wrangler types
// includes it in Env. Set the real value with `wrangler secret put CRON_SECRET`.
"vars": {
"CRON_SECRET": "",
},
// Hourly orphan sweep. The Astro adapter does not expose a scheduled()
// entrypoint, so the trigger calls POST /api/uncertainty/internal/orphan-sweep
// via an external scheduler (or a future companion worker) using the
// CRON_SECRET bearer token.
"triggers": {
"crons": ["0 * * * *"],
},
}
Loading