From 0f3a3d3f495392a2c0d68ff90033a031b966d5c8 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Mon, 13 Jul 2026 21:05:57 -0700 Subject: [PATCH] fix: stamp migrated task rows with the central-registry project id on rootDir-only boots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...migration-stamp-project-id-rootdir-boot.md | 7 ++ .../startup-factory-integration.test.ts | 75 +++++++++++++++++++ packages/core/src/postgres/startup-factory.ts | 34 ++++++++- 3 files changed, 112 insertions(+), 4 deletions(-) create mode 100644 .changeset/pg-migration-stamp-project-id-rootdir-boot.md diff --git a/.changeset/pg-migration-stamp-project-id-rootdir-boot.md b/.changeset/pg-migration-stamp-project-id-rootdir-boot.md new file mode 100644 index 0000000000..a03943dbb9 --- /dev/null +++ b/.changeset/pg-migration-stamp-project-id-rootdir-boot.md @@ -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. diff --git a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts index 26c01f4447..0990565fb7 100644 --- a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts +++ b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts @@ -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(); + } + }); }); diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index d29d7ac5e9..3df22dd1e0 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -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`, ); } /*