mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-26 14:30:13 +00:00
9bd191048d
* 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
159 lines
5.7 KiB
Go
159 lines
5.7 KiB
Go
package store
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/PerpetualSoftware/pad/internal/models"
|
|
)
|
|
|
|
// BUG-2539. items.updated_at / items.deleted_at are RFC3339 whole-second
|
|
// strings; the /changes cursor is a unix-millisecond value that gets formatted
|
|
// with the same second precision, i.e. truncated DOWN. Under the original
|
|
// strict `>` that made every change landing in the cursor's own second compare
|
|
// equal and vanish — permanently, since the caller advances its cursor past
|
|
// that second afterwards.
|
|
//
|
|
// Both legs use a cursor that is strictly EARLIER than the mutation in real
|
|
// time, so a correct implementation returns the change in BOTH. They differ
|
|
// only in whether the cursor truncates to the same second as the write, which
|
|
// is exactly the axis the bug lives on: the second leg is the control, and it
|
|
// passed before the fix as well as after.
|
|
//
|
|
// The same-second claim is checked against the timestamps the writes ACTUALLY
|
|
// stored, not against a wall-clock reading taken before them — a leg whose
|
|
// writes drifted into the next second would otherwise pass under the unfixed
|
|
// query and be counted as evidence (Codex P2). Alignment is retried rather than
|
|
// skipped so the leg cannot silently stop testing anything.
|
|
func TestItemsModifiedSince_SameSecondCursor(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
name string
|
|
// how far before the mutation the cursor sits
|
|
cursorLead time.Duration
|
|
// whether that should land the cursor inside the mutation's own second
|
|
sameSecond bool
|
|
}{
|
|
{name: "cursor inside the mutation's own second", cursorLead: 200 * time.Millisecond, sameSecond: true},
|
|
{name: "cursor in the previous second (control)", cursorLead: 1200 * time.Millisecond, sameSecond: false},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
const attempts = 8
|
|
for attempt := 1; ; attempt++ {
|
|
aligned := runSameSecondCursorLeg(t, tc.cursorLead, tc.sameSecond, attempt == attempts)
|
|
if aligned {
|
|
return
|
|
}
|
|
if attempt == attempts {
|
|
t.Fatalf("could not land the writes in the intended second after %d attempts", attempts)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// runSameSecondCursorLeg runs one attempt. It reports whether the writes landed
|
|
// in the intended second relative to the cursor; when they did, it asserts the
|
|
// delta. `strict` makes a misalignment fatal rather than a retry signal.
|
|
func runSameSecondCursorLeg(t *testing.T, cursorLead time.Duration, wantSameSecond, strict bool) bool {
|
|
t.Helper()
|
|
|
|
s := testStore(t)
|
|
ws := createTestWorkspace(t, s, "ChangesCursor")
|
|
coll := createTestCollection(t, s, ws.ID, "Tasks")
|
|
|
|
// Park the mutations ~500ms into a wall-clock second so a 200ms lead stays
|
|
// inside that second and a 1200ms lead cannot.
|
|
for {
|
|
if frac := time.Now().UnixMilli() % 1000; frac > 450 && frac < 550 {
|
|
break
|
|
}
|
|
time.Sleep(2 * time.Millisecond)
|
|
}
|
|
|
|
archived := createTestItem(t, s, ws.ID, coll.ID, "archived in-window", "")
|
|
updatedItem := createTestItem(t, s, ws.ID, coll.ID, "updated in-window", "")
|
|
|
|
cursor := time.Now().Add(-cursorLead)
|
|
|
|
if err := s.DeleteItem(archived.ID); err != nil {
|
|
t.Fatalf("archive: %v", err)
|
|
}
|
|
newTitle := "updated in-window (touched)"
|
|
if _, err := s.UpdateItem(updatedItem.ID, models.ItemUpdate{Title: &newTitle}); err != nil {
|
|
t.Fatalf("update: %v", err)
|
|
}
|
|
|
|
// The alignment that matters is between the cursor and what was STORED.
|
|
storedDeletedAt := scalarString(t, s, `SELECT deleted_at FROM items WHERE id = `+s.dialect.Placeholder(1), archived.ID)
|
|
storedUpdatedAt := scalarString(t, s, `SELECT updated_at FROM items WHERE id = `+s.dialect.Placeholder(1), updatedItem.ID)
|
|
cursorSecond := cursor.UTC().Truncate(time.Second).Format(time.RFC3339)
|
|
gotSameSecond := storedDeletedAt == cursorSecond && storedUpdatedAt == cursorSecond
|
|
if gotSameSecond != wantSameSecond {
|
|
if !strict {
|
|
return false
|
|
}
|
|
t.Fatalf("writes landed at deleted_at=%s updated_at=%s against cursor second %s (same-second=%v, wanted %v)",
|
|
storedDeletedAt, storedUpdatedAt, cursorSecond, gotSameSecond, wantSameSecond)
|
|
}
|
|
if cursor.After(time.Now()) {
|
|
t.Fatalf("cursor %s is not earlier than the mutations", cursor)
|
|
}
|
|
|
|
updated, err := s.ItemsModifiedSince(ws.ID, ItemsModifiedSinceParams{Since: cursor})
|
|
if err != nil {
|
|
t.Fatalf("ItemsModifiedSince: %v", err)
|
|
}
|
|
var deletedIDs []string
|
|
for _, it := range updated {
|
|
if it.DeletedAt != nil {
|
|
deletedIDs = append(deletedIDs, it.ID)
|
|
}
|
|
}
|
|
|
|
if !containsID(deletedIDs, archived.ID) {
|
|
t.Errorf("archive stored at %s is missing from the deleted list for cursor second %s (same-second=%v)",
|
|
storedDeletedAt, cursorSecond, wantSameSecond)
|
|
}
|
|
|
|
// The archived row must ALSO come back in `updated` — that is the only
|
|
// coverage of the `(deleted_at IS NULL OR deleted_at >= ?)` arm, which
|
|
// could otherwise regress to `>` unnoticed (Codex P2). Archived views need
|
|
// the full row, not just the id.
|
|
var sawArchivedInUpdated, sawUpdated bool
|
|
for _, it := range updated {
|
|
switch it.ID {
|
|
case archived.ID:
|
|
sawArchivedInUpdated = true
|
|
case updatedItem.ID:
|
|
sawUpdated = true
|
|
}
|
|
}
|
|
if !sawArchivedInUpdated {
|
|
t.Errorf("archived row stored at %s is missing from the updated list for cursor second %s (same-second=%v)",
|
|
storedDeletedAt, cursorSecond, wantSameSecond)
|
|
}
|
|
if !sawUpdated {
|
|
t.Errorf("update stored at %s is missing from the updated list for cursor second %s (same-second=%v)",
|
|
storedUpdatedAt, cursorSecond, wantSameSecond)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func containsID(ids []string, want string) bool {
|
|
for _, id := range ids {
|
|
if id == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func scalarString(t *testing.T, s *Store, query string, args ...any) string {
|
|
t.Helper()
|
|
var v string
|
|
if err := s.db.QueryRow(query, args...).Scan(&v); err != nil {
|
|
t.Fatalf("scalar query %q: %v", query, err)
|
|
}
|
|
return v
|
|
}
|