M cmd/lethe/main.go => cmd/lethe/main.go +10 -4
@@ 26,6 26,7 @@ import (
"sourcecraft.dev/bigbes/lethe/internal/config"
"sourcecraft.dev/bigbes/lethe/internal/domain/ingest"
"sourcecraft.dev/bigbes/lethe/internal/domain/project"
+ "sourcecraft.dev/bigbes/lethe/internal/domain/savedsearch"
"sourcecraft.dev/bigbes/lethe/internal/domain/session"
"sourcecraft.dev/bigbes/lethe/internal/domain/stats"
"sourcecraft.dev/bigbes/lethe/internal/platform/database"
@@ 100,15 101,18 @@ func run() int {
sessionHnd = &session.Handler{}
projectRepo = &project.Repository{}
projectHnd = &project.Handler{}
- statsRepo = &stats.Repository{}
- statsHnd = &stats.Handler{}
- serverSvc = &server.Server{}
+ statsRepo = &stats.Repository{}
+ statsHnd = &stats.Handler{}
+ savedSearchRepo = &savedsearch.Repository{}
+ savedSearchHnd = &savedsearch.Handler{}
+ serverSvc = &server.Server{}
)
registered := []any{
loggerSvc, metricsSvc, dbSvc, dbCheckSvc, healthSetSvc,
authSvc, ingestRepo, ingestSvc, ingestHnd,
- sessionRepo, sessionHnd, projectRepo, projectHnd, statsRepo, statsHnd, serverSvc,
+ sessionRepo, sessionHnd, projectRepo, projectHnd, statsRepo, statsHnd,
+ savedSearchRepo, savedSearchHnd, serverSvc,
}
mgr.AddComponent(ctx,
@@ 128,6 132,8 @@ func run() int {
steward.MustServiceAsset(projectHnd),
steward.MustServiceAsset(statsRepo),
steward.MustServiceAsset(statsHnd),
+ steward.MustServiceAsset(savedSearchRepo),
+ steward.MustServiceAsset(savedSearchHnd),
steward.MustServiceAsset(serverSvc, steward.Root()),
)
A internal/domain/savedsearch/handler.go => internal/domain/savedsearch/handler.go +236 -0
@@ 0,0 1,236 @@
+package savedsearch
+
+import (
+ "context"
+ "encoding/json"
+ "log/slog"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+ "go.bigb.es/auxilia/culpa"
+ "go.bigb.es/auxilia/scribe"
+
+ "sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
+ "sourcecraft.dev/bigbes/lethe/internal/pkg/httputil"
+ "sourcecraft.dev/bigbes/lethe/internal/server/auth"
+)
+
+// Handler is the steward-managed HTTP boundary for the saved-searches CRUD API.
+// Repo is the injected SQL steward; the Handler holds no other state.
+type Handler struct {
+ Repo *Repository `inject:""`
+}
+
+// Init satisfies the steward Initer contract. The Handler is stateless beyond
+// its injected dependencies.
+func (h *Handler) Init(_ context.Context) error { return nil }
+
+// Mount registers the four CRUD routes under r. Server.Init mounts this inside
+// the /api/v1 group, so the effective paths are:
+//
+// GET /api/v1/saved-searches
+// POST /api/v1/saved-searches
+// PUT /api/v1/saved-searches/{name}
+// DELETE /api/v1/saved-searches/{name}
+func (h *Handler) Mount(r chi.Router) {
+ r.Get("/saved-searches", h.List)
+ r.Post("/saved-searches", h.Create)
+ r.Put("/saved-searches/{name}", h.Update)
+ r.Delete("/saved-searches/{name}", h.Delete)
+}
+
+// ownerOf derives the owner from the authenticated identity on the context.
+// The ?owner= query parameter is intentionally ignored (IV2).
+func (h *Handler) ownerOf(r *http.Request) string {
+ return auth.MustIdentity(r.Context()).User
+}
+
+// validateName enforces the name constraints (IV7, UK1):
+// - non-empty
+// - at most 64 characters
+// - no "/" character
+func validateName(name string) error {
+ if name == "" {
+ return culpa.WithCode(
+ culpa.WithPublic(culpa.New("name is empty"), "name must not be empty"),
+ "VALIDATION",
+ )
+ }
+ if len(name) > 64 {
+ return culpa.WithCode(
+ culpa.WithPublic(culpa.New("name too long"), "name must be 64 characters or fewer"),
+ "VALIDATION",
+ )
+ }
+ if strings.Contains(name, "/") {
+ return culpa.WithCode(
+ culpa.WithPublic(culpa.New("name contains slash"), "name must not contain '/'"),
+ "VALIDATION",
+ )
+ }
+ return nil
+}
+
+// validateQuery enforces the query constraints: non-empty.
+func validateQuery(query string) error {
+ if query == "" {
+ return culpa.WithCode(
+ culpa.WithPublic(culpa.New("query is empty"), "query must not be empty"),
+ "VALIDATION",
+ )
+ }
+ return nil
+}
+
+// listResponse is the JSON body returned by List.
+type listResponse struct {
+ SavedSearches []SavedSearch `json:"saved_searches"`
+}
+
+// List handles GET /saved-searches. The owner is derived from the auth context;
+// ?owner= is silently ignored (IV2). Writes { saved_searches: [...] }.
+func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
+ owner := h.ownerOf(r)
+ rows, err := h.Repo.List(r.Context(), owner)
+ if err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+ if writeErr := httputil.WriteJSON(w, http.StatusOK, listResponse{SavedSearches: rows}); writeErr != nil {
+ slog.Default().ErrorContext(r.Context(), "write saved-searches list response", scribe.Err(writeErr))
+ }
+}
+
+// createRequest is the decoded JSON body for POST /saved-searches.
+type createRequest struct {
+ Name string `json:"name"`
+ Query string `json:"query"`
+}
+
+// Create handles POST /saved-searches. Decodes { name, query }, validates both
+// fields, inserts the row with now as both timestamps, and returns 201 Created
+// with the new row body.
+func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
+ // Defensive: reject any explicit ?owner= param to prevent confusion (IV2).
+ if r.URL.Query().Get("owner") != "" {
+ apierror.Render(w, r, culpa.WithCode(
+ culpa.WithPublic(culpa.New("?owner= not accepted on write paths"), "?owner= is not accepted on saved-search write paths"),
+ "INVALID",
+ ))
+ return
+ }
+
+ var req createRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ apierror.Render(w, r, culpa.WithCode(
+ culpa.WithPublic(culpa.Wrap(err, "decode body"), "invalid request body"),
+ "INVALID",
+ ))
+ return
+ }
+
+ if err := validateName(req.Name); err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+ if err := validateQuery(req.Query); err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+
+ now := time.Now().Unix()
+ s := SavedSearch{
+ Owner: h.ownerOf(r),
+ Name: req.Name,
+ Query: req.Query,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := h.Repo.Create(r.Context(), s); err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+
+ if writeErr := httputil.WriteJSON(w, http.StatusCreated, s); writeErr != nil {
+ slog.Default().ErrorContext(r.Context(), "write saved-search create response", scribe.Err(writeErr))
+ }
+}
+
+// updateRequest is the decoded JSON body for PUT /saved-searches/{name}.
+// Both fields are optional; at least one must be non-nil.
+type updateRequest struct {
+ Name *string `json:"name"`
+ Query *string `json:"query"`
+}
+
+// Update handles PUT /saved-searches/{name}. Decodes { name?, query? }, requires
+// at least one field, validates non-nil fields, and returns 200 with the updated row.
+func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
+ // Defensive: reject any explicit ?owner= param (IV2).
+ if r.URL.Query().Get("owner") != "" {
+ apierror.Render(w, r, culpa.WithCode(
+ culpa.WithPublic(culpa.New("?owner= not accepted on write paths"), "?owner= is not accepted on saved-search write paths"),
+ "INVALID",
+ ))
+ return
+ }
+
+ urlName := chi.URLParam(r, "name")
+
+ var req updateRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
+ apierror.Render(w, r, culpa.WithCode(
+ culpa.WithPublic(culpa.Wrap(err, "decode body"), "invalid request body"),
+ "INVALID",
+ ))
+ return
+ }
+
+ if req.Name == nil && req.Query == nil {
+ apierror.Render(w, r, culpa.WithCode(
+ culpa.WithPublic(culpa.New("no fields to update"), "at least one of name or query must be provided"),
+ "VALIDATION",
+ ))
+ return
+ }
+
+ if req.Name != nil {
+ if err := validateName(*req.Name); err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+ }
+ if req.Query != nil {
+ if err := validateQuery(*req.Query); err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+ }
+
+ owner := h.ownerOf(r)
+ now := time.Now().Unix()
+ updated, err := h.Repo.Update(r.Context(), owner, urlName, req.Name, req.Query, now)
+ if err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+
+ if writeErr := httputil.WriteJSON(w, http.StatusOK, updated); writeErr != nil {
+ slog.Default().ErrorContext(r.Context(), "write saved-search update response", scribe.Err(writeErr))
+ }
+}
+
+// Delete handles DELETE /saved-searches/{name}. Returns 204 No Content on
+// success, 404 when the row does not exist.
+func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
+ owner := h.ownerOf(r)
+ name := chi.URLParam(r, "name")
+
+ if err := h.Repo.Delete(r.Context(), owner, name); err != nil {
+ apierror.Render(w, r, err)
+ return
+ }
+ w.WriteHeader(http.StatusNoContent)
+}
A internal/domain/savedsearch/handler_test.go => internal/domain/savedsearch/handler_test.go +332 -0
@@ 0,0 1,332 @@
+package savedsearch_test
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/lethe/internal/domain/savedsearch"
+ "sourcecraft.dev/bigbes/lethe/internal/server/auth"
+)
+
+// fakeAuthMiddleware injects a fixed Identity onto the request context so
+// the handler can call auth.MustIdentity without a real Authenticator.
+func fakeAuthMiddleware(id auth.Identity) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ ctx := auth.WithIdentity(r.Context(), id)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
+
+// newHandler wires a Handler against a fresh in-memory database.
+func newHandler(t *testing.T) *savedsearch.Handler {
+ t.Helper()
+ repo := newRepo(t)
+ h := &savedsearch.Handler{Repo: repo}
+ if err := h.Init(context.Background()); err != nil {
+ t.Fatalf("handler.Init: %v", err)
+ }
+ return h
+}
+
+// mountWithIdentity builds a chi router with the fake auth middleware and
+// the savedsearch handler mounted under /api/v1.
+func mountWithIdentity(h *savedsearch.Handler, id auth.Identity) http.Handler {
+ r := chi.NewRouter()
+ r.Route("/api/v1", func(r chi.Router) {
+ r.Use(fakeAuthMiddleware(id))
+ h.Mount(r)
+ })
+ return r
+}
+
+// savedSearchListBody is the decoded JSON body from GET /saved-searches.
+type savedSearchListBody struct {
+ SavedSearches []json.RawMessage `json:"saved_searches"`
+}
+
+// problemBody captures RFC 7807 fields tests assert on.
+type problemBody struct {
+ Status int `json:"status"`
+ Code string `json:"code"`
+}
+
+func doGET(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodGet, path, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
+
+func doPOST(t *testing.T, router http.Handler, path string, body any) *httptest.ResponseRecorder {
+ t.Helper()
+ b, err := json.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal POST body: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
+
+func doPUT(t *testing.T, router http.Handler, path string, body any) *httptest.ResponseRecorder {
+ t.Helper()
+ b, err := json.Marshal(body)
+ if err != nil {
+ t.Fatalf("marshal PUT body: %v", err)
+ }
+ req := httptest.NewRequest(http.MethodPut, path, bytes.NewReader(b))
+ req.Header.Set("Content-Type", "application/json")
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
+
+func doDELETE(t *testing.T, router http.Handler, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodDelete, path, nil)
+ rec := httptest.NewRecorder()
+ router.ServeHTTP(rec, req)
+ return rec
+}
+
+// TestHandler_List_Authenticated verifies GET /saved-searches returns 200
+// with { saved_searches: [] } when no rows exist.
+func TestHandler_List_Authenticated(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec := doGET(t, router, "/api/v1/saved-searches")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d; want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ var body savedSearchListBody
+ if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
+ t.Fatalf("unmarshal: %v (body=%s)", err, rec.Body.String())
+ }
+ if body.SavedSearches == nil {
+ t.Fatal("saved_searches key missing or null")
+ }
+ if len(body.SavedSearches) != 0 {
+ t.Fatalf("expected empty list; got %d items", len(body.SavedSearches))
+ }
+}
+
+// TestHandler_List_OwnerParamIgnored verifies that ?owner= is silently ignored
+// (IV2) and the response is identical to a call without the param.
+func TestHandler_List_OwnerParamIgnored(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec1 := doGET(t, router, "/api/v1/saved-searches")
+ rec2 := doGET(t, router, "/api/v1/saved-searches?owner=alice")
+
+ if rec1.Code != http.StatusOK || rec2.Code != http.StatusOK {
+ t.Fatalf("status1=%d status2=%d; both want 200", rec1.Code, rec2.Code)
+ }
+ if rec1.Body.String() != rec2.Body.String() {
+ t.Fatalf("bodies differ:\n no-param: %s\n with-param: %s",
+ rec1.Body.String(), rec2.Body.String())
+ }
+}
+
+// TestHandler_List_OwnerFieldAbsentInJSON verifies that Owner field is not
+// present in the JSON output (json:"-").
+func TestHandler_List_OwnerFieldAbsentInJSON(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ // Create a row first.
+ doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "test", "query": "q"})
+
+ rec := doGET(t, router, "/api/v1/saved-searches")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("status=%d; want 200; body=%s", rec.Code, rec.Body.String())
+ }
+ bodyStr := rec.Body.String()
+ if strings.Contains(bodyStr, `"owner"`) {
+ t.Fatalf("owner field should be absent in JSON; body=%s", bodyStr)
+ }
+}
+
+// TestHandler_Create_EmptyNameReturns400 verifies validation of empty name.
+func TestHandler_Create_EmptyNameReturns400(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "", "query": "x"})
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "VALIDATION" {
+ t.Fatalf("expected VALIDATION; got %q (body=%s)", p.Code, rec.Body.String())
+ }
+}
+
+// TestHandler_Create_SlashInNameReturns400 verifies that a name with "/" is
+// rejected (IV7).
+func TestHandler_Create_SlashInNameReturns400(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "a/b", "query": "x"})
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "VALIDATION" {
+ t.Fatalf("expected VALIDATION; got %q (body=%s)", p.Code, rec.Body.String())
+ }
+}
+
+// TestHandler_Create_NameTooLongReturns400 verifies the 64-char cap (UK1).
+func TestHandler_Create_NameTooLongReturns400(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ longName := strings.Repeat("a", 65)
+ rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": longName, "query": "x"})
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "VALIDATION" {
+ t.Fatalf("expected VALIDATION; got %q (body=%s)", p.Code, rec.Body.String())
+ }
+}
+
+// TestHandler_Create_EmptyQueryReturns400 verifies that an empty query is
+// rejected.
+func TestHandler_Create_EmptyQueryReturns400(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "ok", "query": ""})
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("status=%d; want 400; body=%s", rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "VALIDATION" {
+ t.Fatalf("expected VALIDATION; got %q (body=%s)", p.Code, rec.Body.String())
+ }
+}
+
+// TestHandler_Create_Valid verifies that a valid POST returns 201 with the new row.
+func TestHandler_Create_Valid(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "mysearch", "query": "model:gpt-4"})
+ if rec.Code != http.StatusCreated {
+ t.Fatalf("status=%d; want 201; body=%s", rec.Code, rec.Body.String())
+ }
+ var got savedsearch.SavedSearch
+ if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil {
+ t.Fatalf("unmarshal: %v (body=%s)", err, rec.Body.String())
+ }
+ if got.Name != "mysearch" {
+ t.Errorf("Name: got %q; want %q", got.Name, "mysearch")
+ }
+ if got.Query != "model:gpt-4" {
+ t.Errorf("Query: got %q; want %q", got.Query, "model:gpt-4")
+ }
+}
+
+// TestHandler_Create_DuplicateReturns409 verifies that a duplicate name for the
+// same owner returns 409 CONFLICT with problem+json.
+func TestHandler_Create_DuplicateReturns409(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "x", "query": "q1"})
+ rec := doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "x", "query": "q2"})
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("status=%d; want 409; body=%s", rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "CONFLICT" {
+ t.Fatalf("expected CONFLICT; got %q (body=%s)", p.Code, rec.Body.String())
+ }
+ // Verify content-type is problem+json.
+ ct := rec.Header().Get("Content-Type")
+ if !strings.Contains(ct, "application/problem+json") {
+ t.Fatalf("expected application/problem+json; got %q", ct)
+ }
+}
+
+// TestHandler_Update_NotFound verifies that PUT /saved-searches/missing returns 404.
+func TestHandler_Update_NotFound(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ rec := doPUT(t, router, "/api/v1/saved-searches/missing", map[string]*string{"query": ptrStr("new")})
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("status=%d; want 404; body=%s", rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "NOT_FOUND" {
+ t.Fatalf("expected NOT_FOUND; got %q (body=%s)", p.Code, rec.Body.String())
+ }
+}
+
+// TestHandler_Update_RenameConflict verifies that renaming onto an existing name
+// for the same owner returns 409 CONFLICT.
+func TestHandler_Update_RenameConflict(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "x", "query": "q1"})
+ doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "y", "query": "q2"})
+
+ rec := doPUT(t, router, "/api/v1/saved-searches/x", map[string]*string{"name": ptrStr("y")})
+ if rec.Code != http.StatusConflict {
+ t.Fatalf("status=%d; want 409; body=%s", rec.Code, rec.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec.Body.Bytes(), &p)
+ if p.Code != "CONFLICT" {
+ t.Fatalf("expected CONFLICT; got %q (body=%s)", p.Code, rec.Body.String())
+ }
+}
+
+// TestHandler_Delete_Sequence verifies 204 on first delete and 404 on second.
+func TestHandler_Delete_Sequence(t *testing.T) {
+ h := newHandler(t)
+ router := mountWithIdentity(h, auth.Identity{User: "alice"})
+
+ // Create a row first.
+ doPOST(t, router, "/api/v1/saved-searches", map[string]string{"name": "todel", "query": "q"})
+
+ rec1 := doDELETE(t, router, "/api/v1/saved-searches/todel")
+ if rec1.Code != http.StatusNoContent {
+ t.Fatalf("first delete: status=%d; want 204; body=%s", rec1.Code, rec1.Body.String())
+ }
+
+ rec2 := doDELETE(t, router, "/api/v1/saved-searches/todel")
+ if rec2.Code != http.StatusNotFound {
+ t.Fatalf("second delete: status=%d; want 404; body=%s", rec2.Code, rec2.Body.String())
+ }
+ var p problemBody
+ _ = json.Unmarshal(rec2.Body.Bytes(), &p)
+ if p.Code != "NOT_FOUND" {
+ t.Fatalf("expected NOT_FOUND; got %q", p.Code)
+ }
+}
A internal/domain/savedsearch/repository.go => internal/domain/savedsearch/repository.go +198 -0
@@ 0,0 1,198 @@
+// 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
+}
+
A internal/domain/savedsearch/repository_test.go => internal/domain/savedsearch/repository_test.go +321 -0
@@ 0,0 1,321 @@
+package savedsearch_test
+
+import (
+ "context"
+ "testing"
+ "time"
+
+ "go.bigb.es/auxilia/culpa"
+ _ "modernc.org/sqlite"
+
+ "sourcecraft.dev/bigbes/lethe/internal/config"
+ "sourcecraft.dev/bigbes/lethe/internal/domain/savedsearch"
+ "sourcecraft.dev/bigbes/lethe/internal/platform/database"
+)
+
+// newTestDatabase builds a Database steward against :memory: (one DB per
+// test, isolated). Cleanup runs Destroy.
+func newTestDatabase(t *testing.T) *database.Database {
+ t.Helper()
+ d := &database.Database{
+ Cfg: config.DatabaseConfig{
+ Path: ":memory:",
+ BusyTimeout: 5 * time.Second,
+ },
+ }
+ if err := d.Init(context.Background()); err != nil {
+ t.Fatalf("database.Init: %v", err)
+ }
+ t.Cleanup(func() { _ = d.Destroy(context.Background()) })
+ return d
+}
+
+// newRepo wires a Repository against a fresh in-memory database.
+func newRepo(t *testing.T) *savedsearch.Repository {
+ t.Helper()
+ d := newTestDatabase(t)
+ repo := &savedsearch.Repository{Database: d}
+ if err := repo.Init(context.Background()); err != nil {
+ t.Fatalf("repo.Init: %v", err)
+ }
+ return repo
+}
+
+// codeOf walks the culpa chain for a CodeDetail and returns the string code,
+// or "" if there isn't one.
+func codeOf(err error) string {
+ var cd culpa.CodeDetail
+ if !culpa.FindDetail(err, &cd) {
+ return ""
+ }
+ s, ok := cd.Code.(string)
+ if !ok {
+ return ""
+ }
+ return s
+}
+
+func ptrStr(s string) *string { return &s }
+
+// TestList_EmptyDB verifies that List on an empty DB returns a non-nil,
+// zero-length slice (JSON [] rather than null).
+func TestList_EmptyDB(t *testing.T) {
+ repo := newRepo(t)
+ got, err := repo.List(context.Background(), "alice")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if got == nil {
+ t.Fatal("expected non-nil slice; got nil")
+ }
+ if len(got) != 0 {
+ t.Fatalf("expected zero-length slice; got len=%d", len(got))
+ }
+}
+
+// TestCreate_ThenList verifies that a Created row is returned by List with
+// correct field values and that created_at == updated_at == the injected now.
+func TestCreate_ThenList(t *testing.T) {
+ repo := newRepo(t)
+ now := int64(1700000000)
+ s := savedsearch.SavedSearch{
+ Owner: "alice",
+ Name: "my search",
+ Query: "model:gpt-4",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := repo.Create(context.Background(), s); err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ rows, err := repo.List(context.Background(), "alice")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(rows) != 1 {
+ t.Fatalf("expected 1 row; got %d", len(rows))
+ }
+ got := rows[0]
+ if got.Name != "my search" {
+ t.Errorf("Name: got %q; want %q", got.Name, "my search")
+ }
+ if got.Query != "model:gpt-4" {
+ t.Errorf("Query: got %q; want %q", got.Query, "model:gpt-4")
+ }
+ if got.CreatedAt != now {
+ t.Errorf("CreatedAt: got %d; want %d", got.CreatedAt, now)
+ }
+ if got.UpdatedAt != now {
+ t.Errorf("UpdatedAt: got %d; want %d", got.UpdatedAt, now)
+ }
+ // Owner is stored in DB but json:"-"; verify the struct still has it.
+ if got.Owner != "alice" {
+ t.Errorf("Owner: got %q; want %q", got.Owner, "alice")
+ }
+}
+
+// TestCreate_Duplicate verifies that a second Create with the same (owner, name)
+// returns a CONFLICT-coded error.
+func TestCreate_Duplicate(t *testing.T) {
+ repo := newRepo(t)
+ now := int64(1700000000)
+ s := savedsearch.SavedSearch{
+ Owner: "alice",
+ Name: "dupe",
+ Query: "q1",
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+ if err := repo.Create(context.Background(), s); err != nil {
+ t.Fatalf("first Create: %v", err)
+ }
+ s.Query = "q2"
+ err := repo.Create(context.Background(), s)
+ if err == nil {
+ t.Fatal("expected CONFLICT error; got nil")
+ }
+ if code := codeOf(err); code != "CONFLICT" {
+ t.Fatalf("expected code CONFLICT; got %q", code)
+ }
+}
+
+// TestCreate_SameNameDifferentOwner verifies that the same name can be used by
+// two different owners without conflict.
+func TestCreate_SameNameDifferentOwner(t *testing.T) {
+ repo := newRepo(t)
+ now := int64(1700000000)
+ if err := repo.Create(context.Background(), savedsearch.SavedSearch{
+ Owner: "alice", Name: "shared", Query: "q1",
+ CreatedAt: now, UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("alice Create: %v", err)
+ }
+ if err := repo.Create(context.Background(), savedsearch.SavedSearch{
+ Owner: "bob", Name: "shared", Query: "q2",
+ CreatedAt: now, UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("bob Create: %v", err)
+ }
+
+ aliceRows, _ := repo.List(context.Background(), "alice")
+ bobRows, _ := repo.List(context.Background(), "bob")
+ if len(aliceRows) != 1 {
+ t.Errorf("alice: expected 1 row; got %d", len(aliceRows))
+ }
+ if len(bobRows) != 1 {
+ t.Errorf("bob: expected 1 row; got %d", len(bobRows))
+ }
+}
+
+// TestUpdate_NotFound verifies that updating a non-existent (owner, name)
+// returns a NOT_FOUND-coded error.
+func TestUpdate_NotFound(t *testing.T) {
+ repo := newRepo(t)
+ _, err := repo.Update(context.Background(), "alice", "missing", nil, ptrStr("newq"), 1700000001)
+ if err == nil {
+ t.Fatal("expected NOT_FOUND error; got nil")
+ }
+ if code := codeOf(err); code != "NOT_FOUND" {
+ t.Fatalf("expected code NOT_FOUND; got %q", code)
+ }
+}
+
+// TestUpdate_RenameConflict verifies that renaming onto an existing name for
+// the same owner returns a CONFLICT-coded error.
+func TestUpdate_RenameConflict(t *testing.T) {
+ repo := newRepo(t)
+ now := int64(1700000000)
+ if err := repo.Create(context.Background(), savedsearch.SavedSearch{
+ Owner: "alice", Name: "a", Query: "q1",
+ CreatedAt: now, UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("Create a: %v", err)
+ }
+ if err := repo.Create(context.Background(), savedsearch.SavedSearch{
+ Owner: "alice", Name: "b", Query: "q2",
+ CreatedAt: now, UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("Create b: %v", err)
+ }
+
+ // Try to rename "a" to "b" (which already exists for alice).
+ _, err := repo.Update(context.Background(), "alice", "a", ptrStr("b"), nil, now+1)
+ if err == nil {
+ t.Fatal("expected CONFLICT error; got nil")
+ }
+ if code := codeOf(err); code != "CONFLICT" {
+ t.Fatalf("expected code CONFLICT; got %q", code)
+ }
+}
+
+// TestUpdate_QueryOnly verifies that passing newName=nil only updates the query
+// and updated_at, leaving the name unchanged.
+func TestUpdate_QueryOnly(t *testing.T) {
+ repo := newRepo(t)
+ now := int64(1700000000)
+ if err := repo.Create(context.Background(), savedsearch.SavedSearch{
+ Owner: "alice", Name: "myq", Query: "old query",
+ CreatedAt: now, UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ later := now + 100
+ updated, err := repo.Update(context.Background(), "alice", "myq", nil, ptrStr("new query"), later)
+ if err != nil {
+ t.Fatalf("Update: %v", err)
+ }
+ if updated.Name != "myq" {
+ t.Errorf("Name should be unchanged: got %q; want %q", updated.Name, "myq")
+ }
+ if updated.Query != "new query" {
+ t.Errorf("Query: got %q; want %q", updated.Query, "new query")
+ }
+ if updated.UpdatedAt != later {
+ t.Errorf("UpdatedAt: got %d; want %d", updated.UpdatedAt, later)
+ }
+ // created_at should remain the original.
+ if updated.CreatedAt != now {
+ t.Errorf("CreatedAt should be unchanged: got %d; want %d", updated.CreatedAt, now)
+ }
+}
+
+// TestDelete_NotFound verifies that deleting a non-existent row returns a
+// NOT_FOUND-coded error.
+func TestDelete_NotFound(t *testing.T) {
+ repo := newRepo(t)
+ err := repo.Delete(context.Background(), "alice", "ghost")
+ if err == nil {
+ t.Fatal("expected NOT_FOUND error; got nil")
+ }
+ if code := codeOf(err); code != "NOT_FOUND" {
+ t.Fatalf("expected code NOT_FOUND; got %q", code)
+ }
+}
+
+// TestDelete_ExistingRow verifies that deleting an existing row succeeds and
+// that the row is absent on a subsequent List.
+func TestDelete_ExistingRow(t *testing.T) {
+ repo := newRepo(t)
+ now := int64(1700000000)
+ if err := repo.Create(context.Background(), savedsearch.SavedSearch{
+ Owner: "alice", Name: "bye", Query: "q",
+ CreatedAt: now, UpdatedAt: now,
+ }); err != nil {
+ t.Fatalf("Create: %v", err)
+ }
+
+ if err := repo.Delete(context.Background(), "alice", "bye"); err != nil {
+ t.Fatalf("Delete: %v", err)
+ }
+
+ rows, err := repo.List(context.Background(), "alice")
+ if err != nil {
+ t.Fatalf("List after Delete: %v", err)
+ }
+ if len(rows) != 0 {
+ t.Fatalf("expected 0 rows after delete; got %d", len(rows))
+ }
+
+ // Subsequent delete should be NOT_FOUND.
+ err = repo.Delete(context.Background(), "alice", "bye")
+ if err == nil {
+ t.Fatal("expected NOT_FOUND on second delete; got nil")
+ }
+ if code := codeOf(err); code != "NOT_FOUND" {
+ t.Fatalf("expected NOT_FOUND; got %q", code)
+ }
+}
+
+// TestList_OrderByUpdatedAtDesc verifies that rows are returned newest first
+// according to updated_at.
+func TestList_OrderByUpdatedAtDesc(t *testing.T) {
+ repo := newRepo(t)
+ base := int64(1700000000)
+ // Insert three rows with staggered updated_at values.
+ for i, name := range []string{"first", "second", "third"} {
+ ts := base + int64(i)*100 // first=base, second=base+100, third=base+200
+ if err := repo.Create(context.Background(), savedsearch.SavedSearch{
+ Owner: "alice",
+ Name: name,
+ Query: "q",
+ CreatedAt: ts,
+ UpdatedAt: ts,
+ }); err != nil {
+ t.Fatalf("Create %s: %v", name, err)
+ }
+ }
+
+ rows, err := repo.List(context.Background(), "alice")
+ if err != nil {
+ t.Fatalf("List: %v", err)
+ }
+ if len(rows) != 3 {
+ t.Fatalf("expected 3 rows; got %d", len(rows))
+ }
+ // Expected order: third (newest), second, first (oldest).
+ if rows[0].Name != "third" || rows[1].Name != "second" || rows[2].Name != "first" {
+ t.Fatalf("unexpected order: %s, %s, %s", rows[0].Name, rows[1].Name, rows[2].Name)
+ }
+}
A internal/platform/database/migrations/0002_saved_searches.down.sql => internal/platform/database/migrations/0002_saved_searches.down.sql +2 -0
@@ 0,0 1,2 @@
+DROP INDEX IF EXISTS idx_saved_searches_owner_updated;
+DROP TABLE IF EXISTS saved_searches;
A internal/platform/database/migrations/0002_saved_searches.up.sql => internal/platform/database/migrations/0002_saved_searches.up.sql +10 -0
@@ 0,0 1,10 @@
+CREATE TABLE saved_searches (
+ owner TEXT NOT NULL,
+ name TEXT NOT NULL,
+ query TEXT NOT NULL,
+ created_at INTEGER NOT NULL,
+ updated_at INTEGER NOT NULL,
+ PRIMARY KEY (owner, name)
+) WITHOUT ROWID;
+CREATE INDEX idx_saved_searches_owner_updated
+ ON saved_searches (owner, updated_at DESC);
M internal/server/server.go => internal/server/server.go +8 -5
@@ 31,6 31,7 @@ import (
"sourcecraft.dev/bigbes/lethe/internal/config"
"sourcecraft.dev/bigbes/lethe/internal/domain/ingest"
"sourcecraft.dev/bigbes/lethe/internal/domain/project"
+ "sourcecraft.dev/bigbes/lethe/internal/domain/savedsearch"
"sourcecraft.dev/bigbes/lethe/internal/domain/session"
"sourcecraft.dev/bigbes/lethe/internal/domain/stats"
"sourcecraft.dev/bigbes/lethe/internal/pkg/apierror"
@@ 55,11 56,12 @@ type Server struct {
Metrics *observability.Metrics `inject:""`
Health *health.Set `inject:""`
- Auth *authpkg.Authenticator `inject:""`
- Ingest *ingest.Handler `inject:""`
- Sessions *session.Handler `inject:""`
- Projects *project.Handler `inject:""`
- Stats *stats.Handler `inject:""`
+ Auth *authpkg.Authenticator `inject:""`
+ Ingest *ingest.Handler `inject:""`
+ Sessions *session.Handler `inject:""`
+ Projects *project.Handler `inject:""`
+ Stats *stats.Handler `inject:""`
+ SavedSearches *savedsearch.Handler `inject:""`
router *chi.Mux
httpSrv *http.Server
@@ 103,6 105,7 @@ func (s *Server) Init(_ context.Context) error {
s.Sessions.Mount(r)
s.Projects.Mount(r)
s.Stats.Mount(r)
+ s.SavedSearches.Mount(r)
})
// SPA catch-all: serves the embedded React app for all non-API GET paths.