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 -->
This commit is contained in:
@@ -428,7 +428,7 @@ export function uninstallElectronAsarNativePathPatchForTests(): void {
|
||||
* first call. Throws if the package is not installed (e.g. a stripped-down
|
||||
* build that omitted the embedded binary).
|
||||
*/
|
||||
type EmbeddedPostgresCtor = new (opts: Record<string, unknown>) => {
|
||||
export type EmbeddedPostgresCtor = new (opts: Record<string, unknown>) => {
|
||||
initialise(): Promise<void>;
|
||||
start(): Promise<void>;
|
||||
stop(): Promise<void>;
|
||||
@@ -442,6 +442,12 @@ type EmbeddedPostgresCtor = new (opts: Record<string, unknown>) => {
|
||||
/** Instance type produced by the embedded-postgres constructor. */
|
||||
type EmbeddedPostgresInstance = InstanceType<EmbeddedPostgresCtor>;
|
||||
let embeddedPostgresCtorCache: EmbeddedPostgresCtor | null = null;
|
||||
|
||||
/** Test-only constructor seam for deterministic lifecycle cancellation coverage. */
|
||||
export function __setEmbeddedPostgresCtorForTests(ctor: EmbeddedPostgresCtor | null): void {
|
||||
embeddedPostgresCtorCache = ctor;
|
||||
}
|
||||
|
||||
function getEmbeddedPostgresCtor(): EmbeddedPostgresCtor {
|
||||
if (embeddedPostgresCtorCache) return embeddedPostgresCtorCache;
|
||||
// FNXC:DesktopEmbeddedPostgres 2026-07-14-18:30:
|
||||
@@ -933,9 +939,12 @@ export class EmbeddedPostgresLifecycle {
|
||||
if (this.options.startTimeoutMs <= 0) {
|
||||
return this.startInternal();
|
||||
}
|
||||
const controller = new AbortController();
|
||||
const startAttempt = this.startInternal(controller.signal);
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(
|
||||
new EmbeddedStartTimeoutError(
|
||||
this.options.startTimeoutMs,
|
||||
@@ -948,8 +957,9 @@ export class EmbeddedPostgresLifecycle {
|
||||
});
|
||||
this.startTimer = timer ?? null;
|
||||
try {
|
||||
return await Promise.race([this.startInternal(), timeout]);
|
||||
return await Promise.race([startAttempt, timeout]);
|
||||
} catch (err) {
|
||||
controller.abort();
|
||||
// On timeout (or any failure), best-effort clean up the partial state so
|
||||
// a retry starts fresh. stop() is safe to call even when not fully running.
|
||||
await this.stop().catch(() => undefined);
|
||||
@@ -964,15 +974,16 @@ export class EmbeddedPostgresLifecycle {
|
||||
* The actual start sequence, with no timeout wrapper. Called by {@link start}
|
||||
* either directly (timeout disabled) or via Promise.race with the timeout.
|
||||
*/
|
||||
private async startInternal(): Promise<ResolvedBackend> {
|
||||
private async startInternal(signal?: AbortSignal): Promise<ResolvedBackend> {
|
||||
const port = this.options.port ?? (await findFreePort());
|
||||
if (signal?.aborted) throw new EmbeddedStartCancelledError(this.options.dataDir);
|
||||
this.resolvedPort = port;
|
||||
|
||||
const alreadyInitialized = isDataDirInitialized(this.options.dataDir);
|
||||
|
||||
normalizeBundledMacosDylibs(this.options.onLog);
|
||||
|
||||
this.pg = new (getEmbeddedPostgresCtor())({
|
||||
const pg = new (getEmbeddedPostgresCtor())({
|
||||
databaseDir: this.options.dataDir,
|
||||
user: this.options.user,
|
||||
password: this.options.password,
|
||||
@@ -984,6 +995,7 @@ export class EmbeddedPostgresLifecycle {
|
||||
onLog: this.options.onLog,
|
||||
onError: this.options.onError,
|
||||
});
|
||||
this.pg = pg;
|
||||
|
||||
// FNXC:PostgresEmbedded 2026-06-24-09:06:
|
||||
// initialise() always runs initdb, which fails on an existing data dir.
|
||||
@@ -996,10 +1008,23 @@ export class EmbeddedPostgresLifecycle {
|
||||
this.options.onLog(
|
||||
`embedded postgres: initializing new data directory at ${this.options.dataDir} (initdb)`,
|
||||
);
|
||||
await this.pg.initialise();
|
||||
await pg.initialise();
|
||||
}
|
||||
|
||||
await this.pg.start();
|
||||
if (signal?.aborted) {
|
||||
await this.settleCancelledStart(pg);
|
||||
throw new EmbeddedStartCancelledError(this.options.dataDir);
|
||||
}
|
||||
|
||||
await pg.start();
|
||||
/*
|
||||
FNXC:PostgresResourceLifecycle 2026-07-14-18:42:
|
||||
Promise.race does not cancel the losing embedded-postgres startup. Check the cooperative cancellation signal after every delayed phase and stop the exact late instance before it can publish running state, registry ownership, or process hooks. A timeout may already have attempted stop while pg.start() was pending, so the post-resolution stop is intentionally repeated to catch a postmaster that appeared after that first cleanup.
|
||||
*/
|
||||
if (signal?.aborted) {
|
||||
await this.settleCancelledStart(pg);
|
||||
throw new EmbeddedStartCancelledError(this.options.dataDir);
|
||||
}
|
||||
this.running = true;
|
||||
this.ownsProcess = true;
|
||||
|
||||
@@ -1009,7 +1034,20 @@ export class EmbeddedPostgresLifecycle {
|
||||
database: this.options.database,
|
||||
});
|
||||
|
||||
await this.ensureDatabase();
|
||||
try {
|
||||
await this.ensureDatabase();
|
||||
} catch (error) {
|
||||
if (signal?.aborted) {
|
||||
await this.settleCancelledStart(pg);
|
||||
throw new EmbeddedStartCancelledError(this.options.dataDir);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (signal?.aborted) {
|
||||
await this.settleCancelledStart(pg);
|
||||
throw new EmbeddedStartCancelledError(this.options.dataDir);
|
||||
}
|
||||
|
||||
this.installShutdownHook();
|
||||
|
||||
@@ -1026,6 +1064,19 @@ export class EmbeddedPostgresLifecycle {
|
||||
};
|
||||
}
|
||||
|
||||
private async settleCancelledStart(pg: EmbeddedPostgresInstance): Promise<void> {
|
||||
try {
|
||||
await pg.stop();
|
||||
} catch (error) {
|
||||
this.options.onError(`embedded postgres: cancelled startup cleanup failed: ${String(error)}`);
|
||||
} finally {
|
||||
if (this.pg === pg) this.pg = null;
|
||||
this.running = false;
|
||||
runningInstances.delete(this.options.dataDir);
|
||||
this.uninstallShutdownHook();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure the application database exists on the running cluster.
|
||||
*
|
||||
@@ -1175,6 +1226,13 @@ export class EmbeddedPostgresLifecycle {
|
||||
};
|
||||
}
|
||||
|
||||
class EmbeddedStartCancelledError extends Error {
|
||||
constructor(dataDir: string) {
|
||||
super(`embedded postgres: cancelled late startup for ${dataDir}`);
|
||||
this.name = "EmbeddedStartCancelledError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:PostgresEmbedded 2026-06-26-16:30 (fix migration-review P1 #24):
|
||||
* Thrown when the embedded PostgreSQL start sequence (initdb + pg_ctl start +
|
||||
|
||||
@@ -81,11 +81,16 @@ export {
|
||||
export {
|
||||
roadmapPluginSchemaInit,
|
||||
cePluginSchemaInit,
|
||||
whatsappPluginSchemaInit,
|
||||
evenRealitiesPluginSchemaInit,
|
||||
reportsPluginSchemaInit,
|
||||
cliPressPluginSchemaInit,
|
||||
DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS,
|
||||
runPluginSchemaInitHooks,
|
||||
runLoadedPluginSchemaInitHooks,
|
||||
assertLoadedPluginSchemaInitHooksSupported,
|
||||
type PluginSchemaInitHook,
|
||||
type LoadedPluginSchemaContract,
|
||||
} from "./plugin-schema-hook.js";
|
||||
|
||||
/**
|
||||
@@ -189,23 +194,20 @@ export {
|
||||
* FNXC:BackendFlip 2026-06-26-14:30:
|
||||
* Runtime startup factory (cutover milestone). `createTaskStoreForBackend()`
|
||||
* is the single entry point production construction sites consult to decide
|
||||
* whether to boot against PostgreSQL or fall back to the legacy SQLite path.
|
||||
* Post default-flip (flip-embedded-pg-default): when DATABASE_URL is unset,
|
||||
* the factory boots embedded PostgreSQL by default; FUSION_NO_EMBEDDED_PG=1
|
||||
* is the opt-out back to legacy SQLite. When DATABASE_URL is set, external
|
||||
* PostgreSQL is used. When it returns a `BackendBootResult`, the call site
|
||||
* uses the ready TaskStore and registers the result's `shutdown()` for
|
||||
* process teardown. When it returns `null`, the call site constructs the
|
||||
* SQLite-backed TaskStore exactly as before (byte-identical legacy path).
|
||||
* how to boot PostgreSQL. Embedded PostgreSQL is the zero-config default and
|
||||
* DATABASE_URL selects an external service; the removed SQLite opt-out is
|
||||
* rejected so callers always receive a live backend result.
|
||||
*/
|
||||
export {
|
||||
createTaskStoreForBackend,
|
||||
createCentralBackendLayer,
|
||||
shouldUsePostgresBackend,
|
||||
isEmbeddedPgRequested,
|
||||
isEmbeddedPgOptedOut,
|
||||
EMBEDDED_PG_ENV,
|
||||
NO_EMBEDDED_PG_ENV,
|
||||
type BackendBootResult,
|
||||
type CentralBackendLayerResult,
|
||||
type CreateTaskStoreForBackendOptions,
|
||||
} from "./startup-factory.js";
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
|
||||
/** The Drizzle instance type startup-factory uses for its `connections.migration`. */
|
||||
type MigrationDb = PostgresJsDatabase<Record<string, never>>;
|
||||
type MigrationTransaction = Parameters<Parameters<MigrationDb["transaction"]>[0]>[0];
|
||||
|
||||
/** Inputs for stamping migrated rows with a project partition key. */
|
||||
export interface StampMigratedProjectRowsInput {
|
||||
@@ -62,13 +63,37 @@ export async function rekeyFallbackProjectPartition(
|
||||
if (!fallbackProjectId || fallbackProjectId === registeredProjectId) return false;
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
const ownedRows = (await tx.execute(sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM project.tasks WHERE project_id = ${fallbackProjectId}
|
||||
UNION ALL
|
||||
SELECT 1 FROM archive.archived_tasks WHERE project_id = ${fallbackProjectId}
|
||||
) AS found
|
||||
`)) as unknown as Array<{ found: boolean }>;
|
||||
const tables = (await tx.execute(sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'project' AND column_name = 'project_id'
|
||||
ORDER BY table_name
|
||||
`)) as unknown as Array<{ table_name: string }>;
|
||||
/*
|
||||
FNXC:ProjectIdentityPromotion 2026-07-14-18:58:
|
||||
Fallback ownership can exist only in satellite tables such as agents, reports, or mission state. Inspect every project-owned table before deciding promotion is a no-op; task/archive-only detection stranded those partitions after central registration.
|
||||
*/
|
||||
let ownsProjectRows = false;
|
||||
for (const { table_name: tableName } of tables) {
|
||||
const rows = (await tx.execute(sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM ${sql.identifier("project")}.${sql.identifier(tableName)}
|
||||
WHERE project_id = ${fallbackProjectId}
|
||||
) AS found
|
||||
`)) as unknown as Array<{ found: boolean }>;
|
||||
if (rows[0]?.found) {
|
||||
ownsProjectRows = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!ownsProjectRows) {
|
||||
const rows = (await tx.execute(sql`
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM archive.archived_tasks WHERE project_id = ${fallbackProjectId}
|
||||
) AS found
|
||||
`)) as unknown as Array<{ found: boolean }>;
|
||||
ownsProjectRows = rows[0]?.found === true;
|
||||
}
|
||||
const migrationState = (await tx.execute(sql`
|
||||
SELECT to_regclass('public.fusion_sqlite_migrations') IS NOT NULL AS exists
|
||||
`)) as unknown as Array<{ exists: boolean }>;
|
||||
@@ -82,15 +107,9 @@ export async function rekeyFallbackProjectPartition(
|
||||
`)) as unknown as Array<{ found: boolean }>;
|
||||
ownsMigrationState = markerRows[0]?.found === true;
|
||||
}
|
||||
if (!ownedRows[0]?.found && !ownsMigrationState) return false;
|
||||
if (!ownsProjectRows && !ownsMigrationState) return false;
|
||||
|
||||
await tx.execute(sql`SET CONSTRAINTS ALL DEFERRED`);
|
||||
const tables = (await tx.execute(sql`
|
||||
SELECT table_name
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'project' AND column_name = 'project_id'
|
||||
ORDER BY table_name
|
||||
`)) as unknown as Array<{ table_name: string }>;
|
||||
for (const { table_name: tableName } of tables) {
|
||||
await tx.execute(sql`
|
||||
UPDATE ${sql.identifier("project")}.${sql.identifier(tableName)}
|
||||
@@ -171,19 +190,52 @@ export async function stampMigratedProjectRows(
|
||||
exactly the boot that performs most real-world migrations. The resolution now
|
||||
falls back to a central-registry path lookup (done by the caller).
|
||||
|
||||
FNXC:ProjectDataIsolation 2026-07-14-12:55:
|
||||
Schema migration 0006 quarantines ambiguous ownerless rows as __legacy_unscoped__. Never bulk-stamp that quarantine here: the SQLite migrator reconciles rows against a specific source and project, while this legacy helper may only claim its historical NULL rows.
|
||||
FNXC:ProjectDataIsolation 2026-07-14-16:50:
|
||||
Schema migration 0006 quarantines ownerless rows when no unique migration owner existed at schema-upgrade time. A later verified startup cutover may claim that quarantine only when the migration ledger now identifies exactly one non-empty project and it is this project; multi-project or otherwise ambiguous quarantines remain untouched for operator reconciliation.
|
||||
*/
|
||||
await db.execute(
|
||||
sql`UPDATE project.tasks SET project_id = ${projectId} WHERE project_id IS NULL`,
|
||||
/*
|
||||
FNXC:ProjectMigrationStamping 2026-07-14-21:55:
|
||||
Partition stamping is one atomic promotion. If any table cannot be re-keyed, roll back every earlier update so startup never exposes a partially migrated project identity.
|
||||
*/
|
||||
return db.transaction((tx) => stampMigratedProjectRowsWithinTransaction(tx, projectId, rootDir));
|
||||
}
|
||||
|
||||
async function stampMigratedProjectRowsWithinTransaction(
|
||||
tx: MigrationTransaction,
|
||||
projectId: string,
|
||||
rootDir: string,
|
||||
): Promise<StampMigratedProjectRowsResult> {
|
||||
const stateTable = (await tx.execute(sql`
|
||||
SELECT to_regclass('public.fusion_sqlite_migrations') IS NOT NULL AS exists
|
||||
`)) as unknown as Array<{ exists: boolean }>;
|
||||
let canClaimLegacyQuarantine = false;
|
||||
if (stateTable[0]?.exists) {
|
||||
const ownershipRows = (await tx.execute(sql`
|
||||
SELECT count(DISTINCT project_id)::int AS project_count,
|
||||
min(project_id) AS only_project_id
|
||||
FROM public.fusion_sqlite_migrations
|
||||
WHERE project_id IS NOT NULL AND project_id <> ''
|
||||
`)) as unknown as Array<{ project_count: number; only_project_id: string | null }>;
|
||||
canClaimLegacyQuarantine =
|
||||
ownershipRows[0]?.project_count === 1 && ownershipRows[0]?.only_project_id === projectId;
|
||||
}
|
||||
|
||||
await tx.execute(
|
||||
sql`UPDATE project.tasks SET project_id = ${projectId}
|
||||
WHERE project_id IS NULL
|
||||
OR (${canClaimLegacyQuarantine} AND project_id = '__legacy_unscoped__')`,
|
||||
);
|
||||
await db.execute(
|
||||
sql`UPDATE project.archived_tasks SET project_id = ${projectId} WHERE project_id IS NULL`,
|
||||
await tx.execute(
|
||||
sql`UPDATE project.archived_tasks SET project_id = ${projectId}
|
||||
WHERE project_id IS NULL
|
||||
OR (${canClaimLegacyQuarantine} AND project_id = '__legacy_unscoped__')`,
|
||||
);
|
||||
// The cold-storage archive is also partitioned (PR #2007 review P1); migrated
|
||||
// snapshots must be owned by this project too.
|
||||
await db.execute(
|
||||
sql`UPDATE archive.archived_tasks SET project_id = ${projectId} WHERE project_id IS NULL`,
|
||||
await tx.execute(
|
||||
sql`UPDATE archive.archived_tasks SET project_id = ${projectId}
|
||||
WHERE project_id IS NULL
|
||||
OR (${canClaimLegacyQuarantine} AND project_id = '__legacy_unscoped__')`,
|
||||
);
|
||||
|
||||
/*
|
||||
@@ -197,9 +249,9 @@ export async function stampMigratedProjectRows(
|
||||
clobbered (then the '' row is left for manual reconciliation rather than
|
||||
destroying either copy).
|
||||
*/
|
||||
await db.execute(
|
||||
await tx.execute(
|
||||
sql`UPDATE project.config SET project_id = ${projectId}
|
||||
WHERE project_id = ''
|
||||
WHERE (project_id = '' OR (${canClaimLegacyQuarantine} AND project_id = '__legacy_unscoped__'))
|
||||
AND NOT EXISTS (SELECT 1 FROM project.config WHERE project_id = ${projectId})`,
|
||||
);
|
||||
|
||||
@@ -217,7 +269,7 @@ export async function stampMigratedProjectRows(
|
||||
unique violation never clobbers a pre-existing per-project row (the outer
|
||||
table alias in the correlated subquery references the row being updated).
|
||||
*/
|
||||
await db.execute(
|
||||
await tx.execute(
|
||||
sql`UPDATE project.workflow_settings SET project_id = ${projectId}
|
||||
WHERE project_id = ${rootDir}
|
||||
AND NOT EXISTS (
|
||||
@@ -226,7 +278,7 @@ export async function stampMigratedProjectRows(
|
||||
AND w2.project_id = ${projectId}
|
||||
)`,
|
||||
);
|
||||
await db.execute(
|
||||
await tx.execute(
|
||||
sql`UPDATE project.workflow_prompt_overrides SET project_id = ${projectId}
|
||||
WHERE project_id = ${rootDir}
|
||||
AND NOT EXISTS (
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
FNXC:MissionFixIdempotency 2026-07-14-18:55:
|
||||
A source feature can produce at most one generated fix for a validator run within a project. The unique index is intentionally additive and fails closed if historical duplicates exist so operators can reconcile conflicting remediation records explicitly.
|
||||
*/
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('project.mission_fix_feature_lineage') IS NOT NULL THEN
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_fix_lineage_project_source_run
|
||||
ON project.mission_fix_feature_lineage (project_id, source_feature_id, run_id);
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -17,6 +17,15 @@
|
||||
|
||||
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { PluginPostgresSchemaDefinition } from "../plugin-types.js";
|
||||
|
||||
export interface LoadedPluginSchemaContract {
|
||||
pluginId: string;
|
||||
/** @deprecated compatibility alias for legacyHook. */
|
||||
hook?: unknown;
|
||||
legacyHook?: unknown;
|
||||
postgresSchema?: PluginPostgresSchemaDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* A plugin schema-init hook. Receives the Drizzle connection and is expected
|
||||
@@ -300,6 +309,46 @@ export const whatsappPluginSchemaInit: PluginSchemaInitHook = {
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* FNXC:EvenRealitiesPostgres 2026-07-14-17:25:
|
||||
* The bundled glasses notifier previously registered only SQLite DDL, so backend startup skipped its table and onLoad later reached a removed synchronous database. Materialize the project-owned PostgreSQL snapshot table explicitly; arbitrary SQLite hook SQL is never translated or executed as PostgreSQL.
|
||||
*/
|
||||
export const evenRealitiesPluginSchemaInit: PluginSchemaInitHook = {
|
||||
pluginId: "fusion-plugin-even-realities-glasses",
|
||||
async init(db) {
|
||||
await db.execute(sql.raw(`
|
||||
CREATE TABLE IF NOT EXISTS project.even_realities_seen_tasks (
|
||||
project_id text NOT NULL DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__'),
|
||||
task_id text NOT NULL,
|
||||
last_column text NOT NULL,
|
||||
updated_at text NOT NULL,
|
||||
PRIMARY KEY (project_id, task_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS "idxEvenRealitiesSeenTasksProjectUpdated"
|
||||
ON project.even_realities_seen_tasks(project_id, updated_at, task_id);
|
||||
ALTER TABLE project.even_realities_seen_tasks ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE project.even_realities_seen_tasks FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS fusion_project_isolation ON project.even_realities_seen_tasks;
|
||||
CREATE POLICY fusion_project_isolation ON project.even_realities_seen_tasks
|
||||
USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true))
|
||||
WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true));
|
||||
DO $even_realities_runtime$
|
||||
BEGIN
|
||||
IF to_regprocedure('project.fusion_assign_project_id()') IS NOT NULL THEN
|
||||
DROP TRIGGER IF EXISTS fusion_assign_project_id ON project.even_realities_seen_tasks;
|
||||
CREATE TRIGGER fusion_assign_project_id
|
||||
BEFORE INSERT OR UPDATE OF project_id ON project.even_realities_seen_tasks
|
||||
FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id();
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime') THEN
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON project.even_realities_seen_tasks TO fusion_runtime;
|
||||
END IF;
|
||||
END
|
||||
$even_realities_runtime$;
|
||||
`));
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* FNXC:PostgresSchema 2026-07-04-00:00:
|
||||
* Reports plugin schema-init hook. Creates the reports table in the project
|
||||
@@ -455,10 +504,132 @@ export const DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS: readonly PluginSchemaInitHook[] =
|
||||
roadmapPluginSchemaInit,
|
||||
cePluginSchemaInit,
|
||||
whatsappPluginSchemaInit,
|
||||
evenRealitiesPluginSchemaInit,
|
||||
reportsPluginSchemaInit,
|
||||
cliPressPluginSchemaInit,
|
||||
];
|
||||
|
||||
const POSTGRES_PLUGIN_SCHEMA_HOOKS = new Map(
|
||||
DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS.map((hook) => [hook.pluginId, hook] as const),
|
||||
);
|
||||
|
||||
const SAFE_POSTGRES_PLUGIN_STATEMENT = /^(?:CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+project\.[a-z][a-z0-9_]*\s*\(|CREATE\s+(?:UNIQUE\s+)?INDEX\s+IF\s+NOT\s+EXISTS\s+(?:"[^"]+"|[a-z][a-z0-9_]*)\s+ON\s+project\.[a-z][a-z0-9_]*\s*\(|ALTER\s+TABLE\s+project\.[a-z][a-z0-9_]*\s+)/i;
|
||||
const CREATE_PLUGIN_TABLE = /^CREATE\s+TABLE\s+IF\s+NOT\s+EXISTS\s+project\.([a-z][a-z0-9_]*)\s*\(/i;
|
||||
|
||||
/**
|
||||
* Validate a third-party schema plan before plugin lifecycle side effects run.
|
||||
* This is a capability boundary, not a SQL sandbox: installed plugins already
|
||||
* execute JavaScript, but ordinary hooks never receive migration credentials.
|
||||
*/
|
||||
export function validatePluginPostgresSchema(
|
||||
pluginId: string,
|
||||
definition: PluginPostgresSchemaDefinition,
|
||||
): void {
|
||||
if (!Number.isSafeInteger(definition.version) || definition.version < 1) {
|
||||
throw new Error(`Plugin "${pluginId}" PostgreSQL schema version must be a positive integer`);
|
||||
}
|
||||
if (!/^[a-z][a-z0-9_]*_$/.test(definition.tablePrefix)) {
|
||||
throw new Error(`Plugin "${pluginId}" PostgreSQL tablePrefix must be lowercase snake_case ending in underscore`);
|
||||
}
|
||||
if (!Array.isArray(definition.statements) || definition.statements.length === 0) {
|
||||
throw new Error(`Plugin "${pluginId}" PostgreSQL schema must declare at least one statement`);
|
||||
}
|
||||
for (const statement of definition.statements) {
|
||||
const normalized = statement.trim().replace(/;\s*$/, "");
|
||||
if (!normalized || normalized.includes(";")) {
|
||||
throw new Error(`Plugin "${pluginId}" PostgreSQL schema requires exactly one statement per item`);
|
||||
}
|
||||
if (!SAFE_POSTGRES_PLUGIN_STATEMENT.test(normalized)) {
|
||||
throw new Error(
|
||||
`Plugin "${pluginId}" PostgreSQL schema may only use idempotent CREATE TABLE/INDEX or ALTER TABLE statements in the project schema`,
|
||||
);
|
||||
}
|
||||
for (const [, table] of normalized.matchAll(/\bproject\.([a-z][a-z0-9_]*)\b/gi)) {
|
||||
if (!table.toLowerCase().startsWith(definition.tablePrefix)) {
|
||||
throw new Error(`Plugin "${pluginId}" PostgreSQL schema may only reference tables beginning with ${definition.tablePrefix}`);
|
||||
}
|
||||
}
|
||||
if (CREATE_PLUGIN_TABLE.test(normalized)) {
|
||||
if (!/\bproject_id\s+text\s+NOT\s+NULL\b/i.test(normalized)) {
|
||||
throw new Error(`Plugin "${pluginId}" PostgreSQL tables must declare project_id text NOT NULL`);
|
||||
}
|
||||
if (!/\bPRIMARY\s+KEY\s*\(\s*project_id\s*,/i.test(normalized)) {
|
||||
throw new Error(`Plugin "${pluginId}" PostgreSQL tables must use a project_id-leading composite primary key`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate runtime-loaded legacy hooks against the PostgreSQL registry.
|
||||
* Runtime AsyncDataLayer connections intentionally have DML-only privileges;
|
||||
* DDL is executed by applySchemaBaseline's migration connection on every boot.
|
||||
*/
|
||||
export function assertLoadedPluginSchemaInitHooksSupported(
|
||||
hooks: ReadonlyArray<LoadedPluginSchemaContract>,
|
||||
): void {
|
||||
for (const loaded of hooks) {
|
||||
if (loaded.postgresSchema) {
|
||||
validatePluginPostgresSchema(loaded.pluginId, loaded.postgresSchema);
|
||||
continue;
|
||||
}
|
||||
if ((loaded.legacyHook ?? loaded.hook) && !POSTGRES_PLUGIN_SCHEMA_HOOKS.has(loaded.pluginId)) {
|
||||
throw new Error(
|
||||
`Plugin "${loaded.pluginId}" declares legacy SQLite onSchemaInit but has no registered PostgreSQL schema hook`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the explicit PostgreSQL schema contract for plugins loaded at runtime.
|
||||
* A legacy `onSchemaInit(Database)` callback is evidence that schema is needed,
|
||||
* but its SQLite SQL is not portable. Only registered PostgreSQL equivalents
|
||||
* may run; unknown hooks fail loudly with an actionable contract error.
|
||||
*/
|
||||
export async function runLoadedPluginSchemaInitHooks(
|
||||
db: PostgresJsDatabase<Record<string, never>>,
|
||||
hooks: ReadonlyArray<LoadedPluginSchemaContract>,
|
||||
): Promise<void> {
|
||||
assertLoadedPluginSchemaInitHooksSupported(hooks);
|
||||
for (const loaded of hooks) {
|
||||
if (loaded.postgresSchema) {
|
||||
const tables = new Set<string>();
|
||||
for (const statement of loaded.postgresSchema.statements) {
|
||||
const normalized = statement.trim().replace(/;\s*$/, "");
|
||||
const table = normalized.match(CREATE_PLUGIN_TABLE)?.[1];
|
||||
if (table) tables.add(table);
|
||||
await db.execute(sql.raw(normalized));
|
||||
}
|
||||
for (const table of tables) {
|
||||
/*
|
||||
FNXC:PluginPostgresContract 2026-07-14-18:32:
|
||||
Fusion owns the isolation envelope for third-party tables. Plugins
|
||||
declare project-local keys; the privileged executor installs forced
|
||||
RLS, ownership stamping, runtime grants, and a single scoped policy.
|
||||
*/
|
||||
await db.execute(sql.raw(`
|
||||
ALTER TABLE project."${table}" ALTER COLUMN project_id
|
||||
SET DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__');
|
||||
ALTER TABLE project."${table}" ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE project."${table}" FORCE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS fusion_project_isolation ON project."${table}";
|
||||
CREATE POLICY fusion_project_isolation ON project."${table}"
|
||||
USING (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true))
|
||||
WITH CHECK (current_setting('fusion.project_bypass', true) = 'on' OR project_id = current_setting('fusion.project_id', true));
|
||||
DROP TRIGGER IF EXISTS fusion_assign_project_id ON project."${table}";
|
||||
CREATE TRIGGER fusion_assign_project_id BEFORE INSERT OR UPDATE OF project_id
|
||||
ON project."${table}" FOR EACH ROW EXECUTE FUNCTION project.fusion_assign_project_id();
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON project."${table}" TO fusion_runtime;
|
||||
`));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const postgresHook = POSTGRES_PLUGIN_SCHEMA_HOOKS.get(loaded.pluginId);
|
||||
if (postgresHook) await postgresHook.init(db);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the given plugin schema-init hooks in registration order. Each hook is
|
||||
* expected to be idempotent; this function does not swallow hook errors.
|
||||
|
||||
@@ -27,7 +27,7 @@ import { sql } from "drizzle-orm";
|
||||
import { runPluginSchemaInitHooks, DEFAULT_PLUGIN_SCHEMA_INIT_HOOKS, type PluginSchemaInitHook } from "./plugin-schema-hook.js";
|
||||
|
||||
/** The latest PostgreSQL schema version known to this applier. */
|
||||
export const SCHEMA_BASELINE_VERSION = "0008";
|
||||
export const SCHEMA_BASELINE_VERSION = "0009";
|
||||
const INITIAL_SCHEMA_VERSION = "0000";
|
||||
const AUTOMATION_ISOLATION_SCHEMA_VERSION = "0001";
|
||||
const ANALYTICS_ISOLATION_SCHEMA_VERSION = "0002";
|
||||
@@ -46,6 +46,7 @@ export const SQLITE_SCHEMA_PARITY_VERSION = "0007";
|
||||
* advisor overrides. Keep this identity fixed when SCHEMA_BASELINE_VERSION advances.
|
||||
*/
|
||||
export const SESSION_ADVISOR_ENABLED_SCHEMA_VERSION = "0008";
|
||||
export const MISSION_FIX_IDEMPOTENCY_VERSION = "0009";
|
||||
|
||||
/** Bookkeeping table for the fresh Drizzle migration history. */
|
||||
export const MIGRATION_BOOKKEEPING_TABLE = "fusion_schema_migrations";
|
||||
@@ -92,6 +93,11 @@ const SESSION_ADVISOR_ENABLED_MIGRATION_PATH = join(
|
||||
"migrations",
|
||||
"0008_session_advisor_enabled.sql",
|
||||
);
|
||||
const MISSION_FIX_IDEMPOTENCY_MIGRATION_PATH = join(
|
||||
__dirname,
|
||||
"migrations",
|
||||
"0009_mission_fix_idempotency.sql",
|
||||
);
|
||||
|
||||
/**
|
||||
* Ensure the migration bookkeeping table exists. Lives in the public schema so
|
||||
@@ -157,6 +163,7 @@ export async function applySchemaBaseline(
|
||||
const projectOwnershipAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_SCHEMA_VERSION);
|
||||
const sqliteSchemaParityAlreadyApplied = applied.includes(SQLITE_SCHEMA_PARITY_VERSION);
|
||||
const sessionAdvisorEnabledAlreadyApplied = applied.includes(SESSION_ADVISOR_ENABLED_SCHEMA_VERSION);
|
||||
const missionFixIdempotencyAlreadyApplied = applied.includes(MISSION_FIX_IDEMPOTENCY_VERSION);
|
||||
let schemaChanged = false;
|
||||
|
||||
if (!baselineAlreadyApplied) {
|
||||
@@ -364,6 +371,22 @@ export async function applySchemaBaseline(
|
||||
schemaChanged = true;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:MissionFixIdempotency 2026-07-14-18:55:
|
||||
Existing PostgreSQL databases receive the validator-run lineage uniqueness invariant independently of earlier schema versions. Duplicate historical rows fail the migration visibly instead of being silently discarded.
|
||||
|
||||
FNXC:PostgresConflictResolution 2026-07-14-20:52:
|
||||
Main assigned migration 0008 to session-advisor state before the cutover landed, so mission lineage uniqueness advances to 0009. Both migrations must run in order; sharing a bookkeeping version would silently skip one invariant.
|
||||
*/
|
||||
if (!missionFixIdempotencyAlreadyApplied) {
|
||||
const migrationSql = await readFile(MISSION_FIX_IDEMPOTENCY_MIGRATION_PATH, "utf8");
|
||||
await tx.execute(sql.raw(migrationSql));
|
||||
await tx.execute(
|
||||
sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MISSION_FIX_IDEMPOTENCY_VERSION}) ON CONFLICT (version) DO NOTHING`,
|
||||
);
|
||||
schemaChanged = true;
|
||||
}
|
||||
|
||||
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,4 +27,4 @@ export * as plugin from "./plugin.js";
|
||||
export { projectTableNames } from "./project.js";
|
||||
export { centralTableNames } from "./central.js";
|
||||
export { archiveTableNames } from "./archive.js";
|
||||
export { roadmapPluginTableNames, cePluginTableNames, reportsPluginTableNames, cliPressPluginTableNames } from "./plugin.js";
|
||||
export { roadmapPluginTableNames, cePluginTableNames, evenRealitiesPluginTableNames, reportsPluginTableNames, cliPressPluginTableNames } from "./plugin.js";
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* while still materializing on a fresh database.
|
||||
*/
|
||||
|
||||
import { text, integer, bigint, boolean, foreignKey, index, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { text, integer, bigint, boolean, foreignKey, index, primaryKey, uniqueIndex } from "drizzle-orm/pg-core";
|
||||
import { projectSchema } from "./project.js";
|
||||
|
||||
/**
|
||||
@@ -71,6 +71,23 @@ export const roadmapPluginTableNames = [
|
||||
"roadmap_features",
|
||||
] as const;
|
||||
|
||||
// ── Even Realities plugin tables ───────────────────────────────────
|
||||
/**
|
||||
* FNXC:EvenRealitiesPostgres 2026-07-14-17:25:
|
||||
* Notification dedupe state is durable, project-private plugin data. The PostgreSQL runtime stores one snapshot row per project/task so identical task IDs in separate projects never suppress each other's glasses notifications.
|
||||
*/
|
||||
export const evenRealitiesSeenTasks = projectSchema.table("even_realities_seen_tasks", {
|
||||
projectId: text("project_id").notNull(),
|
||||
taskId: text("task_id").notNull(),
|
||||
lastColumn: text("last_column").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.taskId] }),
|
||||
index("idxEvenRealitiesSeenTasksProjectUpdated").on(t.projectId, t.updatedAt, t.taskId),
|
||||
]);
|
||||
|
||||
export const evenRealitiesPluginTableNames = ["even_realities_seen_tasks"] as const;
|
||||
|
||||
// ── Compound Engineering plugin tables ──────────────────────────────
|
||||
// FNXC:PostgresSchema 2026-07-04-00:00:
|
||||
// Mirror of plugins/fusion-plugin-compound-engineering/src/schema.ts
|
||||
|
||||
@@ -1228,22 +1228,27 @@ export const pullRequestThreadState = projectSchema.table("pull_request_thread_s
|
||||
]);
|
||||
|
||||
export const goals = projectSchema.table("goals", {
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
id: text("id").notNull(),
|
||||
title: text("title").notNull(),
|
||||
description: text("description"),
|
||||
status: text("status").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}, (t) => [index("idxGoalsStatus").on(t.status)]);
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
index("idxGoalsStatus").on(t.status),
|
||||
]);
|
||||
|
||||
export const missionGoals = projectSchema.table("mission_goals", {
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
missionId: text("mission_id").notNull(),
|
||||
goalId: text("goal_id").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.missionId, t.goalId] }),
|
||||
foreignKey({ columns: [t.missionId], foreignColumns: [missions.id] }).onDelete("cascade"),
|
||||
foreignKey({ columns: [t.goalId], foreignColumns: [goals.id] }).onDelete("cascade"),
|
||||
primaryKey({ columns: [t.projectId, t.missionId, t.goalId] }),
|
||||
foreignKey({ columns: [t.projectId, t.missionId], foreignColumns: [missions.projectId, missions.id] }).onDelete("cascade"),
|
||||
foreignKey({ columns: [t.projectId, t.goalId], foreignColumns: [goals.projectId, goals.id] }).onDelete("cascade"),
|
||||
index("idxMissionGoalsGoalId").on(t.goalId),
|
||||
]);
|
||||
|
||||
@@ -1351,7 +1356,8 @@ export const missionFeatures = projectSchema.table("mission_features", {
|
||||
]);
|
||||
|
||||
export const missionEvents = projectSchema.table("mission_events", {
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
id: text("id").notNull(),
|
||||
missionId: text("mission_id").notNull(),
|
||||
eventType: text("event_type").notNull(),
|
||||
description: text("description").notNull(),
|
||||
@@ -1359,7 +1365,8 @@ export const missionEvents = projectSchema.table("mission_events", {
|
||||
timestamp: text("timestamp").notNull(),
|
||||
seq: integer("seq").notNull().default(0),
|
||||
}, (t) => [
|
||||
foreignKey({ columns: [t.missionId], foreignColumns: [missions.id] }).onDelete("cascade"),
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
foreignKey({ columns: [t.projectId, t.missionId], foreignColumns: [missions.projectId, missions.id] }).onDelete("cascade"),
|
||||
index("idxMissionEventsMissionId").on(t.missionId),
|
||||
index("idxMissionEventsTimestamp").on(t.timestamp),
|
||||
index("idxMissionEventsType").on(t.eventType),
|
||||
@@ -1535,9 +1542,12 @@ export const pluginActivations = projectSchema.table("plugin_activations", {
|
||||
|
||||
export const knowledgePages = projectSchema.table("knowledge_pages", {
|
||||
id: integer("id").generatedAlwaysAsIdentity().primaryKey(),
|
||||
// FNXC:KnowledgeIndex 2026-07-14-16:35:
|
||||
// Knowledge pages contain task and PR history, so their Drizzle model must expose the project ownership added by migration 0006. Async dashboard reads and upserts use this key explicitly in addition to the database RLS policy.
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
sourceKind: text("source_kind").notNull(),
|
||||
sourceId: text("source_id").notNull(),
|
||||
sourceKey: text("source_key").notNull().unique(),
|
||||
sourceKey: text("source_key").notNull(),
|
||||
title: text("title").notNull(),
|
||||
summary: text("summary"),
|
||||
content: text("content").notNull(),
|
||||
@@ -1546,6 +1556,7 @@ export const knowledgePages = projectSchema.table("knowledge_pages", {
|
||||
createdAt: text("created_at").notNull(),
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
}, (t) => [
|
||||
uniqueIndex("knowledge_pages_source_key_unique").on(t.projectId, t.sourceKey),
|
||||
index("idxKnowledgePagesSourceKind").on(t.sourceKind),
|
||||
index("idxKnowledgePagesUpdatedAt").on(t.updatedAt),
|
||||
]);
|
||||
@@ -1786,17 +1797,19 @@ export const missionContractAssertions = projectSchema.table("mission_contract_a
|
||||
]);
|
||||
|
||||
export const missionFeatureAssertions = projectSchema.table("mission_feature_assertions", {
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
featureId: text("feature_id").notNull(),
|
||||
assertionId: text("assertion_id").notNull(),
|
||||
createdAt: text("created_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.featureId, t.assertionId] }),
|
||||
primaryKey({ columns: [t.projectId, t.featureId, t.assertionId] }),
|
||||
index("idxFeatureAssertionsFeatureId").on(t.featureId),
|
||||
index("idxFeatureAssertionsAssertionId").on(t.assertionId),
|
||||
]);
|
||||
|
||||
export const missionValidatorRuns = projectSchema.table("mission_validator_runs", {
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
id: text("id").notNull(),
|
||||
featureId: text("feature_id").notNull(),
|
||||
milestoneId: text("milestone_id").notNull(),
|
||||
sliceId: text("slice_id").notNull(),
|
||||
@@ -1812,6 +1825,7 @@ export const missionValidatorRuns = projectSchema.table("mission_validator_runs"
|
||||
updatedAt: text("updated_at").notNull(),
|
||||
taskId: text("task_id"),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
index("idxValidatorRunsFeatureId").on(t.featureId),
|
||||
index("idxValidatorRunsMilestoneId").on(t.milestoneId),
|
||||
index("idxValidatorRunsSliceId").on(t.sliceId),
|
||||
@@ -1819,7 +1833,8 @@ export const missionValidatorRuns = projectSchema.table("mission_validator_runs"
|
||||
]);
|
||||
|
||||
export const missionValidatorFailures = projectSchema.table("mission_validator_failures", {
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
id: text("id").notNull(),
|
||||
runId: text("run_id").notNull(),
|
||||
featureId: text("feature_id").notNull(),
|
||||
assertionId: text("assertion_id").notNull(),
|
||||
@@ -1828,19 +1843,22 @@ export const missionValidatorFailures = projectSchema.table("mission_validator_f
|
||||
actual: text("actual"),
|
||||
createdAt: text("created_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
index("idxValidatorFailuresRunId").on(t.runId),
|
||||
index("idxValidatorFailuresFeatureId").on(t.featureId),
|
||||
index("idxValidatorFailuresAssertionId").on(t.assertionId),
|
||||
]);
|
||||
|
||||
export const missionFixFeatureLineage = projectSchema.table("mission_fix_feature_lineage", {
|
||||
id: text("id").primaryKey(),
|
||||
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
|
||||
id: text("id").notNull(),
|
||||
sourceFeatureId: text("source_feature_id").notNull(),
|
||||
fixFeatureId: text("fix_feature_id").notNull(),
|
||||
runId: text("run_id").notNull(),
|
||||
failedAssertionIds: jsonb("failed_assertion_ids").notNull().default([]),
|
||||
createdAt: text("created_at").notNull(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.projectId, t.id] }),
|
||||
index("idxFixLineageSourceFeatureId").on(t.sourceFeatureId),
|
||||
index("idxFixLineageFixFeatureId").on(t.fixFeatureId),
|
||||
index("idxFixLineageRunId").on(t.runId),
|
||||
|
||||
@@ -773,10 +773,8 @@ function openSqlite(path: string): DatabaseSync {
|
||||
// ":memory:". The migrator is a cutover tool run by operators against a
|
||||
// real .fusion path, so the real-path guard is bypassed only when the path
|
||||
// is explicit. Here we use the standard constructor; tests pass temp paths.
|
||||
const db = new DatabaseSync(path);
|
||||
// Read-only guard: open with immutable so we never modify the source.
|
||||
// (node:sqlite does not have a read-only open flag in the constructor; we
|
||||
// simply never issue writes against the source.)
|
||||
// FNXC:LegacySqliteBoundary 2026-07-14-18:42: the cutover migrator reads legacy sources without checkpointing or modifying them.
|
||||
const db = new DatabaseSync(path, { readOnly: true });
|
||||
return db;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,14 @@
|
||||
* FNXC:RuntimeStartupWiring 2026-06-26-14:00:
|
||||
* This is the single startup entry point that production construction sites
|
||||
* (engine InProcessRuntime, dashboard project-store-resolver, CLI serve/
|
||||
* dashboard commands, desktop local-server/local-runtime) consult to decide
|
||||
* whether to boot against PostgreSQL or fall back to the legacy SQLite path.
|
||||
* dashboard commands, desktop local-server/local-runtime) use to boot
|
||||
* PostgreSQL.
|
||||
*
|
||||
* The factory encapsulates the five-step backend boot sequence so individual
|
||||
* call sites do not each re-implement backend resolution, connection opening,
|
||||
* schema application, AsyncDataLayer construction, and dual-read harness
|
||||
* integration. A call site asks: "given the current environment, do I get a
|
||||
* PostgreSQL-backed TaskStore, or do I keep the SQLite default?" The factory
|
||||
* answers with either a ready {@link BackendBootResult} or `null` (meaning:
|
||||
* use the legacy SQLite construction — byte-identical to the pre-migration
|
||||
* path).
|
||||
* integration. The factory always returns a ready {@link BackendBootResult}
|
||||
* or throws an actionable startup error.
|
||||
*
|
||||
* Resolution rules (matching the mission architecture):
|
||||
* - DATABASE_URL set (external mode): connect to the external PostgreSQL
|
||||
@@ -24,23 +21,19 @@
|
||||
* PostgreSQL, then proceed like external mode against the embedded URL.
|
||||
* This is the DEFAULT production path — embedded PG is the zero-config
|
||||
* backend, mirroring the zero-config SQLite experience it replaces.
|
||||
* - DATABASE_URL unset AND FUSION_NO_EMBEDDED_PG=1: return `null`. The caller
|
||||
* constructs the legacy SQLite-backed TaskStore. This is the explicit
|
||||
* opt-out for the legacy SQLite path, available for backward compatibility
|
||||
* during the cutover window.
|
||||
* - DATABASE_URL unset AND FUSION_NO_EMBEDDED_PG=1: reject the obsolete
|
||||
* configuration. SQLite files are accepted only as migration inputs.
|
||||
*
|
||||
* FNXC:BackendFlip 2026-06-26-14:05:
|
||||
* The default backend was flipped from SQLite to embedded PostgreSQL in this
|
||||
* change (feature flip-embedded-pg-default, cutover milestone). Previously
|
||||
* embedded PG required an explicit opt-in via FUSION_EMBEDDED_PG=1; now it is
|
||||
* the default and FUSION_NO_EMBEDDED_PG=1 is the opt-out back to legacy
|
||||
* SQLite. FUSION_EMBEDDED_PG=1 is still honored as a no-op alias for backward
|
||||
* the default. FUSION_EMBEDDED_PG=1 is still honored as a no-op alias for backward
|
||||
* compatibility (it cannot force embedded when DATABASE_URL is set, since
|
||||
* external mode always wins). The flip is safe because the embedded-postgres
|
||||
* platform binaries are now bundled for macOS/Linux/Windows (arm64/x64) and
|
||||
* the boot smoke has been updated to exercise the embedded path by default
|
||||
* with an initdb-aware health-check timeout. Tests that need the fast SQLite
|
||||
* default (no initdb, no binary) set FUSION_NO_EMBEDDED_PG=1 explicitly.
|
||||
* with an initdb-aware health-check timeout.
|
||||
*/
|
||||
|
||||
import { join, resolve } from "node:path";
|
||||
@@ -58,10 +51,11 @@ import {
|
||||
import {
|
||||
createConnectionSet,
|
||||
createConnectionSetFromUrl,
|
||||
DatabaseConnectionError,
|
||||
type PostgresConnections,
|
||||
} from "./connection.js";
|
||||
import { applySchemaBaseline } from "./schema-applier.js";
|
||||
import { createAsyncDataLayer, type AsyncDataLayer } from "./data-layer.js";
|
||||
import { runLoadedPluginSchemaInitHooks, type LoadedPluginSchemaContract } from "./plugin-schema-hook.js";
|
||||
import {
|
||||
lookupRegisteredProjectIdByPath,
|
||||
rekeyFallbackProjectPartition,
|
||||
@@ -106,12 +100,8 @@ export const EMBEDDED_PG_ENV = "FUSION_EMBEDDED_PG";
|
||||
|
||||
/**
|
||||
* FNXC:BackendFlip 2026-06-26-14:10:
|
||||
* Opt-out environment variable that forces the legacy SQLite backend when
|
||||
* DATABASE_URL is unset. This is the escape hatch for the cutover window:
|
||||
* operators or tests that need the fast, no-binary SQLite default set
|
||||
* FUSION_NO_EMBEDDED_PG=1. Truthy values: 1, true, yes, on (case-insensitive).
|
||||
* Everything else (unset, 0, no, false, off) means "use the embedded PG
|
||||
* default".
|
||||
* Retired SQLite opt-out variable. It remains parseable so startup can return
|
||||
* a clear migration error instead of silently ignoring stale operator config.
|
||||
*/
|
||||
export const NO_EMBEDDED_PG_ENV = "FUSION_NO_EMBEDDED_PG";
|
||||
|
||||
@@ -121,14 +111,11 @@ export const NO_EMBEDDED_PG_ENV = "FUSION_NO_EMBEDDED_PG";
|
||||
*
|
||||
* FNXC:BackendFlip 2026-06-26-14:15:
|
||||
* Post default-flip, embedded PG is the DEFAULT in embedded mode. The legacy
|
||||
* FUSION_EMBEDDED_PG opt-in is now a no-op (setting it does nothing because
|
||||
* embedded is already on). The only way to opt OUT of embedded PG back to
|
||||
* legacy SQLite is FUSION_NO_EMBEDDED_PG=1. This function returns true unless
|
||||
* the opt-out is set.
|
||||
* FUSION_EMBEDDED_PG opt-in is a no-op. A false result identifies obsolete
|
||||
* opt-out configuration that the startup factory rejects.
|
||||
*
|
||||
* The opt-out is honored when set to a truthy value: 1, true, yes, on
|
||||
* (case-insensitive). Everything else (unset, 0, no, false, off) means
|
||||
* "use the embedded PG default" (return true).
|
||||
* A retired opt-out value is detected so startup can reject it with a clear
|
||||
* migration message. Everything else uses embedded PostgreSQL by default.
|
||||
*
|
||||
* @returns true when embedded PG should be used (the default); false when the
|
||||
* operator explicitly opted out via FUSION_NO_EMBEDDED_PG=1.
|
||||
@@ -139,9 +126,7 @@ export function isEmbeddedPgRequested(env: NodeJS.ProcessEnv = process.env): boo
|
||||
|
||||
/**
|
||||
* FNXC:BackendFlip 2026-06-26-14:15:
|
||||
* Return true when the operator has explicitly opted out of embedded PG via
|
||||
* FUSION_NO_EMBEDDED_PG=1 (the legacy SQLite escape hatch). Exposed for test
|
||||
* assertion and call-site cheap checks.
|
||||
* Detect obsolete FUSION_NO_EMBEDDED_PG configuration for diagnostics.
|
||||
*/
|
||||
export function isEmbeddedPgOptedOut(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
const raw = (env[NO_EMBEDDED_PG_ENV] ?? "").trim().toLowerCase();
|
||||
@@ -169,6 +154,158 @@ export interface BackendBootResult {
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
/** PostgreSQL resources used by CentralCore before a project TaskStore exists. */
|
||||
export interface CentralBackendLayerResult {
|
||||
readonly backend: ResolvedBackend;
|
||||
readonly asyncLayer: AsyncDataLayer;
|
||||
releaseConnections(): Promise<void>;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
interface SchemaBackendBootResult {
|
||||
readonly backend: ResolvedBackend;
|
||||
readonly connections: PostgresConnections;
|
||||
readonly embeddedLifecycle: EmbeddedLifecycleLike | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* FNXC:PostgresStartupLifecycle 2026-07-14-19:18:
|
||||
* Central-registry and project-store startup must share one backend-resolution,
|
||||
* embedded-lifecycle, administrative-connection, and schema-baseline path.
|
||||
* Callers retain ownership of the returned resources and may replace the
|
||||
* administrative pool with an RLS-bound runtime pool after migration.
|
||||
*/
|
||||
async function bootSchemaBackend(
|
||||
options: Pick<CreateTaskStoreForBackendOptions, "env" | "backend" | "embeddedPgRequested" | "embeddedDataDir" | "poolMax">,
|
||||
bypassProjectIsolation = false,
|
||||
): Promise<SchemaBackendBootResult> {
|
||||
const env = options.env ?? process.env;
|
||||
const backend = options.backend ?? resolveBackend(env);
|
||||
const embeddedRequested = options.embeddedPgRequested ?? isEmbeddedPgRequested(env);
|
||||
if (backend.mode === "embedded" && !embeddedRequested) {
|
||||
throw new Error(
|
||||
"The SQLite opt-out has been removed. Unset FUSION_NO_EMBEDDED_PG and use embedded PostgreSQL, or configure DATABASE_URL.",
|
||||
);
|
||||
}
|
||||
|
||||
let embeddedLifecycle: EmbeddedLifecycleLike | null = null;
|
||||
let resolvedBackend = backend;
|
||||
if (backend.mode === "embedded") {
|
||||
const { EmbeddedPostgresLifecycle, defaultEmbeddedDataDir, DEFAULT_EMBEDDED_DATABASE } =
|
||||
await import("./embedded-lifecycle.js");
|
||||
const dataDir = resolve(options.embeddedDataDir ?? defaultEmbeddedDataDir());
|
||||
log.log(`startup-factory: starting embedded PostgreSQL (data dir ${dataDir})`);
|
||||
embeddedLifecycle = new EmbeddedPostgresLifecycle({
|
||||
dataDir,
|
||||
database: DEFAULT_EMBEDDED_DATABASE,
|
||||
onLog: (message) => log.log(message),
|
||||
onError: (error) => log.error(String(error)),
|
||||
});
|
||||
try {
|
||||
resolvedBackend = await embeddedLifecycle.start();
|
||||
} catch (error) {
|
||||
await embeddedLifecycle.stop().catch(() => undefined);
|
||||
throw new Error(
|
||||
`startup-factory: failed to start embedded PostgreSQL: ${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.log(describeBackendForLog(resolvedBackend));
|
||||
let connections: PostgresConnections | undefined;
|
||||
try {
|
||||
connections = resolvedBackend.mode === "external"
|
||||
? await createConnectionSet(env, {
|
||||
backend: resolvedBackend,
|
||||
poolMax: options.poolMax,
|
||||
bypassProjectIsolation,
|
||||
})
|
||||
: await createConnectionSetFromUrl(resolvedBackend, {
|
||||
poolMax: options.poolMax,
|
||||
bypassProjectIsolation,
|
||||
});
|
||||
await applySchemaBaseline(connections.migration);
|
||||
return { backend: resolvedBackend, connections, embeddedLifecycle };
|
||||
} catch (error) {
|
||||
await connections?.close().catch(() => undefined);
|
||||
await embeddedLifecycle?.stop().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open an unscoped PostgreSQL layer for the central project/node registry.
|
||||
*
|
||||
* FNXC:CentralPostgresCutover 2026-07-14-17:12:
|
||||
* CentralCore is used by project discovery and node commands before any
|
||||
* project-scoped TaskStore exists. It therefore needs a first-class backend
|
||||
* bootstrap that applies the shared schema without inventing a project root or
|
||||
* constructing a dead SQLite CentralDatabase. Embedded instances are reused by
|
||||
* the lifecycle registry, while each caller owns only its connection pool.
|
||||
*/
|
||||
export async function createCentralBackendLayer(
|
||||
options: Pick<CreateTaskStoreForBackendOptions, "env" | "backend" | "embeddedPgRequested" | "embeddedDataDir" | "poolMax" | "globalSettingsDir"> = {},
|
||||
): Promise<CentralBackendLayerResult> {
|
||||
const boot = await bootSchemaBackend(options, true);
|
||||
const { backend: resolvedBackend, connections, embeddedLifecycle } = boot;
|
||||
try {
|
||||
/*
|
||||
FNXC:CentralPostgresCutover 2026-07-14-19:06:
|
||||
Central-only startup must import and verify fusion-central.db before exposing the registry layer. Project startup is not guaranteed to run first, so deferring this source made legacy projects and nodes appear missing from PostgreSQL-only commands.
|
||||
*/
|
||||
let globalDir = options.globalSettingsDir;
|
||||
if (!globalDir) {
|
||||
const { resolveGlobalDir } = await import("../global-settings.js");
|
||||
globalDir = resolveGlobalDir();
|
||||
}
|
||||
const legacyCentralPath = join(globalDir, "fusion-central.db");
|
||||
const {
|
||||
CENTRAL_SQLITE_MIGRATION_KEY,
|
||||
formatMigrationProgress,
|
||||
isSqliteMigrationComplete,
|
||||
migrateSqliteToPostgres,
|
||||
} = await import("./sqlite-migrator.js");
|
||||
const centralMigrationComplete = await isSqliteMigrationComplete(
|
||||
connections.migration,
|
||||
CENTRAL_SQLITE_MIGRATION_KEY,
|
||||
);
|
||||
if (!centralMigrationComplete && existsSync(legacyCentralPath) && isValidSqliteDatabaseFile(legacyCentralPath)) {
|
||||
const report = await migrateSqliteToPostgres(connections.migration, [{
|
||||
sqlitePath: legacyCentralPath,
|
||||
pgSchema: "central",
|
||||
}], {
|
||||
skipBaseline: true,
|
||||
migrationKey: CENTRAL_SQLITE_MIGRATION_KEY,
|
||||
onProgress: (event) => log.log(`central startup: SQLite migration — ${formatMigrationProgress(event)}`),
|
||||
});
|
||||
const failures = report.tables.filter((table) => !table.skipped && !table.verified);
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`${failures.length} central table(s) failed verification: ${failures.map((table) => table.table).join(", ")}`);
|
||||
}
|
||||
}
|
||||
const asyncLayer = createAsyncDataLayer(connections);
|
||||
let connectionsReleased = false;
|
||||
const releaseConnections = async (): Promise<void> => {
|
||||
if (connectionsReleased) return;
|
||||
connectionsReleased = true;
|
||||
await asyncLayer.close().catch(() => undefined);
|
||||
};
|
||||
return {
|
||||
backend: resolvedBackend,
|
||||
asyncLayer,
|
||||
releaseConnections,
|
||||
async shutdown(): Promise<void> {
|
||||
await releaseConnections();
|
||||
await embeddedLifecycle?.stop().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
await connections.close().catch(() => undefined);
|
||||
await embeddedLifecycle?.stop().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Options for {@link createTaskStoreForBackend}.
|
||||
*/
|
||||
@@ -191,9 +328,8 @@ export interface CreateTaskStoreForBackendOptions {
|
||||
readonly backend?: ResolvedBackend;
|
||||
/**
|
||||
* Override the embedded-PG decision (tests). When omitted, the decision is
|
||||
* read from the environment: embedded PG is on by default unless
|
||||
* FUSION_NO_EMBEDDED_PG=1 is set. Pass `true` to force embedded, `false` to
|
||||
* force the legacy SQLite path.
|
||||
* read from the environment. Pass `true` to force embedded in tests;
|
||||
* `false` exercises the retired-opt-out error path.
|
||||
*/
|
||||
readonly embeddedPgRequested?: boolean;
|
||||
/**
|
||||
@@ -215,32 +351,32 @@ export interface CreateTaskStoreForBackendOptions {
|
||||
|
||||
/**
|
||||
* Decide whether the factory should attempt a PostgreSQL boot for the given
|
||||
* environment. Returns true when DATABASE_URL is set (external) or embedded PG
|
||||
* is the default (DATABASE_URL unset, no opt-out). Returns false only when the
|
||||
* operator explicitly opted out via FUSION_NO_EMBEDDED_PG=1.
|
||||
* environment. PostgreSQL is the only runtime backend, so this compatibility
|
||||
* probe always returns true.
|
||||
*
|
||||
* Exposed so call sites can cheaply check "should I even try PostgreSQL?"
|
||||
* before awaiting the full factory (which opens connections).
|
||||
*/
|
||||
export function shouldUsePostgresBackend(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
opts: { embeddedPgRequested?: boolean } = {},
|
||||
_env: NodeJS.ProcessEnv = process.env,
|
||||
_opts: { embeddedPgRequested?: boolean } = {},
|
||||
): boolean {
|
||||
const backend = resolveBackend(env);
|
||||
if (backend.mode === "external") return true;
|
||||
const embeddedRequested = opts.embeddedPgRequested ?? isEmbeddedPgRequested(env);
|
||||
return embeddedRequested;
|
||||
/*
|
||||
* FNXC:PostgresFinalCutover 2026-07-14-17:08:
|
||||
* PostgreSQL is the only runtime backend after the final migration. Keep this
|
||||
* compatibility probe deterministic so old callers cannot interpret an
|
||||
* obsolete environment flag as permission to construct a SQLite TaskStore.
|
||||
*/
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construct a PostgreSQL-backed TaskStore for the current environment, or
|
||||
* return `null` when the legacy SQLite path should be used.
|
||||
* Construct a PostgreSQL-backed TaskStore for the current environment.
|
||||
*
|
||||
* FNXC:BackendFlip 2026-06-26-14:20:
|
||||
* Post default-flip, the sequence is:
|
||||
* 1. Resolve the backend (external via DATABASE_URL, or embedded when unset).
|
||||
* 2. If embedded mode AND the operator opted out (FUSION_NO_EMBEDDED_PG=1),
|
||||
* return null — caller uses legacy SQLite.
|
||||
* 2. Reject the retired SQLite opt-out in embedded mode.
|
||||
* 3. For external mode: open connections via createConnectionSet.
|
||||
* 4. For embedded mode: start the EmbeddedPostgresLifecycle, then open
|
||||
* connections via createConnectionSetFromUrl with the resolved URL.
|
||||
@@ -255,23 +391,27 @@ export function shouldUsePostgresBackend(
|
||||
* which redacts the password (VAL-CONN-004, VAL-CONN-005). The resolved
|
||||
* backend is logged via describeBackendForLog (password redacted).
|
||||
*
|
||||
* @returns the backend boot result, or `null` to use the legacy SQLite path.
|
||||
* @returns the mandatory PostgreSQL backend boot result.
|
||||
*/
|
||||
export async function createTaskStoreForBackend(
|
||||
options: CreateTaskStoreForBackendOptions,
|
||||
): Promise<BackendBootResult | null> {
|
||||
): Promise<BackendBootResult> {
|
||||
const env = options.env ?? process.env;
|
||||
const backend = options.backend ?? resolveBackend(env);
|
||||
const embeddedRequested = options.embeddedPgRequested ?? isEmbeddedPgRequested(env);
|
||||
|
||||
// FNXC:BackendFlip 2026-06-26-14:25:
|
||||
// Step 2: the ONLY way to reach the legacy SQLite path post default-flip is
|
||||
// the explicit opt-out (FUSION_NO_EMBEDDED_PG=1). When the operator opts out,
|
||||
// `embeddedRequested` is false and we return null so the caller constructs the
|
||||
// legacy SQLite-backed TaskStore. In every other embedded-mode case, embedded
|
||||
// PG is the default and we proceed to boot it.
|
||||
/*
|
||||
* FNXC:PostgresFinalCutover 2026-07-14-17:08:
|
||||
* The SQLite runtime and its Database implementation have been removed, so
|
||||
* the historical opt-out must fail explicitly. Returning null here caused
|
||||
* dozens of callers to construct a non-functional TaskStore and split the
|
||||
* central registry away from PostgreSQL. External DATABASE_URL always wins;
|
||||
* the obsolete flag is only rejected when it would have selected SQLite.
|
||||
*/
|
||||
if (backend.mode === "embedded" && !embeddedRequested) {
|
||||
return null;
|
||||
throw new Error(
|
||||
"The SQLite opt-out has been removed. Unset FUSION_NO_EMBEDDED_PG and use embedded PostgreSQL, or configure DATABASE_URL.",
|
||||
);
|
||||
}
|
||||
|
||||
// When constructing via the constructor (no projectId), rootDir is required.
|
||||
@@ -282,89 +422,16 @@ export async function createTaskStoreForBackend(
|
||||
}
|
||||
const rootDir = options.rootDir ?? "";
|
||||
|
||||
let embeddedLifecycle: EmbeddedLifecycleLike | null = null;
|
||||
let resolvedBackend: ResolvedBackend = backend;
|
||||
|
||||
// Step 4: embedded mode — start the bundled PostgreSQL first so we have a
|
||||
// connection URL. createConnectionSet throws in embedded mode without a URL.
|
||||
//
|
||||
// FNXC:BackendFlip 2026-06-26-14:25:
|
||||
// This branch now runs by default in embedded mode (DATABASE_URL unset)
|
||||
// unless the operator opted out. The embedded-lifecycle module is imported
|
||||
// LAZILY here (see the note at the top of the file) so the `embedded-postgres`
|
||||
// package and its platform-specific dynamic imports stay out of the CLI
|
||||
// bundle unless embedded PG is actually used at runtime.
|
||||
if (backend.mode === "embedded" && embeddedRequested) {
|
||||
const { EmbeddedPostgresLifecycle, defaultEmbeddedDataDir, DEFAULT_EMBEDDED_DATABASE } =
|
||||
await import("./embedded-lifecycle.js");
|
||||
const dataDir = resolve(options.embeddedDataDir ?? defaultEmbeddedDataDir());
|
||||
log.log(`startup-factory: starting embedded PostgreSQL (data dir ${dataDir})`);
|
||||
embeddedLifecycle = new EmbeddedPostgresLifecycle({
|
||||
dataDir,
|
||||
database: DEFAULT_EMBEDDED_DATABASE,
|
||||
onLog: (msg) => log.log(msg),
|
||||
onError: (err) => log.error(String(err)),
|
||||
});
|
||||
try {
|
||||
resolvedBackend = await embeddedLifecycle.start();
|
||||
} catch (err) {
|
||||
// FNXC:BackendFlip 2026-06-26-14:25:
|
||||
// Embedded startup failure is fatal — embedded PG is the default and the
|
||||
// operator did not opt out. Surface a clear error rather than silently
|
||||
// falling back to SQLite (which would mask a real binary/environment
|
||||
// problem and could split-write two backends).
|
||||
await embeddedLifecycle.stop().catch(() => undefined);
|
||||
throw new Error(
|
||||
`startup-factory: failed to start embedded PostgreSQL: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log.log(describeBackendForLog(resolvedBackend));
|
||||
|
||||
// Steps 3 & 4 (connection opening). External mode uses createConnectionSet
|
||||
// (resolves from env); embedded mode uses createConnectionSetFromUrl with
|
||||
// the lifecycle-provided URL.
|
||||
let connections;
|
||||
let boot: SchemaBackendBootResult;
|
||||
try {
|
||||
if (resolvedBackend.mode === "external") {
|
||||
connections = await createConnectionSet(env, {
|
||||
backend: resolvedBackend,
|
||||
poolMax: options.poolMax,
|
||||
});
|
||||
} else {
|
||||
connections = await createConnectionSetFromUrl(resolvedBackend, {
|
||||
poolMax: options.poolMax,
|
||||
});
|
||||
}
|
||||
boot = await bootSchemaBackend(options);
|
||||
} catch (err) {
|
||||
// VAL-CONN-004: unreachable DATABASE_URL fails loudly. If we started an
|
||||
// embedded cluster, stop it before propagating.
|
||||
if (embeddedLifecycle) {
|
||||
await embeddedLifecycle.stop().catch(() => undefined);
|
||||
}
|
||||
if (err instanceof DatabaseConnectionError) {
|
||||
throw err;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Step 5: apply the schema baseline (idempotent) to the migration connection.
|
||||
try {
|
||||
await applySchemaBaseline(connections.migration);
|
||||
} catch (err) {
|
||||
await connections.close().catch(() => undefined);
|
||||
if (embeddedLifecycle) {
|
||||
await embeddedLifecycle.stop().catch(() => undefined);
|
||||
}
|
||||
throw new Error(
|
||||
`startup-factory: failed to apply PostgreSQL schema baseline: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
`startup-factory: failed to initialize PostgreSQL schema backend: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
let { connections } = boot;
|
||||
const { backend: resolvedBackend, embeddedLifecycle } = boot;
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigration 2026-07-10:
|
||||
@@ -416,7 +483,8 @@ export async function createTaskStoreForBackend(
|
||||
const legacyCentralPath = globalDir ? join(globalDir, "fusion-central.db") : undefined;
|
||||
if (!migrationProjectId && legacyCentralPath && existsSync(legacyCentralPath) && isValidSqliteDatabaseFile(legacyCentralPath)) {
|
||||
const { DatabaseSync } = await import("../sqlite-adapter.js");
|
||||
const legacyCentral = new DatabaseSync(legacyCentralPath);
|
||||
// FNXC:LegacySqliteBoundary 2026-07-14-18:42: central identity lookup is migration-only and read-only.
|
||||
const legacyCentral = new DatabaseSync(legacyCentralPath, { readOnly: true });
|
||||
try {
|
||||
const row = legacyCentral.prepare(`SELECT id FROM projects WHERE path = ? LIMIT 1`).get(rootDir) as
|
||||
| { id: string }
|
||||
@@ -452,15 +520,11 @@ export async function createTaskStoreForBackend(
|
||||
instead surface through scoped post-copy verification and fail closed.
|
||||
Without a registered identity the legacy whole-table check applies.
|
||||
|
||||
FNXC:PostgresCutover 2026-07-13-20:50:
|
||||
FNXC:PostgresCutover 2026-07-14-18:42:
|
||||
Order matters: the PostgreSQL emptiness count runs BEFORE the SQLite
|
||||
validity probe. isValidSqliteDatabaseFile opens the file with a
|
||||
read-write DatabaseSync, and that open/close performs WAL recovery and
|
||||
a checkpoint — i.e. it WRITES to the legacy fusion.db on every boot.
|
||||
Post-cutover the legacy files must stay byte-quiet: steady-state boots
|
||||
(PG already populated) must not open SQLite at all. The probe now runs
|
||||
only on the rare empty-PG path where auto-migration is actually being
|
||||
considered.
|
||||
validity probe. The probe is read-only, and steady-state boots (PG
|
||||
already populated) still avoid opening SQLite entirely. It runs only
|
||||
on the empty-PG path where one-time auto-migration is considered.
|
||||
*/
|
||||
const migrationKey = `project:${migrationProjectId ?? rootDir}`;
|
||||
const { migrateSqliteToPostgres, defaultMigrationSources, formatMigrationProgress, isSqliteMigrationComplete, completeSqliteMigration, recordSqliteMigrationComplete, CENTRAL_SQLITE_MIGRATION_KEY } = await import("./sqlite-migrator.js");
|
||||
@@ -576,7 +640,7 @@ export async function createTaskStoreForBackend(
|
||||
await embeddedLifecycle.stop().catch(() => undefined);
|
||||
}
|
||||
throw new Error(
|
||||
`startup-factory: SQLite → PostgreSQL first-boot auto-migration failed (refusing to boot an empty database over existing SQLite data; run 'fn db migrate' manually or set FUSION_NO_EMBEDDED_PG=1 to stay on SQLite): ${
|
||||
`startup-factory: SQLite → PostgreSQL first-boot auto-migration failed (refusing to boot an empty database over existing SQLite data; restore the retained backup and run 'fn db migrate' manually): ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
}`,
|
||||
);
|
||||
@@ -670,6 +734,30 @@ export async function createTaskStoreForBackend(
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PluginPostgresContract 2026-07-14-18:32:
|
||||
Plugin DDL uses a one-shot administrative pool created only after a validated
|
||||
declarative plan arrives. The TaskStore holds the executor capability, not
|
||||
the connection, and PluginContext exposes neither one to runtime hooks.
|
||||
*/
|
||||
taskStore.setPluginPostgresSchemaExecutor(async (contracts: readonly LoadedPluginSchemaContract[]) => {
|
||||
const schemaConnections = resolvedBackend.mode === "external"
|
||||
? await createConnectionSet(env, {
|
||||
backend: resolvedBackend,
|
||||
poolMax: 1,
|
||||
bypassProjectIsolation: true,
|
||||
})
|
||||
: await createConnectionSetFromUrl(resolvedBackend, {
|
||||
poolMax: 1,
|
||||
bypassProjectIsolation: true,
|
||||
});
|
||||
try {
|
||||
await runLoadedPluginSchemaInitHooks(schemaConnections.migration, contracts);
|
||||
} finally {
|
||||
await schemaConnections.close();
|
||||
}
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PostgresMigrationBanner 2026-07-12:
|
||||
Step 7.5 — persist the auto-migration notice into project settings so the
|
||||
|
||||
Reference in New Issue
Block a user