diff --git a/src/selfhost/pg-dialect.ts b/src/selfhost/pg-dialect.ts index 55737d2ce8..bd7b9ecbf4 100644 --- a/src/selfhost/pg-dialect.ts +++ b/src/selfhost/pg-dialect.ts @@ -12,6 +12,16 @@ const REPLACE_CONFLICT_KEYS: Record = { tunables_overrides_shadow: ["project"], orb_export_cursor: ["instance_hash"], orb_signals: ["instance_id", "repo_hash", "pr_hash"], + // ams_signals (#8382): TWO columns here, deliberately — this must name the table's REAL unique constraint + // (`UNIQUE (instance_id, pr_hash)`, migrations/0148_ams_signals.sql), or Postgres rejects the generated + // `ON CONFLICT` with "no unique or exclusion constraint matching". The 3-column shape orb_signals needed + // (migrations/0060) buys nothing here: both tables are fed by the same exporter + // (packages/loopover-miner/lib/orb-export.ts), which derives `prHash` from + // `hmac(`${repoFullName}:${prNumber}`)` — the repo is INSIDE the pr_hash input, so within one instance a + // pr_hash already identifies (repo, PR) and repo_hash is functionally determined by it. Two repos can + // therefore never collide on (instance_id, pr_hash), and widening the key would need a table recreate for + // zero uniqueness gain. Revisit only if pr_hash ever stops being repo-scoped at the producer. + ams_signals: ["instance_id", "pr_hash"], }; /** Replace `?` placeholders with `$1,$2,…`, skipping any `?` inside single-quoted string literals. A `?` diff --git a/test/unit/selfhost-pg-dialect.test.ts b/test/unit/selfhost-pg-dialect.test.ts index dac29ddd0c..02f542750b 100644 --- a/test/unit/selfhost-pg-dialect.test.ts +++ b/test/unit/selfhost-pg-dialect.test.ts @@ -115,6 +115,29 @@ describe("pg-dialect (#977 SQLite → Postgres)", () => { expect(translateInsertOr("SELECT 1")).toBe("SELECT 1"); // passthrough }); + // #8382: src/ams/ingest.ts's live INSERT OR REPLACE threw "no known conflict key" on every self-host + // Postgres deployment, failing the first AMS telemetry-ingest write outright. The statement below is the + // one that module actually issues, verbatim. + it("REGRESSION (#8382): translates the real ams_signals ingest INSERT OR REPLACE without throwing", () => { + const translated = translateInsertOr( + `INSERT OR REPLACE INTO ams_signals + (instance_id, repo_hash, pr_hash, decision, reason_bucket, closed_at, received_at) + VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)`, + ); + expect(translated).toContain("INSERT INTO ams_signals"); + // The conflict target must name the table's REAL constraint (UNIQUE (instance_id, pr_hash), migration + // 0148) — a 3-column target would make Postgres reject the statement outright. + expect(translated).toContain("ON CONFLICT (instance_id, pr_hash) DO UPDATE SET"); + // Key columns are excluded from the SET list; every non-key column is upserted. + expect(translated).toContain("repo_hash=excluded.repo_hash"); + expect(translated).toContain("decision=excluded.decision"); + expect(translated).toContain("reason_bucket=excluded.reason_bucket"); + expect(translated).toContain("closed_at=excluded.closed_at"); + expect(translated).toContain("received_at=excluded.received_at"); + expect(translated).not.toContain("instance_id=excluded.instance_id"); + expect(translated).not.toContain("pr_hash=excluded.pr_hash"); + }); + it("translateSql composes all passes; translateDdl handles the ISO-now default", () => { expect(translateSql("SELECT * FROM t WHERE updated_at > datetime('now', ?)")).toMatch(/\$1/); expect(translateDdl("created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))")).toContain("to_char(now() AT TIME ZONE 'UTC'");