feat(FN-4029): expose database health in API and store contracts
Adds database health endpoints to the API layer (FN-4029), exposing a new store health accessor through `api-node.ts` and `api/legacy.ts`, with aligned auth middleware integration tests and updated architecture documentation. Fusion-Task-Id: FN-4029
This commit is contained in:
5
.changeset/fn-4029-health-endpoint.md
Normal file
5
.changeset/fn-4029-health-endpoint.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Include database integrity health details on `/api/health` with `database.healthy`, `database.lastCheckedAt`, and `database.isRunning`.
|
||||||
@@ -847,9 +847,9 @@ A `prefetchLazyViews()` function runs once on mount via `requestIdleCallback` to
|
|||||||
### Health and monitoring endpoints
|
### Health and monitoring endpoints
|
||||||
- **Health check**: `GET /api/health`
|
- **Health check**: `GET /api/health`
|
||||||
- Returns liveness status for load balancers and monitoring
|
- Returns liveness status for load balancers and monitoring
|
||||||
- Response: `{ status: "ok" | "degraded", version: string, uptime: number, database: { corruptionDetected: boolean, integrityCheckPending: boolean, integrityCheckLastRunAt: string | null } }`
|
- Response: `{ status: "ok" | "degraded", version: string, uptime: number, database: { healthy: boolean, isRunning: boolean, lastCheckedAt: string | null } }`
|
||||||
- Startup does not block on full `PRAGMA integrity_check(100)`; Fusion schedules it in the background shortly after boot.
|
- Startup does not block on full `PRAGMA integrity_check(100)`; Fusion schedules it in the background shortly after boot.
|
||||||
- Background integrity checks are deduplicated process-wide per on-disk SQLite path: multiple `Database` instances sharing the same `fusion.db` join one shared run, and each instance still updates `database.integrityCheckPending`, `database.integrityCheckLastRunAt`, and `database.corruptionDetected` from the shared result.
|
- Background integrity checks are deduplicated process-wide per on-disk SQLite path: multiple `Database` instances sharing the same `fusion.db` join one shared run, and each instance still updates the underlying integrity state (`integrityCheckPending`, `integrityCheckLastRunAt`, `corruptionDetected`) that maps to `database.isRunning`, `database.lastCheckedAt`, and `database.healthy`.
|
||||||
- No authentication required
|
- No authentication required
|
||||||
|
|
||||||
### Custom Provider endpoints
|
### Custom Provider endpoints
|
||||||
|
|||||||
45
packages/core/src/__tests__/store-health.test.ts
Normal file
45
packages/core/src/__tests__/store-health.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { TaskStore } from "../store.js";
|
||||||
|
import { createTaskStoreTestHarness } from "./store-test-helpers.js";
|
||||||
|
|
||||||
|
describe("TaskStore.getDatabaseHealth", () => {
|
||||||
|
const harness = createTaskStoreTestHarness();
|
||||||
|
let store: TaskStore;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await harness.beforeEach();
|
||||||
|
store = harness.store();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports healthy by default before corruption is detected", () => {
|
||||||
|
const health = store.getDatabaseHealth();
|
||||||
|
|
||||||
|
expect(health.healthy).toBe(true);
|
||||||
|
expect(health.isRunning).toBe(false);
|
||||||
|
expect(health.lastCheckedAt).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an in-progress integrity check", () => {
|
||||||
|
const db = store.getDatabase();
|
||||||
|
db.integrityCheckPending = true;
|
||||||
|
|
||||||
|
const health = store.getDatabaseHealth();
|
||||||
|
|
||||||
|
expect(health.healthy).toBe(true);
|
||||||
|
expect(health.isRunning).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports unhealthy when corruption has been detected", () => {
|
||||||
|
const db = store.getDatabase();
|
||||||
|
db.corruptionDetected = true;
|
||||||
|
db.integrityCheckPending = false;
|
||||||
|
db.integrityCheckLastRunAt = "2026-05-11T12:34:56.000Z";
|
||||||
|
|
||||||
|
const health = store.getDatabaseHealth();
|
||||||
|
|
||||||
|
expect(health.healthy).toBe(false);
|
||||||
|
expect(health.isRunning).toBe(false);
|
||||||
|
expect(health.lastCheckedAt?.toISOString()).toBe("2026-05-11T12:34:56.000Z");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6977,14 +6977,14 @@ ${stepsSection}`;
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDatabaseHealth(): {
|
getDatabaseHealth(): {
|
||||||
corruptionDetected: boolean;
|
healthy: boolean;
|
||||||
integrityCheckPending: boolean;
|
lastCheckedAt: Date | null;
|
||||||
integrityCheckLastRunAt: string | null;
|
isRunning: boolean;
|
||||||
} {
|
} {
|
||||||
return {
|
return {
|
||||||
corruptionDetected: this.db.corruptionDetected,
|
healthy: !this.db.corruptionDetected,
|
||||||
integrityCheckPending: this.db.integrityCheckPending,
|
lastCheckedAt: this.db.integrityCheckLastRunAt ? new Date(this.db.integrityCheckLastRunAt) : null,
|
||||||
integrityCheckLastRunAt: this.db.integrityCheckLastRunAt,
|
isRunning: this.db.integrityCheckPending,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,12 @@ describe("api-node", () => {
|
|||||||
|
|
||||||
describe("fetchRemoteNodeHealth", () => {
|
describe("fetchRemoteNodeHealth", () => {
|
||||||
it("calls proxyApi with correct path and nodeId", async () => {
|
it("calls proxyApi with correct path and nodeId", async () => {
|
||||||
const mockHealth = { status: "online", version: "1.0.0", nodeId: "node_abc" };
|
const mockHealth = {
|
||||||
|
status: "online",
|
||||||
|
version: "1.0.0",
|
||||||
|
nodeId: "node_abc",
|
||||||
|
database: { healthy: true, isRunning: false, lastCheckedAt: null },
|
||||||
|
};
|
||||||
mockProxyApi.mockResolvedValueOnce(mockHealth);
|
mockProxyApi.mockResolvedValueOnce(mockHealth);
|
||||||
|
|
||||||
const result = await fetchRemoteNodeHealth("node_abc");
|
const result = await fetchRemoteNodeHealth("node_abc");
|
||||||
@@ -44,7 +49,12 @@ describe("api-node", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("returns remote node health data", async () => {
|
it("returns remote node health data", async () => {
|
||||||
const mockHealth = { status: "offline", version: "2.0.0", nodeId: "node_xyz" };
|
const mockHealth = {
|
||||||
|
status: "offline",
|
||||||
|
version: "2.0.0",
|
||||||
|
nodeId: "node_xyz",
|
||||||
|
database: { healthy: false, isRunning: true, lastCheckedAt: "2026-05-11T10:00:00.000Z" },
|
||||||
|
};
|
||||||
mockProxyApi.mockResolvedValueOnce(mockHealth);
|
mockProxyApi.mockResolvedValueOnce(mockHealth);
|
||||||
|
|
||||||
const result = await fetchRemoteNodeHealth("node_xyz");
|
const result = await fetchRemoteNodeHealth("node_xyz");
|
||||||
@@ -52,6 +62,11 @@ describe("api-node", () => {
|
|||||||
expect(result.status).toBe("offline");
|
expect(result.status).toBe("offline");
|
||||||
expect(result.version).toBe("2.0.0");
|
expect(result.version).toBe("2.0.0");
|
||||||
expect(result.nodeId).toBe("node_xyz");
|
expect(result.nodeId).toBe("node_xyz");
|
||||||
|
expect(result.database).toEqual({
|
||||||
|
healthy: false,
|
||||||
|
isRunning: true,
|
||||||
|
lastCheckedAt: "2026-05-11T10:00:00.000Z",
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export interface RemoteNodeHealth {
|
|||||||
status: string;
|
status: string;
|
||||||
version: string;
|
version: string;
|
||||||
nodeId: string;
|
nodeId: string;
|
||||||
|
database: {
|
||||||
|
healthy: boolean;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
isRunning: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fetch health information from a remote node */
|
/** Fetch health information from a remote node */
|
||||||
|
|||||||
@@ -199,6 +199,11 @@ export interface DashboardHealthResponse {
|
|||||||
status: string;
|
status: string;
|
||||||
version: string;
|
version: string;
|
||||||
uptime: number;
|
uptime: number;
|
||||||
|
database: {
|
||||||
|
healthy: boolean;
|
||||||
|
lastCheckedAt: string | null;
|
||||||
|
isRunning: boolean;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchDashboardHealth(): Promise<DashboardHealthResponse> {
|
export function fetchDashboardHealth(): Promise<DashboardHealthResponse> {
|
||||||
|
|||||||
@@ -66,9 +66,9 @@ class MockStore extends EventEmitter {
|
|||||||
|
|
||||||
getDatabaseHealth() {
|
getDatabaseHealth() {
|
||||||
return {
|
return {
|
||||||
corruptionDetected: false,
|
healthy: true,
|
||||||
integrityCheckPending: false,
|
isRunning: false,
|
||||||
integrityCheckLastRunAt: null,
|
lastCheckedAt: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -69,9 +69,9 @@ function createMockStore(overrides: Partial<TaskStore> = {}): TaskStore {
|
|||||||
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
prepare: vi.fn().mockReturnValue({ run: vi.fn().mockReturnValue({ changes: 0 }), get: vi.fn(), all: vi.fn().mockReturnValue([]) }),
|
||||||
}),
|
}),
|
||||||
getDatabaseHealth: vi.fn().mockReturnValue({
|
getDatabaseHealth: vi.fn().mockReturnValue({
|
||||||
corruptionDetected: false,
|
healthy: true,
|
||||||
integrityCheckPending: false,
|
isRunning: false,
|
||||||
integrityCheckLastRunAt: null,
|
lastCheckedAt: null,
|
||||||
}),
|
}),
|
||||||
getMissionStore: vi.fn().mockReturnValue({
|
getMissionStore: vi.fn().mockReturnValue({
|
||||||
listMissions: vi.fn().mockReturnValue([]),
|
listMissions: vi.fn().mockReturnValue([]),
|
||||||
@@ -313,9 +313,9 @@ describe("createServer health and headless mode", () => {
|
|||||||
version: CLI_PACKAGE_VERSION,
|
version: CLI_PACKAGE_VERSION,
|
||||||
uptime: expect.any(Number),
|
uptime: expect.any(Number),
|
||||||
database: {
|
database: {
|
||||||
corruptionDetected: false,
|
healthy: true,
|
||||||
integrityCheckPending: false,
|
isRunning: false,
|
||||||
integrityCheckLastRunAt: null,
|
lastCheckedAt: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -323,9 +323,9 @@ describe("createServer health and headless mode", () => {
|
|||||||
it("reports degraded status when database corruption is detected", async () => {
|
it("reports degraded status when database corruption is detected", async () => {
|
||||||
const store = createMockStore({
|
const store = createMockStore({
|
||||||
getDatabaseHealth: vi.fn().mockReturnValue({
|
getDatabaseHealth: vi.fn().mockReturnValue({
|
||||||
corruptionDetected: true,
|
healthy: false,
|
||||||
integrityCheckPending: false,
|
isRunning: false,
|
||||||
integrityCheckLastRunAt: "2026-05-11T10:00:00.000Z",
|
lastCheckedAt: new Date("2026-05-11T10:00:00.000Z"),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const app = createServer(store);
|
const app = createServer(store);
|
||||||
@@ -338,9 +338,9 @@ describe("createServer health and headless mode", () => {
|
|||||||
version: CLI_PACKAGE_VERSION,
|
version: CLI_PACKAGE_VERSION,
|
||||||
uptime: expect.any(Number),
|
uptime: expect.any(Number),
|
||||||
database: {
|
database: {
|
||||||
corruptionDetected: true,
|
healthy: false,
|
||||||
integrityCheckPending: false,
|
isRunning: false,
|
||||||
integrityCheckLastRunAt: "2026-05-11T10:00:00.000Z",
|
lastCheckedAt: "2026-05-11T10:00:00.000Z",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1058,7 +1058,7 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT
|
|||||||
app.get("/api/health", (_req, res) => {
|
app.get("/api/health", (_req, res) => {
|
||||||
const database = store.getDatabaseHealth();
|
const database = store.getDatabaseHealth();
|
||||||
res.json({
|
res.json({
|
||||||
status: database.corruptionDetected ? "degraded" : "ok",
|
status: database.healthy ? "ok" : "degraded",
|
||||||
version: cliPackageVersion,
|
version: cliPackageVersion,
|
||||||
uptime: Math.floor(process.uptime()),
|
uptime: Math.floor(process.uptime()),
|
||||||
database,
|
database,
|
||||||
|
|||||||
Reference in New Issue
Block a user