// Package savedsearch implements the saved-search CRUD API: a per-owner list of // named queries that can be stored, renamed, updated, and deleted. The package // layers as Repository (raw SQL) and Handler (HTTP boundary). Both are steward // services. // // Owner-scope invariant (IV2): the owner is always derived from the // authenticated identity; no ?owner= query parameter is accepted on any // saved-search route. // // Uniqueness invariant (IV3): (owner, name) is a composite primary key; // duplicate names per owner produce a CONFLICT-coded error, never a silent // overwrite. package savedsearch import ( "context" "database/sql" "errors" "strings" "go.bigb.es/auxilia/culpa" "modernc.org/sqlite" sqlite3 "modernc.org/sqlite/lib" "sourcecraft.dev/bigbes/lethe/internal/platform/database" ) // SavedSearch is the row shape for the saved_searches table. The Owner field // carries json:"-" so it is never included in wire responses (IV2); all other // fields use snake_case JSON tags. type SavedSearch struct { Owner string `db:"owner" json:"-"` Name string `db:"name" json:"name"` Query string `db:"query" json:"query"` CreatedAt int64 `db:"created_at" json:"created_at"` UpdatedAt int64 `db:"updated_at" json:"updated_at"` } // Repository is the SQL steward for the saved_searches table. It is stateless // beyond its injected dependencies; Init is empty. type Repository struct { Database *database.Database `inject:""` } // Init satisfies the steward Initer contract. Nothing to set up — the // underlying *sqlx.DB is owned by the Database steward. func (r *Repository) Init(_ context.Context) error { return nil } // List returns all saved searches for the given owner ordered by updated_at // DESC. Returns a non-nil zero-length slice when no rows are present so // JSON-encoding produces [] rather than null. func (r *Repository) List(ctx context.Context, owner string) ([]SavedSearch, error) { const q = `SELECT owner, name, query, created_at, updated_at FROM saved_searches WHERE owner = ? ORDER BY updated_at DESC` out := make([]SavedSearch, 0) if err := r.Database.DB.SelectContext(ctx, &out, q, owner); err != nil { return nil, culpa.WithCode(culpa.Wrap(err, "list saved searches"), "DB_QUERY") } return out, nil } // Create inserts a new saved search row. On primary-key collision (same owner // + name already exists) it returns a CONFLICT-coded error with a public // message suitable for surfacing to clients (IV3). func (r *Repository) Create(ctx context.Context, s SavedSearch) error { const q = `INSERT INTO saved_searches (owner, name, query, created_at, updated_at) VALUES (?, ?, ?, ?, ?)` _, err := r.Database.DB.ExecContext(ctx, q, s.Owner, s.Name, s.Query, s.CreatedAt, s.UpdatedAt) if err != nil { if isSQLiteConstraint(err) { return culpa.WithCode( culpa.WithPublic(culpa.Wrap(err, "create saved search"), "saved search with that name already exists"), "CONFLICT", ) } return culpa.WithCode(culpa.Wrap(err, "create saved search"), "DB_QUERY") } return nil } // Update modifies an existing saved search identified by (owner, oldName). // newName and newQuery are optional; at least one must be non-nil (the handler // enforces this precondition). updated_at is always set to now. // // The rename + read are performed inside a single transaction (BeginTxx) so // the returned SavedSearch reflects the post-update state atomically. // // RowsAffected == 0 → NOT_FOUND; primary-key collision on rename → CONFLICT. func (r *Repository) Update(ctx context.Context, owner, oldName string, newName *string, newQuery *string, now int64) (SavedSearch, error) { tx, err := r.Database.DB.BeginTxx(ctx, nil) if err != nil { return SavedSearch{}, culpa.WithCode(culpa.Wrap(err, "begin update transaction"), "DB_QUERY") } defer func() { _ = tx.Rollback() }() // Build a dynamic SET clause: name and/or query, plus updated_at. var sets []string var args []any if newName != nil { sets = append(sets, "name = ?") args = append(args, *newName) } if newQuery != nil { sets = append(sets, "query = ?") args = append(args, *newQuery) } sets = append(sets, "updated_at = ?") args = append(args, now) // WHERE clause args. args = append(args, owner, oldName) updateQ := "UPDATE saved_searches SET " + strings.Join(sets, ", ") + " WHERE owner = ? AND name = ?" res, err := tx.ExecContext(ctx, updateQ, args...) if err != nil { if isSQLiteConstraint(err) { return SavedSearch{}, culpa.WithCode( culpa.WithPublic(culpa.Wrap(err, "update saved search"), "saved search with that name already exists"), "CONFLICT", ) } return SavedSearch{}, culpa.WithCode(culpa.Wrap(err, "update saved search"), "DB_QUERY") } n, err := res.RowsAffected() if err != nil { return SavedSearch{}, culpa.WithCode(culpa.Wrap(err, "rows affected"), "DB_QUERY") } if n == 0 { return SavedSearch{}, culpa.WithCode( culpa.WithPublic(culpa.New("saved search not found"), "saved search not found"), "NOT_FOUND", ) } // Determine the effective name (may have changed on rename). effectiveName := oldName if newName != nil { effectiveName = *newName } const selectQ = `SELECT owner, name, query, created_at, updated_at FROM saved_searches WHERE owner = ? AND name = ?` var updated SavedSearch if err := tx.GetContext(ctx, &updated, selectQ, owner, effectiveName); err != nil { if errors.Is(err, sql.ErrNoRows) { return SavedSearch{}, culpa.WithCode(culpa.New("saved search not found after update"), "NOT_FOUND") } return SavedSearch{}, culpa.WithCode(culpa.Wrap(err, "read updated saved search"), "DB_QUERY") } if err := tx.Commit(); err != nil { return SavedSearch{}, culpa.WithCode(culpa.Wrap(err, "commit update transaction"), "DB_QUERY") } return updated, nil } // Delete removes the saved search identified by (owner, name). Returns // NOT_FOUND when no row is deleted. func (r *Repository) Delete(ctx context.Context, owner, name string) error { const q = `DELETE FROM saved_searches WHERE owner = ? AND name = ?` res, err := r.Database.DB.ExecContext(ctx, q, owner, name) if err != nil { return culpa.WithCode(culpa.Wrap(err, "delete saved search"), "DB_QUERY") } n, err := res.RowsAffected() if err != nil { return culpa.WithCode(culpa.Wrap(err, "rows affected"), "DB_QUERY") } if n == 0 { return culpa.WithCode( culpa.WithPublic(culpa.New("saved search not found"), "saved search not found"), "NOT_FOUND", ) } return nil } // isSQLiteConstraint returns true when err is a SQLite constraint violation // (PRIMARY KEY, UNIQUE, NOT NULL, or generic CONSTRAINT). This mirrors the // pattern used in internal/domain/ingest/repository.go. func isSQLiteConstraint(err error) bool { var se *sqlite.Error if !errors.As(err, &se) { return false } switch se.Code() { case sqlite3.SQLITE_CONSTRAINT, sqlite3.SQLITE_CONSTRAINT_CHECK, sqlite3.SQLITE_CONSTRAINT_NOTNULL, sqlite3.SQLITE_CONSTRAINT_PRIMARYKEY, sqlite3.SQLITE_CONSTRAINT_UNIQUE: return true } return false }