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(
|
legacy.prepare(
|
||||||
`INSERT INTO tasks (id, title, description, "column", createdAt, updatedAt) VALUES (?, ?, ?, ?, ?, ?)`,
|
`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");
|
).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 {
|
} finally {
|
||||||
legacy.close();
|
legacy.close();
|
||||||
}
|
}
|
||||||
@@ -239,6 +251,13 @@ pgDescribe("startup-factory: external PostgreSQL boot (integration)", () => {
|
|||||||
expect(boot).not.toBeNull();
|
expect(boot).not.toBeNull();
|
||||||
try {
|
try {
|
||||||
const layer = boot!.taskStore.getAsyncLayer()!;
|
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(
|
const rows = (await layer.db.execute(
|
||||||
`SELECT id, project_id FROM project.tasks ORDER BY id`,
|
`SELECT id, project_id FROM project.tasks ORDER BY id`,
|
||||||
)) as unknown as Array<{ id: string; project_id: string | null }>;
|
)) 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) {
|
for (const row of rows) {
|
||||||
expect(row.project_id, `${row.id} must be stamped with the registered project id`).toBe("proj_stamp_test");
|
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 {
|
} finally {
|
||||||
await boot!.shutdown();
|
await boot!.shutdown();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -370,6 +370,29 @@ export async function createTaskStoreForBackend(
|
|||||||
let autoMigrationNotice:
|
let autoMigrationNotice:
|
||||||
| { migratedAt: string; migratedRows: number; tables: number; sqliteBackups: string[] }
|
| { migratedAt: string; migratedRows: number; tables: number; sqliteBackups: string[] }
|
||||||
| undefined;
|
| 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) {
|
if (rootDir) {
|
||||||
try {
|
try {
|
||||||
const fusionDir = join(rootDir, ".fusion");
|
const fusionDir = join(rootDir, ".fusion");
|
||||||
@@ -447,17 +470,7 @@ export async function createTaskStoreForBackend(
|
|||||||
centrally, leave rows NULL — readers for unregistered
|
centrally, leave rows NULL — readers for unregistered
|
||||||
single-project setups use an unbound layer with no scope filter.
|
single-project setups use an unbound layer with no scope filter.
|
||||||
*/
|
*/
|
||||||
let stampProjectId = options.projectId;
|
const stampProjectId = options.projectId ?? (await lookupRegisteredProjectIdByPath());
|
||||||
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) {
|
if (stampProjectId) {
|
||||||
await connections.migration.execute(
|
await connections.migration.execute(
|
||||||
drizzleSql`UPDATE project.tasks SET project_id = ${stampProjectId} WHERE project_id IS NULL`,
|
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(
|
await connections.migration.execute(
|
||||||
drizzleSql`UPDATE archive.archived_tasks SET project_id = ${stampProjectId} WHERE project_id IS NULL`,
|
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:
|
FNXC:PostgresMigrationBanner 2026-07-12:
|
||||||
@@ -508,7 +538,18 @@ export async function createTaskStoreForBackend(
|
|||||||
// single project. options.projectId is the central-registry ID both the
|
// single project. options.projectId is the central-registry ID both the
|
||||||
// dashboard (getOrCreateProjectStore) and the engine (InProcessRuntime) pass,
|
// dashboard (getOrCreateProjectStore) and the engine (InProcessRuntime) pass,
|
||||||
// so a task's row is stamped and filtered under one consistent partition key.
|
// 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.
|
// Step 7: construct the TaskStore in backend mode.
|
||||||
/*
|
/*
|
||||||
|
|||||||
Reference in New Issue
Block a user