From f8ffe71823ce9e5df56ca26cef67e8a5395db175 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 12:57:07 +0000 Subject: [PATCH 1/2] perf(dashboard): Skip rebuilding unchanged conversation detail on polls Add a detail_version column that every write to a table the detail report reads advances. The detail route hashes the subtree version with the per-viewer state and answers a matching If-None-Match with 304 before it builds the report. A new oxlint rule, junior/touch-conversation-detail, fails when a detail table write does not call touchConversationDetail afterwards or set detailVersion itself. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- package.json | 3 +- packages/junior-dashboard/src/app.ts | 6 +- .../src/conversation-cache.ts | 2 +- packages/junior/.oxlintrc.json | 13 +- .../0074_conversation_detail_version.sql | 1 + .../junior/migrations/meta/0074_snapshot.json | 4077 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 7 + .../junior/src/api/conversations/archive.ts | 8 +- .../src/api/conversations/detail-etag.ts | 86 + .../junior/src/api/conversations/routes.ts | 21 +- packages/junior/src/chat/attachments/store.ts | 12 +- packages/junior/src/chat/briefs/store.ts | 2 + .../junior/src/chat/conversations/fork.ts | 4 +- .../junior/src/chat/conversations/metrics.ts | 2 + .../src/chat/conversations/session-stop.ts | 3 +- .../conversations/sql/conversation-row.ts | 2 + .../chat/conversations/sql/detail-version.ts | 23 + .../src/chat/conversations/sql/history.ts | 3 + .../src/chat/conversations/sql/location.ts | 2 + .../chat/conversations/sql/participants.ts | 5 + .../src/chat/conversations/sql/purge.ts | 2 + .../src/chat/conversations/sql/store.ts | 3 + .../junior/src/chat/plugins/annotations.ts | 3 + packages/junior/src/chat/slack/attachments.ts | 2 + .../junior/src/db/schema/conversations.ts | 4 + .../api/conversations/detail.test.ts | 64 + tools/oxlint/junior/index.ts | 13 + tools/oxlint/junior/package.json | 5 + .../rules/touch-conversation-detail.test.ts | 78 + .../junior/rules/touch-conversation-detail.ts | 245 + 30 files changed, 4692 insertions(+), 9 deletions(-) create mode 100644 packages/junior/migrations/0074_conversation_detail_version.sql create mode 100644 packages/junior/migrations/meta/0074_snapshot.json create mode 100644 packages/junior/src/api/conversations/detail-etag.ts create mode 100644 packages/junior/src/chat/conversations/sql/detail-version.ts create mode 100644 tools/oxlint/junior/index.ts create mode 100644 tools/oxlint/junior/package.json create mode 100644 tools/oxlint/junior/rules/touch-conversation-detail.test.ts create mode 100644 tools/oxlint/junior/rules/touch-conversation-detail.ts diff --git a/package.json b/package.json index ee99c9e386..8f110d4d50 100644 --- a/package.json +++ b/package.json @@ -13,10 +13,11 @@ "worktree:setup": "node scripts/worktree.mjs setup", "cloudflare:token": "node scripts/refresh-cloudflare-tunnel-token.mjs", "prepare": "simple-git-hooks", - "lint": "pnpm file-length:check && pnpm migration-metadata:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior tool-key-casing:check && pnpm --filter @sentry/junior tool-error-classification:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-google lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-sentry lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint", + "lint": "pnpm file-length:check && pnpm oxlint-rules:check && pnpm migration-metadata:check && pnpm test-architecture:check && pnpm dashboard-style:check && pnpm --filter @sentry/junior tool-annotations:check && pnpm --filter @sentry/junior tool-key-casing:check && pnpm --filter @sentry/junior tool-error-classification:check && pnpm --filter @sentry/junior lint && pnpm --filter @sentry/junior-memory lint && pnpm --filter @sentry/junior-github lint && pnpm --filter @sentry/junior-google lint && pnpm --filter @sentry/junior-linear lint && pnpm --filter @sentry/junior-sentry lint && pnpm --filter @sentry/junior-vercel lint && pnpm --filter @sentry/junior-dashboard lint && pnpm ast-grep:lint && pnpm package:lint", "lint:fix": "pnpm --filter @sentry/junior lint:fix", "file-length:check": "node --test scripts/check-file-length.test.mjs && node scripts/check-file-length.mjs", "migration-metadata:check": "node --test scripts/check-drizzle-migrations.test.mjs && node scripts/check-drizzle-migrations.mjs", + "oxlint-rules:check": "node --test tools/oxlint/junior/rules/*.test.ts", "test-architecture:check": "node --test scripts/check-test-architecture.test.mjs && node scripts/check-test-architecture.mjs", "dashboard-style:check": "node --test scripts/check-dashboard-styles.test.mjs && node scripts/check-dashboard-styles.mjs", "ast-grep:lint": "ast-grep test --test-dir ast-grep/tests --skip-snapshot-tests && ast-grep scan", diff --git a/packages/junior-dashboard/src/app.ts b/packages/junior-dashboard/src/app.ts index 4e33e60fd2..c4102ad5ce 100644 --- a/packages/junior-dashboard/src/app.ts +++ b/packages/junior-dashboard/src/app.ts @@ -508,7 +508,11 @@ export function createDashboardApp( }; app.use("*", requireAuth); - app.use("/api/conversations/:conversationId", revalidateConversation); + // Core detail reads set a version ETag before they build the report. Mock + // reporting has no stored version, so it revalidates on content instead. + if (options.mockConversations) { + app.use("/api/conversations/:conversationId", revalidateConversation); + } for (const route of authenticatedRoutes) { const handler = (c: Context<{ Variables: Variables }>) => { diff --git a/packages/junior-dashboard/src/conversation-cache.ts b/packages/junior-dashboard/src/conversation-cache.ts index cb091cd5b8..8852f52160 100644 --- a/packages/junior-dashboard/src/conversation-cache.ts +++ b/packages/junior-dashboard/src/conversation-cache.ts @@ -1,7 +1,7 @@ import { createHash } from "node:crypto"; import type { MiddlewareHandler } from "hono"; -/** Revalidate dashboard detail reads only after the route checks viewer access. */ +/** Revalidate mock detail reads on content, after the route checks viewer access. */ export const revalidateConversation: MiddlewareHandler = async ( context, next, diff --git a/packages/junior/.oxlintrc.json b/packages/junior/.oxlintrc.json index 13042a185e..8a1ede02bc 100644 --- a/packages/junior/.oxlintrc.json +++ b/packages/junior/.oxlintrc.json @@ -13,6 +13,10 @@ { "name": "anti-slop", "specifier": "../../tools/oxlint/anti-slop/index.ts" + }, + { + "name": "junior", + "specifier": "../../tools/oxlint/junior/index.ts" } ], "ignorePatterns": [ @@ -28,7 +32,8 @@ ".pi/**", ".roo/**", ".windsurf/**", - "tools/oxlint/anti-slop/**" + "tools/oxlint/anti-slop/**", + "tools/oxlint/junior/**" ], "rules": { "no-restricted-imports": [ @@ -73,6 +78,12 @@ "anti-slop/require-safety-comment-for-type-assertion": "off" }, "overrides": [ + { + "files": ["src/**/*.ts"], + "rules": { + "junior/touch-conversation-detail": "error" + } + }, { "files": ["**/junior-github/src/**/*.ts"], "rules": { diff --git a/packages/junior/migrations/0074_conversation_detail_version.sql b/packages/junior/migrations/0074_conversation_detail_version.sql new file mode 100644 index 0000000000..cdf123f07d --- /dev/null +++ b/packages/junior/migrations/0074_conversation_detail_version.sql @@ -0,0 +1 @@ +ALTER TABLE "junior_conversations" ADD COLUMN "detail_version" integer DEFAULT 0 NOT NULL; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0074_snapshot.json b/packages/junior/migrations/meta/0074_snapshot.json new file mode 100644 index 0000000000..b0f65ee874 --- /dev/null +++ b/packages/junior/migrations/meta/0074_snapshot.json @@ -0,0 +1,4077 @@ +{ + "id": "d61ed924-fabd-4829-acaf-c7fa21bd4b6b", + "prevId": "596de823-c4c5-4ffc-9a2b-bace35abecdd", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": ["parent_conversation_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agent_type": { + "name": "agent_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_profile": { + "name": "model_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_mailbox_status": { + "name": "parent_mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_parent_mailbox_idx": { + "name": "junior_agent_invocations_parent_mailbox_idx", + "columns": [ + { + "expression": "parent_mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_artifacts": { + "name": "junior_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_artifacts_conversation_sha_uidx": { + "name": "junior_artifacts_conversation_sha_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_gc_idx": { + "name": "junior_artifacts_gc_idx", + "columns": [ + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_conversation_idx": { + "name": "junior_artifacts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_attachments": { + "name": "junior_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vision_summary": { + "name": "vision_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_attachments_conversation_idx": { + "name": "junior_attachments_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_provider_idx": { + "name": "junior_attachments_provider_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_gc_idx": { + "name": "junior_attachments_gc_idx", + "columns": [ + { + "expression": "storage_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_attachments_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_attachments_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_attachments", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automation_runs": { + "name": "junior_automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_version": { + "name": "automation_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at_ms": { + "name": "claimed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at_ms": { + "name": "started_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "completed_at_ms": { + "name": "completed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_message_ts": { + "name": "result_message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_automation_runs_automation_idx": { + "name": "junior_automation_runs_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_completed_idx": { + "name": "junior_automation_runs_completed_idx", + "columns": [ + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_incomplete_idx": { + "name": "junior_automation_runs_incomplete_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automation_runs\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automation_runs_automation_id_junior_automations_id_fk": { + "name": "junior_automation_runs_automation_id_junior_automations_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_automation_runs_automation_version_fk": { + "name": "junior_automation_runs_automation_version_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automation_versions", + "columnsFrom": ["automation_id", "automation_version"], + "columnsTo": ["automation_id", "version"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "junior_automation_runs_conversation_id_unique": { + "name": "junior_automation_runs_conversation_id_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id"] + } + }, + "policies": {}, + "checkConstraints": { + "junior_automation_runs_status_check": { + "name": "junior_automation_runs_status_check", + "value": "\"junior_automation_runs\".\"status\" in ('pending', 'running', 'completed', 'failed', 'blocked', 'skipped')" + } + }, + "isRLSEnabled": false + }, + "public.junior_automation_versions": { + "name": "junior_automation_versions", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edited_by": { + "name": "edited_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_automation_versions_automation_id_fk": { + "name": "junior_automation_versions_automation_id_fk", + "tableFrom": "junior_automation_versions", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_automation_versions_pk": { + "name": "junior_automation_versions_pk", + "columns": ["automation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automations": { + "name": "junior_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_automations_team_idx": { + "name": "junior_automations_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_creator_identity_idx": { + "name": "junior_automations_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_run_now_due_idx": { + "name": "junior_automations_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_next_run_due_idx": { + "name": "junior_automations_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_match_idx": { + "name": "junior_automations_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_automations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_automations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_automations_kind_check": { + "name": "junior_automations_kind_check", + "value": "\"junior_automations\".\"kind\" in ('scheduled', 'event')" + }, + "junior_automations_status_check": { + "name": "junior_automations_status_check", + "value": "\"junior_automations\".\"status\" in ('active', 'blocked', 'paused', 'completed', 'deleted')" + }, + "junior_automations_record_check": { + "name": "junior_automations_record_check", + "value": "jsonb_typeof(\"junior_automations\".\"record\") = 'object'" + }, + "junior_automations_trigger_check": { + "name": "junior_automations_trigger_check", + "value": "(\"junior_automations\".\"kind\" = 'scheduled' and \"junior_automations\".\"namespace\" is null and \"junior_automations\".\"identifier\" is null) or (\"junior_automations\".\"kind\" = 'event' and \"junior_automations\".\"namespace\" is not null and \"junior_automations\".\"next_run_at_ms\" is null and \"junior_automations\".\"run_now_at_ms\" is null and \"junior_automations\".\"status\" <> 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_code_changes": { + "name": "junior_code_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "conversation_ids": { + "name": "conversation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_code_changes_provider_id_idx": { + "name": "junior_code_changes_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_opened_at_idx": { + "name": "junior_code_changes_opened_at_idx", + "columns": [ + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_merged_at_idx": { + "name": "junior_code_changes_merged_at_idx", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_closed_at_idx": { + "name": "junior_code_changes_closed_at_idx", + "columns": [ + { + "expression": "closed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_open_idx": { + "name": "junior_code_changes_open_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_code_changes\".\"state\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_code_changes_repository_id_junior_code_repositories_id_fk": { + "name": "junior_code_changes_repository_id_junior_code_repositories_id_fk", + "tableFrom": "junior_code_changes", + "tableTo": "junior_code_repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_code_repositories": { + "name": "junior_code_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_code_repositories_provider_id_idx": { + "name": "junior_code_repositories_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": ["conversation_id", "plugin", "kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_briefs": { + "name": "junior_conversation_briefs", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "through_seq": { + "name": "through_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "junior_conversation_briefs_conversation_turn_idx": { + "name": "junior_conversation_briefs_conversation_turn_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_conversation_version_idx": { + "name": "junior_conversation_briefs_conversation_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_search_idx": { + "name": "junior_conversation_briefs_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_briefs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_briefs_conversation_id_version_pk": { + "name": "junior_conversation_briefs_conversation_id_version_pk", + "columns": ["conversation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_plugin_report_idx": { + "name": "junior_conversation_events_plugin_report_idx", + "columns": [ + { + "expression": "(\"payload\"->>'namespace')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "(\"payload\"->>'name')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'structured_event'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_actor_identity_idx": { + "name": "junior_conversation_events_actor_identity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_events_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_metrics": { + "name": "junior_conversation_metrics", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_metrics_occurred_at_metric_idx": { + "name": "junior_conversation_metrics_occurred_at_metric_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_metrics", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_metrics_conversation_id_run_id_metric_pk": { + "name": "junior_conversation_metrics_conversation_id_run_id_metric_pk", + "columns": ["conversation_id", "run_id", "metric"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_participants": { + "name": "junior_conversation_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversation_participants_user_activity_idx": { + "name": "junior_conversation_participants_user_activity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_participants_root_idx": { + "name": "junior_conversation_participants_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_participants_user_id_junior_users_id_fk": { + "name": "junior_conversation_participants_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_participants_user_root_pk": { + "name": "junior_conversation_participants_user_root_pk", + "columns": ["user_id", "root_conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_read_positions": { + "name": "junior_conversation_read_positions", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_read_seq": { + "name": "last_read_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_read_positions_user_id_junior_users_id_fk": { + "name": "junior_conversation_read_positions_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_read_positions", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_read_positions_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_read_positions_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_read_positions", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_read_positions_user_conversation_pk": { + "name": "junior_conversation_read_positions_user_conversation_pk", + "columns": ["user_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location_json": { + "name": "location_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_conversation_id": { + "name": "forked_from_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "quick_question_at": { + "name": "quick_question_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "detail_version": { + "name": "detail_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_forked_from_idx": { + "name": "junior_conversations_forked_from_idx", + "columns": [ + { + "expression": "forked_from_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["credential_subject_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["forked_from_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "signed_in_at": { + "name": "signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": ["location_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_assignees": { + "name": "junior_review_item_assignees", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_item_assignees_user_idx": { + "name": "junior_review_item_assignees_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_item_assignees_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_assignees_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_assignees", + "tableTo": "junior_review_items", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_assignees_user_id_junior_users_id_fk": { + "name": "junior_review_item_assignees_user_id_junior_users_id_fk", + "tableFrom": "junior_review_item_assignees", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_assignees_item_id_user_id_pk": { + "name": "junior_review_item_assignees_item_id_user_id_pk", + "columns": ["item_id", "user_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_conversations": { + "name": "junior_review_item_conversations", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_item_conversations_conversation_idx": { + "name": "junior_review_item_conversations_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_item_conversations_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_conversations_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_conversations", + "tableTo": "junior_review_items", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_conversations_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_item_conversations_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_item_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_conversations_item_id_conversation_id_pk": { + "name": "junior_review_item_conversations_item_id_conversation_id_pk", + "columns": ["item_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_item_slack_cards": { + "name": "junior_review_item_slack_cards", + "schema": "", + "columns": { + "item_id": { + "name": "item_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "message_ts": { + "name": "message_ts", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_review_item_slack_cards_item_id_junior_review_items_id_fk": { + "name": "junior_review_item_slack_cards_item_id_junior_review_items_id_fk", + "tableFrom": "junior_review_item_slack_cards", + "tableTo": "junior_review_items", + "columnsFrom": ["item_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_item_slack_cards_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_item_slack_cards_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_item_slack_cards", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_review_item_slack_cards_item_id_conversation_id_pk": { + "name": "junior_review_item_slack_cards_item_id_conversation_id_pk", + "columns": ["item_id", "conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_review_items": { + "name": "junior_review_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request": { + "name": "request", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "answer": { + "name": "answer", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "assigned_to_everyone": { + "name": "assigned_to_everyone", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "dedupe_key": { + "name": "dedupe_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_title": { + "name": "subject_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject_url": { + "name": "subject_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by_conversation_id": { + "name": "added_by_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_by_turn_id": { + "name": "added_by_turn_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by_user_id": { + "name": "closed_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "close_reason": { + "name": "close_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_review_items_open_dedupe_uidx": { + "name": "junior_review_items_open_dedupe_uidx", + "columns": [ + { + "expression": "coalesce(\"provider\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"provider_tenant_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "dedupe_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_review_items\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_status_idx": { + "name": "junior_review_items_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_subject_idx": { + "name": "junior_review_items_subject_idx", + "columns": [ + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_review_items_automation_idx": { + "name": "junior_review_items_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_review_items_added_by_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_review_items_added_by_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_conversations", + "columnsFrom": ["added_by_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_review_items_automation_id_junior_automations_id_fk": { + "name": "junior_review_items_automation_id_junior_automations_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_review_items_closed_by_user_id_junior_users_id_fk": { + "name": "junior_review_items_closed_by_user_id_junior_users_id_fk", + "tableFrom": "junior_review_items", + "tableTo": "junior_users", + "columnsFrom": ["closed_by_user_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_review_items_kind_check": { + "name": "junior_review_items_kind_check", + "value": "(\"junior_review_items\".\"kind\" = 'notice' and \"junior_review_items\".\"request\" is null and \"junior_review_items\".\"answer\" is null) or (\"junior_review_items\".\"kind\" = 'action' and \"junior_review_items\".\"request\" is not null)" + }, + "junior_review_items_status_check": { + "name": "junior_review_items_status_check", + "value": "(\"junior_review_items\".\"status\" = 'open' and \"junior_review_items\".\"closed_at\" is null) or (\"junior_review_items\".\"status\" in ('done', 'dismissed') and \"junior_review_items\".\"closed_at\" is not null)" + }, + "junior_review_items_tenant_check": { + "name": "junior_review_items_tenant_check", + "value": "(\"junior_review_items\".\"provider\" is null) = (\"junior_review_items\".\"provider_tenant_id\" is null)" + }, + "junior_review_items_visibility_check": { + "name": "junior_review_items_visibility_check", + "value": "\"junior_review_items\".\"visibility\" = 'public' or not \"junior_review_items\".\"assigned_to_everyone\"" + } + }, + "isRLSEnabled": false + }, + "public.junior_snapshots": { + "name": "junior_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_hash": { + "name": "profile_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_duration_ms": { + "name": "build_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_started_at": { + "name": "build_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_phase": { + "name": "build_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_sandbox_name": { + "name": "build_sandbox_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_command_id": { + "name": "build_command_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_error": { + "name": "build_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_snapshots_snapshot_id_uidx": { + "name": "junior_snapshots_snapshot_id_uidx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_idx": { + "name": "junior_snapshots_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_profile_status_idx": { + "name": "junior_snapshots_workspace_profile_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_active_build_uidx": { + "name": "junior_snapshots_active_build_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_snapshots\".\"status\" = 'building'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_status_idx": { + "name": "junior_snapshots_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_snapshots_workspace_id_junior_workspaces_id_fk": { + "name": "junior_snapshots_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_snapshots", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_snapshots_status_check": { + "name": "junior_snapshots_status_check", + "value": "\"junior_snapshots\".\"status\" in ('building', 'failed', 'ready')" + }, + "junior_snapshots_build_phase_check": { + "name": "junior_snapshots_build_phase_check", + "value": "\"junior_snapshots\".\"build_phase\" is null or \"junior_snapshots\".\"build_phase\" in ('created', 'dependencies_installed', 'repositories_prepared')" + }, + "junior_snapshots_ready_fields_check": { + "name": "junior_snapshots_ready_fields_check", + "value": "\"junior_snapshots\".\"status\" <> 'ready' or (\"junior_snapshots\".\"snapshot_id\" is not null and \"junior_snapshots\".\"build_duration_ms\" is not null and \"junior_snapshots\".\"generated_at\" is not null)" + }, + "junior_snapshots_build_fields_check": { + "name": "junior_snapshots_build_fields_check", + "value": "\"junior_snapshots\".\"status\" = 'ready' or (\"junior_snapshots\".\"build_started_at\" is not null and \"junior_snapshots\".\"build_phase\" is not null)" + }, + "junior_snapshots_build_duration_check": { + "name": "junior_snapshots_build_duration_check", + "value": "\"junior_snapshots\".\"build_duration_ms\" is null or \"junior_snapshots\".\"build_duration_ms\" >= 0" + }, + "junior_snapshots_size_bytes_check": { + "name": "junior_snapshots_size_bytes_check", + "value": "\"junior_snapshots\".\"size_bytes\" is null or \"junior_snapshots\".\"size_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": ["date", "namespace", "metric", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "customization": { + "name": "customization", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspace_repos": { + "name": "junior_workspace_repos", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_workspace_repos_workspace_id_junior_workspaces_id_fk": { + "name": "junior_workspace_repos_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_workspace_repos", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_workspace_repos_workspace_id_provider_repo_pk": { + "name": "junior_workspace_repos_workspace_id_provider_repo_pk", + "columns": ["workspace_id", "provider", "repo"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspaces": { + "name": "junior_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_script": { + "name": "setup_script", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "vcpus": { + "name": "vcpus", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_workspaces_name_idx": { + "name": "junior_workspaces_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 067a318c0b..b7c567278e 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -519,6 +519,13 @@ "when": 1793782228382, "tag": "0073_conversation_quick_question", "breakpoints": true + }, + { + "idx": 74, + "version": "7", + "when": 1793795341052, + "tag": "0074_conversation_detail_version", + "breakpoints": true } ] } diff --git a/packages/junior/src/api/conversations/archive.ts b/packages/junior/src/api/conversations/archive.ts index b828edf58d..23f44fe288 100644 --- a/packages/junior/src/api/conversations/archive.ts +++ b/packages/junior/src/api/conversations/archive.ts @@ -1,6 +1,7 @@ import type { User } from "@sentry/junior-plugin-api"; import { and, eq, isNotNull, sql } from "drizzle-orm"; import { getDb, getSqlExecutor } from "@/chat/db"; +import { touchConversationDetail } from "@/chat/conversations/sql/detail-version"; import { resolveRootConversationId } from "@/chat/conversations/sql/participants"; import { juniorConversationParticipants, @@ -57,7 +58,11 @@ async function setViewerArchive(args: { lastMessageAt: sql`greatest(${juniorConversationParticipants.lastMessageAt}, excluded.last_message_at)`, }, }); - return { archivedAt: archivedAt.toISOString(), status: "updated" as const }; + await touchConversationDetail(db, [args.rootConversationId]); + return { + archivedAt: archivedAt.toISOString(), + status: "updated" as const, + }; } await db @@ -73,6 +78,7 @@ async function setViewerArchive(args: { isNotNull(juniorConversationParticipants.archivedAt), ), ); + await touchConversationDetail(db, [args.rootConversationId]); return { archivedAt: null, status: "updated" as const }; }); } diff --git a/packages/junior/src/api/conversations/detail-etag.ts b/packages/junior/src/api/conversations/detail-etag.ts new file mode 100644 index 0000000000..cd5139b23d --- /dev/null +++ b/packages/junior/src/api/conversations/detail-etag.ts @@ -0,0 +1,86 @@ +import { createHash } from "node:crypto"; +import type { User } from "@sentry/junior-plugin-api"; +import { eq, sql } from "drizzle-orm"; +import { getDb } from "@/chat/db"; +import { + conversationSubtree, + conversationSubtreeColumns, +} from "@/chat/conversations/sql/subtree"; +import { juniorConversations } from "@/db/schema"; +import { JUNIOR_VERSION } from "@/version"; +import { readConversationAccessFromSql } from "./access"; +import { readConversationAgentTree } from "./agent-tree"; +import { readConversationArchivedAt } from "./archive"; +import { readConversationForks } from "./fork"; +import { readConversationReadPosition } from "./read-position"; + +/** + * Sum the detail versions of a Conversation and its child agents. Child + * writes bump only their own row, and the row count changes when a child + * starts, so the parent needs no bump of its own. + */ +async function readSubtreeDetailVersion( + conversationId: string, +): Promise<{ conversations: number; version: string }> { + const [row] = await getDb() + .select({ + conversations: sql`count(*)::int`, + version: sql`coalesce(sum(${juniorConversations.detailVersion}), 0)::text`, + }) + .from(juniorConversations) + .innerJoin( + conversationSubtree([conversationId]), + eq( + conversationSubtreeColumns.conversationId, + juniorConversations.conversationId, + ), + ); + return row ?? { conversations: 0, version: "0" }; +} + +/** + * Return the detail ETag without building the report, or undefined for a + * missing Conversation. + * + * Shared content is covered by `detail_version`. Viewer state and data that + * other Conversations own (forks, agent tree labels) are cheap, so they are + * read on each request. Names, avatars, Automation titles, and plugin sidebar + * labels are not covered: they refresh with the next detail change or a reload. + */ +export async function readConversationDetailEtag( + conversationId: string, + options: { limit: number; viewer?: User }, +): Promise { + const { viewer } = options; + const [subtree, access, archivedAtMs, forks, agentTree, readPosition] = + await Promise.all([ + readSubtreeDetailVersion(conversationId), + readConversationAccessFromSql(getDb(), [conversationId], viewer), + viewer ? readConversationArchivedAt(viewer, conversationId) : undefined, + readConversationForks(conversationId, viewer), + readConversationAgentTree(conversationId, viewer), + viewer ? readConversationReadPosition(viewer, conversationId) : undefined, + ]); + if (subtree.conversations === 0) return undefined; + const basis = JSON.stringify({ + access: access.get(conversationId) ?? null, + agentTree: agentTree ?? null, + archivedAtMs: archivedAtMs ?? null, + forks, + junior: JUNIOR_VERSION, + limit: options.limit, + readPosition: readPosition ?? null, + subtree, + }); + return `W/"${createHash("sha256").update(basis).digest("hex")}"`; +} + +/** Report whether an `If-None-Match` header names the current ETag. */ +export function etagMatches(header: string | undefined, etag: string): boolean { + const opaque = etag.replace(/^W\//, ""); + return Boolean( + header + ?.split(",") + .some((value) => value.trim().replace(/^W\//, "") === opaque), + ); +} diff --git a/packages/junior/src/api/conversations/routes.ts b/packages/junior/src/api/conversations/routes.ts index c685467eac..b764071853 100644 --- a/packages/junior/src/api/conversations/routes.ts +++ b/packages/junior/src/api/conversations/routes.ts @@ -43,6 +43,7 @@ import { type WebInputServices, } from "./create"; import { readConversationDetail } from "./detail"; +import { etagMatches, readConversationDetailEtag } from "./detail-etag"; import { forkConversationForViewer, keepQuickQuestionForViewer } from "./fork"; import { readConversationEvents } from "./event-list"; import { readConversationFeed } from "./list"; @@ -429,12 +430,30 @@ export function createConversationRoutes( const { conversationId } = context.req.valid("param"); const query = context.req.valid("query"); const viewer = context.get("viewer"); + // Polls send the last ETag. Compare it before the report is built, + // because building the report is the expensive part of this route. + const etag = await readConversationDetailEtag(conversationId, { + limit: query.limit, + ...(viewer ? { viewer } : undefined), + }); + if (etag && etagMatches(context.req.header("if-none-match"), etag)) { + return new Response(null, { + status: 304, + headers: { "cache-control": "private, no-store", etag }, + }); + } const report = await readConversationDetail(conversationId, { ...query, ...(viewer ? { viewer } : undefined), }); if (!report) throwApiError(404, "Conversation not found."); - return jsonResponse(conversationDetailReportSchema, report); + return jsonResponse( + conversationDetailReportSchema, + report, + etag + ? { headers: { "cache-control": "private, no-store", etag } } + : undefined, + ); }, ); diff --git a/packages/junior/src/chat/attachments/store.ts b/packages/junior/src/chat/attachments/store.ts index 393eda56fa..98adf96ca8 100644 --- a/packages/junior/src/chat/attachments/store.ts +++ b/packages/junior/src/chat/attachments/store.ts @@ -3,6 +3,7 @@ import { and, asc, eq, inArray, isNotNull, isNull, lte, or } from "drizzle-orm"; import type { JuniorSqlDatabase } from "@/db/db"; import { juniorAttachments, juniorConversations } from "@/db/schema"; import type { SandboxFileUpload } from "@/chat/tools/sandbox/file-uploads"; +import { touchConversationDetail } from "@/chat/conversations/sql/detail-version"; import type { AttachmentStorage } from "./storage"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -126,6 +127,7 @@ async function rejectPurgedAttachmentWrite(args: { .update(juniorAttachments) .set({ deleteRequestedAt: args.now }) .where(eq(juniorAttachments.id, args.id)); + await touchConversationDetail(args.db.db(), [args.conversationId]); await bestEffortDelete(args.storage, args.storageKey); throw purgedConversationError(args.conversationId); } @@ -247,6 +249,7 @@ export async function storeAttachment(args: { ), ) .returning({ id: juniorAttachments.id }); + await touchConversationDetail(args.db.db(), [args.conversationId]); if (revived.length > 0) { if (await isConversationPurged(args.db, args.conversationId)) { await rejectPurgedAttachmentWrite({ @@ -309,6 +312,7 @@ export async function storeAttachment(args: { await bestEffortDelete(args.storage, storageKey); throw error; } + await touchConversationDetail(args.db.db(), [args.conversationId]); if (inserted.length > 0) { if (await isConversationPurged(args.db, args.conversationId)) { await rejectPurgedAttachmentWrite({ @@ -366,6 +370,7 @@ export async function storeAttachment(args: { ), ) .returning({ id: juniorAttachments.id }); + await touchConversationDetail(args.db.db(), [args.conversationId]); if (revived.length > 0) { if (await isConversationPurged(args.db, args.conversationId)) { await rejectPurgedAttachmentWrite({ @@ -486,6 +491,7 @@ export async function requestAttachmentDeletion( isNull(juniorAttachments.deleteRequestedAt), ), ); + await touchConversationDetail(db.db(), conversationIds); } /** @@ -561,7 +567,11 @@ export async function collectAttachmentGarbage(args: { ), ) .returning({ - id: juniorAttachments.id, + conversationId: juniorAttachments.conversationId, }); + await touchConversationDetail( + args.db.db(), + removed.map((row) => row.conversationId), + ); return { deleted: removed.length }; } diff --git a/packages/junior/src/chat/briefs/store.ts b/packages/junior/src/chat/briefs/store.ts index b89034079d..8cead618fa 100644 --- a/packages/junior/src/chat/briefs/store.ts +++ b/packages/junior/src/chat/briefs/store.ts @@ -1,6 +1,7 @@ import { and, desc, eq, max } from "drizzle-orm"; import type { JuniorDatabase } from "@/db/db"; import { juniorConversationBriefs, juniorConversations } from "@/db/schema"; +import { touchConversationDetail } from "@/chat/conversations/sql/detail-version"; import { resolveRootVisibility } from "@/chat/conversations/sql/privacy"; import { conversationBriefSchema, type ConversationBrief } from "./schema"; @@ -114,6 +115,7 @@ export async function appendConversationBrief( if (!row) { throw new Error("Brief insert conflicted after the Turn check"); } + await touchConversationDetail(tx, [input.conversationId]); return { inserted: true, value: briefVersionFromRow(row) }; }); } diff --git a/packages/junior/src/chat/conversations/fork.ts b/packages/junior/src/chat/conversations/fork.ts index 5af4c7e40b..ee2f2624e4 100644 --- a/packages/junior/src/chat/conversations/fork.ts +++ b/packages/junior/src/chat/conversations/fork.ts @@ -4,6 +4,7 @@ import type { User } from "@sentry/junior-plugin-api"; import type { ConversationEvent } from "./history"; import { loadTurnProjection } from "./projection"; import { contextProvenance } from "./provenance"; +import { nextDetailVersion } from "./sql/detail-version"; import { withConversationEventLock } from "./sql/event-lock"; import { createConversationId, @@ -185,6 +186,7 @@ export async function forkConversation(input: { ...(input.quickQuestion ? { quickQuestionAt: new Date(nowMs) } : undefined), + detailVersion: nextDetailVersion, }) .where(eq(juniorConversations.conversationId, conversationId)); await copyForkEvents({ @@ -228,7 +230,7 @@ export async function keepQuickQuestion(conversationId: string): Promise { const kept = await executor .db() .update(juniorConversations) - .set({ quickQuestionAt: null }) + .set({ quickQuestionAt: null, detailVersion: nextDetailVersion }) .where( and( eq(juniorConversations.conversationId, conversationId), diff --git a/packages/junior/src/chat/conversations/metrics.ts b/packages/junior/src/chat/conversations/metrics.ts index ff4fe3dae5..f3b6f85f4e 100644 --- a/packages/junior/src/chat/conversations/metrics.ts +++ b/packages/junior/src/chat/conversations/metrics.ts @@ -11,6 +11,7 @@ import { type AgentTurnUsage, } from "@/chat/usage"; import type { ConversationRunMetrics } from "./store"; +import { nextDetailVersion } from "./sql/detail-version"; function finite(value: number | undefined): number | undefined { return value !== undefined && Number.isFinite(value) && value >= 0 @@ -187,6 +188,7 @@ export async function replaceConversationMetrics( 'cost', ${cost} )) end`, + detailVersion: nextDetailVersion, }) .where(eq(juniorConversations.conversationId, args.conversationId)); } diff --git a/packages/junior/src/chat/conversations/session-stop.ts b/packages/junior/src/chat/conversations/session-stop.ts index 10d643c0b2..bdfef0b06a 100644 --- a/packages/junior/src/chat/conversations/session-stop.ts +++ b/packages/junior/src/chat/conversations/session-stop.ts @@ -6,6 +6,7 @@ import type { ConversationWorkerResult, } from "@/chat/task-execution/worker"; import { juniorConversations } from "@/db/schema"; +import { nextDetailVersion } from "@/chat/conversations/sql/detail-version"; /** * Record a session stop on each Conversation. Call this after their watches @@ -22,7 +23,7 @@ export async function markConversationsStopped( await getSqlExecutor() .db() .update(juniorConversations) - .set({ stoppedAt: new Date(nowMs) }) + .set({ stoppedAt: new Date(nowMs), detailVersion: nextDetailVersion }) .where(inArray(juniorConversations.conversationId, [...conversationIds])); } diff --git a/packages/junior/src/chat/conversations/sql/conversation-row.ts b/packages/junior/src/chat/conversations/sql/conversation-row.ts index 5c49e96fe2..f36c6a8359 100644 --- a/packages/junior/src/chat/conversations/sql/conversation-row.ts +++ b/packages/junior/src/chat/conversations/sql/conversation-row.ts @@ -1,6 +1,7 @@ import { sql } from "drizzle-orm"; import type { JuniorSqlDatabase } from "@/db/db"; import { juniorConversations } from "@/db/schema"; +import { nextDetailVersion } from "./detail-version"; /** * Establish the conversation metadata row on first contact for content writes @@ -41,6 +42,7 @@ export async function ensureConversationRow( lastActivityAt: sql`greatest(${juniorConversations.lastActivityAt}, excluded.last_activity_at)`, updatedAt: sql`greatest(${juniorConversations.updatedAt}, excluded.updated_at)`, transcriptPurgedAt: null, + detailVersion: nextDetailVersion, }, }); } diff --git a/packages/junior/src/chat/conversations/sql/detail-version.ts b/packages/junior/src/chat/conversations/sql/detail-version.ts new file mode 100644 index 0000000000..cdce122a4f --- /dev/null +++ b/packages/junior/src/chat/conversations/sql/detail-version.ts @@ -0,0 +1,23 @@ +import { inArray, sql } from "drizzle-orm"; +import type { JuniorDatabase } from "@/db/db"; +import { juniorConversations } from "@/db/schema"; + +/** SQL that advances `detail_version` inside a `juniorConversations` set. */ +export const nextDetailVersion = sql`${juniorConversations.detailVersion} + 1`; + +/** + * Mark the detail report of each Conversation as changed. Call it after the + * write it covers, in the same transaction when there is one. A bump before + * the write lets a detail read store old content under the new version. + */ +export async function touchConversationDetail( + db: JuniorDatabase, + conversationIds: readonly string[], +): Promise { + const ids = [...new Set(conversationIds)]; + if (ids.length === 0) return; + await db + .update(juniorConversations) + .set({ detailVersion: nextDetailVersion }) + .where(inArray(juniorConversations.conversationId, ids)); +} diff --git a/packages/junior/src/chat/conversations/sql/history.ts b/packages/junior/src/chat/conversations/sql/history.ts index 315aeac29c..409ee7bf25 100644 --- a/packages/junior/src/chat/conversations/sql/history.ts +++ b/packages/junior/src/chat/conversations/sql/history.ts @@ -35,6 +35,7 @@ import { import { juniorConversationEvents } from "@/db/schema"; import { sanitizePostgresJson } from "@/db/postgres-json"; import { encodeHistoryPayload } from "../history-payload"; +import { touchConversationDetail } from "./detail-version"; import { withConversationEventLock } from "./event-lock"; import { recordConversationParticipant } from "./participants"; @@ -242,6 +243,7 @@ class SqlConversationEventStore implements ConversationEventStore { ); } await this.executor.db().insert(juniorConversationEvents).values(rows); + await touchConversationDetail(this.executor.db(), [conversationId]); for (const event of pending) { if (event.data.type === "turn_started") { await this.recordTurnInputParticipants( @@ -329,6 +331,7 @@ class SqlConversationEventStore implements ConversationEventStore { parsed, ), ); + await touchConversationDetail(this.executor.db(), [conversationId]); return { status: "replaced" as const, historyVersion }; }, ); diff --git a/packages/junior/src/chat/conversations/sql/location.ts b/packages/junior/src/chat/conversations/sql/location.ts index 0bd161bfcf..8b843f0abb 100644 --- a/packages/junior/src/chat/conversations/sql/location.ts +++ b/packages/junior/src/chat/conversations/sql/location.ts @@ -6,6 +6,7 @@ import type { SessionSource } from "@/chat/source"; import type { JuniorSqlDatabase } from "@/db/db"; import { juniorConversations, type juniorDestinations } from "@/db/schema"; import type { ProviderConversationBinding } from "./bindings"; +import { nextDetailVersion } from "./detail-version"; type LocationRow = typeof juniorDestinations.$inferSelect; @@ -131,6 +132,7 @@ export async function startLocationThread( .update(juniorConversations) .set({ location: sql`${location} || jsonb_build_object('threadTs', ${binding.providerConversationId}::text)`, + detailVersion: nextDetailVersion, }) .where( and( diff --git a/packages/junior/src/chat/conversations/sql/participants.ts b/packages/junior/src/chat/conversations/sql/participants.ts index ea08db63b2..23986bf6cb 100644 --- a/packages/junior/src/chat/conversations/sql/participants.ts +++ b/packages/junior/src/chat/conversations/sql/participants.ts @@ -5,6 +5,7 @@ import { juniorConversations, juniorIdentities, } from "@/db/schema"; +import { touchConversationDetail } from "./detail-version"; /** Resolve the root conversation id that owns personal-feed membership. */ export async function resolveRootConversationId( @@ -71,6 +72,10 @@ export async function recordConversationParticipant( ...(args.restoreArchive ? { archivedAt: null } : undefined), }, }); + await touchConversationDetail(executor.db(), [ + args.conversationId, + rootConversationId, + ]); } /** Find which of the supplied root conversations include the linked user. */ diff --git a/packages/junior/src/chat/conversations/sql/purge.ts b/packages/junior/src/chat/conversations/sql/purge.ts index c3d31181dc..fa0069a631 100644 --- a/packages/junior/src/chat/conversations/sql/purge.ts +++ b/packages/junior/src/chat/conversations/sql/purge.ts @@ -12,6 +12,7 @@ import { juniorAgentInvocations, juniorReviewItems, } from "@/db/schema"; +import { touchConversationDetail } from "./detail-version"; import { withConversationEventLock } from "./event-lock"; import { resolveRootVisibility } from "./privacy"; import { withConversationMutationLock } from "./store"; @@ -360,6 +361,7 @@ export async function purgeConversationTree( : undefined), }) .where(inArray(juniorConversations.conversationId, ids)); + await touchConversationDetail(executor.db(), ids); return { purged: true, conversations: ids.length }; }, ); diff --git a/packages/junior/src/chat/conversations/sql/store.ts b/packages/junior/src/chat/conversations/sql/store.ts index bbd9928c07..94e6fc65ee 100644 --- a/packages/junior/src/chat/conversations/sql/store.ts +++ b/packages/junior/src/chat/conversations/sql/store.ts @@ -50,6 +50,7 @@ import { locationFromRow, visibilityFromLocationRow, } from "./location"; +import { nextDetailVersion } from "./detail-version"; type ConversationRow = Omit< typeof juniorConversations.$inferSelect, "legacyArchivedAt" @@ -573,6 +574,7 @@ export class SqlStore implements ConversationStore { metricRunId: args.execution.runId, executionDurationMs: args.metrics.durationMs, executionUsage: args.metrics.usage ?? null, + detailVersion: nextDetailVersion, }) .where(eq(juniorConversations.conversationId, args.conversationId)); } @@ -851,6 +853,7 @@ export class SqlStore implements ConversationStore { lastCheckpointAt: sql`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_checkpoint_at, ${juniorConversations.lastCheckpointAt}) else ${juniorConversations.lastCheckpointAt} end`, lastEnqueuedAt: sql`case when ${incomingExecutionIsFresh} then coalesce(excluded.last_enqueued_at, ${juniorConversations.lastEnqueuedAt}) else ${juniorConversations.lastEnqueuedAt} end`, rootConversationId: sql`coalesce(${juniorConversations.rootConversationId}, excluded.root_conversation_id)`, + detailVersion: nextDetailVersion, }, }) .returning({ diff --git a/packages/junior/src/chat/plugins/annotations.ts b/packages/junior/src/chat/plugins/annotations.ts index 3e464af1e3..7651b08c56 100644 --- a/packages/junior/src/chat/plugins/annotations.ts +++ b/packages/junior/src/chat/plugins/annotations.ts @@ -9,6 +9,7 @@ import { juniorConversationAnnotations, juniorConversations, } from "@/db/schema"; +import { touchConversationDetail } from "@/chat/conversations/sql/detail-version"; function annotationFromRow(row: { annotation: unknown; @@ -56,6 +57,7 @@ export function createPluginAnnotations(args: { ], set: { annotation, updatedAt: now }, }); + await touchConversationDetail(args.db, [args.conversationId]); }, async remove(kind, key) { await args.db @@ -71,6 +73,7 @@ export function createPluginAnnotations(args: { eq(juniorConversationAnnotations.key, key), ), ); + await touchConversationDetail(args.db, [args.conversationId]); }, async list() { const rows = await args.db diff --git a/packages/junior/src/chat/slack/attachments.ts b/packages/junior/src/chat/slack/attachments.ts index cb198ca3a0..b60b51c226 100644 --- a/packages/junior/src/chat/slack/attachments.ts +++ b/packages/junior/src/chat/slack/attachments.ts @@ -3,6 +3,7 @@ import type { MessageAttachment } from "@/chat/attachments/input"; import { and, eq, inArray, isNull } from "drizzle-orm"; import type { JuniorSqlDatabase } from "@/db/db"; import { juniorAttachments } from "@/db/schema"; +import { touchConversationDetail } from "@/chat/conversations/sql/detail-version"; /** Save the vision summary for a stored Slack attachment. */ export async function recordSlackAttachment(args: { @@ -22,6 +23,7 @@ export async function recordSlackAttachment(args: { isNull(juniorAttachments.deleteRequestedAt), ), ); + await touchConversationDetail(args.db.db(), [args.conversationId]); } /** Read a saved vision summary for one live attachment. */ diff --git a/packages/junior/src/db/schema/conversations.ts b/packages/junior/src/db/schema/conversations.ts index d2b223bd30..cb9550d0b4 100644 --- a/packages/junior/src/db/schema/conversations.ts +++ b/packages/junior/src/db/schema/conversations.ts @@ -89,6 +89,10 @@ export const juniorConversations = pgTable( executionDurationMs: integer("execution_duration_ms").notNull().default(0), executionUsage: jsonb("execution_usage_json").$type(), metricRunId: text("metric_run_id"), + // Bumped by every write that changes this Conversation's detail report. + // Detail reads compare it before they build the report. The + // `junior/touch-conversation-detail` lint rule enforces the bump. + detailVersion: integer("detail_version").notNull().default(0), // Keep the legacy column in the schema. Runtime code must not read or write it. legacyArchivedAt: timestamptz("archived_at"), }, diff --git a/packages/junior/tests/integration/api/conversations/detail.test.ts b/packages/junior/tests/integration/api/conversations/detail.test.ts index bd2d2a42d4..e556451bfb 100644 --- a/packages/junior/tests/integration/api/conversations/detail.test.ts +++ b/packages/junior/tests/integration/api/conversations/detail.test.ts @@ -17,6 +17,7 @@ import { usersInfoOk } from "../../../fixtures/slack/factories/api"; import { juniorIdentities } from "@/db/schema"; import { and, eq } from "drizzle-orm"; import { queueSlackApiResponse } from "../../../msw/handlers/slack-api"; +import { createChild } from "../../../fixtures/dashboard-reporting"; describe("conversation detail API", () => { afterEach(async () => { @@ -119,6 +120,69 @@ describe("conversation detail API", () => { }); }); + it("answers unchanged polls with 304 until the subtree or annotations change", async () => { + const conversationId = "local:test:revalidated-detail"; + const childConversationId = "local:test:revalidated-detail:child"; + await getConversationStore().recordActivity({ + destination: { platform: "local" as const, conversationId }, + conversationId, + nowMs: 1, + source: "internal", + title: "Revalidated conversation", + visibility: "public", + }); + const app = createJuniorApi(); + const poll = (etag?: string) => + app.request(`http://localhost/api/conversations/${conversationId}`, { + headers: etag ? { "if-none-match": etag } : {}, + }); + + const first = await poll(); + const firstEtag = first.headers.get("etag")!; + expect(first.status).toBe(200); + expect(firstEtag).toMatch(/^W\/"/); + const unchanged = await poll(firstEtag); + expect(unchanged.status).toBe(304); + expect(await unchanged.text()).toBe(""); + + await createChild({ + childConversationId, + parentConversationId: conversationId, + }); + const withChild = await poll(firstEtag); + const childEtag = withChild.headers.get("etag")!; + expect(withChild.status).toBe(200); + expect(childEtag).not.toBe(firstEtag); + + // A child agent writes only its own row. The parent still revalidates. + await getConversationEventStore().append(childConversationId, [ + { + createdAtMs: 5, + data: { + messageId: "child-message", + role: "assistant", + text: "child-message", + type: "message", + }, + }, + ]); + const childChanged = await poll(childEtag); + const childChangedEtag = childChanged.headers.get("etag")!; + expect(childChanged.status).toBe(200); + + await createPluginAnnotations({ + conversationId, + db: getDb(), + plugin: "github", + }).upsert({ + kind: "resource_link", + key: "getsentry/junior#1", + label: "getsentry/junior #1", + url: "https://github.com/getsentry/junior/pull/1", + }); + expect((await poll(childChangedEtag)).status).toBe(200); + }); + it("titles an untitled conversation from its first human message", async () => { const conversationId = "local:test:untitled-detail"; await getConversationStore().recordActivity({ diff --git a/tools/oxlint/junior/index.ts b/tools/oxlint/junior/index.ts new file mode 100644 index 0000000000..bd98fec375 --- /dev/null +++ b/tools/oxlint/junior/index.ts @@ -0,0 +1,13 @@ +import { definePlugin } from "@oxlint/plugins"; + +import { touchConversationDetailRule } from "./rules/touch-conversation-detail.ts"; + +/** Oxlint rules for Junior runtime invariants. */ +const juniorPlugin = definePlugin({ + meta: { name: "junior" }, + rules: { + "touch-conversation-detail": touchConversationDetailRule, + }, +}); + +export default juniorPlugin; diff --git a/tools/oxlint/junior/package.json b/tools/oxlint/junior/package.json new file mode 100644 index 0000000000..eff4f677df --- /dev/null +++ b/tools/oxlint/junior/package.json @@ -0,0 +1,5 @@ +{ + "name": "junior-oxlint-junior", + "private": true, + "type": "module" +} diff --git a/tools/oxlint/junior/rules/touch-conversation-detail.test.ts b/tools/oxlint/junior/rules/touch-conversation-detail.test.ts new file mode 100644 index 0000000000..13ec1c7b46 --- /dev/null +++ b/tools/oxlint/junior/rules/touch-conversation-detail.test.ts @@ -0,0 +1,78 @@ +import { describe, it } from "node:test"; +import { RuleTester } from "oxlint/plugins-dev"; + +import { touchConversationDetailRule } from "./touch-conversation-detail.ts"; + +RuleTester.describe = describe; +RuleTester.it = it; + +const missingTouch = [{ messageId: "missingTouch" }]; + +new RuleTester().run("touch-conversation-detail", touchConversationDetailRule, { + valid: [ + `async function append(db) { + await db.insert(juniorConversationEvents).values(rows); + await touchConversationDetail(db, [id]); + }`, + `async function stop(db) { + await db.update(juniorConversations).set({ stoppedAt, detailVersion: nextDetailVersion }); + }`, + `async function create(db) { + await db.insert(juniorConversations).values(row); + }`, + `async function ensure(db) { + const insert = db.insert(juniorConversations).values(row); + await insert.onConflictDoUpdate({ target, set: { updatedAt, detailVersion: nextDetailVersion } }); + }`, + `async function read(db) { + await db.select().from(juniorConversationEvents); + }`, + `async function other(db) { + await db.insert(juniorUsers).values(row); + }`, + ], + invalid: [ + { + code: `async function append(db) { + await db.insert(juniorConversationEvents).values(rows); + }`, + errors: missingTouch, + }, + { + code: `async function append(db) { + await touchConversationDetail(db, [id]); + await db.insert(juniorConversationEvents).values(rows); + }`, + errors: missingTouch, + }, + { + code: `async function append(db) { + const write = async () => db.delete(juniorConversationAnnotations).where(match); + await write(); + await touchConversationDetail(db, [id]); + }`, + errors: missingTouch, + }, + { + code: `async function rename(db) { + await db.update(juniorConversations).set({ title }); + }`, + errors: missingTouch, + }, + { + code: `async function ensure(db) { + const insert = db.insert(juniorConversations).values(row); + await insert.onConflictDoUpdate({ target, set: { updatedAt } }); + }`, + errors: missingTouch, + }, + { + code: "async function raw(db) { await db.execute(sql`update ${juniorConversationBriefs} set x = 1`); }", + errors: missingTouch, + }, + { + code: "async function raw(db) { await db.execute(sql`delete from junior_attachments where id = ${id}`); }", + errors: missingTouch, + }, + ], +}); diff --git a/tools/oxlint/junior/rules/touch-conversation-detail.ts b/tools/oxlint/junior/rules/touch-conversation-detail.ts new file mode 100644 index 0000000000..5d62f85cce --- /dev/null +++ b/tools/oxlint/junior/rules/touch-conversation-detail.ts @@ -0,0 +1,245 @@ +import { defineRule } from "@oxlint/plugins"; + +import type { Context, ESTree } from "@oxlint/plugins"; + +// Tables that the Conversation detail report reads. Keep this list in sync +// with `readConversationDetail`. +const detailTables = new Set([ + "juniorAttachments", + "juniorConversationAnnotations", + "juniorConversationBriefs", + "juniorConversationEvents", + "juniorConversationParticipants", + "juniorConversations", +]); +const detailTableNames = [ + "junior_attachments", + "junior_conversation_annotations", + "junior_conversation_briefs", + "junior_conversation_events", + "junior_conversation_participants", + "junior_conversations", +]; +const writeMethods = new Set(["delete", "insert", "update"]); +const touchFunction = "touchConversationDetail"; +const versionKey = "detailVersion"; +const rawWritePrefix = /\b(?:insert\s+into|update|delete\s+from)\s*"?$/i; +const rawWriteNamed = new RegExp( + String.raw`\b(?:insert\s+into|update|delete\s+from)\s+"?(?:${detailTableNames.join("|")})\b`, + "i", +); + +type Write = { node: ESTree.Node; scope: ESTree.Node | null; end: number }; + +function propertyName(node: ESTree.Node): string | null { + if (node.type !== "MemberExpression") return null; + if (!node.computed && node.property.type === "Identifier") { + return node.property.name; + } + return node.property.type === "Literal" && + typeof node.property.value === "string" + ? node.property.value + : null; +} + +function enclosingFunction(node: ESTree.Node): ESTree.Node | null { + let current = node.parent; + while (current) { + if ( + current.type === "FunctionDeclaration" || + current.type === "FunctionExpression" || + current.type === "ArrowFunctionExpression" + ) { + return current; + } + current = current.parent; + } + return null; +} + +/** Return each `.method(...)` call chained onto a query builder call. */ +function chainedCalls( + call: ESTree.CallExpression, +): Array<{ name: string; call: ESTree.CallExpression }> { + const calls: Array<{ name: string; call: ESTree.CallExpression }> = []; + let current: ESTree.Node = call; + while ( + current.parent?.type === "MemberExpression" && + current.parent.object === current && + current.parent.parent?.type === "CallExpression" && + current.parent.parent.callee === current.parent + ) { + const name = propertyName(current.parent); + current = current.parent.parent; + if (name) calls.push({ name, call: current }); + } + return calls; +} + +function objectHasKey(node: ESTree.Node | undefined, key: string): boolean { + if (node?.type !== "ObjectExpression") return false; + return node.properties.some( + (property) => + property.type === "Property" && + !property.computed && + ((property.key.type === "Identifier" && property.key.name === key) || + (property.key.type === "Literal" && property.key.value === key)), + ); +} + +function objectProperty( + node: ESTree.Node | undefined, + key: string, +): ESTree.Node | undefined { + if (node?.type !== "ObjectExpression") return undefined; + for (const property of node.properties) { + if ( + property.type === "Property" && + !property.computed && + property.key.type === "Identifier" && + property.key.name === key + ) { + return property.value; + } + } + return undefined; +} + +/** + * Return the calls made on a builder, through its chain or through the + * variable that holds it, as in `const insert = db.insert(t); insert.x()`. + */ +function builderCalls( + context: Context, + call: ESTree.CallExpression, +): Array<{ name: string; call: ESTree.CallExpression }> { + const calls = chainedCalls(call); + const top: ESTree.Node = calls.at(-1)?.call ?? call; + if (top.parent?.type !== "VariableDeclarator" || top.parent.init !== top) { + return calls; + } + for (const variable of context.sourceCode.getDeclaredVariables(top.parent)) { + for (const reference of variable.references) { + const member = reference.identifier.parent; + const used = member?.parent; + if ( + member?.type !== "MemberExpression" || + used?.type !== "CallExpression" + ) + continue; + const name = propertyName(member); + if (name && used.callee === member) calls.push({ name, call: used }); + } + } + return calls; +} + +/** + * A `juniorConversations` write that advances `detailVersion` itself needs no + * separate touch. A plain insert creates a row whose version starts at zero, + * and detail reads count the rows of a subtree. + */ +function conversationWriteBumpsItself( + context: Context, + method: string, + call: ESTree.CallExpression, +): boolean { + const calls = builderCalls(context, call); + if (method === "update") { + const set = calls.find((item) => item.name === "set"); + return objectHasKey(set?.call.arguments[0], versionKey); + } + if (method === "insert") { + return calls + .filter((item) => item.name === "onConflictDoUpdate") + .every((upsert) => + objectHasKey( + objectProperty(upsert.call.arguments[0], "set"), + versionKey, + ), + ); + } + return false; +} + +/** Require each detail table write to advance the Conversation detail version. */ +export const touchConversationDetailRule = defineRule({ + meta: { + type: "problem", + docs: { + description: + "Require writes to tables that the Conversation detail report reads to call touchConversationDetail afterwards.", + }, + messages: { + missingTouch: + "This write changes the Conversation detail report. Call `touchConversationDetail` after it in the same function, or set `detailVersion` on a `juniorConversations` write. Otherwise open detail pages keep serving the old report.", + }, + }, + create(context) { + const writes: Write[] = []; + const touches: Array<{ scope: ESTree.Node | null; start: number }> = []; + + return { + CallExpression(node) { + if ( + node.callee.type === "Identifier" && + node.callee.name === touchFunction + ) { + touches.push({ + scope: enclosingFunction(node), + start: node.range[0], + }); + return; + } + const method = propertyName(node.callee); + const table = node.arguments[0]; + if ( + !method || + !writeMethods.has(method) || + table?.type !== "Identifier" + ) + return; + if (!detailTables.has(table.name)) return; + if ( + table.name === "juniorConversations" && + conversationWriteBumpsItself(context, method, node) + ) { + return; + } + const chain = chainedCalls(node); + const end = (chain.at(-1)?.call ?? node).range[1]; + writes.push({ node, scope: enclosingFunction(node), end }); + }, + TaggedTemplateExpression(node) { + if (node.tag.type !== "Identifier" || node.tag.name !== "sql") return; + const { quasis, expressions } = node.quasi; + const writesTable = + quasis.some((quasi) => rawWriteNamed.test(quasi.value.raw)) || + expressions.some((expression, index) => { + const before = quasis[index]?.value.raw ?? ""; + return ( + expression.type === "Identifier" && + detailTables.has(expression.name) && + rawWritePrefix.test(before) + ); + }); + if (writesTable) { + writes.push({ + node, + scope: enclosingFunction(node), + end: node.range[1], + }); + } + }, + "Program:exit"() { + for (const write of writes) { + const touched = touches.some( + (touch) => touch.scope === write.scope && touch.start > write.end, + ); + if (!touched) + context.report({ node: write.node, messageId: "missingTouch" }); + } + }, + }; + }, +}); From 44478d10fca562dab03460676271bde0e558d93e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 14:22:03 +0000 Subject: [PATCH 2/2] perf(conversations): Bump detail_version in the write statement Carry the detail_version bump as a data-modifying CTE on the write it covers, so it needs no extra round trip. touchConversationDetail stays for the purge transaction and attachment GC, which learn their ids from the write. Participant upserts no longer bump. Each caller already bumps the Conversation in the same transaction, and the root sums its subtree. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> Co-Authored-By: BYK --- .../junior/src/api/conversations/archive.ts | 6 +-- packages/junior/src/chat/attachments/store.ts | 19 ++++---- packages/junior/src/chat/briefs/store.ts | 4 +- .../chat/conversations/sql/detail-version.ts | 43 +++++++++++++++---- .../src/chat/conversations/sql/history.ts | 15 ++++--- .../chat/conversations/sql/participants.ts | 9 ++-- .../junior/src/chat/plugins/annotations.ts | 6 +-- packages/junior/src/chat/slack/attachments.ts | 8 ++-- .../rules/touch-conversation-detail.test.ts | 9 ++++ .../junior/rules/touch-conversation-detail.ts | 22 +++++++++- 10 files changed, 99 insertions(+), 42 deletions(-) diff --git a/packages/junior/src/api/conversations/archive.ts b/packages/junior/src/api/conversations/archive.ts index 23f44fe288..6425625165 100644 --- a/packages/junior/src/api/conversations/archive.ts +++ b/packages/junior/src/api/conversations/archive.ts @@ -1,7 +1,7 @@ import type { User } from "@sentry/junior-plugin-api"; import { and, eq, isNotNull, sql } from "drizzle-orm"; import { getDb, getSqlExecutor } from "@/chat/db"; -import { touchConversationDetail } from "@/chat/conversations/sql/detail-version"; +import { conversationDetailTouch } from "@/chat/conversations/sql/detail-version"; import { resolveRootConversationId } from "@/chat/conversations/sql/participants"; import { juniorConversationParticipants, @@ -41,6 +41,7 @@ async function setViewerArchive(args: { const archivedAt = new Date(); const lastMessageAt = new Date(args.lastSeenAt); await db + .with(conversationDetailTouch(db, [args.rootConversationId])) .insert(juniorConversationParticipants) .values({ archivedAt, @@ -58,7 +59,6 @@ async function setViewerArchive(args: { lastMessageAt: sql`greatest(${juniorConversationParticipants.lastMessageAt}, excluded.last_message_at)`, }, }); - await touchConversationDetail(db, [args.rootConversationId]); return { archivedAt: archivedAt.toISOString(), status: "updated" as const, @@ -66,6 +66,7 @@ async function setViewerArchive(args: { } await db + .with(conversationDetailTouch(db, [args.rootConversationId])) .update(juniorConversationParticipants) .set({ archivedAt: null }) .where( @@ -78,7 +79,6 @@ async function setViewerArchive(args: { isNotNull(juniorConversationParticipants.archivedAt), ), ); - await touchConversationDetail(db, [args.rootConversationId]); return { archivedAt: null, status: "updated" as const }; }); } diff --git a/packages/junior/src/chat/attachments/store.ts b/packages/junior/src/chat/attachments/store.ts index 98adf96ca8..0ae9f8f2fd 100644 --- a/packages/junior/src/chat/attachments/store.ts +++ b/packages/junior/src/chat/attachments/store.ts @@ -3,7 +3,10 @@ import { and, asc, eq, inArray, isNotNull, isNull, lte, or } from "drizzle-orm"; import type { JuniorSqlDatabase } from "@/db/db"; import { juniorAttachments, juniorConversations } from "@/db/schema"; import type { SandboxFileUpload } from "@/chat/tools/sandbox/file-uploads"; -import { touchConversationDetail } from "@/chat/conversations/sql/detail-version"; +import { + conversationDetailTouch, + touchConversationDetail, +} from "@/chat/conversations/sql/detail-version"; import type { AttachmentStorage } from "./storage"; const DAY_MS = 24 * 60 * 60 * 1000; @@ -122,12 +125,12 @@ async function rejectPurgedAttachmentWrite(args: { storage: AttachmentStorage; storageKey: string; }): Promise { - await args.db - .db() + const db = args.db.db(); + await db + .with(conversationDetailTouch(db, [args.conversationId])) .update(juniorAttachments) .set({ deleteRequestedAt: args.now }) .where(eq(juniorAttachments.id, args.id)); - await touchConversationDetail(args.db.db(), [args.conversationId]); await bestEffortDelete(args.storage, args.storageKey); throw purgedConversationError(args.conversationId); } @@ -237,6 +240,7 @@ export async function storeAttachment(args: { const oldStorageKey = existing.storageKey; const revived = await args.db .db() + .with(conversationDetailTouch(args.db.db(), [args.conversationId])) .update(juniorAttachments) .set({ deleteRequestedAt: null, @@ -249,7 +253,6 @@ export async function storeAttachment(args: { ), ) .returning({ id: juniorAttachments.id }); - await touchConversationDetail(args.db.db(), [args.conversationId]); if (revived.length > 0) { if (await isConversationPurged(args.db, args.conversationId)) { await rejectPurgedAttachmentWrite({ @@ -290,6 +293,7 @@ export async function storeAttachment(args: { try { inserted = await args.db .db() + .with(conversationDetailTouch(args.db.db(), [args.conversationId])) .insert(juniorAttachments) .values({ id, @@ -312,7 +316,6 @@ export async function storeAttachment(args: { await bestEffortDelete(args.storage, storageKey); throw error; } - await touchConversationDetail(args.db.db(), [args.conversationId]); if (inserted.length > 0) { if (await isConversationPurged(args.db, args.conversationId)) { await rejectPurgedAttachmentWrite({ @@ -358,6 +361,7 @@ export async function storeAttachment(args: { const revived = await args.db .db() + .with(conversationDetailTouch(args.db.db(), [args.conversationId])) .update(juniorAttachments) .set({ deleteRequestedAt: null, @@ -370,7 +374,6 @@ export async function storeAttachment(args: { ), ) .returning({ id: juniorAttachments.id }); - await touchConversationDetail(args.db.db(), [args.conversationId]); if (revived.length > 0) { if (await isConversationPurged(args.db, args.conversationId)) { await rejectPurgedAttachmentWrite({ @@ -483,6 +486,7 @@ export async function requestAttachmentDeletion( if (conversationIds.length === 0) return; await db .db() + .with(conversationDetailTouch(db.db(), conversationIds)) .update(juniorAttachments) .set({ deleteRequestedAt: new Date(nowMs) }) .where( @@ -491,7 +495,6 @@ export async function requestAttachmentDeletion( isNull(juniorAttachments.deleteRequestedAt), ), ); - await touchConversationDetail(db.db(), conversationIds); } /** diff --git a/packages/junior/src/chat/briefs/store.ts b/packages/junior/src/chat/briefs/store.ts index 8cead618fa..13ef640189 100644 --- a/packages/junior/src/chat/briefs/store.ts +++ b/packages/junior/src/chat/briefs/store.ts @@ -1,7 +1,7 @@ import { and, desc, eq, max } from "drizzle-orm"; import type { JuniorDatabase } from "@/db/db"; import { juniorConversationBriefs, juniorConversations } from "@/db/schema"; -import { touchConversationDetail } from "@/chat/conversations/sql/detail-version"; +import { conversationDetailTouch } from "@/chat/conversations/sql/detail-version"; import { resolveRootVisibility } from "@/chat/conversations/sql/privacy"; import { conversationBriefSchema, type ConversationBrief } from "./schema"; @@ -93,6 +93,7 @@ export async function appendConversationBrief( .where(eq(juniorConversationBriefs.conversationId, input.conversationId)); const version = (versions[0]?.version ?? 0) + 1; const inserted = await tx + .with(conversationDetailTouch(tx, [input.conversationId])) .insert(juniorConversationBriefs) .values({ conversationId: input.conversationId, @@ -115,7 +116,6 @@ export async function appendConversationBrief( if (!row) { throw new Error("Brief insert conflicted after the Turn check"); } - await touchConversationDetail(tx, [input.conversationId]); return { inserted: true, value: briefVersionFromRow(row) }; }); } diff --git a/packages/junior/src/chat/conversations/sql/detail-version.ts b/packages/junior/src/chat/conversations/sql/detail-version.ts index cdce122a4f..7df8a95dd4 100644 --- a/packages/junior/src/chat/conversations/sql/detail-version.ts +++ b/packages/junior/src/chat/conversations/sql/detail-version.ts @@ -5,19 +5,44 @@ import { juniorConversations } from "@/db/schema"; /** SQL that advances `detail_version` inside a `juniorConversations` set. */ export const nextDetailVersion = sql`${juniorConversations.detailVersion} + 1`; +function bumpDetailVersion( + db: JuniorDatabase, + conversationIds: readonly string[], +) { + return db + .update(juniorConversations) + .set({ detailVersion: nextDetailVersion }) + .where(inArray(juniorConversations.conversationId, [...conversationIds])); +} + +/** + * Return a CTE that marks the detail report of each Conversation as changed, + * in the same statement as the write that it covers: + * `db.with(conversationDetailTouch(db, ids)).insert(table)`. + * Postgres runs a data-modifying CTE even when the statement does not read it, + * and both changes commit together. + */ +export function conversationDetailTouch( + db: JuniorDatabase, + conversationIds: readonly string[], +) { + return db.$with("conversation_detail_touch").as( + bumpDetailVersion(db, conversationIds).returning({ + conversationId: juniorConversations.conversationId, + }), + ); +} + /** - * Mark the detail report of each Conversation as changed. Call it after the - * write it covers, in the same transaction when there is one. A bump before - * the write lets a detail read store old content under the new version. + * Mark the detail report of each Conversation as changed, for a write that + * cannot carry `conversationDetailTouch`. Call it after that write, in the same + * transaction when there is one. A bump before the write lets a detail read + * store old content under the new version. */ export async function touchConversationDetail( db: JuniorDatabase, conversationIds: readonly string[], ): Promise { - const ids = [...new Set(conversationIds)]; - if (ids.length === 0) return; - await db - .update(juniorConversations) - .set({ detailVersion: nextDetailVersion }) - .where(inArray(juniorConversations.conversationId, ids)); + if (conversationIds.length === 0) return; + await bumpDetailVersion(db, conversationIds); } diff --git a/packages/junior/src/chat/conversations/sql/history.ts b/packages/junior/src/chat/conversations/sql/history.ts index 409ee7bf25..c7be02a869 100644 --- a/packages/junior/src/chat/conversations/sql/history.ts +++ b/packages/junior/src/chat/conversations/sql/history.ts @@ -35,7 +35,7 @@ import { import { juniorConversationEvents } from "@/db/schema"; import { sanitizePostgresJson } from "@/db/postgres-json"; import { encodeHistoryPayload } from "../history-payload"; -import { touchConversationDetail } from "./detail-version"; +import { conversationDetailTouch } from "./detail-version"; import { withConversationEventLock } from "./event-lock"; import { recordConversationParticipant } from "./participants"; @@ -242,8 +242,11 @@ class SqlConversationEventStore implements ConversationEventStore { ), ); } - await this.executor.db().insert(juniorConversationEvents).values(rows); - await touchConversationDetail(this.executor.db(), [conversationId]); + const db = this.executor.db(); + await db + .with(conversationDetailTouch(db, [conversationId])) + .insert(juniorConversationEvents) + .values(rows); for (const event of pending) { if (event.data.type === "turn_started") { await this.recordTurnInputParticipants( @@ -320,8 +323,9 @@ class SqlConversationEventStore implements ConversationEventStore { } await ensureConversationRow(this.executor, conversationId, Date.now()); const historyVersion = currentHistoryVersion + 1; - await this.executor - .db() + const db = this.executor.db(); + await db + .with(conversationDetailTouch(db, [conversationId])) .insert(juniorConversationEvents) .values( insertFromEvent( @@ -331,7 +335,6 @@ class SqlConversationEventStore implements ConversationEventStore { parsed, ), ); - await touchConversationDetail(this.executor.db(), [conversationId]); return { status: "replaced" as const, historyVersion }; }, ); diff --git a/packages/junior/src/chat/conversations/sql/participants.ts b/packages/junior/src/chat/conversations/sql/participants.ts index 23986bf6cb..539fdd31f0 100644 --- a/packages/junior/src/chat/conversations/sql/participants.ts +++ b/packages/junior/src/chat/conversations/sql/participants.ts @@ -5,7 +5,6 @@ import { juniorConversations, juniorIdentities, } from "@/db/schema"; -import { touchConversationDetail } from "./detail-version"; /** Resolve the root conversation id that owns personal-feed membership. */ export async function resolveRootConversationId( @@ -54,6 +53,10 @@ export async function recordConversationParticipant( args.conversationId, ); const at = new Date(args.atMs); + // Each caller bumps the Conversation in the same transaction: event appends + // carry the touch, and the root upsert sets `detailVersion`. The root needs + // no bump, because its detail version sums its subtree. + // oxlint-disable-next-line junior/touch-conversation-detail -- callers bump in the same transaction. await executor .db() .insert(juniorConversationParticipants) @@ -72,10 +75,6 @@ export async function recordConversationParticipant( ...(args.restoreArchive ? { archivedAt: null } : undefined), }, }); - await touchConversationDetail(executor.db(), [ - args.conversationId, - rootConversationId, - ]); } /** Find which of the supplied root conversations include the linked user. */ diff --git a/packages/junior/src/chat/plugins/annotations.ts b/packages/junior/src/chat/plugins/annotations.ts index 7651b08c56..b5cbe35d03 100644 --- a/packages/junior/src/chat/plugins/annotations.ts +++ b/packages/junior/src/chat/plugins/annotations.ts @@ -9,7 +9,7 @@ import { juniorConversationAnnotations, juniorConversations, } from "@/db/schema"; -import { touchConversationDetail } from "@/chat/conversations/sql/detail-version"; +import { conversationDetailTouch } from "@/chat/conversations/sql/detail-version"; function annotationFromRow(row: { annotation: unknown; @@ -38,6 +38,7 @@ export function createPluginAnnotations(args: { const annotation = conversationAnnotationInputSchema.parse(input); const now = new Date(); await args.db + .with(conversationDetailTouch(args.db, [args.conversationId])) .insert(juniorConversationAnnotations) .values({ annotation, @@ -57,10 +58,10 @@ export function createPluginAnnotations(args: { ], set: { annotation, updatedAt: now }, }); - await touchConversationDetail(args.db, [args.conversationId]); }, async remove(kind, key) { await args.db + .with(conversationDetailTouch(args.db, [args.conversationId])) .delete(juniorConversationAnnotations) .where( and( @@ -73,7 +74,6 @@ export function createPluginAnnotations(args: { eq(juniorConversationAnnotations.key, key), ), ); - await touchConversationDetail(args.db, [args.conversationId]); }, async list() { const rows = await args.db diff --git a/packages/junior/src/chat/slack/attachments.ts b/packages/junior/src/chat/slack/attachments.ts index b60b51c226..4f641dd2dd 100644 --- a/packages/junior/src/chat/slack/attachments.ts +++ b/packages/junior/src/chat/slack/attachments.ts @@ -3,7 +3,7 @@ import type { MessageAttachment } from "@/chat/attachments/input"; import { and, eq, inArray, isNull } from "drizzle-orm"; import type { JuniorSqlDatabase } from "@/db/db"; import { juniorAttachments } from "@/db/schema"; -import { touchConversationDetail } from "@/chat/conversations/sql/detail-version"; +import { conversationDetailTouch } from "@/chat/conversations/sql/detail-version"; /** Save the vision summary for a stored Slack attachment. */ export async function recordSlackAttachment(args: { @@ -12,8 +12,9 @@ export async function recordSlackAttachment(args: { db: JuniorSqlDatabase; visionSummary: string; }): Promise { - await args.db - .db() + const db = args.db.db(); + await db + .with(conversationDetailTouch(db, [args.conversationId])) .update(juniorAttachments) .set({ visionSummary: args.visionSummary }) .where( @@ -23,7 +24,6 @@ export async function recordSlackAttachment(args: { isNull(juniorAttachments.deleteRequestedAt), ), ); - await touchConversationDetail(args.db.db(), [args.conversationId]); } /** Read a saved vision summary for one live attachment. */ diff --git a/tools/oxlint/junior/rules/touch-conversation-detail.test.ts b/tools/oxlint/junior/rules/touch-conversation-detail.test.ts index 13ec1c7b46..4427323d88 100644 --- a/tools/oxlint/junior/rules/touch-conversation-detail.test.ts +++ b/tools/oxlint/junior/rules/touch-conversation-detail.test.ts @@ -10,6 +10,9 @@ const missingTouch = [{ messageId: "missingTouch" }]; new RuleTester().run("touch-conversation-detail", touchConversationDetailRule, { valid: [ + `async function append(db) { + await db.with(conversationDetailTouch(db, [id])).insert(juniorConversationEvents).values(rows); + }`, `async function append(db) { await db.insert(juniorConversationEvents).values(rows); await touchConversationDetail(db, [id]); @@ -32,6 +35,12 @@ new RuleTester().run("touch-conversation-detail", touchConversationDetailRule, { }`, ], invalid: [ + { + code: `async function append(db) { + await db.with(otherCte).insert(juniorConversationEvents).values(rows); + }`, + errors: missingTouch, + }, { code: `async function append(db) { await db.insert(juniorConversationEvents).values(rows); diff --git a/tools/oxlint/junior/rules/touch-conversation-detail.ts b/tools/oxlint/junior/rules/touch-conversation-detail.ts index 5d62f85cce..d99b9693b1 100644 --- a/tools/oxlint/junior/rules/touch-conversation-detail.ts +++ b/tools/oxlint/junior/rules/touch-conversation-detail.ts @@ -22,6 +22,7 @@ const detailTableNames = [ ]; const writeMethods = new Set(["delete", "insert", "update"]); const touchFunction = "touchConversationDetail"; +const touchCte = "conversationDetailTouch"; const versionKey = "detailVersion"; const rawWritePrefix = /\b(?:insert\s+into|update|delete\s+from)\s*"?$/i; const rawWriteNamed = new RegExp( @@ -134,6 +135,22 @@ function builderCalls( return calls; } +/** Report whether a write is built on `db.with(conversationDetailTouch(...))`. */ +function carriesTouchCte(call: ESTree.CallExpression): boolean { + const builder = + call.callee.type === "MemberExpression" ? call.callee.object : null; + return ( + builder?.type === "CallExpression" && + propertyName(builder.callee) === "with" && + builder.arguments.some( + (argument) => + argument.type === "CallExpression" && + argument.callee.type === "Identifier" && + argument.callee.name === touchCte, + ) + ); +} + /** * A `juniorConversations` write that advances `detailVersion` itself needs no * separate touch. A plain insert creates a row whose version starts at zero, @@ -168,11 +185,11 @@ export const touchConversationDetailRule = defineRule({ type: "problem", docs: { description: - "Require writes to tables that the Conversation detail report reads to call touchConversationDetail afterwards.", + "Require writes to tables that the Conversation detail report reads to advance the Conversation detail version.", }, messages: { missingTouch: - "This write changes the Conversation detail report. Call `touchConversationDetail` after it in the same function, or set `detailVersion` on a `juniorConversations` write. Otherwise open detail pages keep serving the old report.", + "This write changes the Conversation detail report. Build it on `db.with(conversationDetailTouch(db, ids))`, call `touchConversationDetail` after it in the same function, or set `detailVersion` on a `juniorConversations` write. Otherwise open detail pages keep serving the old report.", }, }, create(context) { @@ -200,6 +217,7 @@ export const touchConversationDetailRule = defineRule({ ) return; if (!detailTables.has(table.name)) return; + if (carriesTouchCte(node)) return; if ( table.name === "juniorConversations" && conversationWriteBumpsItself(context, method, node)