FN-9004: reconcile PostgreSQL project ownership defaults

Reconcile PostgreSQL project ownership defaults and schema declarations.

- Add an idempotent migration restoring the GitHub check-state project default.
- Align Drizzle project ownership defaults and composite keys with PostgreSQL.
- Cover schema declarations and upgrade migration behavior.

Files changed:
 .changeset/fn-9004-project-ownership-defaults.md   |   7 +
 docs/storage.md                                    |   1 +
 ...ema-ownership-declarations-remaining.pg.test.ts | 170 +++++++++++++++++++++
 .../src/__tests__/postgres/schema-applier.test.ts  |   9 +-
 ...04_project_ownership_default_reconciliation.sql |  13 ++
 packages/core/src/postgres/schema-applier.ts       |  13 +-
 packages/core/src/postgres/schema/project.ts       |  47 ++++--
 7 files changed, 248 insertions(+), 12 deletions(-)

Fusion-Task-Id: FN-9004

Fusion-Task-Lineage: 43bb14f6-01b7-42be-a47c-7da1128f465e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-12 09:13:04 -07:00
parent b1dad5c9be
commit b6839f4f03
7 changed files with 248 additions and 12 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Reconcile PostgreSQL GitHub check-state ownership defaults during upgrades.
category: fix
dev: Migration 0057 restores the project_id ownership default for github_check_states.

View File

@@ -37,6 +37,7 @@ See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-revi
- The `0000_initial.sql` baseline defines the table and indexes only. The later `0025_symbol_locks.sql` migration enables and forces RLS, creates `fusion_project_isolation`, and attaches `fusion_assign_project_id` after `0006_project_ownership.sql` creates that function/policy machinery. Both fresh full-applier and upgrade paths therefore end with the same project-isolation contract.
- `project.agent_ratings` is project-owned with composite `(project_id, id)` identity, allowing the same rating id in separate projects without cross-project reads or deletes. The dynamic `0006_project_ownership.sql` migration reconciles the physical table; `0055_fn_8988_agent_ratings_project_partition.sql` repeats that guarantee idempotently for historical drift. Bound `addRating`, `getRatings`, and `deleteRating` apply the project ownership partition, while unbound compatibility layers retain trigger-stamped writes and unscoped reads/deletes.
- FN-8997 audited `workflow_steps`, `chat_room_members`, and `chat_room_messages`: their Drizzle declarations now model 0006's `project_id` and composite keys, and bound workflow/chat helpers scope reads and mutations on that partition. Chat isolation requires both membership/message predicates **and** the parent `chat_rooms.project_id` predicate; either leg alone can resolve a foreign row when room IDs collide. `plugins` remains an intentionally unmodeled compatibility table because it has no runtime Drizzle path. Migration `0056_fn_8997_project_ownership_declaration_drift.sql` is idempotent and adds only partition-prefixed predicate indexes; it does not rewrite healthy 0006 ownership columns or keys.
- FN-9004 reconciles the post-0006 `project.github_check_states` ownership default. Migration `0057_fn_9004_project_ownership_default_reconciliation.sql` idempotently restores the `0006` GUC/legacy-fallback default without rewriting rows, keys, RLS, or triggers; the `config`, `automations`, `deployments`, and `incidents` Drizzle declarations now also model their catalog-proven project-leading keys.
- `project.workflows` has project-local `(project_id, id)` identity. Bound definition reads, updates, deletes, companion workflow settings/prompt-override deletes, and analytics name prefetches use `projectScopeFor`; blank/unbound layers deliberately retain cross-project compatibility reads. The per-project workflow-id counter intentionally scans occupancy across every partition before allocation, because burning a colliding ID is safer than reusing a legacy or stale-counter ID held elsewhere.
- FN-9002 makes the `0006_project_ownership.sql` partition expressible for `artifacts`, `secrets`, `branch_groups`, `plugin_activations`, `chat_messages`, `run_audit_events`, `verification_cache`, and `approval_requests`: each declaration retains the database default and models its project-leading identity (plus the artifacts task FK and secrets/branch-name unique keys).
- FN-9000 scopes every load-bearing runtime Drizzle read, update, and delete for those eight tables with the bound `projectId`; blank/unbound layers intentionally retain cross-project compatibility reads. Chat message operations scope both `chat_messages` and their parent `chat_sessions` row. The `central.secrets_global` dispatch remains global, while `project.plugins` has no runtime Drizzle path and pre-cutover SQLite compatibility paths remain unchanged. Verification-cache entries now remain inside their owning project rather than being shared across projects.

