mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
7757cd8e08
Previously, `CreateChat` inserted the `chats` row with the DB default status (`waiting`), then updated it to `pending` in the same transaction via `setChatPendingWithStore`. This wasted two extra queries per chat creation (`GetChatByID` + `UpdateChatStatus`) and rewrote the same row immediately after inserting it. Now `CreateChat` passes the status directly to `InsertChat`, so the row is written once in its final create-time state. The `setChatPendingWithStore` helper is removed entirely. `InsertChat` now requires an explicit `status` parameter at all callsites instead of relying on a DB column default. ## Motivation On an experimental branch we're trialing firing all chatd notifications from plpgsql triggers. The old two-step insert made that awkward: in an `AFTER INSERT` trigger, `NEW` only contained the insert-time row (`waiting`), not the final committed state (`pending`). To emit the correct event payload the trigger had to be deferred and re-read the row from `chats` at commit time. With this change, `NEW` already contains the correct row to publish — no deferred trigger, no extra `SELECT`, simpler and cheaper trigger logic. That said, this seems like a worthwhile change regardless of the trigger experiment: writing the final row state once removes unnecessary DB work on every chat creation and makes the create path easier to reason about.
1177 lines
32 KiB
SQL
1177 lines
32 KiB
SQL
-- name: ArchiveChatByID :many
|
|
WITH chats AS (
|
|
UPDATE chats
|
|
SET archived = true, pin_order = 0, updated_at = NOW()
|
|
WHERE id = @id::uuid OR root_chat_id = @id::uuid
|
|
RETURNING *
|
|
)
|
|
SELECT *
|
|
FROM chats
|
|
ORDER BY (id = @id::uuid) DESC, created_at ASC, id ASC;
|
|
|
|
-- name: UnarchiveChatByID :many
|
|
WITH chats AS (
|
|
UPDATE chats
|
|
SET archived = false, updated_at = NOW()
|
|
WHERE id = @id::uuid OR root_chat_id = @id::uuid
|
|
RETURNING *
|
|
)
|
|
SELECT *
|
|
FROM chats
|
|
ORDER BY (id = @id::uuid) DESC, created_at ASC, id ASC;
|
|
|
|
-- name: PinChatByID :exec
|
|
WITH target_chat AS (
|
|
SELECT
|
|
id,
|
|
owner_id
|
|
FROM
|
|
chats
|
|
WHERE
|
|
id = @id::uuid
|
|
),
|
|
-- Under READ COMMITTED, concurrent pin operations for the same
|
|
-- owner may momentarily produce duplicate pin_order values because
|
|
-- each CTE snapshot does not see the other's writes. The next
|
|
-- pin/unpin/reorder operation's ROW_NUMBER() self-heals the
|
|
-- sequence, so this is acceptable.
|
|
ranked AS (
|
|
SELECT
|
|
c.id,
|
|
ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS next_pin_order
|
|
FROM
|
|
chats c
|
|
JOIN
|
|
target_chat ON c.owner_id = target_chat.owner_id
|
|
WHERE
|
|
c.pin_order > 0
|
|
AND c.archived = FALSE
|
|
AND c.id <> target_chat.id
|
|
),
|
|
updates AS (
|
|
SELECT
|
|
ranked.id,
|
|
ranked.next_pin_order AS pin_order
|
|
FROM
|
|
ranked
|
|
UNION ALL
|
|
SELECT
|
|
target_chat.id,
|
|
COALESCE((
|
|
SELECT
|
|
MAX(ranked.next_pin_order)
|
|
FROM
|
|
ranked
|
|
), 0) + 1 AS pin_order
|
|
FROM
|
|
target_chat
|
|
)
|
|
UPDATE
|
|
chats c
|
|
SET
|
|
pin_order = updates.pin_order
|
|
FROM
|
|
updates
|
|
WHERE
|
|
c.id = updates.id;
|
|
|
|
-- name: UnpinChatByID :exec
|
|
WITH target_chat AS (
|
|
SELECT
|
|
id,
|
|
owner_id
|
|
FROM
|
|
chats
|
|
WHERE
|
|
id = @id::uuid
|
|
),
|
|
ranked AS (
|
|
SELECT
|
|
c.id,
|
|
ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position
|
|
FROM
|
|
chats c
|
|
JOIN
|
|
target_chat ON c.owner_id = target_chat.owner_id
|
|
WHERE
|
|
c.pin_order > 0
|
|
AND c.archived = FALSE
|
|
),
|
|
target AS (
|
|
SELECT
|
|
ranked.id,
|
|
ranked.current_position
|
|
FROM
|
|
ranked
|
|
WHERE
|
|
ranked.id = @id::uuid
|
|
),
|
|
updates AS (
|
|
SELECT
|
|
ranked.id,
|
|
CASE
|
|
WHEN ranked.id = target.id THEN 0
|
|
WHEN ranked.current_position > target.current_position THEN ranked.current_position - 1
|
|
ELSE ranked.current_position
|
|
END AS pin_order
|
|
FROM
|
|
ranked
|
|
CROSS JOIN
|
|
target
|
|
)
|
|
UPDATE
|
|
chats c
|
|
SET
|
|
pin_order = updates.pin_order
|
|
FROM
|
|
updates
|
|
WHERE
|
|
c.id = updates.id;
|
|
|
|
-- name: UpdateChatPinOrder :exec
|
|
WITH target_chat AS (
|
|
SELECT
|
|
id,
|
|
owner_id
|
|
FROM
|
|
chats
|
|
WHERE
|
|
id = @id::uuid
|
|
),
|
|
ranked AS (
|
|
SELECT
|
|
c.id,
|
|
ROW_NUMBER() OVER (ORDER BY c.pin_order ASC, c.id ASC) :: integer AS current_position,
|
|
COUNT(*) OVER () :: integer AS pinned_count
|
|
FROM
|
|
chats c
|
|
JOIN
|
|
target_chat ON c.owner_id = target_chat.owner_id
|
|
WHERE
|
|
c.pin_order > 0
|
|
AND c.archived = FALSE
|
|
),
|
|
target AS (
|
|
SELECT
|
|
ranked.id,
|
|
ranked.current_position,
|
|
LEAST(GREATEST(@pin_order::integer, 1), ranked.pinned_count) AS desired_position
|
|
FROM
|
|
ranked
|
|
WHERE
|
|
ranked.id = @id::uuid
|
|
),
|
|
updates AS (
|
|
SELECT
|
|
ranked.id,
|
|
CASE
|
|
WHEN ranked.id = target.id THEN target.desired_position
|
|
WHEN target.desired_position < target.current_position
|
|
AND ranked.current_position >= target.desired_position
|
|
AND ranked.current_position < target.current_position THEN ranked.current_position + 1
|
|
WHEN target.desired_position > target.current_position
|
|
AND ranked.current_position > target.current_position
|
|
AND ranked.current_position <= target.desired_position THEN ranked.current_position - 1
|
|
ELSE ranked.current_position
|
|
END AS pin_order
|
|
FROM
|
|
ranked
|
|
CROSS JOIN
|
|
target
|
|
)
|
|
UPDATE
|
|
chats c
|
|
SET
|
|
pin_order = updates.pin_order
|
|
FROM
|
|
updates
|
|
WHERE
|
|
c.id = updates.id;
|
|
|
|
-- name: SoftDeleteChatMessagesAfterID :exec
|
|
UPDATE
|
|
chat_messages
|
|
SET
|
|
deleted = true
|
|
WHERE
|
|
chat_id = @chat_id::uuid
|
|
AND id > @after_id::bigint;
|
|
|
|
-- name: SoftDeleteChatMessageByID :exec
|
|
UPDATE
|
|
chat_messages
|
|
SET
|
|
deleted = true
|
|
WHERE
|
|
id = @id::bigint;
|
|
|
|
-- name: GetChatByID :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
chats
|
|
WHERE
|
|
id = @id::uuid;
|
|
|
|
-- name: GetChatMessageByID :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
chat_messages
|
|
WHERE
|
|
id = @id::bigint
|
|
AND deleted = false;
|
|
|
|
-- name: GetChatMessagesByChatID :many
|
|
SELECT
|
|
*
|
|
FROM
|
|
chat_messages
|
|
WHERE
|
|
chat_id = @chat_id::uuid
|
|
AND id > @after_id::bigint
|
|
AND visibility IN ('user', 'both')
|
|
AND deleted = false
|
|
ORDER BY
|
|
created_at ASC;
|
|
|
|
-- name: GetChatMessagesByChatIDAscPaginated :many
|
|
SELECT
|
|
*
|
|
FROM
|
|
chat_messages
|
|
WHERE
|
|
chat_id = @chat_id::uuid
|
|
AND id > @after_id::bigint
|
|
AND visibility IN ('user', 'both')
|
|
AND deleted = false
|
|
ORDER BY
|
|
id ASC
|
|
LIMIT
|
|
COALESCE(NULLIF(@limit_val::int, 0), 50);
|
|
|
|
-- name: GetChatMessagesByChatIDDescPaginated :many
|
|
SELECT
|
|
*
|
|
FROM
|
|
chat_messages
|
|
WHERE
|
|
chat_id = @chat_id::uuid
|
|
AND CASE
|
|
WHEN @before_id::bigint > 0 THEN id < @before_id::bigint
|
|
ELSE true
|
|
END
|
|
AND visibility IN ('user', 'both')
|
|
AND deleted = false
|
|
ORDER BY
|
|
id DESC
|
|
LIMIT
|
|
COALESCE(NULLIF(@limit_val::int, 0), 50);
|
|
|
|
-- name: GetChatMessagesForPromptByChatID :many
|
|
WITH latest_compressed_summary AS (
|
|
SELECT
|
|
id
|
|
FROM
|
|
chat_messages
|
|
WHERE
|
|
chat_id = @chat_id::uuid
|
|
AND compressed = TRUE
|
|
AND deleted = false
|
|
AND visibility = 'model'
|
|
ORDER BY
|
|
created_at DESC,
|
|
id DESC
|
|
LIMIT
|
|
1
|
|
)
|
|
SELECT
|
|
*
|
|
FROM
|
|
chat_messages
|
|
WHERE
|
|
chat_id = @chat_id::uuid
|
|
AND visibility IN ('model', 'both')
|
|
AND deleted = false
|
|
AND (
|
|
(
|
|
role = 'system'
|
|
AND compressed = FALSE
|
|
)
|
|
OR (
|
|
compressed = FALSE
|
|
AND (
|
|
NOT EXISTS (
|
|
SELECT
|
|
1
|
|
FROM
|
|
latest_compressed_summary
|
|
)
|
|
OR id > (
|
|
SELECT
|
|
id
|
|
FROM
|
|
latest_compressed_summary
|
|
)
|
|
)
|
|
)
|
|
OR id = (
|
|
SELECT
|
|
id
|
|
FROM
|
|
latest_compressed_summary
|
|
)
|
|
)
|
|
ORDER BY
|
|
created_at ASC,
|
|
id ASC;
|
|
|
|
-- name: GetChats :many
|
|
SELECT
|
|
sqlc.embed(chats),
|
|
EXISTS (
|
|
SELECT 1 FROM chat_messages cm
|
|
WHERE cm.chat_id = chats.id
|
|
AND cm.role = 'assistant'
|
|
AND cm.deleted = false
|
|
AND cm.id > COALESCE(chats.last_read_message_id, 0)
|
|
) AS has_unread
|
|
FROM
|
|
chats
|
|
WHERE
|
|
CASE
|
|
WHEN @owner_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN chats.owner_id = @owner_id
|
|
ELSE true
|
|
END
|
|
AND CASE
|
|
WHEN sqlc.narg('archived') :: boolean IS NULL THEN true
|
|
ELSE chats.archived = sqlc.narg('archived') :: boolean
|
|
END
|
|
AND CASE
|
|
-- This allows using the last element on a page as effectively a cursor.
|
|
-- This is an important option for scripts that need to paginate without
|
|
-- duplicating or missing data.
|
|
WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
|
|
-- The pagination cursor is the last ID of the previous page.
|
|
-- The query is ordered by the updated_at field, so select all
|
|
-- rows before the cursor.
|
|
(updated_at, id) < (
|
|
SELECT
|
|
updated_at, id
|
|
FROM
|
|
chats
|
|
WHERE
|
|
id = @after_id
|
|
)
|
|
)
|
|
ELSE true
|
|
END
|
|
AND CASE
|
|
WHEN sqlc.narg('label_filter')::jsonb IS NOT NULL THEN chats.labels @> sqlc.narg('label_filter')::jsonb
|
|
ELSE true
|
|
END
|
|
-- Authorize Filter clause will be injected below in GetAuthorizedChats
|
|
-- @authorize_filter
|
|
ORDER BY
|
|
-- Deterministic and consistent ordering of all rows, even if they share
|
|
-- a timestamp. This is to ensure consistent pagination.
|
|
(updated_at, id) DESC OFFSET @offset_opt
|
|
LIMIT
|
|
-- The chat list is unbounded and expected to grow large.
|
|
-- Default to 50 to prevent accidental excessively large queries.
|
|
COALESCE(NULLIF(@limit_opt :: int, 0), 50);
|
|
|
|
-- name: InsertChat :one
|
|
INSERT INTO chats (
|
|
owner_id,
|
|
workspace_id,
|
|
build_id,
|
|
agent_id,
|
|
parent_chat_id,
|
|
root_chat_id,
|
|
last_model_config_id,
|
|
title,
|
|
mode,
|
|
status,
|
|
mcp_server_ids,
|
|
labels
|
|
) VALUES (
|
|
@owner_id::uuid,
|
|
sqlc.narg('workspace_id')::uuid,
|
|
sqlc.narg('build_id')::uuid,
|
|
sqlc.narg('agent_id')::uuid,
|
|
sqlc.narg('parent_chat_id')::uuid,
|
|
sqlc.narg('root_chat_id')::uuid,
|
|
@last_model_config_id::uuid,
|
|
@title::text,
|
|
sqlc.narg('mode')::chat_mode,
|
|
@status::chat_status,
|
|
COALESCE(@mcp_server_ids::uuid[], '{}'::uuid[]),
|
|
COALESCE(sqlc.narg('labels')::jsonb, '{}'::jsonb)
|
|
)
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: InsertChatMessages :many
|
|
WITH updated_chat AS (
|
|
UPDATE
|
|
chats
|
|
SET
|
|
last_model_config_id = (
|
|
SELECT val
|
|
FROM UNNEST(@model_config_id::uuid[])
|
|
WITH ORDINALITY AS t(val, ord)
|
|
WHERE val != '00000000-0000-0000-0000-000000000000'::uuid
|
|
ORDER BY ord DESC
|
|
LIMIT 1
|
|
)
|
|
WHERE
|
|
id = @chat_id::uuid
|
|
AND EXISTS (
|
|
SELECT 1
|
|
FROM UNNEST(@model_config_id::uuid[])
|
|
WHERE unnest != '00000000-0000-0000-0000-000000000000'::uuid
|
|
)
|
|
AND chats.last_model_config_id IS DISTINCT FROM (
|
|
SELECT val
|
|
FROM UNNEST(@model_config_id::uuid[])
|
|
WITH ORDINALITY AS t(val, ord)
|
|
WHERE val != '00000000-0000-0000-0000-000000000000'::uuid
|
|
ORDER BY ord DESC
|
|
LIMIT 1
|
|
)
|
|
)
|
|
INSERT INTO chat_messages (
|
|
chat_id,
|
|
created_by,
|
|
model_config_id,
|
|
role,
|
|
content,
|
|
content_version,
|
|
visibility,
|
|
input_tokens,
|
|
output_tokens,
|
|
total_tokens,
|
|
reasoning_tokens,
|
|
cache_creation_tokens,
|
|
cache_read_tokens,
|
|
context_limit,
|
|
compressed,
|
|
total_cost_micros,
|
|
runtime_ms,
|
|
provider_response_id
|
|
)
|
|
SELECT
|
|
@chat_id::uuid,
|
|
NULLIF(UNNEST(@created_by::uuid[]), '00000000-0000-0000-0000-000000000000'::uuid),
|
|
NULLIF(UNNEST(@model_config_id::uuid[]), '00000000-0000-0000-0000-000000000000'::uuid),
|
|
UNNEST(@role::chat_message_role[]),
|
|
UNNEST(@content::text[])::jsonb,
|
|
UNNEST(@content_version::smallint[]),
|
|
UNNEST(@visibility::chat_message_visibility[]),
|
|
NULLIF(UNNEST(@input_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@output_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@total_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@reasoning_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@cache_creation_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@cache_read_tokens::bigint[]), 0),
|
|
NULLIF(UNNEST(@context_limit::bigint[]), 0),
|
|
UNNEST(@compressed::boolean[]),
|
|
NULLIF(UNNEST(@total_cost_micros::bigint[]), 0),
|
|
NULLIF(UNNEST(@runtime_ms::bigint[]), 0),
|
|
NULLIF(UNNEST(@provider_response_id::text[]), '')
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatMessageByID :one
|
|
UPDATE
|
|
chat_messages
|
|
SET
|
|
model_config_id = COALESCE(sqlc.narg('model_config_id')::uuid, model_config_id),
|
|
content = sqlc.narg('content')::jsonb
|
|
WHERE
|
|
id = @id::bigint
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatByID :one
|
|
UPDATE
|
|
chats
|
|
SET
|
|
title = @title::text,
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatLastModelConfigByID :one
|
|
UPDATE
|
|
chats
|
|
SET
|
|
-- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
|
|
last_model_config_id = @last_model_config_id::uuid
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatLabelsByID :one
|
|
UPDATE
|
|
chats
|
|
SET
|
|
labels = @labels::jsonb,
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatWorkspaceBinding :one
|
|
UPDATE chats SET
|
|
workspace_id = sqlc.narg('workspace_id')::uuid,
|
|
build_id = sqlc.narg('build_id')::uuid,
|
|
agent_id = sqlc.narg('agent_id')::uuid,
|
|
updated_at = NOW()
|
|
WHERE id = @id::uuid
|
|
RETURNING *;
|
|
|
|
-- name: UpdateChatBuildAgentBinding :one
|
|
UPDATE chats SET
|
|
build_id = sqlc.narg('build_id')::uuid,
|
|
agent_id = sqlc.narg('agent_id')::uuid,
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING *;
|
|
|
|
-- name: UpdateChatLastInjectedContext :one
|
|
-- Updates the cached injected context parts (AGENTS.md +
|
|
-- skills) on the chat row. Called only when context changes
|
|
-- (first workspace attach or agent change). updated_at is
|
|
-- intentionally not touched to avoid reordering the chat list.
|
|
UPDATE chats SET
|
|
last_injected_context = sqlc.narg('last_injected_context')::jsonb
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING *;
|
|
|
|
-- name: UpdateChatMCPServerIDs :one
|
|
UPDATE
|
|
chats
|
|
SET
|
|
mcp_server_ids = @mcp_server_ids::uuid[],
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: AcquireChats :many
|
|
-- Acquires up to @num_chats pending chats for processing. Uses SKIP LOCKED
|
|
-- to prevent multiple replicas from acquiring the same chat.
|
|
UPDATE
|
|
chats
|
|
SET
|
|
status = 'running'::chat_status,
|
|
started_at = @started_at::timestamptz,
|
|
heartbeat_at = @started_at::timestamptz,
|
|
updated_at = @started_at::timestamptz,
|
|
worker_id = @worker_id::uuid
|
|
WHERE
|
|
id = ANY(
|
|
SELECT
|
|
id
|
|
FROM
|
|
chats
|
|
WHERE
|
|
status = 'pending'::chat_status
|
|
ORDER BY
|
|
updated_at ASC
|
|
FOR UPDATE
|
|
SKIP LOCKED
|
|
LIMIT
|
|
@num_chats::int
|
|
)
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatStatus :one
|
|
UPDATE
|
|
chats
|
|
SET
|
|
status = @status::chat_status,
|
|
worker_id = sqlc.narg('worker_id')::uuid,
|
|
started_at = sqlc.narg('started_at')::timestamptz,
|
|
heartbeat_at = sqlc.narg('heartbeat_at')::timestamptz,
|
|
last_error = sqlc.narg('last_error')::text,
|
|
updated_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpdateChatStatusPreserveUpdatedAt :one
|
|
UPDATE
|
|
chats
|
|
SET
|
|
status = @status::chat_status,
|
|
worker_id = sqlc.narg('worker_id')::uuid,
|
|
started_at = sqlc.narg('started_at')::timestamptz,
|
|
heartbeat_at = sqlc.narg('heartbeat_at')::timestamptz,
|
|
last_error = sqlc.narg('last_error')::text,
|
|
updated_at = @updated_at::timestamptz
|
|
WHERE
|
|
id = @id::uuid
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: GetStaleChats :many
|
|
-- Find chats that appear stuck (running but heartbeat has expired).
|
|
-- Used for recovery after coderd crashes or long hangs.
|
|
SELECT
|
|
*
|
|
FROM
|
|
chats
|
|
WHERE
|
|
status = 'running'::chat_status
|
|
AND heartbeat_at < @stale_threshold::timestamptz;
|
|
|
|
-- name: UpdateChatHeartbeat :execrows
|
|
-- Bumps the heartbeat timestamp for a running chat so that other
|
|
-- replicas know the worker is still alive.
|
|
UPDATE
|
|
chats
|
|
SET
|
|
heartbeat_at = NOW()
|
|
WHERE
|
|
id = @id::uuid
|
|
AND worker_id = @worker_id::uuid
|
|
AND status = 'running'::chat_status;
|
|
|
|
-- name: GetChatDiffStatusByChatID :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
chat_diff_statuses
|
|
WHERE
|
|
chat_id = @chat_id::uuid;
|
|
|
|
-- name: GetChatDiffStatusesByChatIDs :many
|
|
SELECT
|
|
*
|
|
FROM
|
|
chat_diff_statuses
|
|
WHERE
|
|
chat_id = ANY(@chat_ids::uuid[]);
|
|
|
|
-- name: UpsertChatDiffStatusReference :one
|
|
INSERT INTO chat_diff_statuses (
|
|
chat_id,
|
|
url,
|
|
git_branch,
|
|
git_remote_origin,
|
|
stale_at
|
|
) VALUES (
|
|
@chat_id::uuid,
|
|
sqlc.narg('url')::text,
|
|
@git_branch::text,
|
|
@git_remote_origin::text,
|
|
@stale_at::timestamptz
|
|
)
|
|
ON CONFLICT (chat_id) DO UPDATE
|
|
SET
|
|
url = CASE
|
|
WHEN EXCLUDED.url IS NOT NULL THEN EXCLUDED.url
|
|
ELSE chat_diff_statuses.url
|
|
END,
|
|
git_branch = CASE
|
|
WHEN EXCLUDED.git_branch != '' THEN EXCLUDED.git_branch
|
|
ELSE chat_diff_statuses.git_branch
|
|
END,
|
|
git_remote_origin = CASE
|
|
WHEN EXCLUDED.git_remote_origin != '' THEN EXCLUDED.git_remote_origin
|
|
ELSE chat_diff_statuses.git_remote_origin
|
|
END,
|
|
stale_at = EXCLUDED.stale_at,
|
|
updated_at = NOW()
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: UpsertChatDiffStatus :one
|
|
INSERT INTO chat_diff_statuses (
|
|
chat_id,
|
|
url,
|
|
pull_request_state,
|
|
pull_request_title,
|
|
pull_request_draft,
|
|
changes_requested,
|
|
additions,
|
|
deletions,
|
|
changed_files,
|
|
author_login,
|
|
author_avatar_url,
|
|
base_branch,
|
|
head_branch,
|
|
pr_number,
|
|
commits,
|
|
approved,
|
|
reviewer_count,
|
|
refreshed_at,
|
|
stale_at
|
|
) VALUES (
|
|
@chat_id::uuid,
|
|
sqlc.narg('url')::text,
|
|
sqlc.narg('pull_request_state')::text,
|
|
@pull_request_title::text,
|
|
@pull_request_draft::boolean,
|
|
@changes_requested::boolean,
|
|
@additions::integer,
|
|
@deletions::integer,
|
|
@changed_files::integer,
|
|
sqlc.narg('author_login')::text,
|
|
sqlc.narg('author_avatar_url')::text,
|
|
sqlc.narg('base_branch')::text,
|
|
sqlc.narg('head_branch')::text,
|
|
sqlc.narg('pr_number')::integer,
|
|
sqlc.narg('commits')::integer,
|
|
sqlc.narg('approved')::boolean,
|
|
sqlc.narg('reviewer_count')::integer,
|
|
@refreshed_at::timestamptz,
|
|
@stale_at::timestamptz
|
|
)
|
|
ON CONFLICT (chat_id) DO UPDATE
|
|
SET
|
|
url = EXCLUDED.url,
|
|
pull_request_state = EXCLUDED.pull_request_state,
|
|
pull_request_title = EXCLUDED.pull_request_title,
|
|
pull_request_draft = EXCLUDED.pull_request_draft,
|
|
changes_requested = EXCLUDED.changes_requested,
|
|
additions = EXCLUDED.additions,
|
|
deletions = EXCLUDED.deletions,
|
|
changed_files = EXCLUDED.changed_files,
|
|
author_login = EXCLUDED.author_login,
|
|
author_avatar_url = EXCLUDED.author_avatar_url,
|
|
base_branch = EXCLUDED.base_branch,
|
|
head_branch = EXCLUDED.head_branch,
|
|
pr_number = EXCLUDED.pr_number,
|
|
commits = EXCLUDED.commits,
|
|
approved = EXCLUDED.approved,
|
|
reviewer_count = EXCLUDED.reviewer_count,
|
|
refreshed_at = EXCLUDED.refreshed_at,
|
|
stale_at = EXCLUDED.stale_at,
|
|
updated_at = NOW()
|
|
RETURNING
|
|
*;
|
|
|
|
-- name: InsertChatQueuedMessage :one
|
|
INSERT INTO chat_queued_messages (chat_id, content)
|
|
VALUES (@chat_id, @content)
|
|
RETURNING *;
|
|
|
|
-- name: GetChatQueuedMessages :many
|
|
SELECT * FROM chat_queued_messages
|
|
WHERE chat_id = @chat_id
|
|
ORDER BY id ASC;
|
|
|
|
-- name: DeleteChatQueuedMessage :exec
|
|
DELETE FROM chat_queued_messages WHERE id = @id AND chat_id = @chat_id;
|
|
|
|
-- name: DeleteAllChatQueuedMessages :exec
|
|
DELETE FROM chat_queued_messages WHERE chat_id = @chat_id;
|
|
|
|
-- name: PopNextQueuedMessage :one
|
|
DELETE FROM chat_queued_messages
|
|
WHERE id = (
|
|
SELECT cqm.id FROM chat_queued_messages cqm
|
|
WHERE cqm.chat_id = @chat_id
|
|
ORDER BY cqm.id ASC
|
|
LIMIT 1
|
|
)
|
|
RETURNING *;
|
|
|
|
-- name: GetLastChatMessageByRole :one
|
|
SELECT
|
|
*
|
|
FROM
|
|
chat_messages
|
|
WHERE
|
|
chat_id = @chat_id::uuid
|
|
AND role = @role::chat_message_role
|
|
AND deleted = false
|
|
ORDER BY
|
|
created_at DESC, id DESC
|
|
LIMIT
|
|
1;
|
|
|
|
-- name: GetChatByIDForUpdate :one
|
|
SELECT * FROM chats WHERE id = @id::uuid FOR UPDATE;
|
|
|
|
-- name: AcquireStaleChatDiffStatuses :many
|
|
WITH acquired AS (
|
|
UPDATE
|
|
chat_diff_statuses
|
|
SET
|
|
-- Claim for 5 minutes. The worker sets the real stale_at
|
|
-- after refresh. If the worker crashes, rows become eligible
|
|
-- again after this interval.
|
|
-- NOTE: updated_at is intentionally NOT touched here so
|
|
-- the worker can read it as "when was this row last
|
|
-- externally changed" (by MarkStale or a successful
|
|
-- refresh).
|
|
stale_at = NOW() + INTERVAL '5 minutes'
|
|
WHERE
|
|
chat_id IN (
|
|
SELECT
|
|
cds.chat_id
|
|
FROM
|
|
chat_diff_statuses cds
|
|
INNER JOIN
|
|
chats c ON c.id = cds.chat_id
|
|
WHERE
|
|
cds.stale_at <= NOW()
|
|
AND cds.git_remote_origin != ''
|
|
AND cds.git_branch != ''
|
|
AND c.archived = FALSE
|
|
ORDER BY
|
|
cds.stale_at ASC
|
|
FOR UPDATE OF cds
|
|
SKIP LOCKED
|
|
LIMIT
|
|
@limit_val::int
|
|
)
|
|
RETURNING *
|
|
)
|
|
SELECT
|
|
acquired.*,
|
|
c.owner_id
|
|
FROM
|
|
acquired
|
|
INNER JOIN
|
|
chats c ON c.id = acquired.chat_id;
|
|
|
|
-- name: BackoffChatDiffStatus :exec
|
|
UPDATE
|
|
chat_diff_statuses
|
|
SET
|
|
-- NOTE: updated_at is intentionally NOT touched here so
|
|
-- the worker can read it as "when was this row last
|
|
-- externally changed" (by MarkStale or a successful
|
|
-- refresh).
|
|
stale_at = @stale_at::timestamptz
|
|
WHERE
|
|
chat_id = @chat_id::uuid;
|
|
|
|
-- name: GetChatCostSummary :one
|
|
-- Aggregate cost summary for a single user within a date range.
|
|
-- Only counts assistant-role messages.
|
|
SELECT
|
|
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
|
|
COUNT(*) FILTER (
|
|
WHERE cm.total_cost_micros IS NOT NULL
|
|
)::bigint AS priced_message_count,
|
|
COUNT(*) FILTER (
|
|
WHERE cm.total_cost_micros IS NULL
|
|
AND (
|
|
cm.input_tokens IS NOT NULL
|
|
OR cm.output_tokens IS NOT NULL
|
|
OR cm.reasoning_tokens IS NOT NULL
|
|
OR cm.cache_creation_tokens IS NOT NULL
|
|
OR cm.cache_read_tokens IS NOT NULL
|
|
)
|
|
)::bigint AS unpriced_message_count,
|
|
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
|
|
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
|
|
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
|
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens
|
|
FROM
|
|
chat_messages cm
|
|
JOIN
|
|
chats c ON c.id = cm.chat_id
|
|
WHERE
|
|
c.owner_id = @owner_id::uuid
|
|
AND cm.role = 'assistant'
|
|
AND cm.created_at >= @start_date::timestamptz
|
|
AND cm.created_at < @end_date::timestamptz;
|
|
|
|
-- name: GetChatCostPerModel :many
|
|
-- Per-model cost breakdown for a single user within a date range.
|
|
-- Only counts assistant-role messages that have a model_config_id.
|
|
SELECT
|
|
cmc.id AS model_config_id,
|
|
cmc.display_name,
|
|
cmc.provider,
|
|
cmc.model,
|
|
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
|
|
COUNT(*) FILTER (
|
|
WHERE cm.input_tokens IS NOT NULL
|
|
OR cm.output_tokens IS NOT NULL
|
|
OR cm.reasoning_tokens IS NOT NULL
|
|
OR cm.cache_creation_tokens IS NOT NULL
|
|
OR cm.cache_read_tokens IS NOT NULL
|
|
)::bigint AS message_count,
|
|
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
|
|
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
|
|
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
|
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens
|
|
FROM
|
|
chat_messages cm
|
|
JOIN
|
|
chats c ON c.id = cm.chat_id
|
|
JOIN
|
|
chat_model_configs cmc ON cmc.id = cm.model_config_id
|
|
WHERE
|
|
c.owner_id = @owner_id::uuid
|
|
AND cm.role = 'assistant'
|
|
AND cm.created_at >= @start_date::timestamptz
|
|
AND cm.created_at < @end_date::timestamptz
|
|
GROUP BY
|
|
cmc.id, cmc.display_name, cmc.provider, cmc.model
|
|
ORDER BY
|
|
total_cost_micros DESC;
|
|
|
|
-- name: GetChatCostPerChat :many
|
|
-- Per-root-chat cost breakdown for a single user within a date range.
|
|
-- Groups by root_chat_id so forked chats roll up under their root.
|
|
-- Only counts assistant-role messages.
|
|
WITH chat_costs AS (
|
|
SELECT
|
|
COALESCE(c.root_chat_id, c.id) AS root_chat_id,
|
|
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
|
|
COUNT(*) FILTER (
|
|
WHERE cm.input_tokens IS NOT NULL
|
|
OR cm.output_tokens IS NOT NULL
|
|
OR cm.reasoning_tokens IS NOT NULL
|
|
OR cm.cache_creation_tokens IS NOT NULL
|
|
OR cm.cache_read_tokens IS NOT NULL
|
|
)::bigint AS message_count,
|
|
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
|
|
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
|
|
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
|
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens
|
|
FROM chat_messages cm
|
|
JOIN chats c ON c.id = cm.chat_id
|
|
WHERE c.owner_id = @owner_id::uuid
|
|
AND cm.role = 'assistant'
|
|
AND cm.created_at >= @start_date::timestamptz
|
|
AND cm.created_at < @end_date::timestamptz
|
|
GROUP BY COALESCE(c.root_chat_id, c.id)
|
|
)
|
|
SELECT
|
|
cc.root_chat_id,
|
|
COALESCE(rc.title, '') AS chat_title,
|
|
cc.total_cost_micros,
|
|
cc.message_count,
|
|
cc.total_input_tokens,
|
|
cc.total_output_tokens,
|
|
cc.total_cache_read_tokens,
|
|
cc.total_cache_creation_tokens
|
|
FROM chat_costs cc
|
|
LEFT JOIN chats rc ON rc.id = cc.root_chat_id
|
|
ORDER BY cc.total_cost_micros DESC;
|
|
|
|
-- name: GetChatCostPerUser :many
|
|
-- Deployment-wide per-user cost rollup within a date range.
|
|
-- Only counts assistant-role messages.
|
|
WITH chat_cost_users AS (
|
|
SELECT
|
|
c.owner_id AS user_id,
|
|
u.username,
|
|
u.name,
|
|
u.avatar_url,
|
|
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
|
|
COUNT(*) FILTER (
|
|
WHERE cm.input_tokens IS NOT NULL
|
|
OR cm.output_tokens IS NOT NULL
|
|
OR cm.reasoning_tokens IS NOT NULL
|
|
OR cm.cache_creation_tokens IS NOT NULL
|
|
OR cm.cache_read_tokens IS NOT NULL
|
|
)::bigint AS message_count,
|
|
COUNT(DISTINCT COALESCE(c.root_chat_id, c.id))::bigint AS chat_count,
|
|
COALESCE(SUM(cm.input_tokens), 0)::bigint AS total_input_tokens,
|
|
COALESCE(SUM(cm.output_tokens), 0)::bigint AS total_output_tokens,
|
|
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
|
|
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens
|
|
FROM
|
|
chat_messages cm
|
|
JOIN
|
|
chats c ON c.id = cm.chat_id
|
|
JOIN
|
|
users u ON u.id = c.owner_id
|
|
WHERE
|
|
cm.role = 'assistant'
|
|
AND cm.created_at >= @start_date::timestamptz
|
|
AND cm.created_at < @end_date::timestamptz
|
|
AND (
|
|
@username::text = ''
|
|
OR u.username ILIKE '%' || @username::text || '%'
|
|
OR u.name ILIKE '%' || @username::text || '%'
|
|
)
|
|
GROUP BY
|
|
c.owner_id,
|
|
u.username,
|
|
u.name,
|
|
u.avatar_url
|
|
)
|
|
SELECT
|
|
user_id,
|
|
username,
|
|
name,
|
|
avatar_url,
|
|
total_cost_micros,
|
|
message_count,
|
|
chat_count,
|
|
total_input_tokens,
|
|
total_output_tokens,
|
|
total_cache_read_tokens,
|
|
total_cache_creation_tokens,
|
|
COUNT(*) OVER()::bigint AS total_count
|
|
FROM
|
|
chat_cost_users
|
|
ORDER BY
|
|
total_cost_micros DESC,
|
|
username ASC
|
|
LIMIT
|
|
sqlc.arg('page_limit')::int
|
|
OFFSET
|
|
sqlc.arg('page_offset')::int;
|
|
|
|
-- name: GetChatUsageLimitConfig :one
|
|
SELECT * FROM chat_usage_limit_config WHERE singleton = TRUE LIMIT 1;
|
|
|
|
-- name: UpsertChatUsageLimitConfig :one
|
|
INSERT INTO chat_usage_limit_config (singleton, enabled, default_limit_micros, period, updated_at)
|
|
VALUES (TRUE, @enabled::boolean, @default_limit_micros::bigint, @period::text, NOW())
|
|
ON CONFLICT (singleton) DO UPDATE SET
|
|
enabled = EXCLUDED.enabled,
|
|
default_limit_micros = EXCLUDED.default_limit_micros,
|
|
period = EXCLUDED.period,
|
|
updated_at = NOW()
|
|
RETURNING *;
|
|
|
|
-- name: ListChatUsageLimitOverrides :many
|
|
SELECT u.id AS user_id, u.username, u.name, u.avatar_url,
|
|
u.chat_spend_limit_micros AS spend_limit_micros
|
|
FROM users u
|
|
WHERE u.chat_spend_limit_micros IS NOT NULL
|
|
ORDER BY u.username ASC;
|
|
|
|
-- name: UpsertChatUsageLimitUserOverride :one
|
|
UPDATE users
|
|
SET chat_spend_limit_micros = @spend_limit_micros::bigint
|
|
WHERE id = @user_id::uuid
|
|
RETURNING id AS user_id, username, name, avatar_url, chat_spend_limit_micros AS spend_limit_micros;
|
|
|
|
-- name: DeleteChatUsageLimitUserOverride :exec
|
|
UPDATE users SET chat_spend_limit_micros = NULL WHERE id = @user_id::uuid;
|
|
|
|
-- name: GetChatUsageLimitUserOverride :one
|
|
SELECT id AS user_id, chat_spend_limit_micros AS spend_limit_micros
|
|
FROM users
|
|
WHERE id = @user_id::uuid AND chat_spend_limit_micros IS NOT NULL;
|
|
|
|
-- name: GetUserChatSpendInPeriod :one
|
|
SELECT COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_spend_micros
|
|
FROM chat_messages cm
|
|
JOIN chats c ON c.id = cm.chat_id
|
|
WHERE c.owner_id = @user_id::uuid
|
|
AND cm.created_at >= @start_time::timestamptz
|
|
AND cm.created_at < @end_time::timestamptz
|
|
AND cm.total_cost_micros IS NOT NULL;
|
|
|
|
-- name: CountEnabledModelsWithoutPricing :one
|
|
-- Counts enabled, non-deleted model configs that lack both input and
|
|
-- output pricing in their JSONB options.cost configuration.
|
|
SELECT COUNT(*)::bigint AS count
|
|
FROM chat_model_configs
|
|
WHERE enabled = TRUE
|
|
AND deleted = FALSE
|
|
AND (
|
|
options->'cost' IS NULL
|
|
OR options->'cost' = 'null'::jsonb
|
|
OR (
|
|
(options->'cost'->>'input_price_per_million_tokens' IS NULL)
|
|
AND (options->'cost'->>'output_price_per_million_tokens' IS NULL)
|
|
)
|
|
);
|
|
|
|
-- name: ListChatUsageLimitGroupOverrides :many
|
|
SELECT
|
|
g.id AS group_id,
|
|
g.name AS group_name,
|
|
g.display_name AS group_display_name,
|
|
g.avatar_url AS group_avatar_url,
|
|
g.chat_spend_limit_micros AS spend_limit_micros,
|
|
(SELECT COUNT(*)
|
|
FROM group_members_expanded gme
|
|
WHERE gme.group_id = g.id
|
|
AND gme.user_is_system = FALSE) AS member_count
|
|
FROM groups g
|
|
WHERE g.chat_spend_limit_micros IS NOT NULL
|
|
ORDER BY g.name ASC;
|
|
|
|
-- name: UpsertChatUsageLimitGroupOverride :one
|
|
UPDATE groups
|
|
SET chat_spend_limit_micros = @spend_limit_micros::bigint
|
|
WHERE id = @group_id::uuid
|
|
RETURNING id AS group_id, name, display_name, avatar_url, chat_spend_limit_micros AS spend_limit_micros;
|
|
|
|
-- name: DeleteChatUsageLimitGroupOverride :exec
|
|
UPDATE groups SET chat_spend_limit_micros = NULL WHERE id = @group_id::uuid;
|
|
|
|
-- name: GetChatUsageLimitGroupOverride :one
|
|
SELECT id AS group_id, chat_spend_limit_micros AS spend_limit_micros
|
|
FROM groups
|
|
WHERE id = @group_id::uuid AND chat_spend_limit_micros IS NOT NULL;
|
|
|
|
-- name: GetUserGroupSpendLimit :one
|
|
-- Returns the minimum (most restrictive) group limit for a user.
|
|
-- Returns -1 if the user has no group limits applied.
|
|
SELECT COALESCE(MIN(g.chat_spend_limit_micros), -1)::bigint AS limit_micros
|
|
FROM groups g
|
|
JOIN group_members_expanded gme ON gme.group_id = g.id
|
|
WHERE gme.user_id = @user_id::uuid
|
|
AND g.chat_spend_limit_micros IS NOT NULL;
|
|
|
|
-- name: GetChatsByWorkspaceIDs :many
|
|
SELECT *
|
|
FROM chats
|
|
WHERE archived = false
|
|
AND workspace_id = ANY(@ids::uuid[])
|
|
ORDER BY workspace_id, updated_at DESC;
|
|
|
|
-- name: ResolveUserChatSpendLimit :one
|
|
-- Resolves the effective spend limit for a user using the hierarchy:
|
|
-- 1. Individual user override (highest priority)
|
|
-- 2. Minimum group limit across all user's groups
|
|
-- 3. Global default from config
|
|
-- Returns -1 if limits are not enabled.
|
|
SELECT CASE
|
|
-- If limits are disabled, return -1.
|
|
WHEN NOT cfg.enabled THEN -1
|
|
-- Individual override takes priority.
|
|
WHEN u.chat_spend_limit_micros IS NOT NULL THEN u.chat_spend_limit_micros
|
|
-- Group limit (minimum across all user's groups) is next.
|
|
WHEN gl.limit_micros IS NOT NULL THEN gl.limit_micros
|
|
-- Fall back to global default.
|
|
ELSE cfg.default_limit_micros
|
|
END::bigint AS effective_limit_micros
|
|
FROM chat_usage_limit_config cfg
|
|
CROSS JOIN users u
|
|
LEFT JOIN LATERAL (
|
|
SELECT MIN(g.chat_spend_limit_micros) AS limit_micros
|
|
FROM groups g
|
|
JOIN group_members_expanded gme ON gme.group_id = g.id
|
|
WHERE gme.user_id = @user_id::uuid
|
|
AND g.chat_spend_limit_micros IS NOT NULL
|
|
) gl ON TRUE
|
|
WHERE u.id = @user_id::uuid
|
|
LIMIT 1;
|
|
|
|
-- name: UpdateChatLastReadMessageID :exec
|
|
-- Updates the last read message ID for a chat. This is used to track
|
|
-- which messages the owner has seen, enabling unread indicators.
|
|
UPDATE chats
|
|
SET last_read_message_id = @last_read_message_id::bigint
|
|
WHERE id = @id::uuid;
|