FN-8103: enforce PostgreSQL-only production data access

Require production paths to use PostgreSQL-aware stores and prevent new unrestricted database access.

- Add a checked allowlist that bans production getDatabase() calls by default.
- Route Quality plugin persistence through an async PostgreSQL-aware store and add Drizzle ORM.
- Document backend-safe plugin storage patterns and cover guarded access behavior.

Files changed:
 docs/PLUGIN_AUTHORING.md                           |  20 +++
 package.json                                       |   6 +-
 .../src/__tests__/agent-logs-backend-mode.test.ts  |   7 +
 packages/core/src/store.ts                         |   7 +-
 packages/core/src/task-store/remaining-ops-5.ts    |   8 +-
 plugins/fusion-plugin-quality/package.json         |   1 +
 .../src/__tests__/async-quality-store.pg.test.ts   |  36 +++++
 .../src/__tests__/cancel-and-plans.test.ts         |   8 +-
 .../src/__tests__/experimental-gate.test.ts        |   1 +
 .../src/routes/create-routes.ts                    |  50 +++----
 .../src/runner/command-runner.ts                   |  17 ++-
 .../src/store/async-quality-store.ts               |  34 +++++
 pnpm-lock.yaml                                     |   3 +
 scripts/__tests__/check-no-getdatabase.test.mjs    |  90 ++++++++++++
 scripts/check-no-getdatabase.mjs                   | 159 +++++++++++++++++++++
 scripts/lib/getdatabase-allowlist.json             |  18 +++
 16 files changed, 422 insertions(+), 43 deletions(-)

Fusion-Task-Id: FN-8103
Fusion-Task-Lineage: ff17bcb2-5341-4c6c-a5c4-993580539676
Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-16 10:13:49 -07:00
parent 1d0feb264a
commit 06d03d4e1f
16 changed files with 422 additions and 43 deletions

View File