View File

@@ -0,0 +1,170 @@
import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest";
import { getTableConfig } from "drizzle-orm/pg-core";
import * as schema from "../../postgres/schema/index.js";
import type { AsyncDataLayer } from "../../postgres/data-layer.js";
import { createScheduleRow, listSchedules } from "../../async-stores/async-automation-store.js";
import {
getIncidentAsync,
ingestIncidentSignalAsync,
recordDeploymentAsync,
} from "../../task-store/async/async-monitor.js";
import {
listGitHubCheckStatesAsync,
recordGitHubCheckStateAsync,
} from "../../task-store/async/async-ci-checks.js";
import {
patchProjectSettings,
readProjectConfig,
writeProjectConfig,
} from "../../task-store/async/async-settings.js";
import {
createSharedPgTaskStoreTestHarness,
pgDescribe,
type SharedPgTaskStoreHarness,
} from "../../__test-utils__/pg-test-harness.js";
const tableNames = ["config", "automations", "deployments", "github_check_states", "incidents"] as const;
const declaredTables = {
config: schema.project.config,
automations: schema.project.automations,
deployments: schema.project.deployments,
github_check_states: schema.project.githubCheckStates,
incidents: schema.project.incidents,
} as const;
const expectedPrimaryKeys: Record<(typeof tableNames)[number], string[]> = {
config: ["project_id"], automations: ["project_id", "id"], deployments: ["project_id", "id"],
github_check_states: ["project_id", "id"], incidents: ["project_id", "id"],
};
pgDescribe("remaining project schema ownership declarations", () => {
const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_fn9004_schema", projectId: "fn9004-runtime-bound",
});
const unboundHarness: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({
prefix: "fusion_fn9004_schema_unbound",
});
beforeAll(async () => { await h.beforeAll(); await unboundHarness.beforeAll(); });
afterAll(async () => { await unboundHarness.afterAll(); await h.afterAll(); });
beforeEach(async () => { await h.beforeEach(); await unboundHarness.beforeEach(); });
afterEach(async () => { await unboundHarness.afterEach(); await h.afterEach(); });
it("matches the post-0006 catalog default and project-leading keys", async () => {
/*
FNXC:MultiProjectIsolation 2026-08-12-15:43:
The declaration sweep must query PostgreSQL rather than infer physical keys from source
migrations. This catches post-0006 tables such as github_check_states that missed its default.
*/
for (const tableName of tableNames) {
const columns = await h.adminSql()<Array<{ data_type: string; is_nullable: string; column_default: string | null }>>`
SELECT data_type, is_nullable, column_default FROM information_schema.columns
WHERE table_schema = 'project' AND table_name = ${tableName} AND column_name = 'project_id'
`;
expect(columns).toEqual([expect.objectContaining({ data_type: "text", is_nullable: "NO", column_default: expect.stringContaining("current_setting") })]);
const constraints = await h.adminSql()<Array<{ conname: string; contype: string; columns: string[] }>>`
SELECT c.conname, c.contype, array_agg(a.attname ORDER BY k.ordinality) AS columns
FROM pg_constraint c CROSS JOIN LATERAL unnest(c.conkey) WITH ORDINALITY k(attnum, ordinality)
JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
WHERE c.conrelid = ('project.' || ${tableName})::regclass AND c.contype IN ('p', 'u')
GROUP BY c.conname, c.contype ORDER BY c.contype, c.conname
`;
expect(constraints.find((constraint) => constraint.contype === "p")?.columns).toEqual(expectedPrimaryKeys[tableName]);
for (const constraint of constraints) expect(constraint.columns[0]).toBe("project_id");
const declaration = getTableConfig(declaredTables[tableName]);
expect(declaration.columns.find((column) => column.name === "project_id")?.hasDefault).toBe(true);
const declaredPrimaryKey = tableName === "config"
? declaration.columns.filter((column) => column.primary).map((column) => column.name)
: declaration.primaryKeys[0]?.columns.map((column) => column.name);
expect(declaredPrimaryKey).toEqual(expectedPrimaryKeys[tableName]);
expect(declaration.uniqueConstraints.map((constraint) => ({ name: constraint.getName(), columns: constraint.columns.map((column) => column.name) })))
.toEqual(expect.arrayContaining(constraints.filter((constraint) => constraint.contype === "u").map((constraint) => ({ name: constraint.conname, columns: constraint.columns }))));
}
});
it("preserves production writer ownership, conflict targets, and scoped reads", async () => {
/*
FNXC:MultiProjectIsolation 2026-08-12-15:57:
Declaration introspection alone cannot prove Drizzle's composite conflict targets or public
mappers. Exercise each owner-writing production helper through bound and unbound layers.
*/
const bind = (projectId: string): AsyncDataLayer => ({ ...h.layer(), projectId });
const projectA = bind("fn9004-writer-a");
const projectB = bind("fn9004-writer-b");
const unbound = unboundHarness.layer();
const schedule = (id: string) => ({
id, name: "Shared schedule", scheduleType: "custom" as const, cronExpression: "* * * * *",
command: "echo shared", enabled: true, runCount: 0, runHistory: [],
createdAt: "2026-08-12T00:00:00.000Z", updatedAt: "2026-08-12T00:00:00.000Z",
});
await createScheduleRow(projectA, schedule("shared"));
await createScheduleRow(projectB, schedule("shared"));
expect(await listSchedules(projectA)).toMatchObject([{ id: "shared" }]);
expect(await listSchedules(projectB)).toMatchObject([{ id: "shared" }]);
await expect(createScheduleRow(unbound, schedule("unbound"))).rejects.toThrow("require asyncLayer.projectId");
await recordDeploymentAsync(projectA.db, { deploymentId: "shared", status: "started" }, projectA.projectId);
await expect(recordDeploymentAsync(projectA.db, { deploymentId: "shared", status: "complete" }, projectA.projectId))
.resolves.toMatchObject({ deploymentId: "shared", status: "complete" });
await recordDeploymentAsync(projectB.db, { deploymentId: "shared", status: "started" }, projectB.projectId);
await recordDeploymentAsync(unbound.db, { deploymentId: "unbound" }, "");
const incidentA = await ingestIncidentSignalAsync(projectA.db, {
groupingKey: "shared", title: "Project A incident", at: "2026-08-12T00:00:00.000Z",
}, projectA.projectId);
const incidentB = await ingestIncidentSignalAsync(projectB.db, {
groupingKey: "shared", title: "Project B incident", at: "2026-08-12T00:00:00.000Z",
}, projectB.projectId);
const unboundIncident = await ingestIncidentSignalAsync(unbound.db, {
groupingKey: "unbound", title: "Legacy incident", at: "2026-08-12T00:00:00.000Z",
}, "");
expect(await getIncidentAsync(projectA.db, incidentA.incident.incidentId, projectA.projectId)).toMatchObject({ title: "Project A incident" });
expect(await getIncidentAsync(projectB.db, incidentA.incident.incidentId, projectB.projectId)).toBeNull();
expect(await getIncidentAsync(unbound.db, unboundIncident.incident.incidentId, "")).toMatchObject({ title: "Legacy incident" });
expect(incidentB.incident.incidentId).not.toBe(incidentA.incident.incidentId);
const check = { repo: "Owner/Repo", headSha: "shared", checkName: "ci/build", state: "success", reportedAt: "2026-08-12T00:00:00.000Z" };
await recordGitHubCheckStateAsync(projectA, check, projectA.projectId!);
await recordGitHubCheckStateAsync(projectB, check, projectB.projectId!);
expect(await listGitHubCheckStatesAsync(projectA, { repo: check.repo, headSha: check.headSha }, projectA.projectId!)).toHaveLength(1);
expect(await listGitHubCheckStatesAsync(projectB, { repo: check.repo, headSha: check.headSha }, projectB.projectId!)).toHaveLength(1);
await expect(recordGitHubCheckStateAsync(unbound, check, " ")).rejects.toThrow("require asyncLayer.projectId");
await writeProjectConfig(projectA, { owner: "a" });
await patchProjectSettings(projectA, { patched: true });
await writeProjectConfig(projectB, { owner: "b" });
expect(await readProjectConfig(projectA)).toMatchObject({ settings: { owner: "a", patched: true } });
expect(await readProjectConfig(projectB)).toMatchObject({ settings: { owner: "b" } });
await writeProjectConfig(unbound, { owner: "legacy" });
expect(await readProjectConfig(unbound)).toMatchObject({ settings: { owner: "legacy" } });
expect(await h.adminSql()<Array<{ project_id: string; deployment_id: string }>>`
SELECT project_id, deployment_id FROM project.deployments
WHERE deployment_id = 'shared' ORDER BY project_id
`).toEqual([
{ project_id: "fn9004-writer-a", deployment_id: "shared" },
{ project_id: "fn9004-writer-b", deployment_id: "shared" },
]);
expect(await unboundHarness.adminSql()<Array<{ project_id: string }>>`
SELECT project_id FROM project.deployments WHERE deployment_id = 'unbound'
`).toEqual([{ project_id: "__legacy_unscoped__" }]);
});
it("stamps omitted owners and permits duplicate natural identities per partition", async () => {
const insertRows = async (projectId: string): Promise<void> => {
await h.adminSql()`SELECT set_config('fusion.project_id', ${projectId}, false)`;
await h.adminSql()`INSERT INTO project.config (id, updated_at) VALUES (1, '2026-08-12')`;
await h.adminSql()`INSERT INTO project.automations (id, name, schedule_type, cron_expression, command, created_at, updated_at) VALUES ('shared', 'Shared', 'cron', '* * * * *', 'echo shared', '2026-08-12', '2026-08-12')`;
await h.adminSql()`INSERT INTO project.deployments (deployment_id, deployed_at, created_at) VALUES ('shared', '2026-08-12', '2026-08-12')`;
await h.adminSql()`INSERT INTO project.github_check_states (repo, head_sha, check_name, state, reported_at, received_at, created_at, updated_at) VALUES ('repo', 'sha', 'check', 'success', '2026-08-12', '2026-08-12', '2026-08-12', '2026-08-12')`;
await h.adminSql()`INSERT INTO project.incidents (incident_id, grouping_key, title, status, opened_at, created_at, updated_at) VALUES ('shared', 'group', 'Shared', 'open', '2026-08-12', '2026-08-12', '2026-08-12')`;
};
await insertRows("fn9004-a"); await insertRows("fn9004-b");
for (const tableName of tableNames) {
const rows = await h.adminSql()<Array<{ count: string }>>`SELECT count(*)::text AS count FROM project.${h.adminSql().unsafe(tableName)} WHERE project_id IN ('fn9004-a', 'fn9004-b')`;
expect(rows).toEqual([{ count: "2" }]);
}
await h.adminSql()`SELECT set_config('fusion.project_id', '', false)`;
await h.adminSql()`INSERT INTO project.deployments (deployment_id, deployed_at, created_at) VALUES ('unbound', '2026-08-12', '2026-08-12')`;
expect(await h.adminSql()<Array<{ project_id: string }>>`SELECT project_id FROM project.deployments WHERE deployment_id = 'unbound'`)
.toEqual([{ project_id: "__legacy_unscoped__" }]);
});
});

