Files
fusion/packages/core/src/sqlite-adapter.ts
gsxdsm 2e4fcfcaea fix(FN-7952): establish PostgreSQL core authority (#2108)
## Summary

Fusion’s core runtime now treats PostgreSQL as the authoritative
metadata store without leaving current CLI, dashboard, desktop, or
engine composition roots uncompilable between stack layers. This is the
99-file foundation for the larger cutover: subsequent PRs migrate the
remaining consumers, plugins, and operator surfaces.

## Design decisions

- Runtime store construction fails closed when an asynchronous
PostgreSQL layer is unavailable; SQLite remains readable only at
explicit migration and identity-recovery boundaries.
- Project ownership is enforced across active, archived, workflow,
mission, analytics, and plugin-schema data.
- The small set of cross-package files in this layer are
compatibility-critical call sites required for a green intermediate
commit, not the complete consumer migration.
- Schema migration 0008 remains assigned to session-advisor state from
current `main`; mission lineage idempotency advances to 0009 so neither
invariant can be skipped.

## Validation

- All affected package typechecks pass: Core, Engine, Dashboard, CLI,
and Desktop.
- `pnpm test:gate` passes: 478 tests across the engine gate, PostgreSQL
core gate, and CLI workflow shape.
- The PR changes exactly 99 files.

## Stack

This is the base PR. Engine/dashboard, CLI/desktop/ops, plugins, and
docs/release follow as stacked PRs, each below 100 changed files.

Related: #2105


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

## Summary by CodeRabbit

* **New Features**
* PostgreSQL is now the standard runtime backend, with embedded
PostgreSQL enabled by default.
* Added project-scoped storage for tasks, archives, chat sessions,
missions, knowledge pages, and operational data.
* Improved archived-task search, filtering, pagination, and restoration.
* Added safer plugin schema initialization with validation and project
isolation.
* Added PostgreSQL-backed workflow, mission, validator, and dashboard
capabilities.

* **Bug Fixes**
  * Improved startup timeout cancellation and resource cleanup.
* Prevented cross-project data access and phantom reservation cleanup
errors.
* Ensured archived tasks remain read-only and asynchronous writes
complete reliably.
  * Retired SQLite opt-out settings with clear startup errors.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 22:13:30 -07:00

156 lines
5.8 KiB
TypeScript

/**
* SQLite adapter that picks the runtime's native SQLite at construction time:
* - Bun runtime → `bun:sqlite` (built-in, no native module dance)
* - Node runtime → `node:sqlite` (Node 22+ built-in)
*
* Exports a `DatabaseSync` class with the subset of node:sqlite's API that the
* fn codebase actually uses: `prepare`, `exec`, `close`, and prepared
* statement methods `all`, `get`, `run`.
*
* The adapter exists because Bun's --compile bundler does not implement
* `node:sqlite` (require returns undefined silently; import throws), so a
* standalone Bun binary cannot use the same module that plain `node` uses.
*/
import { createRequire } from "node:module";
import { assertOutsideRealFusionPath } from "./test-safety.js";
const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
// Use createRequire so the bundler does not statically trace these specifiers.
// Bun's bundler will skip require() calls whose argument it cannot resolve at
// build time, which keeps `node:sqlite` from being eagerly pulled into the
// compiled binary (where it would fail to resolve at runtime).
const requireFromHere = createRequire(import.meta.url);
export interface SqliteRunResult {
changes: number | bigint;
lastInsertRowid: number | bigint;
}
export interface SqliteStatement {
all(...params: unknown[]): unknown[];
get(...params: unknown[]): unknown;
run(...params: unknown[]): SqliteRunResult;
}
interface RawStatement {
all: (...params: unknown[]) => unknown[];
get: (...params: unknown[]) => unknown;
run: (...params: unknown[]) => { changes: number | bigint; lastInsertRowid: number | bigint };
}
interface RawDatabase {
exec(sql: string): void;
prepare(sql: string): RawStatement;
close(): void;
// Optional snapshot API (node:sqlite ≥ 22.x, bun:sqlite). Both runtimes
// expose `serialize()` → Uint8Array and `deserialize(buf)` that replaces the
// open database's contents in place. Used only by the test snapshot harness.
serialize?: () => Uint8Array;
deserialize?: (data: Uint8Array) => void;
}
type DatabaseCtor = new (path: string, options?: Record<string, unknown>) => RawDatabase;
let cachedCtor: DatabaseCtor | null = null;
function loadDatabaseCtor(): DatabaseCtor {
if (cachedCtor) return cachedCtor;
if (isBun) {
const mod = requireFromHere("bun:sqlite") as { Database: DatabaseCtor };
cachedCtor = mod.Database;
} else {
const mod = requireFromHere("node:sqlite") as { DatabaseSync: DatabaseCtor };
cachedCtor = mod.DatabaseSync;
}
return cachedCtor;
}
/**
* Drop-in replacement for `node:sqlite`'s `DatabaseSync`. Backed by
* `bun:sqlite` under Bun and `node:sqlite` under Node.
*/
export class DatabaseSync {
private impl: RawDatabase;
constructor(path: string, options?: { readOnly?: boolean }) {
assertOutsideRealFusionPath(path, "SQLite database open");
const Ctor = loadDatabaseCtor();
/* FNXC:LegacySqliteBoundary 2026-07-14-18:42:
* Remaining SQLite access is migration/import/validation only. Open those
* sources read-only so discovery cannot create files, recover WALs, or
* checkpoint legacy databases during normal PostgreSQL startup.
*/
const runtimeOptions = options?.readOnly
? (isBun ? { readonly: true } : { readOnly: true })
: undefined;
this.impl = runtimeOptions ? new Ctor(path, runtimeOptions) : new Ctor(path);
}
exec(sql: string): void {
this.impl.exec(sql);
}
close(): void {
this.impl.close();
}
/**
* FNXC:CoreTests 2026-06-25-16:30:
* Snapshot the entire database into a byte buffer. Backs the test-only
* migrated-DB snapshot harness so the 129-migration init() runs once per
* test file instead of once per test. Throws if the runtime lacks the API.
*/
serialize(): Uint8Array {
if (typeof this.impl.serialize !== "function") {
throw new Error("SQLite runtime does not support serialize()");
}
return this.impl.serialize();
}
/**
* FNXC:CoreTests 2026-06-25-16:30:
* Replace this (in-memory) database's contents with a previously serialized
* snapshot. Restores a fully-migrated schema without replaying migrations.
*/
deserialize(data: Uint8Array): void {
if (typeof this.impl.deserialize !== "function") {
throw new Error("SQLite runtime does not support deserialize()");
}
this.impl.deserialize(data);
}
prepare(sql: string): SqliteStatement {
const stmt = this.impl.prepare(sql);
/*
FNXC:Storage 2026-06-25-00:00:
Node v26's node:sqlite rejects `undefined` bound parameters with
ERR_INVALID_ARG_TYPE ("Provided value cannot be bound to SQLite parameter").
Bun's bun:sqlite and the legacy better-sqlite3 treat `undefined` as NULL.
To preserve the historical contract that callers may pass `undefined` for
an optional/absent column value, coerce each param: undefined → null before
handing it to the underlying statement. This is the production-safe fix
(no caller depends on `undefined` being a distinct value from NULL — NULL
is the SQL-correct representation of "no value"). The coercion is applied
uniformly across all/get/run so behavior is identical regardless of which
execute path a caller takes.
*/
const coerceParams = (params: unknown[]): unknown[] =>
params.map((p) => (p === undefined ? null : p));
// Both node:sqlite and bun:sqlite expose the same .all/.get/.run shape.
// Normalize `get` to return undefined (not null) when no row matches, and
// pass run() through unchanged — both runtimes already produce the same
// { changes, lastInsertRowid } shape.
return {
all: (...params: unknown[]) => stmt.all(...coerceParams(params)),
get: (...params: unknown[]) => {
const row = stmt.get(...coerceParams(params));
return row ?? undefined;
},
run: (...params: unknown[]) => stmt.run(...coerceParams(params)),
};
}
}