## Summary Speeds up **time-to-HTTP-ready** for `fn dashboard` and `fn serve` after the PostgreSQL cutover without reintroducing the historical 3s cwd-engine race that degraded webhooks. - **Dashboard store share (serve parity):** inject the factory-booted `TaskStore` as `externalTaskStore` so cwd `ensureEngine` does not open a second pool; share only when store root matches project working directory (multi-project safe). - **Serve multi-project:** stop awaiting `startAll()` before listen; await only the primary engine; background the rest + reconciliation. - **Defer non-route-critical engine work:** ordered OAuth (refresh → monitor), automation schedule syncs, and auto-merge **enqueue** after the engine handle is returnable. - **Critical-path merge status clear:** still clear stale `merging`/`merging-pr` before ready so manual merge is not blocked after crash. - **Serve `--paused`:** apply `enginePaused` before `ensureEngine`/`startAll` (dashboard ordering). - **Stop safety:** generation counter so deferred tails cannot resume after `stop()` clears `shuttingDown`. - **Phase timing:** shared `phaseTime` helper, factory substep logs, serve time-to-listen. Plan: `docs/plans/2026-07-14-001-feat-faster-startup-plan.md` ## Test plan - [x] `packages/engine` — `project-engine-manager.test.ts` (path-matched external store) - [x] `packages/engine` — `project-engine-deferred-startup.test.ts` (status clear, OAuth order, stop generation) - [x] `packages/cli` — `startup-phase.test.ts` - [x] `packages/cli` — `serve.test.ts` (60 tests, including `--paused`) - [ ] Local: warm `fn dashboard` / `fn serve` and compare `startup phase *` / `time-to-listen` logs - [ ] `pnpm smoke:boot` (real serve `/api/health` on ephemeral port) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Performance** * Improved dashboard and serve startup times, including faster time-to-listen and time-to-ready. * Moved non-essential background initialization off the critical startup path. * Parallelized dashboard service initialization where possible. * **Reliability** * Improved multi-project startup handling and project selection. * Prevented cross-project task-store sharing. * Added safer shutdown behavior for partially completed startup. * **Diagnostics** * Added startup phase timing logs to help identify performance bottlenecks. * **Tests** * Expanded coverage for deferred startup, shutdown, project isolation, and startup timing. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
837 lines
39 KiB
TypeScript
837 lines
39 KiB
TypeScript
/**
|
|
* Runtime startup factory: construct a PostgreSQL-backed TaskStore.
|
|
*
|
|
* 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) 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. 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
|
|
* server, apply the schema baseline, construct the AsyncDataLayer. Returns
|
|
* a backend boot result.
|
|
* - DATABASE_URL unset (embedded mode): start the bundled embedded
|
|
* 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: 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. 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.
|
|
*/
|
|
|
|
import { join, resolve } from "node:path";
|
|
import { existsSync } from "node:fs";
|
|
import { createHash } from "node:crypto";
|
|
import { sql } from "drizzle-orm";
|
|
import { isValidSqliteDatabaseFile } from "../sqlite-validation.js";
|
|
import { createLogger } from "../logger.js";
|
|
import { TaskStore } from "../store.js";
|
|
import {
|
|
resolveBackend,
|
|
describeBackendForLog,
|
|
type ResolvedBackend,
|
|
} from "./backend-resolver.js";
|
|
import {
|
|
createConnectionSet,
|
|
createConnectionSetFromUrl,
|
|
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,
|
|
stampMigratedProjectRows,
|
|
} from "./migration-stamping.js";
|
|
|
|
// FNXC:RuntimeStartupWiring 2026-06-24-10:55:
|
|
// The embedded PostgreSQL lifecycle module imports the `embedded-postgres`
|
|
// package, which uses dynamic import() for platform-specific binaries
|
|
// (@embedded-postgres/linux-x64, etc.). Importing it statically would pull
|
|
// those unresolved dynamic imports into the CLI bundle (tsup/esbuild bundles
|
|
// @fusion/* with noExternal), breaking the build on platforms whose optional
|
|
// binary is absent. The embedded lifecycle is therefore loaded LAZILY via
|
|
// await import() only when embedded PG is actually used at runtime
|
|
// (DATABASE_URL unset AND FUSION_NO_EMBEDDED_PG not set — the default since
|
|
// the flip-embedded-pg-default change). The external (DATABASE_URL) and
|
|
// legacy SQLite-opt-out paths never touch it.
|
|
type EmbeddedLifecycleLike = {
|
|
start(): Promise<ResolvedBackend>;
|
|
stop(): Promise<void>;
|
|
};
|
|
|
|
const log = createLogger("startup-factory");
|
|
|
|
/**
|
|
* FNXC:ProjectDataIsolation 2026-07-14-12:10:
|
|
* An unregistered project still needs a stable, non-shared PostgreSQL partition. Derive a deterministic identity from its canonical root path so first-boot migration and every later runtime session select the same isolated rows without inventing cross-project ownership.
|
|
*/
|
|
function fallbackProjectIdForRoot(rootDir: string): string {
|
|
return `local-${createHash("sha256").update(resolve(rootDir)).digest("hex").slice(0, 24)}`;
|
|
}
|
|
|
|
/**
|
|
* FNXC:BackendFlip 2026-06-26-14:10:
|
|
* Legacy opt-in environment variable for the bundled embedded PostgreSQL.
|
|
* Since the default-flip (flip-embedded-pg-default), embedded PG is on by
|
|
* default when DATABASE_URL is unset, so this variable is now a no-op alias
|
|
* kept for backward compatibility with scripts/docs that still set it. It
|
|
* cannot force embedded mode when DATABASE_URL is set (external always wins).
|
|
*/
|
|
export const EMBEDDED_PG_ENV = "FUSION_EMBEDDED_PG";
|
|
|
|
/**
|
|
* FNXC:BackendFlip 2026-06-26-14:10:
|
|
* 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";
|
|
|
|
/**
|
|
* Return true when the embedded PostgreSQL backend should be used in embedded
|
|
* mode (DATABASE_URL unset).
|
|
*
|
|
* 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 a no-op. A false result identifies obsolete
|
|
* opt-out configuration that the startup factory rejects.
|
|
*
|
|
* 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.
|
|
*/
|
|
export function isEmbeddedPgRequested(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
return !isEmbeddedPgOptedOut(env);
|
|
}
|
|
|
|
/**
|
|
* FNXC:BackendFlip 2026-06-26-14:15:
|
|
* 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();
|
|
return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
|
|
}
|
|
|
|
/**
|
|
* The result of a successful PostgreSQL backend boot. The caller uses
|
|
* `.taskStore` as the runtime store and must call `.shutdown()` during
|
|
* process teardown to release the connection pool and (if started) stop the
|
|
* embedded PostgreSQL process.
|
|
*/
|
|
export interface BackendBootResult {
|
|
/** The PostgreSQL-backed TaskStore (constructed with an AsyncDataLayer). */
|
|
readonly taskStore: TaskStore;
|
|
/** The resolved backend descriptor (embedded or external). */
|
|
readonly backend: ResolvedBackend;
|
|
/** The constructed AsyncDataLayer (also reachable via taskStore.getAsyncLayer()). */
|
|
readonly asyncLayer: AsyncDataLayer;
|
|
/**
|
|
* Release all backend resources: close the TaskStore (which closes the
|
|
* AsyncDataLayer / connection pool) and stop the embedded PostgreSQL
|
|
* process if one was started. Best-effort; errors are logged, not thrown.
|
|
*/
|
|
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}.
|
|
*/
|
|
export interface CreateTaskStoreForBackendOptions {
|
|
/**
|
|
* The project working directory (rootDir) the TaskStore is scoped to. This
|
|
* is the same value a legacy `new TaskStore(rootDir)` call would receive.
|
|
* Required when `projectId` is omitted; ignored when `projectId` is set
|
|
* (the project context is resolved from the central registry instead).
|
|
*/
|
|
readonly rootDir?: string;
|
|
/** Optional global settings directory (forwarded to the TaskStore constructor). */
|
|
readonly globalSettingsDir?: string;
|
|
/** Environment record (defaults to process.env). */
|
|
readonly env?: NodeJS.ProcessEnv;
|
|
/**
|
|
* Override the resolved backend (tests). When omitted, the backend is
|
|
* resolved from the environment via resolveBackend().
|
|
*/
|
|
readonly backend?: ResolvedBackend;
|
|
/**
|
|
* Override the embedded-PG decision (tests). When omitted, the decision is
|
|
* read from the environment. Pass `true` to force embedded in tests;
|
|
* `false` exercises the retired-opt-out error path.
|
|
*/
|
|
readonly embeddedPgRequested?: boolean;
|
|
/**
|
|
* Override the embedded data directory (tests). Defaults to
|
|
* defaultEmbeddedDataDir().
|
|
*/
|
|
readonly embeddedDataDir?: string;
|
|
/**
|
|
* Connection pool sizing override (forwarded to createConnectionSet).
|
|
*/
|
|
readonly poolMax?: number;
|
|
/**
|
|
* The project ID, forwarded to TaskStore.getOrCreateForProject when set.
|
|
* When omitted, the factory constructs the TaskStore directly via the
|
|
* constructor (matching `new TaskStore(rootDir)`).
|
|
*/
|
|
readonly projectId?: string;
|
|
}
|
|
|
|
/**
|
|
* Decide whether the factory should attempt a PostgreSQL boot for the given
|
|
* 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 } = {},
|
|
): boolean {
|
|
/*
|
|
* 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.
|
|
*
|
|
* 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. 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.
|
|
* 5. Apply the schema baseline to the migration connection (idempotent).
|
|
* 6. Construct the AsyncDataLayer from the connection set.
|
|
* 7. Construct the TaskStore with the AsyncDataLayer (backend mode).
|
|
* 8. Integrate the dual-read harness when FUSION_DUAL_READ=1. The harness
|
|
* is held by the result's shutdown path; the runtime-*-async features
|
|
* consult it for write routing.
|
|
*
|
|
* Credential safety: connection errors are wrapped in DatabaseConnectionError
|
|
* which redacts the password (VAL-CONN-004, VAL-CONN-005). The resolved
|
|
* backend is logged via describeBackendForLog (password redacted).
|
|
*
|
|
* @returns the mandatory PostgreSQL backend boot result.
|
|
*/
|
|
export async function createTaskStoreForBackend(
|
|
options: CreateTaskStoreForBackendOptions,
|
|
): Promise<BackendBootResult> {
|
|
const env = options.env ?? process.env;
|
|
const backend = options.backend ?? resolveBackend(env);
|
|
const embeddedRequested = options.embeddedPgRequested ?? isEmbeddedPgRequested(env);
|
|
|
|
/*
|
|
* 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) {
|
|
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.
|
|
if (!options.projectId && !options.rootDir) {
|
|
throw new Error(
|
|
"createTaskStoreForBackend: rootDir is required when projectId is not provided",
|
|
);
|
|
}
|
|
const rootDir = options.rootDir ?? "";
|
|
|
|
/*
|
|
FNXC:FasterStartup 2026-07-14-23:55:
|
|
Factory substep timings feed CLI phase logs so operators can separate embedded
|
|
PG start + schema work from engine bring-up. Cheap permanent diagnostics.
|
|
*/
|
|
const factoryT0 = Date.now();
|
|
let boot: SchemaBackendBootResult;
|
|
try {
|
|
const schemaT0 = Date.now();
|
|
boot = await bootSchemaBackend(options);
|
|
log.log(`startup phase backend.schemaBackend: ${Date.now() - schemaT0}ms`);
|
|
} catch (err) {
|
|
throw new Error(
|
|
`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:
|
|
Step 5.5 — first-boot auto-migration from legacy SQLite. The pre-flip upgrade
|
|
contract ("auto-migrate + keep the SQLite file as a backup") was documented
|
|
below but never wired: an existing SQLite instance switched to the PG backend
|
|
booted an EMPTY database with its data silently stranded in .fusion/fusion.db
|
|
(the review-flagged data-loss trap). Guarded to run at most once per
|
|
database: only when a valid legacy fusion.db exists at the project root AND
|
|
the PG project.tasks table is still empty. Failure is LOUD (boot aborts) —
|
|
silently continuing on an empty database is exactly the trap this exists to
|
|
close. `fn db migrate` remains the manual/explicit path (dry-run, external
|
|
URLs, partial sources).
|
|
*/
|
|
// FNXC:PostgresMigrationBanner 2026-07-12: populated when Step 5.5 actually
|
|
// migrated data; persisted into project settings after Step 7.
|
|
let autoMigrationNotice:
|
|
| { migratedAt: string; migratedRows: number; tables: number; sqliteBackups: string[] }
|
|
| undefined;
|
|
/*
|
|
FNXC:CentralProjectIdentity 2026-07-13-22:00:
|
|
Resolve the central-registry project id for a rootDir-booted store. Post
|
|
de-cwd architecture: cwd/rootDir is ONLY a lookup key into central.projects;
|
|
project IDENTITY (the partition key every task/config read and write is
|
|
scoped by) comes from the registry. Before this, `fn dashboard` / `fn serve`
|
|
booted their main store UNBOUND (rootDir only), so unscoped API requests
|
|
read and wrote NULL-project_id rows on the shared embedded cluster while the
|
|
projectId-bound engine could not see them. Returns undefined when the path
|
|
is not registered (legacy/unregistered single-project setups stay unbound,
|
|
matching their unfiltered readers).
|
|
*/
|
|
if (rootDir) {
|
|
try {
|
|
const fusionDir = join(rootDir, ".fusion");
|
|
const legacySqlitePath = join(fusionDir, "fusion.db");
|
|
if (existsSync(legacySqlitePath)) {
|
|
let globalDir = options.globalSettingsDir;
|
|
if (!globalDir) {
|
|
try {
|
|
const { resolveGlobalDir } = await import("../global-settings.js");
|
|
globalDir = resolveGlobalDir();
|
|
} catch {
|
|
globalDir = undefined;
|
|
}
|
|
}
|
|
|
|
let migrationProjectId = options.projectId
|
|
?? (await lookupRegisteredProjectIdByPath(connections.migration, rootDir));
|
|
const legacyCentralPath = globalDir ? join(globalDir, "fusion-central.db") : undefined;
|
|
if (!migrationProjectId && legacyCentralPath && existsSync(legacyCentralPath) && isValidSqliteDatabaseFile(legacyCentralPath)) {
|
|
const { DatabaseSync } = await import("../sqlite-adapter.js");
|
|
// 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 }
|
|
| undefined;
|
|
migrationProjectId = row?.id;
|
|
} catch {
|
|
// A pre-registry central database leaves legacy single-project startup unbound.
|
|
} finally {
|
|
legacyCentral.close();
|
|
}
|
|
}
|
|
const fallbackProjectId = fallbackProjectIdForRoot(rootDir);
|
|
migrationProjectId ??= fallbackProjectId;
|
|
if (migrationProjectId !== fallbackProjectId) {
|
|
await rekeyFallbackProjectPartition(
|
|
connections.migration,
|
|
fallbackProjectId,
|
|
migrationProjectId,
|
|
);
|
|
}
|
|
/*
|
|
FNXC:MultiProjectIsolation 2026-07-11:
|
|
With per-project task partitioning (project_id on project.tasks), the
|
|
first-boot emptiness check must be scoped to THIS project — otherwise
|
|
the second project booting against the shared embedded cluster sees the
|
|
first project's rows and silently skips migrating its own legacy
|
|
fusion.db (the exact data-loss trap Step 5.5 exists to close).
|
|
|
|
FNXC:MultiProjectMigration 2026-07-13-22:37:
|
|
Resolve identity from PostgreSQL or the legacy central registry before
|
|
the emptiness check, then count only that partition. NULL or another
|
|
project's rows cannot suppress this migration; global-key collisions
|
|
instead surface through scoped post-copy verification and fail closed.
|
|
Without a registered identity the legacy whole-table check applies.
|
|
|
|
FNXC:PostgresCutover 2026-07-14-18:42:
|
|
Order matters: the PostgreSQL emptiness count runs BEFORE the SQLite
|
|
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, migrateLegacyProjectPluginRows, defaultMigrationSources, formatMigrationProgress, isSqliteMigrationComplete, completeSqliteMigration, recordSqliteMigrationComplete, CENTRAL_SQLITE_MIGRATION_KEY } = await import("./sqlite-migrator.js");
|
|
const migrationComplete = await isSqliteMigrationComplete(connections.migration, migrationKey);
|
|
if (!migrationComplete && isValidSqliteDatabaseFile(legacySqlitePath)) {
|
|
// The central (global-dir) source is optional: when no global dir is
|
|
// resolvable (e.g. tests without an explicit dir), migrate only the
|
|
// project-local sources rather than failing the boot.
|
|
/*
|
|
FNXC:PostgresMultiProjectCutover 2026-07-14-11:18:
|
|
The central SQLite database is cluster-global, not a per-project source. Migrate and verify it once, then exclude it from later registered-project cutovers so mutable global rows are not compared with each project's accumulated PostgreSQL state.
|
|
*/
|
|
const centralMigrationComplete = await isSqliteMigrationComplete(
|
|
connections.migration, CENTRAL_SQLITE_MIGRATION_KEY,
|
|
);
|
|
const sources = defaultMigrationSources(fusionDir, globalDir ?? join(fusionDir, "__no-global-dir__"))
|
|
.filter((source) => !centralMigrationComplete || source.pgSchema !== "central")
|
|
.filter((source) => existsSync(source.sqlitePath) && isValidSqliteDatabaseFile(source.sqlitePath));
|
|
if (sources.length > 0) {
|
|
log.log(`startup-factory: empty PostgreSQL database with legacy SQLite data present — auto-migrating ${sources.length} source(s) (SQLite files are kept as backups)`);
|
|
const report = await migrateSqliteToPostgres(connections.migration, sources, {
|
|
skipBaseline: true,
|
|
projectId: migrationProjectId,
|
|
projectPath: rootDir,
|
|
migrationKey,
|
|
deferCompletion: true,
|
|
/*
|
|
FNXC:CliMigrationProgress 2026-07-14-13:47:
|
|
First-boot migration can copy hundreds of thousands of rows. Forward structured phase, table, quarter-copy, and terminal events to the CLI logger so an operator sees forward progress and an explicit rollback instead of a silent startup wait.
|
|
*/
|
|
onProgress: (event) => {
|
|
log.log(`startup-factory: SQLite migration — ${formatMigrationProgress(event)}`);
|
|
},
|
|
});
|
|
/*
|
|
FNXC:PostgresMigrationVerification 2026-07-13-22:37:
|
|
Startup may advertise and bind a migrated database only after every non-disposable source table passes row-count and content verification. Fail before project stamping and before the migration notice so conflicts and unmapped operator tables remain diagnosable rather than becoming a false successful cutover.
|
|
*/
|
|
const failedTables = report.tables.filter((table) => !table.skipped && !table.verified);
|
|
if (failedTables.length > 0) {
|
|
const failures = failedTables
|
|
.map((table) => `${table.schema}.${table.table} (${table.skipReason ?? `source=${table.sourceRows}, target=${table.targetRows}`})`)
|
|
.join(", ");
|
|
throw new Error(`${failedTables.length} table(s) failed verification: ${failures}`);
|
|
}
|
|
const migratedRows = report.tables.reduce((sum, table) => sum + table.insertedRows, 0);
|
|
/*
|
|
FNXC:MultiProjectIsolation 2026-07-11:
|
|
The SQLite migrator predates partitioning and leaves project_id
|
|
NULL — rows the strict taskProjectScope filter (project_id = $bound)
|
|
would never surface, so the scheduler/board would show an empty
|
|
project right after a "successful" migration. Stamp the
|
|
just-migrated rows with the booting project's id. Safe because the
|
|
scoped emptiness check above guarantees every NULL-project_id row
|
|
in tasks/archived_tasks was written by THIS migration pass.
|
|
*/
|
|
/*
|
|
FNXC:MultiProjectIsolation 2026-07-13-21:20:
|
|
The stamping id must also be derivable WITHOUT options.projectId.
|
|
The main cutover path — `fn dashboard` in the project directory —
|
|
boots with rootDir only, so the previous `if (options.projectId)`
|
|
guard skipped stamping on exactly the boot that performs most
|
|
real-world migrations. The rows stayed NULL, every project-bound
|
|
reader (engine InProcessRuntime, dashboard project-store-resolver)
|
|
filtered them out, and the board showed no tasks right after a
|
|
successful migration. When no projectId is bound, resolve it from
|
|
the just-migrated central registry by matching the registered
|
|
project path to this rootDir. If the project was never registered
|
|
centrally, leave rows NULL — readers for unregistered
|
|
single-project setups use an unbound layer with no scope filter.
|
|
*/
|
|
const stampProjectId = migrationProjectId
|
|
?? (await lookupRegisteredProjectIdByPath(connections.migration, rootDir));
|
|
if (stampProjectId) {
|
|
/*
|
|
FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
|
The stamping DML (tasks/archived_tasks NULL→id, config ''→id, and
|
|
the workflow_settings/workflow_prompt_overrides rootDir-key→id
|
|
re-key) is shared with `fn db migrate` via
|
|
stampMigratedProjectRows. rootDir is the pre-isolation key for the
|
|
workflow tables, so it is passed alongside the stamp id.
|
|
*/
|
|
await stampMigratedProjectRows(connections.migration, {
|
|
projectId: stampProjectId,
|
|
rootDir,
|
|
});
|
|
}
|
|
await completeSqliteMigration(connections.migration, migrationKey);
|
|
if (sources.some((source) => source.pgSchema === "central")) {
|
|
await recordSqliteMigrationComplete(
|
|
connections.migration, CENTRAL_SQLITE_MIGRATION_KEY, stampProjectId,
|
|
);
|
|
}
|
|
/*
|
|
FNXC:PostgresMigrationBanner 2026-07-12:
|
|
Remember the successful auto-migration so the dashboard can show a
|
|
one-time "your data was migrated and a backup exists" banner. The
|
|
notice is persisted into project settings AFTER the TaskStore is
|
|
constructed (the settings write needs the async layer).
|
|
*/
|
|
autoMigrationNotice = {
|
|
migratedAt: new Date().toISOString(),
|
|
migratedRows,
|
|
tables: report.tables.length,
|
|
sqliteBackups: sources.map((source) => source.sqlitePath),
|
|
};
|
|
log.log(`startup-factory: SQLite → PostgreSQL auto-migration complete (${migratedRows} row(s) across ${report.tables.length} table(s))`);
|
|
}
|
|
}
|
|
/*
|
|
FNXC:PluginLegacyMigration 2026-07-15-02:09:
|
|
The retained-SQLite plugin bridge requires schema-marker and central plugin writes, so steady-state startup must run it through the privileged migration connection before that connection is replaced by the project-scoped fusion_runtime role. This bridge remains independently marker-gated because projects that completed the core cutover before plugin migration existed still need their plugin state recovered; every runtime surface receives the already-migrated store and PluginStore.init stays DDL-free.
|
|
*/
|
|
if (isValidSqliteDatabaseFile(legacySqlitePath)) {
|
|
await migrateLegacyProjectPluginRows(
|
|
connections.migration,
|
|
legacySqlitePath,
|
|
rootDir,
|
|
);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
await connections.close().catch(() => undefined);
|
|
if (embeddedLifecycle) {
|
|
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; restore the retained backup and run 'fn db migrate' manually): ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Step 6: construct the AsyncDataLayer.
|
|
// FNXC:MultiProjectIsolation 2026-07-10:
|
|
// Bind the layer to this project so the task-store helpers scope every
|
|
// read/claim/insert on the shared embedded-PG `project.tasks` table to a
|
|
// single project. options.projectId is the central-registry ID both the
|
|
// dashboard (getOrCreateProjectStore) and the engine (InProcessRuntime) pass,
|
|
// so a task's row is stamped and filtered under one consistent partition key.
|
|
/*
|
|
FNXC:CentralProjectIdentity 2026-07-13-22:00:
|
|
rootDir-only boots (fn dashboard / fn serve / desktop / per-path project
|
|
stores) now ALSO bind: when the rootDir is a centrally-registered project,
|
|
its registry id becomes the layer's partition key. cwd/rootDir is only the
|
|
lookup key; identity comes from central.projects. Runs after Step 5.5 so a
|
|
first boot resolves against the registry the migration just populated.
|
|
Unregistered paths resolve to undefined and boot unbound, preserving legacy
|
|
single-project behavior.
|
|
*/
|
|
const resolvedProjectId = options.projectId
|
|
?? (rootDir
|
|
? (await lookupRegisteredProjectIdByPath(connections.migration, rootDir))
|
|
?? fallbackProjectIdForRoot(rootDir)
|
|
: undefined);
|
|
|
|
if (rootDir && resolvedProjectId) {
|
|
await rekeyFallbackProjectPartition(
|
|
connections.migration,
|
|
fallbackProjectIdForRoot(rootDir),
|
|
resolvedProjectId,
|
|
);
|
|
}
|
|
|
|
/*
|
|
FNXC:ProjectDataIsolation 2026-07-14-12:10:
|
|
Schema application and SQLite copy require an administrative connection, but application stores must never inherit that bypass. Replace the bootstrap pools with project-bound sessions before constructing any store so every agent and satellite query is constrained by forced RLS.
|
|
*/
|
|
if (resolvedProjectId) {
|
|
const runtimeRoleRows = (await connections.migration.execute(sql`
|
|
SELECT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'fusion_runtime')
|
|
AND pg_has_role(current_user, 'fusion_runtime', 'MEMBER') AS usable
|
|
`)) as unknown as Array<{ usable: boolean }>;
|
|
await connections.close();
|
|
connections = await createConnectionSetFromUrl(resolvedBackend, {
|
|
poolMax: options.poolMax,
|
|
projectId: resolvedProjectId,
|
|
useRuntimeRole: runtimeRoleRows[0]?.usable === true,
|
|
});
|
|
}
|
|
const asyncLayer = createAsyncDataLayer(connections, { projectId: resolvedProjectId });
|
|
|
|
// Step 7: construct the TaskStore in backend mode.
|
|
/*
|
|
FNXC:PostgresCutover 2026-07-10 (fork review, TrinaryCompute/postgres-v057):
|
|
When BOTH projectId and rootDir are provided, the explicit rootDir must win.
|
|
Previously the projectId branch dropped options.rootDir and re-resolved the
|
|
path via CentralCore from process.cwd(), so a scoped store could root at the
|
|
DASHBOARD's cwd instead of the project dir. The observable failure: createTask
|
|
wrote the bootstrap PROMPT.md stub under the dashboard cwd while triage wrote
|
|
the real spec under the project dir, so isUnplannedForExecution kept reading
|
|
the stale stub and pinned every card "unplanned" forever (never dispatched).
|
|
*/
|
|
let taskStore: TaskStore;
|
|
try {
|
|
const constructT0 = Date.now();
|
|
if (options.projectId && !options.rootDir) {
|
|
taskStore = await TaskStore.getOrCreateForProject(
|
|
options.projectId,
|
|
undefined,
|
|
options.globalSettingsDir,
|
|
asyncLayer,
|
|
);
|
|
} else {
|
|
taskStore = new TaskStore(rootDir, options.globalSettingsDir, {
|
|
asyncLayer,
|
|
});
|
|
await taskStore.init();
|
|
}
|
|
log.log(`startup phase backend.taskStore.construct: ${Date.now() - constructT0}ms`);
|
|
} catch (err) {
|
|
await asyncLayer.close().catch(() => undefined);
|
|
if (embeddedLifecycle) {
|
|
await embeddedLifecycle.stop().catch(() => undefined);
|
|
}
|
|
throw new Error(
|
|
`startup-factory: failed to construct PostgreSQL-backed TaskStore: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`,
|
|
);
|
|
}
|
|
log.log(`startup phase backend.factory.total: ${Date.now() - factoryT0}ms`);
|
|
|
|
/*
|
|
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
|
|
dashboard shows a one-time "your data was migrated and a backup exists"
|
|
banner (dismissible; a "Need help?" button links to the Fusion Discord).
|
|
Best-effort: a failed settings write must not fail a boot whose migration
|
|
already succeeded — the loud path is the migration itself (Step 5.5).
|
|
*/
|
|
if (autoMigrationNotice) {
|
|
try {
|
|
const { patchProjectSettings } = await import("../task-store/async-settings.js");
|
|
await patchProjectSettings(asyncLayer, {
|
|
sqliteMigrationNotice: { ...autoMigrationNotice, dismissed: false },
|
|
});
|
|
} catch (err) {
|
|
log.warn(`startup-factory: failed to persist the migration notice (banner will not show): ${err instanceof Error ? err.message : String(err)}`);
|
|
}
|
|
}
|
|
|
|
// FNXC:SqliteRemoval 2026-06-25-00:00:
|
|
// Dual-read harness integration removed. The dual-read cutover harness was
|
|
// a transitional operator tool that should NOT ship to end users. The upgrade
|
|
// path is auto-migrate (migrator + row-count verification) + keep the SQLite
|
|
// file as a backup. The harness was deleted so it never becomes a maintenance
|
|
// burden.
|
|
|
|
const shutdownEmbedded = embeddedLifecycle;
|
|
return {
|
|
taskStore,
|
|
backend: resolvedBackend,
|
|
asyncLayer,
|
|
async shutdown() {
|
|
// Close the TaskStore first (releases the AsyncDataLayer / pool), then
|
|
// stop the embedded cluster if one was started. Best-effort: log errors.
|
|
try {
|
|
await taskStore.close();
|
|
} catch (err) {
|
|
log.warn(`startup-factory: TaskStore.close() failed during shutdown: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`);
|
|
}
|
|
if (shutdownEmbedded) {
|
|
try {
|
|
await shutdownEmbedded.stop();
|
|
} catch (err) {
|
|
log.warn(`startup-factory: embedded PostgreSQL stop failed during shutdown: ${
|
|
err instanceof Error ? err.message : String(err)
|
|
}`);
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|