-- Chatbot backend support, milestone M1: bot.sql. -- See plans/chatbot_plan_codex.v10.md §5 (Core SQL Queries) for the -- canonical query shapes. Only M1 queries live here; M4 algorithm -- queries (CompleteBotTurn, FailBotTurn, MarkBotTurnSessionDeleted, -- InsertBotTurnPrompt, GetBotTurn, etc.) are added in M4. -- -- Naming conventions: -- - All bot_* tables use snake_case columns. New named params -- (@session_id, @client_id, @created_by, @grace_seconds, @attempt_id, -- @ordinal, @session_ids, @turn_limit) follow snake_case so sqlc -- generates Go field names ClientID, SessionID, CreatedBy, etc. -- name: LockBotSessionForOwner :one -- Owner-scoped session lookup with row-level lock. Returns the session -- plus two derived ordinals computed from bot_turns: -- last_seen_ordinal = MAX(ordinal) regardless of status -- last_terminal_ordinal = MAX(ordinal) where status <> 'in_flight' -- The FOR UPDATE OF s clause locks the session row but not the -- bot_turns rows (those are read uncontended by the subselects). -- Must be called inside a transaction. Plan §5. SELECT s.*, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal FROM bot_sessions s WHERE s.id = @session_id AND s.client_id = @client_id AND s.created_by = @created_by AND s.is_deleted = false FOR UPDATE OF s; -- name: AbandonExpiredInflightForOwner :exec -- Owner-scoped grace-window abandonment. Mutates bot_turns rows whose -- attempt_started_at is older than NOW() - grace_seconds AND whose owning -- session matches the caller's (client_id, created_by, is_deleted=false). -- The EXISTS predicate is defense in depth — the service layer is -- expected to call LockBotSessionForOwner first. Plan §5. UPDATE bot_turns bt SET status = 'abandoned', error = jsonb_build_object( 'code', 'turn_abandoned_grace_expired', 'message', 'no terminal status reached within grace window' ) WHERE bt.session_id = @session_id AND bt.status = 'in_flight' AND bt.attempt_started_at < NOW() - make_interval(secs => @grace_seconds::int) AND EXISTS ( SELECT 1 FROM bot_sessions s WHERE s.id = bt.session_id AND s.client_id = @client_id AND s.created_by = @created_by AND s.is_deleted = false ); -- name: ResetBotTurnForRetry :exec -- Reset a terminal-failure turn back to in_flight for retry. Mints a -- fresh attempt_id, resets attempt_started_at and created_at, and -- nullifies completion/error. Only rows in (errored, abandoned) are -- eligible; in_flight, completed, and session_deleted rows are no-ops. -- Plan §5. UPDATE bot_turns SET status = 'in_flight', attempt_id = @attempt_id, attempt_started_at = NOW(), created_at = NOW(), completion = NULL, error = NULL WHERE session_id = @session_id AND ordinal = @ordinal AND status IN ('errored', 'abandoned'); -- name: ListLastTurnsForSessions :many -- Session-list preview query. For each session_id in @session_ids, -- return the most recent @turn_limit turns ordered by ordinal DESC. -- The service caller is expected to pass turn_limit = min(request.limit, -- cfg.RecentTurns) per plan §3. The full row column set is returned per -- plan §5: session_id, ordinal, prompt, completion, status, created_at, -- latency_ms, tokens_in, tokens_out, error. SELECT session_id, ordinal, prompt, completion, status, created_at, latency_ms, tokens_in, tokens_out, error FROM ( SELECT bt.*, ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY ordinal DESC) AS rn FROM bot_turns bt WHERE bt.session_id = ANY(@session_ids::uuid[]) ) ranked WHERE rn <= @turn_limit::int ORDER BY session_id, ordinal DESC; -- ---------- Milestone M2 queries ---------- -- -- Session/scope CRUD queries used by internal/bot/service_session.go. -- All reads carry the derived last_terminal_ordinal column (plan §4) so -- the API response can populate `lastTurn` without a follow-up query. -- Owner-scoped queries filter on (client_id, created_by, is_deleted=false); -- super-admin reads filter on (client_id, is_deleted=false) only. -- name: InsertBotSession :one -- Insert a new owner-scoped session. Title and state default to '' / '{}' -- on the schema; we let those defaults apply rather than passing the -- caller's intent on creation. Plan §6 says title is derived from the -- first turn's prompt (handled by M4's AddTurn path, not here). INSERT INTO bot_sessions (client_id, created_by) VALUES (@client_id, @created_by) RETURNING id, client_id, created_by, created_at, updated_at, title, state, is_deleted; -- name: GetBotSessionForOwner :one -- Read-only owner-scoped session read. Mirrors LockBotSessionForOwner -- but without FOR UPDATE so callers that only need to display a session -- do not block writers. The derived ordinal columns match plan §4. SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal FROM bot_sessions s WHERE s.id = @session_id AND s.client_id = @client_id AND s.created_by = @created_by AND s.is_deleted = false; -- name: ListBotSessionsForOwner :many -- Owner-scoped list with limit/offset. Backed by -- idx_bot_sessions_client_user_active (plan §4): the WHERE filter and -- the (client_id, created_by, updated_at DESC) ordering match the index -- column order so the planner can satisfy the request without a sort. SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal FROM bot_sessions s WHERE s.client_id = @client_id AND s.created_by = @created_by AND s.is_deleted = false ORDER BY s.updated_at DESC LIMIT @limit_val::int OFFSET @offset_val::int; -- name: GetBotSessionForSuperAdmin :one -- Super-admin client-scoped session read. No created_by predicate per -- plan §9. is_deleted=false filter is the M2 default; including -- soft-deleted rows is out of scope for M2. SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal FROM bot_sessions s WHERE s.id = @session_id AND s.client_id = @client_id AND s.is_deleted = false; -- name: ListBotSessionsForSuperAdmin :many -- Super-admin client-scoped list. Backed by idx_bot_sessions_client_active. -- Same ordering invariant as ListBotSessionsForOwner. SELECT s.id, s.client_id, s.created_by, s.created_at, s.updated_at, s.title, s.state, s.is_deleted, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id), 0)::int AS last_seen_ordinal, COALESCE((SELECT MAX(ordinal) FROM bot_turns WHERE session_id = s.id AND status <> 'in_flight'), 0)::int AS last_terminal_ordinal FROM bot_sessions s WHERE s.client_id = @client_id AND s.is_deleted = false ORDER BY s.updated_at DESC LIMIT @limit_val::int OFFSET @offset_val::int; -- name: SoftDeleteBotSessionForSuperAdmin :execrows -- Mark a session as deleted. Returns the affected row count so the -- caller can distinguish 0 (not found / already deleted) from 1 (soft -- delete applied). Filters on (client_id, is_deleted=false) so a second -- delete on an already-deleted row returns 0. UPDATE bot_sessions SET is_deleted = true, updated_at = NOW() WHERE id = @session_id AND client_id = @client_id AND is_deleted = false; -- name: MarkInflightTurnsSessionDeletedForSession :exec -- Side-effect of a super-admin DELETE: any in-flight turn on the deleted -- session must transition to status='session_deleted' with an explanatory -- error JSON so the M4 AddTurn tx2 callback finds a terminal row instead -- of a stale in_flight row. The bot_turns_status_fields_consistent CHECK -- requires error IS NOT NULL on session_deleted rows. UPDATE bot_turns SET status = 'session_deleted', error = jsonb_build_object( 'code', 'session_deleted_by_admin', 'message', 'session deleted while turn in flight' ) WHERE session_id = @session_id AND status = 'in_flight'; -- name: ListBotSessionDocumentsForSession :many -- Returns the document_id set persisted in bot_session_documents for the -- given session, in deterministic order. M2 returns these as-is — no -- folder expansion (that lives in the M3 FastAPI payload assembly). SELECT document_id FROM bot_session_documents WHERE session_id = @session_id ORDER BY document_id; -- name: ListBotSessionFoldersForSession :many -- Returns the folder_id set persisted in bot_session_folders for the -- given session, in deterministic order. SELECT folder_id FROM bot_session_folders WHERE session_id = @session_id ORDER BY folder_id; -- name: DeleteBotSessionDocumentsForSession :exec -- @sqlc-vet-disable -- Wipe the persisted document scope for a session. Used as the first -- half of the replace-set semantics in PatchBotSessionScope. The caller -- must follow with InsertBotSessionDocument inside the same transaction -- so the union write is atomic. DELETE FROM bot_session_documents WHERE session_id = @session_id; -- name: DeleteBotSessionFoldersForSession :exec -- @sqlc-vet-disable -- Wipe the persisted folder scope for a session. Same role as -- DeleteBotSessionDocumentsForSession. DELETE FROM bot_session_folders WHERE session_id = @session_id; -- name: InsertBotSessionDocument :exec -- Add one (session_id, document_id) link. ON CONFLICT DO NOTHING covers -- the rare case where the same id appears twice in the new set; the -- service-layer dedupe runs first so this is defense in depth. INSERT INTO bot_session_documents (session_id, document_id) VALUES (@session_id, @document_id) ON CONFLICT (session_id, document_id) DO NOTHING; -- name: InsertBotSessionFolder :exec -- Add one (session_id, folder_id) link. INSERT INTO bot_session_folders (session_id, folder_id) VALUES (@session_id, @folder_id) ON CONFLICT (session_id, folder_id) DO NOTHING; -- name: GetDocumentClientIDsForBotScope :many -- Bulk client-id lookup for a candidate document set. Returns one row -- per matching document so the service can detect cross-client ids by -- comparing against the session's client_id and missing rows. Documents -- not found return no row (caller must compare set sizes). SELECT id, clientId AS client_id FROM documents WHERE id = ANY(@document_ids::uuid[]); -- name: GetFolderClientIDsForBotScope :many -- Bulk client-id lookup for a candidate folder set. Same shape and -- semantics as GetDocumentClientIDsForBotScope. SELECT id, clientId AS client_id FROM folders WHERE id = ANY(@folder_ids::uuid[]); -- name: TouchBotSessionUpdatedAt :exec -- Bumps updated_at on a bot_sessions row so the owner list-by-updated_at -- ordering reflects the most recent activity. Used after PatchScope. UPDATE bot_sessions SET updated_at = NOW() WHERE id = @session_id; -- ---------- Milestone M3 queries ---------- -- name: GetCompletedTurnsForSessionAscending :many -- Returns the most recent N completed turns for a session, ordered by -- ordinal ASCENDING for the FastAPI conversation_history payload (plan §7). -- The CTE first filters to completed turns and DESCENDS by ordinal so the -- LIMIT clause picks the newest N; the outer SELECT then re-orders ASC -- because conversation_history is "two messages per turn, ascending -- ordinal" per plan §7. The service caller passes turn_limit = cfg.RecentTurns. WITH recent AS ( SELECT ordinal, prompt, completion, created_at FROM bot_turns WHERE session_id = @session_id AND status = 'completed' ORDER BY ordinal DESC LIMIT @turn_limit::int ) SELECT ordinal, prompt, completion, created_at FROM recent ORDER BY ordinal ASC; -- ---------- Milestone M4 queries ---------- -- -- Turn CRUD + AddTurn algorithm queries per plan §6. Each mutating -- query compares-and-sets on attempt_id so a stale tx2 callback after -- abandonment or retry affects zero rows (plan §11 invariant). -- name: GetBotTurn :one -- Read one turn by (session_id, ordinal). Returns pgx.ErrNoRows when -- no row exists. The service uses this in tx1 after -- LockBotSessionForOwner so the existing-row classification (plan §6) -- runs against a stable snapshot. SELECT session_id, ordinal, prompt, completion, status, attempt_id, attempt_started_at, created_at, latency_ms, tokens_in, tokens_out, error FROM bot_turns WHERE session_id = @session_id AND ordinal = @ordinal; -- name: FindBotTurnByPrompt :one -- Find the lowest-ordinal turn whose prompt matches input. Used by the -- handler to pick a target ordinal under prompt-match semantics: a -- retry of an errored/abandoned/completed/session_deleted turn must -- reuse the existing row's ordinal so the service classifies it via -- the existing-row branch of plan §6. pgx.ErrNoRows when no match. SELECT session_id, ordinal, prompt, completion, status FROM bot_turns WHERE session_id = @session_id AND prompt = @prompt ORDER BY ordinal ASC LIMIT 1; -- name: InsertBotTurnPrompt :one -- Insert a new in_flight turn at the requested ordinal. The partial -- unique index `bot_turns_one_inflight_per_session` rejects insertion -- when another in_flight row exists for the session — the service -- catches the unique-violation pgconn.PgError and maps it to -- ErrPriorTurnInFlight. Returns the inserted row so the caller can -- surface created_at without a follow-up read. INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at) VALUES (@session_id, @ordinal, @prompt, 'in_flight', @attempt_id, NOW(), NOW()) RETURNING session_id, ordinal, prompt, completion, status, attempt_id, attempt_started_at, created_at, latency_ms, tokens_in, tokens_out, error; -- name: CompleteBotTurn :execrows -- Compare-and-set: flips status from in_flight to completed when -- attempt_id matches. Returns affected row count so a stale tx2 -- callback (whose attempt_id no longer matches) reports 0 rows and the -- service returns ErrTurnSuperseded. Plan §6 + plan §11. UPDATE bot_turns SET status = 'completed', completion = @completion, latency_ms = @latency_ms, tokens_in = @tokens_in, tokens_out = @tokens_out WHERE session_id = @session_id AND ordinal = @ordinal AND attempt_id = @attempt_id AND status = 'in_flight'; -- name: FailBotTurn :execrows -- Compare-and-set: flips status from in_flight to errored when -- attempt_id matches. Error JSON shape is `{"code","message","attempt"}` -- per plan §6. UPDATE bot_turns SET status = 'errored', error = @error WHERE session_id = @session_id AND ordinal = @ordinal AND attempt_id = @attempt_id AND status = 'in_flight'; -- name: MarkBotTurnSessionDeleted :execrows -- Compare-and-set: flips status from in_flight to session_deleted when -- attempt_id matches. Used by the AddTurn tx2 path when -- LockBotSessionForOwner finds the session disappeared mid-FastAPI-call. -- Plan §6. UPDATE bot_turns SET status = 'session_deleted', error = @error WHERE session_id = @session_id AND ordinal = @ordinal AND attempt_id = @attempt_id AND status = 'in_flight'; -- name: SetBotSessionTitle :exec -- Sets the session title and bumps updated_at, but ONLY when the -- existing title is empty. Plan §6: title is derived from the first -- turn's prompt on ordinal=1; the empty-title guard prevents a retry -- on ordinal=1 (after an errored/abandoned reset) from clobbering a -- title that was already set on a prior successful attempt. UPDATE bot_sessions SET title = @title, updated_at = NOW() WHERE id = @session_id AND title = ''; -- name: ListTurnsForSession :many -- Owner-scoped list of every turn for a session, ordered ASC by ordinal. -- The owner check is the responsibility of the caller; the service -- runs GetBotSessionForOwner first so a non-owner caller never reaches -- this query. SELECT session_id, ordinal, prompt, completion, status, attempt_id, attempt_started_at, created_at, latency_ms, tokens_in, tokens_out, error FROM bot_turns WHERE session_id = @session_id ORDER BY ordinal ASC; -- name: UpdateBotSessionState :exec -- Persists a session_state object after tx2's compare-and-set. Plan §6 -- mandates that null updated_session_state preserves prior; this query -- is only invoked when the service has decided to write a non-null -- (possibly stripped) value. NULL @state explicitly clears state. UPDATE bot_sessions SET state = COALESCE(@state, '{}'::jsonb), updated_at = NOW() WHERE id = @session_id AND client_id = @client_id;