From 0ed0e53d562fa00545d05319dea4c8e5e91db52a Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 12 Aug 2026 07:09:55 -0700 Subject: [PATCH] FN-9002: model project-owned schema identities Align project schema declarations with the existing ownership partition. - Model eight project-owned tables with project-leading identities and defaults. - Preserve composite foreign-key and project-scoped uniqueness metadata. - Add PostgreSQL declaration coverage and document deferred runtime predicates. Files changed: ...n-9002-project-schema-ownership-declarations.md | 7 + docs/storage.md | 1 + ...roject-schema-ownership-declarations.pg.test.ts | 245 +++++++++++++++++++++ .../core/src/async-stores/async-secrets-store.ts | 26 +-- packages/core/src/postgres/schema/project.ts | 44 +++- 5 files changed, 298 insertions(+), 25 deletions(-) Fusion-Task-Id: FN-9002 Fusion-Task-Lineage: b6f45d1c-d7d9-4a96-b86e-6d120df3f756 Co-authored-by: Fusion (runfusion.ai) --- ...2-project-schema-ownership-declarations.md | 7 + docs/storage.md | 1 + ...t-schema-ownership-declarations.pg.test.ts | 245 ++++++++++++++++++ .../src/async-stores/async-secrets-store.ts | 26 +- packages/core/src/postgres/schema/project.ts | 44 +++- 5 files changed, 298 insertions(+), 25 deletions(-) create mode 100644 .changeset/fn-9002-project-schema-ownership-declarations.md create mode 100644 packages/core/src/__tests__/postgres/project-schema-ownership-declarations.pg.test.ts diff --git a/.changeset/fn-9002-project-schema-ownership-declarations.md b/.changeset/fn-9002-project-schema-ownership-declarations.md new file mode 100644 index 0000000000..400023fbf9 --- /dev/null +++ b/.changeset/fn-9002-project-schema-ownership-declarations.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Model eight project-owned storage tables with their partition identities. +category: internal +dev: Eight declarations now match the 0006 project partition; runtime predicates land in FN-9000. diff --git a/docs/storage.md b/docs/storage.md index f8e7fa6e43..a52ab5f521 100644 --- a/docs/storage.md +++ b/docs/storage.md @@ -38,6 +38,7 @@ See the [2026-07-14 PostgreSQL runtime cutover review](./postgres-migration-revi - `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. - `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). Their runtime owners are respectively async attachments/archive/task mutation; async secrets; async branch groups; activation analytics/events; async chat; async audit/merge coordination/data layer; workflow definitions; and async approval requests. This is declaration-only: existing inserts keep omitting `projectId`, public record mappers keep omitting it, and all project predicates are deliberately deferred to FN-9000. - Startup and Batch 1 self-healing expire locks when their lease elapsed or the owner task is terminal/missing. They never move a task or alter scheduler, worktree, semaphore, or verification state. Run-audit events are `symbol-lock:acquired`, `symbol-lock:acquire-conflict`, `symbol-lock:renewed`, `symbol-lock:released`, `symbol-lock:reconcile-stale`, and deduplicated `symbol-lock:reconcile-stale-no-action`; metadata uses only counts/outcomes and normalized opaque keys. - FN-8405 adds `Task.declaredSymbols` as the durable, normalized task declaration source. `## Declared Symbols` in PROMPT.md is parsed only on create/update writes: an absent key may hydrate from the prompt, while a present `undefined`, `null` (update), or `[]` clears and suppresses hydration; a non-empty explicit array wins. Store resolution (`resolveTaskSymbols` and `resolveTaskSymbolsForWorkItem({ taskId })`) reads only the durable field, and slim projections plus archive/restore retain it. Scheduler admission remains a separate FN-8306 consumer; File Scope is never treated as a symbol source. diff --git a/packages/core/src/__tests__/postgres/project-schema-ownership-declarations.pg.test.ts b/packages/core/src/__tests__/postgres/project-schema-ownership-declarations.pg.test.ts new file mode 100644 index 0000000000..f939fd3eb2 --- /dev/null +++ b/packages/core/src/__tests__/postgres/project-schema-ownership-declarations.pg.test.ts @@ -0,0 +1,245 @@ +import { afterAll, afterEach, beforeAll, beforeEach, expect, it } from "vitest"; +import { getTableConfig } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { AsyncSecretsStore } from "../../async-stores/async-secrets-store.js"; +import { createApprovalRequest } from "../../async-stores/async-approval-request-store.js"; +import { addChatMessage } from "../../async-stores/async-chat-store.js"; +import { recordRunAuditEvent, recordRunAuditEventWithinTransaction } from "../../postgres/data-layer.js"; +import { insertArtifactRow } from "../../task-store/async/async-comments-attachments.js"; +import { createBranchGroup } from "../../task-store/async/async-branch-groups.js"; +import { recordPluginActivation } from "../../task-store/async/async-events.js"; +import * as schema from "../../postgres/schema/index.js"; +import { + createSharedPgTaskStoreTestHarness, + pgDescribe, + type SharedPgTaskStoreHarness, +} from "../../__test-utils__/pg-test-harness.js"; + +const tableNames = [ + "artifacts", + "secrets", + "branch_groups", + "plugin_activations", + "chat_messages", + "run_audit_events", + "verification_cache", + "approval_requests", +] as const; + +const declaredTables = { + artifacts: schema.project.artifacts, + secrets: schema.project.secrets, + branch_groups: schema.project.branchGroups, + plugin_activations: schema.project.pluginActivations, + chat_messages: schema.project.chatMessages, + run_audit_events: schema.project.runAuditEvents, + verification_cache: schema.project.verificationCache, + approval_requests: schema.project.approvalRequests, +} as const; + +const expectedPrimaryKeys: Record<(typeof tableNames)[number], string[]> = { + artifacts: ["project_id", "id"], + secrets: ["project_id", "id"], + branch_groups: ["project_id", "id"], + plugin_activations: ["project_id", "id"], + chat_messages: ["project_id", "id"], + run_audit_events: ["project_id", "id"], + verification_cache: ["project_id", "tree_sha", "test_command", "build_command"], + approval_requests: ["project_id", "id"], +}; + +pgDescribe("project schema ownership declarations", () => { + const h: SharedPgTaskStoreHarness = createSharedPgTaskStoreTestHarness({ + prefix: "fusion_fn9002_schema", + projectId: "fn9002-runtime-bound", + }); + + beforeAll(h.beforeAll); + afterAll(h.afterAll); + beforeEach(h.beforeEach); + afterEach(h.afterEach); + + it("proves migration 0006 physically partitions all eight drifted tables", async () => { + /* + FNXC:MultiProjectIsolation 2026-08-12-13:45: + Migration 0006 partitioned every project-schema base table before these eight Drizzle + declarations caught up. Assert catalog truth first so declaration work cannot guess an + ordered key, unique index, or foreign-key name from the pre-isolation snapshot. + */ + for (const tableName of tableNames) { + const columns = await h.adminSql()>` + SELECT column_name, 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({ + column_name: "project_id", + data_type: "text", + is_nullable: "NO", + column_default: expect.stringContaining("current_setting"), + })]); + + const constraints = await h.adminSql()>` + 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', 'f') + 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 uniqueIndexes = await h.adminSql()>` + SELECT idx.relname AS index_name, + array_agg(a.attname ORDER BY key.ordinality) AS columns + FROM pg_index i + JOIN pg_class idx ON idx.oid = i.indexrelid + CROSS JOIN LATERAL unnest(i.indkey) WITH ORDINALITY key(attnum, ordinality) + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = key.attnum + WHERE i.indrelid = ('project.' || ${tableName})::regclass + AND i.indisunique AND NOT i.indisprimary + GROUP BY idx.relname + `; + for (const index of uniqueIndexes) { + expect(index.columns[0]).toBe("project_id"); + } + + const declaration = getTableConfig(declaredTables[tableName]); + expect(declaration.columns.map((column) => column.name)).toContain("project_id"); + expect(declaration.columns.find((column) => column.name === "project_id")?.hasDefault).toBe(true); + expect(declaration.primaryKeys).toHaveLength(1); + expect(declaration.primaryKeys[0]?.columns.map((column) => column.name)) + .toEqual(expectedPrimaryKeys[tableName]); + } + + const artifactForeignKey = getTableConfig(schema.project.artifacts).foreignKeys + .find((key) => key.getName() === "artifacts_task_id_fkey"); + expect(artifactForeignKey?.reference().columns.map((column) => column.name)) + .toEqual(["project_id", "task_id"]); + expect(artifactForeignKey?.reference().foreignColumns.map((column) => column.name)) + .toEqual(["project_id", "id"]); + expect(getTableConfig(schema.project.secrets).uniqueConstraints.map((key) => ({ + name: key.getName(), columns: key.columns.map((column) => column.name), + }))).toContainEqual({ name: "secrets_key_unique", columns: ["project_id", "key"] }); + expect(getTableConfig(schema.project.branchGroups).uniqueConstraints.map((key) => ({ + name: key.getName(), columns: key.columns.map((column) => column.name), + }))).toContainEqual({ name: "branch_groups_branch_name_key", columns: ["project_id", "branch_name"] }); + }); + + it("keeps production inserts default-stamped and public mappers partition-free", async () => { + /* + FNXC:MultiProjectIsolation 2026-08-12-13:45: + These are the real async store helpers whose inserts intentionally omit projectId. Exercise + them through a GUC-bound layer so the declaration change proves Drizzle still delegates + ownership stamping to migration 0006 rather than relying only on catalog or raw-SQL tests. + */ + const layer = h.layer(); + const artifact = await insertArtifactRow(layer, { + type: "document", title: "Runtime artifact", content: "content", authorId: "agent", + authorType: "agent", + }, {}); + const secrets = new AsyncSecretsStore(layer, async () => Buffer.alloc(32, 7)); + const secret = await secrets.createSecret({ scope: "project", key: "RUNTIME_KEY", plaintextValue: "value" }); + const branchGroup = await createBranchGroup(layer.db, { + sourceType: "planning", sourceId: "runtime", branchName: "runtime-branch", + }); + const activation = await recordPluginActivation(layer.db, { pluginId: "runtime-plugin", source: "test" }); + const message = await addChatMessage(layer.db, { + id: "runtime-message", sessionId: "runtime-session", role: "user", content: "Runtime", + thinkingOutput: null, metadata: null, attachments: undefined, createdAt: "2026-08-12T00:00:00.000Z", + }); + const audit = await recordRunAuditEvent(layer, { + agentId: "agent", runId: "runtime-run", domain: "test", mutationType: "insert", target: "runtime", + }); + const approval = await createApprovalRequest(layer, { + id: "runtime-approval", + requester: { actorId: "agent", actorType: "agent", actorName: "Agent" }, + targetAction: { category: "test", action: "insert", summary: "Runtime", resourceType: "task", resourceId: "runtime" }, + }); + await h.store().recordVerificationCachePass("runtime-tree", "test", "build", "runtime-task"); + await h.store().recordVerificationCachePass("runtime-tree", "test", "build", "updated-task"); + + const mapped = [artifact, secret, branchGroup, activation, message, audit, approval]; + for (const row of mapped) expect("projectId" in row).toBe(false); + expect(await h.store().getVerificationCacheHit("runtime-tree", "test", "build")) + .toMatchObject({ taskId: "updated-task" }); + + for (const tableName of tableNames) { + const rows = await h.adminSql()>` + SELECT project_id FROM project.${h.adminSql().unsafe(tableName)} + WHERE project_id = 'fn9002-runtime-bound' + `; + expect(rows.length).toBeGreaterThan(0); + } + + await layer.transactionImmediate(async (tx) => { + await tx.execute(sql`SELECT set_config('fusion.project_id', '', true)`); + await recordRunAuditEventWithinTransaction(tx, { + agentId: "agent", runId: "unbound-run", domain: "test", mutationType: "insert", target: "unbound", + }); + }); + expect(await h.adminSql()>` + SELECT project_id FROM project.run_audit_events WHERE run_id = 'unbound-run' + `).toEqual([{ project_id: "__legacy_unscoped__" }]); + }); + + it("keeps duplicate natural identities available in every physical partition", async () => { + const insertRows = async (projectId: string): Promise => { + await h.adminSql()`SELECT set_config('fusion.project_id', ${projectId}, false)`; + await h.adminSql()`INSERT INTO project.artifacts (id, type, title, author_id, created_at, updated_at) + VALUES ('shared', 'document', 'Shared', 'agent', '2026-08-12', '2026-08-12')`; + await h.adminSql()`INSERT INTO project.secrets (id, key, value_ciphertext, nonce, created_at, updated_at) + VALUES ('shared', 'SHARED_KEY', decode('00', 'hex'), decode('00', 'hex'), '2026-08-12', '2026-08-12')`; + await h.adminSql()`INSERT INTO project.branch_groups (id, source_type, source_id, branch_name, created_at, updated_at) + VALUES ('shared', 'planning', 'shared', 'shared-branch', 1, 1)`; + await h.adminSql()`INSERT INTO project.plugin_activations (plugin_id, source, activated_at) + VALUES ('shared', 'test', '2026-08-12')`; + await h.adminSql()`INSERT INTO project.chat_messages (id, session_id, role, content, created_at) + VALUES ('shared', 'shared', 'user', 'Shared', '2026-08-12')`; + await h.adminSql()`INSERT INTO project.run_audit_events (id, timestamp, agent_id, run_id, domain, mutation_type, target) + VALUES ('shared', '2026-08-12', 'agent', 'run', 'test', 'insert', 'shared')`; + await h.adminSql()`INSERT INTO project.verification_cache (tree_sha, test_command, build_command, recorded_at) + VALUES ('shared', 'test', 'build', '2026-08-12')`; + await h.adminSql()`INSERT INTO project.approval_requests ( + id, status, requester_actor_id, requester_actor_type, requester_actor_name, + target_action_category, target_action_operation, target_action_summary, + target_resource_type, target_resource_id, requested_at, created_at, updated_at + ) VALUES ( + 'shared', 'pending', 'agent', 'agent', 'Agent', 'test', 'insert', 'Shared', + 'task', 'shared', '2026-08-12', '2026-08-12', '2026-08-12' + )`; + }; + + await insertRows("fn9002-project-a"); + await insertRows("fn9002-project-b"); + for (const tableName of tableNames) { + const rows = await h.adminSql()>` + SELECT count(*)::text AS count FROM project.${h.adminSql().unsafe(tableName)} + WHERE project_id IN ('fn9002-project-a', 'fn9002-project-b') + `; + expect(rows).toEqual([{ count: "2" }]); + } + + await h.adminSql()`SELECT set_config('fusion.project_id', '', false)`; + await h.adminSql()`INSERT INTO project.chat_messages (id, session_id, role, content, created_at) + VALUES ('unbound', 'unbound', 'user', 'Unbound', '2026-08-12')`; + expect(await h.adminSql()>` + SELECT project_id FROM project.chat_messages WHERE id = 'unbound' + `).toEqual([{ project_id: "__legacy_unscoped__" }]); + }); +}); diff --git a/packages/core/src/async-stores/async-secrets-store.ts b/packages/core/src/async-stores/async-secrets-store.ts index b6b71f3a38..ae47e21260 100644 --- a/packages/core/src/async-stores/async-secrets-store.ts +++ b/packages/core/src/async-stores/async-secrets-store.ts @@ -118,24 +118,22 @@ export class SecretsStoreError extends Error { } /** - * FNXC:SecretsStore 2026-06-24-20:12: - * The columns both secrets tables share. project.secrets and - * central.secrets_global have identical column shapes but are distinct Drizzle - * table objects (different schema/name literal). The helpers operate on the - * project.secrets table type and the global table is cast at the dispatch - * boundary since the two are structurally identical column-for-column. + * FNXC:SecretsStore 2026-08-12-13:45: + * Migration 0006 partitions project.secrets with project_id while + * central.secrets_global remains global. The shared helpers deliberately cast + * the central ref at this dispatch boundary; FN-9000 adds project-only + * predicates without leaking a project_id reference into the central leg. */ type ProjectSecretsTable = typeof schema.project.secrets; /** - * Resolve the Drizzle table ref for a scope. Both tables share the same column - * shape, so the call sites are identical once the table ref is selected. - * FNXC:SecretsStore 2026-06-24-20:10: - * Under the shared PostgreSQL backend a single connection serves both schemas, - * so the dual-database injection collapses to a scope-to-table dispatch. The - * central.secrets_global table is structurally identical to project.secrets - * (same columns, same types), so it is cast to the project table type at the - * dispatch boundary; the helper bodies then compile against one table type. + * Resolve the Drizzle table ref for a scope. The deliberate shape bridge keeps + * current shared helper bodies stable until project-only scoping arrives. + * FNXC:SecretsStore 2026-08-12-13:45: + * A shared PostgreSQL connection serves both schemas. project.secrets now + * models its 0006 partition while central.secrets_global does not have that + * column, so the cast is a dispatch-only compatibility bridge, not evidence + * that their physical shapes are identical. */ function tableForScope(scope: SecretScope): ProjectSecretsTable { return scope === "project" diff --git a/packages/core/src/postgres/schema/project.ts b/packages/core/src/postgres/schema/project.ts index 91083c2a1f..1014bd60aa 100644 --- a/packages/core/src/postgres/schema/project.ts +++ b/packages/core/src/postgres/schema/project.ts @@ -1171,8 +1171,15 @@ export const taskDocuments = projectSchema.table("task_documents", { index("idxTaskDocumentsTaskId").on(t.taskId), ]); +/* +FNXC:MultiProjectIsolation 2026-08-12-13:45: +Migration 0006 physically partitions these declarations, but the Drizzle schema drifted and could +not express their project_id-leading identities. Keep the database current_setting default so +bound and unbound trigger-stamped inserts omit projectId; FN-9000 owns runtime predicates. +*/ export const artifacts = projectSchema.table("artifacts", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), type: text("type").notNull(), title: text("title").notNull(), description: text("description"), @@ -1187,7 +1194,8 @@ export const artifacts = projectSchema.table("artifacts", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ - foreignKey({ columns: [t.taskId], foreignColumns: [tasks.id] }).onDelete("cascade"), + primaryKey({ columns: [t.projectId, t.id] }), + foreignKey({ name: "artifacts_task_id_fkey", columns: [t.projectId, t.taskId], foreignColumns: [tasks.projectId, tasks.id] }).onDelete("cascade"), index("idxArtifactsTaskId").on(t.taskId), index("idxArtifactsAuthorId").on(t.authorId), index("idxArtifactsType").on(t.type), @@ -1391,7 +1399,8 @@ export const evalRunEvents = projectSchema.table("eval_run_events", { // ── Secrets (project-scoped) ───────────────────────────────────────── export const secrets = projectSchema.table("secrets", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), key: text("key").notNull(), valueCiphertext: bytea("value_ciphertext").notNull(), nonce: bytea("nonce").notNull(), @@ -1404,7 +1413,8 @@ export const secrets = projectSchema.table("secrets", { lastReadAt: text("last_read_at"), lastReadBy: text("last_read_by"), }, (t) => [ - unique("secrets_key_unique").on(t.key), + primaryKey({ columns: [t.projectId, t.id] }), + unique("secrets_key_unique").on(t.projectId, t.key), check("secrets_access_policy_check", sql`${t.accessPolicy} IN ('auto', 'prompt', 'deny')`), check("secrets_env_exportable_check", sql`${t.envExportable} IN (0, 1)`), ]); @@ -1451,10 +1461,11 @@ export const missions = projectSchema.table("missions", { }, (t) => [primaryKey({ columns: [t.projectId, t.id] })]); export const branchGroups = projectSchema.table("branch_groups", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), sourceType: text("source_type").notNull(), sourceId: text("source_id").notNull(), - branchName: text("branch_name").notNull().unique(), + branchName: text("branch_name").notNull(), worktreePath: text("worktree_path"), autoMerge: integer("auto_merge").notNull().default(0), prState: text("pr_state").notNull().default("none"), @@ -1470,6 +1481,8 @@ export const branchGroups = projectSchema.table("branch_groups", { updatedAt: bigint("updated_at", { mode: "number" }).notNull(), closedAt: bigint("closed_at", { mode: "number" }), }, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), + unique("branch_groups_branch_name_key").on(t.projectId, t.branchName), check("branch_groups_source_type_check", sql`${t.sourceType} IN ('mission','planning','new-task')`), check("branch_groups_pr_state_check", sql`${t.prState} IN ('none','open','merged','closed')`), check("branch_groups_status_check", sql`${t.status} IN ('open','finalized','abandoned')`), @@ -1905,12 +1918,14 @@ export const usageEvents = projectSchema.table("usage_events", { ]); export const pluginActivations = projectSchema.table("plugin_activations", { - id: integer("id").generatedAlwaysAsIdentity().primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: integer("id").generatedAlwaysAsIdentity().notNull(), pluginId: text("plugin_id").notNull(), source: text("source").notNull(), pluginVersion: text("plugin_version"), activatedAt: text("activated_at").notNull(), }, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), index("idxPluginActivationsActivatedAt").on(t.activatedAt), index("idxPluginActivationsPluginId").on(t.pluginId), ]); @@ -2178,7 +2193,8 @@ export const cliSessions = projectSchema.table("cli_sessions", { ]); export const chatMessages = projectSchema.table("chat_messages", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), sessionId: text("session_id").notNull(), role: text("role").notNull(), content: text("content").notNull(), @@ -2187,6 +2203,7 @@ export const chatMessages = projectSchema.table("chat_messages", { createdAt: text("created_at").notNull(), attachments: jsonb("attachments"), }, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), index("idxChatMessagesSessionId").on(t.sessionId), index("idxChatMessagesCreatedAt").on(t.createdAt), ]); @@ -2218,7 +2235,8 @@ export const chatTokenUsage = projectSchema.table("chat_token_usage", { ]); export const runAuditEvents = projectSchema.table("run_audit_events", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), timestamp: text("timestamp").notNull(), taskId: text("task_id"), agentId: text("agent_id").notNull(), @@ -2228,6 +2246,7 @@ export const runAuditEvents = projectSchema.table("run_audit_events", { target: text("target").notNull(), metadata: jsonb("metadata"), }, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), index("idxRunAuditEventsRunIdTimestamp").on(t.runId, t.timestamp), index("idxRunAuditEventsTaskIdTimestamp").on(t.taskId, t.timestamp), index("idxRunAuditEventsTimestamp").on(t.timestamp), @@ -2340,13 +2359,14 @@ export const missionLineageStops = projectSchema.table("mission_lineage_stops", ]); export const verificationCache = projectSchema.table("verification_cache", { + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), treeSha: text("tree_sha").notNull(), testCommand: text("test_command").notNull().default(""), buildCommand: text("build_command").notNull().default(""), recordedAt: text("recorded_at").notNull(), taskId: text("task_id"), }, (t) => [ - primaryKey({ columns: [t.treeSha, t.testCommand, t.buildCommand] }), + primaryKey({ columns: [t.projectId, t.treeSha, t.testCommand, t.buildCommand] }), index("idxVerificationCacheRecordedAt").on(t.recordedAt), ]); @@ -2387,7 +2407,8 @@ export const importTranslationCache = projectSchema.table("import_translation_ca ]); export const approvalRequests = projectSchema.table("approval_requests", { - id: text("id").primaryKey(), + projectId: text("project_id").notNull().default(sql`current_setting('fusion.project_id', true)`), + id: text("id").notNull(), status: text("status").notNull(), requesterActorId: text("requester_actor_id").notNull(), requesterActorType: text("requester_actor_type").notNull(), @@ -2406,6 +2427,7 @@ export const approvalRequests = projectSchema.table("approval_requests", { createdAt: text("created_at").notNull(), updatedAt: text("updated_at").notNull(), }, (t) => [ + primaryKey({ columns: [t.projectId, t.id] }), index("idxApprovalRequestsStatusCreatedAt").on(t.status, t.createdAt), index("idxApprovalRequestsRequesterCreatedAt").on(t.requesterActorId, t.createdAt), index("idxApprovalRequestsTaskCreatedAt").on(t.taskId, t.createdAt),