diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index d02aadc07a..5c24a079dc 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -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 diff --git a/package.json b/package.json index ee615fbf07..28e64fb8b0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/core/src/__tests__/agent-logs-backend-mode.test.ts b/packages/core/src/__tests__/agent-logs-backend-mode.test.ts index 59828ae65d..0aa58bfad6 100644 --- a/packages/core/src/__tests__/agent-logs-backend-mode.test.ts +++ b/packages/core/src/__tests__/agent-logs-backend-mode.test.ts @@ -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(); diff --git a/packages/core/src/store.ts b/packages/core/src/store.ts index 05ba6b71af..11643d8605 100644 --- a/packages/core/src/store.ts +++ b/packages/core/src/store.ts @@ -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; diff --git a/packages/core/src/task-store/remaining-ops-5.ts b/packages/core/src/task-store/remaining-ops-5.ts index 775be408e1..9b6bdf0236 100644 --- a/packages/core/src/task-store/remaining-ops-5.ts +++ b/packages/core/src/task-store/remaining-ops-5.ts @@ -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) { diff --git a/plugins/fusion-plugin-quality/package.json b/plugins/fusion-plugin-quality/package.json index fbc73ac61b..68ec33a0ef 100644 --- a/plugins/fusion-plugin-quality/package.json +++ b/plugins/fusion-plugin-quality/package.json @@ -26,6 +26,7 @@ "dependencies": { "@fusion/core": "workspace:*", "@fusion/plugin-sdk": "workspace:*", + "drizzle-orm": "^0.45.2", "lucide-react": "^0.542.0" }, "devDependencies": { diff --git a/plugins/fusion-plugin-quality/src/__tests__/async-quality-store.pg.test.ts b/plugins/fusion-plugin-quality/src/__tests__/async-quality-store.pg.test.ts new file mode 100644 index 0000000000..b5922b7951 --- /dev/null +++ b/plugins/fusion-plugin-quality/src/__tests__/async-quality-store.pg.test.ts @@ -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(); } + }); +}); diff --git a/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts b/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts index 186772dccb..b1f3eac836 100644 --- a/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts +++ b/plugins/fusion-plugin-quality/src/__tests__/cancel-and-plans.test.ts @@ -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"); diff --git a/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts b/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts index bee1b256f5..514519eded 100644 --- a/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts +++ b/plugins/fusion-plugin-quality/src/__tests__/experimental-gate.test.ts @@ -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(), diff --git a/plugins/fusion-plugin-quality/src/routes/create-routes.ts b/plugins/fusion-plugin-quality/src/routes/create-routes.ts index 24ae41b363..bc1cf5dfcf 100644 --- a/plugins/fusion-plugin-quality/src/routes/create-routes.ts +++ b/plugins/fusion-plugin-quality/src/routes/create-routes.ts @@ -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(); -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), }) - .then(() => { - store.pruneRuns(projectId, getRunRetentionCount(ctx.settings as Record)); + .then(async () => { + await store.pruneRuns(projectId, getRunRetentionCount(ctx.settings as Record)); }) - .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, diff --git a/plugins/fusion-plugin-quality/src/runner/command-runner.ts b/plugins/fusion-plugin-quality/src/runner/command-runner.ts index 7eac528664..de65e2d172 100644 --- a/plugins/fusion-plugin-quality/src/runner/command-runner.ts +++ b/plugins/fusion-plugin-quality/src/runner/command-runner.ts @@ -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 { + 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 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): Promise { return await this.layer.db.execute(query) as unknown as RunRow[]; } + async createRun(input: CreateTestRunInput): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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; } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86c3c414db..406e976171 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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) diff --git a/scripts/__tests__/check-no-getdatabase.test.mjs b/scripts/__tests__/check-no-getdatabase.test.mjs new file mode 100644 index 0000000000..0d366f2884 --- /dev/null +++ b/scripts/__tests__/check-no-getdatabase.test.mjs @@ -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])); + } + }); +}); diff --git a/scripts/check-no-getdatabase.mjs b/scripts/check-no-getdatabase.mjs new file mode 100644 index 0000000000..7056845bc4 --- /dev/null +++ b/scripts/check-no-getdatabase.mjs @@ -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(); diff --git a/scripts/lib/getdatabase-allowlist.json b/scripts/lib/getdatabase-allowlist.json new file mode 100644 index 0000000000..68a0c8846e --- /dev/null +++ b/scripts/lib/getdatabase-allowlist.json @@ -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" + } + ] +}