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:
7
.changeset/pg-central-project-identity-binding.md
Normal file
7
.changeset/pg-central-project-identity-binding.md
Normal 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).
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user