View File

@@ -99,6 +99,7 @@ import {
AGENT_RATING_PROJECT_ISOLATION_VERSION,
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
} from "../../postgres/schema-applier.js";
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
@@ -131,7 +132,8 @@ describe("schema-applier: immutable migration identities", () => {
expect(AGENT_RATING_PROJECT_ISOLATION_VERSION).toBe("0054");
expect(AGENT_RATINGS_PROJECT_PARTITION_VERSION).toBe("0055");
expect(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION).toBe("0056");
expect(SCHEMA_BASELINE_VERSION).toBe("0056");
expect(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION).toBe("0057");
expect(SCHEMA_BASELINE_VERSION).toBe("0057");
});
it("keeps monitor and approval isolation assigned to version 0003", () => {
@@ -1803,6 +1805,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
AGENT_RATING_PROJECT_ISOLATION_VERSION,
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
]);
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
});
@@ -1885,6 +1888,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
AGENT_RATING_PROJECT_ISOLATION_VERSION,
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
]);
});
@@ -2100,6 +2104,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
AGENT_RATING_PROJECT_ISOLATION_VERSION,
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
]);
});
@@ -2196,6 +2201,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
AGENT_RATING_PROJECT_ISOLATION_VERSION,
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
]);
});
@@ -2292,6 +2298,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
AGENT_RATING_PROJECT_ISOLATION_VERSION,
AGENT_RATINGS_PROJECT_PARTITION_VERSION,
PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION,
PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION,
]);
});
});

