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
10 changes: 8 additions & 2 deletions .github/workflows/selfhost.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,11 +61,17 @@ jobs:
if: ${{ github.event_name == 'push' || github.event.pull_request.draft != true }}
runs-on: ubuntu-latest
timeout-minutes: 20
env:
# Throwaway credential for the job-scoped ephemeral Postgres SERVICE CONTAINER below — deliberately
# not a repository secret: the container exists only for this job's lifetime, is reachable only from
# the runner's localhost, and holds nothing but synthetic test rows. Single-sourced here so the
# service env and the test step can never drift.
PG_TEST_PASSWORD: devpw
services:
postgres:
image: postgres:18-alpine
env:
POSTGRES_PASSWORD: devpw
POSTGRES_PASSWORD: ${{ env.PG_TEST_PASSWORD }}
POSTGRES_DB: loopover
ports:
- 5432:5432
Expand Down Expand Up @@ -94,7 +100,7 @@ jobs:
run: npm run build --workspace @loopover/engine

- name: Postgres integration test (real PG)
run: PG_TEST_URL=postgres://postgres:devpw@localhost:5432/loopover npx vitest run test/integration/selfhost-pg.test.ts
run: PG_TEST_URL=postgres://postgres:${{ env.PG_TEST_PASSWORD }}@localhost:5432/loopover npx vitest run test/integration/selfhost-pg.test.ts test/integration/selfhost-pg-calibration.test.ts

