fix: stamp migrated task rows with the central-registry project id on rootDir-only boots
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 <noreply@anthropic.com>
This commit is contained in:
7
.changeset/pg-migration-stamp-project-id-rootdir-boot.md
Normal file
7
.changeset/pg-migration-stamp-project-id-rootdir-boot.md
Normal file
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
/*
|
||||
|
||||
Reference in New Issue
Block a user