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. --- [](https://github.com/EveryInc/compound-engineering-plugin) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## 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. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/postgres-health-false-corruption.md
Normal file
7
.changeset/postgres-health-false-corruption.md
Normal file
@@ -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.
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -472,12 +472,18 @@ export interface SqliteMigrationState {
|
|||||||
* Dashboard health reads the authoritative per-project cutover marker after
|
* Dashboard health reads the authoritative per-project cutover marker after
|
||||||
* listen. A running or failed marker is never aged out here: progress ticks do
|
* 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.
|
* 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(
|
export async function getSqliteMigrationState(
|
||||||
db: PostgresJsDatabase<Record<string, never>>,
|
db: PostgresJsDatabase<Record<string, never>>,
|
||||||
migrationKey: string,
|
migrationKey: string,
|
||||||
): Promise<SqliteMigrationState | null> {
|
): Promise<SqliteMigrationState | null> {
|
||||||
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`
|
const rows = (await db.execute(sql`
|
||||||
SELECT migration_key, project_id, status, last_error, updated_at
|
SELECT migration_key, project_id, status, last_error, updated_at
|
||||||
FROM public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)}
|
FROM public.${sql.identifier(SQLITE_MIGRATION_STATE_TABLE)}
|
||||||
@@ -507,12 +513,7 @@ export async function isSqliteMigrationComplete(
|
|||||||
db: PostgresJsDatabase<Record<string, never>>,
|
db: PostgresJsDatabase<Record<string, never>>,
|
||||||
migrationKey: string,
|
migrationKey: string,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
await ensureMigrationStateTable(db);
|
return (await getSqliteMigrationState(db, migrationKey))?.status === "complete";
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Mark caller-side stamping and verification complete for a durable cutover. */
|
/** Mark caller-side stamping and verification complete for a durable cutover. */
|
||||||
|
|||||||
@@ -11,6 +11,12 @@ interface DbCorruptionBannerProps {
|
|||||||
refreshError: string | null;
|
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({
|
export function DbCorruptionBanner({
|
||||||
errors,
|
errors,
|
||||||
lastCheckedAt,
|
lastCheckedAt,
|
||||||
@@ -33,7 +39,7 @@ export function DbCorruptionBanner({
|
|||||||
<span className="status-dot status-dot--error" aria-hidden="true" />
|
<span className="status-dot status-dot--error" aria-hidden="true" />
|
||||||
<AlertTriangle aria-hidden="true" />
|
<AlertTriangle aria-hidden="true" />
|
||||||
<div className="db-corruption-banner__headline-copy">
|
<div className="db-corruption-banner__headline-copy">
|
||||||
<h2 className="db-corruption-banner__headline">{t("dbBanner.title", "Database corruption detected")}</h2>
|
<h2 className="db-corruption-banner__headline">{t("dbBanner.title", "Database health check failed")}</h2>
|
||||||
{checkedAtLabel ? (
|
{checkedAtLabel ? (
|
||||||
<p className="db-corruption-banner__meta">{t("dbBanner.lastChecked", "Last checked: {{checkedAtLabel}}", { checkedAtLabel })}</p>
|
<p className="db-corruption-banner__meta">{t("dbBanner.lastChecked", "Last checked: {{checkedAtLabel}}", { checkedAtLabel })}</p>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -53,7 +59,7 @@ export function DbCorruptionBanner({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="db-corruption-banner__body">
|
<p className="db-corruption-banner__body">
|
||||||
{t("dbBanner.body", "Fusion's database health check reported the backend is unreachable or corrupt. Review the failing objects below before continuing critical operations.")}
|
{t("dbBanner.body", "Fusion's PostgreSQL health check reported a failure. Review the details below before continuing critical operations.")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="db-corruption-banner__list">
|
<ul className="db-corruption-banner__list">
|
||||||
@@ -68,10 +74,10 @@ export function DbCorruptionBanner({
|
|||||||
<strong className="db-corruption-banner__footer-label">{t("dbBanner.whatToDo", "What to do:")}</strong>{" "}
|
<strong className="db-corruption-banner__footer-label">{t("dbBanner.whatToDo", "What to do:")}</strong>{" "}
|
||||||
<Trans
|
<Trans
|
||||||
i18nKey="app:dbBanner.instructions"
|
i18nKey="app:dbBanner.instructions"
|
||||||
defaults="Back up the project, run <cmd>fn db vacuum</cmd> to compact and verify the database, and restore from a known-good backup if corruption persists. See <docsLink>docs/storage.md</docsLink> for the storage layout and recovery guidance."
|
defaults="Back up the project, check <cmd>database logs</cmd> and PostgreSQL connectivity and permissions, then refresh health. See <docsLink>docs/storage.md</docsLink> for storage and recovery guidance."
|
||||||
components={{
|
components={{
|
||||||
cmd: <code />,
|
cmd: <code />,
|
||||||
docsLink: <a href="docs/storage.md" />,
|
docsLink: <a href="/docs/storage.md" />,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -16,11 +16,14 @@ describe("DbCorruptionBanner", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByRole("alert")).toBeInTheDocument();
|
expect(screen.getByRole("alert")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Database corruption detected")).toBeInTheDocument();
|
expect(screen.getByText("Database health check failed")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Last checked:/)).toBeInTheDocument();
|
expect(screen.getByText(/Last checked:/)).toBeInTheDocument();
|
||||||
expect(screen.getByText("bad row")).toBeInTheDocument();
|
expect(screen.getByText("bad row")).toBeInTheDocument();
|
||||||
expect(screen.getByText("bad index")).toBeInTheDocument();
|
expect(screen.getByText("bad index")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("link", { name: "docs/storage.md" })).toHaveAttribute("href", "docs/storage.md");
|
expect(screen.getByText(/PostgreSQL health check reported a failure/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("database logs")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("link", { name: "docs/storage.md" })).toHaveAttribute("href", "/docs/storage.md");
|
||||||
|
expect(screen.queryByText(/\[object Object\]|\{\{link\}\}/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls onRefresh when the button is clicked", () => {
|
it("calls onRefresh when the button is clicked", () => {
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ export function DashboardBanners({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{viewMode === "project" && currentProject && dashboardHealth?.database?.corruptionDetected === true && (
|
{viewMode === "project" && currentProject && dashboardHealth?.database?.healthy === false && (
|
||||||
<DbCorruptionBanner
|
<DbCorruptionBanner
|
||||||
errors={dashboardHealth.database.corruptionErrors}
|
errors={dashboardHealth.database.corruptionErrors}
|
||||||
lastCheckedAt={dashboardHealth.database.lastCheckedAt}
|
lastCheckedAt={dashboardHealth.database.lastCheckedAt}
|
||||||
|
|||||||
@@ -30,7 +30,9 @@ vi.mock("../../PostOnboardingRecommendations", () => ({ PostOnboardingRecommenda
|
|||||||
vi.mock("../../UpdateAvailableBanner", () => ({ UpdateAvailableBanner: () => null }));
|
vi.mock("../../UpdateAvailableBanner", () => ({ UpdateAvailableBanner: () => null }));
|
||||||
vi.mock("../../MergeAdvanceNotice", () => ({ default: () => null }));
|
vi.mock("../../MergeAdvanceNotice", () => ({ default: () => null }));
|
||||||
vi.mock("../../TaskIdIntegrityBanner", () => ({ TaskIdIntegrityBanner: () => null }));
|
vi.mock("../../TaskIdIntegrityBanner", () => ({ TaskIdIntegrityBanner: () => null }));
|
||||||
vi.mock("../../DbCorruptionBanner", () => ({ DbCorruptionBanner: () => null }));
|
vi.mock("../../DbCorruptionBanner", () => ({
|
||||||
|
DbCorruptionBanner: () => <section data-testid="database-health-banner" />,
|
||||||
|
}));
|
||||||
vi.mock("../../SetupWarningBanner", () => ({
|
vi.mock("../../SetupWarningBanner", () => ({
|
||||||
SetupWarningBanner: ({
|
SetupWarningBanner: ({
|
||||||
hasAiProvider,
|
hasAiProvider,
|
||||||
@@ -76,6 +78,31 @@ describe("isMigrationStatusBannerActive", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DashboardBanners database health visibility", () => {
|
||||||
|
it("renders degraded PostgreSQL health without claiming corruption", () => {
|
||||||
|
render(
|
||||||
|
<DashboardBanners
|
||||||
|
{...buildProps({
|
||||||
|
sessionsNeedingInput: [],
|
||||||
|
dashboardHealth: {
|
||||||
|
status: "degraded",
|
||||||
|
engine: { available: true },
|
||||||
|
database: {
|
||||||
|
healthy: false,
|
||||||
|
corruptionDetected: false,
|
||||||
|
corruptionErrors: ["PostgreSQL health layer unavailable"],
|
||||||
|
lastCheckedAt: null,
|
||||||
|
},
|
||||||
|
taskIdIntegrity: { status: "error", anomalies: [] },
|
||||||
|
} as DashboardBannersProps["dashboardHealth"],
|
||||||
|
})}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByTestId("database-health-banner")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function buildSession(overrides: Partial<AiSessionSummary> = {}): AiSessionSummary {
|
function buildSession(overrides: Partial<AiSessionSummary> = {}): AiSessionSummary {
|
||||||
return {
|
return {
|
||||||
id: overrides.id ?? "session-1",
|
id: overrides.id ?? "session-1",
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ describe("evaluateDashboardPostgresHealth", () => {
|
|||||||
expect(healthMocks.checkPostgresHealth).not.toHaveBeenCalled();
|
expect(healthMocks.checkPostgresHealth).not.toHaveBeenCalled();
|
||||||
expect(result.database).toMatchObject({
|
expect(result.database).toMatchObject({
|
||||||
healthy: false,
|
healthy: false,
|
||||||
corruptionDetected: true,
|
corruptionDetected: false,
|
||||||
corruptionErrors: ["PostgreSQL health layer unavailable"],
|
corruptionErrors: ["PostgreSQL health layer unavailable"],
|
||||||
});
|
});
|
||||||
expect(result.taskIdIntegrity).toMatchObject({
|
expect(result.taskIdIntegrity).toMatchObject({
|
||||||
@@ -123,7 +123,7 @@ describe("evaluateDashboardPostgresHealth", () => {
|
|||||||
|
|
||||||
expect(result.database).toMatchObject({
|
expect(result.database).toMatchObject({
|
||||||
healthy: false,
|
healthy: false,
|
||||||
corruptionDetected: true,
|
corruptionDetected: false,
|
||||||
corruptionErrors: ["PostgreSQL task-ID integrity check failed: integrity query timed out"],
|
corruptionErrors: ["PostgreSQL task-ID integrity check failed: integrity query timed out"],
|
||||||
});
|
});
|
||||||
expect(result.taskIdIntegrity).toMatchObject({
|
expect(result.taskIdIntegrity).toMatchObject({
|
||||||
@@ -131,4 +131,21 @@ describe("evaluateDashboardPostgresHealth", () => {
|
|||||||
error: "PostgreSQL task-ID integrity check failed: integrity query timed out",
|
error: "PostgreSQL task-ID integrity check failed: integrity query timed out",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps advisory migration-status failures from degrading proven database health", async () => {
|
||||||
|
const store = { getAsyncLayer: () => layer, getRootDir: () => "/repo" } as TaskStore;
|
||||||
|
healthMocks.getSqliteMigrationState.mockRejectedValue(
|
||||||
|
new Error("Failed query: SELECT migration marker", { cause: new Error("permission denied") }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await evaluateDashboardPostgresHealth(store);
|
||||||
|
|
||||||
|
expect(result.database).toMatchObject({
|
||||||
|
healthy: true,
|
||||||
|
corruptionDetected: false,
|
||||||
|
corruptionErrors: [],
|
||||||
|
});
|
||||||
|
expect(result.taskIdIntegrity).toMatchObject({ status: "ok", anomalies: [] });
|
||||||
|
expect(result.migration).toBeUndefined();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -75,19 +75,26 @@ export async function evaluateDashboardPostgresHealth(
|
|||||||
]);
|
]);
|
||||||
if (errors.length > 0) return failedHealth(checkedAt, ...errors);
|
if (errors.length > 0) return failedHealth(checkedAt, ...errors);
|
||||||
|
|
||||||
|
let taskIdIntegrity: DashboardTaskIdIntegrityHealth;
|
||||||
|
try {
|
||||||
|
taskIdIntegrity = await detectTaskIdIntegrityAnomaliesAsync(layer.db);
|
||||||
|
} catch (error) {
|
||||||
|
return failedHealth(
|
||||||
|
checkedAt,
|
||||||
|
`PostgreSQL task-ID integrity check failed: ${errorMessage(error)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const taskIdIntegrity = await detectTaskIdIntegrityAnomaliesAsync(layer.db);
|
|
||||||
const migration = await resolveDashboardMigrationHealth(store, layer, context);
|
const migration = await resolveDashboardMigrationHealth(store, layer, context);
|
||||||
return {
|
return {
|
||||||
database: healthyDatabase(checkedAt),
|
database: healthyDatabase(checkedAt),
|
||||||
taskIdIntegrity,
|
taskIdIntegrity,
|
||||||
...(migration ? { migration } : {}),
|
...(migration ? { migration } : {}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch {
|
||||||
return failedHealth(
|
// Migration status is advisory after connectivity and task-ID queries pass.
|
||||||
checkedAt,
|
return { database: healthyDatabase(checkedAt), taskIdIntegrity };
|
||||||
`PostgreSQL task-ID integrity check failed: ${errorMessage(error)}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,7 +154,7 @@ function failedHealth(
|
|||||||
return {
|
return {
|
||||||
database: {
|
database: {
|
||||||
healthy: false,
|
healthy: false,
|
||||||
corruptionDetected: true,
|
corruptionDetected: false,
|
||||||
corruptionErrors: visibleErrors,
|
corruptionErrors: visibleErrors,
|
||||||
lastCheckedAt: checkedAt,
|
lastCheckedAt: checkedAt,
|
||||||
isRunning: false,
|
isRunning: false,
|
||||||
|
|||||||
@@ -183,6 +183,29 @@ describe("FN-5284: self-healing DB corruption surfacing", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses SQLite corruption wording for the fallback ntfy notification", async () => {
|
||||||
|
const store = createMockStore({
|
||||||
|
getDatabaseHealth: vi.fn().mockReturnValue({
|
||||||
|
healthy: false,
|
||||||
|
corruptionDetected: true,
|
||||||
|
corruptionErrors: ["connection refused"],
|
||||||
|
lastCheckedAt: new Date("2026-05-20T00:05:00.000Z"),
|
||||||
|
isRunning: false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const manager = new SelfHealingManager(store, { rootDir: "/tmp/test-project" });
|
||||||
|
stubMaintenance(manager);
|
||||||
|
|
||||||
|
await (manager as any).runMaintenance();
|
||||||
|
|
||||||
|
expect(notifierModule.sendNtfyNotification).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
title: "Database corruption detected",
|
||||||
|
message: "Background SQLite integrity check detected corruption. Errors: connection refused.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("respects the cooldown and avoids duplicate dispatches and audits", async () => {
|
it("respects the cooldown and avoids duplicate dispatches and audits", async () => {
|
||||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||||
vi.spyOn(notifierModule, "getActiveNotificationService").mockReturnValue({ dispatch } as unknown as NotificationService);
|
vi.spyOn(notifierModule, "getActiveNotificationService").mockReturnValue({ dispatch } as unknown as NotificationService);
|
||||||
|
|||||||
@@ -2006,12 +2006,12 @@
|
|||||||
"updatingVersion": "Updating to a new frontend version..."
|
"updatingVersion": "Updating to a new frontend version..."
|
||||||
},
|
},
|
||||||
"dbBanner": {
|
"dbBanner": {
|
||||||
"body": "Fusion's background SQLite integrity check reported corruption. Review the failing objects below before continuing critical operations.",
|
"body": "Fusion's PostgreSQL health check reported a failure. Review the details below before continuing critical operations.",
|
||||||
"instructions": "Back up the project, try {{cmd}} if the database still opens cleanly, and restore from a known-good backup if corruption persists. See {{link}} for the storage layout and recovery guidance.",
|
"instructions": "Back up the project, check <cmd>database logs</cmd> and PostgreSQL connectivity and permissions, then refresh health. See <docsLink>docs/storage.md</docsLink> for storage and recovery guidance.",
|
||||||
"lastChecked": "Last checked: {{checkedAtLabel}}",
|
"lastChecked": "Last checked: {{checkedAtLabel}}",
|
||||||
"refreshHealth": "Refresh health",
|
"refreshHealth": "Refresh health",
|
||||||
"refreshing": "Refreshing…",
|
"refreshing": "Refreshing…",
|
||||||
"title": "Database corruption detected",
|
"title": "Database health check failed",
|
||||||
"whatToDo": "What to do:"
|
"whatToDo": "What to do:"
|
||||||
},
|
},
|
||||||
"deepLink": {
|
"deepLink": {
|
||||||
|
|||||||
@@ -2009,12 +2009,12 @@
|
|||||||
"title": "Panel del proyecto"
|
"title": "Panel del proyecto"
|
||||||
},
|
},
|
||||||
"dbBanner": {
|
"dbBanner": {
|
||||||
"body": "La verificación de integridad de SQLite de Fusion en segundo plano informó de corrupción. Revise los objetos con error a continuación antes de continuar con operaciones críticas.",
|
"body": "La comprobación de estado de PostgreSQL de Fusion informó de un fallo. Revise los detalles antes de continuar con operaciones críticas.",
|
||||||
"instructions": "Haga copia de seguridad del proyecto, intente {{cmd}} si la base de datos aún se abre correctamente, y restaure desde una copia de seguridad conocida si la corrupción persiste. Consulte {{link}} para conocer la disposición del almacenamiento y la orientación de recuperación.",
|
"instructions": "Haga una copia de seguridad del proyecto, revise los <cmd>registros de la base de datos</cmd> y la conectividad y los permisos de PostgreSQL, y después actualice el estado. Consulte <docsLink>docs/storage.md</docsLink> para obtener orientación sobre almacenamiento y recuperación.",
|
||||||
"lastChecked": "Última comprobación: {{checkedAtLabel}}",
|
"lastChecked": "Última comprobación: {{checkedAtLabel}}",
|
||||||
"refreshHealth": "Actualizar estado",
|
"refreshHealth": "Actualizar estado",
|
||||||
"refreshing": "Actualizando…",
|
"refreshing": "Actualizando…",
|
||||||
"title": "Corrupción de base de datos detectada",
|
"title": "Falló la comprobación de estado de la base de datos",
|
||||||
"whatToDo": "Qué hacer:"
|
"whatToDo": "Qué hacer:"
|
||||||
},
|
},
|
||||||
"deepLink": {
|
"deepLink": {
|
||||||
|
|||||||
@@ -2009,12 +2009,12 @@
|
|||||||
"title": "Tableau de bord du projet"
|
"title": "Tableau de bord du projet"
|
||||||
},
|
},
|
||||||
"dbBanner": {
|
"dbBanner": {
|
||||||
"body": "La vérification d'intégrité SQLite en arrière-plan de Fusion a signalé une corruption. Veuillez examiner les objets défaillants ci-dessous avant de continuer les opérations critiques.",
|
"body": "La vérification de l'état de PostgreSQL par Fusion a signalé un échec. Examinez les détails avant de poursuivre les opérations critiques.",
|
||||||
"instructions": "Sauvegardez le projet, essayez {{cmd}} si la base de données s'ouvre toujours correctement, et restaurez à partir d'une sauvegarde connue bonne si la corruption persiste. Consultez {{link}} pour la disposition du stockage et les conseils de récupération.",
|
"instructions": "Sauvegardez le projet, consultez les <cmd>journaux de la base de données</cmd> ainsi que la connectivité et les autorisations PostgreSQL, puis actualisez l'état. Consultez <docsLink>docs/storage.md</docsLink> pour les conseils de stockage et de récupération.",
|
||||||
"lastChecked": "Dernier contrôle : {{checkedAtLabel}}",
|
"lastChecked": "Dernier contrôle : {{checkedAtLabel}}",
|
||||||
"refreshHealth": "Actualiser la santé",
|
"refreshHealth": "Actualiser la santé",
|
||||||
"refreshing": "Actualisation…",
|
"refreshing": "Actualisation…",
|
||||||
"title": "Corruption de base de données détectée",
|
"title": "Échec de la vérification de l'état de la base de données",
|
||||||
"whatToDo": "Que faire :"
|
"whatToDo": "Que faire :"
|
||||||
},
|
},
|
||||||
"deepLink": {
|
"deepLink": {
|
||||||
|
|||||||
@@ -2009,12 +2009,12 @@
|
|||||||
"title": "프로젝트 대시보드"
|
"title": "프로젝트 대시보드"
|
||||||
},
|
},
|
||||||
"dbBanner": {
|
"dbBanner": {
|
||||||
"body": "Fusion의 백그라운드 SQLite 무결성 검사에서 손상이 발견되었습니다. 중요한 작업을 계속하기 전에 아래의 실패한 개체를 검토하세요.",
|
"body": "Fusion의 PostgreSQL 상태 검사에서 오류가 보고되었습니다. 중요한 작업을 계속하기 전에 아래 세부 정보를 검토하세요.",
|
||||||
"instructions": "프로젝트를 백업하고, 데이터베이스가 정상적으로 열리면 {{cmd}}를 시도해 보세요. 손상이 지속될 경우 알려진 정상 백업에서 복원하세요. 저장소 레이아웃 및 복구 안내는 {{link}}를 참고하세요.",
|
"instructions": "프로젝트를 백업하고 <cmd>데이터베이스 로그</cmd>와 PostgreSQL 연결 및 권한을 확인한 다음 상태를 새로 고치세요. 저장소 및 복구 지침은 <docsLink>docs/storage.md</docsLink>를 참조하세요.",
|
||||||
"lastChecked": "마지막 확인: {{checkedAtLabel}}",
|
"lastChecked": "마지막 확인: {{checkedAtLabel}}",
|
||||||
"refreshHealth": "상태 새로 고침",
|
"refreshHealth": "상태 새로 고침",
|
||||||
"refreshing": "새로 고치는 중…",
|
"refreshing": "새로 고치는 중…",
|
||||||
"title": "데이터베이스 손상 감지됨",
|
"title": "데이터베이스 상태 검사 실패",
|
||||||
"whatToDo": "조치 방법:"
|
"whatToDo": "조치 방법:"
|
||||||
},
|
},
|
||||||
"deepLink": {
|
"deepLink": {
|
||||||
|
|||||||
@@ -2009,12 +2009,12 @@
|
|||||||
"title": "项目仪表板"
|
"title": "项目仪表板"
|
||||||
},
|
},
|
||||||
"dbBanner": {
|
"dbBanner": {
|
||||||
"body": "Fusion 的后台 SQLite 完整性检查报告有损坏。继续关键操作之前,请查看下面的失败对象。",
|
"body": "Fusion 的 PostgreSQL 健康检查报告失败。在继续关键操作之前,请检查下面的详细信息。",
|
||||||
"instructions": "备份项目,如果数据库仍然可以干净地打开,请尝试 {{cmd}},如果损坏仍然存在,请从已知的良好备份中恢复。有关存储布局和恢复指导,请参阅 {{link}}。",
|
"instructions": "请备份项目,检查<cmd>数据库日志</cmd>以及 PostgreSQL 连接和权限,然后刷新健康状态。有关存储和恢复指南,请参阅 <docsLink>docs/storage.md</docsLink>。",
|
||||||
"lastChecked": "上次检查:{{checkedAtLabel}}",
|
"lastChecked": "上次检查:{{checkedAtLabel}}",
|
||||||
"refreshHealth": "刷新健康状态",
|
"refreshHealth": "刷新健康状态",
|
||||||
"refreshing": "正在刷新…",
|
"refreshing": "正在刷新…",
|
||||||
"title": "检测到数据库损坏",
|
"title": "数据库健康检查失败",
|
||||||
"whatToDo": "该怎么做:"
|
"whatToDo": "该怎么做:"
|
||||||
},
|
},
|
||||||
"deepLink": {
|
"deepLink": {
|
||||||
|
|||||||
@@ -2009,12 +2009,12 @@
|
|||||||
"title": "專案儀表板"
|
"title": "專案儀表板"
|
||||||
},
|
},
|
||||||
"dbBanner": {
|
"dbBanner": {
|
||||||
"body": "Fusion 的背景 SQLite 完整性檢查報告有損毀。繼續重要操作之前,請查看下面的失敗物件。",
|
"body": "Fusion 的 PostgreSQL 健康檢查回報失敗。在繼續關鍵操作前,請檢查下方詳細資訊。",
|
||||||
"instructions": "備份專案,如果資料庫仍然可以乾淨地開啟,請嘗試 {{cmd}},如果損毀仍然存在,請從已知的良好備份中恢復。有關儲存配置和恢復指導,請參閱 {{link}}。",
|
"instructions": "請備份專案,檢查<cmd>資料庫記錄</cmd>以及 PostgreSQL 連線與權限,然後重新整理健康狀態。關於儲存與復原指南,請參閱 <docsLink>docs/storage.md</docsLink>。",
|
||||||
"lastChecked": "上次檢查:{{checkedAtLabel}}",
|
"lastChecked": "上次檢查:{{checkedAtLabel}}",
|
||||||
"refreshHealth": "重新整理健康狀態",
|
"refreshHealth": "重新整理健康狀態",
|
||||||
"refreshing": "正在重新整理…",
|
"refreshing": "正在重新整理…",
|
||||||
"title": "偵測到資料庫損毀",
|
"title": "資料庫健康檢查失敗",
|
||||||
"whatToDo": "該怎麼做:"
|
"whatToDo": "該怎麼做:"
|
||||||
},
|
},
|
||||||
"deepLink": {
|
"deepLink": {
|
||||||
|
|||||||
20
packages/i18n/src/__tests__/db-banner-catalog.test.ts
Normal file
20
packages/i18n/src/__tests__/db-banner-catalog.test.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
const locales = ["en", "es", "fr", "ko", "zh-CN", "zh-TW"] as const;
|
||||||
|
|
||||||
|
describe("database health banner catalogs", () => {
|
||||||
|
it.each(locales)("uses renderable rich-text guidance in %s", (locale) => {
|
||||||
|
const catalogPath = new URL(`../../locales/${locale}/app.json`, import.meta.url);
|
||||||
|
const catalog = JSON.parse(readFileSync(catalogPath, "utf8")) as {
|
||||||
|
dbBanner: { body: string; instructions: string; title: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(catalog.dbBanner.title).toBeTruthy();
|
||||||
|
expect(catalog.dbBanner.body).toBeTruthy();
|
||||||
|
expect(catalog.dbBanner.instructions).toContain("<cmd>");
|
||||||
|
expect(catalog.dbBanner.instructions).toContain("</cmd>");
|
||||||
|
expect(catalog.dbBanner.instructions).toContain("<docsLink>docs/storage.md</docsLink>");
|
||||||
|
expect(catalog.dbBanner.instructions).not.toMatch(/\{\{(?:cmd|link)\}\}/);
|
||||||
|
});
|
||||||
|
});
|
||||||
6
packages/i18n/src/resources.d.ts
vendored
6
packages/i18n/src/resources.d.ts
vendored
@@ -1973,12 +1973,12 @@ export default interface Resources {
|
|||||||
"updatingVersion": "Updating to a new frontend version..."
|
"updatingVersion": "Updating to a new frontend version..."
|
||||||
},
|
},
|
||||||
"dbBanner": {
|
"dbBanner": {
|
||||||
"body": "Fusion's background SQLite integrity check reported corruption. Review the failing objects below before continuing critical operations.",
|
"body": "Fusion's PostgreSQL health check reported a failure. Review the details below before continuing critical operations.",
|
||||||
"instructions": "Back up the project, try {{cmd}} if the database still opens cleanly, and restore from a known-good backup if corruption persists. See {{link}} for the storage layout and recovery guidance.",
|
"instructions": "Back up the project, check <cmd>database logs</cmd> and PostgreSQL connectivity and permissions, then refresh health. See <docsLink>docs/storage.md</docsLink> for storage and recovery guidance.",
|
||||||
"lastChecked": "Last checked: {{checkedAtLabel}}",
|
"lastChecked": "Last checked: {{checkedAtLabel}}",
|
||||||
"refreshHealth": "Refresh health",
|
"refreshHealth": "Refresh health",
|
||||||
"refreshing": "Refreshing…",
|
"refreshing": "Refreshing…",
|
||||||
"title": "Database corruption detected",
|
"title": "Database health check failed",
|
||||||
"whatToDo": "What to do:"
|
"whatToDo": "What to do:"
|
||||||
},
|
},
|
||||||
"deepLink": {
|
"deepLink": {
|
||||||
|
|||||||
Reference in New Issue
Block a user