From 90b9feb7ae8406fdce000f93f9a5792a9ee80341 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 19 Jul 2026 18:59:28 -0700 Subject: [PATCH] fix: prevent false PostgreSQL corruption alerts (#2352) ## Summary PostgreSQL runtime roles without `CREATE` permission on `public` no longer trigger schema writes during migration-marker health reads, so `permission denied for schema public` is not mislabeled as database corruption. Once connectivity and task-ID integrity pass, an unavailable migration marker is treated as advisory instead of making the whole database unhealthy. Dashboard and notification guidance now describes a PostgreSQL health failure accurately and renders actionable log and recovery links in every supported locale. ## Validation - 54 targeted tests passed across core, dashboard, engine, and i18n. - Typechecks passed for all four affected packages. - Scoped ESLint, strict changeset validation, and diff checks passed. --- [![Compound Engineering](https://img.shields.io/badge/Built_with-Compound_Engineering-6366f1)](https://github.com/EveryInc/compound-engineering-plugin) ## Summary by CodeRabbit * **Bug Fixes** * PostgreSQL health failures are now reported as degraded health checks rather than database corruption. * Migration-status lookup failures no longer incorrectly mark an otherwise healthy database as unhealthy. * Migration-state checks are now read-only and avoid creating or modifying database structures. * **UI & Localization** * Updated database health banner messaging and recovery guidance across supported languages. * The banner now appears for broader PostgreSQL health failures and links to storage documentation. --- .../postgres-health-false-corruption.md | 7 ++ .../postgres/sqlite-migration-state.test.ts | 87 +++++++++++++++++++ packages/core/src/postgres/sqlite-migrator.ts | 15 ++-- .../app/components/DbCorruptionBanner.tsx | 14 ++- .../__tests__/DbCorruptionBanner.test.tsx | 7 +- .../components/dashboard/DashboardBanners.tsx | 2 +- .../__tests__/DashboardBanners.test.tsx | 29 ++++++- .../dashboard-postgres-health.test.ts | 21 ++++- .../src/dashboard-postgres-health.ts | 21 +++-- .../self-healing-db-corruption.test.ts | 23 +++++ packages/i18n/locales/en/app.json | 6 +- packages/i18n/locales/es/app.json | 6 +- packages/i18n/locales/fr/app.json | 6 +- packages/i18n/locales/ko/app.json | 6 +- packages/i18n/locales/zh-CN/app.json | 6 +- packages/i18n/locales/zh-TW/app.json | 6 +- .../src/__tests__/db-banner-catalog.test.ts | 20 +++++ packages/i18n/src/resources.d.ts | 6 +- 18 files changed, 243 insertions(+), 45 deletions(-) create mode 100644 .changeset/postgres-health-false-corruption.md create mode 100644 packages/core/src/__tests__/postgres/sqlite-migration-state.test.ts create mode 100644 packages/i18n/src/__tests__/db-banner-catalog.test.ts diff --git a/.changeset/postgres-health-false-corruption.md b/.changeset/postgres-health-false-corruption.md new file mode 100644 index 0000000000..fd586520bc --- /dev/null +++ b/.changeset/postgres-health-false-corruption.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Report PostgreSQL health failures accurately without false database-corruption guidance. +category: fix +dev: Makes migration-marker reads non-mutating and treats marker permission failures as advisory. diff --git a/packages/core/src/__tests__/postgres/sqlite-migration-state.test.ts b/packages/core/src/__tests__/postgres/sqlite-migration-state.test.ts new file mode 100644 index 0000000000..4747668369 --- /dev/null +++ b/packages/core/src/__tests__/postgres/sqlite-migration-state.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + getSqliteMigrationState, + isSqliteMigrationComplete, +} from "../../postgres/sqlite-migrator.js"; + +function queryText(query: unknown): string { + return (query as { queryChunks?: Array<{ value: string[] }> }).queryChunks + ?.flatMap((chunk) => chunk.value).join("") ?? ""; +} + +describe("SQLite migration state reads", () => { + function recordingDb(results: unknown[][]) { + const statements: string[] = []; + const execute = vi.fn(async (query: unknown) => { + statements.push(queryText(query)); + return results.shift() ?? []; + }); + return { db: { execute }, statements }; + } + + function expectReadOnly(statements: string[]) { + expect(statements).not.toEqual([]); + for (const statement of statements) { + expect(statement).not.toMatch(/\b(?:CREATE|INSERT|UPDATE|DELETE|ALTER|DROP)\b/i); + } + } + + it("treats a missing marker table as no state without issuing DDL", async () => { + const { db, statements } = recordingDb([[{ exists: false }]]); + + await expect(getSqliteMigrationState(db as never, "project:demo")).resolves.toBeNull(); + expect(statements).toHaveLength(1); + expect(statements[0]).toContain("to_regclass"); + expectReadOnly(statements); + }); + + it("returns no state when the marker table exists without the requested key", async () => { + const { db, statements } = recordingDb([[{ exists: true }], []]); + + await expect(getSqliteMigrationState(db as never, "project:demo")).resolves.toBeNull(); + expect(statements).toHaveLength(2); + expectReadOnly(statements); + }); + + it("maps an existing marker without issuing writes", async () => { + const marker = { + migration_key: "project:demo", + project_id: "demo", + status: "failed" as const, + last_error: "copy failed", + updated_at: "2026-07-19T00:00:00.000Z", + }; + const { db, statements } = recordingDb([[{ exists: true }], [marker]]); + + await expect(getSqliteMigrationState(db as never, "project:demo")).resolves.toEqual({ + migrationKey: marker.migration_key, + projectId: marker.project_id, + status: marker.status, + lastError: marker.last_error, + updatedAt: marker.updated_at, + }); + expectReadOnly(statements); + }); + + it.each([ + [undefined, false], + ["running", false], + ["failed", false], + ["complete", true], + ] as const)("reports %s markers as complete=%s without issuing DDL", async (status, expected) => { + const results = status === undefined + ? [[{ exists: false }]] + : [[{ exists: true }], [{ + migration_key: "project:demo", + project_id: "demo", + status, + last_error: null, + updated_at: "2026-07-19T00:00:00.000Z", + }]]; + const { db, statements } = recordingDb(results); + + await expect(isSqliteMigrationComplete(db as never, "project:demo")).resolves.toBe(expected); + expectReadOnly(statements); + }); +}); diff --git a/packages/core/src/postgres/sqlite-migrator.ts b/packages/core/src/postgres/sqlite-migrator.ts index 571c94efa5..23b494bba4 100644 --- a/packages/core/src/postgres/sqlite-migrator.ts +++ b/packages/core/src/postgres/sqlite-migrator.ts @@ -472,12 +472,18 @@ export interface SqliteMigrationState { * Dashboard health reads the authoritative per-project cutover marker after * listen. A running or failed marker is never aged out here: progress ticks do * not update updated_at, and post-listen running means completion was missed. + * Reads must not create the marker table: dashboard and startup connections can + * use a runtime role that intentionally lacks CREATE permission in public. */ export async function getSqliteMigrationState( db: PostgresJsDatabase>, migrationKey: string, ): Promise { - await ensureMigrationStateTable(db); + const tableRows = (await db.execute(sql` + SELECT to_regclass('public.${sql.raw(SQLITE_MIGRATION_STATE_TABLE)}') IS NOT NULL AS exists + `)) as unknown as Array<{ exists: boolean }>; + if (!tableRows[0]?.exists) return null; + const rows = (await db.execute(sql` SELECT migration_key, project_id, status, last_error, updated_at FROM public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} @@ -507,12 +513,7 @@ export async function isSqliteMigrationComplete( db: PostgresJsDatabase>, migrationKey: string, ): Promise { - await ensureMigrationStateTable(db); - const rows = (await db.execute(sql` - SELECT status FROM public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)} - WHERE migration_key = ${migrationKey} - `)) as unknown as Array<{ status: string }>; - return rows[0]?.status === "complete"; + return (await getSqliteMigrationState(db, migrationKey))?.status === "complete"; } /** Mark caller-side stamping and verification complete for a durable cutover. */ diff --git a/packages/dashboard/app/components/DbCorruptionBanner.tsx b/packages/dashboard/app/components/DbCorruptionBanner.tsx index ffd2ac44ca..6ec62d38d1 100644 --- a/packages/dashboard/app/components/DbCorruptionBanner.tsx +++ b/packages/dashboard/app/components/DbCorruptionBanner.tsx @@ -11,6 +11,12 @@ interface DbCorruptionBannerProps { refreshError: string | null; } +/* +FNXC:PostgresHealth 2026-07-19-17:55: +The shared degraded-health shape includes connectivity, permissions, and query +failures, so the banner must not claim corruption or prescribe SQLite recovery. +Keep operator guidance focused on PostgreSQL diagnostics and the reported error. +*/ export function DbCorruptionBanner({ errors, lastCheckedAt, @@ -33,7 +39,7 @@ export function DbCorruptionBanner({