Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions packages/core/src/__test-utils__/pg-test-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,9 +546,12 @@ export function createSharedPgTaskStoreTestHarness(options?: {
// not user input, so interpolation is safe here).
await harness.adminDb.execute(
sql.raw(
`INSERT INTO ${PROJECT_SCHEMA}.config (id, next_id, next_workflow_step_id, settings, workflow_steps, updated_at)
VALUES (1, 1, 1, '${defaultsJson.replace(/'/g, "''")}'::jsonb, '[]'::jsonb, now())
ON CONFLICT (id) DO UPDATE SET next_id = 1, next_workflow_step_id = 1, settings = EXCLUDED.settings, workflow_steps = '[]'::jsonb, updated_at = now()`,
// FNXC:MultiProjectIsolation 2026-07-11: config is keyed per-project on
// project_id (the PK) — id is no longer unique, so the upsert arbiter
// must be project_id. Harness stores run project-agnostic (projectId '').
`INSERT INTO ${PROJECT_SCHEMA}.config (id, project_id, next_id, next_workflow_step_id, settings, workflow_steps, updated_at)
VALUES (1, '', 1, 1, '${defaultsJson.replace(/'/g, "''")}'::jsonb, '[]'::jsonb, now())
ON CONFLICT (project_id) DO UPDATE SET next_id = 1, next_workflow_step_id = 1, settings = EXCLUDED.settings, workflow_steps = '[]'::jsonb, updated_at = now()`,
),
);
// Drop any in-memory caches so the store doesn't serve stale rows.
Expand Down
58 changes: 56 additions & 2 deletions packages/core/src/postgres/data-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
* Drizzle transaction callback wrapper.
*/

import { sql, type SQL } from "drizzle-orm";
import { sql, eq, type SQL } from "drizzle-orm";
import type { PostgresJsDatabase, PostgresJsTransaction } from "drizzle-orm/postgres-js";
import { randomUUID } from "node:crypto";
import type { PostgresConnections } from "./connection.js";
Expand Down Expand Up @@ -137,6 +137,22 @@ export interface TransactionOptions {
export interface AsyncDataLayer {
/** Schema-typed runtime Drizzle instance for non-transactional queries. */
readonly db: DrizzleDb;
/**
* FNXC:MultiProjectIsolation 2026-07-10:
* The central-registry project ID this data layer is bound to, or undefined
* for a project-agnostic layer (single-project / global / analytics reads).
*
* In embedded-PG mode every per-project TaskStore gets its OWN AsyncDataLayer
* instance (constructed by the startup factory per projectId) but they all
* connect to the SAME shared `fusion` database + `project` schema. This field
* lets the task-store helpers scope every read/claim/insert on the flat
* `project.tasks` / `project.archived_tasks` tables to a single project so
* per-project engines cannot poll/claim/execute each other's tasks. When
* undefined the scope filter is a no-op (back-compat: single-project stores,
* cross-project analytics, and the SQLite path — which isolates by file — are
* unaffected).
*/
readonly projectId?: string;
/**
* Run an async callback inside a PostgreSQL transaction. All writes inside
* the callback commit atomically; a thrown error rolls back every write
Expand Down Expand Up @@ -203,8 +219,13 @@ export interface RunAuditEvent {
* data-layer contract plugin stores consume.
*
* @param connections The resolved PostgreSQL connection set (runtime + migration).
* @param options Optional binding: `projectId` scopes task-table reads/writes
* to a single project (embedded-PG multi-project isolation, FNXC:MultiProjectIsolation).
*/
export function createAsyncDataLayer(connections: PostgresConnections): AsyncDataLayer {
export function createAsyncDataLayer(
connections: PostgresConnections,
options?: { projectId?: string },
): AsyncDataLayer {
// The runtime Drizzle instance is schema-less at the connection layer
// (connection.ts constructs it without a schema binding so it works for
// any caller). We cast to the schema-typed view so callers get
Expand All @@ -213,6 +234,7 @@ export function createAsyncDataLayer(connections: PostgresConnections): AsyncDat

return {
db,
projectId: options?.projectId,
async transaction<T>(fn: (tx: DbTransaction) => Promise<T>, options?: TransactionOptions): Promise<T> {
return runInTransaction(db, fn, options);
},
Expand Down Expand Up @@ -357,3 +379,35 @@ export async function recordRunAuditEvent(
export function projectTable(tableName: string): SQL {
return sql.raw(`${PROJECT_SCHEMA}."${tableName}"`);
}

/**
* FNXC:MultiProjectIsolation 2026-07-10:
* The per-project scope predicate for the flat `project.tasks` table. Returns
* `project_id = <layer.projectId>` when the data layer is bound to a project,
* or `undefined` (a no-op inside Drizzle's `and(...)`) when it is not.
*
* Every backend-mode task READ / CLAIM / LIST / COUNT path folds this into its
* WHERE clause so a per-project engine only ever sees its own project's rows on
* the shared embedded-PG cluster. `undefined` preserves the pre-isolation
* behavior for project-agnostic layers (single-project stores, cross-project
* analytics) and is safe to pass through `and()` (Drizzle drops undefined
* operands).
*
* NOTE: this operand is passed to `and(...)` so it is only enforced where a
* caller actually threads it in. The load-bearing sites are the row-scan
* readers (readLiveTaskRows, readTaskRow, countLiveTasks), the merge-lease
* candidate scan, and the search scans — see the FNXC:MultiProjectIsolation
* markers in the task-store helpers.
*/
export function taskProjectScope(layer: Pick<AsyncDataLayer, "projectId">): SQL | undefined {
return layer.projectId ? eq(schema.project.tasks.projectId, layer.projectId) : undefined;
}

/** As {@link taskProjectScope} but for the `project.archived_tasks` table. */
export function archivedTaskProjectScope(
layer: Pick<AsyncDataLayer, "projectId">,
): SQL | undefined {
return layer.projectId
? eq(schema.project.archivedTasks.projectId, layer.projectId)
: undefined;
}
22 changes: 19 additions & 3 deletions packages/core/src/postgres/migrations/0000_initial.sql
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ CREATE SCHEMA IF NOT EXISTS archive;

CREATE TABLE IF NOT EXISTS project.tasks (
id text PRIMARY KEY,
-- FNXC:MultiProjectIsolation 2026-07-10: per-project partition key for
-- embedded-PG multi-project isolation (shared cluster/db/schema). Populated
-- from the store's bound projectId on insert; filtered on every backend-mode
-- read/claim/list. Nullable so SQLite mode + legacy rows are unaffected.
project_id text,
lineage_id text,
title text,
description text NOT NULL,
Expand Down Expand Up @@ -174,17 +179,22 @@ CREATE TABLE IF NOT EXISTS project.tasks (
) STORED
);

-- FNXC:MultiProjectIsolation 2026-07-11: per-project config isolation. The old
-- singleton row (id = 1, CHECK-enforced) forced every project in the shared
-- `project` schema to share one taskPrefix / maxConcurrent / maxWorktrees. The
-- row is now keyed per-project on project_id (the PK); `id` stays for column
-- parity (always 1) but is no longer the PK / no longer CHECK-constrained.
CREATE TABLE IF NOT EXISTS project.config (
id integer PRIMARY KEY,
id integer DEFAULT 1,
project_id text NOT NULL DEFAULT '' PRIMARY KEY,
next_id integer DEFAULT 1,
next_workflow_step_id integer DEFAULT 1,
-- FNXC:SqliteFinalRemoval 2026-06-28: WF-id counter for createWorkflowDefinition
-- (SQLite used a __meta row; PG has no __meta table).
next_workflow_definition_id integer DEFAULT 1,
settings jsonb DEFAULT '{}',
workflow_steps jsonb DEFAULT '[]',
updated_at text,
CONSTRAINT config_id_check CHECK (id = 1)
updated_at text
);

CREATE TABLE IF NOT EXISTS project.distributed_task_id_state (
Expand Down Expand Up @@ -276,10 +286,13 @@ CREATE INDEX IF NOT EXISTS "idxActivityLogTaskId" ON project.activity_log(task_i

CREATE TABLE IF NOT EXISTS project.archived_tasks (
id text PRIMARY KEY,
-- FNXC:MultiProjectIsolation 2026-07-10: per-project partition key (see project.tasks.project_id).
project_id text,
data text NOT NULL,
archived_at text NOT NULL
);
CREATE INDEX IF NOT EXISTS "idxArchivedTasksId" ON project.archived_tasks(id);
CREATE INDEX IF NOT EXISTS "idxArchivedTasksProjectId" ON project.archived_tasks(project_id);

CREATE TABLE IF NOT EXISTS project.task_commit_associations (
id text PRIMARY KEY,
Expand Down Expand Up @@ -1465,6 +1478,9 @@ CREATE INDEX IF NOT EXISTS "idxTasksSourceParentTaskId" ON project.tasks(source_
-- The partial predicate shrinks the index to live rows only so the planner
-- can serve the most common board filter without a bitmap-AND over two indexes.
CREATE INDEX IF NOT EXISTS "idxTasksLiveColumn" ON project.tasks("column") WHERE deleted_at IS NULL;
-- FNXC:MultiProjectIsolation 2026-07-10: composite (project_id, column) partial
-- index for the per-project board scan + scheduler poll.
CREATE INDEX IF NOT EXISTS "idxTasksProjectLiveColumn" ON project.tasks(project_id, "column") WHERE deleted_at IS NULL;
-- FNXC:TaskStoreSearch 2026-06-24-12:35:
-- GIN index on the tasks search_vector for full-text search (VAL-SEARCH-001).
-- Replaces the FTS5 index. REINDEX restores search after bloat (VAL-SEARCH-007).
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/postgres/postgres-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,11 @@ export const EXPECTED_PROJECT_COLUMNS: ReadonlyArray<{ table: string; column: st
{ table: "tasks", column: "created_at", type: "text" },
{ table: "tasks", column: "updated_at", type: "text" },
{ table: "tasks", column: "deleted_at", type: "text" },
// FNXC:MultiProjectIsolation 2026-07-11: per-project partition key (PR #2007).
// Listed so existing embedded-PG databases self-heal the column on boot —
// the baseline's CREATE TABLE IF NOT EXISTS never upgrades an existing
// table, and every scoped task read/claim now folds project_id into WHERE.
{ table: "tasks", column: "project_id", type: "text" },
// distributed_task_id_state
{ table: "distributed_task_id_state", column: "prefix", type: "text" },
{ table: "distributed_task_id_state", column: "next_sequence", type: "integer" },
Expand All @@ -163,6 +168,8 @@ export const EXPECTED_PROJECT_COLUMNS: ReadonlyArray<{ table: string; column: st
{ table: "archived_tasks", column: "id", type: "text" },
{ table: "archived_tasks", column: "data", type: "text" },
{ table: "archived_tasks", column: "archived_at", type: "text" },
// FNXC:MultiProjectIsolation 2026-07-11: see tasks.project_id above.
{ table: "archived_tasks", column: "project_id", type: "text" },
// chat_sessions — FN-7775 per-chat thinking level (added 2026-07-10); listed
// so existing embedded-PG databases self-heal the column via ALTER TABLE
// ADD COLUMN IF NOT EXISTS on boot (CREATE TABLE IF NOT EXISTS alone never
Expand Down
45 changes: 42 additions & 3 deletions packages/core/src/postgres/schema/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,20 @@ export const projectSchema = pgSchema(PROJECT_SCHEMA);
// ── Tasks ────────────────────────────────────────────────────────────
export const tasks = projectSchema.table("tasks", {
id: text("id").primaryKey(),
/*
FNXC:MultiProjectIsolation 2026-07-10:
Partition key for embedded-PG multi-project isolation. In embedded mode every
project's per-project TaskStore connects its AsyncDataLayer to ONE shared
`fusion` database + ONE `project` schema, so this flat tasks table is shared
across all projects. Without a project_id, per-project engines poll the same
unfiltered table and claim/execute each other's tasks in the wrong repo.
This column (populated from the store's bound projectId on every insert and
filtered on every read/claim/list in backend mode) re-adds the partition key
the SQLite per-file storage provided implicitly. Nullable so SQLite mode (which
isolates via per-file storage) and legacy rows are unaffected; the filter is
a no-op when the layer has no bound projectId (single-project / global reads).
*/
projectId: text("project_id"),
lineageId: text("lineage_id"),
title: text("title"),
description: text("description").notNull(),
Expand Down Expand Up @@ -257,6 +271,17 @@ export const tasks = projectSchema.table("tasks", {
.on(t.column)
.where(sql`${t.deletedAt} IS NULL`),
/*
FNXC:MultiProjectIsolation 2026-07-10:
Composite index for the per-project isolation filter. Every backend-mode task
read/claim/list adds `project_id = $current`; the hottest board query shape is
`WHERE deleted_at IS NULL AND project_id = ? AND "column" = ?`. Leading with
project_id (then column) serves the per-project board scan and the scheduler
poll from one index. Partial on live rows keeps it small.
*/
index("idxTasksProjectLiveColumn")
.on(t.projectId, t.column)
.where(sql`${t.deletedAt} IS NULL`),
/*
FNXC:TaskStoreSearch 2026-06-24-12:15:
GIN index on the search_vector tsvector for full-text search
(VAL-SEARCH-001). This is the PostgreSQL replacement for the FTS5 index.
Expand All @@ -270,7 +295,16 @@ export const tasks = projectSchema.table("tasks", {

// ── Config ───────────────────────────────────────────────────────────
export const config = projectSchema.table("config", {
id: integer("id").primaryKey(),
// FNXC:MultiProjectIsolation 2026-07-11:
// In embedded-PG mode every project shares this `project` schema, so the old
// singleton config row (id = 1, enforced by a CHECK constraint) forced ALL
// projects to share one taskPrefix / maxConcurrent / maxWorktrees. The row is
// now keyed per-project on `project_id` (the effective PK). `id` is retained
// for column-shape parity (always 1) but is no longer the PK and no longer
// CHECK-constrained. Single-project / SQLite-parity callers leave project_id
// at its '' default (one row), preserving the pre-isolation behavior.
id: integer("id").default(1),
projectId: text("project_id").notNull().default("").primaryKey(),
nextId: integer("next_id").default(1),
nextWorkflowStepId: integer("next_workflow_step_id").default(1),
// FNXC:SqliteFinalRemoval 2026-06-28:
Expand All @@ -281,7 +315,7 @@ export const config = projectSchema.table("config", {
settings: jsonb("settings").default({}),
workflowSteps: jsonb("workflow_steps").default([]),
updatedAt: text("updated_at"),
}, (t) => [check("config_id_check", sql`${t.id} = 1`)]);
});

// ── Distributed task ID allocator ────────────────────────────────────
export const distributedTaskIdState = projectSchema.table("distributed_task_id_state", {
Expand Down Expand Up @@ -381,9 +415,14 @@ export const activityLog = projectSchema.table("activity_log", {
// ── Archived tasks (project-side legacy copy) ────────────────────────
export const archivedTasks = projectSchema.table("archived_tasks", {
id: text("id").primaryKey(),
// FNXC:MultiProjectIsolation 2026-07-10: per-project partition key (see tasks.projectId).
Comment thread
gsxdsm marked this conversation as resolved.
projectId: text("project_id"),
data: text("data").notNull(),
archivedAt: text("archived_at").notNull(),
}, (t) => [index("idxArchivedTasksId").on(t.id)]);
}, (t) => [
index("idxArchivedTasksId").on(t.id),
index("idxArchivedTasksProjectId").on(t.projectId),
]);

// ── Task commit associations ─────────────────────────────────────────
export const taskCommitAssociations = projectSchema.table("task_commit_associations", {
Expand Down
41 changes: 39 additions & 2 deletions packages/core/src/postgres/startup-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,8 +370,21 @@ export async function createTaskStoreForBackend(
const fusionDir = join(rootDir, ".fusion");
const legacySqlitePath = join(fusionDir, "fusion.db");
if (existsSync(legacySqlitePath) && isValidSqliteDatabaseFile(legacySqlitePath)) {
/*
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). NULL
project_id rows are counted as blocking: they may be this project's
pre-isolation data, and migrating on top of them risks id collisions.
Without a bound projectId the pre-isolation whole-table check applies.
*/
const countRows = (await connections.migration.execute(
drizzleSql`SELECT count(*)::int AS count FROM project.tasks`,
options.projectId
? drizzleSql`SELECT count(*)::int AS count FROM project.tasks WHERE project_id = ${options.projectId} OR project_id IS NULL`
Comment thread
greptile-apps[bot] marked this conversation as resolved.
: drizzleSql`SELECT count(*)::int AS count FROM project.tasks`,
)) as Array<{ count: number }>;
const pgTaskCount = Number(countRows[0]?.count ?? 0);
if (pgTaskCount === 0) {
Expand All @@ -394,6 +407,24 @@ export async function createTaskStoreForBackend(
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 });
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.
*/
if (options.projectId) {
await connections.migration.execute(
drizzleSql`UPDATE project.tasks SET project_id = ${options.projectId} WHERE project_id IS NULL`,
);
await connections.migration.execute(
drizzleSql`UPDATE project.archived_tasks SET project_id = ${options.projectId} WHERE project_id IS NULL`,
);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
log.log(`startup-factory: SQLite → PostgreSQL auto-migration complete (${migratedRows} row(s) across ${report.tables.length} table(s))`);
}
}
Expand All @@ -412,7 +443,13 @@ export async function createTaskStoreForBackend(
}

// Step 6: construct the AsyncDataLayer.
const asyncLayer = createAsyncDataLayer(connections);
// 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.
const asyncLayer = createAsyncDataLayer(connections, { projectId: options.projectId });

// Step 7: construct the TaskStore in backend mode.
/*
Expand Down
Loading