Skip to content

Commit 512a619

Browse files
authored
feat: Add cursor pagination to list endpoints; remove offset pagination (#42)
* chore: cursor pagination on feedback_records and webhooks * chore: refactoring * chore: removed offset style pagination * chore: refactoring * chore: fix webhook list limit * chore: sorting list
1 parent eed1a56 commit 512a619

21 files changed

Lines changed: 595 additions & 264 deletions

internal/api/handlers/feedback_records_handler.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/formbricks/hub/internal/api/validation"
1515
"github.com/formbricks/hub/internal/huberrors"
1616
"github.com/formbricks/hub/internal/models"
17+
"github.com/formbricks/hub/pkg/cursor"
1718
)
1819

1920
// FeedbackRecordsService defines the interface for feedback records business logic.
@@ -123,6 +124,12 @@ func (h *FeedbackRecordsHandler) List(w http.ResponseWriter, r *http.Request) {
123124

124125
result, err := h.service.ListFeedbackRecords(r.Context(), filters)
125126
if err != nil {
127+
if errors.Is(err, cursor.ErrInvalidCursor) {
128+
response.RespondBadRequest(w, "Invalid cursor: omit for first page, or use the exact next_cursor value from the previous response")
129+
130+
return
131+
}
132+
126133
response.RespondInternalServerError(w, "An unexpected error occurred")
127134

128135
return

internal/api/handlers/search_handler.go

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,9 @@ import (
1818

1919
// SearchService defines the interface for semantic search and similar feedback.
2020
type SearchService interface {
21-
SemanticSearch(ctx context.Context, query, tenantID string, limit, offset int, minScore float64, cursor string) (
21+
SemanticSearch(ctx context.Context, query, tenantID string, limit int, minScore float64, cursor string) (
2222
service.SearchResult, error)
23-
SimilarFeedback(ctx context.Context, feedbackRecordID uuid.UUID, tenantID string, limit, offset int,
23+
SimilarFeedback(ctx context.Context, feedbackRecordID uuid.UUID, tenantID string, limit int,
2424
minScore float64, cursor string) (service.SearchResult, error)
2525
}
2626

@@ -55,14 +55,7 @@ type SemanticSearchResultItem struct {
5555
ValueText string `json:"value_text"` // value_text of the feedback record (the text that was embedded)
5656
}
5757

58-
// maxSearchOffset caps how far paging can go. With OFFSET-based paging the database
59-
// still computes and discards all rows before the offset, so large offsets (e.g. 5000)
60-
// make queries slow. Clamping keeps latency predictable and discourages deep paging.
61-
// To support deeper paging without this limit, switch to cursor-based (keyset) pagination:
62-
// return a cursor (e.g. last score + last feedback_record_id), and query WHERE (score, id) after cursor
63-
// instead of OFFSET.
6458
const (
65-
maxSearchOffset = 1000
6659
defaultSearchLimit = 10
6760
maxSearchLimit = 100
6861
)
@@ -99,17 +92,10 @@ func (h *SearchHandler) SemanticSearch(w http.ResponseWriter, r *http.Request) {
9992
}
10093

10194
limit := parseLimit(r.URL.Query().Get("limit"), defaultSearchLimit, maxSearchLimit)
102-
10395
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
104-
105-
offset := 0
106-
if cursor == "" {
107-
offset = min(parseOffset(r.URL.Query().Get("offset")), maxSearchOffset)
108-
}
109-
11096
minScore := parseMinScore(r.URL.Query().Get("min_score"))
11197

112-
res, err := h.service.SemanticSearch(r.Context(), req.Query, req.TenantID, limit, offset, minScore, cursor)
98+
res, err := h.service.SemanticSearch(r.Context(), req.Query, req.TenantID, limit, minScore, cursor)
11399
if err != nil {
114100
if errors.Is(err, service.ErrMissingTenantID) {
115101
response.RespondBadRequest(w, "tenant_id is required")
@@ -177,17 +163,10 @@ func (h *SearchHandler) SimilarFeedback(w http.ResponseWriter, r *http.Request)
177163
}
178164

179165
limit := parseLimit(r.URL.Query().Get("limit"), defaultSearchLimit, maxSearchLimit)
180-
181166
cursor := strings.TrimSpace(r.URL.Query().Get("cursor"))
182-
183-
offset := 0
184-
if cursor == "" {
185-
offset = min(parseOffset(r.URL.Query().Get("offset")), maxSearchOffset)
186-
}
187-
188167
minScore := parseMinScore(r.URL.Query().Get("min_score"))
189168

190-
res, err := h.service.SimilarFeedback(r.Context(), id, tenantID, limit, offset, minScore, cursor)
169+
res, err := h.service.SimilarFeedback(r.Context(), id, tenantID, limit, minScore, cursor)
191170
if err != nil {
192171
if errors.Is(err, service.ErrEmbeddingNotFound) {
193172
response.RespondNotFound(w, "Feedback record has no embedding for the current model")
@@ -233,20 +212,6 @@ func parseLimit(s string, def, upperBound int) int {
233212
return min(n, upperBound)
234213
}
235214

236-
// parseOffset returns the query param "offset" as a non-negative int; default 0.
237-
func parseOffset(s string) int {
238-
if s == "" {
239-
return 0
240-
}
241-
242-
n, err := strconv.Atoi(s)
243-
if err != nil || n < 0 {
244-
return 0
245-
}
246-
247-
return n
248-
}
249-
250215
// defaultMinScore is the default minimum similarity score when the query param is omitted (reduces noise).
251216
const defaultMinScore = 0.7
252217

internal/api/handlers/search_handler_test.go

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,27 @@ import (
1717
)
1818

1919
type mockSearchService struct {
20-
semanticFunc func(ctx context.Context, query, tenantID string, limit, offset int, minScore float64,
20+
semanticFunc func(ctx context.Context, query, tenantID string, limit int, minScore float64,
2121
cursor string) (service.SearchResult, error)
22-
similarFunc func(ctx context.Context, feedbackRecordID uuid.UUID, tenantID string, limit, offset int,
22+
similarFunc func(ctx context.Context, feedbackRecordID uuid.UUID, tenantID string, limit int,
2323
minScore float64, cursor string) (service.SearchResult, error)
2424
}
2525

2626
func (m *mockSearchService) SemanticSearch(
27-
ctx context.Context, query, tenantID string, limit, offset int, minScore float64, cursor string,
27+
ctx context.Context, query, tenantID string, limit int, minScore float64, cursor string,
2828
) (service.SearchResult, error) {
2929
if m.semanticFunc != nil {
30-
return m.semanticFunc(ctx, query, tenantID, limit, offset, minScore, cursor)
30+
return m.semanticFunc(ctx, query, tenantID, limit, minScore, cursor)
3131
}
3232

3333
return service.SearchResult{}, nil
3434
}
3535

3636
func (m *mockSearchService) SimilarFeedback(
37-
ctx context.Context, feedbackRecordID uuid.UUID, tenantID string, limit, offset int, minScore float64, cursor string,
37+
ctx context.Context, feedbackRecordID uuid.UUID, tenantID string, limit int, minScore float64, cursor string,
3838
) (service.SearchResult, error) {
3939
if m.similarFunc != nil {
40-
return m.similarFunc(ctx, feedbackRecordID, tenantID, limit, offset, minScore, cursor)
40+
return m.similarFunc(ctx, feedbackRecordID, tenantID, limit, minScore, cursor)
4141
}
4242

4343
return service.SearchResult{}, nil
@@ -60,7 +60,7 @@ func TestSearchHandler_SemanticSearch(t *testing.T) {
6060
t.Run("empty query returns 400", func(t *testing.T) {
6161
called := false
6262
mock := &mockSearchService{
63-
semanticFunc: func(_ context.Context, _, _ string, _, _ int, _ float64, _ string) (service.SearchResult, error) {
63+
semanticFunc: func(_ context.Context, _, _ string, _ int, _ float64, _ string) (service.SearchResult, error) {
6464
called = true
6565

6666
return service.SearchResult{}, service.ErrEmptyQuery
@@ -85,13 +85,12 @@ func TestSearchHandler_SemanticSearch(t *testing.T) {
8585
val1 := "Login is very slow."
8686
val2 := "Dashboard loads fast."
8787
mock := &mockSearchService{
88-
semanticFunc: func(_ context.Context, query, tenantID string, limit, offset int, minScore float64,
88+
semanticFunc: func(_ context.Context, query, tenantID string, limit int, minScore float64,
8989
cursor string,
9090
) (service.SearchResult, error) {
9191
assert.Equal(t, "login is slow", query)
9292
assert.Equal(t, "env-1", tenantID)
9393
assert.Equal(t, 10, limit)
94-
assert.Equal(t, 0, offset)
9594
assert.InDelta(t, 0.7, minScore, 1e-9)
9695
assert.Empty(t, cursor)
9796

@@ -132,7 +131,7 @@ func TestSearchHandler_SemanticSearch(t *testing.T) {
132131

133132
t.Run("invalid cursor returns 400", func(t *testing.T) {
134133
mock := &mockSearchService{
135-
semanticFunc: func(_ context.Context, _, _ string, _, _ int, _ float64, cursor string) (service.SearchResult, error) {
134+
semanticFunc: func(_ context.Context, _, _ string, _ int, _ float64, cursor string) (service.SearchResult, error) {
136135
if cursor != "" {
137136
return service.SearchResult{}, service.ErrInvalidCursor
138137
}
@@ -171,7 +170,7 @@ func TestSearchHandler_SimilarFeedback(t *testing.T) {
171170

172171
t.Run("embedding not found returns 404", func(t *testing.T) {
173172
mock := &mockSearchService{
174-
similarFunc: func(_ context.Context, _ uuid.UUID, _ string, _, _ int, _ float64, _ string) (service.SearchResult, error) {
173+
similarFunc: func(_ context.Context, _ uuid.UUID, _ string, _ int, _ float64, _ string) (service.SearchResult, error) {
175174
return service.SearchResult{}, service.ErrEmbeddingNotFound
176175
},
177176
}
@@ -191,13 +190,12 @@ func TestSearchHandler_SimilarFeedback(t *testing.T) {
191190
similarID := uuid.MustParse("018e1234-5678-9abc-def0-aaaaaaaaaaaa")
192191
similarVal := "Similar feedback text."
193192
mock := &mockSearchService{
194-
similarFunc: func(_ context.Context, fid uuid.UUID, tenantID string, limit, offset int, minScore float64,
193+
similarFunc: func(_ context.Context, fid uuid.UUID, tenantID string, limit int, minScore float64,
195194
cursor string,
196195
) (service.SearchResult, error) {
197196
assert.Equal(t, id, fid)
198197
assert.Equal(t, "env-1", tenantID)
199198
assert.Equal(t, 10, limit)
200-
assert.Equal(t, 0, offset)
201199
assert.InDelta(t, 0.7, minScore, 1e-9)
202200
assert.Empty(t, cursor)
203201

internal/api/handlers/webhooks_handler.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/formbricks/hub/internal/api/validation"
1414
"github.com/formbricks/hub/internal/huberrors"
1515
"github.com/formbricks/hub/internal/models"
16+
"github.com/formbricks/hub/pkg/cursor"
1617
)
1718

1819
// WebhooksService defines the interface for webhooks business logic.
@@ -122,6 +123,12 @@ func (h *WebhooksHandler) List(w http.ResponseWriter, r *http.Request) {
122123

123124
result, err := h.service.ListWebhooks(r.Context(), filters)
124125
if err != nil {
126+
if errors.Is(err, cursor.ErrInvalidCursor) {
127+
response.RespondBadRequest(w, "Invalid cursor: omit for first page, or use the exact next_cursor value from the previous response")
128+
129+
return
130+
}
131+
125132
slog.Error("Failed to list webhooks", "method", r.Method, "path", r.URL.Path, "error", err)
126133
response.RespondInternalServerError(w, "An unexpected error occurred")
127134

internal/models/feedback_records.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -186,15 +186,14 @@ type ListFeedbackRecordsFilters struct {
186186
Since *time.Time `form:"since" validate:"omitempty"`
187187
Until *time.Time `form:"until" validate:"omitempty"`
188188
Limit int `form:"limit" validate:"omitempty,min=1,max=1000"`
189-
Offset int `form:"offset" validate:"omitempty,min=0"`
189+
Cursor string `form:"cursor" validate:"omitempty"` // keyset; omit for first page, use next_cursor for next
190190
}
191191

192192
// ListFeedbackRecordsResponse represents the response for listing feedback records.
193193
type ListFeedbackRecordsResponse struct {
194-
Data []FeedbackRecord `json:"data"`
195-
Total int64 `json:"total"`
196-
Limit int `json:"limit"`
197-
Offset int `json:"offset"`
194+
Data []FeedbackRecord `json:"data"`
195+
Limit int `json:"limit"`
196+
NextCursor string `json:"next_cursor,omitempty"` // present when there may be more results
198197
}
199198

200199
// BulkDeleteFilters represents query parameters for bulk delete operation.

internal/models/webhooks.go

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -198,13 +198,12 @@ type ListWebhooksFilters struct {
198198
Enabled *bool `form:"enabled"`
199199
TenantID *string `form:"tenant_id" validate:"omitempty,no_null_bytes"`
200200
Limit int `form:"limit" validate:"omitempty,min=1,max=1000"`
201-
Offset int `form:"offset" validate:"omitempty,min=0"`
201+
Cursor string `form:"cursor" validate:"omitempty"` // keyset cursor; omit for first page, use next_cursor for subsequent pages
202202
}
203203

204204
// ListWebhooksResponse represents the response for listing webhooks.
205205
type ListWebhooksResponse struct {
206-
Data []Webhook `json:"data"`
207-
Total int64 `json:"total"`
208-
Limit int `json:"limit"`
209-
Offset int `json:"offset"`
206+
Data []Webhook `json:"data"`
207+
Limit int `json:"limit"`
208+
NextCursor string `json:"next_cursor,omitempty"` // present when there may be more results
210209
}

internal/repository/embeddings_repository.go

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -169,22 +169,18 @@ func (r *EmbeddingsRepository) GetEmbeddingByFeedbackRecordAndModelAndTenant(
169169
// filtered in application code (not in WHERE) so pgvector's iterative index scan can run. Uses
170170
// full-precision query vector (no quantization); sets hnsw.ef_search for better recall. Over-fetches
171171
// then trims to limit to account for tenant/minScore filtering. excludeID optionally excludes one
172-
// feedback record (e.g. for "similar" endpoint). offset is the number of rows to skip (for paging).
172+
// feedback record (e.g. for "similar" endpoint). First page only; use NearestFeedbackRecordsByEmbeddingAfterCursor for next pages.
173173
func (r *EmbeddingsRepository) NearestFeedbackRecordsByEmbedding(
174-
ctx context.Context, model string, queryEmbedding []float32, tenantID string, limit, offset int, excludeID *uuid.UUID, minScore float64,
174+
ctx context.Context, model string, queryEmbedding []float32, tenantID string, limit int, excludeID *uuid.UUID, minScore float64,
175175
) ([]models.FeedbackRecordWithScore, bool, error) {
176176
if len(queryEmbedding) != models.EmbeddingVectorDimensions {
177177
return nil, false, fmt.Errorf("%w: got %d, want %d", ErrEmbeddingDimensionMismatch, len(queryEmbedding), models.EmbeddingVectorDimensions)
178178
}
179179

180-
if offset < 0 {
181-
offset = 0
182-
}
183-
184180
// Full-precision query vector (ephemeral); pgvector compares vector vs halfvec natively.
185181
queryVec := pgvector.NewVector(queryEmbedding)
186182

187-
fetchLimit := min(limit*nearestOverFetchFactor+offset, maxNearestFetchLimit)
183+
fetchLimit := min(limit*nearestOverFetchFactor, maxNearestFetchLimit)
188184

189185
dbTx, err := r.db.BeginTx(ctx, pgx.TxOptions{})
190186
if err != nil {
@@ -210,15 +206,15 @@ func (r *EmbeddingsRepository) NearestFeedbackRecordsByEmbedding(
210206
INNER JOIN feedback_records fr ON fr.id = e.feedback_record_id
211207
WHERE e.model = $2 AND fr.tenant_id = $3
212208
ORDER BY (e.embedding <=> $1), e.feedback_record_id
213-
LIMIT $4 OFFSET $5`, queryVec, model, tenantID, fetchLimit, offset)
209+
LIMIT $4`, queryVec, model, tenantID, fetchLimit)
214210
} else {
215211
rows, err = dbTx.Query(ctx, `
216212
SELECT e.feedback_record_id, (1 - (e.embedding <=> $1)) AS score, COALESCE(fr.field_label, ''), fr.value_text
217213
FROM embeddings e
218214
INNER JOIN feedback_records fr ON fr.id = e.feedback_record_id
219215
WHERE e.model = $2 AND fr.tenant_id = $3 AND e.feedback_record_id != $4
220216
ORDER BY (e.embedding <=> $1), e.feedback_record_id
221-
LIMIT $5 OFFSET $6`, queryVec, model, tenantID, *excludeID, fetchLimit, offset)
217+
LIMIT $5`, queryVec, model, tenantID, *excludeID, fetchLimit)
222218
}
223219

224220
if err != nil {

0 commit comments

Comments
 (0)