Groundwork for PostgreSQL, with no PostgreSQL in it yet: the store still opens SQLite and behaves exactly as it did. One set of queries rather than one per database. The SQL here is almost entirely portable — ON CONFLICT upserts work in both, and the only construct that genuinely differs is the placeholder — so two copies of every query would buy nothing and drift, one query at a time, discovered in production. The rewrite is a wrapper around the handle rather than a change at each call site. There are sixty of those and sixty chances to forget one; this way the only route to the driver goes through the rewrite. Making it a wrapper found six places that reached past it, including all three manual transactions and two prepared statements — every one of which would have worked on SQLite and failed on PostgreSQL, on a path nobody exercises before deploying. The rewriter skips quoted sections. No query here contains a question mark in a literal today; this is so that adding one is a test failure rather than a statement that silently binds the wrong argument. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
239 lines
7.5 KiB
Go
239 lines
7.5 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
// ErrThreadsNotEnabled means the project has not asked llmgw to keep
|
|
// conversations.
|
|
//
|
|
// Returned rather than silently ignoring the thread. Ignoring it would send
|
|
// the model a conversation with no history, and it would answer confidently
|
|
// about something it was never told — which reads as the model being wrong
|
|
// rather than as a setting being off.
|
|
var ErrThreadsNotEnabled = errors.New("store: this project does not keep conversations")
|
|
|
|
// ErrNoThread means the id does not exist, or belongs to another project.
|
|
//
|
|
// One error for both on purpose. Distinguishing them would turn thread ids
|
|
// into an oracle: a caller could learn which ids exist in projects they cannot
|
|
// read by watching which error comes back.
|
|
var ErrNoThread = errors.New("store: no such thread")
|
|
|
|
// Message is one turn of a stored conversation.
|
|
type Message struct {
|
|
Role string
|
|
Content string
|
|
// Model is which model produced an assistant turn, empty for a user one. A
|
|
// thread that moved between models otherwise reads as a single voice.
|
|
Model string
|
|
}
|
|
|
|
// EnsureAnonymousProject creates the single project that owns everything kept
|
|
// by a gateway with no authentication, and sets how long it keeps it.
|
|
//
|
|
// A real project rather than a special case in every query: threads and notes
|
|
// are scoped by project id and enforce it in SQL, and an empty id would have to
|
|
// be an exception in each of those places.
|
|
func (s *SQLite) EnsureAnonymousProject(ctx context.Context, id string, retainHours int) error {
|
|
if err := s.PutOrg(ctx, Org{ID: id, Name: id}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.PutTeam(ctx, Team{ID: id, OrgID: id, Name: id}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.PutProject(ctx, Project{ID: id, TeamID: id, Name: id}); err != nil {
|
|
return err
|
|
}
|
|
if err := s.SetThreadRetention(ctx, id, retainHours); err != nil {
|
|
return err
|
|
}
|
|
return s.SetMemoryRetention(ctx, id, retainHours)
|
|
}
|
|
|
|
// ThreadRetention reports how long a project keeps conversations.
|
|
//
|
|
// Zero means it keeps none, which is the default and was the only behaviour
|
|
// before threads existed.
|
|
func (s *SQLite) ThreadRetention(ctx context.Context, projectID string) (time.Duration, error) {
|
|
var hours sql.NullInt64
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT thread_retention_hours FROM projects WHERE id = ?`, projectID).Scan(&hours)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return 0, nil
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !hours.Valid || hours.Int64 <= 0 {
|
|
return 0, nil
|
|
}
|
|
return time.Duration(hours.Int64) * time.Hour, nil
|
|
}
|
|
|
|
// SetThreadRetention turns conversation storage on for a project, or off.
|
|
//
|
|
// Hours rather than a boolean: a thread store with no expiry is a transcript
|
|
// of everything a team ever typed, kept forever because nobody chose a
|
|
// number. Turning it on means saying for how long.
|
|
func (s *SQLite) SetThreadRetention(ctx context.Context, projectID string, hours int) error {
|
|
var v any
|
|
if hours > 0 {
|
|
v = hours
|
|
}
|
|
res, err := s.db.ExecContext(ctx,
|
|
`UPDATE projects SET thread_retention_hours = ? WHERE id = ?`, v, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return fmt.Errorf("store: no project %q", projectID)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Thread returns a conversation's messages in order.
|
|
//
|
|
// The project is part of the lookup rather than checked afterwards, so there
|
|
// is no path that reads another project's conversation even briefly.
|
|
func (s *SQLite) Thread(ctx context.Context, projectID, threadID string) ([]Message, error) {
|
|
var owner string
|
|
err := s.db.QueryRowContext(ctx,
|
|
`SELECT project_id FROM threads WHERE id = ?`, threadID).Scan(&owner)
|
|
if errors.Is(err, sql.ErrNoRows) || (err == nil && owner != projectID) {
|
|
return nil, ErrNoThread
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return queryAll(ctx, s.db,
|
|
`SELECT role, content, COALESCE(model, '') FROM thread_messages
|
|
WHERE thread_id = ? ORDER BY seq`,
|
|
[]any{threadID}, func(rows *sql.Rows) (Message, error) {
|
|
var m Message
|
|
return m, rows.Scan(&m.Role, &m.Content, &m.Model)
|
|
})
|
|
}
|
|
|
|
// AppendThread adds turns to a conversation, creating it if it is new.
|
|
//
|
|
// One transaction, because a half-written exchange is worse than a lost one:
|
|
// a stored question with no answer would be replayed to the next model as
|
|
// though the assistant had ignored it.
|
|
func (s *SQLite) AppendThread(ctx context.Context, projectID, threadID string, msgs []Message) error {
|
|
if len(msgs) == 0 {
|
|
return nil
|
|
}
|
|
retention, err := s.ThreadRetention(ctx, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if retention == 0 {
|
|
return ErrThreadsNotEnabled
|
|
}
|
|
|
|
q, tx, err := s.begin(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
|
|
now := time.Now().UnixMilli()
|
|
var owner string
|
|
err = q.QueryRowContext(ctx, `SELECT project_id FROM threads WHERE id = ?`, threadID).Scan(&owner)
|
|
switch {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
if _, err := q.ExecContext(ctx,
|
|
`INSERT INTO threads (id, project_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
|
|
threadID, projectID, now, now); err != nil {
|
|
return err
|
|
}
|
|
case err != nil:
|
|
return err
|
|
case owner != projectID:
|
|
// Appending to a thread another project owns would let one team write
|
|
// into another's conversation, and then read it back through their
|
|
// own replies.
|
|
return ErrNoThread
|
|
}
|
|
|
|
var next int64
|
|
if err := q.QueryRowContext(ctx,
|
|
`SELECT COALESCE(MAX(seq), -1) + 1 FROM thread_messages WHERE thread_id = ?`,
|
|
threadID).Scan(&next); err != nil {
|
|
return err
|
|
}
|
|
for i, m := range msgs {
|
|
var model any
|
|
if m.Model != "" {
|
|
model = m.Model
|
|
}
|
|
if _, err := q.ExecContext(ctx,
|
|
`INSERT INTO thread_messages (thread_id, seq, role, content, model) VALUES (?, ?, ?, ?, ?)`,
|
|
threadID, next+int64(i), m.Role, m.Content, model); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
// Touched on every append, so retention measures idleness rather than age.
|
|
if _, err := q.ExecContext(ctx,
|
|
`UPDATE threads SET updated_at = ? WHERE id = ?`, now, threadID); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// DeleteThread removes a conversation and everything in it.
|
|
//
|
|
// Somebody has to be able to say "forget that", and a retention window is not
|
|
// an answer to a message sent by mistake.
|
|
func (s *SQLite) DeleteThread(ctx context.Context, projectID, threadID string) error {
|
|
res, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM threads WHERE id = ? AND project_id = ?`, threadID, projectID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if n, _ := res.RowsAffected(); n == 0 {
|
|
return ErrNoThread
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// PruneThreads deletes conversations past their project's retention.
|
|
//
|
|
// Per project, because the window is per project. A single sweep with one
|
|
// cutoff would apply the shortest retention to everyone or the longest to
|
|
// nobody, and the number a project chose is the promise made to it.
|
|
func (s *SQLite) PruneThreads(ctx context.Context, now time.Time) (int, error) {
|
|
type window struct {
|
|
project string
|
|
hours int64
|
|
}
|
|
windows, err := queryAll(ctx, s.db,
|
|
`SELECT id, thread_retention_hours FROM projects WHERE thread_retention_hours > 0`,
|
|
nil, func(rows *sql.Rows) (window, error) {
|
|
var w window
|
|
return w, rows.Scan(&w.project, &w.hours)
|
|
})
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
var removed int
|
|
for _, w := range windows {
|
|
cutoff := now.Add(-time.Duration(w.hours) * time.Hour).UnixMilli()
|
|
res, err := s.db.ExecContext(ctx,
|
|
`DELETE FROM threads WHERE project_id = ? AND updated_at < ?`, w.project, cutoff)
|
|
if err != nil {
|
|
return removed, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
removed += int(n)
|
|
}
|
|
return removed, nil
|
|
}
|