Files
pad/internal/server/bug3791_changes_cursor_test.go
T
xarmian 9bd191048d fix(api): /changes is bounded at 1000 and continues by a cursor on the write order (BUG-3791) (#2151)
* fix(api): /changes is bounded at 1000 and continues by a cursor on the write order, so sync can page without missing a change (BUG-3791)

GET /workspaces/{ws}/changes?since= returned every item modified since
with its body, unbounded, plus a second unbounded query for deleted ids.
It now returns at most `limit` items (missing, invalid or above it: 1000,
as BUG-3704 ruled for the item lists), and when more remain says
has_more with an opaque next_cursor.

The cursor is a position in (seq, id) order, not an offset and not
updated_at. Every write that moves updated_at also moves seq, under the
workspace seq lock on Postgres and the single writer on SQLite, so a
change committed while a caller pages gets a seq past every committed
row and is returned by a later page of the same walk. Every page repeats
the first page's server_time, so the caller's next since is unchanged by
paging.

Visibility moves into the query: a window over hidden rows would let the
has-more probe count them and put a hidden row's position in the cursor.
The deleted list is derived from the page's archived rows (archiving
bumps seq and updated_at), replacing the second query.

The web client's api.changes.since walks every page (walkChanges): it
keeps each item's last row, derives deleted from it, and throws past 50
pages, which every caller already answers with a full refresh.

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS

* fix(api): a /changes cursor continues only the workspace it came from (BUG-3791, Codex r1 Medium)

Claude-Session: https://claude.ai/code/session_01Q9WssCjFuHjSfLABoW4tmS
2026-10-14 03:34:15 -04:00

321 lines
12 KiB
Go

