fix: bind rootDir boots to the central project registry and re-key the migrated config row

Implements the central-project-identity architecture: cwd/rootDir is ONLY a
lookup key into central.projects; project identity (the partition key for
every task/config read and write) comes from the registry.

- createTaskStoreForBackend resolves the registered project id by path for
  rootDir-only boots and binds the AsyncDataLayer to it. Previously
  'fn dashboard' / 'fn serve' / desktop booted their main store UNBOUND, so
  unscoped API requests wrote NULL-project_id rows the projectId-bound engine
  could never see, and unbound config reads (id = 1) were indeterminate once
  multiple per-project rows existed. The engine already worked registry-first
  (resolveLocalProjectWorkingDirectory); this brings the store boots in line.
- Step 5.5 auto-migration now also re-keys the migrated legacy config row
  ('' -> project id, guarded against clobbering an existing per-project row).
  configScope() has no bound->'' fallback, so the migrated project settings,
  workflowSteps, taskPrefix, and nextId counters were silently invisible to
  bound readers right after a successful migration.
- Unregistered paths resolve to undefined and boot unbound, preserving legacy
  single-project behavior with unfiltered readers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-13 21:26:09 -07:00
parent 0f3a3d3f49
commit 3ccc9f96e6
3 changed files with 88 additions and 12 deletions

View File

@@ -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).

View File

@@ -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();
}

View File

@@ -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<string | undefined> => {
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.
/*