diff --git a/.changeset/pg-central-project-identity-binding.md b/.changeset/pg-central-project-identity-binding.md new file mode 100644 index 0000000000..6e09cebf09 --- /dev/null +++ b/.changeset/pg-central-project-identity-binding.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Bind dashboard/serve stores to the central project registry instead of relying on cwd identity. +category: fix +dev: createTaskStoreForBackend now resolves the central-registry project id for rootDir-only boots (fn dashboard, fn serve, desktop, per-path project stores) and binds the AsyncDataLayer to it — cwd/rootDir is only a lookup key into central.projects; identity/partitioning comes from the registry. Also re-keys the migrated legacy config row ('' → project id) during first-boot auto-migration so bound readers keep the migrated settings, workflowSteps, taskPrefix, and nextId counters. Unregistered paths still boot unbound (legacy single-project behavior). 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 0990565fb7..94c4a8d6d4 100644 --- a/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts +++ b/packages/core/src/__tests__/postgres/startup-factory-integration.test.ts @@ -209,6 +209,18 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { 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"); + // Legacy singleton config row — must be re-keyed from '' to the + // registered project id so bound configScope readers still see the + // migrated settings (FNXC:CentralProjectIdentity). + legacy.exec(`CREATE TABLE IF NOT EXISTS config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + settings TEXT DEFAULT '{}', + updatedAt TEXT + );`); + legacy.prepare(`INSERT INTO config (id, settings, updatedAt) VALUES (1, ?, ?)`).run( + JSON.stringify({ taskPrefix: "ST", merger: { mode: "ai" } }), + "2026-06-01T00:00:00Z", + ); } finally { legacy.close(); } @@ -239,6 +251,13 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { expect(boot).not.toBeNull(); try { const layer = boot!.taskStore.getAsyncLayer()!; + /* + FNXC:CentralProjectIdentity 2026-07-13-22:00: + A rootDir-only boot of a REGISTERED project must bind its layer to the + registry id — cwd/rootDir is only the lookup key; identity comes from + central.projects. + */ + expect(layer.projectId, "rootDir-only boot must bind to the registered project id").toBe("proj_stamp_test"); 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 }>; @@ -246,6 +265,15 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => { for (const row of rows) { expect(row.project_id, `${row.id} must be stamped with the registered project id`).toBe("proj_stamp_test"); } + // The migrated legacy config row ('' key) must be re-keyed to the + // project so bound settings reads see the migrated settings. + const configRows = (await layer.db.execute( + `SELECT project_id, settings FROM project.config`, + )) as unknown as Array<{ project_id: string; settings: { taskPrefix?: string } | null }>; + const projectConfig = configRows.find((r) => r.project_id === "proj_stamp_test"); + expect(projectConfig, "migrated config row must be re-keyed to the project").toBeDefined(); + expect(projectConfig!.settings?.taskPrefix).toBe("ST"); + expect(configRows.some((r) => r.project_id === ""), "no orphaned '' config row").toBe(false); } finally { await boot!.shutdown(); } diff --git a/packages/core/src/postgres/startup-factory.ts b/packages/core/src/postgres/startup-factory.ts index 3df22dd1e0..850fc21594 100644 --- a/packages/core/src/postgres/startup-factory.ts +++ b/packages/core/src/postgres/startup-factory.ts @@ -370,6 +370,29 @@ export async function createTaskStoreForBackend( let autoMigrationNotice: | { migratedAt: string; migratedRows: number; tables: number; sqliteBackups: string[] } | undefined; + /* + FNXC:CentralProjectIdentity 2026-07-13-22:00: + Resolve the central-registry project id for a rootDir-booted store. Post + de-cwd architecture: cwd/rootDir is ONLY a lookup key into central.projects; + project IDENTITY (the partition key every task/config read and write is + scoped by) comes from the registry. Before this, `fn dashboard` / `fn serve` + booted their main store UNBOUND (rootDir only), so unscoped API requests + read and wrote NULL-project_id rows on the shared embedded cluster while the + projectId-bound engine could not see them. Returns undefined when the path + is not registered (legacy/unregistered single-project setups stay unbound, + matching their unfiltered readers). + */ + const lookupRegisteredProjectIdByPath = async (): Promise => { + if (!rootDir) return undefined; + try { + const projectRows = (await connections.migration.execute( + drizzleSql`SELECT id FROM central.projects WHERE path = ${rootDir} LIMIT 1`, + )) as Array<{ id: string }>; + return projectRows[0]?.id; + } catch { + return undefined; + } + }; if (rootDir) { try { const fusionDir = join(rootDir, ".fusion"); @@ -447,17 +470,7 @@ export async function createTaskStoreForBackend( 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; - } - } + const stampProjectId = options.projectId ?? (await lookupRegisteredProjectIdByPath()); if (stampProjectId) { await connections.migration.execute( drizzleSql`UPDATE project.tasks SET project_id = ${stampProjectId} WHERE project_id IS NULL`, @@ -470,6 +483,23 @@ export async function createTaskStoreForBackend( 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: + 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 connections.migration.execute( + drizzleSql`UPDATE project.config SET project_id = ${stampProjectId} + WHERE project_id = '' + AND NOT EXISTS (SELECT 1 FROM project.config WHERE project_id = ${stampProjectId})`, + ); } /* FNXC:PostgresMigrationBanner 2026-07-12: @@ -508,7 +538,18 @@ export async function createTaskStoreForBackend( // 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 }); + /* + FNXC:CentralProjectIdentity 2026-07-13-22:00: + rootDir-only boots (fn dashboard / fn serve / desktop / per-path project + stores) now ALSO bind: when the rootDir is a centrally-registered project, + its registry id becomes the layer's partition key. cwd/rootDir is only the + lookup key; identity comes from central.projects. Runs after Step 5.5 so a + first boot resolves against the registry the migration just populated. + Unregistered paths resolve to undefined and boot unbound, preserving legacy + single-project behavior. + */ + const resolvedProjectId = options.projectId ?? (await lookupRegisteredProjectIdByPath()); + const asyncLayer = createAsyncDataLayer(connections, { projectId: resolvedProjectId }); // Step 7: construct the TaskStore in backend mode. /*