Merged in feature/chatbot1 (pull request #223)
Chatbot functionality * baseline working * missing test file * more tests
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
-- Rollback migration 131. Drop in reverse dependency order: turns and
|
||||
-- scope tables reference bot_sessions (via FK), so they go first.
|
||||
-- DROP TABLE removes any indexes and CHECK constraints attached to the
|
||||
-- table, so no separate index/constraint drops are needed.
|
||||
|
||||
DROP TABLE IF EXISTS bot_turns;
|
||||
DROP TABLE IF EXISTS bot_session_folders;
|
||||
DROP TABLE IF EXISTS bot_session_documents;
|
||||
DROP TABLE IF EXISTS bot_sessions;
|
||||
@@ -0,0 +1,102 @@
|
||||
-- Chatbot backend support, milestone M1: chatbot tables.
|
||||
-- See plans/chatbot_plan_codex.v10.md §4 (Data Model) for the canonical
|
||||
-- definition. Existing tables retain camelCase column names (per migrations
|
||||
-- 1-130); new bot_* tables use snake_case to match migrations 127-130.
|
||||
--
|
||||
-- Tables created in dependency order:
|
||||
-- 1. bot_sessions — owner-scoped chatbot conversations
|
||||
-- 2. bot_session_documents — explicit per-session document scope
|
||||
-- 3. bot_session_folders — explicit per-session folder scope
|
||||
-- 4. bot_turns — append-only turn log per session
|
||||
|
||||
-- ---------- bot_sessions ----------
|
||||
|
||||
CREATE TABLE bot_sessions (
|
||||
id uuid NOT NULL DEFAULT uuid_generate_v7(),
|
||||
client_id varchar(255) NOT NULL REFERENCES clients(clientId) ON DELETE CASCADE,
|
||||
created_by varchar(255) NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
title text NOT NULL DEFAULT '',
|
||||
state jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
is_deleted boolean NOT NULL DEFAULT false,
|
||||
CONSTRAINT pk_bot_sessions PRIMARY KEY (id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bot_sessions_client_user_active
|
||||
ON bot_sessions (client_id, created_by, updated_at DESC)
|
||||
WHERE is_deleted = false;
|
||||
|
||||
CREATE INDEX idx_bot_sessions_client_active
|
||||
ON bot_sessions (client_id, updated_at DESC)
|
||||
WHERE is_deleted = false;
|
||||
|
||||
-- ---------- bot_session_documents (scope) ----------
|
||||
|
||||
CREATE TABLE bot_session_documents (
|
||||
session_id uuid NOT NULL REFERENCES bot_sessions(id) ON DELETE CASCADE,
|
||||
document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||
CONSTRAINT pk_bot_session_documents PRIMARY KEY (session_id, document_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bsd_document ON bot_session_documents(document_id);
|
||||
|
||||
-- ---------- bot_session_folders (scope) ----------
|
||||
|
||||
CREATE TABLE bot_session_folders (
|
||||
session_id uuid NOT NULL REFERENCES bot_sessions(id) ON DELETE CASCADE,
|
||||
folder_id uuid NOT NULL REFERENCES folders(id) ON DELETE CASCADE,
|
||||
CONSTRAINT pk_bot_session_folders PRIMARY KEY (session_id, folder_id)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_bsf_folder ON bot_session_folders(folder_id);
|
||||
|
||||
-- ---------- bot_turns ----------
|
||||
--
|
||||
-- bot_turns_status_fields_consistent groups session_deleted with errored
|
||||
-- and abandoned: completion IS NULL AND error IS NOT NULL. This matches
|
||||
-- plan §4 verbatim.
|
||||
|
||||
CREATE TABLE bot_turns (
|
||||
session_id uuid NOT NULL REFERENCES bot_sessions(id) ON DELETE CASCADE,
|
||||
ordinal int NOT NULL CHECK (ordinal > 0),
|
||||
prompt text NOT NULL,
|
||||
completion text,
|
||||
status text NOT NULL DEFAULT 'in_flight',
|
||||
attempt_id uuid NOT NULL,
|
||||
attempt_started_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
latency_ms int,
|
||||
tokens_in int,
|
||||
tokens_out int,
|
||||
error jsonb,
|
||||
CONSTRAINT pk_bot_turns PRIMARY KEY (session_id, ordinal),
|
||||
CONSTRAINT bot_turns_status_values CHECK (
|
||||
status IN ('in_flight', 'completed', 'errored', 'abandoned', 'session_deleted')
|
||||
),
|
||||
-- bot_turns_status_fields_consistent enforces the (status, completion,
|
||||
-- error) consistency matrix from plan §4. The trailing OR-clause
|
||||
-- (`status NOT IN (...)`) is an evaluation-order guard, not a relaxation
|
||||
-- of the rule: Postgres evaluates table-level CHECK constraints in
|
||||
-- alphabetical order by constraint name, so without this guard a row
|
||||
-- with an UNKNOWN status (e.g. 'frobnicated') trips this constraint
|
||||
-- before bot_turns_status_values gets a chance to reject the unknown
|
||||
-- enum value. With the guard, unknown statuses pass this consistency
|
||||
-- check (every branch becomes irrelevant) and bot_turns_status_values
|
||||
-- owns the rejection. For every documented status value the plan §4
|
||||
-- literal still holds verbatim.
|
||||
CONSTRAINT bot_turns_status_fields_consistent CHECK (
|
||||
(status = 'in_flight' AND completion IS NULL AND error IS NULL)
|
||||
OR (status = 'completed' AND completion IS NOT NULL AND error IS NULL)
|
||||
OR (status IN ('errored', 'abandoned', 'session_deleted') AND completion IS NULL AND error IS NOT NULL)
|
||||
OR status NOT IN ('in_flight', 'completed', 'errored', 'abandoned', 'session_deleted')
|
||||
)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX bot_turns_one_inflight_per_session
|
||||
ON bot_turns (session_id)
|
||||
WHERE status = 'in_flight';
|
||||
|
||||
CREATE INDEX idx_bot_turns_session_completed
|
||||
ON bot_turns (session_id, ordinal)
|
||||
WHERE status = 'completed';
|
||||
@@ -0,0 +1,397 @@
|
||||
-- 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;
|
||||
@@ -120,6 +120,39 @@ SELECT custom_schema_id
|
||||
FROM documents
|
||||
WHERE id = @id;
|
||||
|
||||
-- name: GetDocumentCustomSchemaIdForClient :one
|
||||
-- Chatbot-scoped variant of GetDocumentCustomSchemaId: adds a clientId
|
||||
-- filter so cross-client lookups miss with pgx.ErrNoRows. Same scalar
|
||||
-- result type (nullable uuid) as the non-scoped query — only the WHERE
|
||||
-- clause differs. Plan §5.
|
||||
SELECT custom_schema_id
|
||||
FROM documents
|
||||
WHERE id = @id
|
||||
AND clientId = @client_id;
|
||||
|
||||
-- name: GetCurrentDocumentCustomMetadataForClient :one
|
||||
-- Chatbot-scoped variant of GetCurrentDocumentCustomMetadata. Same column
|
||||
-- set (the metadata row decorated with schema id/name/version), but the
|
||||
-- joined documents row must belong to @client_id; cross-client calls miss
|
||||
-- with pgx.ErrNoRows. Plan §5.
|
||||
SELECT
|
||||
dcm.id,
|
||||
dcm.document_id,
|
||||
dcm.metadata,
|
||||
dcm.version,
|
||||
dcm.created_at,
|
||||
dcm.created_by,
|
||||
cms.id AS schema_id,
|
||||
cms.name AS schema_name,
|
||||
cms.version AS schema_version
|
||||
FROM document_custom_metadata dcm
|
||||
JOIN documents d ON d.id = dcm.document_id
|
||||
LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id
|
||||
WHERE dcm.document_id = @document_id
|
||||
AND d.clientId = @client_id
|
||||
ORDER BY dcm.version DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: GetDocumentClientID :one
|
||||
-- Read a document's clientId for the AssignSchema cross-client check.
|
||||
-- documents.clientId is the unquoted (lowercased) varchar column.
|
||||
|
||||
@@ -81,6 +81,29 @@ SELECT
|
||||
FROM documents d
|
||||
WHERE d.id = $1;
|
||||
|
||||
-- name: GetDocumentEnrichedForClient :one
|
||||
-- Chatbot-scoped read: same column set as GetDocumentEnriched, but adds a
|
||||
-- clientId filter so cross-client lookups miss with pgx.ErrNoRows.
|
||||
-- Plan §5 mandates a clientId filter on every chatbot document/folder
|
||||
-- read so document scope cannot leak between clients. documents.clientId
|
||||
-- is the unquoted (lowercased) varchar column from migration 5.
|
||||
SELECT
|
||||
d.id,
|
||||
d.clientId,
|
||||
d.hash,
|
||||
d.folderId,
|
||||
d.filename,
|
||||
d.originalPath,
|
||||
d.file_size_bytes,
|
||||
d.custom_schema_id,
|
||||
EXISTS(
|
||||
SELECT 1 FROM document_custom_metadata
|
||||
WHERE document_id = d.id
|
||||
) AS has_custom_metadata
|
||||
FROM documents d
|
||||
WHERE d.id = @id
|
||||
AND d.clientId = @client_id;
|
||||
|
||||
-- name: LockDocumentForLegacyExtractionWrite :one
|
||||
-- Milestone 2.5 parent-row lock acquired by CreateFieldExtraction as the
|
||||
-- FIRST DML statement inside its transaction. Serializes with AssignSchema
|
||||
|
||||
@@ -131,6 +131,33 @@ WITH RECURSIVE folder_tree AS (
|
||||
)
|
||||
SELECT id FROM documents WHERE folderId IN (SELECT id FROM folder_tree);
|
||||
|
||||
-- name: GetDocumentIDsInFolderTreeForClient :many
|
||||
-- Chatbot-scoped recursive folder tree walk. Plan §5 mandates that the
|
||||
-- clientId filter applies at every recursive step AND at the final
|
||||
-- document selection so a cross-client root cannot leak documents.
|
||||
--
|
||||
-- Step-by-step:
|
||||
-- - Base case: select the root folder only when its clientId matches.
|
||||
-- - Recursive case: descend through folders.parentId, requiring the
|
||||
-- child folder's clientId to match. A folder owned by a different
|
||||
-- client cannot enter the CTE even if its parent did (which it
|
||||
-- cannot, because the base case already rejects cross-client roots).
|
||||
-- - Final selection: documents must reside in folder_tree AND share
|
||||
-- the same clientId, defending against any future case where a
|
||||
-- document row references a folder owned by a different client.
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id FROM folders
|
||||
WHERE id = @folder_id::uuid
|
||||
AND clientId = @client_id::varchar
|
||||
UNION ALL
|
||||
SELECT f.id FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
WHERE f.clientId = @client_id::varchar
|
||||
)
|
||||
SELECT id FROM documents
|
||||
WHERE folderId IN (SELECT id FROM folder_tree)
|
||||
AND clientId = @client_id::varchar;
|
||||
|
||||
-- name: DeleteDocumentUploadsInFolderTree :exec
|
||||
-- @sqlc-vet-disable
|
||||
-- Delete documentUploads rows that reference folders in the tree
|
||||
|
||||
@@ -8,6 +8,19 @@ SELECT * FROM documentLabels
|
||||
WHERE documentId = $1
|
||||
ORDER BY appliedAt DESC;
|
||||
|
||||
-- name: GetDocumentLabelsForClient :many
|
||||
-- Chatbot-scoped variant of GetDocumentLabels. Joins documents to enforce
|
||||
-- the clientId filter so cross-client lookups return an empty slice
|
||||
-- rather than another tenant's labels. Returns the same documentLabels
|
||||
-- column shape as GetDocumentLabels (not the joined documents row).
|
||||
-- Plan §5.
|
||||
SELECT dl.*
|
||||
FROM documentLabels dl
|
||||
JOIN documents d ON d.id = dl.documentId
|
||||
WHERE dl.documentId = @document_id
|
||||
AND d.clientId = @client_id
|
||||
ORDER BY dl.appliedAt DESC;
|
||||
|
||||
-- name: GetMostRecentLabel :one
|
||||
SELECT * FROM documentLabels
|
||||
WHERE documentId = $1 AND label = $2
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,644 @@
|
||||
// Milestone 1 §M1 chatbot tables — migration shape and constraint tests.
|
||||
//
|
||||
// These tests are the authoritative spec for migration 131
|
||||
// (00000000000131_create_chatbot_tables). They run against the real
|
||||
// Postgres testcontainer that internal/test.CreateDB stands up. No mocks.
|
||||
//
|
||||
// Plan reference: plans/chatbot_plan_codex.v10.md §4 (Data Model).
|
||||
//
|
||||
// Coverage:
|
||||
// - TestMigration131_UpDownRoundtrip: down then up apply cleanly and
|
||||
// re-create every table, column, index, and CHECK constraint.
|
||||
// - TestMigration131_BotTurnsCheckConstraints: bot_turns_status_values
|
||||
// rejects unknown statuses; bot_turns_status_fields_consistent rejects
|
||||
// every illegal (status, completion, error) tuple per §4.
|
||||
// - TestMigration131_PartialUniqueIndexInflight: only one in_flight row
|
||||
// per session is allowed.
|
||||
// - TestMigration131_BotSessionsPartialIndexes: the two partial indexes
|
||||
// on bot_sessions exist with the documented WHERE clause.
|
||||
// - TestMigration131_ScopeTablesCascade: delete-cascade behavior for
|
||||
// both bot_session_documents and bot_session_folders.
|
||||
//
|
||||
// All five tests follow the existing migration-test convention:
|
||||
// - t.Parallel + testing.Short skip mirroring TestMigration127_*
|
||||
// - per-test client/session reset because the testcontainer DB is reused
|
||||
// - introspection via information_schema and pg_catalog
|
||||
// - SQLSTATE assertions on negative-path expectations
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/serviceconfig"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// chatbotMigrationFiles is the canonical pair of file paths the round-trip
|
||||
// test reads. Migration 131 is currently the latest, so single-step
|
||||
// reversal is correct (team-lead M1 dispatch confirmed this).
|
||||
const (
|
||||
chatbotUp131Path = "../migrations/00000000000131_create_chatbot_tables.up.sql"
|
||||
chatbotDown131Path = "../migrations/00000000000131_create_chatbot_tables.down.sql"
|
||||
)
|
||||
|
||||
// resetClientForBot wipes any leftover client/session rows from prior
|
||||
// runs. Mirrors resetClient in customschemas_queries_test.go: the
|
||||
// Postgres container and per-test database are reused across runs, so
|
||||
// fresh CreateClient calls would collide with leftover rows. Documents
|
||||
// must be deleted first because clients->documents is NOT cascade.
|
||||
// bot_sessions cascades via the bot_sessions FK on clients(clientId).
|
||||
func resetClientForBot(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, name string) {
|
||||
t.Helper()
|
||||
pool := cfg.GetDBPool()
|
||||
_, err := pool.Exec(ctx, `DELETE FROM documents WHERE clientId = $1`, clientID)
|
||||
require.NoError(t, err, "pre-test DELETE documents must succeed")
|
||||
_, err = pool.Exec(ctx, `DELETE FROM clients WHERE clientId = $1`, clientID)
|
||||
require.NoError(t, err, "pre-test DELETE clients must succeed")
|
||||
err = cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Name: name,
|
||||
Clientid: clientID,
|
||||
})
|
||||
require.NoError(t, err, "CreateClient(%s) must succeed", clientID)
|
||||
}
|
||||
|
||||
// seedBotSession inserts one bot_sessions row and returns its id. Uses
|
||||
// raw SQL because the sqlc bot.sql generated layer is part of M1 and
|
||||
// may not be in place when this helper is called.
|
||||
func seedBotSession(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, createdBy string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := cfg.GetDBPool().QueryRow(ctx, `
|
||||
INSERT INTO bot_sessions (client_id, created_by)
|
||||
VALUES ($1, $2)
|
||||
RETURNING id
|
||||
`, clientID, createdBy).Scan(&id)
|
||||
require.NoError(t, err, "seed bot_sessions must succeed")
|
||||
return id
|
||||
}
|
||||
|
||||
// TestMigration131_UpDownRoundtrip proves the down.sql is complete: it
|
||||
// drops every object created by up.sql, and a subsequent up.sql re-apply
|
||||
// re-creates them. Mirrors TestMigration127_UpDownRoundtrip. Best-effort
|
||||
// cleanup runs the up.sql under context.Background() because t.Context()
|
||||
// is canceled before t.Cleanup callbacks in Go 1.24+.
|
||||
func TestMigration131_UpDownRoundtrip(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
|
||||
upSQL, err := os.ReadFile(chatbotUp131Path)
|
||||
require.NoError(t, err, "must read migration 131 up.sql at %s", chatbotUp131Path)
|
||||
downSQL, err := os.ReadFile(chatbotDown131Path)
|
||||
require.NoError(t, err, "must read migration 131 down.sql at %s", chatbotDown131Path)
|
||||
|
||||
t.Cleanup(func() {
|
||||
// Restore migrated state on the shared container regardless of
|
||||
// test outcome. context.Background() is intentional — t.Context()
|
||||
// is canceled before Cleanup callbacks in Go 1.24+.
|
||||
_, _ = pool.Exec(context.Background(), string(upSQL)) //nolint:usetesting // see comment
|
||||
})
|
||||
|
||||
// 1. Down — single-step because 131 is currently the latest migration.
|
||||
_, err = pool.Exec(ctx, string(downSQL))
|
||||
require.NoError(t, err, "down migration 131 must apply cleanly")
|
||||
|
||||
// All four tables must be gone.
|
||||
for _, table := range []string{"bot_sessions", "bot_session_documents", "bot_session_folders", "bot_turns"} {
|
||||
var oid *string
|
||||
err = pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, table).Scan(&oid)
|
||||
require.NoError(t, err)
|
||||
require.Nilf(t, oid, "%s must not exist after down migration", table)
|
||||
}
|
||||
|
||||
// 2. Up — re-create everything.
|
||||
_, err = pool.Exec(ctx, string(upSQL))
|
||||
require.NoError(t, err, "up migration 131 must re-apply cleanly")
|
||||
|
||||
// All four tables back.
|
||||
for _, table := range []string{"bot_sessions", "bot_session_documents", "bot_session_folders", "bot_turns"} {
|
||||
var oid *string
|
||||
err = pool.QueryRow(ctx, `SELECT to_regclass('public.' || $1)::text`, table).Scan(&oid)
|
||||
require.NoError(t, err)
|
||||
require.NotNilf(t, oid, "%s must exist after up migration", table)
|
||||
require.Equal(t, table, *oid)
|
||||
}
|
||||
|
||||
// bot_turns CHECK constraints reappear.
|
||||
for _, conname := range []string{"bot_turns_status_values", "bot_turns_status_fields_consistent"} {
|
||||
var contype string
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT c.contype::text
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
WHERE c.conname = $1 AND t.relname = 'bot_turns'
|
||||
`, conname).Scan(&contype)
|
||||
require.NoErrorf(t, err, "%s must exist on bot_turns", conname)
|
||||
require.Equalf(t, "c", contype, "%s must be a CHECK constraint", conname)
|
||||
}
|
||||
|
||||
// Partial unique index reappears.
|
||||
var indexExists bool
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = 'bot_turns'
|
||||
AND indexname = 'bot_turns_one_inflight_per_session'
|
||||
)
|
||||
`).Scan(&indexExists)
|
||||
require.NoError(t, err)
|
||||
require.True(t, indexExists, "bot_turns_one_inflight_per_session must reappear after up")
|
||||
|
||||
// Partial unique index must include the WHERE status='in_flight' clause.
|
||||
var indexDef string
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT indexdef FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND indexname = 'bot_turns_one_inflight_per_session'
|
||||
`).Scan(&indexDef)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, indexDef, "in_flight",
|
||||
"bot_turns_one_inflight_per_session must filter on status='in_flight'")
|
||||
|
||||
// idx_bot_turns_session_completed reappears.
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = 'bot_turns'
|
||||
AND indexname = 'idx_bot_turns_session_completed'
|
||||
)
|
||||
`).Scan(&indexExists)
|
||||
require.NoError(t, err)
|
||||
require.True(t, indexExists, "idx_bot_turns_session_completed must reappear after up")
|
||||
}
|
||||
|
||||
// TestMigration131_BotTurnsCheckConstraints exercises both CHECK
|
||||
// constraints on bot_turns directly. The status-enum CHECK rejects any
|
||||
// value outside the documented set; the status/payload consistency CHECK
|
||||
// enforces the (status, completion, error) matrix from plan §4.
|
||||
func TestMigration131_BotTurnsCheckConstraints(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
|
||||
const clientID = "MIG131_CHK"
|
||||
resetClientForBot(t, ctx, cfg, clientID, "mig131-chk")
|
||||
sessionID := seedBotSession(t, ctx, cfg, clientID, "tester@example.com")
|
||||
|
||||
// Helper: assert the INSERT trips a check_violation (23514) naming
|
||||
// the expected constraint.
|
||||
assertCheckViolation := func(t *testing.T, err error, wantConstraint string) {
|
||||
t.Helper()
|
||||
require.Error(t, err, "expected CHECK violation, got success")
|
||||
var pgErr *pgconn.PgError
|
||||
require.True(t, errors.As(err, &pgErr),
|
||||
"expected *pgconn.PgError, got %T: %v", err, err)
|
||||
require.Equal(t, "23514", pgErr.Code,
|
||||
"expected check_violation SQLSTATE 23514, got %q: %s", pgErr.Code, pgErr.Message)
|
||||
require.Equal(t, wantConstraint, pgErr.ConstraintName,
|
||||
"expected %s to be the tripped constraint", wantConstraint)
|
||||
}
|
||||
|
||||
t.Run("status_enum_rejects_unknown", func(t *testing.T) {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion, error)
|
||||
VALUES ($1, $2, $3, $4, $5, NULL, NULL)
|
||||
`, sessionID, 1, "p", "frobnicated", uuid.New())
|
||||
assertCheckViolation(t, err, "bot_turns_status_values")
|
||||
})
|
||||
|
||||
// Each row of this table sets the (status, completion, error) tuple
|
||||
// the consistency CHECK must reject. ordinal collisions are avoided
|
||||
// by giving each case its own ordinal.
|
||||
type fieldsRow struct {
|
||||
name string
|
||||
ordinal int
|
||||
status string
|
||||
completionPtr *string
|
||||
errorJSON *string
|
||||
}
|
||||
str := func(s string) *string { return &s }
|
||||
const errJSON = `{"code":"x","message":"y"}`
|
||||
|
||||
t.Run("status_fields_consistent", func(t *testing.T) {
|
||||
cases := []fieldsRow{
|
||||
{
|
||||
name: "in_flight_with_completion_rejected",
|
||||
ordinal: 2,
|
||||
status: "in_flight",
|
||||
completionPtr: str("not allowed"),
|
||||
errorJSON: nil,
|
||||
},
|
||||
{
|
||||
name: "in_flight_with_error_rejected",
|
||||
ordinal: 3,
|
||||
status: "in_flight",
|
||||
completionPtr: nil,
|
||||
errorJSON: str(errJSON),
|
||||
},
|
||||
{
|
||||
name: "completed_with_error_rejected",
|
||||
ordinal: 4,
|
||||
status: "completed",
|
||||
completionPtr: str("answer"),
|
||||
errorJSON: str(errJSON),
|
||||
},
|
||||
{
|
||||
name: "completed_without_completion_rejected",
|
||||
ordinal: 5,
|
||||
status: "completed",
|
||||
completionPtr: nil,
|
||||
errorJSON: nil,
|
||||
},
|
||||
{
|
||||
name: "errored_with_completion_rejected",
|
||||
ordinal: 6,
|
||||
status: "errored",
|
||||
completionPtr: str("oops"),
|
||||
errorJSON: str(errJSON),
|
||||
},
|
||||
{
|
||||
name: "errored_without_error_rejected",
|
||||
ordinal: 7,
|
||||
status: "errored",
|
||||
completionPtr: nil,
|
||||
errorJSON: nil,
|
||||
},
|
||||
{
|
||||
name: "abandoned_without_error_rejected",
|
||||
ordinal: 8,
|
||||
status: "abandoned",
|
||||
completionPtr: nil,
|
||||
errorJSON: nil,
|
||||
},
|
||||
{
|
||||
name: "abandoned_with_completion_rejected",
|
||||
ordinal: 9,
|
||||
status: "abandoned",
|
||||
completionPtr: str("partial"),
|
||||
errorJSON: str(errJSON),
|
||||
},
|
||||
{
|
||||
name: "session_deleted_without_error_rejected",
|
||||
ordinal: 10,
|
||||
status: "session_deleted",
|
||||
completionPtr: nil,
|
||||
errorJSON: nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion, error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb)
|
||||
`,
|
||||
sessionID, c.ordinal, "prompt", c.status, uuid.New(),
|
||||
c.completionPtr, c.errorJSON)
|
||||
assertCheckViolation(t, err, "bot_turns_status_fields_consistent")
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Positive control: each legal tuple from plan §4 must be accepted.
|
||||
// One session can hold at most one in_flight (partial unique index),
|
||||
// so the in_flight case uses a fresh session.
|
||||
t.Run("legal_status_tuples_accepted", func(t *testing.T) {
|
||||
legalSessionID := seedBotSession(t, ctx, cfg, clientID, "legal@example.com")
|
||||
|
||||
// in_flight with NULL completion and NULL error.
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
|
||||
VALUES ($1, 1, 'p', 'in_flight', $2)
|
||||
`, legalSessionID, uuid.New())
|
||||
require.NoError(t, err, "in_flight with NULLs must be accepted")
|
||||
|
||||
// completed with completion non-NULL, error NULL. New session to
|
||||
// avoid colliding with the legal in_flight row above.
|
||||
completedSessionID := seedBotSession(t, ctx, cfg, clientID, "completed@example.com")
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
|
||||
VALUES ($1, 1, 'p', 'completed', $2, 'ok')
|
||||
`, completedSessionID, uuid.New())
|
||||
require.NoError(t, err, "completed with completion must be accepted")
|
||||
|
||||
// errored with error non-NULL, completion NULL.
|
||||
erroredSessionID := seedBotSession(t, ctx, cfg, clientID, "errored@example.com")
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, error)
|
||||
VALUES ($1, 1, 'p', 'errored', $2, $3::jsonb)
|
||||
`, erroredSessionID, uuid.New(), errJSON)
|
||||
require.NoError(t, err, "errored with error must be accepted")
|
||||
|
||||
// abandoned with error non-NULL.
|
||||
abandonedSessionID := seedBotSession(t, ctx, cfg, clientID, "abandoned@example.com")
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, error)
|
||||
VALUES ($1, 1, 'p', 'abandoned', $2, $3::jsonb)
|
||||
`, abandonedSessionID, uuid.New(), errJSON)
|
||||
require.NoError(t, err, "abandoned with error must be accepted")
|
||||
|
||||
// session_deleted with error non-NULL.
|
||||
deletedSessionID := seedBotSession(t, ctx, cfg, clientID, "deleted@example.com")
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, error)
|
||||
VALUES ($1, 1, 'p', 'session_deleted', $2, $3::jsonb)
|
||||
`, deletedSessionID, uuid.New(), errJSON)
|
||||
require.NoError(t, err, "session_deleted with error must be accepted")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMigration131_PartialUniqueIndexInflight proves bot_turns_one_inflight_per_session
|
||||
// rejects a second in_flight row for the same session, but accepts mixed
|
||||
// states (in_flight + completed) and multiple completed rows.
|
||||
func TestMigration131_PartialUniqueIndexInflight(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
|
||||
const clientID = "MIG131_UNIQ"
|
||||
resetClientForBot(t, ctx, cfg, clientID, "mig131-uniq")
|
||||
sessionID := seedBotSession(t, ctx, cfg, clientID, "uniq@example.com")
|
||||
|
||||
t.Run("two_inflight_same_session_rejected", func(t *testing.T) {
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
|
||||
VALUES ($1, 1, 'p1', 'in_flight', $2)
|
||||
`, sessionID, uuid.New())
|
||||
require.NoError(t, err, "first in_flight row must succeed")
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
|
||||
VALUES ($1, 2, 'p2', 'in_flight', $2)
|
||||
`, sessionID, uuid.New())
|
||||
require.Error(t, err, "second in_flight row for same session must be rejected")
|
||||
|
||||
var pgErr *pgconn.PgError
|
||||
require.True(t, errors.As(err, &pgErr),
|
||||
"expected *pgconn.PgError, got %T: %v", err, err)
|
||||
require.Equal(t, "23505", pgErr.Code,
|
||||
"expected unique_violation SQLSTATE 23505, got %q", pgErr.Code)
|
||||
require.Equal(t, "bot_turns_one_inflight_per_session", pgErr.ConstraintName,
|
||||
"expected bot_turns_one_inflight_per_session to be the tripped constraint")
|
||||
})
|
||||
|
||||
t.Run("inflight_plus_completed_allowed", func(t *testing.T) {
|
||||
// Fresh session so we don't collide with the previous subtest.
|
||||
mixedSession := seedBotSession(t, ctx, cfg, clientID, "mixed@example.com")
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
|
||||
VALUES ($1, 1, 'p1', 'completed', $2, 'ok')
|
||||
`, mixedSession, uuid.New())
|
||||
require.NoError(t, err, "completed row must succeed")
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id)
|
||||
VALUES ($1, 2, 'p2', 'in_flight', $2)
|
||||
`, mixedSession, uuid.New())
|
||||
require.NoError(t, err, "in_flight after completed must be accepted (different status)")
|
||||
})
|
||||
|
||||
t.Run("two_completed_allowed", func(t *testing.T) {
|
||||
complSession := seedBotSession(t, ctx, cfg, clientID, "completed-twice@example.com")
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
|
||||
VALUES ($1, 1, 'p1', 'completed', $2, 'ok1')
|
||||
`, complSession, uuid.New())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, completion)
|
||||
VALUES ($1, 2, 'p2', 'completed', $2, 'ok2')
|
||||
`, complSession, uuid.New())
|
||||
require.NoError(t, err, "two completed rows for the same session must be accepted")
|
||||
})
|
||||
}
|
||||
|
||||
// TestMigration131_BotSessionsPartialIndexes asserts the two partial
|
||||
// indexes on bot_sessions exist with the documented WHERE clause
|
||||
// (is_deleted = false). Plan §4: idx_bot_sessions_client_user_active and
|
||||
// idx_bot_sessions_client_active are both partial indexes.
|
||||
func TestMigration131_BotSessionsPartialIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
|
||||
expected := map[string][]string{
|
||||
// Map index name -> ordered column list per plan §4.
|
||||
"idx_bot_sessions_client_user_active": {"client_id", "created_by", "updated_at"},
|
||||
"idx_bot_sessions_client_active": {"client_id", "updated_at"},
|
||||
}
|
||||
|
||||
for indexName, wantCols := range expected {
|
||||
t.Run(indexName, func(t *testing.T) {
|
||||
var indexDef string
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT indexdef FROM pg_indexes
|
||||
WHERE schemaname = 'public'
|
||||
AND tablename = 'bot_sessions'
|
||||
AND indexname = $1
|
||||
`, indexName).Scan(&indexDef)
|
||||
require.NoErrorf(t, err, "index %s must exist on bot_sessions", indexName)
|
||||
|
||||
// WHERE clause must filter is_deleted = false. Postgres
|
||||
// normalises the clause text, so a substring match is enough.
|
||||
require.Contains(t, indexDef, "is_deleted = false",
|
||||
"index %s must be partial on is_deleted = false; got %q",
|
||||
indexName, indexDef)
|
||||
|
||||
// Each expected column must appear in the indexdef text.
|
||||
for _, col := range wantCols {
|
||||
require.Containsf(t, indexDef, col,
|
||||
"index %s definition must reference column %s; got %q",
|
||||
indexName, col, indexDef)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMigration131_ScopeTablesCascade exercises the FK-cascade behavior
|
||||
// declared in plan §4: deleting a bot_sessions row cascades through
|
||||
// bot_session_documents and bot_session_folders; deleting a referenced
|
||||
// document or folder cascades the linkage row in those join tables.
|
||||
func TestMigration131_ScopeTablesCascade(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
|
||||
const clientID = "MIG131_CSC"
|
||||
resetClientForBot(t, ctx, cfg, clientID, "mig131-csc")
|
||||
|
||||
// Use the sqlc generated CreateFolder/CreateDocument to seed
|
||||
// scope-table fixtures. CreateFolder accepts parentId=NULL because
|
||||
// the migration declares parentId as a nullable FK on folders.
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
t.Run("session_delete_cascades_documents", func(t *testing.T) {
|
||||
// Seed: one session, one document, one bot_session_documents row.
|
||||
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-doc@example.com")
|
||||
|
||||
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "mig131_csc_doc_hash_session",
|
||||
})
|
||||
require.NoError(t, err, "CreateDocument must succeed")
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_session_documents (session_id, document_id) VALUES ($1, $2)
|
||||
`, sessionID, docID)
|
||||
require.NoError(t, err, "INSERT bot_session_documents must succeed")
|
||||
|
||||
// Sanity check the link is present.
|
||||
var linkCount int
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM bot_session_documents
|
||||
WHERE session_id = $1 AND document_id = $2
|
||||
`, sessionID, docID).Scan(&linkCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, linkCount, "link row must exist before delete")
|
||||
|
||||
// Delete the session; the link must vanish via cascade.
|
||||
_, err = pool.Exec(ctx, `DELETE FROM bot_sessions WHERE id = $1`, sessionID)
|
||||
require.NoError(t, err, "DELETE bot_sessions must succeed")
|
||||
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM bot_session_documents WHERE session_id = $1
|
||||
`, sessionID).Scan(&linkCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, linkCount, "link rows must cascade away with parent session")
|
||||
})
|
||||
|
||||
t.Run("session_delete_cascades_folders", func(t *testing.T) {
|
||||
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-fld@example.com")
|
||||
|
||||
folder, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
|
||||
Path: "/csc-folder-session",
|
||||
Parentid: nil,
|
||||
Clientid: clientID,
|
||||
Createdby: "tester",
|
||||
})
|
||||
require.NoError(t, err, "CreateFolder must succeed")
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_session_folders (session_id, folder_id) VALUES ($1, $2)
|
||||
`, sessionID, folder.ID)
|
||||
require.NoError(t, err, "INSERT bot_session_folders must succeed")
|
||||
|
||||
var linkCount int
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM bot_session_folders
|
||||
WHERE session_id = $1 AND folder_id = $2
|
||||
`, sessionID, folder.ID).Scan(&linkCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, linkCount, "folder link must exist before delete")
|
||||
|
||||
_, err = pool.Exec(ctx, `DELETE FROM bot_sessions WHERE id = $1`, sessionID)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM bot_session_folders WHERE session_id = $1
|
||||
`, sessionID).Scan(&linkCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, linkCount, "folder link must cascade away with parent session")
|
||||
})
|
||||
|
||||
t.Run("document_delete_cascades_link", func(t *testing.T) {
|
||||
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-doc2@example.com")
|
||||
|
||||
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientID,
|
||||
Hash: "mig131_csc_doc_hash_doc",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_session_documents (session_id, document_id) VALUES ($1, $2)
|
||||
`, sessionID, docID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Delete the document; the link row must cascade.
|
||||
_, err = pool.Exec(ctx, `DELETE FROM documents WHERE id = $1`, docID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var linkCount int
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM bot_session_documents WHERE document_id = $1
|
||||
`, docID).Scan(&linkCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, linkCount,
|
||||
"deleting referenced document must cascade away the link row")
|
||||
})
|
||||
|
||||
t.Run("folder_delete_cascades_link", func(t *testing.T) {
|
||||
sessionID := seedBotSession(t, ctx, cfg, clientID, "csc-fld2@example.com")
|
||||
|
||||
folder, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
|
||||
Path: "/csc-folder-folder",
|
||||
Parentid: nil,
|
||||
Clientid: clientID,
|
||||
Createdby: "tester",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO bot_session_folders (session_id, folder_id) VALUES ($1, $2)
|
||||
`, sessionID, folder.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = pool.Exec(ctx, `DELETE FROM folders WHERE id = $1`, folder.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var linkCount int
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT COUNT(*) FROM bot_session_folders WHERE folder_id = $1
|
||||
`, folder.ID).Scan(&linkCount)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 0, linkCount,
|
||||
"deleting referenced folder must cascade away the link row")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,921 @@
|
||||
// Milestone 1 §M1 chatbot repository tests.
|
||||
//
|
||||
// These tests are the authoritative behavioral spec for the sqlc-generated
|
||||
// bot.sql query layer (plan §5) and the new client-scoped document/folder
|
||||
// queries (plan §5 again, the "client-scoped" bullet list).
|
||||
//
|
||||
// Compile contract: this file references the generated method names and
|
||||
// parameter struct names exactly as plan §5 specifies. Until backend-eng
|
||||
// adds internal/database/queries/bot.sql plus the client-scoped variants
|
||||
// in document.sql/folders.sql/custommetadata.sql/labels.sql and re-runs
|
||||
// `task db:generate`, the file fails to compile. That is the failing
|
||||
// state we want — the team-lead M1 dispatch explicitly endorsed it.
|
||||
//
|
||||
// All tests use:
|
||||
// - real Postgres via internal/test.CreateDB
|
||||
// - per-test client/session/turn rows seeded with raw SQL where the
|
||||
// sqlc layer is not yet available, otherwise via cfg.GetDBQueries()
|
||||
// - explicit clock arithmetic for grace-window assertions instead of
|
||||
// time.Sleep, so the test does not flake under slow CI
|
||||
//
|
||||
// No mocks. No t.Skip beyond testing.Short. No commented-out tests.
|
||||
package repository_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"queryorchestration/internal/database/repository"
|
||||
"queryorchestration/internal/serviceconfig"
|
||||
"queryorchestration/internal/test"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// botStatusInFlight etc. mirror the status string set defined by the
|
||||
// bot_turns_status_values CHECK constraint in plan §4. Defined here so
|
||||
// negative-path tests don't accidentally drift from the migration.
|
||||
const (
|
||||
botStatusInFlight = "in_flight"
|
||||
botStatusCompleted = "completed"
|
||||
botStatusErrored = "errored"
|
||||
botStatusAbandoned = "abandoned"
|
||||
botStatusSessionDeleted = "session_deleted"
|
||||
)
|
||||
|
||||
// resetBotClient mirrors resetClientForBot in bot_migration_test.go but
|
||||
// is package-local to bot_repository_test.go so the two test files do
|
||||
// not silently share helpers across compilation boundaries. Same idempotency
|
||||
// dance: documents first (no cascade from clients), then clients (cascade
|
||||
// wipes bot_sessions and the join tables).
|
||||
func resetBotClient(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, name string) {
|
||||
t.Helper()
|
||||
pool := cfg.GetDBPool()
|
||||
_, err := pool.Exec(ctx, `DELETE FROM documents WHERE clientId = $1`, clientID)
|
||||
require.NoError(t, err, "pre-test DELETE documents must succeed")
|
||||
_, err = pool.Exec(ctx, `DELETE FROM clients WHERE clientId = $1`, clientID)
|
||||
require.NoError(t, err, "pre-test DELETE clients must succeed")
|
||||
err = cfg.GetDBQueries().CreateClient(ctx, &repository.CreateClientParams{
|
||||
Name: name,
|
||||
Clientid: clientID,
|
||||
})
|
||||
require.NoError(t, err, "CreateClient(%s) must succeed", clientID)
|
||||
}
|
||||
|
||||
// seedSession inserts one bot_sessions row with the requested
|
||||
// (client, created_by, is_deleted) and returns its id.
|
||||
func seedSession(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, clientID, createdBy string, isDeleted bool) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := cfg.GetDBPool().QueryRow(ctx, `
|
||||
INSERT INTO bot_sessions (client_id, created_by, is_deleted)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING id
|
||||
`, clientID, createdBy, isDeleted).Scan(&id)
|
||||
require.NoError(t, err, "seed bot_sessions must succeed")
|
||||
return id
|
||||
}
|
||||
|
||||
// seedTurn inserts one bot_turns row with the given status and ordinal,
|
||||
// minting a fresh attempt_id. attemptStartedAt drives the grace-window
|
||||
// abandonment test directly instead of relying on time.Sleep.
|
||||
func seedTurn(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, sessionID uuid.UUID, ordinal int, status string, attemptStartedAt time.Time) uuid.UUID {
|
||||
t.Helper()
|
||||
attemptID := uuid.New()
|
||||
pool := cfg.GetDBPool()
|
||||
|
||||
switch status {
|
||||
case botStatusInFlight:
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6)
|
||||
`, sessionID, ordinal, "prompt", status, attemptID, attemptStartedAt)
|
||||
require.NoError(t, err, "seed in_flight turn must succeed")
|
||||
case botStatusCompleted:
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at, completion)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, 'answer')
|
||||
`, sessionID, ordinal, "prompt", status, attemptID, attemptStartedAt)
|
||||
require.NoError(t, err, "seed completed turn must succeed")
|
||||
case botStatusErrored, botStatusAbandoned, botStatusSessionDeleted:
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id, attempt_started_at, created_at, error)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $6, $7::jsonb)
|
||||
`, sessionID, ordinal, "prompt", status, attemptID, attemptStartedAt,
|
||||
`{"code":"x","message":"y"}`)
|
||||
require.NoError(t, err, "seed %s turn must succeed", status)
|
||||
default:
|
||||
t.Fatalf("seedTurn: unknown status %q", status)
|
||||
}
|
||||
return attemptID
|
||||
}
|
||||
|
||||
// fetchTurnStatus returns the current status of (sessionID, ordinal).
|
||||
// Helper used by retry-reset and abandonment tests.
|
||||
func fetchTurnStatus(t *testing.T, ctx context.Context, cfg *serviceconfig.BaseConfig, sessionID uuid.UUID, ordinal int) string {
|
||||
t.Helper()
|
||||
var status string
|
||||
err := cfg.GetDBPool().QueryRow(ctx, `
|
||||
SELECT status FROM bot_turns WHERE session_id = $1 AND ordinal = $2
|
||||
`, sessionID, ordinal).Scan(&status)
|
||||
require.NoError(t, err)
|
||||
return status
|
||||
}
|
||||
|
||||
// TestLockBotSessionForOwner_DerivedOrdinals proves the derived columns
|
||||
// last_seen_ordinal and last_terminal_ordinal in plan §5's owner-lock
|
||||
// query are computed correctly across a session whose turns include
|
||||
// in_flight + completed + errored + abandoned at known ordinals.
|
||||
//
|
||||
// Layout: ordinal 1 completed, 2 errored, 3 abandoned, 4 in_flight.
|
||||
// last_seen_ordinal must be 4 (the MAX). last_terminal_ordinal must be 3
|
||||
// (the MAX where status <> 'in_flight').
|
||||
func TestLockBotSessionForOwner_DerivedOrdinals(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientID = "BOT_REPO_LOCK"
|
||||
actor = "u1@example.com"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientID, "bot-repo-lock")
|
||||
sessionID := seedSession(t, ctx, cfg, clientID, actor, false)
|
||||
|
||||
now := time.Now().UTC()
|
||||
seedTurn(t, ctx, cfg, sessionID, 1, botStatusCompleted, now.Add(-30*time.Minute))
|
||||
seedTurn(t, ctx, cfg, sessionID, 2, botStatusErrored, now.Add(-20*time.Minute))
|
||||
seedTurn(t, ctx, cfg, sessionID, 3, botStatusAbandoned, now.Add(-10*time.Minute))
|
||||
seedTurn(t, ctx, cfg, sessionID, 4, botStatusInFlight, now.Add(-1*time.Minute))
|
||||
|
||||
// LockBotSessionForOwner is FOR UPDATE — must run inside a tx.
|
||||
tx, err := pool.Begin(ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
txQueries := queries.WithTx(tx)
|
||||
|
||||
row, err := txQueries.LockBotSessionForOwner(ctx, &repository.LockBotSessionForOwnerParams{
|
||||
SessionID: sessionID,
|
||||
ClientID: clientID,
|
||||
CreatedBy: actor,
|
||||
})
|
||||
require.NoError(t, err, "LockBotSessionForOwner must return one row when (client, owner) match")
|
||||
require.NotNil(t, row)
|
||||
|
||||
require.Equal(t, sessionID, row.ID)
|
||||
require.Equal(t, clientID, row.ClientID)
|
||||
require.Equal(t, actor, row.CreatedBy)
|
||||
require.Equal(t, int32(4), row.LastSeenOrdinal,
|
||||
"last_seen_ordinal must be MAX(ordinal) = 4")
|
||||
require.Equal(t, int32(3), row.LastTerminalOrdinal,
|
||||
"last_terminal_ordinal must be MAX(ordinal) where status <> in_flight = 3")
|
||||
}
|
||||
|
||||
// TestLockBotSessionForOwner_CrossClientRejected enumerates the four
|
||||
// cells of the (lookup_client, lookup_user) matrix that must miss the
|
||||
// session created by (clientA, U1):
|
||||
//
|
||||
// (clientB, U1) -> 0 rows (cross-client, same user)
|
||||
// (clientA, U2) -> 0 rows (same client, different user)
|
||||
// (clientB, U2) -> 0 rows (cross-client and cross-user)
|
||||
//
|
||||
// (clientA, U1) is the positive control covered by DerivedOrdinals; not
|
||||
// repeated here to keep the table tight.
|
||||
func TestLockBotSessionForOwner_CrossClientRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientA = "BOT_REPO_X_A"
|
||||
clientB = "BOT_REPO_X_B"
|
||||
userU1 = "u1@example.com"
|
||||
userU2 = "u2@example.com"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-x-a")
|
||||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-x-b")
|
||||
|
||||
sessionID := seedSession(t, ctx, cfg, clientA, userU1, false)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
clientID string
|
||||
createdBy string
|
||||
}{
|
||||
{name: "cross_client_same_user", clientID: clientB, createdBy: userU1},
|
||||
{name: "same_client_other_user", clientID: clientA, createdBy: userU2},
|
||||
{name: "cross_client_other_user", clientID: clientB, createdBy: userU2},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
tx, err := pool.Begin(ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
txQueries := queries.WithTx(tx)
|
||||
|
||||
row, err := txQueries.LockBotSessionForOwner(ctx, &repository.LockBotSessionForOwnerParams{
|
||||
SessionID: sessionID,
|
||||
ClientID: c.clientID,
|
||||
CreatedBy: c.createdBy,
|
||||
})
|
||||
require.Errorf(t, err,
|
||||
"lookup with (clientID=%s, createdBy=%s) must return ErrNoRows", c.clientID, c.createdBy)
|
||||
require.ErrorIs(t, err, pgx.ErrNoRows,
|
||||
"want pgx.ErrNoRows, got %T: %v", err, err)
|
||||
_ = row
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestLockBotSessionForOwner_DeletedExcluded proves that a session with
|
||||
// is_deleted = true is invisible to LockBotSessionForOwner even when the
|
||||
// caller would otherwise be the rightful owner. Plan §5: WHERE clause
|
||||
// must include `is_deleted = false`.
|
||||
func TestLockBotSessionForOwner_DeletedExcluded(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientID = "BOT_REPO_DEL"
|
||||
actor = "deleted-owner@example.com"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientID, "bot-repo-del")
|
||||
sessionID := seedSession(t, ctx, cfg, clientID, actor, true)
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
txQueries := queries.WithTx(tx)
|
||||
|
||||
row, err := txQueries.LockBotSessionForOwner(ctx, &repository.LockBotSessionForOwnerParams{
|
||||
SessionID: sessionID,
|
||||
ClientID: clientID,
|
||||
CreatedBy: actor,
|
||||
})
|
||||
require.Error(t, err, "is_deleted=true session must not be returned")
|
||||
require.ErrorIs(t, err, pgx.ErrNoRows)
|
||||
_ = row
|
||||
}
|
||||
|
||||
// TestAbandonExpiredInflightForOwner_OwnerOnly proves the EXISTS guard
|
||||
// in plan §5: AbandonExpiredInflightForOwner mutates only when the
|
||||
// caller's (client_id, created_by) match the session row. Cross-client
|
||||
// or cross-user calls are no-ops on the same DB state.
|
||||
func TestAbandonExpiredInflightForOwner_OwnerOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientA = "BOT_REPO_AB_A"
|
||||
clientB = "BOT_REPO_AB_B"
|
||||
userU1 = "u1@example.com"
|
||||
userU2 = "u2@example.com"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-ab-a")
|
||||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-ab-b")
|
||||
|
||||
sessionID := seedSession(t, ctx, cfg, clientA, userU1, false)
|
||||
// Seed an expired in_flight: attempt_started_at is one hour old.
|
||||
seedTurn(t, ctx, cfg, sessionID, 1, botStatusInFlight, time.Now().UTC().Add(-1*time.Hour))
|
||||
|
||||
// Grace window: 60 seconds. Anything older than 60s must be eligible.
|
||||
const graceSeconds = 60
|
||||
|
||||
t.Run("cross_client_no_op", func(t *testing.T) {
|
||||
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
|
||||
SessionID: sessionID,
|
||||
ClientID: clientB, // wrong client
|
||||
CreatedBy: userU1,
|
||||
GraceSeconds: graceSeconds,
|
||||
})
|
||||
require.NoError(t, err, "abandon must not error when EXISTS guard fails")
|
||||
require.Equal(t, botStatusInFlight,
|
||||
fetchTurnStatus(t, ctx, cfg, sessionID, 1),
|
||||
"row must remain in_flight when client mismatch")
|
||||
})
|
||||
|
||||
t.Run("cross_user_no_op", func(t *testing.T) {
|
||||
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
|
||||
SessionID: sessionID,
|
||||
ClientID: clientA,
|
||||
CreatedBy: userU2, // wrong user
|
||||
GraceSeconds: graceSeconds,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, botStatusInFlight,
|
||||
fetchTurnStatus(t, ctx, cfg, sessionID, 1),
|
||||
"row must remain in_flight when user mismatch")
|
||||
})
|
||||
|
||||
t.Run("rightful_owner_abandons", func(t *testing.T) {
|
||||
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
|
||||
SessionID: sessionID,
|
||||
ClientID: clientA,
|
||||
CreatedBy: userU1,
|
||||
GraceSeconds: graceSeconds,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, botStatusAbandoned,
|
||||
fetchTurnStatus(t, ctx, cfg, sessionID, 1),
|
||||
"rightful owner must abandon expired in_flight row")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAbandonExpiredInflightForOwner_GraceWindow proves the grace-window
|
||||
// boundary: rows with attempt_started_at newer than NOW() - grace are NOT
|
||||
// abandoned; older rows are. Uses two sibling sessions so each row is
|
||||
// unambiguously identified by session_id.
|
||||
func TestAbandonExpiredInflightForOwner_GraceWindow(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientID = "BOT_REPO_GR"
|
||||
actor = "owner@example.com"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientID, "bot-repo-gr")
|
||||
|
||||
now := time.Now().UTC()
|
||||
freshSession := seedSession(t, ctx, cfg, clientID, actor, false)
|
||||
expiredSession := seedSession(t, ctx, cfg, clientID, actor, false)
|
||||
|
||||
// Grace = 120 seconds; fresh row is 30s old, expired row is 600s old.
|
||||
const graceSeconds = 120
|
||||
seedTurn(t, ctx, cfg, freshSession, 1, botStatusInFlight, now.Add(-30*time.Second))
|
||||
seedTurn(t, ctx, cfg, expiredSession, 1, botStatusInFlight, now.Add(-600*time.Second))
|
||||
|
||||
// Two abandon calls — one per session. The fresh-session call must be
|
||||
// a no-op; the expired-session call must mutate the row.
|
||||
for _, sessionID := range []uuid.UUID{freshSession, expiredSession} {
|
||||
err := queries.AbandonExpiredInflightForOwner(ctx, &repository.AbandonExpiredInflightForOwnerParams{
|
||||
SessionID: sessionID,
|
||||
ClientID: clientID,
|
||||
CreatedBy: actor,
|
||||
GraceSeconds: graceSeconds,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
require.Equal(t, botStatusInFlight,
|
||||
fetchTurnStatus(t, ctx, cfg, freshSession, 1),
|
||||
"fresh row (within grace) must remain in_flight")
|
||||
require.Equal(t, botStatusAbandoned,
|
||||
fetchTurnStatus(t, ctx, cfg, expiredSession, 1),
|
||||
"expired row (outside grace) must be abandoned")
|
||||
}
|
||||
|
||||
// TestResetBotTurnForRetry_OnlyTerminal proves plan §5: the retry reset
|
||||
// resets to in_flight only for rows in (errored, abandoned). Rows with
|
||||
// status in (in_flight, completed, session_deleted) must be no-ops.
|
||||
func TestResetBotTurnForRetry_OnlyTerminal(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientID = "BOT_REPO_RR"
|
||||
actor = "retry@example.com"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientID, "bot-repo-rr")
|
||||
|
||||
type tc struct {
|
||||
name string
|
||||
seededStatus string
|
||||
expectReset bool
|
||||
expectedStatus string
|
||||
}
|
||||
|
||||
cases := []tc{
|
||||
{name: "errored_resets", seededStatus: botStatusErrored, expectReset: true, expectedStatus: botStatusInFlight},
|
||||
{name: "abandoned_resets", seededStatus: botStatusAbandoned, expectReset: true, expectedStatus: botStatusInFlight},
|
||||
{name: "in_flight_no_op", seededStatus: botStatusInFlight, expectReset: false, expectedStatus: botStatusInFlight},
|
||||
{name: "completed_no_op", seededStatus: botStatusCompleted, expectReset: false, expectedStatus: botStatusCompleted},
|
||||
{name: "session_deleted_no_op", seededStatus: botStatusSessionDeleted, expectReset: false, expectedStatus: botStatusSessionDeleted},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
// Each subtest gets its own session so the partial unique
|
||||
// index on bot_turns(session_id) WHERE status='in_flight'
|
||||
// does not interfere across cases.
|
||||
sessionID := seedSession(t, ctx, cfg, clientID, actor, false)
|
||||
seededAt := time.Now().UTC().Add(-1 * time.Hour) // far in the past
|
||||
seedTurn(t, ctx, cfg, sessionID, 1, c.seededStatus, seededAt)
|
||||
|
||||
// Snapshot original timestamps so we can prove created_at and
|
||||
// attempt_started_at advance only when the reset is supposed
|
||||
// to fire.
|
||||
var origAttemptStartedAt, origCreatedAt time.Time
|
||||
var origAttemptID uuid.UUID
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT attempt_started_at, created_at, attempt_id
|
||||
FROM bot_turns WHERE session_id = $1 AND ordinal = 1
|
||||
`, sessionID).Scan(&origAttemptStartedAt, &origCreatedAt, &origAttemptID)
|
||||
require.NoError(t, err)
|
||||
|
||||
newAttemptID := uuid.New()
|
||||
err = queries.ResetBotTurnForRetry(ctx, &repository.ResetBotTurnForRetryParams{
|
||||
SessionID: sessionID,
|
||||
Ordinal: 1,
|
||||
AttemptID: newAttemptID,
|
||||
})
|
||||
require.NoError(t, err, "ResetBotTurnForRetry must not error in any case")
|
||||
|
||||
// Read back current state.
|
||||
var gotStatus string
|
||||
var gotAttemptID uuid.UUID
|
||||
var gotAttemptStartedAt, gotCreatedAt time.Time
|
||||
err = pool.QueryRow(ctx, `
|
||||
SELECT status, attempt_id, attempt_started_at, created_at
|
||||
FROM bot_turns WHERE session_id = $1 AND ordinal = 1
|
||||
`, sessionID).Scan(&gotStatus, &gotAttemptID, &gotAttemptStartedAt, &gotCreatedAt)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, c.expectedStatus, gotStatus,
|
||||
"status must be %q after reset of seeded %q", c.expectedStatus, c.seededStatus)
|
||||
|
||||
if c.expectReset {
|
||||
require.Equal(t, newAttemptID, gotAttemptID,
|
||||
"reset must replace attempt_id with the caller-provided value")
|
||||
require.Truef(t, gotAttemptStartedAt.After(origAttemptStartedAt),
|
||||
"reset must advance attempt_started_at; orig=%v got=%v",
|
||||
origAttemptStartedAt, gotAttemptStartedAt)
|
||||
require.Truef(t, gotCreatedAt.After(origCreatedAt),
|
||||
"reset must advance created_at; orig=%v got=%v",
|
||||
origCreatedAt, gotCreatedAt)
|
||||
} else {
|
||||
require.Equal(t, origAttemptID, gotAttemptID,
|
||||
"no-op case must not change attempt_id")
|
||||
require.Truef(t, gotAttemptStartedAt.Equal(origAttemptStartedAt),
|
||||
"no-op case must not change attempt_started_at; orig=%v got=%v",
|
||||
origAttemptStartedAt, gotAttemptStartedAt)
|
||||
require.Truef(t, gotCreatedAt.Equal(origCreatedAt),
|
||||
"no-op case must not change created_at; orig=%v got=%v",
|
||||
origCreatedAt, gotCreatedAt)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestListLastTurnsForSessions_TurnLimit proves plan §5's preview query
|
||||
// returns at most turn_limit most-recent turns per session, ordered
|
||||
// session_id, ordinal DESC.
|
||||
//
|
||||
// Layout: 4 sessions × 7 turns each (ordinals 1..7), all completed.
|
||||
// turn_limit = 3 must yield exactly 12 rows (4 × 3), and within each
|
||||
// session the rows must be ordinals (7, 6, 5).
|
||||
func TestListLastTurnsForSessions_TurnLimit(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientID = "BOT_REPO_LIST"
|
||||
actor = "list@example.com"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientID, "bot-repo-list")
|
||||
|
||||
sessions := make([]uuid.UUID, 4)
|
||||
now := time.Now().UTC()
|
||||
for i := range sessions {
|
||||
sessions[i] = seedSession(t, ctx, cfg, clientID, actor, false)
|
||||
for ord := 1; ord <= 7; ord++ {
|
||||
// Stagger created_at by one second per turn so any future
|
||||
// secondary ordering by created_at is also deterministic.
|
||||
seedTurn(t, ctx, cfg, sessions[i], ord, botStatusCompleted,
|
||||
now.Add(time.Duration(ord)*time.Second))
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := queries.ListLastTurnsForSessions(ctx, &repository.ListLastTurnsForSessionsParams{
|
||||
SessionIds: sessions,
|
||||
TurnLimit: 3,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, 4*3, "must return exactly turn_limit rows per session")
|
||||
|
||||
// Bucket per session, then assert each bucket holds the top 3
|
||||
// ordinals in DESC order.
|
||||
bySession := map[uuid.UUID][]int32{}
|
||||
for _, r := range rows {
|
||||
bySession[r.SessionID] = append(bySession[r.SessionID], r.Ordinal)
|
||||
}
|
||||
require.Len(t, bySession, 4, "all four sessions must appear in the result")
|
||||
for _, sid := range sessions {
|
||||
got := bySession[sid]
|
||||
require.Equal(t, []int32{7, 6, 5}, got,
|
||||
"session %s must yield ordinals (7,6,5) in DESC order; got %v", sid, got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetDocumentEnrichedForClient_CrossClient proves the client-scoped
|
||||
// document enriched read returns no row for a cross-client lookup.
|
||||
// Plan §5 mandates a clientId filter on every document/folder read so
|
||||
// the chatbot scope cannot leak documents across clients.
|
||||
func TestGetDocumentEnrichedForClient_CrossClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientA = "BOT_REPO_DOC_A"
|
||||
clientB = "BOT_REPO_DOC_B"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-doc-a")
|
||||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-doc-b")
|
||||
|
||||
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientA,
|
||||
Hash: "bot_repo_doc_hash_enriched",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("same_client_returns_row", func(t *testing.T) {
|
||||
row, err := queries.GetDocumentEnrichedForClient(ctx, &repository.GetDocumentEnrichedForClientParams{
|
||||
ID: docID,
|
||||
ClientID: clientA,
|
||||
})
|
||||
require.NoError(t, err, "same-client lookup must succeed")
|
||||
require.NotNil(t, row)
|
||||
require.Equal(t, docID, row.ID)
|
||||
require.Equal(t, clientA, row.Clientid)
|
||||
})
|
||||
|
||||
t.Run("cross_client_returns_no_rows", func(t *testing.T) {
|
||||
_, err := queries.GetDocumentEnrichedForClient(ctx, &repository.GetDocumentEnrichedForClientParams{
|
||||
ID: docID,
|
||||
ClientID: clientB,
|
||||
})
|
||||
require.Error(t, err, "cross-client lookup must miss")
|
||||
require.ErrorIs(t, err, pgx.ErrNoRows)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetDocumentLabelsForClient_CrossClient mirrors the document
|
||||
// enriched test for the labels endpoint. Empty result on cross-client
|
||||
// lookup, populated result on same-client.
|
||||
func TestGetDocumentLabelsForClient_CrossClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientA = "BOT_REPO_LBL_A"
|
||||
clientB = "BOT_REPO_LBL_B"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-lbl-a")
|
||||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-lbl-b")
|
||||
|
||||
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientA,
|
||||
Hash: "bot_repo_doc_hash_labels",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = queries.ApplyLabel(ctx, &repository.ApplyLabelParams{
|
||||
Documentid: docID,
|
||||
Label: "Ingested", // seeded by migration 110
|
||||
Appliedby: "tester",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("same_client_returns_labels", func(t *testing.T) {
|
||||
labels, err := queries.GetDocumentLabelsForClient(ctx, &repository.GetDocumentLabelsForClientParams{
|
||||
DocumentID: docID,
|
||||
ClientID: clientA,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, labels, 1, "must return the one applied label")
|
||||
})
|
||||
|
||||
t.Run("cross_client_returns_empty", func(t *testing.T) {
|
||||
labels, err := queries.GetDocumentLabelsForClient(ctx, &repository.GetDocumentLabelsForClientParams{
|
||||
DocumentID: docID,
|
||||
ClientID: clientB,
|
||||
})
|
||||
require.NoError(t, err, ":many cross-client must return empty slice, not error")
|
||||
require.Empty(t, labels, "cross-client must return no labels")
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetDocumentCustomSchemaIdForClient_CrossClient: same-client returns
|
||||
// the bound schema id (or NULL); cross-client returns no row.
|
||||
func TestGetDocumentCustomSchemaIdForClient_CrossClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientA = "BOT_REPO_CSI_A"
|
||||
clientB = "BOT_REPO_CSI_B"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-csi-a")
|
||||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-csi-b")
|
||||
|
||||
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientA,
|
||||
Hash: "bot_repo_doc_hash_csi",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("same_client_returns_row", func(t *testing.T) {
|
||||
schemaID, err := queries.GetDocumentCustomSchemaIdForClient(ctx, &repository.GetDocumentCustomSchemaIdForClientParams{
|
||||
ID: docID,
|
||||
ClientID: clientA,
|
||||
})
|
||||
require.NoError(t, err, "same-client lookup must succeed even when schema is NULL")
|
||||
// schemaID is *uuid.UUID by sqlc convention for nullable cols;
|
||||
// document has no schema bound, so the value must be nil.
|
||||
require.Nil(t, schemaID,
|
||||
"unbound document must report nil custom_schema_id, got %v", schemaID)
|
||||
})
|
||||
|
||||
t.Run("cross_client_returns_no_rows", func(t *testing.T) {
|
||||
_, err := queries.GetDocumentCustomSchemaIdForClient(ctx, &repository.GetDocumentCustomSchemaIdForClientParams{
|
||||
ID: docID,
|
||||
ClientID: clientB,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, pgx.ErrNoRows)
|
||||
})
|
||||
}
|
||||
|
||||
// TestCurrentDocMetadataForClient_CrossClient: same-client returns the
|
||||
// most recent metadata row; cross-client returns no row.
|
||||
//
|
||||
// Renamed from TestGetCurrentDocumentCustomMetadataForClient_CrossClient
|
||||
// because Postgres NAMEDATALEN caps identifiers at 63 chars; the
|
||||
// internal/test.GetAlias helper concatenates `postgrestest` with the
|
||||
// test name and the original 52-char name pushed the alias to 64 chars.
|
||||
// Postgres truncates silently and rerun resolves to a stale leftover DB.
|
||||
// Shorter name keeps the alias at 55 chars, well under the cap.
|
||||
func TestCurrentDocMetadataForClient_CrossClient(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientA = "BOT_REPO_CMD_A"
|
||||
clientB = "BOT_REPO_CMD_B"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-cmd-a")
|
||||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-cmd-b")
|
||||
|
||||
docID, err := queries.CreateDocument(ctx, &repository.CreateDocumentParams{
|
||||
Clientid: clientA,
|
||||
Hash: "bot_repo_doc_hash_meta",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Seed a metadata row with no schema bound (custom_schema_id NULL is
|
||||
// permitted on document_custom_metadata per migration 129; the schema
|
||||
// join in the query returns NULL for schemaName/schemaVersion).
|
||||
_, err = queries.CreateDocumentCustomMetadata(ctx, &repository.CreateDocumentCustomMetadataParams{
|
||||
DocumentID: docID,
|
||||
Metadata: []byte(`{"k":"v"}`),
|
||||
Version: 1,
|
||||
CreatedBy: "tester",
|
||||
})
|
||||
require.NoError(t, err, "seed CreateDocumentCustomMetadata must succeed")
|
||||
|
||||
t.Run("same_client_returns_row", func(t *testing.T) {
|
||||
row, err := queries.GetCurrentDocumentCustomMetadataForClient(ctx, &repository.GetCurrentDocumentCustomMetadataForClientParams{
|
||||
DocumentID: docID,
|
||||
ClientID: clientA,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, row)
|
||||
require.Equal(t, docID, row.DocumentID)
|
||||
})
|
||||
|
||||
t.Run("cross_client_returns_no_rows", func(t *testing.T) {
|
||||
_, err := queries.GetCurrentDocumentCustomMetadataForClient(ctx, &repository.GetCurrentDocumentCustomMetadataForClientParams{
|
||||
DocumentID: docID,
|
||||
ClientID: clientB,
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, pgx.ErrNoRows)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGetDocumentIDsInFolderTreeForClient_FolderRecursion proves the
|
||||
// recursive folder tree walk filters clientId at every level. Layout:
|
||||
//
|
||||
// client A folders: client B folders:
|
||||
// /A /B
|
||||
// /A/sub (parent=/A) /B/sub (parent=/B)
|
||||
//
|
||||
// Documents:
|
||||
//
|
||||
// docA1 in /A
|
||||
// docA2 in /A/sub
|
||||
// docB1 in /B
|
||||
// docB2 in /B/sub
|
||||
//
|
||||
// Calling GetDocumentIDsInFolderTreeForClient(rootFolder=/A, client=A)
|
||||
// must return {docA1, docA2}; calling it with (/A, client=B) must
|
||||
// return empty even though folder /A exists, because the clientId
|
||||
// filter at the recursive step rejects the cross-client root.
|
||||
func TestGetDocumentIDsInFolderTreeForClient_FolderRecursion(t *testing.T) {
|
||||
t.Parallel()
|
||||
if testing.Short() {
|
||||
t.SkipNow()
|
||||
}
|
||||
ctx := t.Context()
|
||||
|
||||
cfg := &serviceconfig.BaseConfig{}
|
||||
test.CreateDB(t, cfg)
|
||||
pool := cfg.GetDBPool()
|
||||
require.NotNil(t, pool)
|
||||
queries := cfg.GetDBQueries()
|
||||
require.NotNil(t, queries)
|
||||
|
||||
const (
|
||||
clientA = "BOT_REPO_FT_A"
|
||||
clientB = "BOT_REPO_FT_B"
|
||||
)
|
||||
resetBotClient(t, ctx, cfg, clientA, "bot-repo-ft-a")
|
||||
resetBotClient(t, ctx, cfg, clientB, "bot-repo-ft-b")
|
||||
|
||||
// Folder tree for client A.
|
||||
rootA, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
|
||||
Path: "/ft-a-root",
|
||||
Parentid: nil,
|
||||
Clientid: clientA,
|
||||
Createdby: "tester",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
subA, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
|
||||
Path: "/ft-a-root/sub",
|
||||
Parentid: &rootA.ID,
|
||||
Clientid: clientA,
|
||||
Createdby: "tester",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Folder tree for client B.
|
||||
rootB, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
|
||||
Path: "/ft-b-root",
|
||||
Parentid: nil,
|
||||
Clientid: clientB,
|
||||
Createdby: "tester",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
subB, err := queries.CreateFolder(ctx, &repository.CreateFolderParams{
|
||||
Path: "/ft-b-root/sub",
|
||||
Parentid: &rootB.ID,
|
||||
Clientid: clientB,
|
||||
Createdby: "tester",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Documents in each folder. The folder column is camelCase per plan §4.
|
||||
mkDoc := func(t *testing.T, clientID string, folderID uuid.UUID, hash string) uuid.UUID {
|
||||
t.Helper()
|
||||
var id uuid.UUID
|
||||
err := pool.QueryRow(ctx, `
|
||||
INSERT INTO documents (clientId, hash, folderId)
|
||||
VALUES ($1, $2, $3) RETURNING id
|
||||
`, clientID, hash, folderID).Scan(&id)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
docA1 := mkDoc(t, clientA, rootA.ID, "ft_doc_a1")
|
||||
docA2 := mkDoc(t, clientA, subA.ID, "ft_doc_a2")
|
||||
_ = mkDoc(t, clientB, rootB.ID, "ft_doc_b1")
|
||||
_ = mkDoc(t, clientB, subB.ID, "ft_doc_b2")
|
||||
|
||||
t.Run("client_A_root_returns_only_A_docs", func(t *testing.T) {
|
||||
ids, err := queries.GetDocumentIDsInFolderTreeForClient(ctx, &repository.GetDocumentIDsInFolderTreeForClientParams{
|
||||
FolderID: rootA.ID,
|
||||
ClientID: clientA,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.ElementsMatch(t, []uuid.UUID{docA1, docA2}, ids,
|
||||
"folder tree of client A must include both A documents and exclude B documents")
|
||||
})
|
||||
|
||||
t.Run("cross_client_root_returns_empty", func(t *testing.T) {
|
||||
ids, err := queries.GetDocumentIDsInFolderTreeForClient(ctx, &repository.GetDocumentIDsInFolderTreeForClientParams{
|
||||
FolderID: rootA.ID, // A's root folder
|
||||
ClientID: clientB, // queried as client B
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, ids,
|
||||
"cross-client recursion must reject the root and return zero documents")
|
||||
})
|
||||
|
||||
t.Run("client_B_root_returns_only_B_docs", func(t *testing.T) {
|
||||
ids, err := queries.GetDocumentIDsInFolderTreeForClient(ctx, &repository.GetDocumentIDsInFolderTreeForClientParams{
|
||||
FolderID: rootB.ID,
|
||||
ClientID: clientB,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ids, 2, "client B tree must yield exactly two documents")
|
||||
})
|
||||
}
|
||||
@@ -195,6 +195,82 @@ func (q *Queries) GetCurrentDocumentCustomMetadata(ctx context.Context, document
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getCurrentDocumentCustomMetadataForClient = `-- name: GetCurrentDocumentCustomMetadataForClient :one
|
||||
SELECT
|
||||
dcm.id,
|
||||
dcm.document_id,
|
||||
dcm.metadata,
|
||||
dcm.version,
|
||||
dcm.created_at,
|
||||
dcm.created_by,
|
||||
cms.id AS schema_id,
|
||||
cms.name AS schema_name,
|
||||
cms.version AS schema_version
|
||||
FROM document_custom_metadata dcm
|
||||
JOIN documents d ON d.id = dcm.document_id
|
||||
LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id
|
||||
WHERE dcm.document_id = $1
|
||||
AND d.clientId = $2
|
||||
ORDER BY dcm.version DESC
|
||||
LIMIT 1
|
||||
`
|
||||
|
||||
type GetCurrentDocumentCustomMetadataForClientParams struct {
|
||||
DocumentID uuid.UUID `db:"document_id"`
|
||||
ClientID string `db:"client_id"`
|
||||
}
|
||||
|
||||
type GetCurrentDocumentCustomMetadataForClientRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
DocumentID uuid.UUID `db:"document_id"`
|
||||
Metadata []byte `db:"metadata"`
|
||||
Version int32 `db:"version"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at"`
|
||||
CreatedBy string `db:"created_by"`
|
||||
SchemaID *uuid.UUID `db:"schema_id"`
|
||||
SchemaName *string `db:"schema_name"`
|
||||
SchemaVersion *int32 `db:"schema_version"`
|
||||
}
|
||||
|
||||
// Chatbot-scoped variant of GetCurrentDocumentCustomMetadata. Same column
|
||||
// set (the metadata row decorated with schema id/name/version), but the
|
||||
// joined documents row must belong to @client_id; cross-client calls miss
|
||||
// with pgx.ErrNoRows. Plan §5.
|
||||
//
|
||||
// SELECT
|
||||
// dcm.id,
|
||||
// dcm.document_id,
|
||||
// dcm.metadata,
|
||||
// dcm.version,
|
||||
// dcm.created_at,
|
||||
// dcm.created_by,
|
||||
// cms.id AS schema_id,
|
||||
// cms.name AS schema_name,
|
||||
// cms.version AS schema_version
|
||||
// FROM document_custom_metadata dcm
|
||||
// JOIN documents d ON d.id = dcm.document_id
|
||||
// LEFT JOIN client_metadata_schemas cms ON cms.id = d.custom_schema_id
|
||||
// WHERE dcm.document_id = $1
|
||||
// AND d.clientId = $2
|
||||
// ORDER BY dcm.version DESC
|
||||
// LIMIT 1
|
||||
func (q *Queries) GetCurrentDocumentCustomMetadataForClient(ctx context.Context, arg *GetCurrentDocumentCustomMetadataForClientParams) (*GetCurrentDocumentCustomMetadataForClientRow, error) {
|
||||
row := q.db.QueryRow(ctx, getCurrentDocumentCustomMetadataForClient, arg.DocumentID, arg.ClientID)
|
||||
var i GetCurrentDocumentCustomMetadataForClientRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.DocumentID,
|
||||
&i.Metadata,
|
||||
&i.Version,
|
||||
&i.CreatedAt,
|
||||
&i.CreatedBy,
|
||||
&i.SchemaID,
|
||||
&i.SchemaName,
|
||||
&i.SchemaVersion,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getDocumentClientID = `-- name: GetDocumentClientID :one
|
||||
SELECT clientId FROM documents WHERE id = $1
|
||||
`
|
||||
@@ -353,6 +429,34 @@ func (q *Queries) GetDocumentCustomSchemaId(ctx context.Context, id uuid.UUID) (
|
||||
return custom_schema_id, err
|
||||
}
|
||||
|
||||
const getDocumentCustomSchemaIdForClient = `-- name: GetDocumentCustomSchemaIdForClient :one
|
||||
SELECT custom_schema_id
|
||||
FROM documents
|
||||
WHERE id = $1
|
||||
AND clientId = $2
|
||||
`
|
||||
|
||||
type GetDocumentCustomSchemaIdForClientParams struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
ClientID string `db:"client_id"`
|
||||
}
|
||||
|
||||
// Chatbot-scoped variant of GetDocumentCustomSchemaId: adds a clientId
|
||||
// filter so cross-client lookups miss with pgx.ErrNoRows. Same scalar
|
||||
// result type (nullable uuid) as the non-scoped query — only the WHERE
|
||||
// clause differs. Plan §5.
|
||||
//
|
||||
// SELECT custom_schema_id
|
||||
// FROM documents
|
||||
// WHERE id = $1
|
||||
// AND clientId = $2
|
||||
func (q *Queries) GetDocumentCustomSchemaIdForClient(ctx context.Context, arg *GetDocumentCustomSchemaIdForClientParams) (*uuid.UUID, error) {
|
||||
row := q.db.QueryRow(ctx, getDocumentCustomSchemaIdForClient, arg.ID, arg.ClientID)
|
||||
var custom_schema_id *uuid.UUID
|
||||
err := row.Scan(&custom_schema_id)
|
||||
return custom_schema_id, err
|
||||
}
|
||||
|
||||
const getDocumentSchemaBindingForReset = `-- name: GetDocumentSchemaBindingForReset :one
|
||||
SELECT
|
||||
d.custom_schema_id,
|
||||
|
||||
@@ -288,6 +288,81 @@ func (q *Queries) GetDocumentEnriched(ctx context.Context, id uuid.UUID) (*GetDo
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getDocumentEnrichedForClient = `-- name: GetDocumentEnrichedForClient :one
|
||||
SELECT
|
||||
d.id,
|
||||
d.clientId,
|
||||
d.hash,
|
||||
d.folderId,
|
||||
d.filename,
|
||||
d.originalPath,
|
||||
d.file_size_bytes,
|
||||
d.custom_schema_id,
|
||||
EXISTS(
|
||||
SELECT 1 FROM document_custom_metadata
|
||||
WHERE document_id = d.id
|
||||
) AS has_custom_metadata
|
||||
FROM documents d
|
||||
WHERE d.id = $1
|
||||
AND d.clientId = $2
|
||||
`
|
||||
|
||||
type GetDocumentEnrichedForClientParams struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
ClientID string `db:"client_id"`
|
||||
}
|
||||
|
||||
type GetDocumentEnrichedForClientRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Clientid string `db:"clientid"`
|
||||
Hash string `db:"hash"`
|
||||
Folderid *uuid.UUID `db:"folderid"`
|
||||
Filename *string `db:"filename"`
|
||||
Originalpath *string `db:"originalpath"`
|
||||
FileSizeBytes *int64 `db:"file_size_bytes"`
|
||||
CustomSchemaID *uuid.UUID `db:"custom_schema_id"`
|
||||
HasCustomMetadata bool `db:"has_custom_metadata"`
|
||||
}
|
||||
|
||||
// Chatbot-scoped read: same column set as GetDocumentEnriched, but adds a
|
||||
// clientId filter so cross-client lookups miss with pgx.ErrNoRows.
|
||||
// Plan §5 mandates a clientId filter on every chatbot document/folder
|
||||
// read so document scope cannot leak between clients. documents.clientId
|
||||
// is the unquoted (lowercased) varchar column from migration 5.
|
||||
//
|
||||
// SELECT
|
||||
// d.id,
|
||||
// d.clientId,
|
||||
// d.hash,
|
||||
// d.folderId,
|
||||
// d.filename,
|
||||
// d.originalPath,
|
||||
// d.file_size_bytes,
|
||||
// d.custom_schema_id,
|
||||
// EXISTS(
|
||||
// SELECT 1 FROM document_custom_metadata
|
||||
// WHERE document_id = d.id
|
||||
// ) AS has_custom_metadata
|
||||
// FROM documents d
|
||||
// WHERE d.id = $1
|
||||
// AND d.clientId = $2
|
||||
func (q *Queries) GetDocumentEnrichedForClient(ctx context.Context, arg *GetDocumentEnrichedForClientParams) (*GetDocumentEnrichedForClientRow, error) {
|
||||
row := q.db.QueryRow(ctx, getDocumentEnrichedForClient, arg.ID, arg.ClientID)
|
||||
var i GetDocumentEnrichedForClientRow
|
||||
err := row.Scan(
|
||||
&i.ID,
|
||||
&i.Clientid,
|
||||
&i.Hash,
|
||||
&i.Folderid,
|
||||
&i.Filename,
|
||||
&i.Originalpath,
|
||||
&i.FileSizeBytes,
|
||||
&i.CustomSchemaID,
|
||||
&i.HasCustomMetadata,
|
||||
)
|
||||
return &i, err
|
||||
}
|
||||
|
||||
const getDocumentEntry = `-- name: GetDocumentEntry :one
|
||||
SELECT documentId, bucket, key FROM documentEntries WHERE documentId = $1 ORDER BY id DESC LIMIT 1
|
||||
`
|
||||
|
||||
@@ -178,6 +178,75 @@ func (q *Queries) GetDocumentIDsInFolderTree(ctx context.Context, folderID *uuid
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getDocumentIDsInFolderTreeForClient = `-- name: GetDocumentIDsInFolderTreeForClient :many
|
||||
WITH RECURSIVE folder_tree AS (
|
||||
SELECT id FROM folders
|
||||
WHERE id = $2::uuid
|
||||
AND clientId = $1::varchar
|
||||
UNION ALL
|
||||
SELECT f.id FROM folders f
|
||||
JOIN folder_tree ft ON f.parentId = ft.id
|
||||
WHERE f.clientId = $1::varchar
|
||||
)
|
||||
SELECT id FROM documents
|
||||
WHERE folderId IN (SELECT id FROM folder_tree)
|
||||
AND clientId = $1::varchar
|
||||
`
|
||||
|
||||
type GetDocumentIDsInFolderTreeForClientParams struct {
|
||||
ClientID string `db:"client_id"`
|
||||
FolderID uuid.UUID `db:"folder_id"`
|
||||
}
|
||||
|
||||
// Chatbot-scoped recursive folder tree walk. Plan §5 mandates that the
|
||||
// clientId filter applies at every recursive step AND at the final
|
||||
// document selection so a cross-client root cannot leak documents.
|
||||
//
|
||||
// Step-by-step:
|
||||
//
|
||||
// - Base case: select the root folder only when its clientId matches.
|
||||
//
|
||||
// - Recursive case: descend through folders.parentId, requiring the
|
||||
// child folder's clientId to match. A folder owned by a different
|
||||
// client cannot enter the CTE even if its parent did (which it
|
||||
// cannot, because the base case already rejects cross-client roots).
|
||||
//
|
||||
// - Final selection: documents must reside in folder_tree AND share
|
||||
// the same clientId, defending against any future case where a
|
||||
// document row references a folder owned by a different client.
|
||||
//
|
||||
// WITH RECURSIVE folder_tree AS (
|
||||
// SELECT id FROM folders
|
||||
// WHERE id = $2::uuid
|
||||
// AND clientId = $1::varchar
|
||||
// UNION ALL
|
||||
// SELECT f.id FROM folders f
|
||||
// JOIN folder_tree ft ON f.parentId = ft.id
|
||||
// WHERE f.clientId = $1::varchar
|
||||
// )
|
||||
// SELECT id FROM documents
|
||||
// WHERE folderId IN (SELECT id FROM folder_tree)
|
||||
// AND clientId = $1::varchar
|
||||
func (q *Queries) GetDocumentIDsInFolderTreeForClient(ctx context.Context, arg *GetDocumentIDsInFolderTreeForClientParams) ([]uuid.UUID, error) {
|
||||
rows, err := q.db.Query(ctx, getDocumentIDsInFolderTreeForClient, arg.ClientID, arg.FolderID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []uuid.UUID{}
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, id)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getDocumentsByFolder = `-- name: GetDocumentsByFolder :many
|
||||
SELECT id, clientid, hash, batch_id, filename, folderid, originalpath, file_size_bytes, custom_schema_id FROM documents
|
||||
WHERE folderId = $1
|
||||
|
||||
@@ -190,6 +190,58 @@ func (q *Queries) GetDocumentLabels(ctx context.Context, documentid uuid.UUID) (
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getDocumentLabelsForClient = `-- name: GetDocumentLabelsForClient :many
|
||||
SELECT dl.id, dl.documentid, dl.label, dl.appliedat, dl.appliedby
|
||||
FROM documentLabels dl
|
||||
JOIN documents d ON d.id = dl.documentId
|
||||
WHERE dl.documentId = $1
|
||||
AND d.clientId = $2
|
||||
ORDER BY dl.appliedAt DESC
|
||||
`
|
||||
|
||||
type GetDocumentLabelsForClientParams struct {
|
||||
DocumentID uuid.UUID `db:"document_id"`
|
||||
ClientID string `db:"client_id"`
|
||||
}
|
||||
|
||||
// Chatbot-scoped variant of GetDocumentLabels. Joins documents to enforce
|
||||
// the clientId filter so cross-client lookups return an empty slice
|
||||
// rather than another tenant's labels. Returns the same documentLabels
|
||||
// column shape as GetDocumentLabels (not the joined documents row).
|
||||
// Plan §5.
|
||||
//
|
||||
// SELECT dl.id, dl.documentid, dl.label, dl.appliedat, dl.appliedby
|
||||
// FROM documentLabels dl
|
||||
// JOIN documents d ON d.id = dl.documentId
|
||||
// WHERE dl.documentId = $1
|
||||
// AND d.clientId = $2
|
||||
// ORDER BY dl.appliedAt DESC
|
||||
func (q *Queries) GetDocumentLabelsForClient(ctx context.Context, arg *GetDocumentLabelsForClientParams) ([]*Documentlabel, error) {
|
||||
rows, err := q.db.Query(ctx, getDocumentLabelsForClient, arg.DocumentID, arg.ClientID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := []*Documentlabel{}
|
||||
for rows.Next() {
|
||||
var i Documentlabel
|
||||
if err := rows.Scan(
|
||||
&i.ID,
|
||||
&i.Documentid,
|
||||
&i.Label,
|
||||
&i.Appliedat,
|
||||
&i.Appliedby,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, &i)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
const getDocumentsByLabel = `-- name: GetDocumentsByLabel :many
|
||||
SELECT DISTINCT d.id, d.clientid, d.hash, d.batch_id, d.filename, d.folderid, d.originalpath, d.file_size_bytes, d.custom_schema_id
|
||||
FROM documents d
|
||||
|
||||
@@ -366,6 +366,42 @@ type BatchUpload struct {
|
||||
FileSizeBytes *int64 `db:"file_size_bytes"`
|
||||
}
|
||||
|
||||
type BotSession struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
ClientID string `db:"client_id"`
|
||||
CreatedBy string `db:"created_by"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at"`
|
||||
UpdatedAt pgtype.Timestamptz `db:"updated_at"`
|
||||
Title string `db:"title"`
|
||||
State []byte `db:"state"`
|
||||
IsDeleted bool `db:"is_deleted"`
|
||||
}
|
||||
|
||||
type BotSessionDocument struct {
|
||||
SessionID uuid.UUID `db:"session_id"`
|
||||
DocumentID uuid.UUID `db:"document_id"`
|
||||
}
|
||||
|
||||
type BotSessionFolder struct {
|
||||
SessionID uuid.UUID `db:"session_id"`
|
||||
FolderID uuid.UUID `db:"folder_id"`
|
||||
}
|
||||
|
||||
type BotTurn struct {
|
||||
SessionID uuid.UUID `db:"session_id"`
|
||||
Ordinal int32 `db:"ordinal"`
|
||||
Prompt string `db:"prompt"`
|
||||
Completion *string `db:"completion"`
|
||||
Status string `db:"status"`
|
||||
AttemptID uuid.UUID `db:"attempt_id"`
|
||||
AttemptStartedAt pgtype.Timestamptz `db:"attempt_started_at"`
|
||||
CreatedAt pgtype.Timestamptz `db:"created_at"`
|
||||
LatencyMs *int32 `db:"latency_ms"`
|
||||
TokensIn *int32 `db:"tokens_in"`
|
||||
TokensOut *int32 `db:"tokens_out"`
|
||||
Error []byte `db:"error"`
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
Clientid string `db:"clientid"`
|
||||
Name string `db:"name"`
|
||||
|
||||
Reference in New Issue
Block a user