@@ -997,6 +997,26 @@ interface PluginContext {
| `emitEvent` | `(event, data) => void` | Emit custom events |
| `createAiSession` | `CreateAiSessionFactory \| undefined` | Engine-injected AI session factory (undefined when engine isn't loaded) |
### Durable data access: PostgreSQL / `AsyncDataLayer`
Production Fusion hosts are PostgreSQL-only. **Plugin routes, hooks, and dashboard-backed feature paths must not call `ctx.taskStore.getDatabase()`**: it is the legacy synchronous SQLite accessor and throws in backend mode. Use a project-bound `AsyncDataLayer` and make store methods asynchronous instead.
```typescript
function getWidgetStore(ctx: PluginContext): AsyncWidgetStore {
const asyncLayer = ctx.taskStore.getAsyncLayer();
if (!asyncLayer) {
throw new Error("Widget plugin requires PostgreSQL AsyncDataLayer");
}
return new AsyncWidgetStore(asyncLayer);
}
const widget = await getWidgetStore(ctx).getWidget(widgetId);
```
Use a direct `drizzle-orm` dependency for plugin-owned PostgreSQL tables and scope every query by `asyncLayer.projectId`. See Reports' `getReportStore` / `ReportStore` async siblings and Quality's `AsyncQualityStore` for production patterns. SQLite/`DatabaseSync` is permitted only inside intentional unit harnesses, never as a production fallback.
The repository gate `scripts/check-no-getdatabase.mjs` scans tracked plugin, dashboard, engine, and core paths. A legitimate transitional exception must be reviewed in `scripts/lib/getdatabase-allowlist.json` and pin the exact `file`, one-based `line`, and trimmed `snippet`; there is no file-level or inline exemption. New plugin feature code is not eligible for that allowlist.
### Logger Methods
```typescript

View File

@@ -14,13 +14,13 @@
"type": "module",
"packageManager": "pnpm@10.33.0",
"scripts": {
"pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs",
"pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs",
"pretest": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs",
"pretest:full": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs",
"check:line-count": "node scripts/check-file-line-count.mjs",
"check:changesets": "node scripts/check-changeset-format.mjs",
"check:quarantine-ledger": "node scripts/check-quarantine-ledger.mjs",
"check:mock-completeness": "node scripts/check-mock-completeness.mjs",
"test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @fusion/core test:pg-gate && pnpm --filter @runfusion/fusion test:ci-shape",
"test:gate": "node scripts/check-no-nohup.mjs && node scripts/check-no-kill-4040.mjs && node scripts/check-no-getdatabase.mjs && node scripts/check-no-test-timeout-appeasement.mjs && node scripts/check-changeset-format.mjs && node scripts/check-mock-completeness.mjs && pnpm --filter @fusion/engine test:core && pnpm --filter @fusion/core test:pg-gate && pnpm --filter @runfusion/fusion test:ci-shape",
"smoke:boot": "node scripts/boot-smoke.mjs",
"local": "node scripts/start-local.mjs",
"dev": "node scripts/dev-with-memory.mjs",

View File

@@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { appendAgentLogBatchImpl, flushAgentLogBufferImpl } from "../task-store/agent-logs.js";
import { appendAgentLogImpl } from "../task-store/workflow-integrity.js";
import { getAgentLogCountImpl, getAgentLogsImpl } from "../task-store/remaining-ops-7.js";
import { dbImpl } from "../task-store/remaining-ops-5.js";
import { readAgentLogEntries } from "../agent-log-file-store.js";
/**
@@ -73,6 +74,12 @@ function makeBackendStore(fusionDir: string): { store: any; dbTouched: () => boo
return { store, dbTouched: () => touched };
}
describe("backend-mode SQLite access guidance", () => {
it("directs backend callers to AsyncDataLayer plugin authoring guidance", () => {
expect(() => dbImpl({ backendMode: true } as never)).toThrow(/getAsyncLayer\(\).*docs\/PLUGIN_AUTHORING\.md/);
});
});
describe("agent-log read filtering", () => {
it("filters before pagination and counts the filtered result", async () => {
const dir = tmp();

View File

@@ -2478,9 +2478,10 @@ Issue #2149 requires read-only type filtering to occur in the file-store before
}
/**
* FNXC:AsyncDataLayer 2026-06-24-11:00: CONTRACT CHANGE (U4, VAL-DATA-001): Returns synchronous Database during migration (U12-U15).
* U15 flips to AsyncDataLayer. New code should target AsyncDataLayer (transactionImmediate, transaction, recordRunAuditEventWithinTransaction).
* Async foundation in packages/core/src/postgres/data-layer.ts preserves BEGIN IMMEDIATE atomicity (VAL-DATA-002/003) and no partial writes (VAL-DATA-004).
* FNXC:PostgresOnlyDataAccess 2026-07-16-10:20:
* This legacy synchronous SQLite accessor is unavailable in backend mode and
* must not be used by plugin, dashboard, engine, or feature data paths.
* Durable production access uses getAsyncLayer() and an async store.
*/
getDatabase(): Database {
return this.db;

View File

@@ -47,10 +47,16 @@ export function trackDeferredTaskCreatedWorkImpl(store: TaskStore, work: () => P
});
}
/*
FNXC:PostgresOnlyDataAccess 2026-07-16-10:20:
Backend mode intentionally has no synchronous SQLite escape hatch. Name the
AsyncDataLayer route and authoring guide in this failure so plugin authors fix
the durable-data boundary rather than adding a backend-specific fallback.
*/
export function dbImpl(store: TaskStore): Database {
if (store.backendMode) {
throw new Error(
"TaskStore.db: SQLite Database is not available in backend mode (AsyncDataLayer injected)",
"TaskStore.db: SQLite Database is not available in backend mode (PostgreSQL/AsyncDataLayer injected). Use ctx.taskStore.getAsyncLayer() / an async store — see docs/PLUGIN_AUTHORING.md",
);
}
if (!store._db) {

View File

@@ -26,6 +26,7 @@
"dependencies": {
"@fusion/core": "workspace:*",
"@fusion/plugin-sdk": "workspace:*",
"drizzle-orm": "^0.45.2",
"lucide-react": "^0.542.0"
},
"devDependencies": {

View File

@@ -0,0 +1,36 @@
/*
FNXC:QualityPostgresDurability 2026-07-16-10:30:
This behavioral test applies the plugin's declarative PostgreSQL schema itself:
core's harness supplies only baseline tables. It proves Quality CRUD uses the
project-bound AsyncDataLayer rather than the SQLite route that failed Task QA.
*/
import { expect, it } from "vitest";
import { sql } from "drizzle-orm";
import type { AsyncDataLayer } from "@fusion/core";
import { createTaskStoreForTest, pgDescribe } from "../../../../packages/core/src/__test-utils__/pg-test-harness.js";
import { qualityPostgresSchema } from "../quality-schema.js";
import { AsyncQualityStore } from "../store/async-quality-store.js";
function projectLayer(layer: AsyncDataLayer, projectId: string): AsyncDataLayer { return { ...layer, projectId }; }
pgDescribe("AsyncQualityStore (PostgreSQL / backend mode)", () => {
it("persists Quality lifecycle data and isolates projects", async () => {
const h = await createTaskStoreForTest({ prefix: "fusion_quality_async" });
try {
for (const statement of qualityPostgresSchema.statements) await h.adminDb.execute(sql.raw(statement));
const projectA = new AsyncQualityStore(projectLayer(h.layer, "quality-a"));
const projectB = new AsyncQualityStore(projectLayer(h.layer, "quality-b"));
const created = await projectA.createRun({ projectId: "quality-a", source: "hub", command: "pnpm verify:fast", cwd: "/repo", cwdKind: "project-root", timeoutMs: 1_000, triggeredBy: "test" });
const updated = await projectA.updateRun("quality-a", created.id, { status: "passed", exitCode: 0, finishedAt: new Date().toISOString(), durationMs: 1 });
expect(updated).toMatchObject({ id: created.id, status: "passed", exitCode: 0 });
expect(await projectA.listRuns("quality-a")).toHaveLength(1);
expect(await projectB.getRun("quality-b", created.id)).toBeNull();
const createdPlan = await projectA.createPlan({ projectId: "quality-a", name: "Fast gate", steps: ["verify-fast"] });
expect((await projectA.getPlan("quality-a", createdPlan.id))?.steps).toEqual(["verify-fast"]);
await projectA.saveSuggestedCases({ projectId: "quality-a", taskId: "FN-8103", cases: [{ id: "case", text: "uses async data layer", done: false, source: "heuristic" }], generatedAt: new Date().toISOString(), method: "heuristic" });
expect((await projectA.getSuggestedCases("quality-a", "FN-8103"))?.cases).toHaveLength(1);
expect(await projectB.getSuggestedCases("quality-b", "FN-8103")).toBeNull();
} finally { await h.teardown(); }
});
});

View File

@@ -18,7 +18,7 @@ describe("cancelQualityRun", () => {
vi.restoreAllMocks();
});
it("kills the supervised child and marks queued/running runs cancelled", () => {
it("kills the supervised child and marks queued/running runs cancelled", async () => {
__clearActiveQualityRunsForTests();
const db = new DatabaseSync(":memory:");
ensureQualitySchema(db as never);
@@ -35,12 +35,12 @@ describe("cancelQualityRun", () => {
store.updateRun("p1", run.id, { status: "running", startedAt: new Date().toISOString() });
const kill = vi.fn();
__registerActiveQualityRunForTests("p1", run.id, { kill });
const cancelled = cancelQualityRun(store, "p1", run.id);
const cancelled = await cancelQualityRun(store, "p1", run.id);
expect(kill).toHaveBeenCalledWith("SIGTERM");
expect(cancelled?.status).toBe("cancelled");
expect(cancelled?.errorMessage).toMatch(/Cancelled/);
const again = cancelQualityRun(store, "p1", run.id);
const again = await cancelQualityRun(store, "p1", run.id);
expect(again?.status).toBe("cancelled");
});
@@ -70,7 +70,7 @@ describe("cancelQualityRun", () => {
timeoutMs: 1_000,
logTruncateKb: 1,
});
cancelQualityRun(store, "p1", run.id);
await cancelQualityRun(store, "p1", run.id);
await expect(execution).resolves.toMatchObject({ status: "cancelled", errorMessage: "Cancelled by operator" });
expect(kill).toHaveBeenCalledWith("SIGTERM");

View File

@@ -13,6 +13,7 @@ function makeCtx(getSettings?: () => unknown) {
return {
taskStore: {
getDatabase: () => db,
getAsyncLayer: () => ({ projectId: "proj-1", db: { execute: vi.fn().mockResolvedValue([]) } }),
getSettings: getSettings ?? (() => Promise.resolve({})),
getRootDir: () => "/tmp",
getTask: vi.fn(),

View File

@@ -1,7 +1,5 @@
import type { PluginContext, PluginRouteDefinition } from "@fusion/plugin-sdk";
import type { Database } from "@fusion/core";
import { ensureQualitySchema } from "../quality-schema.js";
import { QualityStore } from "../store/quality-store.js";
import { AsyncQualityStore } from "../store/async-quality-store.js";
import { isQualityPresetId, listPresetCatalog, resolvePresetCommand } from "../runner/command-presets.js";
import { cancelQualityRun, defaultTimeoutMs, executeQualityRun } from "../runner/command-runner.js";
import { getAllowRootFallback, getDefaultPreviewScript, getLogTruncateKb, getRunRetentionCount } from "../settings.js";
@@ -40,15 +38,17 @@ function requireProjectId(req: Req): string {
return id;
}
function getDb(ctx: PluginContext): Database {
// Prefer sync database when available (SQLite / sync facade).
return ctx.taskStore.getDatabase();
}
const qualityStoreCache = new WeakMap<object, AsyncQualityStore>();
function getStore(ctx: PluginContext): QualityStore {
const db = getDb(ctx);
ensureQualitySchema(db);
return new QualityStore(db);
function getStore(ctx: PluginContext): AsyncQualityStore {
const key = ctx.taskStore as object;
const cached = qualityStoreCache.get(key);
if (cached) return cached;
const asyncLayer = ctx.taskStore.getAsyncLayer();
if (!asyncLayer) throw new Error("Quality plugin requires ctx.taskStore.getAsyncLayer() / PostgreSQL AsyncDataLayer");
const store = new AsyncQualityStore(asyncLayer);
qualityStoreCache.set(key, store);
return store;
}
function httpError(status: number, message: string): never {
@@ -139,7 +139,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
const store = getStore(ctx);
const taskId = typeof r.query?.taskId === "string" ? r.query.taskId : undefined;
const limit = typeof r.query?.limit === "string" ? Number(r.query.limit) : 50;
return { runs: store.listRuns(projectId, { taskId, limit }) };
return { runs: await store.listRuns(projectId, { taskId, limit }) };
},
},
{
@@ -151,7 +151,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
const projectId = requireProjectId(r);
const runId = r.params?.runId;
if (!runId) httpError(400, "runId required");
const run = getStore(ctx).getRun(projectId, runId);
const run = await getStore(ctx).getRun(projectId, runId);
if (!run) httpError(404, "Run not found");
return { run };
},
@@ -177,7 +177,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
const source = body.source === "hub" ? "hub" : "task-tab";
const store = getStore(ctx);
const active = store.findActiveRun(projectId, taskId);
const active = await store.findActiveRun(projectId, taskId);
if (active) {
httpError(409, `A run is already active (${active.id})`);
}
@@ -263,7 +263,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
}
const timeoutMs = defaultTimeoutMs(verificationCommandTimeoutMs);
const run = store.createRun({
const run = await store.createRun({
projectId,
taskId,
source,
@@ -285,14 +285,14 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
timeoutMs,
logTruncateKb: getLogTruncateKb(ctx.settings as Record<string, unknown>),
})
.then(() => {
store.pruneRuns(projectId, getRunRetentionCount(ctx.settings as Record<string, unknown>));
.then(async () => {
await store.pruneRuns(projectId, getRunRetentionCount(ctx.settings as Record<string, unknown>));
})
.catch((err) => {
.catch(async (err) => {
ctx.logger?.warn?.(
`Quality run ${run.id} failed: ${err instanceof Error ? err.message : String(err)}`,
);
store.updateRun(projectId, run.id, {
await store.updateRun(projectId, run.id, {
status: "error",
errorMessage: err instanceof Error ? err.message : String(err),
finishedAt: new Date().toISOString(),
@@ -312,12 +312,12 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
const runId = r.params?.runId;
if (!runId) httpError(400, "runId required");
const store = getStore(ctx);
const run = store.getRun(projectId, runId);
const run = await store.getRun(projectId, runId);
if (!run) httpError(404, "Run not found");
if (run.status !== "queued" && run.status !== "running") {
return { run };
}
const updated = cancelQualityRun(store, projectId, runId);
const updated = await cancelQualityRun(store, projectId, runId);
return { run: updated };
},
},
@@ -328,7 +328,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
handler: async (req, ctx) => {
const r = req as Req;
const projectId = requireProjectId(r);
return { plans: getStore(ctx).listPlans(projectId) };
return { plans: await getStore(ctx).listPlans(projectId) };
},
},
{
@@ -342,7 +342,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
const name = typeof body.name === "string" ? body.name.trim() : "";
if (!name) httpError(400, "name is required");
const steps = validatePlanSteps(Array.isArray(body.steps) ? body.steps : []);
const plan = getStore(ctx).createPlan({ projectId, name, steps });
const plan = await getStore(ctx).createPlan({ projectId, name, steps });
return { plan };
},
},
@@ -355,7 +355,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
const projectId = requireProjectId(r);
const taskId = r.params?.taskId;
if (!taskId) httpError(400, "taskId required");
const existing = getStore(ctx).getSuggestedCases(projectId, taskId);
const existing = await getStore(ctx).getSuggestedCases(projectId, taskId);
return { suggestions: existing };
},
},
@@ -391,7 +391,7 @@ export function createQualityRoutes(): PluginRouteDefinition[] {
? task.modifiedFiles.filter((p): p is string => typeof p === "string")
: [],
});
const snapshot = getStore(ctx).saveSuggestedCases({
const snapshot = await getStore(ctx).saveSuggestedCases({
projectId,
taskId,
cases,

View File

@@ -1,5 +1,8 @@
import { superviseSpawn } from "@fusion/core";
import type { QualityStore } from "../store/quality-store.js";
import type { AsyncQualityStore } from "../store/async-quality-store.js";
type QualityPersistence = QualityStore | AsyncQualityStore;
import type { TestRun, TestRunStatus } from "../store/quality-types.js";
/*
@@ -24,12 +27,12 @@ Keep each live supervisor by project/run so the cancel route can terminate its
process group, while the runner's final write preserves the cancelled terminal
state if the child closes after that request.
*/
export function cancelQualityRun(store: QualityStore, projectId: string, runId: string): TestRun | null {
const current = store.getRun(projectId, runId);
export async function cancelQualityRun(store: QualityPersistence, projectId: string, runId: string): Promise<TestRun | null> {
const current = await store.getRun(projectId, runId);
if (!current || (current.status !== "queued" && current.status !== "running")) return current;
activeQualityRuns.get(activeRunKey(projectId, runId))?.kill("SIGTERM");
return store.updateRun(projectId, runId, {
return await store.updateRun(projectId, runId, {
status: "cancelled",
finishedAt: new Date().toISOString(),
errorMessage: "Cancelled by operator",
@@ -45,7 +48,7 @@ export function __registerActiveQualityRunForTests(projectId: string, runId: str
}
export interface RunCommandOptions {
store: QualityStore;
store: QualityPersistence;
projectId: string;
runId: string;
command: string;
@@ -65,7 +68,7 @@ export async function executeQualityRun(opts: RunCommandOptions): Promise<TestRu
const { store, projectId, runId, command, cwd } = opts;
const timeoutMs = Math.min(Math.max(opts.timeoutMs, 1_000), HARD_TIMEOUT_MS);
const startedAt = new Date().toISOString();
store.updateRun(projectId, runId, { status: "running", startedAt });
await store.updateRun(projectId, runId, { status: "running", startedAt });
let stdout = "";
let stderr = "";
@@ -128,9 +131,9 @@ export async function executeQualityRun(opts: RunCommandOptions): Promise<TestRu
const finishedAt = new Date().toISOString();
const durationMs = Math.max(0, Date.parse(finishedAt) - Date.parse(startedAt));
const current = store.getRun(projectId, runId);
const current = await store.getRun(projectId, runId);
const wasCancelled = current?.status === "cancelled";
const updated = store.updateRun(projectId, runId, {
const updated = await store.updateRun(projectId, runId, {
status: wasCancelled ? "cancelled" : status,
exitCode,
errorMessage: wasCancelled ? current.errorMessage ?? "Cancelled by operator" : errorMessage,

View File

@@ -0,0 +1,34 @@
import { randomUUID } from "node:crypto";
import type { AsyncDataLayer } from "@fusion/core";
import { sql } from "drizzle-orm";
import type { CreateTestPlanInput, CreateTestRunInput, QualityPresetId, SuggestedCase, SuggestedCasesSnapshot, TestPlan, TestPlanStatus, TestRun, TestRunStatus } from "./quality-types.js";
/*
FNXC:QualityPostgresDurability 2026-07-16-10:15:
Task QA previously called TaskStore.getDatabase(), which throws when production
injects PostgreSQL AsyncDataLayer. This store owns durable Quality data through
the project-bound async layer only, so every plugin route remains available in
backend mode and every query explicitly scopes project_id.
*/
type RunRow = { id: string; project_id: string; task_id: string | null; plan_id: string | null; source: string; preset_id: string | null; command: string; cwd: string; cwd_kind: string; status: string; exit_code: number | null; error_message: string | null; timeout_ms: number; started_at: string | null; finished_at: string | null; duration_ms: number | null; stdout: string; stderr: string; triggered_by: string; created_at: string; updated_at: string };
type PlanRow = { id: string; project_id: string; name: string; status: string; steps_json: string; created_at: string; updated_at: string };
function run(row: RunRow): TestRun { return { id: row.id, projectId: row.project_id, taskId: row.task_id ?? undefined, planId: row.plan_id ?? undefined, source: row.source as TestRun["source"], presetId: (row.preset_id as QualityPresetId | null) ?? undefined, command: row.command, cwd: row.cwd, cwdKind: row.cwd_kind as TestRun["cwdKind"], status: row.status as TestRunStatus, exitCode: row.exit_code ?? undefined, errorMessage: row.error_message ?? undefined, timeoutMs: row.timeout_ms, startedAt: row.started_at ?? undefined, finishedAt: row.finished_at ?? undefined, durationMs: row.duration_ms ?? undefined, stdout: row.stdout ?? "", stderr: row.stderr ?? "", triggeredBy: row.triggered_by, createdAt: row.created_at, updatedAt: row.updated_at }; }
function plan(row: PlanRow): TestPlan { let steps: QualityPresetId[] = []; try { const parsed = JSON.parse(row.steps_json); if (Array.isArray(parsed)) steps = parsed.filter((value): value is QualityPresetId => typeof value === "string"); } catch { /* malformed legacy data is empty */ } return { id: row.id, projectId: row.project_id, name: row.name, status: row.status as TestPlanStatus, steps, createdAt: row.created_at, updatedAt: row.updated_at }; }
export class AsyncQualityStore {
private readonly projectId: string;
constructor(private readonly layer: AsyncDataLayer) { if (!layer.projectId) throw new Error("Quality plugin requires a project-bound PostgreSQL AsyncDataLayer"); this.projectId = layer.projectId; }
private async runs(query: ReturnType<typeof sql>): Promise<RunRow[]> { return await this.layer.db.execute(query) as unknown as RunRow[]; }
async createRun(input: CreateTestRunInput): Promise<TestRun> { const now = new Date().toISOString(); const id = `qrun_${randomUUID()}`; await this.layer.db.execute(sql`INSERT INTO project.quality_test_runs (project_id,id,task_id,plan_id,source,preset_id,command,cwd,cwd_kind,status,timeout_ms,stdout,stderr,triggered_by,created_at,updated_at) VALUES (${this.projectId},${id},${input.taskId ?? null},${input.planId ?? null},${input.source},${input.presetId ?? null},${input.command},${input.cwd},${input.cwdKind},'queued',${input.timeoutMs},'','',${input.triggeredBy},${now},${now})`); return (await this.getRun(input.projectId, id))!; }
async getRun(projectId: string, id: string): Promise<TestRun | null> { const rows = await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND id=${id} LIMIT 1`); return rows[0] ? run(rows[0]) : null; }
async listRuns(projectId: string, opts?: { taskId?: string; limit?: number }): Promise<TestRun[]> { const limit = opts?.limit && opts.limit > 0 ? Math.min(opts.limit, 200) : 50; const rows = opts?.taskId ? await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id=${opts.taskId} ORDER BY created_at DESC,id DESC LIMIT ${limit}`) : await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} ORDER BY created_at DESC,id DESC LIMIT ${limit}`); return rows.map(run); }
async updateRun(projectId: string, id: string, patch: Partial<{ status: TestRunStatus; exitCode: number | null; errorMessage: string | null; startedAt: string | null; finishedAt: string | null; durationMs: number | null; stdout: string; stderr: string }>): Promise<TestRun | null> { const current = await this.getRun(projectId, id); if (!current) return null; const now = new Date().toISOString(); await this.layer.db.execute(sql`UPDATE project.quality_test_runs SET status=${patch.status ?? current.status},exit_code=${patch.exitCode !== undefined ? patch.exitCode : current.exitCode ?? null},error_message=${patch.errorMessage !== undefined ? patch.errorMessage : current.errorMessage ?? null},started_at=${patch.startedAt !== undefined ? patch.startedAt : current.startedAt ?? null},finished_at=${patch.finishedAt !== undefined ? patch.finishedAt : current.finishedAt ?? null},duration_ms=${patch.durationMs !== undefined ? patch.durationMs : current.durationMs ?? null},stdout=${patch.stdout !== undefined ? patch.stdout : current.stdout},stderr=${patch.stderr !== undefined ? patch.stderr : current.stderr},updated_at=${now} WHERE project_id=${this.projectId} AND id=${id}`); return this.getRun(projectId, id); }
async pruneRuns(projectId: string, retention: number): Promise<number> { if (retention <= 0) return 0; const rows = await this.runs(sql`DELETE FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND status NOT IN ('queued','running') AND id NOT IN (SELECT id FROM project.quality_test_runs WHERE project_id=${this.projectId} AND status NOT IN ('queued','running') ORDER BY created_at DESC,id DESC LIMIT ${retention}) RETURNING id`); return rows.length; }
async findActiveRun(projectId: string, taskId?: string): Promise<TestRun | null> { const rows = taskId ? await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id=${taskId} AND status IN ('queued','running') ORDER BY created_at DESC LIMIT 1`) : await this.runs(sql`SELECT * FROM project.quality_test_runs WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id IS NULL AND status IN ('queued','running') ORDER BY created_at DESC LIMIT 1`); return rows[0] ? run(rows[0]) : null; }
async createPlan(input: CreateTestPlanInput): Promise<TestPlan> { const now = new Date().toISOString(); const id = `qplan_${randomUUID()}`; await this.layer.db.execute(sql`INSERT INTO project.quality_test_plans(project_id,id,name,status,steps_json,created_at,updated_at) VALUES(${this.projectId},${id},${input.name},${input.status ?? "active"},${JSON.stringify(input.steps)},${now},${now})`); return (await this.getPlan(input.projectId,id))!; }
async getPlan(projectId: string, id: string): Promise<TestPlan | null> { const rows = await this.layer.db.execute(sql`SELECT * FROM project.quality_test_plans WHERE project_id=${this.projectId} AND project_id=${projectId} AND id=${id} LIMIT 1`) as unknown as PlanRow[]; return rows[0] ? plan(rows[0]) : null; }
async listPlans(projectId: string, opts?: { includeArchived?: boolean }): Promise<TestPlan[]> { const rows = await this.layer.db.execute(opts?.includeArchived ? sql`SELECT * FROM project.quality_test_plans WHERE project_id=${this.projectId} AND project_id=${projectId} ORDER BY updated_at DESC,id DESC` : sql`SELECT * FROM project.quality_test_plans WHERE project_id=${this.projectId} AND project_id=${projectId} AND status != 'archived' ORDER BY updated_at DESC,id DESC`) as unknown as PlanRow[]; return rows.map(plan); }
async getSuggestedCases(projectId: string, taskId: string): Promise<SuggestedCasesSnapshot | null> { const rows = await this.layer.db.execute(sql`SELECT * FROM project.quality_suggested_cases WHERE project_id=${this.projectId} AND project_id=${projectId} AND task_id=${taskId} LIMIT 1`) as unknown as Array<{project_id:string;task_id:string;cases_json:string;generated_at:string;method:string}>; if (!rows[0]) return null; let cases: SuggestedCase[]=[]; try { const parsed=JSON.parse(rows[0].cases_json); if(Array.isArray(parsed)) cases=parsed as SuggestedCase[]; } catch { /* malformed legacy data is empty */ } return {projectId:rows[0].project_id,taskId:rows[0].task_id,cases,generatedAt:rows[0].generated_at,method:rows[0].method as SuggestedCasesSnapshot["method"]}; }
async saveSuggestedCases(snapshot: SuggestedCasesSnapshot): Promise<SuggestedCasesSnapshot> { await this.layer.db.execute(sql`INSERT INTO project.quality_suggested_cases(project_id,task_id,cases_json,generated_at,method) VALUES(${this.projectId},${snapshot.taskId},${JSON.stringify(snapshot.cases)},${snapshot.generatedAt},${snapshot.method}) ON CONFLICT(project_id,task_id) DO UPDATE SET cases_json=excluded.cases_json,generated_at=excluded.generated_at,method=excluded.method`); return snapshot; }
}

3
pnpm-lock.yaml generated
View File

@@ -1149,6 +1149,9 @@ importers:
'@fusion/plugin-sdk':
specifier: workspace:*
version: link:../../packages/plugin-sdk
drizzle-orm:
specifier: ^0.45.2
version: 0.45.2(@opentelemetry/api@1.9.0)(better-sqlite3@12.9.0)(pg@8.22.0)(postgres@3.4.9)
lucide-react:
specifier: ^0.542.0
version: 0.542.0(react@19.2.4)

View File

@@ -0,0 +1,90 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
scanFileContent,
scanTrackedFiles,
validateAllowlistEntries,
} from "../check-no-getdatabase.mjs";
const file = "plugins/example/src/route.ts";
const line = " return ctx.taskStore.getDatabase();";
const pinned = { file, line: 2, snippet: line.trim(), reason: "FN-9999: temporary backend-guarded shim; remove during migration.", allowlistedAt: "2026-07-16" };
function invocations(content, entries = []) {
return scanFileContent(content, file, { allowlistEntries: entries }).filter((match) => match.type === "invocation");
}
describe("check-no-getdatabase", () => {
it("flags executable invocations and exempts only the exact pinned occurrence", () => {
const content = `const ignored = 1;\n${line}`;
assert.equal(invocations(content).length, 1);
assert.equal(scanFileContent(content, file, { allowlistEntries: [pinned] }).length, 0);
});
it("does not turn an invocation pin into a file-level exemption", () => {
const content = `const ignored = 1;\n${line}\n return ctx.taskStore.getDatabase();`;
assert.equal(invocations(content, [pinned]).length, 1);
});
it("does not exempt identical source text on a different line", () => {
const content = `const ignored = 1;\n${line}\n${line}`;
assert.equal(invocations(content, [pinned]).length, 1);
});
it("flags a moved call and reports the former pin as stale", () => {
const content = `\n\n${line}`;
const matches = scanFileContent(content, file, { allowlistEntries: [pinned] });
assert.equal(matches.filter((match) => match.type === "invocation").length, 1);
assert.equal(matches.filter((match) => match.type === "stale-allowlist").length, 1);
});
it("fails stale allowlist fingerprints", () => {
const content = `const ignored = 1;\n return ctx.taskStore.getDatabase ( );`;
const matches = scanFileContent(content, file, { allowlistEntries: [pinned] });
assert.equal(matches.some((match) => match.type === "stale-allowlist"), true);
assert.equal(matches.some((match) => match.type === "invocation"), true);
});
it("ignores comments, strings, literal templates, declarations, and near-misses", () => {
const content = [
"// getDatabase()",
"/** getDatabase( */",
"const quoted = 'getDatabase('; const double = \"call getDatabase()\";",
"const literal = `call getDatabase() only`;",
"getDatabase(): Database { return database; }",
"public getDatabase(): Database { return database; }",
"async getDatabase ( ) { return database; }",
"interface Store { getDatabase(): Database; }",
"const testDouble = { getDatabase() { return database; } };",
"getDatabaseHealth(); getDatabasePath(); refreshDatabaseHealth();",
].join("\n");
assert.deepEqual(invocations(content), []);
});
it("scans template interpolation expressions but not template literal text", () => {
assert.equal(invocations("const text = `literal getDatabase()`;").length, 0);
assert.equal(invocations("const text = `${ctx.taskStore.getDatabase()}`;").length, 1);
assert.equal(invocations("const text = `${`nested ${ctx.taskStore.getDatabase()}`}`;").length, 1);
});
it("allows explicit backend-guarded legacy pins", () => {
const core = "packages/core/src/store.ts";
const legacyLine = " await this.getDatabase().runPluginSchemaInits(";
const entry = { file: core, line: 1, snippet: legacyLine.trim(), reason: "FN-8104: remove legacy SQLite fallback.", allowlistedAt: "2026-07-16" };
assert.deepEqual(scanFileContent(legacyLine, core, { allowlistEntries: [entry] }), []);
});
it("skips deleted tracked files and rethrows non-ENOENT read failures", () => {
const matches = scanTrackedFiles(["deleted.ts"], { allowlistEntries: [], readFile: () => { const error = new Error("gone"); error.code = "ENOENT"; throw error; } });
assert.deepEqual(matches, []);
assert.throws(() => scanTrackedFiles(["denied.ts"], { allowlistEntries: [], readFile: () => { throw new Error("denied"); } }), /denied/);
});
it("validates every required allowlist field", () => {
for (const key of ["file", "line", "snippet", "reason", "allowlistedAt"]) {
const candidate = { ...pinned };
delete candidate[key];
assert.throws(() => validateAllowlistEntries([candidate]));
}
});
});

View File

@@ -0,0 +1,159 @@
#!/usr/bin/env node
/*
FNXC:PostgresOnlyDataAccess 2026-07-16-10:00:
Production plugin, dashboard, engine, and core access must use PostgreSQL through
AsyncDataLayer, after a Quality route reached SQLite in backend mode and crashed.
This scanner bans executable getDatabase() calls. Exceptions live only in the dated,
invocation-pinned JSON allowlist keyed by file+line+snippet: no file-level,
identical-line, or inline-marker bypass exists. Template ${...} expressions remain
executable and are scanned while literal template text is ignored.
*/
import { readFileSync } from "node:fs";
import { spawnSync } from "node:child_process";
import { fileURLToPath } from "node:url";
export const ALLOWLIST_PATH = "scripts/lib/getdatabase-allowlist.json";
export const SCAN_ROOTS = ["plugins", "packages/dashboard", "packages/engine", "packages/core"];
function listTrackedTargets() {
const result = spawnSync("git", ["ls-files", "--", ...SCAN_ROOTS], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] });
if (result.status !== 0) throw new Error(result.stderr?.trim() || "git ls-files failed");
return result.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
}
export function validateAllowlistEntries(entries) {
if (!Array.isArray(entries)) throw new Error(`${ALLOWLIST_PATH} must contain an entries array`);
return entries.map((entry, index) => {
const prefix = `${ALLOWLIST_PATH} entries[${index}]`;
if (!entry || typeof entry.file !== "string" || !entry.file.trim()) throw new Error(`${prefix} must include a non-empty file`);
if (!Number.isInteger(entry.line) || entry.line < 1) throw new Error(`${prefix} must include a 1-based integer line`);
if (typeof entry.snippet !== "string" || !entry.snippet.trim()) throw new Error(`${prefix} must include a non-empty snippet`);
if (typeof entry.reason !== "string" || !entry.reason.trim()) throw new Error(`${prefix} must include a non-empty reason`);
if (typeof entry.allowlistedAt !== "string" || Number.isNaN(Date.parse(entry.allowlistedAt))) throw new Error(`${prefix} must include an ISO-8601 allowlistedAt date`);
return entry;
});
}
function loadAllowlistEntries(path = ALLOWLIST_PATH) {
let parsed;
try { parsed = JSON.parse(readFileSync(path, "utf8")); }
catch (error) { throw new Error(`Failed to read ${path}: ${error instanceof Error ? error.message : String(error)}`); }
return validateAllowlistEntries(parsed.entries);
}
/** Replace non-code with spaces while retaining line/column offsets. */
function codeMask(source) {
const out = source.split("");
const blank = (start, end) => { for (let i = start; i < end; i++) if (out[i] !== "\n" && out[i] !== "\r") out[i] = " "; };
const scan = (start, end, templateExpression = false) => {
for (let i = start; i < end;) {
if (source.startsWith("//", i)) { const close = source.indexOf("\n", i + 2); blank(i, close < 0 ? end : close); i = close < 0 ? end : close; continue; }
if (source.startsWith("/*", i)) { const close = source.indexOf("*/", i + 2); const until = close < 0 ? end : close + 2; blank(i, until); i = until; continue; }
const quote = source[i];
if (quote === "'" || quote === '"') {
let j = i + 1;
while (j < end) { if (source[j] === "\\") { j += 2; continue; } if (source[j] === quote) { j++; break; } j++; }
blank(i, j); i = j; continue;
}
if (quote === "`") {
let j = i + 1; blank(i, i + 1);
while (j < end) {
if (source[j] === "\\") { blank(j, Math.min(j + 2, end)); j += 2; continue; }
if (source[j] === "`") { blank(j, j + 1); j++; break; }
if (source[j] === "$" && source[j + 1] === "{") {
blank(j, j + 2); let depth = 1; const exprStart = j + 2; j += 2;
while (j < end && depth) { if (source[j] === "{") depth++; else if (source[j] === "}") depth--; j++; }
const exprEnd = depth === 0 ? j - 1 : end;
scan(exprStart, exprEnd, true); if (depth === 0) blank(j - 1, j); continue;
}
blank(j, j + 1); j++;
}
i = j;
continue;
}
i++;
}
};
scan(0, source.length);
return out.join("");
}
function closingParen(source, open) {
let depth = 0;
for (let i = open; i < source.length; i++) {
if (source[i] === "(") depth++;
else if (source[i] === ")" && --depth === 0) return i;
}
return -1;
}
function isDeclaration(mask, index, openParen) {
const before = mask.slice(Math.max(0, index - 100), index);
if (/\.\s*$/.test(before)) return false;
const close = closingParen(mask, openParen);
if (close < 0) return false;
const after = mask.slice(close + 1, Math.min(mask.length, close + 240));
const precedingDeclarationToken = /\b(?:function|public|private|protected|static|async|abstract|readonly|declare)\s*$/.test(before);
const signature = /^\s*(?:\??\s*)?(?::[^\n{;=]+)?\s*(?:\{|;)/.test(after);
return precedingDeclarationToken || signature;
}
function lineAt(content, index) {
const lineNumber = content.slice(0, index).split("\n").length;
const start = content.lastIndexOf("\n", index - 1) + 1;
const end = content.indexOf("\n", index);
return { lineNumber, line: content.slice(start, end < 0 ? content.length : end) };
}
export function scanFileContent(content, filePath, options = {}) {
const entries = validateAllowlistEntries(options.allowlistEntries ?? []);
const mask = codeMask(content);
const matches = [];
const pinned = new Set();
const pattern = /\bgetDatabase\s*\(/g;
for (let found; (found = pattern.exec(mask));) {
const openParen = mask.indexOf("(", found.index);
if (isDeclaration(mask, found.index, openParen)) continue;
const { lineNumber, line } = lineAt(content, found.index);
const exact = entries.find((entry) => entry.file === filePath && entry.line === lineNumber && entry.snippet === line.trim());
if (exact) { pinned.add(exact); continue; }
matches.push({ type: "invocation", filePath, lineNumber, line });
}
for (const entry of entries) {
if (entry.file === filePath && !pinned.has(entry)) matches.push({ type: "stale-allowlist", filePath, lineNumber: entry.line, line: entry.snippet });
}
return matches;
}
export function scanTrackedFiles(files = listTrackedTargets(), options = {}) {
const entries = validateAllowlistEntries(options.allowlistEntries ?? loadAllowlistEntries(options.allowlistPath));
const readFile = options.readFile ?? readFileSync;
const matches = [];
const seen = new Set(files);
for (const entry of entries) if (!seen.has(entry.file)) matches.push({ type: "stale-allowlist", filePath: entry.file, lineNumber: entry.line, line: entry.snippet });
for (const filePath of files) {
let content;
try { content = readFile(filePath, "utf8"); }
catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") continue; throw error; }
matches.push(...scanFileContent(content, filePath, { allowlistEntries: entries }));
}
return matches;
}
export function formatFailureMessage(matches) {
return [
"[check-no-getdatabase] found non-PostgreSQL durable-data access or a stale exemption.",
"Use ctx.taskStore.getAsyncLayer() with an async store; see docs/PLUGIN_AUTHORING.md.",
`Legitimate transitional exemptions must be invocation-pinned in ${ALLOWLIST_PATH} by file+line+snippet (all three required).`,
...matches.map(({ type, filePath, lineNumber, line }) => `${type}: ${filePath}:${lineNumber}: ${line.trim()}`),
].join("\n");
}
export function main() {
const matches = scanTrackedFiles();
if (!matches.length) return 0;
console.error(formatFailureMessage(matches));
return 1;
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) process.exitCode = main();

View File

@@ -0,0 +1,18 @@
{
"entries": [
{
"file": "packages/core/src/store.ts",
"line": 2604,
"snippet": "await this.getDatabase().runPluginSchemaInits(",
"reason": "FN-8104: Backend-guarded SQLite plugin-schema fallback; remove with the coordinated SQLite/U15 retirement.",
"allowlistedAt": "2026-07-16"
},
{
"file": "packages/engine/src/self-healing.ts",
"line": 5358,
"snippet": "const db = this.store.getDatabase();",
"reason": "FN-8104: Backend-guarded SQLite self-healing fallback; remove with the coordinated SQLite/U15 retirement.",
"allowlistedAt": "2026-07-16"
}
]
}