diff --git a/src/selfhost/pg-dialect.ts b/src/selfhost/pg-dialect.ts index bd7b9ecbf4..07b0686f34 100644 --- a/src/selfhost/pg-dialect.ts +++ b/src/selfhost/pg-dialect.ts @@ -12,6 +12,9 @@ const REPLACE_CONFLICT_KEYS: Record = { tunables_overrides_shadow: ["project"], orb_export_cursor: ["instance_hash"], orb_signals: ["instance_id", "repo_hash", "pr_hash"], + // #8893: orb_reuse_counters (migrations/0177) is written with INSERT OR REPLACE by src/orb/ingest.ts; its + // PRIMARY KEY (instance_id, day) is the conflict target the hourly ORB export needs on self-host Postgres. + orb_reuse_counters: ["instance_id", "day"], // 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 diff --git a/test/unit/selfhost-pg-dialect.test.ts b/test/unit/selfhost-pg-dialect.test.ts index 02f542750b..cc1a29275d 100644 --- a/test/unit/selfhost-pg-dialect.test.ts +++ b/test/unit/selfhost-pg-dialect.test.ts @@ -138,6 +138,25 @@ describe("pg-dialect (#977 SQLite → Postgres)", () => { expect(translated).not.toContain("pr_hash=excluded.pr_hash"); }); + // #8893: src/orb/ingest.ts's hourly ORB export issues this INSERT OR REPLACE; without a + // REPLACE_CONFLICT_KEYS entry, translateInsertOr threw "no known conflict key" on the first + // orb_reuse_counters write on every self-host Postgres deployment. The statement below is verbatim. + it("REGRESSION (#8893): translates the real orb_reuse_counters ingest INSERT OR REPLACE without throwing", () => { + const translated = translateInsertOr( + `INSERT OR REPLACE INTO orb_reuse_counters (instance_id, day, hits, misses, received_at) + VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)`, + ); + expect(translated).toContain("INSERT INTO orb_reuse_counters"); + // PRIMARY KEY (instance_id, day) — migration 0177 — is the conflict target. + expect(translated).toContain("ON CONFLICT (instance_id, day) DO UPDATE SET"); + // Non-key columns are upserted; the key columns are excluded from the SET list. + expect(translated).toContain("hits=excluded.hits"); + expect(translated).toContain("misses=excluded.misses"); + expect(translated).toContain("received_at=excluded.received_at"); + expect(translated).not.toContain("instance_id=excluded.instance_id"); + expect(translated).not.toContain("day=excluded.day"); + }); + 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'");