## 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 -->
228 lines
9.4 KiB
TypeScript
228 lines
9.4 KiB
TypeScript
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
import { basename, join } from "node:path";
|
|
import { and, eq } from "drizzle-orm";
|
|
import { DatabaseSync } from "./sqlite-adapter.js";
|
|
import { createLogger } from "./logger.js";
|
|
import * as schema from "./postgres/schema/index.js";
|
|
import type { AsyncDataLayer } from "./postgres/data-layer.js";
|
|
|
|
const log = createLogger("project-identity");
|
|
const PROJECT_ID_RE = /^proj_[a-f0-9]{16}$/;
|
|
|
|
/** __meta keys backing the project identity stamp. */
|
|
const META_KEY_PROJECT_ID = "projectId";
|
|
const META_KEY_PROJECT_CREATED_AT = "projectCreatedAt";
|
|
export const PROJECT_IDENTITY_FILENAME = "project.json";
|
|
|
|
export type ProjectIdentity = { id: string; createdAt: string };
|
|
|
|
export class ProjectIdentityMismatchError extends Error {
|
|
constructor(public readonly existingId: string, public readonly incomingId: string) {
|
|
super(`Project identity mismatch: existing id ${existingId} differs from incoming id ${incomingId}`);
|
|
this.name = "ProjectIdentityMismatchError";
|
|
}
|
|
}
|
|
|
|
export class ProjectIdentityConflictError extends Error {
|
|
constructor(
|
|
public readonly projectId: string,
|
|
public readonly existingPath: string,
|
|
public readonly incomingPath: string,
|
|
) {
|
|
super(
|
|
`Project identity conflict: id ${projectId} already belongs to ${existingPath} (incoming path: ${incomingPath})`,
|
|
);
|
|
this.name = "ProjectIdentityConflictError";
|
|
}
|
|
}
|
|
|
|
function resolveFusionDir(inputPath: string): string {
|
|
return basename(inputPath) === ".fusion" ? inputPath : join(inputPath, ".fusion");
|
|
}
|
|
|
|
function readMeta(db: DatabaseSync, key: string): string | undefined {
|
|
const row = db.prepare("SELECT value FROM __meta WHERE key = ?").get(key) as { value?: string } | undefined;
|
|
return row?.value;
|
|
}
|
|
|
|
export function readProjectIdentity(fusionDir: string): ProjectIdentity | null {
|
|
const resolvedFusionDir = resolveFusionDir(fusionDir);
|
|
const markerPath = join(resolvedFusionDir, PROJECT_IDENTITY_FILENAME);
|
|
/*
|
|
* FNXC:ProjectIdentityMarker 2026-07-14-17:10:
|
|
* Project discovery and identity must not require opening fusion.db after the
|
|
* PostgreSQL cutover. Prefer a small filesystem marker; read the SQLite meta
|
|
* table only as a one-way compatibility path for pre-cutover projects.
|
|
*/
|
|
if (existsSync(markerPath)) {
|
|
try {
|
|
const parsed = JSON.parse(readFileSync(markerPath, "utf8")) as Partial<ProjectIdentity>;
|
|
if (typeof parsed.id !== "string" || !PROJECT_ID_RE.test(parsed.id) || typeof parsed.createdAt !== "string" || !parsed.createdAt) {
|
|
log.warn(`Ignoring malformed project identity in ${markerPath}`);
|
|
return null;
|
|
}
|
|
return { id: parsed.id, createdAt: parsed.createdAt };
|
|
} catch (error) {
|
|
log.warn(`Unable to read project identity from ${markerPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const dbPath = join(resolvedFusionDir, "fusion.db");
|
|
if (!existsSync(dbPath)) return null;
|
|
|
|
let db: DatabaseSync | undefined;
|
|
try {
|
|
/* FNXC:LegacySqliteBoundary 2026-07-14-18:42:
|
|
* This is a one-way identity import for projects without project.json.
|
|
* It must be read-only and must not materialize __meta in a legacy file.
|
|
*/
|
|
db = new DatabaseSync(dbPath, { readOnly: true });
|
|
const id = readMeta(db, "projectId");
|
|
const createdAt = readMeta(db, "projectCreatedAt");
|
|
if (!id || !createdAt) return null;
|
|
if (!PROJECT_ID_RE.test(id)) {
|
|
log.warn(`Ignoring malformed stored projectId '${id}' in ${dbPath}`);
|
|
return null;
|
|
}
|
|
return { id, createdAt };
|
|
} catch (error) {
|
|
log.warn(`Unable to read project identity from ${dbPath}: ${error instanceof Error ? error.message : String(error)}`);
|
|
return null;
|
|
} finally {
|
|
db?.close();
|
|
}
|
|
}
|
|
|
|
export function writeProjectIdentity(fusionDir: string, identity: ProjectIdentity): void {
|
|
if (!PROJECT_ID_RE.test(identity.id)) {
|
|
throw new TypeError(`Invalid project identity id: ${identity.id}`);
|
|
}
|
|
|
|
const resolvedFusionDir = resolveFusionDir(fusionDir);
|
|
if (!existsSync(resolvedFusionDir)) {
|
|
mkdirSync(resolvedFusionDir, { recursive: true });
|
|
}
|
|
const existing = readProjectIdentity(resolvedFusionDir);
|
|
if (existing && existing.id !== identity.id) {
|
|
throw new ProjectIdentityMismatchError(existing.id, identity.id);
|
|
}
|
|
const markerPath = join(resolvedFusionDir, PROJECT_IDENTITY_FILENAME);
|
|
const temporaryPath = `${markerPath}.${process.pid}.tmp`;
|
|
writeFileSync(temporaryPath, `${JSON.stringify(identity, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
|
|
renameSync(temporaryPath, markerPath);
|
|
}
|
|
|
|
/** Return true when a PostgreSQL-era marker or a legacy SQLite identity exists. */
|
|
export function hasProjectIdentity(fusionDir: string): boolean {
|
|
return readProjectIdentity(fusionDir) !== null;
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
// Backend-mode (PostgreSQL) project-identity helpers
|
|
//
|
|
// FNXC:MigrateProjectIdentity 2026-06-26-10:05:
|
|
// The running backend store must be able to read/write its own project
|
|
// identity without touching the local SQLite fusion.db. These async helpers
|
|
// target the same `projectId` / `projectCreatedAt` keys, but in the
|
|
// PostgreSQL `project.__meta` table via the AsyncDataLayer. They are the
|
|
// backend-mode dual of the sync local-path functions above.
|
|
//
|
|
// The central registry (`central.projects`) is the authoritative mapping of
|
|
// paths → projectIds, but the per-project `__meta` stamp is the on-disk (and
|
|
// now on-PG) marker that lets a store confirm its own identity without a
|
|
// CentralCore round-trip. In backend mode the SQLite local-path functions
|
|
// below are bypassed entirely by callers that hold the AsyncDataLayer.
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
async function readMetaAsync(layer: AsyncDataLayer, key: string): Promise<string | null> {
|
|
const projectId = layer.projectId ?? "";
|
|
const rows = await layer.db
|
|
.select({ value: schema.project.projectMeta.value })
|
|
.from(schema.project.projectMeta)
|
|
.where(and(
|
|
eq(schema.project.projectMeta.projectId, projectId),
|
|
eq(schema.project.projectMeta.key, key),
|
|
));
|
|
return rows[0]?.value ?? null;
|
|
}
|
|
|
|
async function upsertMetaAsync(layer: AsyncDataLayer, key: string, value: string): Promise<void> {
|
|
const projectId = layer.projectId ?? "";
|
|
/*
|
|
FNXC:PostgresMultiProjectCutover 2026-07-14-11:18:
|
|
Backend identity reads and writes must use the data layer's project binding so one registered project cannot inherit or overwrite another project's PostgreSQL __meta stamp.
|
|
*/
|
|
await layer.db
|
|
.insert(schema.project.projectMeta)
|
|
.values({ projectId, key, value })
|
|
.onConflictDoUpdate({
|
|
target: [schema.project.projectMeta.projectId, schema.project.projectMeta.key],
|
|
set: { value },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* FNXC:MigrateProjectIdentity 2026-06-26-10:10:
|
|
* Read the project identity from the PostgreSQL `project.__meta` table.
|
|
*
|
|
* This is the backend-mode dual of {@link readProjectIdentity}: it returns the
|
|
* same `ProjectIdentity` shape but reads the `projectId` / `projectCreatedAt`
|
|
* keys from PostgreSQL via the AsyncDataLayer, never touching SQLite. Returns
|
|
* `null` when the keys are absent or the stored id is malformed (mirroring the
|
|
* sync path's fail-soft behavior).
|
|
*
|
|
* @param layer The AsyncDataLayer for the running backend project database.
|
|
* @returns The identity, or null when not stored / malformed.
|
|
*/
|
|
export async function readProjectIdentityAsync(
|
|
layer: AsyncDataLayer,
|
|
): Promise<ProjectIdentity | null> {
|
|
try {
|
|
const id = await readMetaAsync(layer, META_KEY_PROJECT_ID);
|
|
const createdAt = await readMetaAsync(layer, META_KEY_PROJECT_CREATED_AT);
|
|
if (!id || !createdAt) return null;
|
|
if (!PROJECT_ID_RE.test(id)) {
|
|
log.warn(`Ignoring malformed stored projectId '${id}' in PostgreSQL __meta`);
|
|
return null;
|
|
}
|
|
return { id, createdAt };
|
|
} catch (error) {
|
|
log.warn(
|
|
`Unable to read project identity from PostgreSQL __meta: ${
|
|
error instanceof Error ? error.message : String(error)
|
|
}`,
|
|
);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* FNXC:MigrateProjectIdentity 2026-06-26-10:15:
|
|
* Write the project identity to the PostgreSQL `project.__meta` table.
|
|
*
|
|
* This is the backend-mode dual of {@link writeProjectIdentity}: it upserts the
|
|
* `projectId` / `projectCreatedAt` keys into PostgreSQL via the AsyncDataLayer,
|
|
* never touching SQLite. Like the sync path, it rejects malformed ids and
|
|
* throws {@link ProjectIdentityMismatchError} when a different id is already
|
|
* stamped.
|
|
*
|
|
* @param layer The AsyncDataLayer for the running backend project database.
|
|
* @param identity The identity to stamp.
|
|
*/
|
|
export async function writeProjectIdentityAsync(
|
|
layer: AsyncDataLayer,
|
|
identity: ProjectIdentity,
|
|
): Promise<void> {
|
|
if (!PROJECT_ID_RE.test(identity.id)) {
|
|
throw new TypeError(`Invalid project identity id: ${identity.id}`);
|
|
}
|
|
|
|
const existingId = await readMetaAsync(layer, META_KEY_PROJECT_ID);
|
|
if (existingId && existingId !== identity.id) {
|
|
throw new ProjectIdentityMismatchError(existingId, identity.id);
|
|
}
|
|
await upsertMetaAsync(layer, META_KEY_PROJECT_ID, identity.id);
|
|
await upsertMetaAsync(layer, META_KEY_PROJECT_CREATED_AT, identity.createdAt);
|
|
}
|