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

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

## Design decisions

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

## Validation

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

## Stack

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

Related: #2105


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

## Summary by CodeRabbit

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

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

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

149 lines
5.1 KiB
TypeScript

/**
* PostgreSQL persistence for the dashboard knowledge index.
*
* FNXC:KnowledgeIndex 2026-07-14-16:42:
* Task and PR history must remain incrementally searchable after PostgreSQL cutover. Keep Drizzle and project-partition enforcement in core so dashboard does not acquire a database-driver dependency, and reject unbound layers because this index contains sensitive repository history.
*/
import { and, desc, eq, ilike, sql } from "drizzle-orm";
import type { AsyncDataLayer } from "./postgres/data-layer.js";
import * as schema from "./postgres/schema/index.js";
export interface AsyncKnowledgePageInput {
sourceKind: "task" | "pr";
sourceId: string;
title: string;
summary?: string | null;
content: string;
tags?: string[];
now?: string;
}
export interface AsyncKnowledgePage {
id: number;
sourceKind: "task" | "pr";
sourceId: string;
sourceKey: string;
title: string;
summary: string | null;
content: string;
tags: string[];
createdAt: string;
updatedAt: string;
}
export interface AsyncKnowledgeQueryOptions {
terms: string[];
sourceKind?: "task" | "pr";
limit: number;
}
function projectIdFor(layer: AsyncDataLayer): string {
const projectId = layer.projectId?.trim();
if (!projectId) throw new Error("PostgreSQL knowledge index access requires asyncLayer.projectId");
return projectId;
}
function toPage(row: typeof schema.project.knowledgePages.$inferSelect): AsyncKnowledgePage {
return {
id: row.id,
sourceKind: row.sourceKind as "task" | "pr",
sourceId: row.sourceId,
sourceKey: row.sourceKey,
title: row.title,
summary: row.summary,
content: row.content,
tags: Array.isArray(row.tags) ? row.tags.filter((tag): tag is string => typeof tag === "string") : [],
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
export async function upsertKnowledgePageInPostgres(
layer: AsyncDataLayer,
input: AsyncKnowledgePageInput,
searchText: string,
): Promise<{ page: AsyncKnowledgePage; created: boolean }> {
const projectId = projectIdFor(layer);
const now = input.now ?? new Date().toISOString();
const sourceKey = `${input.sourceKind}:${input.sourceId}`;
const values = {
projectId,
sourceKind: input.sourceKind,
sourceId: input.sourceId,
sourceKey,
title: input.title,
summary: input.summary ?? null,
content: input.content,
tags: input.tags ?? [],
searchText,
createdAt: now,
updatedAt: now,
};
const result = await layer.transactionImmediate(async (tx) => {
/*
FNXC:KnowledgeIndex 2026-07-14-18:10:
Creation reporting must be determined by the atomic insert result so concurrent first writers cannot both report that they created the same project-scoped page.
*/
const [inserted] = await tx
.insert(schema.project.knowledgePages)
.values(values)
.onConflictDoNothing({
target: [schema.project.knowledgePages.projectId, schema.project.knowledgePages.sourceKey],
})
.returning();
if (inserted) return { row: inserted, created: true };
const [updated] = await tx
.update(schema.project.knowledgePages)
.set({
title: input.title,
summary: input.summary ?? null,
content: input.content,
tags: input.tags ?? [],
searchText,
updatedAt: now,
})
.where(and(
eq(schema.project.knowledgePages.projectId, projectId),
eq(schema.project.knowledgePages.sourceKey, sourceKey),
))
.returning();
return { row: updated, created: false };
});
if (!result.row) throw new Error(`knowledge page ${sourceKey} was not returned after upsert`);
return { page: toPage(result.row), created: result.created };
}
export async function queryKnowledgePagesInPostgres(
layer: AsyncDataLayer,
options: AsyncKnowledgeQueryOptions,
): Promise<AsyncKnowledgePage[]> {
const projectId = projectIdFor(layer);
const predicates = [eq(schema.project.knowledgePages.projectId, projectId)];
for (const term of options.terms) {
/*
FNXC:KnowledgeIndex 2026-07-14-21:55:
Knowledge search preserves the case-insensitive substring behavior of the legacy index while treating user-provided percent, underscore, and backslash characters literally instead of as PostgreSQL wildcard syntax.
*/
const escaped = term.replace(/[\\%_]/g, "\\$&");
predicates.push(ilike(schema.project.knowledgePages.searchText, `%${escaped}%`));
}
if (options.sourceKind) predicates.push(eq(schema.project.knowledgePages.sourceKind, options.sourceKind));
const rows = await layer.db
.select()
.from(schema.project.knowledgePages)
.where(and(...predicates))
.orderBy(desc(schema.project.knowledgePages.updatedAt), desc(schema.project.knowledgePages.id))
.limit(options.limit);
return rows.map(toPage);
}
export async function countKnowledgePagesInPostgres(layer: AsyncDataLayer): Promise<number> {
const projectId = projectIdFor(layer);
const [row] = await layer.db
.select({ count: sql<number>`count(*)::int` })
.from(schema.project.knowledgePages)
.where(eq(schema.project.knowledgePages.projectId, projectId));
return row?.count ?? 0;
}