View File

@@ -0,0 +1,13 @@
/*
FNXC:MultiProjectIsolation 2026-08-12-15:43:
Migration 0048 created github_check_states after ownership migration 0006 and retained its
pre-isolation empty project_id default. Reconcile only that default so omitted ownership values
continue through the database GUC/trigger path without rewriting rows, keys, RLS, or triggers.
*/
DO $$
BEGIN
IF to_regclass('project.github_check_states') IS NOT NULL THEN
ALTER TABLE project.github_check_states
ALTER COLUMN project_id SET DEFAULT COALESCE(NULLIF(current_setting('fusion.project_id', true), ''), '__legacy_unscoped__');
END IF;
END $$;

View File

@@ -62,7 +62,8 @@ capacity-model table drop that landed while this PR was open.
/* FNXC:MemoryRecall 2026-08-10-11:03: Explicit baseline registration prevents the recall migration from being silently skipped. */
/* FNXC:SpecLockMissionAlignment 2026-08-10-16:17: advance the schema ceiling so SQLite and PostgreSQL feature projections retain reconciled drift alignment. */
/* FNXC:MultiProjectIsolation 2026-08-11-10:25: schema startup must register project-local agent ratings before bound stores scope their mutations. */
export const SCHEMA_BASELINE_VERSION = "0056";
/* FNXC:MultiProjectIsolation 2026-08-12-15:43: 0057 reconciles the post-0006 GitHub check-state ownership default on upgrades. */
export const SCHEMA_BASELINE_VERSION = "0057";
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
const INITIAL_SCHEMA_VERSION = "0000";
@@ -217,6 +218,8 @@ export const AGENT_RATING_PROJECT_ISOLATION_VERSION = "0054";
export const AGENT_RATINGS_PROJECT_PARTITION_VERSION = "0055";
/** FNXC:MultiProjectIsolation 2026-08-12-02:12: register FN-8997 predicate indexes explicitly; migration files are never auto-discovered. */
export const PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION = "0056";
/** FNXC:MultiProjectIsolation 2026-08-12-15:43: register the 0048 default reconciliation explicitly for upgrades. */
export const PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION = "0057";
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
@@ -447,6 +450,7 @@ const MISSION_FEATURE_SPEC_ALIGNMENT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0053
const AGENT_RATING_PROJECT_ISOLATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0054_fn_8957_agent_rating_project_isolation.sql");
const AGENT_RATINGS_PROJECT_PARTITION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0055_fn_8988_agent_ratings_project_partition.sql");
const PROJECT_OWNERSHIP_DECLARATION_DRIFT_MIGRATION_PATH = join(MIGRATIONS_DIR, "0056_fn_8997_project_ownership_declaration_drift.sql");
const PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_MIGRATION_PATH = join(MIGRATIONS_DIR, "0057_fn_9004_project_ownership_default_reconciliation.sql");
/**
* Ensure the migration bookkeeping table exists. Lives in the public schema so
@@ -573,6 +577,7 @@ export async function applySchemaBaseline(
const agentRatingProjectIsolationAlreadyApplied = applied.includes(AGENT_RATING_PROJECT_ISOLATION_VERSION);
const agentRatingsProjectPartitionAlreadyApplied = applied.includes(AGENT_RATINGS_PROJECT_PARTITION_VERSION);
const projectOwnershipDeclarationDriftAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION);
const projectOwnershipDefaultReconciliationAlreadyApplied = applied.includes(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION);
assertBinaryNotOlderThanDatabase(applied);
let schemaChanged = false;
@@ -1261,6 +1266,12 @@ export async function applySchemaBaseline(
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${PROJECT_OWNERSHIP_DECLARATION_DRIFT_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
if (!projectOwnershipDefaultReconciliationAlreadyApplied) {
const migrationSql = await readFile(PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_MIGRATION_PATH, "utf8");
await tx.execute(sql.raw(migrationSql));
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${PROJECT_OWNERSHIP_DEFAULT_RECONCILIATION_VERSION}) ON CONFLICT (version) DO NOTHING`);
schemaChanged = true;
}
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
});
}

View File

@@ -373,9 +373,14 @@ export const config = projectSchema.table("config", {
// now keyed per-project on `project_id` (the effective PK). `id` is retained
// for column-shape parity (always 1) but is no longer the PK and no longer
// CHECK-constrained. Single-project / SQLite-parity callers leave project_id
// at its '' default (one row), preserving the pre-isolation behavior.
// through the database ownership trigger, preserving the pre-isolation behavior.
/*
FNXC:MultiProjectIsolation 2026-08-12-15:43:
Migration 0006 owns config's project_id default and leaves its catalog PK as (project_id).
Drizzle emits DEFAULT while the database GUC/trigger selects the effective ownership partition.
*/
id: integer("id").default(1),
projectId: text("project_id").notNull().default("").primaryKey(),
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`).primaryKey(),
nextId: integer("next_id").default(1),
nextWorkflowStepId: integer("next_workflow_step_id").default(1),
// FNXC:SqliteFinalRemoval 2026-06-28:
@@ -832,9 +837,13 @@ export const taskCommitAssociations = projectSchema.table("task_commit_associati
export const automations = projectSchema.table("automations", {
/*
* FNXC:AutomationIsolation 2026-07-13-22:37:
* Automations are partitioned by the AsyncDataLayer's project ID because embedded PostgreSQL consolidates the per-project SQLite files into one table. The composite key deliberately permits the same automation ID in two projects without allowing either project's CRUD or cron-claim path to address the other row. The empty default preserves an explicit partition for legacy and project-agnostic callers until startup stamps migrated rows.
* Automations are partitioned by the AsyncDataLayer's project ID because embedded PostgreSQL consolidates the per-project SQLite files into one table. The composite key deliberately permits the same automation ID in two projects without allowing either project's CRUD or cron-claim path to address the other row.
*
* FNXC:MultiProjectIsolation 2026-08-12-15:43:
* Migration 0006 owns the default through the database GUC/trigger; Drizzle emits DEFAULT
* without replacing the physical legacy fallback or the fail-closed bound writer contract.
*/
projectId: text("project_id").notNull().default(""),
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
id: text("id").notNull(),
name: text("name").notNull(),
description: text("description"),
@@ -1952,8 +1961,13 @@ export const knowledgePages = projectSchema.table("knowledge_pages", {
]);
export const deployments = projectSchema.table("deployments", {
id: integer("id").generatedAlwaysAsIdentity().primaryKey(),
projectId: text("project_id").notNull().default(""),
id: integer("id").generatedAlwaysAsIdentity().notNull(),
/*
FNXC:MultiProjectIsolation 2026-08-12-15:43:
Migration 0006 rebuilt deployments_pkey as (project_id, id). Drizzle emits DEFAULT while the
database GUC/trigger keeps ownership stamping and the physical legacy fallback authoritative.
*/
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
deploymentId: text("deployment_id").notNull(),
service: text("service"),
environment: text("environment"),
@@ -1964,6 +1978,7 @@ export const deployments = projectSchema.table("deployments", {
meta: jsonb("meta"),
createdAt: text("created_at").notNull(),
}, (t) => [
primaryKey({ columns: [t.projectId, t.id], name: "deployments_pkey" }),
uniqueIndex("idxDeploymentsProjectDeploymentId").on(t.projectId, t.deploymentId),
index("idxDeploymentsProjectDeployedAt").on(t.projectId, t.deployedAt),
index("idxDeploymentsDeployedAt").on(t.deployedAt),
@@ -1977,7 +1992,12 @@ required checks cannot admit stale or cross-project results; received_at support
*/
export const githubCheckStates = projectSchema.table("github_check_states", {
id: integer("id").generatedAlwaysAsIdentity().notNull(),
projectId: text("project_id").notNull().default(""),
/*
FNXC:MultiProjectIsolation 2026-08-12-15:43:
Migration 0048 post-dated 0006, so FN-9004 reconciles its physical default to the ownership
expression. Drizzle emits DEFAULT while the database GUC/trigger remains the stamping authority.
*/
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
repo: text("repo").notNull(),
headSha: text("head_sha").notNull(),
checkName: text("check_name").notNull(),
@@ -1998,9 +2018,14 @@ export const githubCheckStates = projectSchema.table("github_check_states", {
]);
export const incidents = projectSchema.table("incidents", {
id: integer("id").generatedAlwaysAsIdentity().primaryKey(),
projectId: text("project_id").notNull().default(""),
incidentId: text("incident_id").notNull().unique(),
id: integer("id").generatedAlwaysAsIdentity().notNull(),
/*
FNXC:MultiProjectIsolation 2026-08-12-15:43:
Migration 0006 rebuilt incident keys with project_id first. Drizzle emits DEFAULT while the
database GUC/trigger retains ownership stamping and its physical legacy fallback.
*/
projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`),
incidentId: text("incident_id").notNull(),
groupingKey: text("grouping_key").notNull(),
title: text("title").notNull(),
severity: text("severity"),
@@ -2014,6 +2039,8 @@ export const incidents = projectSchema.table("incidents", {
createdAt: text("created_at").notNull(),
updatedAt: text("updated_at").notNull(),
}, (t) => [
primaryKey({ columns: [t.projectId, t.id], name: "incidents_pkey" }),
unique("incidents_incident_id_key").on(t.projectId, t.incidentId),
index("idxIncidentsProjectOpenedAt").on(t.projectId, t.openedAt),
index("idxIncidentsProjectStatus").on(t.projectId, t.status),
index("idxIncidentsGroupingKey").on(t.groupingKey),