diff --git a/packages/plugins/plugin-briefs/migrations/001_briefs_cards.sql b/packages/plugins/plugin-briefs/migrations/001_briefs_cards.sql index e0adf6016e2..d52d21e9dca 100644 --- a/packages/plugins/plugin-briefs/migrations/001_briefs_cards.sql +++ b/packages/plugins/plugin-briefs/migrations/001_briefs_cards.sql @@ -1,7 +1,7 @@ CREATE TABLE plugin_briefs_37182b0291.briefs_cards ( id uuid PRIMARY KEY, company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, - user_id text NOT NULL, + user_id text NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE, slug text NOT NULL, title text NOT NULL, grouping_description text NOT NULL, @@ -22,7 +22,7 @@ CREATE TABLE plugin_briefs_37182b0291.briefs_cards ( CREATE TABLE plugin_briefs_37182b0291.briefs_card_sources ( id uuid PRIMARY KEY, company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, - user_id text NOT NULL, + user_id text NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE, card_id uuid NOT NULL REFERENCES plugin_briefs_37182b0291.briefs_cards(id) ON DELETE CASCADE, source_kind text NOT NULL CHECK (source_kind IN ('issue_tree', 'issue', 'comment', 'run', 'document', 'work_product', 'interaction', 'activity_event', 'approval')), source_id text NOT NULL, @@ -42,7 +42,7 @@ CREATE TABLE plugin_briefs_37182b0291.briefs_card_sources ( CREATE TABLE plugin_briefs_37182b0291.briefs_card_snapshots ( id uuid PRIMARY KEY, company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, - user_id text NOT NULL, + user_id text NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE, card_id uuid NOT NULL REFERENCES plugin_briefs_37182b0291.briefs_cards(id) ON DELETE CASCADE, summary_paragraph text, summary_status text NOT NULL CHECK (summary_status IN ('ok', 'pending', 'fallback')), @@ -67,7 +67,7 @@ ALTER TABLE plugin_briefs_37182b0291.briefs_cards CREATE TABLE plugin_briefs_37182b0291.briefs_user_preferences ( id uuid PRIMARY KEY, company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, - user_id text NOT NULL, + user_id text NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE, cadence text NOT NULL DEFAULT 'hourly' CHECK (cadence IN ('manual', 'hourly', 'daily')), retention_days integer NOT NULL DEFAULT 7 CHECK (retention_days > 0), done_retention_hours integer NOT NULL DEFAULT 72 CHECK (done_retention_hours > 0), @@ -83,7 +83,7 @@ CREATE TABLE plugin_briefs_37182b0291.briefs_user_preferences ( CREATE TABLE plugin_briefs_37182b0291.briefs_cursors ( id uuid PRIMARY KEY, company_id uuid NOT NULL REFERENCES public.companies(id) ON DELETE CASCADE, - user_id text NOT NULL, + user_id text NOT NULL REFERENCES public."user"(id) ON DELETE CASCADE, cursor_key text NOT NULL, cursor_kind text NOT NULL, last_seen_at timestamptz, diff --git a/packages/plugins/plugin-briefs/src/store.ts b/packages/plugins/plugin-briefs/src/store.ts index 82e5d3ab017..7cb169b8b96 100644 --- a/packages/plugins/plugin-briefs/src/store.ts +++ b/packages/plugins/plugin-briefs/src/store.ts @@ -257,49 +257,70 @@ export function createBriefsStore(db: PluginDatabaseClient) { [card.companyId, card.userId, card.slug], ); const persistedCardId = existingRows[0]?.id ?? card.id; + const sourceRowsJson = JSON.stringify(card.sources.map((source) => ({ + id: source.id, + source_kind: source.sourceKind, + source_id: source.sourceId, + issue_id: source.issueId, + identifier: source.identifier, + title_line: source.titleLine, + right_tag: source.rightTag, + link_path: source.linkPath, + is_intra_tree_blocked: source.isIntraTreeBlocked, + event_at: source.eventAt, + metadata: source.metadata ?? {}, + }))); await db.execute( - `DELETE FROM ${sourcesTable} WHERE company_id = $1 AND user_id = $2 AND card_id = $3`, - [card.companyId, card.userId, persistedCardId], - ); - for (const source of card.sources) { - await db.execute( - `INSERT INTO ${sourcesTable} ( + `WITH deleted_sources AS ( + DELETE FROM ${sourcesTable} + WHERE company_id = $1 AND user_id = $2 AND card_id = $3 + RETURNING id + ), + source_rows AS ( + SELECT * + FROM jsonb_to_recordset($4::jsonb) AS source( + id uuid, + source_kind text, + source_id text, + issue_id uuid, + identifier text, + title_line text, + right_tag text, + link_path text, + is_intra_tree_blocked boolean, + event_at timestamptz, + metadata jsonb + ) + ), + inserted_sources AS ( + INSERT INTO ${sourcesTable} ( id, company_id, user_id, card_id, source_kind, source_id, issue_id, identifier, title_line, right_tag, link_path, is_intra_tree_blocked, event_at, metadata ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::timestamptz, $14::jsonb)`, - [ - source.id, - source.companyId, - source.userId, - persistedCardId, - source.sourceKind, - source.sourceId, - source.issueId, - source.identifier, - source.titleLine, - source.rightTag, - source.linkPath, - source.isIntraTreeBlocked, - source.eventAt, - JSON.stringify(source.metadata ?? {}), - ], - ); - } - - await db.execute( - `INSERT INTO ${snapshotsTable} ( - id, company_id, user_id, card_id, summary_paragraph, summary_status, summary_model, - summary_tokens_in, summary_tokens_out, summary_failure_reason, task_rows, evidence_source_ids, - generated_by_agent_id, generated_by_run_id, deterministic_state_inputs + SELECT id, $1::uuid, $2, $3::uuid, source_kind, source_id, issue_id, identifier, title_line, + right_tag, link_path, is_intra_tree_blocked, event_at, metadata + FROM source_rows + RETURNING id + ), + inserted_snapshot AS ( + INSERT INTO ${snapshotsTable} ( + id, company_id, user_id, card_id, summary_paragraph, summary_status, summary_model, + summary_tokens_in, summary_tokens_out, summary_failure_reason, task_rows, evidence_source_ids, + generated_by_agent_id, generated_by_run_id, deterministic_state_inputs + ) + VALUES ($5::uuid, $1::uuid, $2, $3::uuid, $6, $7, $8, $9, $10, $11, $12::jsonb, $13::jsonb, $14, $15, $16::jsonb) + RETURNING id ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11::jsonb, $12::jsonb, $13, $14, $15::jsonb)`, + UPDATE ${cardsTable} + SET latest_snapshot_id = (SELECT id FROM inserted_snapshot), updated_at = now() + WHERE company_id = $1 AND user_id = $2 AND id = $3`, [ - card.snapshot.id, card.companyId, card.userId, persistedCardId, + sourceRowsJson, + card.snapshot.id, card.snapshot.summaryParagraph, card.snapshot.summaryStatus, card.snapshot.summaryModel, @@ -314,13 +335,6 @@ export function createBriefsStore(db: PluginDatabaseClient) { ], ); - await db.execute( - `UPDATE ${cardsTable} - SET latest_snapshot_id = $1, updated_at = now() - WHERE company_id = $2 AND user_id = $3 AND id = $4`, - [card.snapshot.id, card.companyId, card.userId, persistedCardId], - ); - return { ...card, id: persistedCardId, diff --git a/server/src/__tests__/plugin-database.test.ts b/server/src/__tests__/plugin-database.test.ts index d69fd34a70f..30c5524b257 100644 --- a/server/src/__tests__/plugin-database.test.ts +++ b/server/src/__tests__/plugin-database.test.ts @@ -47,6 +47,12 @@ describe("plugin database SQL validation", () => { ["issues"], ) ).not.toThrow(); + expect(() => + validatePluginMigrationStatement( + "CREATE TABLE plugin_test.rows (id uuid PRIMARY KEY, user_id text REFERENCES public.\"user\"(id) ON DELETE CASCADE)", + "plugin_test", + ) + ).not.toThrow(); }); it("allows qualified index creation and namespace-scoped migration backfills", () => { @@ -129,6 +135,28 @@ describe("plugin database SQL validation", () => { ).toThrow(/namespace/i); }); + it("allows namespace-scoped data-modifying WITH runtime statements", () => { + expect(() => + validatePluginRuntimeExecute( + ` + WITH removed AS ( + DELETE FROM plugin_test.rows WHERE id = $1 RETURNING id + ) + UPDATE plugin_test.cards + SET latest_row_id = (SELECT id FROM removed) + WHERE id = $2 + `, + "plugin_test", + ) + ).not.toThrow(); + expect(() => + validatePluginRuntimeExecute( + "WITH rows AS (SELECT id FROM plugin_test.rows) SELECT id FROM rows", + "plugin_test", + ) + ).toThrow(/must perform/i); + }); + it("targets anonymous DO blocks without rejecting do-prefixed aliases", () => { expect(() => validatePluginRuntimeQuery( diff --git a/server/src/services/plugin-database.ts b/server/src/services/plugin-database.ts index d6b4cdda10e..7087cdf5ae5 100644 --- a/server/src/services/plugin-database.ts +++ b/server/src/services/plugin-database.ts @@ -167,6 +167,15 @@ function assertAllowedPublicRead( } } +function assertAllowedPublicMigrationRef( + ref: SqlRef, + allowedCoreReadTables: ReadonlySet, +): void { + if (ref.schema !== "public") return; + if (ref.keyword === "references" && ref.table === "user") return; + assertAllowedPublicRead(ref, allowedCoreReadTables); +} + function assertNoBannedSql(statement: string): void { const normalized = normaliseSql(statement); const banned = [ @@ -236,7 +245,7 @@ export function validatePluginMigrationStatement( for (const ref of refs) { if (ref.schema === namespace) continue; if (ref.schema === "public") { - assertAllowedPublicRead(ref, allowedCoreReadTables); + assertAllowedPublicMigrationRef(ref, allowedCoreReadTables); continue; } throw new Error(`Plugin SQL references schema "${ref.schema}" outside namespace "${namespace}"`); @@ -281,9 +290,15 @@ export function validatePluginRuntimeExecute(query: string, namespace: string): const statement = statements[0]!; assertNoBannedSql(statement); const normalized = normaliseSql(statement); - if (!/^(insert\s+into|update|delete\s+from)\b/.test(normalized)) { + if (!/^(insert\s+into|update|delete\s+from|with)\b/.test(normalized)) { throw new Error("ctx.db.execute only allows INSERT, UPDATE, or DELETE"); } + if ( + normalized.startsWith("with ") && + !/\b(insert\s+into|update|delete\s+from)\b/.test(normalized) + ) { + throw new Error("ctx.db.execute WITH statements must perform INSERT, UPDATE, or DELETE"); + } if (/\b(alter|create|drop|truncate)\b/.test(normalized)) { throw new Error("ctx.db.execute cannot contain DDL keywords"); }