fix(briefs): harden card persistence

This commit is contained in:
Dotta committed 2026-05-24 02:52:50 +00:00
1 parent 915c796d58
commit 5584df1747
4 files changed
+104 -47

No files matched your search

@@ -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,
+54 -40
View File
@@ -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,
@@ -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(
+17 -2
View File
@@ -167,6 +167,15 @@ function assertAllowedPublicRead(
}
}
function assertAllowedPublicMigrationRef(
ref: SqlRef,
allowedCoreReadTables: ReadonlySet<string>,
): 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");
}