Files
fusion/packages/core/src/task-store/async-lifecycle.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

187 lines
8.4 KiB
TypeScript

/**
* Async Drizzle task-lifecycle / lineage helpers (U13).
*
* FNXC:TaskStoreLifecycle 2026-06-24-04:30:
* Async equivalents of the sync SQLite lineage-integrity and lifecycle call
* sites in store.ts. These helpers target the PostgreSQL `project.tasks` table
* via Drizzle and preserve the three load-bearing lineage invariants the
* migration must not regress:
*
* VAL-DATA-010 — Lineage-integrity gate blocks parent delete with live
* children. A parent task that has live (non-archived, non-soft-deleted)
* children (rows whose `source_parent_task_id` points at the parent) cannot
* be deleted or archived until those children are cleared. This is the
* `findLiveLineageChildren` gate that `deleteTask` / `archiveTask` consult.
* VAL-DATA-011 — `removeLineageReferences` clears the `source_parent_task_id`
* edge on each live child so the parent can then be deleted. The clear is a
* plain `UPDATE ... SET source_parent_task_id = NULL` (NULL = no parent).
* VAL-DATA-012 — Archived / soft-deleted children do NOT block parent delete.
* The lineage-integrity gate only counts children whose `column != 'archived'`
* AND whose `deleted_at IS NULL`. A child that was archived or soft-deleted
* no longer counts as "live" and does not block the parent.
*
* Transition context (see library/taskstore-persistence-notes.md):
* `getDatabase()` still returns the sync `Database` until U15 flips it. The
* TaskStore facade keeps its sync lifecycle path (the gate depends on it).
* These helpers are the async target the migrating store and the PostgreSQL
* integration tests consume. They program against the stable `AsyncDataLayer`
* interface (U4), not the underlying driver.
*/
import { and, eq, ne, sql } from "drizzle-orm";
import * as schema from "../postgres/schema/index.js";
import type { AsyncDataLayer, DbTransaction } from "../postgres/data-layer.js";
import { ACTIVE_TASK_FILTER } from "./async-persistence.js";
/**
* FNXC:TaskStoreLifecycle 2026-06-24-04:35:
* The lineage-integrity "live child" predicate. A child counts as live (and
* therefore blocks parent delete/archive) only when ALL of the following hold:
* 1. `source_parent_task_id = <parent>` — it is a lineage child of the parent.
* 2. `id != <parent>` — the parent itself is never its own child.
* 3. `column != 'archived'` — archived children do not block (VAL-DATA-012).
* 4. `deleted_at IS NULL` — soft-deleted children do not block (VAL-DATA-012).
*
* Condition (4) is the soft-delete visibility filter shared with every live
* reader. A soft-deleted child has already been moved to `column = 'archived'`
* by `softDeleteTaskRow`, so condition (3) would already exclude it; condition
* (4) is kept explicitly for defense-in-depth and to make the soft-delete
* invariant self-documenting at the call site.
*
* This mirrors the sync `findLiveLineageChildren` SQL in store.ts exactly:
* SELECT id FROM tasks
* WHERE sourceParentTaskId = ? AND id != ? AND "column" != 'archived'
* AND <ACTIVE_TASKS_WHERE>
*/
/*
FNXC:ArchiveProjectIsolation 2026-07-14-21:48:
Task and lineage IDs are project-local. Archive, lineage, comment, document, artifact, and log gates share this partition resolver so same-ID rows cannot be read or mutated across projects; unbound compatibility callers remain confined to the explicit legacy quarantine.
*/
export function projectPartition(projectId?: string): string {
return projectId?.trim() || "__legacy_unscoped__";
}
export function liveLineageChildFilter(parentId: string, projectId?: string) {
return and(
eq(schema.project.tasks.projectId, projectPartition(projectId)),
eq(schema.project.tasks.sourceParentTaskId, parentId),
ne(schema.project.tasks.id, parentId),
ne(schema.project.tasks.column, "archived"),
ACTIVE_TASK_FILTER,
);
}
/**
* FNXC:TaskStoreLifecycle 2026-06-24-04:40:
* Find the ids of live lineage children of a parent task (VAL-DATA-010).
*
* A "live" child is one whose `source_parent_task_id` points at the parent,
* whose id is not the parent itself, whose column is not `archived`, and whose
* `deleted_at` is NULL. Archived and soft-deleted children are intentionally
* excluded so they do not block parent deletion (VAL-DATA-012).
*
* This is the async equivalent of the sync `findLiveLineageChildren(id)` in
* store.ts. It is the gate that `deleteTask` / `archiveTask` consult before
* proceeding: if the returned list is non-empty and the caller did not opt into
* `removeLineageReferences`, the delete/archive is rejected with
* `TaskHasLineageChildrenError`.
*
* @param db The Drizzle instance or transaction handle to read through.
* @param parentId The id of the prospective parent being deleted/archived.
* @returns The ids of live children (empty if none).
*/
export async function findLiveLineageChildren(
db: AsyncDataLayer["db"] | DbTransaction,
parentId: string,
projectId?: string,
): Promise<string[]> {
const rows = await db
.select({ id: schema.project.tasks.id })
.from(schema.project.tasks)
.where(liveLineageChildFilter(parentId, projectId));
return rows.map((row) => row.id);
}
/**
* FNXC:TaskStoreLifecycle 2026-06-24-04:45:
* Clear the `source_parent_task_id` lineage edge on each live child so the
* parent can then be deleted (VAL-DATA-011).
*
* This is the async equivalent of `rewriteLineageChildrenForRemoval` in
* store.ts. For each child id, it sets `source_parent_task_id = NULL` and
* stamps `updated_at`. After this runs, `findLiveLineageChildren(parentId)`
* returns an empty list, so the lineage-integrity gate no longer blocks the
* parent delete.
*
* The clear is idempotent: re-running against an already-cleared child is a
* no-op (the UPDATE matches zero rows). It only clears children that still
* point at THIS parent (the `source_parent_task_id = parentId` guard), so a
* child that was reparented elsewhere is left untouched.
*
* @param tx The transaction handle (the parent delete must run in the SAME
* transaction so the lineage clear and the parent soft-delete commit or roll
* back atomically).
* @param parentId The id of the parent being removed.
* @param childIds The live child ids to clear (from `findLiveLineageChildren`).
* @param nowIso The timestamp to stamp on `updated_at`.
* @returns The number of child rows actually updated (cleared).
*/
export async function removeLineageReferences(
tx: DbTransaction,
parentId: string,
childIds: readonly string[],
nowIso: string,
projectId?: string,
): Promise<number> {
// FNXC:TaskStoreLifecycle 2026-06-24-06:05:
// A single bulk UPDATE clears all children that still point at this parent.
// The WHERE guards on BOTH id (in the child set) AND source_parent_task_id
// (still pointing at this parent), so a child that was reparented elsewhere
// is left untouched. Using an IN-list keeps this to one round-trip regardless
// of child count. We count affected rows via a RETURNING read so the count is
// accurate regardless of how the driver exposes rowCount.
if (childIds.length === 0) {
return 0;
}
const returned = await tx
.update(schema.project.tasks)
.set({
sourceParentTaskId: null,
updatedAt: nowIso,
})
.where(
and(
eq(schema.project.tasks.projectId, projectPartition(projectId)),
sql`${schema.project.tasks.id} IN ${childIds}`,
eq(schema.project.tasks.sourceParentTaskId, parentId),
),
)
.returning({ id: schema.project.tasks.id });
return returned.length;
}
/**
* FNXC:TaskStoreLifecycle 2026-06-24-04:50:
* Check whether a parent has ANY live lineage children (VAL-DATA-010).
*
* This is a cheaper variant of `findLiveLineageChildren` for call sites that
* only need the boolean (the gate). It uses `LIMIT 1` + an existence check so
* the query short-circuits on the first live child instead of materializing
* the full list.
*
* @param db The Drizzle instance or transaction handle to read through.
* @param parentId The id of the prospective parent.
* @returns `true` if at least one live child exists (delete/archive must be rejected).
*/
export async function hasLiveLineageChildren(
db: AsyncDataLayer["db"] | DbTransaction,
parentId: string,
projectId?: string,
): Promise<boolean> {
const rows = await db
.select({ one: sql<number>`1` })
.from(schema.project.tasks)
.where(liveLineageChildFilter(parentId, projectId))
.limit(1);
return rows.length > 0;
}