1364 lines
44 KiB
Go
1364 lines
44 KiB
Go
|
|
// Code generated by sqlc. DO NOT EDIT.
|
||
|
|
// versions:
|
||
|
|
// sqlc v1.27.0
|
||
|
|
// source: bot.sql
|
||
|
|
|
||
|
|
package repository
|
||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5/pgtype"
|
||
|
|
)
|
||
|
|
|
||
|
|
const abandonExpiredInflightForOwner = `-- name: AbandonExpiredInflightForOwner :exec
|
||
|
|
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 = $1
|
||
|
|
AND bt.status = 'in_flight'
|
||
|
|
AND bt.attempt_started_at < NOW() - make_interval(secs => $2::int)
|
||
|
|
AND EXISTS (
|
||
|
|
SELECT 1
|
||
|
|
FROM bot_sessions s
|
||
|
|
WHERE s.id = bt.session_id
|
||
|
|
AND s.client_id = $3
|
||
|
|
AND s.created_by = $4
|
||
|
|
AND s.is_deleted = false
|
||
|
|
)
|
||
|
|
`
|
||
|
|
|
||
|
|
type AbandonExpiredInflightForOwnerParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
GraceSeconds int32 `db:"grace_seconds"`
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
CreatedBy string `db:"created_by"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// AND bt.status = 'in_flight'
|
||
|
|
// AND bt.attempt_started_at < NOW() - make_interval(secs => $2::int)
|
||
|
|
// AND EXISTS (
|
||
|
|
// SELECT 1
|
||
|
|
// FROM bot_sessions s
|
||
|
|
// WHERE s.id = bt.session_id
|
||
|
|
// AND s.client_id = $3
|
||
|
|
// AND s.created_by = $4
|
||
|
|
// AND s.is_deleted = false
|
||
|
|
// )
|
||
|
|
func (q *Queries) AbandonExpiredInflightForOwner(ctx context.Context, arg *AbandonExpiredInflightForOwnerParams) error {
|
||
|
|
_, err := q.db.Exec(ctx, abandonExpiredInflightForOwner,
|
||
|
|
arg.SessionID,
|
||
|
|
arg.GraceSeconds,
|
||
|
|
arg.ClientID,
|
||
|
|
arg.CreatedBy,
|
||
|
|
)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
const completeBotTurn = `-- name: CompleteBotTurn :execrows
|
||
|
|
UPDATE bot_turns
|
||
|
|
SET status = 'completed',
|
||
|
|
completion = $1,
|
||
|
|
latency_ms = $2,
|
||
|
|
tokens_in = $3,
|
||
|
|
tokens_out = $4
|
||
|
|
WHERE session_id = $5
|
||
|
|
AND ordinal = $6
|
||
|
|
AND attempt_id = $7
|
||
|
|
AND status = 'in_flight'
|
||
|
|
`
|
||
|
|
|
||
|
|
type CompleteBotTurnParams struct {
|
||
|
|
Completion *string `db:"completion"`
|
||
|
|
LatencyMs *int32 `db:"latency_ms"`
|
||
|
|
TokensIn *int32 `db:"tokens_in"`
|
||
|
|
TokensOut *int32 `db:"tokens_out"`
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
Ordinal int32 `db:"ordinal"`
|
||
|
|
AttemptID uuid.UUID `db:"attempt_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1,
|
||
|
|
// latency_ms = $2,
|
||
|
|
// tokens_in = $3,
|
||
|
|
// tokens_out = $4
|
||
|
|
// WHERE session_id = $5
|
||
|
|
// AND ordinal = $6
|
||
|
|
// AND attempt_id = $7
|
||
|
|
// AND status = 'in_flight'
|
||
|
|
func (q *Queries) CompleteBotTurn(ctx context.Context, arg *CompleteBotTurnParams) (int64, error) {
|
||
|
|
result, err := q.db.Exec(ctx, completeBotTurn,
|
||
|
|
arg.Completion,
|
||
|
|
arg.LatencyMs,
|
||
|
|
arg.TokensIn,
|
||
|
|
arg.TokensOut,
|
||
|
|
arg.SessionID,
|
||
|
|
arg.Ordinal,
|
||
|
|
arg.AttemptID,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
return result.RowsAffected(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const deleteBotSessionDocumentsForSession = `-- name: DeleteBotSessionDocumentsForSession :exec
|
||
|
|
DELETE FROM bot_session_documents
|
||
|
|
WHERE session_id = $1
|
||
|
|
`
|
||
|
|
|
||
|
|
// @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 = $1
|
||
|
|
func (q *Queries) DeleteBotSessionDocumentsForSession(ctx context.Context, sessionID uuid.UUID) error {
|
||
|
|
_, err := q.db.Exec(ctx, deleteBotSessionDocumentsForSession, sessionID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
const deleteBotSessionFoldersForSession = `-- name: DeleteBotSessionFoldersForSession :exec
|
||
|
|
DELETE FROM bot_session_folders
|
||
|
|
WHERE session_id = $1
|
||
|
|
`
|
||
|
|
|
||
|
|
// @sqlc-vet-disable
|
||
|
|
// Wipe the persisted folder scope for a session. Same role as
|
||
|
|
// DeleteBotSessionDocumentsForSession.
|
||
|
|
//
|
||
|
|
// DELETE FROM bot_session_folders
|
||
|
|
// WHERE session_id = $1
|
||
|
|
func (q *Queries) DeleteBotSessionFoldersForSession(ctx context.Context, sessionID uuid.UUID) error {
|
||
|
|
_, err := q.db.Exec(ctx, deleteBotSessionFoldersForSession, sessionID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
const failBotTurn = `-- name: FailBotTurn :execrows
|
||
|
|
UPDATE bot_turns
|
||
|
|
SET status = 'errored',
|
||
|
|
error = $1
|
||
|
|
WHERE session_id = $2
|
||
|
|
AND ordinal = $3
|
||
|
|
AND attempt_id = $4
|
||
|
|
AND status = 'in_flight'
|
||
|
|
`
|
||
|
|
|
||
|
|
type FailBotTurnParams struct {
|
||
|
|
Error []byte `db:"error"`
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
Ordinal int32 `db:"ordinal"`
|
||
|
|
AttemptID uuid.UUID `db:"attempt_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// WHERE session_id = $2
|
||
|
|
// AND ordinal = $3
|
||
|
|
// AND attempt_id = $4
|
||
|
|
// AND status = 'in_flight'
|
||
|
|
func (q *Queries) FailBotTurn(ctx context.Context, arg *FailBotTurnParams) (int64, error) {
|
||
|
|
result, err := q.db.Exec(ctx, failBotTurn,
|
||
|
|
arg.Error,
|
||
|
|
arg.SessionID,
|
||
|
|
arg.Ordinal,
|
||
|
|
arg.AttemptID,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
return result.RowsAffected(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const findBotTurnByPrompt = `-- name: FindBotTurnByPrompt :one
|
||
|
|
SELECT session_id, ordinal, prompt, completion, status
|
||
|
|
FROM bot_turns
|
||
|
|
WHERE session_id = $1
|
||
|
|
AND prompt = $2
|
||
|
|
ORDER BY ordinal ASC
|
||
|
|
LIMIT 1
|
||
|
|
`
|
||
|
|
|
||
|
|
type FindBotTurnByPromptParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
Prompt string `db:"prompt"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type FindBotTurnByPromptRow struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
Ordinal int32 `db:"ordinal"`
|
||
|
|
Prompt string `db:"prompt"`
|
||
|
|
Completion *string `db:"completion"`
|
||
|
|
Status string `db:"status"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// AND prompt = $2
|
||
|
|
// ORDER BY ordinal ASC
|
||
|
|
// LIMIT 1
|
||
|
|
func (q *Queries) FindBotTurnByPrompt(ctx context.Context, arg *FindBotTurnByPromptParams) (*FindBotTurnByPromptRow, error) {
|
||
|
|
row := q.db.QueryRow(ctx, findBotTurnByPrompt, arg.SessionID, arg.Prompt)
|
||
|
|
var i FindBotTurnByPromptRow
|
||
|
|
err := row.Scan(
|
||
|
|
&i.SessionID,
|
||
|
|
&i.Ordinal,
|
||
|
|
&i.Prompt,
|
||
|
|
&i.Completion,
|
||
|
|
&i.Status,
|
||
|
|
)
|
||
|
|
return &i, err
|
||
|
|
}
|
||
|
|
|
||
|
|
const getBotSessionForOwner = `-- name: GetBotSessionForOwner :one
|
||
|
|
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 = $1
|
||
|
|
AND s.client_id = $2
|
||
|
|
AND s.created_by = $3
|
||
|
|
AND s.is_deleted = false
|
||
|
|
`
|
||
|
|
|
||
|
|
type GetBotSessionForOwnerParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
CreatedBy string `db:"created_by"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type GetBotSessionForOwnerRow 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"`
|
||
|
|
LastSeenOrdinal int32 `db:"last_seen_ordinal"`
|
||
|
|
LastTerminalOrdinal int32 `db:"last_terminal_ordinal"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// AND s.client_id = $2
|
||
|
|
// AND s.created_by = $3
|
||
|
|
// AND s.is_deleted = false
|
||
|
|
func (q *Queries) GetBotSessionForOwner(ctx context.Context, arg *GetBotSessionForOwnerParams) (*GetBotSessionForOwnerRow, error) {
|
||
|
|
row := q.db.QueryRow(ctx, getBotSessionForOwner, arg.SessionID, arg.ClientID, arg.CreatedBy)
|
||
|
|
var i GetBotSessionForOwnerRow
|
||
|
|
err := row.Scan(
|
||
|
|
&i.ID,
|
||
|
|
&i.ClientID,
|
||
|
|
&i.CreatedBy,
|
||
|
|
&i.CreatedAt,
|
||
|
|
&i.UpdatedAt,
|
||
|
|
&i.Title,
|
||
|
|
&i.State,
|
||
|
|
&i.IsDeleted,
|
||
|
|
&i.LastSeenOrdinal,
|
||
|
|
&i.LastTerminalOrdinal,
|
||
|
|
)
|
||
|
|
return &i, err
|
||
|
|
}
|
||
|
|
|
||
|
|
const getBotSessionForSuperAdmin = `-- name: GetBotSessionForSuperAdmin :one
|
||
|
|
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 = $1
|
||
|
|
AND s.client_id = $2
|
||
|
|
AND s.is_deleted = false
|
||
|
|
`
|
||
|
|
|
||
|
|
type GetBotSessionForSuperAdminParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type GetBotSessionForSuperAdminRow 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"`
|
||
|
|
LastSeenOrdinal int32 `db:"last_seen_ordinal"`
|
||
|
|
LastTerminalOrdinal int32 `db:"last_terminal_ordinal"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// AND s.client_id = $2
|
||
|
|
// AND s.is_deleted = false
|
||
|
|
func (q *Queries) GetBotSessionForSuperAdmin(ctx context.Context, arg *GetBotSessionForSuperAdminParams) (*GetBotSessionForSuperAdminRow, error) {
|
||
|
|
row := q.db.QueryRow(ctx, getBotSessionForSuperAdmin, arg.SessionID, arg.ClientID)
|
||
|
|
var i GetBotSessionForSuperAdminRow
|
||
|
|
err := row.Scan(
|
||
|
|
&i.ID,
|
||
|
|
&i.ClientID,
|
||
|
|
&i.CreatedBy,
|
||
|
|
&i.CreatedAt,
|
||
|
|
&i.UpdatedAt,
|
||
|
|
&i.Title,
|
||
|
|
&i.State,
|
||
|
|
&i.IsDeleted,
|
||
|
|
&i.LastSeenOrdinal,
|
||
|
|
&i.LastTerminalOrdinal,
|
||
|
|
)
|
||
|
|
return &i, err
|
||
|
|
}
|
||
|
|
|
||
|
|
const getBotTurn = `-- name: GetBotTurn :one
|
||
|
|
|
||
|
|
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 = $1
|
||
|
|
AND ordinal = $2
|
||
|
|
`
|
||
|
|
|
||
|
|
type GetBotTurnParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
Ordinal int32 `db:"ordinal"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- 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).
|
||
|
|
// 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 = $1
|
||
|
|
// AND ordinal = $2
|
||
|
|
func (q *Queries) GetBotTurn(ctx context.Context, arg *GetBotTurnParams) (*BotTurn, error) {
|
||
|
|
row := q.db.QueryRow(ctx, getBotTurn, arg.SessionID, arg.Ordinal)
|
||
|
|
var i BotTurn
|
||
|
|
err := row.Scan(
|
||
|
|
&i.SessionID,
|
||
|
|
&i.Ordinal,
|
||
|
|
&i.Prompt,
|
||
|
|
&i.Completion,
|
||
|
|
&i.Status,
|
||
|
|
&i.AttemptID,
|
||
|
|
&i.AttemptStartedAt,
|
||
|
|
&i.CreatedAt,
|
||
|
|
&i.LatencyMs,
|
||
|
|
&i.TokensIn,
|
||
|
|
&i.TokensOut,
|
||
|
|
&i.Error,
|
||
|
|
)
|
||
|
|
return &i, err
|
||
|
|
}
|
||
|
|
|
||
|
|
const getCompletedTurnsForSessionAscending = `-- name: GetCompletedTurnsForSessionAscending :many
|
||
|
|
|
||
|
|
WITH recent AS (
|
||
|
|
SELECT ordinal, prompt, completion, created_at
|
||
|
|
FROM bot_turns
|
||
|
|
WHERE session_id = $1
|
||
|
|
AND status = 'completed'
|
||
|
|
ORDER BY ordinal DESC
|
||
|
|
LIMIT $2::int
|
||
|
|
)
|
||
|
|
SELECT ordinal, prompt, completion, created_at
|
||
|
|
FROM recent
|
||
|
|
ORDER BY ordinal ASC
|
||
|
|
`
|
||
|
|
|
||
|
|
type GetCompletedTurnsForSessionAscendingParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
TurnLimit int32 `db:"turn_limit"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type GetCompletedTurnsForSessionAscendingRow struct {
|
||
|
|
Ordinal int32 `db:"ordinal"`
|
||
|
|
Prompt string `db:"prompt"`
|
||
|
|
Completion *string `db:"completion"`
|
||
|
|
CreatedAt pgtype.Timestamptz `db:"created_at"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- Milestone M3 queries ----------
|
||
|
|
// 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 = $1
|
||
|
|
// AND status = 'completed'
|
||
|
|
// ORDER BY ordinal DESC
|
||
|
|
// LIMIT $2::int
|
||
|
|
// )
|
||
|
|
// SELECT ordinal, prompt, completion, created_at
|
||
|
|
// FROM recent
|
||
|
|
// ORDER BY ordinal ASC
|
||
|
|
func (q *Queries) GetCompletedTurnsForSessionAscending(ctx context.Context, arg *GetCompletedTurnsForSessionAscendingParams) ([]*GetCompletedTurnsForSessionAscendingRow, error) {
|
||
|
|
rows, err := q.db.Query(ctx, getCompletedTurnsForSessionAscending, arg.SessionID, arg.TurnLimit)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []*GetCompletedTurnsForSessionAscendingRow{}
|
||
|
|
for rows.Next() {
|
||
|
|
var i GetCompletedTurnsForSessionAscendingRow
|
||
|
|
if err := rows.Scan(
|
||
|
|
&i.Ordinal,
|
||
|
|
&i.Prompt,
|
||
|
|
&i.Completion,
|
||
|
|
&i.CreatedAt,
|
||
|
|
); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items = append(items, &i)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const getDocumentClientIDsForBotScope = `-- name: GetDocumentClientIDsForBotScope :many
|
||
|
|
SELECT id, clientId AS client_id
|
||
|
|
FROM documents
|
||
|
|
WHERE id = ANY($1::uuid[])
|
||
|
|
`
|
||
|
|
|
||
|
|
type GetDocumentClientIDsForBotScopeRow struct {
|
||
|
|
ID uuid.UUID `db:"id"`
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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($1::uuid[])
|
||
|
|
func (q *Queries) GetDocumentClientIDsForBotScope(ctx context.Context, documentIds []uuid.UUID) ([]*GetDocumentClientIDsForBotScopeRow, error) {
|
||
|
|
rows, err := q.db.Query(ctx, getDocumentClientIDsForBotScope, documentIds)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []*GetDocumentClientIDsForBotScopeRow{}
|
||
|
|
for rows.Next() {
|
||
|
|
var i GetDocumentClientIDsForBotScopeRow
|
||
|
|
if err := rows.Scan(&i.ID, &i.ClientID); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items = append(items, &i)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const getFolderClientIDsForBotScope = `-- name: GetFolderClientIDsForBotScope :many
|
||
|
|
SELECT id, clientId AS client_id
|
||
|
|
FROM folders
|
||
|
|
WHERE id = ANY($1::uuid[])
|
||
|
|
`
|
||
|
|
|
||
|
|
type GetFolderClientIDsForBotScopeRow struct {
|
||
|
|
ID uuid.UUID `db:"id"`
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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($1::uuid[])
|
||
|
|
func (q *Queries) GetFolderClientIDsForBotScope(ctx context.Context, folderIds []uuid.UUID) ([]*GetFolderClientIDsForBotScopeRow, error) {
|
||
|
|
rows, err := q.db.Query(ctx, getFolderClientIDsForBotScope, folderIds)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []*GetFolderClientIDsForBotScopeRow{}
|
||
|
|
for rows.Next() {
|
||
|
|
var i GetFolderClientIDsForBotScopeRow
|
||
|
|
if err := rows.Scan(&i.ID, &i.ClientID); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items = append(items, &i)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const insertBotSession = `-- name: InsertBotSession :one
|
||
|
|
|
||
|
|
INSERT INTO bot_sessions (client_id, created_by)
|
||
|
|
VALUES ($1, $2)
|
||
|
|
RETURNING id, client_id, created_by, created_at, updated_at, title, state, is_deleted
|
||
|
|
`
|
||
|
|
|
||
|
|
type InsertBotSessionParams struct {
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
CreatedBy string `db:"created_by"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------- 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.
|
||
|
|
// 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 ($1, $2)
|
||
|
|
// RETURNING id, client_id, created_by, created_at, updated_at, title, state, is_deleted
|
||
|
|
func (q *Queries) InsertBotSession(ctx context.Context, arg *InsertBotSessionParams) (*BotSession, error) {
|
||
|
|
row := q.db.QueryRow(ctx, insertBotSession, arg.ClientID, arg.CreatedBy)
|
||
|
|
var i BotSession
|
||
|
|
err := row.Scan(
|
||
|
|
&i.ID,
|
||
|
|
&i.ClientID,
|
||
|
|
&i.CreatedBy,
|
||
|
|
&i.CreatedAt,
|
||
|
|
&i.UpdatedAt,
|
||
|
|
&i.Title,
|
||
|
|
&i.State,
|
||
|
|
&i.IsDeleted,
|
||
|
|
)
|
||
|
|
return &i, err
|
||
|
|
}
|
||
|
|
|
||
|
|
const insertBotSessionDocument = `-- name: InsertBotSessionDocument :exec
|
||
|
|
INSERT INTO bot_session_documents (session_id, document_id)
|
||
|
|
VALUES ($1, $2)
|
||
|
|
ON CONFLICT (session_id, document_id) DO NOTHING
|
||
|
|
`
|
||
|
|
|
||
|
|
type InsertBotSessionDocumentParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
DocumentID uuid.UUID `db:"document_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 ($1, $2)
|
||
|
|
// ON CONFLICT (session_id, document_id) DO NOTHING
|
||
|
|
func (q *Queries) InsertBotSessionDocument(ctx context.Context, arg *InsertBotSessionDocumentParams) error {
|
||
|
|
_, err := q.db.Exec(ctx, insertBotSessionDocument, arg.SessionID, arg.DocumentID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
const insertBotSessionFolder = `-- name: InsertBotSessionFolder :exec
|
||
|
|
INSERT INTO bot_session_folders (session_id, folder_id)
|
||
|
|
VALUES ($1, $2)
|
||
|
|
ON CONFLICT (session_id, folder_id) DO NOTHING
|
||
|
|
`
|
||
|
|
|
||
|
|
type InsertBotSessionFolderParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
FolderID uuid.UUID `db:"folder_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Add one (session_id, folder_id) link.
|
||
|
|
//
|
||
|
|
// INSERT INTO bot_session_folders (session_id, folder_id)
|
||
|
|
// VALUES ($1, $2)
|
||
|
|
// ON CONFLICT (session_id, folder_id) DO NOTHING
|
||
|
|
func (q *Queries) InsertBotSessionFolder(ctx context.Context, arg *InsertBotSessionFolderParams) error {
|
||
|
|
_, err := q.db.Exec(ctx, insertBotSessionFolder, arg.SessionID, arg.FolderID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
const insertBotTurnPrompt = `-- name: InsertBotTurnPrompt :one
|
||
|
|
INSERT INTO bot_turns (session_id, ordinal, prompt, status, attempt_id,
|
||
|
|
attempt_started_at, created_at)
|
||
|
|
VALUES ($1, $2, $3, 'in_flight', $4,
|
||
|
|
NOW(), NOW())
|
||
|
|
RETURNING session_id, ordinal, prompt, completion, status, attempt_id,
|
||
|
|
attempt_started_at, created_at, latency_ms, tokens_in,
|
||
|
|
tokens_out, error
|
||
|
|
`
|
||
|
|
|
||
|
|
type InsertBotTurnPromptParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
Ordinal int32 `db:"ordinal"`
|
||
|
|
Prompt string `db:"prompt"`
|
||
|
|
AttemptID uuid.UUID `db:"attempt_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 ($1, $2, $3, 'in_flight', $4,
|
||
|
|
// NOW(), NOW())
|
||
|
|
// RETURNING session_id, ordinal, prompt, completion, status, attempt_id,
|
||
|
|
// attempt_started_at, created_at, latency_ms, tokens_in,
|
||
|
|
// tokens_out, error
|
||
|
|
func (q *Queries) InsertBotTurnPrompt(ctx context.Context, arg *InsertBotTurnPromptParams) (*BotTurn, error) {
|
||
|
|
row := q.db.QueryRow(ctx, insertBotTurnPrompt,
|
||
|
|
arg.SessionID,
|
||
|
|
arg.Ordinal,
|
||
|
|
arg.Prompt,
|
||
|
|
arg.AttemptID,
|
||
|
|
)
|
||
|
|
var i BotTurn
|
||
|
|
err := row.Scan(
|
||
|
|
&i.SessionID,
|
||
|
|
&i.Ordinal,
|
||
|
|
&i.Prompt,
|
||
|
|
&i.Completion,
|
||
|
|
&i.Status,
|
||
|
|
&i.AttemptID,
|
||
|
|
&i.AttemptStartedAt,
|
||
|
|
&i.CreatedAt,
|
||
|
|
&i.LatencyMs,
|
||
|
|
&i.TokensIn,
|
||
|
|
&i.TokensOut,
|
||
|
|
&i.Error,
|
||
|
|
)
|
||
|
|
return &i, err
|
||
|
|
}
|
||
|
|
|
||
|
|
const listBotSessionDocumentsForSession = `-- name: ListBotSessionDocumentsForSession :many
|
||
|
|
SELECT document_id
|
||
|
|
FROM bot_session_documents
|
||
|
|
WHERE session_id = $1
|
||
|
|
ORDER BY document_id
|
||
|
|
`
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// ORDER BY document_id
|
||
|
|
func (q *Queries) ListBotSessionDocumentsForSession(ctx context.Context, sessionID uuid.UUID) ([]uuid.UUID, error) {
|
||
|
|
rows, err := q.db.Query(ctx, listBotSessionDocumentsForSession, sessionID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []uuid.UUID{}
|
||
|
|
for rows.Next() {
|
||
|
|
var document_id uuid.UUID
|
||
|
|
if err := rows.Scan(&document_id); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items = append(items, document_id)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const listBotSessionFoldersForSession = `-- name: ListBotSessionFoldersForSession :many
|
||
|
|
SELECT folder_id
|
||
|
|
FROM bot_session_folders
|
||
|
|
WHERE session_id = $1
|
||
|
|
ORDER BY folder_id
|
||
|
|
`
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// ORDER BY folder_id
|
||
|
|
func (q *Queries) ListBotSessionFoldersForSession(ctx context.Context, sessionID uuid.UUID) ([]uuid.UUID, error) {
|
||
|
|
rows, err := q.db.Query(ctx, listBotSessionFoldersForSession, sessionID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []uuid.UUID{}
|
||
|
|
for rows.Next() {
|
||
|
|
var folder_id uuid.UUID
|
||
|
|
if err := rows.Scan(&folder_id); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items = append(items, folder_id)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const listBotSessionsForOwner = `-- name: ListBotSessionsForOwner :many
|
||
|
|
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 = $1
|
||
|
|
AND s.created_by = $2
|
||
|
|
AND s.is_deleted = false
|
||
|
|
ORDER BY s.updated_at DESC
|
||
|
|
LIMIT $4::int OFFSET $3::int
|
||
|
|
`
|
||
|
|
|
||
|
|
type ListBotSessionsForOwnerParams struct {
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
CreatedBy string `db:"created_by"`
|
||
|
|
OffsetVal int32 `db:"offset_val"`
|
||
|
|
LimitVal int32 `db:"limit_val"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ListBotSessionsForOwnerRow 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"`
|
||
|
|
LastSeenOrdinal int32 `db:"last_seen_ordinal"`
|
||
|
|
LastTerminalOrdinal int32 `db:"last_terminal_ordinal"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// AND s.created_by = $2
|
||
|
|
// AND s.is_deleted = false
|
||
|
|
// ORDER BY s.updated_at DESC
|
||
|
|
// LIMIT $4::int OFFSET $3::int
|
||
|
|
func (q *Queries) ListBotSessionsForOwner(ctx context.Context, arg *ListBotSessionsForOwnerParams) ([]*ListBotSessionsForOwnerRow, error) {
|
||
|
|
rows, err := q.db.Query(ctx, listBotSessionsForOwner,
|
||
|
|
arg.ClientID,
|
||
|
|
arg.CreatedBy,
|
||
|
|
arg.OffsetVal,
|
||
|
|
arg.LimitVal,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []*ListBotSessionsForOwnerRow{}
|
||
|
|
for rows.Next() {
|
||
|
|
var i ListBotSessionsForOwnerRow
|
||
|
|
if err := rows.Scan(
|
||
|
|
&i.ID,
|
||
|
|
&i.ClientID,
|
||
|
|
&i.CreatedBy,
|
||
|
|
&i.CreatedAt,
|
||
|
|
&i.UpdatedAt,
|
||
|
|
&i.Title,
|
||
|
|
&i.State,
|
||
|
|
&i.IsDeleted,
|
||
|
|
&i.LastSeenOrdinal,
|
||
|
|
&i.LastTerminalOrdinal,
|
||
|
|
); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items = append(items, &i)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const listBotSessionsForSuperAdmin = `-- name: ListBotSessionsForSuperAdmin :many
|
||
|
|
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 = $1
|
||
|
|
AND s.is_deleted = false
|
||
|
|
ORDER BY s.updated_at DESC
|
||
|
|
LIMIT $3::int OFFSET $2::int
|
||
|
|
`
|
||
|
|
|
||
|
|
type ListBotSessionsForSuperAdminParams struct {
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
OffsetVal int32 `db:"offset_val"`
|
||
|
|
LimitVal int32 `db:"limit_val"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ListBotSessionsForSuperAdminRow 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"`
|
||
|
|
LastSeenOrdinal int32 `db:"last_seen_ordinal"`
|
||
|
|
LastTerminalOrdinal int32 `db:"last_terminal_ordinal"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// AND s.is_deleted = false
|
||
|
|
// ORDER BY s.updated_at DESC
|
||
|
|
// LIMIT $3::int OFFSET $2::int
|
||
|
|
func (q *Queries) ListBotSessionsForSuperAdmin(ctx context.Context, arg *ListBotSessionsForSuperAdminParams) ([]*ListBotSessionsForSuperAdminRow, error) {
|
||
|
|
rows, err := q.db.Query(ctx, listBotSessionsForSuperAdmin, arg.ClientID, arg.OffsetVal, arg.LimitVal)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []*ListBotSessionsForSuperAdminRow{}
|
||
|
|
for rows.Next() {
|
||
|
|
var i ListBotSessionsForSuperAdminRow
|
||
|
|
if err := rows.Scan(
|
||
|
|
&i.ID,
|
||
|
|
&i.ClientID,
|
||
|
|
&i.CreatedBy,
|
||
|
|
&i.CreatedAt,
|
||
|
|
&i.UpdatedAt,
|
||
|
|
&i.Title,
|
||
|
|
&i.State,
|
||
|
|
&i.IsDeleted,
|
||
|
|
&i.LastSeenOrdinal,
|
||
|
|
&i.LastTerminalOrdinal,
|
||
|
|
); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items = append(items, &i)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const listLastTurnsForSessions = `-- name: ListLastTurnsForSessions :many
|
||
|
|
SELECT session_id, ordinal, prompt, completion, status, created_at,
|
||
|
|
latency_ms, tokens_in, tokens_out, error
|
||
|
|
FROM (
|
||
|
|
SELECT bt.session_id, bt.ordinal, bt.prompt, bt.completion, bt.status, bt.attempt_id, bt.attempt_started_at, bt.created_at, bt.latency_ms, bt.tokens_in, bt.tokens_out, bt.error,
|
||
|
|
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY ordinal DESC) AS rn
|
||
|
|
FROM bot_turns bt
|
||
|
|
WHERE bt.session_id = ANY($1::uuid[])
|
||
|
|
) ranked
|
||
|
|
WHERE rn <= $2::int
|
||
|
|
ORDER BY session_id, ordinal DESC
|
||
|
|
`
|
||
|
|
|
||
|
|
type ListLastTurnsForSessionsParams struct {
|
||
|
|
SessionIds []uuid.UUID `db:"session_ids"`
|
||
|
|
TurnLimit int32 `db:"turn_limit"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type ListLastTurnsForSessionsRow struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
Ordinal int32 `db:"ordinal"`
|
||
|
|
Prompt string `db:"prompt"`
|
||
|
|
Completion *string `db:"completion"`
|
||
|
|
Status string `db:"status"`
|
||
|
|
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"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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.session_id, bt.ordinal, bt.prompt, bt.completion, bt.status, bt.attempt_id, bt.attempt_started_at, bt.created_at, bt.latency_ms, bt.tokens_in, bt.tokens_out, bt.error,
|
||
|
|
// ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY ordinal DESC) AS rn
|
||
|
|
// FROM bot_turns bt
|
||
|
|
// WHERE bt.session_id = ANY($1::uuid[])
|
||
|
|
// ) ranked
|
||
|
|
// WHERE rn <= $2::int
|
||
|
|
// ORDER BY session_id, ordinal DESC
|
||
|
|
func (q *Queries) ListLastTurnsForSessions(ctx context.Context, arg *ListLastTurnsForSessionsParams) ([]*ListLastTurnsForSessionsRow, error) {
|
||
|
|
rows, err := q.db.Query(ctx, listLastTurnsForSessions, arg.SessionIds, arg.TurnLimit)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []*ListLastTurnsForSessionsRow{}
|
||
|
|
for rows.Next() {
|
||
|
|
var i ListLastTurnsForSessionsRow
|
||
|
|
if err := rows.Scan(
|
||
|
|
&i.SessionID,
|
||
|
|
&i.Ordinal,
|
||
|
|
&i.Prompt,
|
||
|
|
&i.Completion,
|
||
|
|
&i.Status,
|
||
|
|
&i.CreatedAt,
|
||
|
|
&i.LatencyMs,
|
||
|
|
&i.TokensIn,
|
||
|
|
&i.TokensOut,
|
||
|
|
&i.Error,
|
||
|
|
); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items = append(items, &i)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const listTurnsForSession = `-- name: ListTurnsForSession :many
|
||
|
|
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 = $1
|
||
|
|
ORDER BY ordinal ASC
|
||
|
|
`
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// ORDER BY ordinal ASC
|
||
|
|
func (q *Queries) ListTurnsForSession(ctx context.Context, sessionID uuid.UUID) ([]*BotTurn, error) {
|
||
|
|
rows, err := q.db.Query(ctx, listTurnsForSession, sessionID)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
items := []*BotTurn{}
|
||
|
|
for rows.Next() {
|
||
|
|
var i BotTurn
|
||
|
|
if err := rows.Scan(
|
||
|
|
&i.SessionID,
|
||
|
|
&i.Ordinal,
|
||
|
|
&i.Prompt,
|
||
|
|
&i.Completion,
|
||
|
|
&i.Status,
|
||
|
|
&i.AttemptID,
|
||
|
|
&i.AttemptStartedAt,
|
||
|
|
&i.CreatedAt,
|
||
|
|
&i.LatencyMs,
|
||
|
|
&i.TokensIn,
|
||
|
|
&i.TokensOut,
|
||
|
|
&i.Error,
|
||
|
|
); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
items = append(items, &i)
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
return items, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const lockBotSessionForOwner = `-- name: LockBotSessionForOwner :one
|
||
|
|
|
||
|
|
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 = $1
|
||
|
|
AND s.client_id = $2
|
||
|
|
AND s.created_by = $3
|
||
|
|
AND s.is_deleted = false
|
||
|
|
FOR UPDATE OF s
|
||
|
|
`
|
||
|
|
|
||
|
|
type LockBotSessionForOwnerParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
CreatedBy string `db:"created_by"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type LockBotSessionForOwnerRow 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"`
|
||
|
|
LastSeenOrdinal int32 `db:"last_seen_ordinal"`
|
||
|
|
LastTerminalOrdinal int32 `db:"last_terminal_ordinal"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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.
|
||
|
|
//
|
||
|
|
// 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.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 = $1
|
||
|
|
// AND s.client_id = $2
|
||
|
|
// AND s.created_by = $3
|
||
|
|
// AND s.is_deleted = false
|
||
|
|
// FOR UPDATE OF s
|
||
|
|
func (q *Queries) LockBotSessionForOwner(ctx context.Context, arg *LockBotSessionForOwnerParams) (*LockBotSessionForOwnerRow, error) {
|
||
|
|
row := q.db.QueryRow(ctx, lockBotSessionForOwner, arg.SessionID, arg.ClientID, arg.CreatedBy)
|
||
|
|
var i LockBotSessionForOwnerRow
|
||
|
|
err := row.Scan(
|
||
|
|
&i.ID,
|
||
|
|
&i.ClientID,
|
||
|
|
&i.CreatedBy,
|
||
|
|
&i.CreatedAt,
|
||
|
|
&i.UpdatedAt,
|
||
|
|
&i.Title,
|
||
|
|
&i.State,
|
||
|
|
&i.IsDeleted,
|
||
|
|
&i.LastSeenOrdinal,
|
||
|
|
&i.LastTerminalOrdinal,
|
||
|
|
)
|
||
|
|
return &i, err
|
||
|
|
}
|
||
|
|
|
||
|
|
const markBotTurnSessionDeleted = `-- name: MarkBotTurnSessionDeleted :execrows
|
||
|
|
UPDATE bot_turns
|
||
|
|
SET status = 'session_deleted',
|
||
|
|
error = $1
|
||
|
|
WHERE session_id = $2
|
||
|
|
AND ordinal = $3
|
||
|
|
AND attempt_id = $4
|
||
|
|
AND status = 'in_flight'
|
||
|
|
`
|
||
|
|
|
||
|
|
type MarkBotTurnSessionDeletedParams struct {
|
||
|
|
Error []byte `db:"error"`
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
Ordinal int32 `db:"ordinal"`
|
||
|
|
AttemptID uuid.UUID `db:"attempt_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// WHERE session_id = $2
|
||
|
|
// AND ordinal = $3
|
||
|
|
// AND attempt_id = $4
|
||
|
|
// AND status = 'in_flight'
|
||
|
|
func (q *Queries) MarkBotTurnSessionDeleted(ctx context.Context, arg *MarkBotTurnSessionDeletedParams) (int64, error) {
|
||
|
|
result, err := q.db.Exec(ctx, markBotTurnSessionDeleted,
|
||
|
|
arg.Error,
|
||
|
|
arg.SessionID,
|
||
|
|
arg.Ordinal,
|
||
|
|
arg.AttemptID,
|
||
|
|
)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
return result.RowsAffected(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const markInflightTurnsSessionDeletedForSession = `-- name: MarkInflightTurnsSessionDeletedForSession :exec
|
||
|
|
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 = $1
|
||
|
|
AND status = 'in_flight'
|
||
|
|
`
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// AND status = 'in_flight'
|
||
|
|
func (q *Queries) MarkInflightTurnsSessionDeletedForSession(ctx context.Context, sessionID uuid.UUID) error {
|
||
|
|
_, err := q.db.Exec(ctx, markInflightTurnsSessionDeletedForSession, sessionID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
const resetBotTurnForRetry = `-- name: ResetBotTurnForRetry :exec
|
||
|
|
UPDATE bot_turns
|
||
|
|
SET status = 'in_flight',
|
||
|
|
attempt_id = $1,
|
||
|
|
attempt_started_at = NOW(),
|
||
|
|
created_at = NOW(),
|
||
|
|
completion = NULL,
|
||
|
|
error = NULL
|
||
|
|
WHERE session_id = $2
|
||
|
|
AND ordinal = $3
|
||
|
|
AND status IN ('errored', 'abandoned')
|
||
|
|
`
|
||
|
|
|
||
|
|
type ResetBotTurnForRetryParams struct {
|
||
|
|
AttemptID uuid.UUID `db:"attempt_id"`
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
Ordinal int32 `db:"ordinal"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1,
|
||
|
|
// attempt_started_at = NOW(),
|
||
|
|
// created_at = NOW(),
|
||
|
|
// completion = NULL,
|
||
|
|
// error = NULL
|
||
|
|
// WHERE session_id = $2
|
||
|
|
// AND ordinal = $3
|
||
|
|
// AND status IN ('errored', 'abandoned')
|
||
|
|
func (q *Queries) ResetBotTurnForRetry(ctx context.Context, arg *ResetBotTurnForRetryParams) error {
|
||
|
|
_, err := q.db.Exec(ctx, resetBotTurnForRetry, arg.AttemptID, arg.SessionID, arg.Ordinal)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
const setBotSessionTitle = `-- name: SetBotSessionTitle :exec
|
||
|
|
UPDATE bot_sessions
|
||
|
|
SET title = $1,
|
||
|
|
updated_at = NOW()
|
||
|
|
WHERE id = $2
|
||
|
|
AND title = ''
|
||
|
|
`
|
||
|
|
|
||
|
|
type SetBotSessionTitleParams struct {
|
||
|
|
Title string `db:"title"`
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1,
|
||
|
|
// updated_at = NOW()
|
||
|
|
// WHERE id = $2
|
||
|
|
// AND title = ''
|
||
|
|
func (q *Queries) SetBotSessionTitle(ctx context.Context, arg *SetBotSessionTitleParams) error {
|
||
|
|
_, err := q.db.Exec(ctx, setBotSessionTitle, arg.Title, arg.SessionID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
const softDeleteBotSessionForSuperAdmin = `-- name: SoftDeleteBotSessionForSuperAdmin :execrows
|
||
|
|
UPDATE bot_sessions
|
||
|
|
SET is_deleted = true,
|
||
|
|
updated_at = NOW()
|
||
|
|
WHERE id = $1
|
||
|
|
AND client_id = $2
|
||
|
|
AND is_deleted = false
|
||
|
|
`
|
||
|
|
|
||
|
|
type SoftDeleteBotSessionForSuperAdminParams struct {
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
// AND client_id = $2
|
||
|
|
// AND is_deleted = false
|
||
|
|
func (q *Queries) SoftDeleteBotSessionForSuperAdmin(ctx context.Context, arg *SoftDeleteBotSessionForSuperAdminParams) (int64, error) {
|
||
|
|
result, err := q.db.Exec(ctx, softDeleteBotSessionForSuperAdmin, arg.SessionID, arg.ClientID)
|
||
|
|
if err != nil {
|
||
|
|
return 0, err
|
||
|
|
}
|
||
|
|
return result.RowsAffected(), nil
|
||
|
|
}
|
||
|
|
|
||
|
|
const touchBotSessionUpdatedAt = `-- name: TouchBotSessionUpdatedAt :exec
|
||
|
|
UPDATE bot_sessions
|
||
|
|
SET updated_at = NOW()
|
||
|
|
WHERE id = $1
|
||
|
|
`
|
||
|
|
|
||
|
|
// 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 = $1
|
||
|
|
func (q *Queries) TouchBotSessionUpdatedAt(ctx context.Context, sessionID uuid.UUID) error {
|
||
|
|
_, err := q.db.Exec(ctx, touchBotSessionUpdatedAt, sessionID)
|
||
|
|
return err
|
||
|
|
}
|
||
|
|
|
||
|
|
const updateBotSessionState = `-- name: UpdateBotSessionState :exec
|
||
|
|
UPDATE bot_sessions
|
||
|
|
SET state = COALESCE($1, '{}'::jsonb),
|
||
|
|
updated_at = NOW()
|
||
|
|
WHERE id = $2
|
||
|
|
AND client_id = $3
|
||
|
|
`
|
||
|
|
|
||
|
|
type UpdateBotSessionStateParams struct {
|
||
|
|
State []byte `db:"state"`
|
||
|
|
SessionID uuid.UUID `db:"session_id"`
|
||
|
|
ClientID string `db:"client_id"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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($1, '{}'::jsonb),
|
||
|
|
// updated_at = NOW()
|
||
|
|
// WHERE id = $2
|
||
|
|
// AND client_id = $3
|
||
|
|
func (q *Queries) UpdateBotSessionState(ctx context.Context, arg *UpdateBotSessionStateParams) error {
|
||
|
|
_, err := q.db.Exec(ctx, updateBotSessionState, arg.State, arg.SessionID, arg.ClientID)
|
||
|
|
return err
|
||
|
|
}
|