- name: Build the self-host bundle
run: node --experimental-strip-types scripts/build-selfhost.ts
Expand Down
23 changes: 23 additions & 0 deletions apps/loopover-ui/content/docs/backtest-calibration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,3 +79,26 @@ npx tsx scripts/backtest-corpus-export.ts --rule-id <ruleId> --output corpus.jso
```

Both CLIs are strictly read-only against the database.

## Self-hosting

On a self-host deployment the same calibration system runs against your Postgres instead of D1 —
the capture writers, the persisted backtest runs, the trend and satisfaction-floor endpoints, and
the loosening loop all go through the storage shim, so the corpus lives in your own
`audit_events` table and never leaves your infrastructure. Parity is pinned by a real-Postgres
integration suite that runs in CI.

All three maintainer CLIs accept `--pg` to run against a self-host Postgres instead of D1. An
explicit connection string wins; a bare `--pg` falls back to `DATABASE_URL`, the same variable the
self-host stack itself boots from:

```bash
npx tsx scripts/backtest-corpus-export.ts --rule-id <ruleId> --output corpus.json --pg "$DATABASE_URL"
npx tsx scripts/backtest-track-record.ts --pg
npx tsx scripts/backfill-calibration-corpus.ts --pg # dry-run; add --apply to write
```

The pg path reuses the deployment's own dialect adapter, so the backfill keeps its
`INSERT OR IGNORE` idempotency contract, and exported corpus manifests name only the database —
never the credentials in your connection string. Each deployment's corpus is its own: there is no
cross-store sync between a self-host Postgres and any other deployment.
55 changes: 43 additions & 12 deletions scripts/backfill-calibration-corpus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,34 @@
// wrapper — mirrors backtest-corpus-export.ts's identical split.
//
// tsx scripts/backfill-calibration-corpus.ts --db loopover [--remote] [--apply]
// … --pg postgres://… runs against a self-host Postgres instead (#8171; bare --pg uses DATABASE_URL)
//
// Deployment note (#8157): source AND destination are the same D1 — the ledger of record for this
// deployment. Self-host operators' corpora live in their own Postgres; a pg-driver variant is an explicit
// non-goal here.
// Deployment note (#8157): source AND destination are the same database — the ledger of record for
// WHICHEVER deployment the flags select: the D1 default, or a self-host Postgres via --pg (#8171). The
// pg path rides the same selfhost adapter the Worker uses, so INSERT OR IGNORE keeps its idempotency
// contract through the dialect translation.
import { spawnSync } from "node:child_process";
import { openPgDatabase, resolvePgConnection, type PgCliSession } from "./pg-cli.js";
import {
buildBackfillInsertStatements,
renderBackfillReport,
synthesizeBackfillRows,
type ReviewTargetDecisionRow,
} from "./backfill-calibration-corpus-core.js";

type Args = { db: string; remote: boolean; apply: boolean };
type Args = { db: string; remote: boolean; apply: boolean; pgPresent: boolean; pgValue: string | undefined };

function parseArgs(argv: string[]): Args {
const args: Args = { db: "loopover", remote: false, apply: false };
const args: Args = { db: "loopover", remote: false, apply: false, pgPresent: false, pgValue: undefined };
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
if (flag === "--remote") args.remote = true;
else if (flag === "--apply") args.apply = true;
else if (flag === "--db") args.db = argv[++i]!;
else if (flag === "--pg") {
args.pgPresent = true;
if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith("--")) args.pgValue = argv[++i];
}
}
return args;
}
Expand All @@ -46,12 +53,31 @@ function d1Execute(db: string, remote: boolean, sql: string): Array<Record<strin
return first?.results ?? [];
}

function main() {
function toFiniteNumber(value: unknown): number | null {
if (typeof value === "number") return Number.isFinite(value) ? value : null;
if (typeof value === "string" && value.trim() !== "") {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
return null;
}

async function main() {
const args = parseArgs(process.argv.slice(2));
const pgConnection = resolvePgConnection(args.pgPresent, args.pgValue, process.env.DATABASE_URL);
const pgSession: PgCliSession | null = pgConnection ? openPgDatabase(pgConnection) : null;
try {
await run(args, pgSession);
} finally {
await pgSession?.close();
}
}

async function run(args: Args, pgSession: PgCliSession | null): Promise<void> {
const execute = async (sql: string): Promise<Array<Record<string, unknown>>> =>
pgSession ? ((await pgSession.db.prepare(sql).all<Record<string, unknown>>()).results ?? []) : d1Execute(args.db, args.remote, sql);

const rows = d1Execute(
args.db,
args.remote,
const rows = await execute(
`SELECT repo, number, verdict, status, json_extract(decision_json, '$.confidence') AS confidence, terminal_at
FROM review_targets WHERE kind = 'pull_request'`,
);
Expand All @@ -60,7 +86,9 @@ function main() {
number: typeof row.number === "number" ? row.number : Number(row.number ?? 0),
verdict: typeof row.verdict === "string" ? row.verdict : null,
status: typeof row.status === "string" ? row.status : null,
confidence: typeof row.confidence === "number" ? row.confidence : null,
// D1's json_extract returns a JSON number as a number; Postgres's translated `->>` ALWAYS returns
// text (the same #4997 semantics the dialect suite pins) — coerce, don't type-gate (#8171).
confidence: toFiniteNumber(row.confidence),
terminalAt: typeof row.terminal_at === "string" ? row.terminal_at : null,
}));

Expand All @@ -74,11 +102,14 @@ function main() {
const statements = buildBackfillInsertStatements(report.rows);
let written = 0;
for (const statement of statements) {
d1Execute(args.db, args.remote, statement);
await execute(statement);
written += 1;
console.error(`applied statement ${written}/${statements.length}`);
}
console.error(`backfill applied: ${report.rows.length} row(s) across ${statements.length} statement(s) (re-runs are no-ops).`);
}

main();
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
35 changes: 29 additions & 6 deletions scripts/backtest-corpus-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// backtest-corpus-export-core.ts (unit-tested); this file is the thin IO wrapper — mirrors export-d1-data.ts.
//
// tsx scripts/backtest-corpus-export.ts --rule-id <ruleId> --output <file.json> [--remote] [--since-date <iso>] [--db loopover]
// … --pg postgres://… reads a self-host Postgres instead (#8171; bare --pg uses DATABASE_URL)
//
// --remote reads the deployed D1 (default is the local miniflare DB). --since-date does an INCREMENTAL export
// (rows whose created_at is >= the date); omit it for a full history. NEVER pass a write command. ORB only —
Expand All @@ -14,6 +15,7 @@ import { writeFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { buildBacktestCorpus, type BacktestCase, type HumanOverrideEvent, type RuleFiredEvent } from "@loopover/engine";
import { buildBacktestCorpusManifest } from "./backtest-corpus-export-core.js";
import { openPgDatabase, pgDatabaseLabel, resolvePgConnection } from "./pg-cli.js";

type D1Row = Record<string, unknown>;

Expand All @@ -23,20 +25,26 @@ type Args = {
remote: boolean;
sinceDate: string | undefined;
db: string;
pgPresent: boolean;
pgValue: string | undefined;
};

const RULE_FIRED_EVENT_TYPE_PREFIX = "signal.rule_fired:";
const HUMAN_OVERRIDE_EVENT_TYPE_PREFIX = "signal.human_override:";

function parseArgs(argv: string[]): Args {
const args: Args = { ruleId: undefined, output: undefined, remote: false, sinceDate: undefined, db: "loopover" };
const args: Args = { ruleId: undefined, output: undefined, remote: false, sinceDate: undefined, db: "loopover", pgPresent: false, pgValue: undefined };
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
if (flag === "--remote") args.remote = true;
else if (flag === "--rule-id") args.ruleId = argv[++i];
else if (flag === "--output") args.output = argv[++i];
else if (flag === "--since-date") args.sinceDate = argv[++i];
else if (flag === "--db") args.db = argv[++i]!;
else if (flag === "--pg") {
args.pgPresent = true;
if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith("--")) args.pgValue = argv[++i];
}
}
return args;
}
Expand Down Expand Up @@ -113,8 +121,9 @@ function rowEventType(row: D1Row): string {
return typeof row.event_type === "string" ? row.event_type : "";
}

function main() {
async function main() {
const args = parseArgs(process.argv.slice(2));
const pgConnection = resolvePgConnection(args.pgPresent, args.pgValue, process.env.DATABASE_URL);
if (!args.ruleId || !args.output) {
console.error(
"Usage: tsx scripts/backtest-corpus-export.ts --rule-id <ruleId> --output <file.json> [--remote] [--since-date <iso>] [--db loopover]",
Expand All @@ -131,7 +140,7 @@ function main() {
sinceClause +
` ORDER BY created_at ASC`;

const rows = d1Query(args.db, args.remote, sql);
const rows = pgConnection ? await pgQuery(pgConnection, sql) : d1Query(args.db, args.remote, sql);
const fired: RuleFiredEvent[] = [];
const overrides: HumanOverrideEvent[] = [];
for (const row of rows) {
Expand All @@ -148,13 +157,27 @@ function main() {
const cases: BacktestCase[] = buildBacktestCorpus(args.ruleId, fired, overrides);
const manifest = buildBacktestCorpusManifest(args.ruleId, cases, {
generatedAt: new Date().toISOString(),
source: args.remote ? "d1-remote" : "d1-local",
database: args.db,
// pgDatabaseLabel names the database WITHOUT the connection string's credentials.
source: pgConnection ? "postgres" : args.remote ? "d1-remote" : "d1-local",
database: pgConnection ? pgDatabaseLabel(pgConnection) : args.db,
incremental: Boolean(args.sinceDate),
...(args.sinceDate ? { sinceDate: args.sinceDate } : {}),
});
writeFileSync(args.output, `${JSON.stringify(manifest, null, 2)}\n`);
console.error(`exported ${manifest.caseCount} cases for rule ${args.ruleId} (checksum ${manifest.checksum.slice(0, 12)}…) → ${args.output}`);
}

main();
// The pg sibling of d1Query: same fail-loud contract, same shim the Worker runs on (#8171).
async function pgQuery(connection: string, sql: string): Promise<D1Row[]> {
const session = openPgDatabase(connection);
try {
return (await session.db.prepare(sql).all<D1Row>()).results ?? [];
} finally {
await session.close();
}
}

main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
39 changes: 28 additions & 11 deletions scripts/backtest-track-record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
// (pure, unit-tested); this file is the thin IO wrapper — mirrors backtest-corpus-export.ts's identical split.
//
// tsx scripts/backtest-track-record.ts --db loopover [--remote]
// tsx scripts/backtest-track-record.ts --pg postgres://… (self-host Postgres — #8171; bare --pg uses DATABASE_URL)
//
// --remote reads the deployed D1 (default is the local miniflare DB). NEVER pass a write command.
import { spawnSync } from "node:child_process";
import { openPgDatabase, resolvePgConnection } from "./pg-cli.js";
import { computeRegressedVerdictTrackRecord, type BacktestComparison } from "@loopover/engine";
import { LOGIC_BACKTEST_EVENT_TYPE } from "./backtest-logic-check-core.js";

Expand All @@ -20,14 +22,18 @@ import { LOGIC_BACKTEST_EVENT_TYPE } from "./backtest-logic-check-core.js";
// module with no Worker-bound imports, so THAT one is imported for real rather than hand-mirrored.
const THRESHOLD_BACKTEST_EVENT_TYPE = "calibration.threshold_backtest_run";

type Args = { db: string | undefined; remote: boolean };
type Args = { db: string | undefined; remote: boolean; pgPresent: boolean; pgValue: string | undefined };

function parseArgs(argv: string[]): Args {
const args: Args = { db: undefined, remote: false };
const args: Args = { db: undefined, remote: false, pgPresent: false, pgValue: undefined };
for (let i = 0; i < argv.length; i += 1) {
const flag = argv[i];
if (flag === "--remote") args.remote = true;
else if (flag === "--db") args.db = argv[++i];
else if (flag === "--pg") {
args.pgPresent = true;
if (argv[i + 1] !== undefined && !argv[i + 1]!.startsWith("--")) args.pgValue = argv[++i];
}
}
return args;
}
Expand All @@ -46,17 +52,15 @@ function d1Query(db: string, remote: boolean, sql: string): Array<Record<string,
return first?.results ?? [];
}

function main() {
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.db) {
console.error("Usage: tsx scripts/backtest-track-record.ts --db <database> [--remote]");
const pgConnection = resolvePgConnection(args.pgPresent, args.pgValue, process.env.DATABASE_URL);
if (!pgConnection && !args.db) {
console.error("Usage: tsx scripts/backtest-track-record.ts --db <database> [--remote] | --pg <postgres://…>");
process.exit(2);
}
const rows = d1Query(
args.db,
args.remote,
`SELECT metadata_json FROM audit_events WHERE event_type IN ('${THRESHOLD_BACKTEST_EVENT_TYPE}', '${LOGIC_BACKTEST_EVENT_TYPE}') ORDER BY created_at ASC`,
);
const sql = `SELECT metadata_json FROM audit_events WHERE event_type IN ('${THRESHOLD_BACKTEST_EVENT_TYPE}', '${LOGIC_BACKTEST_EVENT_TYPE}') ORDER BY created_at ASC`;
const rows = pgConnection ? await pgQuery(pgConnection, sql) : d1Query(args.db!, args.remote, sql);
const comparisons: BacktestComparison[] = [];
for (const row of rows) {
try {
Expand All @@ -75,4 +79,17 @@ function main() {
}
}

main();
// The pg sibling of d1Query: same fail-loud contract, same shim the Worker runs on (#8171).
async function pgQuery(connection: string, sql: string): Promise<Array<Record<string, unknown>>> {
const session = openPgDatabase(connection);
try {
return (await session.db.prepare(sql).all<Record<string, unknown>>()).results ?? [];
} finally {
await session.close();
}
}

main().catch((error: unknown) => {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
});
50 changes: 50 additions & 0 deletions scripts/pg-cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// Shared pg-driver plumbing for the calibration CLIs (#8171). Self-host operators' ledgers live in
// Postgres behind the same shim the Worker uses, so the CLIs reuse src/selfhost/pg-adapter.ts's
// createPgAdapter — the SAME dialect translation the deployed code runs, never a second SQL
// implementation — and each script's pure core stays untouched. Driver selection mirrors how the
// selfhost stack itself picks its DB (src/selfhost/preflight.ts reads DATABASE_URL): an explicit
// `--pg <conn>` wins, a bare `--pg` falls back to DATABASE_URL, and without `--pg` the D1/wrangler
// path stays the default — DATABASE_URL alone never silently switches drivers.
import pg from "pg";
import { createPgAdapter } from "../src/selfhost/pg-adapter.js";

/** PURE driver selection: the pg connection string to use, or null for the default D1/wrangler path.
* Throws (fail-loud, like every IO error in these CLIs) when `--pg` was given but no connection string
* can be resolved — a silent fall-through to D1 could read the WRONG deployment's ledger. */
export function resolvePgConnection(pgFlagPresent: boolean, pgFlagValue: string | undefined, databaseUrl: string | undefined): string | null {
if (!pgFlagPresent) return null;
const explicit = pgFlagValue?.trim();
if (explicit) return explicit;
const fromEnv = databaseUrl?.trim();
if (fromEnv) return fromEnv;
throw new Error("--pg was given without a connection string and DATABASE_URL is not set");
}

/** PURE label for reports/manifests: names the database WITHOUT the credentials the connection string
* carries (nothing secret-shaped may reach a written artifact). */
export function pgDatabaseLabel(connection: string): string {
try {
const database = new URL(connection).pathname.replace(/^\//, "");
return database ? `postgres:${database}` : "postgres";
} catch {
return "postgres";
}
}

export type PgCliSession = {
/** The selfhost adapter: SQLite-dialect SQL in, translated Postgres out — identical to the Worker path. */
db: D1Database;
close(): Promise<void>;
};

/** Open a pooled connection wrapped in the selfhost adapter. Callers own close() (try/finally). */
export function openPgDatabase(connection: string): PgCliSession {
const pool = new pg.Pool({ connectionString: connection });
pg.types.setTypeParser(20, (value: string) => Number.parseInt(value, 10)); // int8 (COUNT/SUM) → number, like D1
return {
db: createPgAdapter(pool),
close: async () => {
await pool.end();
},
};
}
12 changes: 11 additions & 1 deletion src/selfhost/pg-dialect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,17 @@ export function translateFunctions(sql: string): string {
.replace(/datetime\(\s*'now'\s*\)/gi, `to_char(now(), 'YYYY-MM-DD HH24:MI:SS')`)
// CURRENT_TIMESTAMP → SQLite's TEXT format (the columns are TEXT)
.replace(/CURRENT_TIMESTAMP/gi, `to_char(now(), 'YYYY-MM-DD HH24:MI:SS')`)
// json_extract(col, '$.key') → (col::jsonb ->> 'key') (single-level paths — all the codebase uses)
// date(<expr>) → TEXT 'YYYY-MM-DD' like SQLite's date(). Postgres would accept date(<text>) via an
// implicit cast, but it returns a `date`-typed value that node-pg parses into a JS Date object — so
// every day-bucketed trend read (rule-calibration-trend.ts, public-accuracy-trend.ts, stats.ts, ...)
// silently bucketed NOTHING on self-host: the JS-side week matching expects the TEXT day D1 returns
// (#8171). `datetime(` cannot match (the regex requires "(" immediately after "date").
.replace(/(?<![\w$])date\(\s*([A-Za-z0-9_.]+|\?)\s*\)/gi, `to_char(($1)::timestamptz, 'YYYY-MM-DD')`)
// json_extract(col, '$.a.b…') (nested paths) → (col::jsonb #>> '{a,b…}') — the persisted backtest runs
// read `$.comparison.verdict`, which the single-level rule below can't see; untranslated it is a hard
// "function json_extract does not exist" error swallowed by the fail-safe trend reads (#8171).
.replace(/json_extract\(\s*([^,]+?)\s*,\s*'\$\.((?:[A-Za-z0-9_]+\.)+[A-Za-z0-9_]+)'\s*\)/gi, (_m, col, path) => `((${col})::jsonb #>> '{${path.split(".").join(",")}}')`)
// json_extract(col, '$.key') → (col::jsonb ->> 'key') (single-level paths)
.replace(/json_extract\(\s*([^,]+?)\s*,\s*'\$\.([A-Za-z0-9_]+)'\s*\)/gi, `(($1)::jsonb ->> '$2')`)
// instr(haystack, needle) → strpos(haystack, needle): both are 1-based first-occurrence index, 0 if
// absent -- a direct semantic match, no formula adjustment needed. Postgres has no `instr` builtin at
Expand Down
Loading
Loading