mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-22 00:30:19 +02:00
fix(briefs): harden card persistence
This commit is contained in:
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user