Files
fusion/scripts/lib/backend-db.mjs
gsxdsm c25f8b796d Harden PostgreSQL migration foundation (#2088)
## Summary

- make SQLite-to-PostgreSQL cutover retryable, fail-closed, versioned,
and transactionally serialized
- isolate migration sessions from runtime traffic and apply schema
upgrades through `0002`
- enforce tenant ownership across automations, analytics, activity,
usage, agent runs, evals, and todos
- replace expired SQLite-only coverage with PostgreSQL parity and
concurrency coverage

This is PR 1 of 2. The stacked follow-up restores PostgreSQL parity for
CLI, engine, dashboard, and bundled integrations.

## Verification

- `pnpm check:changesets --strict`
- `pnpm --filter @fusion/core typecheck`
- migration schema, connection, and SQLite cutover suite: 57 tests
passed
- `pnpm test:gate`: 463 tests passed

## Post-Deploy Monitoring & Validation

- take a restorable PostgreSQL backup before deploy
- confirm `fusion_schema_migrations` contains `0002`
- confirm each expected project has a complete
`fusion_sqlite_migrations` row
- verify no null or empty tenant ownership in automations, activity
logs, agent runs, and usage events
- monitor for ownership inference failures, cutover verification
failures, and migration session errors
- restore the backup for data rollback; do not downgrade the
tenant-isolation schema in place

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* PostgreSQL-backed analytics and live dashboard metrics are now
project-scoped (activity, tools, monitor, signals, and live snapshots).
* Evaluation runs and scheduled eval batches received lifecycle
improvements (ordering, updates, and execution flow).
* Todo list changes now emit events; WhatsApp persistence and
project-scoped roadmap data are supported.

* **Bug Fixes**
* SQLite-to-PostgreSQL cutovers now fail safely with stronger
verification, serialized cutover handling, and safer project ownership.
* PostgreSQL backend writes and reads are now strictly project-isolated
and fail closed when project context is missing.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 08:16:42 -07:00

102 lines
4.1 KiB
JavaScript

/**
* FNXC:PostgresCutover 2026-07-05-13:00:
* Shared PostgreSQL backend access for operational scripts.
*
* The ops/maintenance scripts under scripts/ used to open `.fusion/fusion.db`
* directly (node:sqlite or the sqlite3 CLI). After the PostgreSQL cutover the
* live data lives in the embedded PostgreSQL cluster (or an external cluster
* via DATABASE_URL), so a direct SQLite open would silently operate on a
* stale/empty marker file. Every script now boots the real backend through
* @fusion/core's startup factory via this helper.
*
* Requires packages/core to be built (`pnpm --filter @fusion/core build`).
*/
import { cpSync, existsSync, readdirSync } from "node:fs";
import { resolve, dirname } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
/**
* Stage the PostgreSQL migration SQL into core's dist. `tsc` emits
* only JS, so dist lacks src/postgres/migrations/*.sql; the schema applier
* resolves them relative to the compiled file (__dirname/migrations). The CLI
* bundle does the same staging in packages/cli/tsup.config.ts.
*/
function ensureMigrationsStaged() {
const src = resolve(repoRoot, "packages/core/src/postgres/migrations");
const dest = resolve(repoRoot, "packages/core/dist/postgres/migrations");
/*
* FNXC:AutomationIsolation 2026-07-13-22:37:
* Operational scripts must stage every versioned PostgreSQL migration, not merely the initial baseline, so an already-initialized database receives the automation project-isolation upgrade before scripts open it.
*/
const requiredMigrations = existsSync(src)
? readdirSync(src).filter((file) => file.endsWith(".sql"))
: [];
if (existsSync(src) && requiredMigrations.some((file) => !existsSync(resolve(dest, file)))) {
cpSync(src, dest, { recursive: true });
}
}
async function importCore() {
ensureMigrationsStaged();
try {
return await import(pathToFileURL(resolve(repoRoot, "packages/core/dist/index.js")).href);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(
`Unable to import packages/core/dist/index.js (${message}). Run: pnpm --filter @fusion/core build`,
);
}
}
/**
* Boot a PostgreSQL-backed TaskStore for the given project root.
*
* Returns `{ core, store, asyncLayer, sql, schema, shutdown }`:
* - `core` — the @fusion/core module (for helpers like recordRunAuditEvent).
* - `store` — the initialized TaskStore (backend mode).
* - `asyncLayer` — the AsyncDataLayer; `asyncLayer.db.execute(sql\`...\`)`
* runs raw SQL. Project tables are schema-qualified (`project."tasks"`).
* - `sql` — the drizzle-orm `sql` template tag (re-exported as drizzleSql).
* - `schema` — postgresSchema (drizzle table objects, e.g. schema.project.tasks).
* - `shutdown` — releases the pool and stops an embedded cluster this boot
* started. Always call it in `finally`.
*
* Throws when the factory opts out (FUSION_NO_EMBEDDED_PG=1): these scripts
* must never fall back to the removed SQLite runtime.
*/
export async function openBackend(rootDir = process.cwd()) {
const core = await importCore();
const boot = await core.createTaskStoreForBackend({ rootDir });
if (!boot) {
throw new Error(
"PostgreSQL backend unavailable (FUSION_NO_EMBEDDED_PG=1 opt-out is set). " +
"This script requires the PostgreSQL backend; the SQLite runtime was removed.",
);
}
const asyncLayer = boot.taskStore.getAsyncLayer();
if (!asyncLayer) {
await boot.shutdown().catch(() => {});
throw new Error("Backend TaskStore has no AsyncDataLayer; cannot run this script.");
}
return {
core,
store: boot.taskStore,
asyncLayer,
sql: core.drizzleSql,
schema: core.postgresSchema,
shutdown: boot.shutdown,
};
}
/**
* Normalize a drizzle `db.execute(...)` result to a plain array of rows.
* postgres-js returns a RowList (array-like); this keeps call sites simple.
*/
export function rowsOf(result) {
if (Array.isArray(result)) return [...result];
if (result && Array.isArray(result.rows)) return [...result.rows];
return [];
}