fix: key workflow settings by the central project id and stamp all partitioned tables on both migration paths
Closes the remaining PG-cutover partitioning gaps: - getWorkflowSettingsProjectId resolves the bound AsyncDataLayer's central- registry id first. In backend mode the SQLite stub's getProjectIdentity() throws, so the old fallback ALWAYS keyed workflow_settings / workflow_prompt_overrides by the rootDir path string — a namespace nothing else reads, making workflow settings appear reset after cutover. - Stamping is extracted into core stampMigratedProjectRows (tasks/archived NULL->id, config ''->id, workflow_settings + workflow_prompt_overrides rootDir-key->id, all guarded against clobbering per-project rows), shared by startup-factory Step 5.5 and 'fn db migrate', which now resolves the registered project by path after the copy and warns when unregistered. - The task-id allocator and merge_queue are verified safe WITHOUT project partitioning: task ids are a global PK, the per-prefix sequence scans are intentionally global (only the per-project config floor can raise them), so two projects sharing a prefix cannot mint duplicate ids. FNXC comments lock the invariant; a cross-project PG regression test proves it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/fn-db-migrate-stamp-rows.md
Normal file
7
.changeset/fn-db-migrate-stamp-rows.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: fn db migrate now stamps migrated rows so tasks, config, and workflow settings stay visible after a cutover.
|
||||||
|
category: fix
|
||||||
|
dev: Extracts the first-boot stamping into core `stampMigratedProjectRows` (project.tasks/archived_tasks/archive.archived_tasks NULL→id, project.config ''→id, and the new project.workflow_settings/workflow_prompt_overrides rootDir-key→id re-key, all NOT_EXISTS-guarded). Shared by startup-factory Step 5.5 and `fn db migrate`, which resolves the registered project id via `lookupRegisteredProjectIdByPath(central.projects.path)` after the copy and warns when the project is unregistered.
|
||||||
7
.changeset/workflow-settings-central-identity.md
Normal file
7
.changeset/workflow-settings-central-identity.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Fix workflow settings and prompt overrides appearing reset after the PostgreSQL migration.
|
||||||
|
category: fix
|
||||||
|
dev: getWorkflowSettingsProjectId now resolves the central-registry id from the bound AsyncDataLayer first. In PG mode the SQLite stub's getProjectIdentity() throws, so the old code always fell through to the rootDir path string — workflow_settings/workflow_prompt_overrides rows were keyed by an absolute path nothing else could find. Legacy path-keyed rows are re-keyed by migration stamping.
|
||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
resolveBackend,
|
resolveBackend,
|
||||||
migrateSqliteToPostgres,
|
migrateSqliteToPostgres,
|
||||||
defaultMigrationSources,
|
defaultMigrationSources,
|
||||||
|
stampMigratedProjectRows,
|
||||||
|
lookupRegisteredProjectIdByPath,
|
||||||
resolveGlobalDir,
|
resolveGlobalDir,
|
||||||
type MigrationReport,
|
type MigrationReport,
|
||||||
} from "@fusion/core";
|
} from "@fusion/core";
|
||||||
@@ -240,6 +242,50 @@ export async function runDbMigrate(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
||||||
|
* The migrator (migrateSqliteToPostgres) is partition-unaware: it copies
|
||||||
|
* legacy rows verbatim, so migrated rows land with NULL project_id, a '' config
|
||||||
|
* key, and rootDir-path-keyed workflow settings — all invisible to bound
|
||||||
|
* readers (engine, dashboard project-store-resolver, configScope,
|
||||||
|
* workflow-settings resolver). The first-boot auto-migration stamps these; the
|
||||||
|
* manual `fn db migrate` path stamped NOTHING, so an operator cutover left the
|
||||||
|
* board/settings empty. Resolve the registered project id for this cwd by
|
||||||
|
* matching central.projects.path (the migration just populated central.projects,
|
||||||
|
* so query AFTER the copy) and re-key the migrated rows. If the project was
|
||||||
|
* never registered centrally, leave rows unstamped and tell the operator how to
|
||||||
|
* fix it (unregistered single-project setups use an unbound, unfiltered layer).
|
||||||
|
*/
|
||||||
|
if (!dryRun) {
|
||||||
|
try {
|
||||||
|
const registeredProjectId = await lookupRegisteredProjectIdByPath(
|
||||||
|
connections.migration,
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
if (registeredProjectId) {
|
||||||
|
await stampMigratedProjectRows(connections.migration, {
|
||||||
|
projectId: registeredProjectId,
|
||||||
|
rootDir: projectRoot,
|
||||||
|
});
|
||||||
|
console.log(
|
||||||
|
`fn db migrate: stamped migrated rows with central-registry project id "${registeredProjectId}" (tasks, archived tasks, config, workflow settings).`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.warn(
|
||||||
|
`fn db migrate: WARNING — no registered project matches path "${projectRoot}" in central.projects; ` +
|
||||||
|
`migrated rows were left UNSTAMPED (NULL project_id / '' config key / rootDir-keyed workflow settings) ` +
|
||||||
|
`and will be invisible to project-bound readers. To fix: register the project (e.g. open it once via the ` +
|
||||||
|
`dashboard/CLI so it is added to central.projects), then re-run \`fn db migrate\` to stamp the rows.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(
|
||||||
|
`fn db migrate: WARNING — post-migration row stamping failed: ${(error as Error).message}. ` +
|
||||||
|
`Migrated rows may be invisible to project-bound readers; re-run \`fn db migrate\` after confirming the project is registered.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await connections.close().catch(() => undefined);
|
await connections.close().catch(() => undefined);
|
||||||
|
|
||||||
// 6. Report.
|
// 6. Report.
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* Cross-project distributed-task-id allocator PostgreSQL integration test.
|
||||||
|
*
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
|
||||||
|
* Locks in the global-task-id invariant on the shared embedded-PG cluster: two
|
||||||
|
* per-project TaskStores (bound to different projectIds, "proj_a" / "proj_b")
|
||||||
|
* over ONE database + ONE `project` schema, both configured with the SAME task
|
||||||
|
* prefix, MUST draw from a single shared per-prefix sequence and never mint a
|
||||||
|
* duplicate task id.
|
||||||
|
*
|
||||||
|
* Why this matters (see async-allocator.ts computeNextSequenceFloor and the
|
||||||
|
* schema note on distributed_task_id_state): `tasks.id` is a global PRIMARY KEY
|
||||||
|
* shared by every project, so the per-prefix sequence in
|
||||||
|
* `distributed_task_id_state` (keyed on prefix only, no project_id) is what
|
||||||
|
* guarantees two projects using the same prefix never collide. The allocator's
|
||||||
|
* high-water scans are unscoped (prefix only) so the shared sequence advances
|
||||||
|
* past every project's max suffix. This test proves:
|
||||||
|
* 1. Interleaved reservations across the two project-bound layers yield ids
|
||||||
|
* that are all UNIQUE and STRICTLY INCREASING per the shared sequence.
|
||||||
|
* 2. Inserting a task under each project (project_id stamped respectively)
|
||||||
|
* with its minted id causes NO tasks.id primary-key violation.
|
||||||
|
* 3. reserve → commit works for both layers against the shared state row.
|
||||||
|
*
|
||||||
|
* Skipped when PostgreSQL is unreachable (FUSION_PG_TEST_SKIP=1) so the merge
|
||||||
|
* gate stays green without a running server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, afterEach } from "vitest";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { execSync } from "node:child_process";
|
||||||
|
import { createAsyncDataLayer, type AsyncDataLayer } from "../../postgres/data-layer.js";
|
||||||
|
import { createConnectionSetFromUrl, type PostgresConnections } from "../../postgres/connection.js";
|
||||||
|
import type { ResolvedBackend } from "../../postgres/backend-resolver.js";
|
||||||
|
import { applySchemaBaseline } from "../../postgres/schema-applier.js";
|
||||||
|
import * as schema from "../../postgres/schema/index.js";
|
||||||
|
import { insertTaskRow } from "../../task-store/async-persistence.js";
|
||||||
|
import {
|
||||||
|
createAsyncDistributedTaskIdAllocator,
|
||||||
|
reconcileTaskIdStateAsync,
|
||||||
|
} from "../../task-store/async-allocator.js";
|
||||||
|
import type { DistributedTaskIdAllocator } from "../../distributed-task-id.js";
|
||||||
|
|
||||||
|
const PG_TEST_URL_BASE =
|
||||||
|
process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432";
|
||||||
|
const PG_AVAILABLE =
|
||||||
|
process.env.FUSION_PG_TEST_SKIP !== "1" && Boolean(PG_TEST_URL_BASE);
|
||||||
|
|
||||||
|
const pgDescribe = PG_AVAILABLE ? describe : describe.skip;
|
||||||
|
|
||||||
|
const SHARED_PREFIX = "KB";
|
||||||
|
|
||||||
|
function uniqueDbName(): string {
|
||||||
|
return `fusion_allocxp_test_${process.pid}_${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function adminExec(statement: string): void {
|
||||||
|
execSync(
|
||||||
|
`psql -h localhost -p 5432 -U ${process.env.USER ?? "postgres"} -d postgres -v ON_ERROR_STOP=1 -c "${statement.replace(/"/g, '\\"')}"`,
|
||||||
|
{ stdio: "pipe", env: process.env },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TestCtx {
|
||||||
|
dbName: string;
|
||||||
|
connections: PostgresConnections;
|
||||||
|
/** One raw connection set; two logical layers differ only by bound projectId. */
|
||||||
|
layerA: AsyncDataLayer;
|
||||||
|
layerB: AsyncDataLayer;
|
||||||
|
allocatorA: DistributedTaskIdAllocator;
|
||||||
|
allocatorB: DistributedTaskIdAllocator;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setupCtx(): Promise<TestCtx> {
|
||||||
|
const dbName = uniqueDbName();
|
||||||
|
try {
|
||||||
|
adminExec(`DROP DATABASE IF EXISTS "${dbName}"`);
|
||||||
|
} catch {
|
||||||
|
// may not exist
|
||||||
|
}
|
||||||
|
adminExec(`CREATE DATABASE "${dbName}"`);
|
||||||
|
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
|
||||||
|
|
||||||
|
const backend: ResolvedBackend = {
|
||||||
|
mode: "external",
|
||||||
|
runtimeUrl: testUrl,
|
||||||
|
migrationUrl: testUrl,
|
||||||
|
migrationUrlOverridden: false,
|
||||||
|
};
|
||||||
|
const schemaConnections = await createConnectionSetFromUrl(backend, {
|
||||||
|
poolMax: 1,
|
||||||
|
connectTimeoutSeconds: 5,
|
||||||
|
});
|
||||||
|
await applySchemaBaseline(schemaConnections.migration);
|
||||||
|
await schemaConnections.close();
|
||||||
|
|
||||||
|
const connections = await createConnectionSetFromUrl(backend, {
|
||||||
|
poolMax: 5,
|
||||||
|
connectTimeoutSeconds: 5,
|
||||||
|
});
|
||||||
|
// Two project-bound layers over the SAME shared database + `project` schema.
|
||||||
|
const layerA = createAsyncDataLayer(connections, { projectId: "proj_a" });
|
||||||
|
const layerB = createAsyncDataLayer(connections, { projectId: "proj_b" });
|
||||||
|
const allocatorA = createAsyncDistributedTaskIdAllocator(layerA);
|
||||||
|
const allocatorB = createAsyncDistributedTaskIdAllocator(layerB);
|
||||||
|
return { dbName, connections, layerA, layerB, allocatorA, allocatorB };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function teardownCtx(ctx: TestCtx | null): Promise<void> {
|
||||||
|
if (!ctx) return;
|
||||||
|
try {
|
||||||
|
await ctx.connections.close();
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
adminExec(`DROP DATABASE IF EXISTS "${ctx.dbName}"`);
|
||||||
|
} catch {
|
||||||
|
// best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Insert a task row with the minted id under the given layer (project_id stamped). */
|
||||||
|
async function insertMintedTask(layer: AsyncDataLayer, id: string): Promise<void> {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
await insertTaskRow(
|
||||||
|
layer,
|
||||||
|
{
|
||||||
|
id,
|
||||||
|
description: "cross-project allocator test task",
|
||||||
|
column: "todo",
|
||||||
|
currentStep: 0,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
{ lineageId: null },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function suffix(taskId: string): number {
|
||||||
|
return Number.parseInt(taskId.split("-")[1] ?? "", 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
pgDescribe("cross-project distributed-task-id allocator (PostgreSQL)", () => {
|
||||||
|
let ctx: TestCtx | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await teardownCtx(ctx);
|
||||||
|
ctx = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it("two projects sharing a prefix draw unique, strictly-increasing ids from ONE shared sequence", async () => {
|
||||||
|
ctx = await setupCtx();
|
||||||
|
const { allocatorA, allocatorB, layerA, layerB } = ctx;
|
||||||
|
|
||||||
|
// Reconcile both on open (mirrors store-open). Both key on the same shared
|
||||||
|
// prefix row, so this is idempotent.
|
||||||
|
await reconcileTaskIdStateAsync(layerA);
|
||||||
|
await reconcileTaskIdStateAsync(layerB);
|
||||||
|
|
||||||
|
const minted: { taskId: string; project: "a" | "b" }[] = [];
|
||||||
|
|
||||||
|
// Interleave a realistic number of reserve→commit allocations, alternating
|
||||||
|
// between the two project-bound allocators. Each uses the REAL allocator
|
||||||
|
// entry points (reserve + commit).
|
||||||
|
const ROUNDS = 12;
|
||||||
|
for (let i = 0; i < ROUNDS; i++) {
|
||||||
|
const useA = i % 2 === 0;
|
||||||
|
const allocator = useA ? allocatorA : allocatorB;
|
||||||
|
const nodeId = useA ? "node-a" : "node-b";
|
||||||
|
|
||||||
|
const reserved = await allocator.reserveDistributedTaskId({
|
||||||
|
prefix: SHARED_PREFIX,
|
||||||
|
nodeId,
|
||||||
|
});
|
||||||
|
const committed = await allocator.commitDistributedTaskIdReservation({
|
||||||
|
reservationId: reserved.reservationId,
|
||||||
|
nodeId,
|
||||||
|
});
|
||||||
|
expect(committed.taskId).toBe(reserved.taskId);
|
||||||
|
|
||||||
|
// Insert the task under the respective project so project_id is stamped.
|
||||||
|
await insertMintedTask(useA ? layerA : layerB, committed.taskId);
|
||||||
|
minted.push({ taskId: committed.taskId, project: useA ? "a" : "b" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. All minted ids are unique (no cross-project duplicate).
|
||||||
|
const ids = minted.map((m) => m.taskId);
|
||||||
|
expect(new Set(ids).size).toBe(ids.length);
|
||||||
|
|
||||||
|
// 2. Suffixes are strictly increasing per the shared sequence (interleaving
|
||||||
|
// the two projects does not reset or fork the counter).
|
||||||
|
const suffixes = ids.map(suffix);
|
||||||
|
for (let i = 1; i < suffixes.length; i++) {
|
||||||
|
expect(suffixes[i]).toBeGreaterThan(suffixes[i - 1]!);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Both projects contributed ids (the interleave actually alternated).
|
||||||
|
expect(minted.some((m) => m.project === "a")).toBe(true);
|
||||||
|
expect(minted.some((m) => m.project === "b")).toBe(true);
|
||||||
|
|
||||||
|
// 4. Exactly one shared state row for the prefix; next_sequence is past the
|
||||||
|
// global max suffix.
|
||||||
|
const stateRows = await layerA.db
|
||||||
|
.select()
|
||||||
|
.from(schema.project.distributedTaskIdState)
|
||||||
|
.where(eq(schema.project.distributedTaskIdState.prefix, SHARED_PREFIX));
|
||||||
|
expect(stateRows).toHaveLength(1);
|
||||||
|
expect(stateRows[0]!.nextSequence).toBeGreaterThan(Math.max(...suffixes));
|
||||||
|
|
||||||
|
// 5. Tasks landed under BOTH project_ids with NO tasks.id PK violation
|
||||||
|
// (proven by the inserts above succeeding). Verify the stamping.
|
||||||
|
const allTasks = await layerA.db
|
||||||
|
.select({ id: schema.project.tasks.id, projectId: schema.project.tasks.projectId })
|
||||||
|
.from(schema.project.tasks);
|
||||||
|
expect(allTasks).toHaveLength(ROUNDS);
|
||||||
|
const byProject = new Map(allTasks.map((t) => [t.id, t.projectId]));
|
||||||
|
for (const m of minted) {
|
||||||
|
expect(byProject.get(m.taskId)).toBe(m.project === "a" ? "proj_a" : "proj_b");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a project's floor cannot mint an id below a sibling project's existing max suffix", async () => {
|
||||||
|
ctx = await setupCtx();
|
||||||
|
const { allocatorA, allocatorB, layerA, layerB } = ctx;
|
||||||
|
|
||||||
|
// Project B pre-populates a HIGH task id under the shared prefix, simulating
|
||||||
|
// a sibling project that already advanced the id namespace far ahead.
|
||||||
|
const highId = `${SHARED_PREFIX}-500`;
|
||||||
|
await insertMintedTask(layerB, highId);
|
||||||
|
|
||||||
|
// Project A opens/reconciles and reserves. Its floor scan is GLOBAL, so it
|
||||||
|
// must jump PAST B's max (500), never reuse an id <= 500.
|
||||||
|
await reconcileTaskIdStateAsync(layerA);
|
||||||
|
const reserved = await allocatorA.reserveDistributedTaskId({
|
||||||
|
prefix: SHARED_PREFIX,
|
||||||
|
nodeId: "node-a",
|
||||||
|
});
|
||||||
|
expect(suffix(reserved.taskId)).toBeGreaterThan(500);
|
||||||
|
|
||||||
|
await allocatorA.commitDistributedTaskIdReservation({
|
||||||
|
reservationId: reserved.reservationId,
|
||||||
|
nodeId: "node-a",
|
||||||
|
});
|
||||||
|
// Inserting under project A with the minted id does not collide with B's row.
|
||||||
|
await insertMintedTask(layerA, reserved.taskId);
|
||||||
|
|
||||||
|
// And B, allocating next, continues strictly above A's id (shared counter).
|
||||||
|
const reservedB = await allocatorB.reserveDistributedTaskId({
|
||||||
|
prefix: SHARED_PREFIX,
|
||||||
|
nodeId: "node-b",
|
||||||
|
});
|
||||||
|
expect(suffix(reservedB.taskId)).toBeGreaterThan(suffix(reserved.taskId));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -221,6 +221,21 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => {
|
|||||||
JSON.stringify({ taskPrefix: "ST", merger: { mode: "ai" } }),
|
JSON.stringify({ taskPrefix: "ST", merger: { mode: "ai" } }),
|
||||||
"2026-06-01T00:00:00Z",
|
"2026-06-01T00:00:00Z",
|
||||||
);
|
);
|
||||||
|
// Legacy workflow_settings keyed by the pre-isolation rootDir path string
|
||||||
|
// (real SQLite schema: workflowId, projectId, "values", updatedAt). Must
|
||||||
|
// be re-keyed from the rootDir path to the registered project id so the
|
||||||
|
// bound workflow-settings resolver still sees the migrated VALUES
|
||||||
|
// (FNXC:CentralProjectIdentity 2026-07-13-23:10).
|
||||||
|
legacy.exec(`CREATE TABLE IF NOT EXISTS workflow_settings (
|
||||||
|
workflowId TEXT NOT NULL,
|
||||||
|
projectId TEXT NOT NULL,
|
||||||
|
"values" TEXT DEFAULT '{}',
|
||||||
|
updatedAt TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (workflowId, projectId)
|
||||||
|
);`);
|
||||||
|
legacy.prepare(
|
||||||
|
`INSERT INTO workflow_settings (workflowId, projectId, "values", updatedAt) VALUES (?, ?, ?, ?)`,
|
||||||
|
).run("wf_default", rootDir, JSON.stringify({ maxWorktrees: 3 }), "2026-06-01T00:00:00Z");
|
||||||
} finally {
|
} finally {
|
||||||
legacy.close();
|
legacy.close();
|
||||||
}
|
}
|
||||||
@@ -274,8 +289,129 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => {
|
|||||||
expect(projectConfig, "migrated config row must be re-keyed to the project").toBeDefined();
|
expect(projectConfig, "migrated config row must be re-keyed to the project").toBeDefined();
|
||||||
expect(projectConfig!.settings?.taskPrefix).toBe("ST");
|
expect(projectConfig!.settings?.taskPrefix).toBe("ST");
|
||||||
expect(configRows.some((r) => r.project_id === ""), "no orphaned '' config row").toBe(false);
|
expect(configRows.some((r) => r.project_id === ""), "no orphaned '' config row").toBe(false);
|
||||||
|
/*
|
||||||
|
FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
||||||
|
The migrated workflow_settings row, keyed by the pre-isolation rootDir
|
||||||
|
path string, must be re-keyed to the registered project id so a bound
|
||||||
|
workflow-settings resolver still sees the migrated VALUES. Before the
|
||||||
|
stamping re-key, this row stayed rootDir-keyed and vanished from every
|
||||||
|
project-bound read.
|
||||||
|
*/
|
||||||
|
const wfRows = (await layer.db.execute(
|
||||||
|
`SELECT workflow_id, project_id, "values" FROM project.workflow_settings ORDER BY workflow_id`,
|
||||||
|
)) as unknown as Array<{ workflow_id: string; project_id: string; values: { maxWorktrees?: number } | null }>;
|
||||||
|
const wfRow = wfRows.find((r) => r.workflow_id === "wf_default");
|
||||||
|
expect(wfRow, "migrated workflow_settings row must survive the migration").toBeDefined();
|
||||||
|
expect(
|
||||||
|
wfRow!.project_id,
|
||||||
|
"workflow_settings row must be re-keyed from the rootDir path to the registered project id",
|
||||||
|
).toBe("proj_stamp_test");
|
||||||
|
expect(wfRow!.values?.maxWorktrees).toBe(3);
|
||||||
|
expect(
|
||||||
|
wfRows.some((r) => r.project_id === rootDir),
|
||||||
|
"no workflow_settings row may remain keyed by the rootDir path",
|
||||||
|
).toBe(false);
|
||||||
} finally {
|
} finally {
|
||||||
await boot!.shutdown();
|
await boot!.shutdown();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
||||||
|
Direct unit-ish coverage of the shared stampMigratedProjectRows helper: seed a
|
||||||
|
freshly-baselined PG schema with unstamped rows (NULL project_id tasks, ''
|
||||||
|
config, rootDir-keyed workflow settings/prompt overrides), run the helper, and
|
||||||
|
assert every table is re-keyed to the supplied project id — including the
|
||||||
|
NOT_EXISTS guard that refuses to clobber a pre-existing per-project row.
|
||||||
|
*/
|
||||||
|
it("stampMigratedProjectRows re-keys all partitioned tables to the project id", async () => {
|
||||||
|
rootDir = await mkdtemp(join(tmpdir(), "stamp-helper-"));
|
||||||
|
dbName = uniqueDbName();
|
||||||
|
adminExec(`CREATE DATABASE "${dbName}"`);
|
||||||
|
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
|
||||||
|
const fakeRootDir = "/legacy/path/to/project";
|
||||||
|
|
||||||
|
const { createConnectionSetFromUrl } = await import("../../postgres/connection.js");
|
||||||
|
const { applySchemaBaseline } = await import("../../postgres/schema-applier.js");
|
||||||
|
const { stampMigratedProjectRows } = await import("../../postgres/migration-stamping.js");
|
||||||
|
const { resolveBackendWithOptions } = await import("../../postgres/backend-resolver.js");
|
||||||
|
|
||||||
|
const connections = await createConnectionSetFromUrl(
|
||||||
|
resolveBackendWithOptions({ databaseUrl: testUrl }),
|
||||||
|
{ poolMax: 1, connectTimeoutSeconds: 30 },
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await applySchemaBaseline(connections.migration);
|
||||||
|
const db = connections.migration;
|
||||||
|
|
||||||
|
// Seed unstamped rows the migrator would have produced.
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO project.tasks (id, description, "column", created_at, updated_at)
|
||||||
|
VALUES ('FN-HELP-1', 'd', 'todo', '2026-06-01T00:00:00Z', '2026-06-01T00:00:00Z')`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO project.config (project_id, settings, updated_at)
|
||||||
|
VALUES ('', '{"taskPrefix":"HL"}'::jsonb, '2026-06-01T00:00:00Z')`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO project.workflow_settings (workflow_id, project_id, "values", updated_at)
|
||||||
|
VALUES ('wf_a', '${fakeRootDir}', '{"maxWorktrees":5}'::jsonb, '2026-06-01T00:00:00Z')`,
|
||||||
|
);
|
||||||
|
// A pre-existing per-project row for wf_b: the rootDir-keyed migrated copy
|
||||||
|
// must NOT clobber it (NOT_EXISTS guard).
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO project.workflow_settings (workflow_id, project_id, "values", updated_at)
|
||||||
|
VALUES ('wf_b', 'proj_help', '{"maxWorktrees":9}'::jsonb, '2026-06-01T00:00:00Z')`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO project.workflow_settings (workflow_id, project_id, "values", updated_at)
|
||||||
|
VALUES ('wf_b', '${fakeRootDir}', '{"maxWorktrees":1}'::jsonb, '2026-06-01T00:00:00Z')`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
`INSERT INTO project.workflow_prompt_overrides (workflow_id, project_id, overrides, updated_at)
|
||||||
|
VALUES ('wf_a', '${fakeRootDir}', '{"executor":"x"}'::jsonb, '2026-06-01T00:00:00Z')`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await stampMigratedProjectRows(db, { projectId: "proj_help", rootDir: fakeRootDir });
|
||||||
|
expect(result.stamped).toBe(true);
|
||||||
|
|
||||||
|
const tasks = (await db.execute(
|
||||||
|
`SELECT project_id FROM project.tasks WHERE id = 'FN-HELP-1'`,
|
||||||
|
)) as unknown as Array<{ project_id: string | null }>;
|
||||||
|
expect(tasks[0]?.project_id).toBe("proj_help");
|
||||||
|
|
||||||
|
const cfg = (await db.execute(
|
||||||
|
`SELECT project_id FROM project.config ORDER BY project_id`,
|
||||||
|
)) as unknown as Array<{ project_id: string }>;
|
||||||
|
expect(cfg.some((r) => r.project_id === "proj_help")).toBe(true);
|
||||||
|
expect(cfg.some((r) => r.project_id === "")).toBe(false);
|
||||||
|
|
||||||
|
const wf = (await db.execute(
|
||||||
|
`SELECT workflow_id, project_id, "values" FROM project.workflow_settings ORDER BY workflow_id, project_id`,
|
||||||
|
)) as unknown as Array<{ workflow_id: string; project_id: string; values: { maxWorktrees?: number } | null }>;
|
||||||
|
// wf_a re-keyed to proj_help.
|
||||||
|
const wfA = wf.find((r) => r.workflow_id === "wf_a");
|
||||||
|
expect(wfA?.project_id).toBe("proj_help");
|
||||||
|
expect(wfA?.values?.maxWorktrees).toBe(5);
|
||||||
|
// wf_b keeps its pre-existing per-project row (value 9); the rootDir copy
|
||||||
|
// was NOT re-keyed (guard) so it remains keyed by the fake rootDir.
|
||||||
|
const wfBProject = wf.find((r) => r.workflow_id === "wf_b" && r.project_id === "proj_help");
|
||||||
|
expect(wfBProject?.values?.maxWorktrees, "pre-existing per-project row must not be clobbered").toBe(9);
|
||||||
|
const wfBLegacy = wf.find((r) => r.workflow_id === "wf_b" && r.project_id === fakeRootDir);
|
||||||
|
expect(wfBLegacy, "guarded rootDir row is left in place for manual reconciliation").toBeDefined();
|
||||||
|
// No wf_a row remains keyed by the fake rootDir path.
|
||||||
|
expect(wf.some((r) => r.workflow_id === "wf_a" && r.project_id === fakeRootDir)).toBe(false);
|
||||||
|
|
||||||
|
const overrides = (await db.execute(
|
||||||
|
`SELECT project_id FROM project.workflow_prompt_overrides WHERE workflow_id = 'wf_a'`,
|
||||||
|
)) as unknown as Array<{ project_id: string }>;
|
||||||
|
expect(overrides[0]?.project_id).toBe("proj_help");
|
||||||
|
|
||||||
|
// No-op when projectId is empty.
|
||||||
|
const noop = await stampMigratedProjectRows(db, { projectId: "", rootDir: fakeRootDir });
|
||||||
|
expect(noop.stamped).toBe(false);
|
||||||
|
} finally {
|
||||||
|
await connections.close().catch(() => undefined);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
/**
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
|
||||||
|
* Regression coverage for the workflow-settings project-identity namespace bug.
|
||||||
|
*
|
||||||
|
* In backend (PostgreSQL) mode `store.db` is a SQLite stub whose
|
||||||
|
* `getProjectIdentity()` throws, so the OLD `getWorkflowSettingsProjectId`
|
||||||
|
* always fell through its catch to `store.rootDir` — an absolute filesystem
|
||||||
|
* path. Every other backend-mode read/write partitions by the central-registry
|
||||||
|
* project id (`asyncLayer.projectId`), so workflow settings landed under a
|
||||||
|
* rootDir key nothing else could find (settings appeared "reset").
|
||||||
|
*
|
||||||
|
* These tests pin the invariant: when the async layer is BOUND to a central
|
||||||
|
* project id, workflow settings + prompt overrides must be keyed by that id
|
||||||
|
* (the `project_id` column), NOT by the rootDir path. An UNBOUND layer keeps
|
||||||
|
* the legacy rootDir fallback.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from "vitest";
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import {
|
||||||
|
pgDescribe,
|
||||||
|
createSharedPgTaskStoreTestHarness,
|
||||||
|
type SharedPgTaskStoreHarness,
|
||||||
|
} from "../../__test-utils__/pg-test-harness.js";
|
||||||
|
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
|
||||||
|
import { getWorkflowSettingsProjectIdImpl } from "../../task-store/remaining-ops-6.js";
|
||||||
|
import type { TaskStore } from "../../store.js";
|
||||||
|
|
||||||
|
const pgTest = pgDescribe;
|
||||||
|
|
||||||
|
/** Stand-in central-registry project id, matching the "proj_" shape used in prod. */
|
||||||
|
const BOUND_PROJECT_ID = "proj_wfsettings_identity_test";
|
||||||
|
|
||||||
|
pgTest("workflow-settings project identity keys by the central-registry id (PostgreSQL)", () => {
|
||||||
|
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
|
||||||
|
prefix: "fusion_wfsettings_identity",
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeAll(h.beforeAll);
|
||||||
|
beforeEach(h.beforeEach);
|
||||||
|
afterEach(h.afterEach);
|
||||||
|
afterAll(h.afterAll);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a project-bound clone of the shared harness layer. `createAsyncDataLayer`
|
||||||
|
* returns an object literal whose transaction methods close over the shared
|
||||||
|
* `db`, so spreading it and overriding `projectId` yields a layer that shares
|
||||||
|
* the same PostgreSQL connection but reports a bound central id.
|
||||||
|
*/
|
||||||
|
function boundLayer(): AsyncDataLayer {
|
||||||
|
return { ...h.layer(), projectId: BOUND_PROJECT_ID };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function boundStore(): Promise<TaskStore> {
|
||||||
|
const { TaskStore: TaskStoreCtor } = await import("../../store.js");
|
||||||
|
return new TaskStoreCtor(h.rootDir(), undefined, { asyncLayer: boundLayer() });
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a projectId-BOUND backend store writes workflow_prompt_overrides under the central id, not rootDir", async () => {
|
||||||
|
const store = await boundStore();
|
||||||
|
const workflowId = "builtin:coding";
|
||||||
|
|
||||||
|
const projectId = store.getWorkflowSettingsProjectId();
|
||||||
|
expect(projectId).toBe(BOUND_PROJECT_ID);
|
||||||
|
// Explicitly prove it is NOT the rootDir path the old code returned.
|
||||||
|
expect(projectId).not.toBe(h.rootDir());
|
||||||
|
|
||||||
|
await store.updateWorkflowPromptOverrides(workflowId, projectId, {
|
||||||
|
"node-a": "override prose for node a",
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = (await h
|
||||||
|
.adminDb()
|
||||||
|
.execute(
|
||||||
|
sql`SELECT project_id, workflow_id FROM project.workflow_prompt_overrides WHERE workflow_id = ${workflowId}`,
|
||||||
|
)) as unknown as Array<{ project_id: string; workflow_id: string }>;
|
||||||
|
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
expect(rows[0].project_id).toBe(BOUND_PROJECT_ID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a projectId-BOUND backend store writes workflow_settings under the central id, not rootDir", async () => {
|
||||||
|
const store = await boundStore();
|
||||||
|
const workflowId = "builtin:coding";
|
||||||
|
|
||||||
|
const projectId = store.getWorkflowSettingsProjectId();
|
||||||
|
expect(projectId).toBe(BOUND_PROJECT_ID);
|
||||||
|
|
||||||
|
// `workflowStepTimeoutMs` is a declared builtin workflow setting, so this
|
||||||
|
// write passes declaration validation and persists a real row.
|
||||||
|
await store.updateWorkflowSettingValues(workflowId, projectId, {
|
||||||
|
workflowStepTimeoutMs: 600_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const rows = (await h
|
||||||
|
.adminDb()
|
||||||
|
.execute(
|
||||||
|
sql`SELECT project_id FROM project.workflow_settings WHERE workflow_id = ${workflowId}`,
|
||||||
|
)) as unknown as Array<{ project_id: string }>;
|
||||||
|
|
||||||
|
expect(rows.length).toBe(1);
|
||||||
|
expect(rows[0].project_id).toBe(BOUND_PROJECT_ID);
|
||||||
|
expect(rows[0].project_id).not.toBe(h.rootDir());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an UNBOUND backend layer falls back to rootDir (legacy key), proving the bound path is what changed", () => {
|
||||||
|
// The shared harness store uses an unbound layer (projectId undefined). The
|
||||||
|
// SQLite stub throws in getProjectIdentity, so resolution falls to rootDir.
|
||||||
|
const unboundStore = h.store();
|
||||||
|
expect(unboundStore.asyncLayer?.projectId).toBeUndefined();
|
||||||
|
expect(unboundStore.getWorkflowSettingsProjectId()).toBe(h.rootDir());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Focused unit coverage of the resolution order, independent of a live PG
|
||||||
|
* connection. Runs unconditionally (not gated on PG availability).
|
||||||
|
*/
|
||||||
|
describe("getWorkflowSettingsProjectIdImpl resolution order (unit)", () => {
|
||||||
|
it("prefers asyncLayer.projectId when the layer is bound", () => {
|
||||||
|
const store = {
|
||||||
|
asyncLayer: { projectId: "proj_central_id" },
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
db: {
|
||||||
|
getProjectIdentity() {
|
||||||
|
throw new Error("SQLite removed in backend mode");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
expect(getWorkflowSettingsProjectIdImpl(store)).toBe("proj_central_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the legacy SQLite identity id when no layer is bound", () => {
|
||||||
|
const store = {
|
||||||
|
asyncLayer: null,
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
db: {
|
||||||
|
getProjectIdentity() {
|
||||||
|
return { id: "legacy_identity_id" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
expect(getWorkflowSettingsProjectIdImpl(store)).toBe("legacy_identity_id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to rootDir when the SQLite stub throws and no layer is bound (old backend behavior)", () => {
|
||||||
|
const store = {
|
||||||
|
asyncLayer: null,
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
db: {
|
||||||
|
getProjectIdentity() {
|
||||||
|
throw new Error("SQLite removed in backend mode");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
expect(getWorkflowSettingsProjectIdImpl(store)).toBe("/tmp/root");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("an unbound layer object (projectId undefined) does not short-circuit the legacy path", () => {
|
||||||
|
const store = {
|
||||||
|
asyncLayer: { projectId: undefined },
|
||||||
|
rootDir: "/tmp/root",
|
||||||
|
db: {
|
||||||
|
getProjectIdentity() {
|
||||||
|
return { id: "legacy_identity_id" };
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
expect(getWorkflowSettingsProjectIdImpl(store)).toBe("legacy_identity_id");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2226,6 +2226,12 @@ export {
|
|||||||
CENTRAL_BACKUP_SCHEMAS,
|
CENTRAL_BACKUP_SCHEMAS,
|
||||||
migrateSqliteToPostgres,
|
migrateSqliteToPostgres,
|
||||||
defaultMigrationSources,
|
defaultMigrationSources,
|
||||||
|
// FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
||||||
|
// Post-migration project-partition stamping, shared by the startup-factory
|
||||||
|
// first-boot auto-migration and `fn db migrate` so migrated rows are re-keyed
|
||||||
|
// to the central-registry project id on BOTH cutover paths.
|
||||||
|
stampMigratedProjectRows,
|
||||||
|
lookupRegisteredProjectIdByPath,
|
||||||
applySchemaBaseline,
|
applySchemaBaseline,
|
||||||
getAppliedMigrations,
|
getAppliedMigrations,
|
||||||
SCHEMA_BASELINE_VERSION,
|
SCHEMA_BASELINE_VERSION,
|
||||||
@@ -2263,6 +2269,8 @@ export type {
|
|||||||
SchemaName,
|
SchemaName,
|
||||||
MigrationReport,
|
MigrationReport,
|
||||||
TableMigrationResult,
|
TableMigrationResult,
|
||||||
|
StampMigratedProjectRowsInput,
|
||||||
|
StampMigratedProjectRowsResult,
|
||||||
BackendBootResult,
|
BackendBootResult,
|
||||||
CreateTaskStoreForBackendOptions,
|
CreateTaskStoreForBackendOptions,
|
||||||
} from "./postgres/index.js";
|
} from "./postgres/index.js";
|
||||||
|
|||||||
@@ -164,6 +164,20 @@ export {
|
|||||||
type TableMigrationResult,
|
type TableMigrationResult,
|
||||||
} from "./sqlite-migrator.js";
|
} from "./sqlite-migrator.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
||||||
|
* Post-migration project-partition stamping, shared by the startup-factory
|
||||||
|
* first-boot auto-migration and the manual `fn db migrate` cutover command so
|
||||||
|
* migrated rows (tasks/archived_tasks/config/workflow settings) are re-keyed to
|
||||||
|
* the central-registry project id on BOTH paths.
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
stampMigratedProjectRows,
|
||||||
|
lookupRegisteredProjectIdByPath,
|
||||||
|
type StampMigratedProjectRowsInput,
|
||||||
|
type StampMigratedProjectRowsResult,
|
||||||
|
} from "./migration-stamping.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FNXC:BackendFlip 2026-06-26-14:30:
|
* FNXC:BackendFlip 2026-06-26-14:30:
|
||||||
* Runtime startup factory (cutover milestone). `createTaskStoreForBackend()`
|
* Runtime startup factory (cutover milestone). `createTaskStoreForBackend()`
|
||||||
|
|||||||
183
packages/core/src/postgres/migration-stamping.ts
Normal file
183
packages/core/src/postgres/migration-stamping.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
||||||
|
* Post-migration project-partition stamping, extracted from the startup-factory
|
||||||
|
* first-boot auto-migration (Step 5.5) so it can be shared with the manual
|
||||||
|
* `fn db migrate` cutover command.
|
||||||
|
*
|
||||||
|
* The SQLite→PostgreSQL migrator (sqlite-migrator.ts) is partition-unaware: it
|
||||||
|
* copies legacy rows verbatim, so migrated rows land with NULL project_id
|
||||||
|
* (tasks/archived_tasks), a legacy singleton config key ('' — SQLite-parity
|
||||||
|
* DEFAULT), and workflow-settings/prompt-override rows keyed by the legacy
|
||||||
|
* rootDir path string (or a pre-isolation identity id) instead of the
|
||||||
|
* central-registry project id the runtime now scopes every read/write by. Every
|
||||||
|
* project-bound reader (engine InProcessRuntime, dashboard
|
||||||
|
* project-store-resolver, configScope, workflow-settings resolver) filters those
|
||||||
|
* rows out, so the board/settings/workflow surfaces show empty right after a
|
||||||
|
* "successful" migration. This helper re-keys the just-migrated rows to the
|
||||||
|
* booting project's central-registry id, closing that silent-invisible-data gap
|
||||||
|
* on BOTH cutover paths.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { sql } from "drizzle-orm";
|
||||||
|
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
||||||
|
|
||||||
|
/** The Drizzle instance type startup-factory uses for its `connections.migration`. */
|
||||||
|
type MigrationDb = PostgresJsDatabase<Record<string, never>>;
|
||||||
|
|
||||||
|
/** Inputs for stamping migrated rows with a project partition key. */
|
||||||
|
export interface StampMigratedProjectRowsInput {
|
||||||
|
/**
|
||||||
|
* The central-registry project id every migrated row must be re-keyed to.
|
||||||
|
* Resolved by the caller (options.projectId, or a path lookup against
|
||||||
|
* central.projects).
|
||||||
|
*/
|
||||||
|
readonly projectId: string;
|
||||||
|
/**
|
||||||
|
* The project rootDir path. Legacy/migrated workflow_settings and
|
||||||
|
* workflow_prompt_overrides rows are keyed by this absolute path string (the
|
||||||
|
* pre-isolation key), so re-keying them requires the rootDir as the match
|
||||||
|
* predicate.
|
||||||
|
*/
|
||||||
|
readonly rootDir: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of a stamping pass. */
|
||||||
|
export interface StampMigratedProjectRowsResult {
|
||||||
|
/** True when the pass ran (a non-empty projectId was supplied). */
|
||||||
|
readonly stamped: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
||||||
|
* Re-key just-migrated rows to the booting project's central-registry id.
|
||||||
|
*
|
||||||
|
* Covers, idempotently:
|
||||||
|
* - project.tasks NULL project_id → projectId
|
||||||
|
* - project.archived_tasks NULL project_id → projectId
|
||||||
|
* - archive.archived_tasks NULL project_id → projectId (cold-storage snapshots)
|
||||||
|
* - project.config '' key → projectId (guarded: never clobbers a
|
||||||
|
* pre-existing per-project row)
|
||||||
|
* - project.workflow_settings rootDir-path key → projectId (guarded)
|
||||||
|
* - project.workflow_prompt_overrides rootDir-path key → projectId (guarded)
|
||||||
|
*
|
||||||
|
* Callers must guarantee the NULL-project_id rows in tasks/archived_tasks were
|
||||||
|
* written by THIS migration pass (the scoped emptiness check in startup-factory
|
||||||
|
* Step 5.5, or the empty-target contract of `fn db migrate`). The config /
|
||||||
|
* workflow re-keys are NOT_EXISTS-guarded so a pre-existing per-project row is
|
||||||
|
* never destroyed.
|
||||||
|
*
|
||||||
|
* @param db A Drizzle instance connected to the target cluster (the same type
|
||||||
|
* startup-factory uses for `connections.migration`). Must run DML.
|
||||||
|
*/
|
||||||
|
export async function stampMigratedProjectRows(
|
||||||
|
db: MigrationDb,
|
||||||
|
{ projectId, rootDir }: StampMigratedProjectRowsInput,
|
||||||
|
): Promise<StampMigratedProjectRowsResult> {
|
||||||
|
if (!projectId) {
|
||||||
|
// No registry identity — leave rows unstamped (unregistered single-project
|
||||||
|
// setups use an unbound layer with no scope filter).
|
||||||
|
return { stamped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
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.
|
||||||
|
|
||||||
|
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 resolution now
|
||||||
|
falls back to a central-registry path lookup (done by the caller).
|
||||||
|
*/
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE project.tasks SET project_id = ${projectId} WHERE project_id IS NULL`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE project.archived_tasks SET project_id = ${projectId} WHERE project_id IS NULL`,
|
||||||
|
);
|
||||||
|
// The cold-storage archive is also partitioned (PR #2007 review P1); migrated
|
||||||
|
// snapshots must be owned by this project too.
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE archive.archived_tasks SET project_id = ${projectId} WHERE project_id IS NULL`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CentralProjectIdentity 2026-07-13-22:00:
|
||||||
|
project.config is keyed by project_id (DEFAULT '' — the legacy SQLite-parity
|
||||||
|
row). The migrator copies the legacy singleton config into the '' row, but
|
||||||
|
configScope() has NO bound→'' fallback, so a bound reader silently lost the
|
||||||
|
migrated project settings, workflowSteps, taskPrefix, and nextId floor
|
||||||
|
(defaults returned right after a "successful" migration). Re-key the migrated
|
||||||
|
row to this project. Guarded so a pre-existing per-project row is never
|
||||||
|
clobbered (then the '' row is left for manual reconciliation rather than
|
||||||
|
destroying either copy).
|
||||||
|
*/
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE project.config SET project_id = ${projectId}
|
||||||
|
WHERE project_id = ''
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM project.config WHERE project_id = ${projectId})`,
|
||||||
|
);
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
||||||
|
project.workflow_settings and project.workflow_prompt_overrides are keyed
|
||||||
|
(workflow_id, project_id). The runtime now keys them by the central-registry
|
||||||
|
project id (asyncLayer.projectId), but legacy/migrated rows carry the
|
||||||
|
pre-isolation key — the absolute rootDir path string (e.g.
|
||||||
|
'/Users/eclipxe/Projects/kb') or a legacy identity id. A bound
|
||||||
|
workflow-settings resolver filters those out, so per-workflow setting VALUES
|
||||||
|
and prompt overrides vanish right after a "successful" migration (defaults
|
||||||
|
returned, custom prompts lost). Re-key the rootDir-path rows to this project.
|
||||||
|
Guarded per-row with NOT EXISTS on the target (workflow_id, projectId) PK so a
|
||||||
|
unique violation never clobbers a pre-existing per-project row (the outer
|
||||||
|
table alias in the correlated subquery references the row being updated).
|
||||||
|
*/
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE project.workflow_settings SET project_id = ${projectId}
|
||||||
|
WHERE project_id = ${rootDir}
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM project.workflow_settings w2
|
||||||
|
WHERE w2.workflow_id = project.workflow_settings.workflow_id
|
||||||
|
AND w2.project_id = ${projectId}
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
await db.execute(
|
||||||
|
sql`UPDATE project.workflow_prompt_overrides SET project_id = ${projectId}
|
||||||
|
WHERE project_id = ${rootDir}
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM project.workflow_prompt_overrides w2
|
||||||
|
WHERE w2.workflow_id = project.workflow_prompt_overrides.workflow_id
|
||||||
|
AND w2.project_id = ${projectId}
|
||||||
|
)`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { stamped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
||||||
|
* Resolve the central-registry project id for a filesystem path by matching
|
||||||
|
* central.projects.path. Shared so both startup-factory (rootDir-only boot) and
|
||||||
|
* `fn db migrate` (post-migration, once central.projects is populated) derive
|
||||||
|
* the same stamping id. Returns undefined when the path is not registered
|
||||||
|
* (legacy/unregistered single-project setups stay unbound, matching their
|
||||||
|
* unfiltered readers). Never throws — a lookup failure yields undefined.
|
||||||
|
*/
|
||||||
|
export async function lookupRegisteredProjectIdByPath(
|
||||||
|
db: MigrationDb,
|
||||||
|
path: string,
|
||||||
|
): Promise<string | undefined> {
|
||||||
|
if (!path) return undefined;
|
||||||
|
try {
|
||||||
|
const rows = (await db.execute(
|
||||||
|
sql`SELECT id FROM central.projects WHERE path = ${path} LIMIT 1`,
|
||||||
|
)) as Array<{ id: string }>;
|
||||||
|
return rows[0]?.id;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -324,6 +324,34 @@ export const config = projectSchema.table("config", {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// ── Distributed task ID allocator ────────────────────────────────────
|
// ── Distributed task ID allocator ────────────────────────────────────
|
||||||
|
/*
|
||||||
|
FNXC:CentralProjectIdentity 2026-07-13-22:40:
|
||||||
|
distributed_task_id_state / distributed_task_id_reservations / merge_queue are
|
||||||
|
INTENTIONALLY NOT project-partitioned (no project_id column), unlike `tasks` and
|
||||||
|
`archived_tasks`. This is load-bearing, not an oversight:
|
||||||
|
|
||||||
|
- Task ids are a GLOBALLY-UNIQUE namespace across the entire embedded-PG
|
||||||
|
cluster: `tasks.id` is a global PRIMARY KEY shared by every project in the
|
||||||
|
one `project` schema. Two ids like "KB-123" must never coexist even across
|
||||||
|
different projects.
|
||||||
|
- The mechanism that guarantees this is the SHARED per-prefix sequence keyed
|
||||||
|
on `distributed_task_id_state.prefix` (PK = prefix, no project_id). Projects
|
||||||
|
that share a prefix share one monotonic counter, so they can never mint the
|
||||||
|
same id. Adding a project_id here would split the counter per project and
|
||||||
|
let two projects using the same prefix collide on `tasks.id`.
|
||||||
|
- `distributed_task_id_reservations` (unique on prefix+sequence and prefix+
|
||||||
|
task_id) is the reservation ledger for that shared counter; scoping it per
|
||||||
|
project would break the uniqueness backstop for the same reason.
|
||||||
|
- `merge_queue` (PK = task_id → FK tasks.id) needs no project_id BECAUSE
|
||||||
|
task_id is globally unique: its PK can never collide across projects, and its
|
||||||
|
rows are scoped to a project transitively through the joined task's
|
||||||
|
project_id (see async-merge-coordination taskStillInReview / taskProjectScope).
|
||||||
|
|
||||||
|
Projects that share a prefix therefore share id numbering by design. The
|
||||||
|
allocator enforces the global floor by scanning tasks/archived_tasks WITHOUT a
|
||||||
|
project_id filter (see async-allocator computeNextSequenceFloor /
|
||||||
|
getMaxTaskSequenceFromTable / taskIdExists).
|
||||||
|
*/
|
||||||
export const distributedTaskIdState = projectSchema.table("distributed_task_id_state", {
|
export const distributedTaskIdState = projectSchema.table("distributed_task_id_state", {
|
||||||
prefix: text("prefix").primaryKey(),
|
prefix: text("prefix").primaryKey(),
|
||||||
nextSequence: integer("next_sequence").notNull(),
|
nextSequence: integer("next_sequence").notNull(),
|
||||||
@@ -564,6 +592,13 @@ export const agentBlockedStates = projectSchema.table("agent_blocked_states", {
|
|||||||
}, (t) => [foreignKey({ columns: [t.agentId], foreignColumns: [agents.id] }).onDelete("cascade")]);
|
}, (t) => [foreignKey({ columns: [t.agentId], foreignColumns: [agents.id] }).onDelete("cascade")]);
|
||||||
|
|
||||||
// ── Merge queue / merge requests / handoff ───────────────────────────
|
// ── Merge queue / merge requests / handoff ───────────────────────────
|
||||||
|
/*
|
||||||
|
FNXC:CentralProjectIdentity 2026-07-13-22:40:
|
||||||
|
merge_queue has NO project_id and is safe without one: its PK is task_id, which
|
||||||
|
is globally unique across the cluster (tasks.id global PK — see the allocator
|
||||||
|
note above), so the PK cannot collide across projects. Per-project scoping is
|
||||||
|
applied transitively via the joined task's project_id in lease/cleanup queries.
|
||||||
|
*/
|
||||||
export const mergeQueue = projectSchema.table("merge_queue", {
|
export const mergeQueue = projectSchema.table("merge_queue", {
|
||||||
taskId: text("task_id").primaryKey(),
|
taskId: text("task_id").primaryKey(),
|
||||||
enqueuedAt: text("enqueued_at").notNull(),
|
enqueuedAt: text("enqueued_at").notNull(),
|
||||||
|
|||||||
@@ -472,34 +472,19 @@ export async function createTaskStoreForBackend(
|
|||||||
*/
|
*/
|
||||||
const stampProjectId = options.projectId ?? (await lookupRegisteredProjectIdByPath());
|
const stampProjectId = options.projectId ?? (await lookupRegisteredProjectIdByPath());
|
||||||
if (stampProjectId) {
|
if (stampProjectId) {
|
||||||
await connections.migration.execute(
|
|
||||||
drizzleSql`UPDATE project.tasks SET project_id = ${stampProjectId} WHERE project_id IS NULL`,
|
|
||||||
);
|
|
||||||
await connections.migration.execute(
|
|
||||||
drizzleSql`UPDATE project.archived_tasks SET project_id = ${stampProjectId} WHERE project_id IS NULL`,
|
|
||||||
);
|
|
||||||
// The cold-storage archive is also partitioned (PR #2007 review
|
|
||||||
// P1); migrated snapshots must be owned by this project too.
|
|
||||||
await connections.migration.execute(
|
|
||||||
drizzleSql`UPDATE archive.archived_tasks SET project_id = ${stampProjectId} WHERE project_id IS NULL`,
|
|
||||||
);
|
|
||||||
/*
|
/*
|
||||||
FNXC:CentralProjectIdentity 2026-07-13-22:00:
|
FNXC:CentralProjectIdentity 2026-07-13-23:10:
|
||||||
project.config is keyed by project_id (DEFAULT '' — the legacy
|
The stamping DML (tasks/archived_tasks NULL→id, config ''→id, and
|
||||||
SQLite-parity row). The migrator copies the legacy singleton
|
the workflow_settings/workflow_prompt_overrides rootDir-key→id
|
||||||
config into the '' row, but configScope() has NO bound→''
|
re-key) is shared with `fn db migrate` via
|
||||||
fallback, so a bound reader silently lost the migrated project
|
stampMigratedProjectRows. rootDir is the pre-isolation key for the
|
||||||
settings, workflowSteps, taskPrefix, and nextId floor (defaults
|
workflow tables, so it is passed alongside the stamp id.
|
||||||
returned right after a "successful" migration). Re-key the
|
|
||||||
migrated row to this project. Guarded so a pre-existing
|
|
||||||
per-project row is never clobbered (then the '' row is left for
|
|
||||||
manual reconciliation rather than destroying either copy).
|
|
||||||
*/
|
*/
|
||||||
await connections.migration.execute(
|
const { stampMigratedProjectRows } = await import("./migration-stamping.js");
|
||||||
drizzleSql`UPDATE project.config SET project_id = ${stampProjectId}
|
await stampMigratedProjectRows(connections.migration, {
|
||||||
WHERE project_id = ''
|
projectId: stampProjectId,
|
||||||
AND NOT EXISTS (SELECT 1 FROM project.config WHERE project_id = ${stampProjectId})`,
|
rootDir,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
/*
|
/*
|
||||||
FNXC:PostgresMigrationBanner 2026-07-12:
|
FNXC:PostgresMigrationBanner 2026-07-12:
|
||||||
|
|||||||
@@ -108,6 +108,20 @@ export async function getConfiguredPrefixAndLegacyNextId(
|
|||||||
* The table is scanned in application code (not SQL) because the prefix/sequence
|
* The table is scanned in application code (not SQL) because the prefix/sequence
|
||||||
* are embedded in the string id column, not a separate numeric column. This
|
* are embedded in the string id column, not a separate numeric column. This
|
||||||
* mirrors the sync `getMaxTaskSequenceFromTable()` exactly.
|
* mirrors the sync `getMaxTaskSequenceFromTable()` exactly.
|
||||||
|
*
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
|
||||||
|
* This scan is deliberately GLOBAL — filtered by prefix only, NEVER by
|
||||||
|
* project_id — because task ids are a globally-unique namespace across the whole
|
||||||
|
* embedded-PG cluster (`project.tasks.id` is a global PRIMARY KEY, shared by all
|
||||||
|
* projects in the one `project` schema). Two projects that share a prefix (e.g.
|
||||||
|
* both "KB") draw from ONE per-prefix sequence, so the high-water mark that
|
||||||
|
* advances that shared sequence MUST observe every project's tasks. Adding a
|
||||||
|
* `project_id` predicate here would compute a per-project floor that ignores a
|
||||||
|
* sibling project's higher max suffix, letting the allocator mint an id another
|
||||||
|
* project already owns — a tasks.id PK collision on insert and a merge_queue
|
||||||
|
* (task_id PK) collision downstream. Do NOT scope this scan to a project to
|
||||||
|
* "align" it with MultiProjectIsolation's per-project task reads; per-project
|
||||||
|
* scoping belongs only on reporting/board reads, never on id-sequence advancement.
|
||||||
*/
|
*/
|
||||||
async function getMaxTaskSequenceFromTable(
|
async function getMaxTaskSequenceFromTable(
|
||||||
db: AsyncDataLayer["db"] | DbTransaction,
|
db: AsyncDataLayer["db"] | DbTransaction,
|
||||||
@@ -167,6 +181,20 @@ async function getMaxReservationSequence(
|
|||||||
* This is the core of VAL-DATA-007. Every known prefix gets bumped to at least
|
* This is the core of VAL-DATA-007. Every known prefix gets bumped to at least
|
||||||
* one past the highest in-use suffix across tasks, archived tasks, and
|
* one past the highest in-use suffix across tasks, archived tasks, and
|
||||||
* reservations so a newly-allocated id never collides with an existing one.
|
* reservations so a newly-allocated id never collides with an existing one.
|
||||||
|
*
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
|
||||||
|
* `projectId` is threaded here for ONE purpose only: scoping the config-row read
|
||||||
|
* (`getConfiguredPrefixAndLegacyNextId`) so a per-project `config.next_id` legacy
|
||||||
|
* floor is read from the bound project's row. That legacy value can only RAISE
|
||||||
|
* the floor (via `Math.max`), never lower the shared sequence. The three
|
||||||
|
* high-water scans below (tasks / archived_tasks / reservations) stay GLOBAL —
|
||||||
|
* they take NO projectId — because the id namespace is global across the cluster
|
||||||
|
* (see getMaxTaskSequenceFromTable). Consequently, with two projects sharing a
|
||||||
|
* prefix, the returned floor is the max in-use suffix across BOTH projects, and
|
||||||
|
* `ensureStateRow`'s `GREATEST(current, floor)` update never moves the shared
|
||||||
|
* `distributed_task_id_state.next_sequence` backward. Net: a per-project floor
|
||||||
|
* can never lower the shared sequence or emit an id below another project's max,
|
||||||
|
* so no cross-project duplicate id is possible.
|
||||||
*/
|
*/
|
||||||
export async function computeNextSequenceFloor(
|
export async function computeNextSequenceFloor(
|
||||||
db: AsyncDataLayer["db"] | DbTransaction,
|
db: AsyncDataLayer["db"] | DbTransaction,
|
||||||
@@ -347,6 +375,13 @@ function formatDistributedTaskId(prefix: string, sequence: number): string {
|
|||||||
* FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-12:35:
|
* FNXC:RuntimeTaskOrchestrationAsync 2026-06-24-12:35:
|
||||||
* Check whether a task ID already exists in the tasks or archived_tasks table.
|
* Check whether a task ID already exists in the tasks or archived_tasks table.
|
||||||
* Used by the async allocator reservation loop to skip past existing IDs.
|
* Used by the async allocator reservation loop to skip past existing IDs.
|
||||||
|
*
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
|
||||||
|
* Defense-in-depth existence probe: matched by exact task id ONLY, never scoped
|
||||||
|
* by project_id, so it detects an id owned by ANY project on the shared cluster.
|
||||||
|
* This backstops the global sequence floor — even if the shared sequence somehow
|
||||||
|
* pointed at a taken id, the reserve loop skips forward until it finds one no
|
||||||
|
* project holds, keeping the global tasks.id namespace collision-free.
|
||||||
*/
|
*/
|
||||||
async function taskIdExists(
|
async function taskIdExists(
|
||||||
tx: DbTransaction,
|
tx: DbTransaction,
|
||||||
|
|||||||
@@ -710,6 +710,34 @@ export async function resolveWorkflowSettingDeclarationsImpl(store: TaskStore,
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getWorkflowSettingsProjectIdImpl(store: TaskStore): string {
|
export function getWorkflowSettingsProjectIdImpl(store: TaskStore): string {
|
||||||
|
/*
|
||||||
|
* FNXC:CentralProjectIdentity 2026-07-13-22:40:
|
||||||
|
* This is the SINGLE seam that produces the `project_id` key for the
|
||||||
|
* `workflow_settings` / `workflow_prompt_overrides` tables (keyed by
|
||||||
|
* (workflow_id, project_id)). Project identity ALWAYS comes from the
|
||||||
|
* central-registry id when available; rootDir is only a filesystem root /
|
||||||
|
* last-resort legacy key.
|
||||||
|
*
|
||||||
|
* Resolution order:
|
||||||
|
* (a) `store.asyncLayer?.projectId` — backend (PostgreSQL) mode bound to a
|
||||||
|
* central-registry project (e.g. "proj_2f4be0f31a404d2c"). This is the
|
||||||
|
* id the rest of the system partitions by, so workflow settings MUST
|
||||||
|
* key by it too.
|
||||||
|
* (b) `store.db.getProjectIdentity()?.id` — legacy SQLite identity id.
|
||||||
|
* (c) `store.rootDir` — absolute filesystem path, last-resort legacy key.
|
||||||
|
*
|
||||||
|
* BUG this fixes: the old code went straight to (b). In backend mode
|
||||||
|
* `store.db` is a SQLite stub whose `getProjectIdentity()` THROWS
|
||||||
|
* (throwSqliteRemoved), so the catch ALWAYS returned `store.rootDir` — an
|
||||||
|
* absolute path like "/Users/…/kb". Meanwhile every other backend-mode read/
|
||||||
|
* write partitions by the central-registry id, so workflow settings landed
|
||||||
|
* under a rootDir key that nothing else could find (settings looked "reset").
|
||||||
|
*
|
||||||
|
* Legacy rows still keyed by rootDir / the old identity id are re-keyed by
|
||||||
|
* migration stamping (owned elsewhere — see the PG startup/migration path).
|
||||||
|
*/
|
||||||
|
const boundProjectId = store.asyncLayer?.projectId;
|
||||||
|
if (boundProjectId) return boundProjectId;
|
||||||
try {
|
try {
|
||||||
return store.db.getProjectIdentity()?.id ?? store.rootDir;
|
return store.db.getProjectIdentity()?.id ?? store.rootDir;
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user