fix: stamp migrated task rows with the central-registry project id on rootDir-only boots

The SQLite -> PostgreSQL auto-migration leaves project_id NULL and Step 5.5
only stamped rows when options.projectId was bound — but 'fn dashboard' in the
project directory (the main cutover path) boots with rootDir only, so every
migrated row stayed NULL, project-bound readers (engine InProcessRuntime,
dashboard project-store-resolver) filtered them all out, and the board showed
no tasks right after a successful migration. The stamping id is now resolved
from the freshly-migrated central registry by matching the registered project
path to rootDir; projects never registered centrally keep NULL rows, matching
their unbound readers. Integration test covers the rootDir-only stamp.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-13 21:05:57 -07:00
parent 7aa969892a
commit 0f3a3d3f49
3 changed files with 112 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix empty task board after the PostgreSQL migration when booting via fn dashboard.
category: fix
dev: The first-boot auto-migration only stamped migrated rows' project_id when the boot passed a bound projectId, but `fn dashboard` boots with rootDir only — so rows stayed NULL and every project-bound reader (engine, project-store-resolver) filtered them out. The stamping id is now resolved from the just-migrated central registry by matching the registered project path to rootDir; unregistered single-project setups still leave rows NULL for their unbound (unfiltered) readers.

View File

@@ -175,4 +175,79 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => {
await second!.shutdown();
}
});
/*
FNXC:MultiProjectIsolation 2026-07-13-21:20:
A rootDir-only boot (`fn dashboard` in the project directory — the main
cutover path) must still stamp migrated NULL-project_id rows when the
central registry knows the project. The previous `if (options.projectId)`
guard skipped stamping on exactly this path, so every project-bound reader
(engine, project-store-resolver) filtered the migrated tasks out and the
board showed empty right after a successful migration.
*/
it("stamps migrated rows with the central-registry project id on a rootDir-only boot", async () => {
rootDir = await mkdtemp(join(tmpdir(), "startup-factory-stamp-"));
dbName = uniqueDbName();
adminExec(`CREATE DATABASE "${dbName}"`);
const testUrl = `${PG_TEST_URL_BASE}/${dbName}`;
const fusionDir = join(rootDir, ".fusion");
const globalDir = join(rootDir, ".fusion-global");
mkdirSync(fusionDir, { recursive: true });
mkdirSync(globalDir, { recursive: true });
const legacy = new DatabaseSync(join(fusionDir, "fusion.db"));
try {
legacy.exec(`CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT,
description TEXT NOT NULL,
"column" TEXT NOT NULL,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);`);
legacy.prepare(
`INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`,
).run("FN-STAMP-1", "Stamped task", "migrated from sqlite", "todo", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z");
} finally {
legacy.close();
}
// Legacy central registry that knows this project by its rootDir path.
const legacyCentral = new DatabaseSync(join(globalDir, "fusion-central.db"));
try {
legacyCentral.exec(`CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL
);`);
legacyCentral.prepare(
`INSERT INTO projects (id, name, path, status, createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`,
).run("proj_stamp_test", "Stamp Test", rootDir, "active", "2026-06-01T00:00:00Z", "2026-06-01T00:00:00Z");
} finally {
legacyCentral.close();
}
const boot = await createTaskStoreForBackend({
rootDir,
globalSettingsDir: globalDir,
env: { DATABASE_URL: testUrl },
});
expect(boot).not.toBeNull();
try {
const layer = boot!.taskStore.getAsyncLayer()!;
const rows = (await layer.db.execute(
`SELECT id, project_id FROM project.tasks ORDER BY id`,
)) as unknown as Array<{ id: string; project_id: string | null }>;
expect(rows.map((r) => r.id)).toContain("FN-STAMP-1");
for (const row of rows) {
expect(row.project_id, `${row.id} must be stamped with the registered project id`).toBe("proj_stamp_test");
}
} finally {
await boot!.shutdown();
}
});
});

View File

@@ -432,17 +432,43 @@ export async function createTaskStoreForBackend(
scoped emptiness check above guarantees every NULL-project_id row
in tasks/archived_tasks was written by THIS migration pass.
*/
if (options.projectId) {
/*
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 rows stayed NULL, every project-bound
reader (engine InProcessRuntime, dashboard project-store-resolver)
filtered them out, and the board showed no tasks right after a
successful migration. When no projectId is bound, resolve it from
the just-migrated central registry by matching the registered
project path to this rootDir. If the project was never registered
centrally, leave rows NULL — readers for unregistered
single-project setups use an unbound layer with no scope filter.
*/
let stampProjectId = options.projectId;
if (!stampProjectId) {
try {
const projectRows = (await connections.migration.execute(
drizzleSql`SELECT id FROM central.projects WHERE path = ${rootDir} LIMIT 1`,
)) as Array<{ id: string }>;
stampProjectId = projectRows[0]?.id;
} catch {
stampProjectId = undefined;
}
}
if (stampProjectId) {
await connections.migration.execute(
drizzleSql`UPDATE project.tasks SET project_id = ${options.projectId} WHERE project_id IS NULL`,
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 = ${options.projectId} WHERE project_id IS NULL`,
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 = ${options.projectId} WHERE project_id IS NULL`,
drizzleSql`UPDATE archive.archived_tasks SET project_id = ${stampProjectId} WHERE project_id IS NULL`,
);
}
/*