Files
coder/coderd/database/queries/chats.sql
T
Ethan 4751416b29 fix!: persist structured chat errors (#24919)
**Breaking change for changelog:**

> `codersdk.Chat.last_error` now returns a structured `ChatError` object
(`{message, kind, provider, retryable, status_code, detail}`) instead of
a plain string. The chats API is experimental
(`/api/experimental/chats`), so this ships without a deprecation cycle;
consumers reading `chat.last_error` as a string must update to read
`chat.last_error.message`. SDK/generated TypeScript terminal error
payloads now use the single `ChatError` type; the live stream error
payload type is renamed from `ChatStreamError` to `ChatError`.

Persisted chat errors now carry the same provider-specific detail (kind,
provider, retryable, HTTP status, optional detail) as the live stream,
so refreshing a failed chat rehydrates with the full structured error
instead of a one-line headline.

Existing rows are migrated in place: legacy text errors are wrapped into
`{message, kind: "generic"}` so already-errored chats still render, and
rows with `last_error IS NULL` stay NULL. Internally, persisted fallback
decoding now reuses the existing `chaterror.KindGeneric` constant, with
no JSON value change.

Closes CODAGT-239
2026-05-05 12:56:06 +10:00

1492 lines
44 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
-- Unarchives a chat (and its children). Stale file references are
-- handled automatically by FK cascades on chat_file_links: when
-- dbpurge deletes a chat_files row, the corresponding
-- chat_file_links rows are cascade-deleted by PostgreSQL.
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 CASE
WHEN @after_id::bigint > 0 THEN id > @after_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
-- Cursor pagination: the last element on a page acts as the cursor.
-- The 4-tuple matches the ORDER BY below. All columns sort DESC
-- (pin_order is negated so lower values sort first in DESC order),
-- which lets us use a single tuple < comparison.
WHEN @after_id :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN (
(CASE WHEN pin_order > 0 THEN 1 ELSE 0 END, -pin_order, updated_at, id) < (
SELECT
CASE WHEN c2.pin_order > 0 THEN 1 ELSE 0 END, -c2.pin_order, c2.updated_at, c2.id
FROM
chats c2
WHERE
c2.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
-- Paginate over root chats only. Children are fetched
-- separately via GetChildChatsByParentIDs and embedded under
-- each parent. Other callers that need the full set should
-- use a narrower query (e.g. GetChatsByWorkspaceIDs).
AND chats.parent_chat_id IS NULL
-- Authorize Filter clause will be injected below in GetAuthorizedChats
-- @authorize_filter
ORDER BY
-- Pinned chats (pin_order > 0) sort before unpinned ones. Within
-- pinned chats, lower pin_order values come first. The negation
-- trick (-pin_order) keeps all sort columns DESC so the cursor
-- tuple < comparison works with uniform direction.
CASE WHEN pin_order > 0 THEN 1 ELSE 0 END DESC,
-pin_order DESC,
updated_at DESC,
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: GetChildChatsByParentIDs :many
-- Fetches child chats of the given parents, optionally filtered by
-- archive state (NULL = all, true/false = match). The archive
-- invariant (parent archived implies child archived) is enforced
-- at write time, not here.
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
chats.parent_chat_id = ANY(@parent_ids :: uuid[])
AND CASE
WHEN sqlc.narg('archived') :: boolean IS NULL THEN true
ELSE chats.archived = sqlc.narg('archived') :: boolean
END
ORDER BY
chats.created_at DESC,
chats.id DESC;
-- name: InsertChat :one
INSERT INTO chats (
organization_id,
owner_id,
workspace_id,
build_id,
agent_id,
parent_chat_id,
root_chat_id,
last_model_config_id,
title,
mode,
plan_mode,
status,
mcp_server_ids,
labels,
dynamic_tools,
client_type
) VALUES (
@organization_id::uuid,
@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,
sqlc.narg('plan_mode')::chat_plan_mode,
@status::chat_status,
COALESCE(@mcp_server_ids::uuid[], '{}'::uuid[]),
COALESCE(sqlc.narg('labels')::jsonb, '{}'::jsonb),
sqlc.narg('dynamic_tools')::jsonb,
@client_type::chat_client_type
)
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: UpdateChatTitleByID :one
UPDATE
chats
SET
-- NOTE: updated_at is intentionally NOT touched here to avoid
-- changing list ordering when a user renames an older chat
-- out-of-band.
title = @title::text
WHERE
id = @id::uuid
RETURNING
*;
-- name: UpdateChatPlanModeByID :one
UPDATE
chats
SET
-- NOTE: updated_at is intentionally NOT touched here to avoid changing list ordering.
plan_mode = sqlc.narg('plan_mode')::chat_plan_mode
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: LinkChatFiles :one
-- LinkChatFiles inserts file associations into the chat_file_links
-- join table with deduplication (ON CONFLICT DO NOTHING). The INSERT
-- is conditional: it only proceeds when the total number of links
-- (existing + genuinely new) does not exceed max_file_links. Returns
-- the number of genuinely new file IDs that were NOT inserted due to
-- the cap. A return value of 0 means all files were linked (or were
-- already linked). A positive value means the cap blocked that many
-- new links.
WITH current AS (
SELECT COUNT(*) AS cnt
FROM chat_file_links
WHERE chat_id = @chat_id::uuid
),
new_links AS (
SELECT @chat_id::uuid AS chat_id, unnest(@file_ids::uuid[]) AS file_id
),
genuinely_new AS (
SELECT nl.chat_id, nl.file_id
FROM new_links nl
WHERE NOT EXISTS (
SELECT 1 FROM chat_file_links cfl
WHERE cfl.chat_id = nl.chat_id AND cfl.file_id = nl.file_id
)
),
inserted AS (
INSERT INTO chat_file_links (chat_id, file_id)
SELECT gn.chat_id, gn.file_id
FROM genuinely_new gn, current c
WHERE c.cnt + (SELECT COUNT(*) FROM genuinely_new) <= @max_file_links::int
ON CONFLICT (chat_id, file_id) DO NOTHING
RETURNING file_id
)
SELECT
(SELECT COUNT(*)::int FROM genuinely_new) -
(SELECT COUNT(*)::int FROM inserted) AS rejected_new_files;
-- 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
AND archived = false
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')::jsonb,
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')::jsonb,
updated_at = @updated_at::timestamptz
WHERE
id = @id::uuid
RETURNING
*;
-- name: GetStaleChats :many
-- Find chats that appear stuck and need recovery. This covers:
-- 1. Running chats whose heartbeat has expired (worker crash).
-- 2. Chats awaiting client action (requires_action) past the
-- timeout threshold (client disappeared).
SELECT
*
FROM
chats
WHERE
(status = 'running'::chat_status
AND heartbeat_at < @stale_threshold::timestamptz)
OR (status = 'requires_action'::chat_status
AND updated_at < @stale_threshold::timestamptz);
-- name: UpdateChatHeartbeats :many
-- Bumps the heartbeat timestamp for the given set of chat IDs,
-- provided they are still running and owned by the specified
-- worker. Returns the IDs that were actually updated so the
-- caller can detect stolen or completed chats via set-difference.
UPDATE
chats
SET
heartbeat_at = @now::timestamptz
WHERE
id = ANY(@ids::uuid[])
AND worker_id = @worker_id::uuid
AND status = 'running'::chat_status
RETURNING id;
-- 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, model_config_id)
VALUES (
@chat_id,
@content,
sqlc.narg('model_config_id')::uuid
)
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: GetChatDiffStatusSummary :one
-- Returns aggregate PR counts across all agent chats for telemetry.
-- Deduplicates by PR URL so forked chats referencing the same pull
-- request are counted once (using the most recently refreshed state).
-- Total is derived from the three recognized state buckets and
-- always equals open + merged + closed; other non-NULL states are
-- intentionally excluded from these aggregates.
WITH deduped AS (
SELECT DISTINCT ON (COALESCE(NULLIF(cds.url, ''), c.id::text))
cds.pull_request_state
FROM chat_diff_statuses cds
JOIN chats c ON c.id = cds.chat_id
WHERE cds.pull_request_state IN ('open', 'merged', 'closed')
ORDER BY COALESCE(NULLIF(cds.url, ''), c.id::text), cds.updated_at DESC, c.id DESC
)
SELECT
COUNT(*)::bigint AS total,
COUNT(*) FILTER (WHERE pull_request_state = 'open')::bigint AS open,
COUNT(*) FILTER (WHERE pull_request_state = 'merged')::bigint AS merged,
COUNT(*) FILTER (WHERE pull_request_state = 'closed')::bigint AS closed
FROM deduped;
-- 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,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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,
cc.total_runtime_ms
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,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms
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,
total_runtime_ms,
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
-- Returns the total spend for a user in the given period.
-- When organization_id is NULL, spend across all organizations is
-- returned (global behavior). Otherwise only spend within the
-- specified organization is included.
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 (sqlc.narg('organization_id')::uuid IS NULL
OR c.organization_id = sqlc.narg('organization_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 no group limits match the specified scope.
-- When organization_id is NULL, groups across all organizations are
-- considered (global behavior). Otherwise only groups within the
-- specified organization are considered.
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 (sqlc.narg('organization_id')::uuid IS NULL
OR g.organization_id = sqlc.narg('organization_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, applies globally across
-- all organizations since it lives on the users table)
-- 2. Minimum group limit across the user's groups
-- 3. Global default from config
-- Returns -1 if limits are not enabled.
-- When organization_id is NULL, groups across all organizations are
-- considered (global behavior). Otherwise only groups within the
-- specified organization are considered.
-- limit_source indicates which tier won: 'user', 'group', 'default',
-- or 'disabled'.
SELECT CASE
WHEN NOT cfg.enabled THEN -1
WHEN u.chat_spend_limit_micros IS NOT NULL THEN u.chat_spend_limit_micros
WHEN gl.limit_micros IS NOT NULL THEN gl.limit_micros
ELSE cfg.default_limit_micros
END::bigint AS effective_limit_micros,
CASE
WHEN NOT cfg.enabled THEN 'disabled'
WHEN u.chat_spend_limit_micros IS NOT NULL THEN 'user'
WHEN gl.limit_micros IS NOT NULL THEN 'group'
ELSE 'default'
END AS limit_source
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 (sqlc.narg('organization_id')::uuid IS NULL
OR g.organization_id = sqlc.narg('organization_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;
-- name: DeleteOldChats :execrows
-- Deletes chats that have been archived for longer than the given
-- threshold. Active (non-archived) chats are never deleted.
-- Related chat_messages, chat_diff_statuses, and
-- chat_queued_messages are removed via ON DELETE CASCADE.
-- Parent/root references on child chats are SET NULL.
WITH deletable AS (
SELECT id
FROM chats
WHERE archived = true
AND updated_at < @before_time::timestamptz
ORDER BY updated_at ASC
LIMIT @limit_count
)
DELETE FROM chats
USING deletable
WHERE chats.id = deletable.id
AND chats.archived = true;
-- name: GetChatsUpdatedAfter :many
-- Retrieves chats updated after the given timestamp for telemetry
-- snapshot collection. Uses updated_at so that long-running chats
-- still appear in each snapshot window while they are active.
SELECT
c.id, c.owner_id, c.created_at, c.updated_at, c.status,
(c.parent_chat_id IS NOT NULL)::bool AS has_parent,
c.root_chat_id, c.workspace_id,
c.mode, c.archived, c.last_model_config_id, c.client_type,
cds.pull_request_state
FROM chats c
LEFT JOIN chat_diff_statuses cds ON cds.chat_id = c.id
WHERE c.updated_at > @updated_after;
-- name: GetChatMessageSummariesPerChat :many
-- Aggregates message-level metrics per chat for messages created
-- after the given timestamp. Uses message created_at so that
-- ongoing activity in long-running chats is captured each window.
SELECT
cm.chat_id,
COUNT(*)::bigint AS message_count,
COUNT(*) FILTER (WHERE cm.role = 'user')::bigint AS user_message_count,
COUNT(*) FILTER (WHERE cm.role = 'assistant')::bigint AS assistant_message_count,
COUNT(*) FILTER (WHERE cm.role = 'tool')::bigint AS tool_message_count,
COUNT(*) FILTER (WHERE cm.role = 'system')::bigint AS system_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.reasoning_tokens), 0)::bigint AS total_reasoning_tokens,
COALESCE(SUM(cm.cache_creation_tokens), 0)::bigint AS total_cache_creation_tokens,
COALESCE(SUM(cm.cache_read_tokens), 0)::bigint AS total_cache_read_tokens,
COALESCE(SUM(cm.total_cost_micros), 0)::bigint AS total_cost_micros,
COALESCE(SUM(cm.runtime_ms), 0)::bigint AS total_runtime_ms,
COUNT(DISTINCT cm.model_config_id)::bigint AS distinct_model_count,
COUNT(*) FILTER (WHERE cm.compressed)::bigint AS compressed_message_count
FROM chat_messages cm
WHERE cm.created_at > @created_after
AND cm.deleted = false
GROUP BY cm.chat_id;
-- name: GetChatModelConfigsForTelemetry :many
-- Returns all model configurations for telemetry snapshot collection.
SELECT id, provider, model, context_limit, enabled, is_default
FROM chat_model_configs
WHERE deleted = false;
-- name: GetActiveChatsByAgentID :many
SELECT *
FROM chats
WHERE agent_id = @agent_id::uuid
AND archived = false
-- Active statuses only: waiting, pending, running, paused,
-- requires_action.
-- Excludes completed and error (terminal states).
AND status IN ('waiting', 'running', 'paused', 'pending', 'requires_action')
ORDER BY updated_at DESC;
-- name: ClearChatMessageProviderResponseIDsByChatID :exec
UPDATE chat_messages
SET provider_response_id = NULL
WHERE chat_id = @chat_id::uuid
AND deleted = false
AND provider_response_id IS NOT NULL;
-- name: SoftDeleteContextFileMessages :exec
UPDATE chat_messages SET deleted = true
WHERE chat_id = @chat_id::uuid
AND deleted = false
AND content::jsonb @> '[{"type": "context-file"}]';
-- name: AutoArchiveInactiveChats :many
-- Archives inactive root chats (pinned and already-archived chats skipped),
-- cascading to children via root_chat_id. Limits apply to roots, not total
-- rows. Used by dbpurge.
WITH to_archive AS (
SELECT
c.id,
-- Activity = MAX(cm.created_at) across the family, or c.created_at
-- when the family has no non-deleted messages.
COALESCE(activity.last_activity_at, c.created_at) AS last_activity_at
FROM chats c
LEFT JOIN LATERAL (
SELECT MAX(cm.created_at) AS last_activity_at
FROM chat_messages cm
JOIN chats fc ON fc.id = cm.chat_id
WHERE (fc.id = c.id OR fc.root_chat_id = c.id)
AND cm.deleted = false
) activity ON TRUE
WHERE c.archived = false
AND c.pin_order = 0
AND c.parent_chat_id IS NULL -- roots only
AND c.created_at < @archive_cutoff::timestamptz
-- New active statuses must be added here to prevent archiving.
AND c.status NOT IN ('running', 'pending', 'paused', 'requires_action')
AND COALESCE(activity.last_activity_at, c.created_at) < @archive_cutoff::timestamptz
-- Sorting by created_at lets Postgres drive the scan from the
-- partial index instead of evaluating every LATERAL subquery
-- before sorting. All candidates are past the cutoff, so the
-- archive order is immaterial once the backlog drains.
ORDER BY c.created_at ASC
LIMIT @limit_count
),
archived AS (
UPDATE chats c
SET archived = true, pin_order = 0, updated_at = NOW()
FROM to_archive t
WHERE (c.id = t.id OR c.root_chat_id = t.id) -- cascade to children
AND c.archived = false
RETURNING c.*
)
SELECT
a.*,
-- Children inherit their root's activity so last_activity_at is never null.
COALESCE(
t.last_activity_at,
(SELECT tr.last_activity_at FROM to_archive tr WHERE tr.id = a.root_chat_id),
a.created_at
)::timestamptz AS last_activity_at
FROM archived a
LEFT JOIN to_archive t ON t.id = a.id
-- created_at ASC flows through to dbpurge's digest truncation; see
-- buildDigestData in dbpurge.go for the tradeoff rationale.
ORDER BY (a.root_chat_id IS NULL) DESC, a.owner_id ASC, a.created_at ASC, a.id ASC;