469 lines
18 KiB
Go
469 lines
18 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/kandev/kandev/internal/db/dialect"
|
|
"github.com/kandev/kandev/internal/task/models"
|
|
)
|
|
|
|
const (
|
|
conversationTimestampParameter = "CAST(? AS timestamp)"
|
|
conversationAscending = "ASC"
|
|
conversationDescending = "DESC"
|
|
)
|
|
|
|
// initConversationSourceSchema installs the bounded revision metadata used by
|
|
// current-state conversation readers. Source rows remain authoritative; this
|
|
// table never stores message or turn payloads.
|
|
func (r *Repository) initConversationSourceSchema() error {
|
|
if _, err := r.db.ExecContext(r.migrationContext(), `
|
|
CREATE TABLE IF NOT EXISTS conversation_session_revisions (
|
|
session_id TEXT PRIMARY KEY,
|
|
revision BIGINT NOT NULL DEFAULT 0 CHECK (revision >= 0),
|
|
FOREIGN KEY (session_id) REFERENCES task_sessions(id) ON DELETE CASCADE
|
|
)`); err != nil {
|
|
return fmt.Errorf("create conversation source revisions: %w", err)
|
|
}
|
|
if dialect.IsPostgres(r.db.DriverName()) {
|
|
return r.initPostgresConversationSourceTriggers()
|
|
}
|
|
return r.initSQLiteConversationSourceTriggers()
|
|
}
|
|
|
|
const sqliteConversationSourceTriggerSQL = `
|
|
CREATE TRIGGER IF NOT EXISTS conversation_source_message_insert
|
|
AFTER INSERT ON task_session_messages
|
|
BEGIN
|
|
INSERT INTO conversation_session_revisions(session_id, revision)
|
|
SELECT NEW.task_session_id, 1
|
|
WHERE EXISTS (SELECT 1 FROM task_sessions WHERE id = NEW.task_session_id)
|
|
ON CONFLICT(session_id) DO UPDATE SET revision = conversation_session_revisions.revision + 1;
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS conversation_source_message_update
|
|
AFTER UPDATE ON task_session_messages
|
|
WHEN OLD.task_session_id IS NOT NEW.task_session_id
|
|
OR OLD.task_id IS NOT NEW.task_id
|
|
OR OLD.turn_id IS NOT NEW.turn_id
|
|
OR OLD.author_type IS NOT NEW.author_type
|
|
OR OLD.author_id IS NOT NEW.author_id
|
|
OR OLD.content IS NOT NEW.content
|
|
OR OLD.requests_input IS NOT NEW.requests_input
|
|
OR OLD.type IS NOT NEW.type
|
|
OR OLD.metadata IS NOT NEW.metadata
|
|
OR OLD.created_at IS NOT NEW.created_at
|
|
OR OLD.updated_at IS NOT NEW.updated_at
|
|
OR OLD.prompt_seq IS NOT NEW.prompt_seq
|
|
BEGIN
|
|
INSERT INTO conversation_session_revisions(session_id, revision)
|
|
SELECT OLD.task_session_id, 1
|
|
WHERE OLD.task_session_id != NEW.task_session_id
|
|
AND EXISTS (SELECT 1 FROM task_sessions WHERE id = OLD.task_session_id)
|
|
ON CONFLICT(session_id) DO UPDATE SET revision = conversation_session_revisions.revision + 1;
|
|
INSERT INTO conversation_session_revisions(session_id, revision)
|
|
SELECT NEW.task_session_id, 1
|
|
WHERE EXISTS (SELECT 1 FROM task_sessions WHERE id = NEW.task_session_id)
|
|
ON CONFLICT(session_id) DO UPDATE SET revision = conversation_session_revisions.revision + 1;
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS conversation_source_message_delete
|
|
AFTER DELETE ON task_session_messages
|
|
BEGIN
|
|
INSERT INTO conversation_session_revisions(session_id, revision)
|
|
SELECT OLD.task_session_id, 1
|
|
WHERE EXISTS (SELECT 1 FROM task_sessions WHERE id = OLD.task_session_id)
|
|
ON CONFLICT(session_id) DO UPDATE SET revision = conversation_session_revisions.revision + 1;
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS conversation_source_turn_insert
|
|
AFTER INSERT ON task_session_turns
|
|
BEGIN
|
|
INSERT INTO conversation_session_revisions(session_id, revision)
|
|
SELECT NEW.task_session_id, 1
|
|
WHERE EXISTS (SELECT 1 FROM task_sessions WHERE id = NEW.task_session_id)
|
|
ON CONFLICT(session_id) DO UPDATE SET revision = conversation_session_revisions.revision + 1;
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS conversation_source_turn_update
|
|
AFTER UPDATE ON task_session_turns
|
|
WHEN OLD.task_session_id IS NOT NEW.task_session_id
|
|
OR OLD.task_id IS NOT NEW.task_id
|
|
OR OLD.execution_profile_id IS NOT NEW.execution_profile_id
|
|
OR OLD.route_generation IS NOT NEW.route_generation
|
|
OR OLD.started_at IS NOT NEW.started_at
|
|
OR OLD.completed_at IS NOT NEW.completed_at
|
|
OR OLD.metadata IS NOT NEW.metadata
|
|
OR OLD.created_at IS NOT NEW.created_at
|
|
OR OLD.updated_at IS NOT NEW.updated_at
|
|
BEGIN
|
|
INSERT INTO conversation_session_revisions(session_id, revision)
|
|
SELECT OLD.task_session_id, 1
|
|
WHERE OLD.task_session_id != NEW.task_session_id
|
|
AND EXISTS (SELECT 1 FROM task_sessions WHERE id = OLD.task_session_id)
|
|
ON CONFLICT(session_id) DO UPDATE SET revision = conversation_session_revisions.revision + 1;
|
|
INSERT INTO conversation_session_revisions(session_id, revision)
|
|
SELECT NEW.task_session_id, 1
|
|
WHERE EXISTS (SELECT 1 FROM task_sessions WHERE id = NEW.task_session_id)
|
|
ON CONFLICT(session_id) DO UPDATE SET revision = conversation_session_revisions.revision + 1;
|
|
END;
|
|
|
|
CREATE TRIGGER IF NOT EXISTS conversation_source_turn_delete
|
|
AFTER DELETE ON task_session_turns
|
|
BEGIN
|
|
INSERT INTO conversation_session_revisions(session_id, revision)
|
|
SELECT OLD.task_session_id, 1
|
|
WHERE EXISTS (SELECT 1 FROM task_sessions WHERE id = OLD.task_session_id)
|
|
ON CONFLICT(session_id) DO UPDATE SET revision = conversation_session_revisions.revision + 1;
|
|
END;`
|
|
|
|
func (r *Repository) initSQLiteConversationSourceTriggers() error {
|
|
if _, err := r.db.ExecContext(r.migrationContext(), sqliteConversationSourceTriggerSQL); err != nil {
|
|
return fmt.Errorf("create SQLite conversation source triggers: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (r *Repository) initPostgresConversationSourceTriggers() error {
|
|
const triggerSQL = `
|
|
CREATE OR REPLACE FUNCTION conversation_source_bump_revision(p_session_id TEXT)
|
|
RETURNS VOID AS $$
|
|
BEGIN
|
|
INSERT INTO conversation_session_revisions(session_id, revision)
|
|
SELECT p_session_id, 1
|
|
WHERE EXISTS (SELECT 1 FROM task_sessions WHERE id = p_session_id)
|
|
ON CONFLICT(session_id) DO UPDATE
|
|
SET revision = conversation_session_revisions.revision + 1;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
CREATE OR REPLACE FUNCTION conversation_source_message_revision()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
PERFORM conversation_source_bump_revision(OLD.task_session_id);
|
|
RETURN OLD;
|
|
END IF;
|
|
IF TG_OP = 'UPDATE' AND OLD.task_session_id IS DISTINCT FROM NEW.task_session_id THEN
|
|
PERFORM conversation_source_bump_revision(OLD.task_session_id);
|
|
END IF;
|
|
PERFORM conversation_source_bump_revision(NEW.task_session_id);
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS conversation_source_message_revision_trigger ON task_session_messages;
|
|
CREATE TRIGGER conversation_source_message_revision_trigger
|
|
AFTER INSERT OR UPDATE OR DELETE ON task_session_messages
|
|
FOR EACH ROW EXECUTE FUNCTION conversation_source_message_revision();
|
|
|
|
CREATE OR REPLACE FUNCTION conversation_source_turn_revision()
|
|
RETURNS TRIGGER AS $$
|
|
BEGIN
|
|
IF TG_OP = 'DELETE' THEN
|
|
PERFORM conversation_source_bump_revision(OLD.task_session_id);
|
|
RETURN OLD;
|
|
END IF;
|
|
IF TG_OP = 'UPDATE' AND OLD.task_session_id IS DISTINCT FROM NEW.task_session_id THEN
|
|
PERFORM conversation_source_bump_revision(OLD.task_session_id);
|
|
END IF;
|
|
PERFORM conversation_source_bump_revision(NEW.task_session_id);
|
|
RETURN NEW;
|
|
END;
|
|
$$ LANGUAGE plpgsql;
|
|
|
|
DROP TRIGGER IF EXISTS conversation_source_turn_revision_trigger ON task_session_turns;
|
|
CREATE TRIGGER conversation_source_turn_revision_trigger
|
|
AFTER INSERT OR UPDATE OR DELETE ON task_session_turns
|
|
FOR EACH ROW EXECUTE FUNCTION conversation_source_turn_revision();`
|
|
if _, err := r.db.ExecContext(r.migrationContext(), r.db.Rebind(triggerSQL)); err != nil {
|
|
return fmt.Errorf("create PostgreSQL conversation source triggers: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ReadConversationRevision reads session existence and its current revision in
|
|
// one short read transaction. A revision row is optional because revision zero
|
|
// is the correct value for an existing session that has not changed since the
|
|
// source schema was installed.
|
|
func (r *Repository) ReadConversationRevision(ctx context.Context, sessionID string) (models.ConversationRevision, error) {
|
|
tx, err := r.beginConversationSourceRead(ctx)
|
|
if err != nil {
|
|
return models.ConversationRevision{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
state, err := readConversationState(ctx, tx, r.db.DriverName(), sessionID)
|
|
if err != nil {
|
|
return models.ConversationRevision{}, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return models.ConversationRevision{}, fmt.Errorf("commit conversation revision read: %w", err)
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
// ReadConversationMessagesPage reads source messages and their revision from a
|
|
// single consistent transaction. The result is a current-state page; it is
|
|
// never a read from the historical journal tables.
|
|
func (r *Repository) ReadConversationMessagesPage(
|
|
ctx context.Context,
|
|
req models.ConversationMessagePageRequest,
|
|
) (models.ConversationMessagePage, error) {
|
|
tx, err := r.beginConversationSourceRead(ctx)
|
|
if err != nil {
|
|
return models.ConversationMessagePage{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
state, err := readConversationState(ctx, tx, r.db.DriverName(), req.SessionID)
|
|
if err != nil {
|
|
return models.ConversationMessagePage{}, err
|
|
}
|
|
if !state.Exists {
|
|
return models.ConversationMessagePage{}, models.ErrTaskSessionNotFound
|
|
}
|
|
limit := conversationSourcePageLimit(req.Limit)
|
|
cursorKey, cursorID, err := readConversationMessageCursor(ctx, tx, r.db.DriverName(), req.SessionID, req.CursorID)
|
|
if err != nil {
|
|
return models.ConversationMessagePage{}, err
|
|
}
|
|
query, args := buildConversationMessagePageQuery(r.db.DriverName(), req, cursorKey, cursorID, limit)
|
|
rows, err := tx.QueryxContext(ctx, tx.Rebind(query), args...)
|
|
if err != nil {
|
|
return models.ConversationMessagePage{}, fmt.Errorf("read conversation message page: %w", err)
|
|
}
|
|
messages, hasMore, scanErr := scanPromptIndexedMessageRows(rows, limit)
|
|
_ = rows.Close()
|
|
if scanErr != nil {
|
|
return models.ConversationMessagePage{}, scanErr
|
|
}
|
|
page := models.ConversationMessagePage{Messages: messages, HasMore: hasMore, Revision: state.Revision}
|
|
if hasMore && len(messages) > 0 {
|
|
page.CursorID = messages[len(messages)-1].ID
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return models.ConversationMessagePage{}, fmt.Errorf("commit conversation message page: %w", err)
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
// ReadConversationTurnsPage is the turn counterpart to
|
|
// ReadConversationMessagesPage. Turn metadata remains available to first-party
|
|
// callers, while the plugin layer maps it to its safe DTO.
|
|
func (r *Repository) ReadConversationTurnsPage(
|
|
ctx context.Context,
|
|
req models.ConversationTurnPageRequest,
|
|
) (models.ConversationTurnPage, error) {
|
|
tx, err := r.beginConversationSourceRead(ctx)
|
|
if err != nil {
|
|
return models.ConversationTurnPage{}, err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
state, err := readConversationState(ctx, tx, r.db.DriverName(), req.SessionID)
|
|
if err != nil {
|
|
return models.ConversationTurnPage{}, err
|
|
}
|
|
if !state.Exists {
|
|
return models.ConversationTurnPage{}, models.ErrTaskSessionNotFound
|
|
}
|
|
limit := conversationSourcePageLimit(req.Limit)
|
|
cursorStarted, cursorID, err := readConversationTurnCursor(ctx, tx, req.SessionID, req.CursorID)
|
|
if err != nil {
|
|
return models.ConversationTurnPage{}, err
|
|
}
|
|
query, args := buildConversationTurnPageQuery(r.db.DriverName(), req, cursorStarted, cursorID, limit)
|
|
rows, err := tx.QueryxContext(ctx, tx.Rebind(query), args...)
|
|
if err != nil {
|
|
return models.ConversationTurnPage{}, fmt.Errorf("read conversation turn page: %w", err)
|
|
}
|
|
defer func() { _ = rows.Close() }()
|
|
turns := make([]*models.Turn, 0, limit)
|
|
for rows.Next() {
|
|
turn, scanErr := scanTurn(rows)
|
|
if scanErr != nil {
|
|
return models.ConversationTurnPage{}, scanErr
|
|
}
|
|
turns = append(turns, turn)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return models.ConversationTurnPage{}, err
|
|
}
|
|
hasMore := len(turns) > limit
|
|
if hasMore {
|
|
turns = turns[:limit]
|
|
}
|
|
page := models.ConversationTurnPage{Turns: turns, HasMore: hasMore, Revision: state.Revision}
|
|
if hasMore && len(turns) > 0 {
|
|
page.CursorID = turns[len(turns)-1].ID
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return models.ConversationTurnPage{}, fmt.Errorf("commit conversation turn page: %w", err)
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
func (r *Repository) beginConversationSourceRead(ctx context.Context) (*sqlx.Tx, error) {
|
|
options := &sql.TxOptions{ReadOnly: true}
|
|
if dialect.IsPostgres(r.db.DriverName()) {
|
|
options.Isolation = sql.LevelRepeatableRead
|
|
}
|
|
return r.ro.BeginTxx(ctx, options)
|
|
}
|
|
|
|
func readConversationState(ctx context.Context, tx *sqlx.Tx, _ string, sessionID string) (models.ConversationRevision, error) {
|
|
var exists int
|
|
if err := tx.QueryRowxContext(ctx, tx.Rebind(`SELECT 1 FROM task_sessions WHERE id = ?`), sessionID).Scan(&exists); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return models.ConversationRevision{SessionID: sessionID}, nil
|
|
}
|
|
return models.ConversationRevision{}, fmt.Errorf("read conversation session: %w", err)
|
|
}
|
|
var revision int64
|
|
if err := tx.QueryRowxContext(ctx, tx.Rebind(`SELECT revision FROM conversation_session_revisions WHERE session_id = ?`), sessionID).Scan(&revision); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return models.ConversationRevision{}, fmt.Errorf("read conversation revision: %w", err)
|
|
}
|
|
return models.ConversationRevision{SessionID: sessionID, Revision: revision, Exists: exists == 1}, nil
|
|
}
|
|
|
|
func conversationSourcePageLimit(limit int) int {
|
|
if limit <= 0 {
|
|
return 20
|
|
}
|
|
if limit > 100 {
|
|
return 100
|
|
}
|
|
return limit
|
|
}
|
|
|
|
func readConversationMessageCursor(ctx context.Context, tx *sqlx.Tx, driver, sessionID, cursorID string) (string, string, error) {
|
|
if cursorID == "" {
|
|
return "", "", nil
|
|
}
|
|
var cursorSession string
|
|
var cursorCreated time.Time
|
|
query := `SELECT task_session_id, created_at FROM task_session_messages WHERE id = ?`
|
|
if err := tx.QueryRowxContext(ctx, tx.Rebind(query), cursorID).Scan(&cursorSession, &cursorCreated); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", "", fmt.Errorf("%w: message cursor %s", models.ErrConversationCursorStale, cursorID)
|
|
}
|
|
return "", "", err
|
|
}
|
|
if cursorSession != sessionID {
|
|
return "", "", fmt.Errorf("%w: message cursor %s", models.ErrConversationCursorStale, cursorID)
|
|
}
|
|
if dialect.IsPostgres(driver) {
|
|
return formatPromptKey(cursorCreated), cursorID, nil
|
|
}
|
|
var key string
|
|
if err := tx.QueryRowxContext(ctx, tx.Rebind(fmt.Sprintf(
|
|
`SELECT %s FROM task_session_messages WHERE id = ?`,
|
|
dialect.NormalizedMicrosecond(driver, "created_at"),
|
|
)), cursorID).Scan(&key); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return "", "", fmt.Errorf("%w: message cursor %s", models.ErrConversationCursorStale, cursorID)
|
|
}
|
|
return "", "", err
|
|
}
|
|
return key, cursorID, nil
|
|
}
|
|
|
|
func readConversationTurnCursor(ctx context.Context, tx *sqlx.Tx, sessionID, cursorID string) (time.Time, string, error) {
|
|
if cursorID == "" {
|
|
return time.Time{}, "", nil
|
|
}
|
|
var cursorSession string
|
|
var cursorStarted time.Time
|
|
if err := tx.QueryRowxContext(ctx, tx.Rebind(
|
|
`SELECT task_session_id, started_at FROM task_session_turns WHERE id = ?`,
|
|
), cursorID).Scan(&cursorSession, &cursorStarted); err != nil {
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return time.Time{}, "", fmt.Errorf("%w: turn cursor %s", models.ErrConversationCursorStale, cursorID)
|
|
}
|
|
return time.Time{}, "", err
|
|
}
|
|
if cursorSession != sessionID {
|
|
return time.Time{}, "", fmt.Errorf("%w: turn cursor %s", models.ErrConversationCursorStale, cursorID)
|
|
}
|
|
return cursorStarted, cursorID, nil
|
|
}
|
|
|
|
func buildConversationMessagePageQuery(
|
|
driver string,
|
|
req models.ConversationMessagePageRequest,
|
|
cursorKey, cursorID string,
|
|
limit int,
|
|
) (string, []interface{}) {
|
|
normalized := dialect.NormalizedMicrosecond(driver, "created_at")
|
|
bound := "?"
|
|
if dialect.IsPostgres(driver) {
|
|
bound = conversationTimestampParameter
|
|
}
|
|
query := `
|
|
SELECT id, task_session_id, task_id, turn_id, author_type, author_id, content,
|
|
requests_input, type, metadata, created_at, updated_at,
|
|
CASE WHEN author_type = 'user' THEN prompt_seq ELSE 0 END AS prompt_index
|
|
FROM task_session_messages
|
|
WHERE task_session_id = ?`
|
|
args := []interface{}{req.SessionID}
|
|
if req.TaskID != nil {
|
|
query += " AND task_id = ?"
|
|
args = append(args, *req.TaskID)
|
|
}
|
|
if len(req.Authors) > 0 {
|
|
placeholders := make([]string, len(req.Authors))
|
|
for index, author := range req.Authors {
|
|
placeholders[index] = "?"
|
|
args = append(args, author)
|
|
}
|
|
query += " AND author_type IN (" + strings.Join(placeholders, ",") + ")"
|
|
}
|
|
sortOrder := conversationAscending
|
|
compare := ">"
|
|
if strings.EqualFold(req.Sort, "desc") {
|
|
sortOrder = conversationDescending
|
|
compare = "<"
|
|
}
|
|
if cursorID != "" {
|
|
query += fmt.Sprintf(" AND (%s %s %s OR (%s = %s AND id %s ?))", normalized, compare, bound, normalized, bound, compare)
|
|
args = append(args, cursorKey, cursorKey, cursorID)
|
|
}
|
|
query += fmt.Sprintf(" ORDER BY %s %s, id %s", normalized, sortOrder, sortOrder)
|
|
query += sqlLimitClause
|
|
args = append(args, limit+1)
|
|
return query, args
|
|
}
|
|
|
|
func buildConversationTurnPageQuery(driver string, req models.ConversationTurnPageRequest, cursorStarted time.Time, cursorID string, limit int) (string, []interface{}) {
|
|
query := `
|
|
SELECT id, task_session_id, task_id, execution_profile_id, route_generation,
|
|
started_at, completed_at, metadata, created_at, updated_at
|
|
FROM task_session_turns turn_row
|
|
WHERE turn_row.task_session_id = ? AND ` + turnHistoryPredicate(driver, "turn_row")
|
|
args := []interface{}{req.SessionID}
|
|
if req.TaskID != nil {
|
|
query += " AND turn_row.task_id = ?"
|
|
args = append(args, *req.TaskID)
|
|
}
|
|
sortOrder := conversationAscending
|
|
if strings.EqualFold(req.Sort, "desc") {
|
|
sortOrder = conversationDescending
|
|
}
|
|
if cursorID != "" {
|
|
compare := ">"
|
|
if sortOrder == conversationDescending {
|
|
compare = "<"
|
|
}
|
|
query += fmt.Sprintf(" AND (turn_row.started_at %s ? OR (turn_row.started_at = ? AND turn_row.id %s ?))", compare, compare)
|
|
args = append(args, cursorStarted, cursorStarted, cursorID)
|
|
}
|
|
query += " ORDER BY turn_row.started_at " + sortOrder + ", turn_row.id " + sortOrder + sqlLimitClause
|
|
args = append(args, limit+1)
|
|
return query, args
|
|
}
|