diff --git a/packages/core/src/__tests__/postgres/sqlite-production-reader-inventory.test.ts b/packages/core/src/__tests__/postgres/sqlite-production-reader-inventory.test.ts new file mode 100644 index 0000000000..59598d89e3 --- /dev/null +++ b/packages/core/src/__tests__/postgres/sqlite-production-reader-inventory.test.ts @@ -0,0 +1,173 @@ +/* +FNXC:SqliteInventoryRatchet 2026-07-26-19:45: +PostgreSQL is the only supported runtime metadata authority. Production code may +open legacy SQLite only at the six cutover-approved read-only migration seams +documented in docs/postgres-migration-review-2026-07-14.md. This structural +ratchet scans the real source tree for `new DatabaseSync(` outside tests and +fails when a new production constructor appears. A second suite drives the real +archive-guard helpers to prove the known incomplete-PG-port behavior (backend +mode returns false rather than consulting a removed SQLite archive handle). +*/ +import { describe, expect, it } from "vitest"; +import { readFileSync, readdirSync } from "node:fs"; +import { join, relative } from "node:path"; +import { + isTaskArchivedImpl, + isTaskIdPresentInArchivedTasksTableImpl, +} from "../../task-store/task-id-integrity.js"; +import type { TaskStore } from "../../store.js"; + +const workspaceRoot = join(__dirname, "..", "..", "..", "..", ".."); + +/** + * Production (non-test) call sites allowed to construct DatabaseSync. + * Paths are repo-relative. Keep in sync with docs/postgres-migration-review-2026-07-14.md + * "Intentional remaining SQLite readers" and the SQLite migration inventory. + */ +export const AUTHORIZED_PRODUCTION_DATABASE_SYNC_CONSTRUCTORS = [ + "packages/core/src/postgres/sqlite-migrator.ts", + "packages/core/src/project-identity.ts", + "packages/core/src/sqlite-validation.ts", + "packages/core/src/postgres/startup-factory.ts", + "packages/cli/src/commands/db.ts", + "scripts/lib/start-local-project.mjs", +] as const; + +const SCAN_ROOTS = [ + "packages/core/src", + "packages/cli/src", + "packages/dashboard/src", + "packages/dashboard/app", + "packages/engine/src", + "packages/desktop/src", + "plugins", + "scripts", +] as const; + +const NEW_DATABASE_SYNC = /new\s+DatabaseSync\s*\(/g; + +function isTestOrNonSourcePath(relPath: string): boolean { + const normalized = relPath.replace(/\\/g, "/"); + if (normalized.includes("/__tests__/") || normalized.includes("/__mocks__/")) return true; + if (/\.(test|spec)\.(ts|tsx|js|mjs|cjs)$/.test(normalized)) return true; + if (normalized.includes("/node_modules/") || normalized.includes("/dist/")) return true; + // Vitest configs and package scripts that only quarantine SQLite tests are not openers. + if (/vitest\.config\.(ts|js|mjs)$/.test(normalized)) return true; + return false; +} + +function listSourceFiles(dir: string): string[] { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? (error as NodeJS.ErrnoException).code : undefined; + if (code === "ENOENT") return []; + throw error; + } + const files: string[] = []; + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + if ( + entry.name === "node_modules" || + entry.name === "dist" || + entry.name === "coverage" || + entry.name.startsWith(".") + ) { + continue; + } + files.push(...listSourceFiles(path)); + continue; + } + if (!/\.(ts|tsx|js|mjs|cjs)$/.test(entry.name)) continue; + files.push(path); + } + return files; +} + +function collectProductionDatabaseSyncConstructors(): Array<{ file: string; line: number; text: string }> { + const hits: Array<{ file: string; line: number; text: string }> = []; + for (const root of SCAN_ROOTS) { + for (const abs of listSourceFiles(join(workspaceRoot, root))) { + const rel = relative(workspaceRoot, abs).replace(/\\/g, "/"); + if (isTestOrNonSourcePath(rel)) continue; + const content = readFileSync(abs, "utf8"); + // Strip block and line comments so docstrings mentioning the constructor do not count. + const codeOnly = content + .replace(/\/\*[\s\S]*?\*\//g, (block) => block.replace(/[^\n]/g, " ")) + .replace(/(^|[^:])\/\/.*$/gm, "$1"); + const lines = codeOnly.split("\n"); + lines.forEach((line, index) => { + NEW_DATABASE_SYNC.lastIndex = 0; + if (NEW_DATABASE_SYNC.test(line)) { + hits.push({ file: rel, line: index + 1, text: line.trim() }); + } + }); + } + } + return hits; +} + +describe("SQLite production reader inventory (cutover ratchet)", () => { + it("allows only the six authorized production new DatabaseSync call sites", () => { + const hits = collectProductionDatabaseSyncConstructors(); + const files = [...new Set(hits.map((h) => h.file))].sort(); + const authorized = [...AUTHORIZED_PRODUCTION_DATABASE_SYNC_CONSTRUCTORS].sort(); + + expect(files, `Unexpected production DatabaseSync constructors:\n${hits.map((h) => `${h.file}:${h.line}: ${h.text}`).join("\n")}`).toEqual( + authorized, + ); + + // Every authorized path must still exist and still construct (no silent delete of a seam). + for (const path of AUTHORIZED_PRODUCTION_DATABASE_SYNC_CONSTRUCTORS) { + expect(files).toContain(path); + } + }); + + it("constructs DatabaseSync only with readOnly: true on authorized production sites", () => { + const hits = collectProductionDatabaseSyncConstructors(); + for (const hit of hits) { + // Read original source around the hit — require readOnly in the same statement region. + const abs = join(workspaceRoot, hit.file); + const content = readFileSync(abs, "utf8"); + const lines = content.split("\n"); + // Window: constructor line plus next 2 lines (options may wrap). + const window = lines.slice(Math.max(0, hit.line - 1), hit.line + 2).join(" "); + expect( + window, + `${hit.file}:${hit.line} must open DatabaseSync read-only`, + ).toMatch(/readOnly\s*:\s*true/); + } + }); +}); + +describe("incomplete PG archive guards (shipped helpers)", () => { + function backendModeStore(): TaskStore { + // Minimal TaskStore shape: backendMode true must never touch store.db. + return { + backendMode: true, + asyncLayer: { projectId: "proj_test" }, + db: { + prepare() { + throw new Error("SQLite Database must not be consulted in backend mode"); + }, + }, + archiveDb: { + get() { + throw new Error("SQLite ArchiveDatabase must not be consulted in backend mode"); + }, + }, + } as unknown as TaskStore; + } + + it("isTaskIdPresentInArchivedTasksTableImpl returns false under backend mode without opening SQLite", () => { + const store = backendModeStore(); + expect(isTaskIdPresentInArchivedTasksTableImpl(store, "FN-9999")).toBe(false); + }); + + it("isTaskArchivedImpl returns false under backend mode without opening SQLite", () => { + const store = backendModeStore(); + expect(isTaskArchivedImpl(store, "FN-9999")).toBe(false); + }); +});