package server
import (
"fmt"
"net/http"
"testing"
"time"
"github.com/PerpetualSoftware/pad/internal/models"
"github.com/PerpetualSoftware/pad/internal/store"
)
// BUG-3791: GET /changes?since= is bounded, and continues by a cursor on the
// workspace write order, so a caller can pull every change in pages and a
// change committed during the walk lands in a later page of it.
type bug3791Page struct {
Updated []models.Item `json:"updated"`
Deleted []string `json:"deleted"`
ServerTime int64 `json:"server_time"`
HasMore bool `json:"has_more"`
NextCursor string `json:"next_cursor"`
}
func bug3791Get(t *testing.T, srv *Server, path, cookie string) bug3791Page {
t.Helper()
res := doRequest(srv, "GET", path, nil)
if cookie != "" {
res = doRequestWithCookie(srv, "GET", path, nil, cookie)
}
if res.Code != http.StatusOK {
t.Fatalf("%s: %d %s", path, res.Code, res.Body.String())
}
var p bug3791Page
parseJSON(t, res, &p)
return p
}
// bug3791Walk pages from since to the end, calling between after each page
// that has a next one.
func bug3791Walk(t *testing.T, srv *Server, slug, cookie string, sinceMs int64, limit int, between func(page int, p bug3791Page)) []bug3791Page {
t.Helper()
base := "/api/v1/workspaces/" + slug + "/changes"
path := fmt.Sprintf("%s?since=%d&limit=%d", base, sinceMs, limit)
var pages []bug3791Page
for i := 0; ; i++ {
if i > 200 {
t.Fatal("walk did not end")
}
p := bug3791Get(t, srv, path, cookie)
pages = append(pages, p)
if len(p.Updated) > limit {
t.Fatalf("page %d: %d rows over limit %d", i, len(p.Updated), limit)
}
if !p.HasMore {
if p.NextCursor != "" {
t.Fatalf("page %d: last page carries a cursor %q", i, p.NextCursor)
}
return pages
}
if p.NextCursor == "" {
t.Fatalf("page %d: has_more without a cursor", i)
}
if between != nil {
between(i, p)
}
path = base + "?cursor=" + p.NextCursor + fmt.Sprintf("&limit=%d", limit)
}
}
func TestBUG3791_ChangesIsBounded(t *testing.T) {
bothBackends(t, func(t *testing.T, srv *Server) {
slug, wsID := task3462Workspace(t, srv, "Bounds 3791", "startup")
tasks, err := srv.store.GetCollectionBySlug(wsID, "tasks")
if err != nil || tasks == nil {
t.Fatalf("tasks: %v", err)
}
for i := 0; i < store.MaxChangesLimit+1; i++ {
if _, err := srv.store.CreateItem(wsID, tasks.ID, models.ItemCreate{Title: fmt.Sprintf("Bounded %04d", i), Fields: `{"status":"open"}`}); err != nil {
t.Fatal(err)
}
}
for _, q := range []string{"", "&limit=0", "&limit=-5", "&limit=abc", "&limit=5000"} {
p := bug3791Get(t, srv, "/api/v1/workspaces/"+slug+"/changes?since=0"+q, "")
if len(p.Updated) != store.MaxChangesLimit || !p.HasMore || p.NextCursor == "" {
t.Fatalf("since=0%s: %d rows, has_more %v, cursor %q; want %d, true, a cursor", q, len(p.Updated), p.HasMore, p.NextCursor, store.MaxChangesLimit)
}
}
})
}
// A walk visits every change exactly once, reports deletions, repeats the first
// page's server time, and a change committed between pages, to a row an earlier
// page already returned, to a row no page has reached yet, or a new row, is
// returned by a later page of the same walk.
func TestBUG3791_WalkSeesEveryChangeIncludingMidWalkWrites(t *testing.T) {
bothBackends(t, func(t *testing.T, srv *Server) {
slug, wsID := task3462Workspace(t, srv, "Walk 3791", "startup")
tasks, err := srv.store.GetCollectionBySlug(wsID, "tasks")
if err != nil || tasks == nil {
t.Fatalf("tasks: %v", err)
}
var made []*models.Item
for i := 0; i < 9; i++ {
it, err := srv.store.CreateItem(wsID, tasks.ID, models.ItemCreate{Title: fmt.Sprintf("Walk %d", i), Fields: `{"status":"open"}`})
if err != nil {
t.Fatal(err)
}
made = append(made, it)
}
if err := srv.store.DeleteItem(made[4].ID); err != nil {
t.Fatal(err)
}
// The template's seeded rows predate the walk's since. The live rows made
// here all tie on updated_at, so an updated_at order cannot be what keeps
// the walk exact.
if _, err := srv.store.DB().Exec(srv.store.D().Rebind(`UPDATE items SET updated_at = '2020-01-01T00:00:00Z' WHERE workspace_id = ? AND collection_id <> ?`), wsID, tasks.ID); err != nil {
t.Fatal(err)
}
if _, err := srv.store.DB().Exec(srv.store.D().Rebind(`UPDATE items SET updated_at = '2026-01-01T00:00:00Z' WHERE collection_id = ? AND deleted_at IS NULL`), tasks.ID); err != nil {
t.Fatal(err)
}
// They tie on seq as well, which no write path produces but the column
// does not forbid: the id tie-break is what keeps the walk exact then.
if _, err := srv.store.DB().Exec(srv.store.D().Rebind(`UPDATE items SET seq = (SELECT MAX(seq) FROM items WHERE workspace_id = ?) WHERE collection_id = ?`), wsID, tasks.ID); err != nil {
t.Fatal(err)
}
sinceMs := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
var touchedEarly, touchedLate, added *models.Item
const renamed = "Walk renamed mid-walk"
pages := bug3791Walk(t, srv, slug, "", sinceMs, 3, func(page int, p bug3791Page) {
if page != 0 {
return
}
firstPageIDs := map[string]bool{}
for _, it := range p.Updated {
firstPageIDs[it.ID] = true
}
// A made row page 0 returned, and one it did not.
for _, it := range made {
if it.ID == made[4].ID {
continue
}
if firstPageIDs[it.ID] && touchedEarly == nil {
touchedEarly = it
}
if !firstPageIDs[it.ID] && touchedLate == nil {
touchedLate = it
}
}
if touchedLate == nil {
t.Fatal("precondition: page 0 returned every made row")
}
if touchedEarly == nil {
t.Fatal("precondition: page 0 returned no made row")
}
for _, it := range []*models.Item{touchedEarly, touchedLate} {
title := renamed
if _, err := srv.store.UpdateItem(it.ID, models.ItemUpdate{Title: &title}); err != nil {
t.Fatal(err)
}
}
var err error
added, err = srv.store.CreateItem(wsID, tasks.ID, models.ItemCreate{Title: "Walk added mid-walk", Fields: `{"status":"open"}`})
if err != nil {
t.Fatal(err)
}
})
seen := map[string]int{}
latestTitle := map[string]string{}
deleted := map[string]bool{}
for i, p := range pages {
if p.ServerTime != pages[0].ServerTime {
t.Fatalf("page %d server_time %d, first page %d", i, p.ServerTime, pages[0].ServerTime)
}
for _, it := range p.Updated {
seen[it.ID]++
latestTitle[it.ID] = it.Title
}
for _, id := range p.Deleted {
deleted[id] = true
}
}
if !deleted[made[4].ID] {
t.Errorf("the deleted row is not in any page's deleted list")
}
for _, it := range made {
if it == touchedEarly {
continue
}
if seen[it.ID] != 1 {
t.Errorf("%s returned %d times, want once", it.Title, seen[it.ID])
}
}
if seen[added.ID] != 1 {
t.Errorf("the row created mid-walk returned %d times, want once", seen[added.ID])
}
if latestTitle[touchedLate.ID] != renamed {
t.Errorf("the row renamed before any page reached it ends the walk titled %q", latestTitle[touchedLate.ID])
}
if seen[touchedEarly.ID] != 2 || latestTitle[touchedEarly.ID] != renamed {
t.Errorf("the row renamed after page 0 returned it: seen %d times, last title %q; want 2, %q", seen[touchedEarly.ID], latestTitle[touchedEarly.ID], renamed)
}
})
}
// Visibility is applied before the window, not after: a restricted member's
// walk returns only the rows they may see, and the has-more probe does not count
// hidden rows, so a page followed only by hidden rows is the last page.
func TestBUG3791_WalkWindowIsOverVisibleRows(t *testing.T) {
srv := testServer(t)
ownerCookie := bootstrapFirstUser(t, srv, "owner@test.com", "Owner")
rr := doRequestWithCookie(srv, "POST", "/api/v1/workspaces", map[string]string{"name": "Vis 3791"}, ownerCookie)
if rr.Code != http.StatusCreated {
t.Fatalf("create workspace: %d %s", rr.Code, rr.Body.String())
}
var ws models.Workspace
parseJSON(t, rr, &ws)
owner, _ := srv.store.GetUserByEmail("owner@test.com")
tasks, _ := srv.store.GetCollectionBySlug(ws.ID, "tasks")
ideas, _ := srv.store.GetCollectionBySlug(ws.ID, "ideas")
docs, _ := srv.store.GetCollectionBySlug(ws.ID, "docs")
if tasks == nil || ideas == nil || docs == nil {
t.Fatal("precondition: startup collections")
}
mk := func(collID, title string) *models.Item {
it, err := srv.store.CreateItem(ws.ID, collID, models.ItemCreate{Title: title})
if err != nil {
t.Fatal(err)
}
return it
}
// Write order: the visible rows first, then only hidden ones.
visibleTask := mk(tasks.ID, "Task in an assigned collection")
grantedIdea := mk(ideas.ID, "Granted idea")
hidden := []*models.Item{mk(ideas.ID, "Hidden idea"), mk(docs.ID, "Hidden doc"), mk(ideas.ID, "Hidden idea 2")}
m, err := srv.store.CreateUser(models.UserCreate{Email: "restricted@test.com", Name: "Restricted", Password: "correct-horse-battery-staple"})
if err != nil {
t.Fatal(err)
}
if err := srv.store.AddWorkspaceMember(ws.ID, m.ID, "viewer"); err != nil {
t.Fatal(err)
}
if err := srv.store.SetMemberCollectionAccess(ws.ID, m.ID, "specific", []string{tasks.ID}); err != nil {
t.Fatal(err)
}
if _, err := srv.store.CreateItemGrant(ws.ID, grantedIdea.ID, m.ID, "view", owner.ID); err != nil {
t.Fatal(err)
}
cookie, err := srv.store.CreateSession(m.ID, "web-test", "192.0.2.1", "", webSessionTTL)
if err != nil {
t.Fatal(err)
}
// Only the rows made above are after since.
if _, err := srv.store.DB().Exec(srv.store.D().Rebind(`UPDATE items SET updated_at = '2020-01-01T00:00:00Z' WHERE workspace_id = ?`), ws.ID); err != nil {
t.Fatal(err)
}
ids := []string{visibleTask.ID, grantedIdea.ID}
for _, h := range hidden {
ids = append(ids, h.ID)
}
for _, id := range ids {
if _, err := srv.store.DB().Exec(srv.store.D().Rebind(`UPDATE items SET updated_at = '2026-01-01T00:00:00Z' WHERE id = ?`), id); err != nil {
t.Fatal(err)
}
}
sinceMs := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC).UnixMilli()
pages := bug3791Walk(t, srv, ws.Slug, cookie, sinceMs, 1, nil)
var got []string
for _, p := range pages {
for _, it := range p.Updated {
got = append(got, it.Title)
}
}
if len(pages) != 2 || len(got) != 2 || got[0] != visibleTask.Title || got[1] != grantedIdea.Title {
t.Fatalf("restricted walk: %d pages, rows %q; want 2 pages: [%q %q]", len(pages), got, visibleTask.Title, grantedIdea.Title)
}
// The owner's walk over the same window sees all five.
owned := 0
for _, p := range bug3791Walk(t, srv, ws.Slug, ownerCookie, sinceMs, 2, nil) {
owned += len(p.Updated)
}
if owned != 5 {
t.Fatalf("owner walk returned %d rows, want 5", owned)
}
}
func TestBUG3791_CursorIsValidated(t *testing.T) {
srv := testServer(t)
slug, wsID := task3462Workspace(t, srv, "Cursor 3791", "startup")
base := "/api/v1/workspaces/" + slug + "/changes"
p := bug3791Get(t, srv, base+"?since=0&limit=1", "")
if !p.HasMore {
t.Fatal("precondition: the template seeds more than one row")
}
for _, q := range []string{
"?cursor=garbage",
"?cursor=c1.!!!",
"?cursor=" + changesCursor{WorkspaceID: wsID, SinceMs: 0, ServerTimeMs: 1}.encode(), // no row id
"?cursor=" + changesCursor{SinceMs: 0, ServerTimeMs: 1, After: store.ChangesPosition{Seq: 1, ID: "x"}}.encode(), // no workspace
"?since=5&cursor=" + p.NextCursor,
} {
if rr := doRequest(srv, "GET", base+q, nil); rr.Code != http.StatusBadRequest {
t.Errorf("%s: %d, want 400", q, rr.Code)
}
}
if rr := doRequest(srv, "GET", base+"?since=0&cursor="+p.NextCursor, nil); rr.Code != http.StatusOK {
t.Errorf("a since equal to the cursor's: %d, want 200", rr.Code)
}
// Codex r1 (Medium): a cursor continues only the workspace it came from.
otherSlug, _ := task3462Workspace(t, srv, "Cursor 3791 other", "startup")
if rr := doRequest(srv, "GET", "/api/v1/workspaces/"+otherSlug+"/changes?cursor="+p.NextCursor, nil); rr.Code != http.StatusBadRequest {
t.Errorf("another workspace's cursor: %d, want 400", rr.Code)
}
}