···8282 slog.Warn("Failed to bootstrap events from repo", "error", err)
8383 }
84848585- // Wire up repo event handler to broadcaster
8686- holdPDS.RepomgrRef().SetEventHandler(broadcaster.SetRepoEventHandler(), true)
8585+ // Backfill records index from existing MST data (one-time on startup)
8686+ if err := holdPDS.BackfillRecordsIndex(ctx); err != nil {
8787+ slog.Warn("Failed to backfill records index", "error", err)
8888+ }
8989+9090+ // Wire up repo event handler with records indexing + broadcaster
9191+ // The indexing handler wraps the broadcaster handler to keep index in sync
9292+ indexingHandler := holdPDS.CreateRecordsIndexEventHandler(broadcaster.SetRepoEventHandler())
9393+ holdPDS.RepomgrRef().SetEventHandler(indexingHandler, true)
87948888- slog.Info("Embedded PDS initialized successfully with firehose enabled")
9595+ slog.Info("Embedded PDS initialized successfully with firehose and records index enabled")
8996 } else {
9097 slog.Error("Database path is required for embedded PDS authorization")
9198 os.Exit(1)
+6-1
pkg/appview/jetstream/backfill.go
···5050 return &BackfillWorker{
5151 db: database,
5252 client: client, // This points to the relay
5353- processor: NewProcessor(database, false, nil), // No cache for batch processing, no stats
5353+ processor: NewProcessor(database, false, NewStatsCache()), // Stats cache for aggregation
5454 defaultHoldDID: defaultHoldDID,
5555 testMode: testMode,
5656 refresher: refresher,
···7676 atproto.StarCollection, // io.atcr.sailor.star
7777 atproto.SailorProfileCollection, // io.atcr.sailor.profile
7878 atproto.RepoPageCollection, // io.atcr.repo.page
7979+ atproto.StatsCollection, // io.atcr.hold.stats (from holds)
7980 }
80818182 for _, collection := range collections {
···311312 case atproto.RepoPageCollection:
312313 // rkey is extracted from the record URI, but for repo pages we use Repository field
313314 return b.processor.ProcessRepoPage(ctx, did, record.URI, record.Value, false)
315315+ case atproto.StatsCollection:
316316+ // Stats are stored in hold PDSes, not user PDSes
317317+ // 'did' here is the hold's DID (e.g., did:web:hold01.atcr.io)
318318+ return b.processor.ProcessStats(ctx, did, record.Value, false)
314319 default:
315320 return fmt.Errorf("unsupported collection: %s", collection)
316321 }
+251
pkg/hold/pds/records.go
···11+package pds
22+33+import (
44+ "context"
55+ "database/sql"
66+ "fmt"
77+ "log/slog"
88+ "strings"
99+1010+ "github.com/bluesky-social/indigo/repo"
1111+ "github.com/ipfs/go-cid"
1212+ _ "github.com/mattn/go-sqlite3"
1313+)
1414+1515+// RecordsIndex provides an efficient index for listing records
1616+// This follows the official ATProto PDS pattern of using SQL for queries
1717+// while MST is used for sync operations.
1818+type RecordsIndex struct {
1919+ db *sql.DB
2020+}
2121+2222+// Record represents a record in the index
2323+type Record struct {
2424+ Collection string
2525+ Rkey string
2626+ Cid string
2727+}
2828+2929+const recordsSchema = `
3030+CREATE TABLE IF NOT EXISTS records (
3131+ collection TEXT NOT NULL,
3232+ rkey TEXT NOT NULL,
3333+ cid TEXT NOT NULL,
3434+ PRIMARY KEY (collection, rkey)
3535+);
3636+CREATE INDEX IF NOT EXISTS idx_records_collection_rkey ON records(collection, rkey);
3737+`
3838+3939+// NewRecordsIndex creates or opens a records index
4040+func NewRecordsIndex(dbPath string) (*RecordsIndex, error) {
4141+ db, err := sql.Open("sqlite3", dbPath)
4242+ if err != nil {
4343+ return nil, fmt.Errorf("failed to open records database: %w", err)
4444+ }
4545+4646+ // Create schema
4747+ _, err = db.Exec(recordsSchema)
4848+ if err != nil {
4949+ db.Close()
5050+ return nil, fmt.Errorf("failed to create records schema: %w", err)
5151+ }
5252+5353+ return &RecordsIndex{db: db}, nil
5454+}
5555+5656+// Close closes the database connection
5757+func (ri *RecordsIndex) Close() error {
5858+ if ri.db != nil {
5959+ return ri.db.Close()
6060+ }
6161+ return nil
6262+}
6363+6464+// IndexRecord adds or updates a record in the index
6565+func (ri *RecordsIndex) IndexRecord(collection, rkey, cidStr string) error {
6666+ _, err := ri.db.Exec(`
6767+ INSERT OR REPLACE INTO records (collection, rkey, cid)
6868+ VALUES (?, ?, ?)
6969+ `, collection, rkey, cidStr)
7070+ return err
7171+}
7272+7373+// DeleteRecord removes a record from the index
7474+func (ri *RecordsIndex) DeleteRecord(collection, rkey string) error {
7575+ _, err := ri.db.Exec(`
7676+ DELETE FROM records WHERE collection = ? AND rkey = ?
7777+ `, collection, rkey)
7878+ return err
7979+}
8080+8181+// ListRecords returns records for a collection with pagination support
8282+// reverse=false (default): newest first (rkey DESC)
8383+// reverse=true: oldest first (rkey ASC)
8484+func (ri *RecordsIndex) ListRecords(collection string, limit int, cursor string, reverse bool) ([]Record, string, error) {
8585+ // Build query based on sort order
8686+ var query string
8787+ var args []any
8888+8989+ if reverse {
9090+ // Oldest first (ascending order)
9191+ if cursor != "" {
9292+ query = `
9393+ SELECT collection, rkey, cid FROM records
9494+ WHERE collection = ? AND rkey > ?
9595+ ORDER BY rkey ASC
9696+ LIMIT ?
9797+ `
9898+ args = []any{collection, cursor, limit + 1}
9999+ } else {
100100+ query = `
101101+ SELECT collection, rkey, cid FROM records
102102+ WHERE collection = ?
103103+ ORDER BY rkey ASC
104104+ LIMIT ?
105105+ `
106106+ args = []any{collection, limit + 1}
107107+ }
108108+ } else {
109109+ // Newest first (descending order) - default
110110+ if cursor != "" {
111111+ query = `
112112+ SELECT collection, rkey, cid FROM records
113113+ WHERE collection = ? AND rkey < ?
114114+ ORDER BY rkey DESC
115115+ LIMIT ?
116116+ `
117117+ args = []any{collection, cursor, limit + 1}
118118+ } else {
119119+ query = `
120120+ SELECT collection, rkey, cid FROM records
121121+ WHERE collection = ?
122122+ ORDER BY rkey DESC
123123+ LIMIT ?
124124+ `
125125+ args = []any{collection, limit + 1}
126126+ }
127127+ }
128128+129129+ rows, err := ri.db.Query(query, args...)
130130+ if err != nil {
131131+ return nil, "", fmt.Errorf("failed to query records: %w", err)
132132+ }
133133+ defer rows.Close()
134134+135135+ var records []Record
136136+ for rows.Next() {
137137+ var rec Record
138138+ if err := rows.Scan(&rec.Collection, &rec.Rkey, &rec.Cid); err != nil {
139139+ return nil, "", fmt.Errorf("failed to scan record: %w", err)
140140+ }
141141+ records = append(records, rec)
142142+ }
143143+144144+ if err := rows.Err(); err != nil {
145145+ return nil, "", fmt.Errorf("error iterating records: %w", err)
146146+ }
147147+148148+ // Determine next cursor
149149+ var nextCursor string
150150+ if len(records) > limit {
151151+ // More records available, set cursor to the last included record
152152+ nextCursor = records[limit-1].Rkey
153153+ records = records[:limit]
154154+ }
155155+156156+ return records, nextCursor, nil
157157+}
158158+159159+// Count returns the number of records in a collection
160160+func (ri *RecordsIndex) Count(collection string) (int, error) {
161161+ var count int
162162+ err := ri.db.QueryRow(`
163163+ SELECT COUNT(*) FROM records WHERE collection = ?
164164+ `, collection).Scan(&count)
165165+ return count, err
166166+}
167167+168168+// TotalCount returns the total number of records in the index
169169+func (ri *RecordsIndex) TotalCount() (int, error) {
170170+ var count int
171171+ err := ri.db.QueryRow(`SELECT COUNT(*) FROM records`).Scan(&count)
172172+ return count, err
173173+}
174174+175175+// BackfillFromRepo populates the records index from an existing MST repo
176176+// Compares MST count with index count - only backfills if they differ
177177+func (ri *RecordsIndex) BackfillFromRepo(ctx context.Context, repoHandle *repo.Repo) error {
178178+ // Count records in MST
179179+ mstCount := 0
180180+ err := repoHandle.ForEach(ctx, "", func(key string, c cid.Cid) error {
181181+ mstCount++
182182+ return nil
183183+ })
184184+ if err != nil {
185185+ return fmt.Errorf("failed to count MST records: %w", err)
186186+ }
187187+188188+ // Count records in index
189189+ indexCount, err := ri.TotalCount()
190190+ if err != nil {
191191+ return fmt.Errorf("failed to check index count: %w", err)
192192+ }
193193+194194+ // Skip if counts match
195195+ if indexCount == mstCount {
196196+ slog.Debug("Records index in sync with MST", "count", indexCount)
197197+ return nil
198198+ }
199199+200200+ slog.Info("Backfilling records index from MST...", "mstCount", mstCount, "indexCount", indexCount)
201201+202202+ // Begin transaction for bulk insert
203203+ tx, err := ri.db.BeginTx(ctx, nil)
204204+ if err != nil {
205205+ return fmt.Errorf("failed to begin transaction: %w", err)
206206+ }
207207+ defer tx.Rollback()
208208+209209+ stmt, err := tx.Prepare(`
210210+ INSERT OR REPLACE INTO records (collection, rkey, cid)
211211+ VALUES (?, ?, ?)
212212+ `)
213213+ if err != nil {
214214+ return fmt.Errorf("failed to prepare statement: %w", err)
215215+ }
216216+ defer stmt.Close()
217217+218218+ recordCount := 0
219219+ err = repoHandle.ForEach(ctx, "", func(key string, c cid.Cid) error {
220220+ // key format: "collection/rkey"
221221+ parts := strings.SplitN(key, "/", 2)
222222+ if len(parts) != 2 {
223223+ return nil // Skip malformed keys
224224+ }
225225+ collection, rkey := parts[0], parts[1]
226226+227227+ _, err := stmt.Exec(collection, rkey, c.String())
228228+ if err != nil {
229229+ return fmt.Errorf("failed to index record %s: %w", key, err)
230230+ }
231231+ recordCount++
232232+233233+ // Log progress every 1000 records
234234+ if recordCount%1000 == 0 {
235235+ slog.Debug("Backfill progress", "count", recordCount)
236236+ }
237237+238238+ return nil
239239+ })
240240+241241+ if err != nil {
242242+ return fmt.Errorf("failed to walk repo: %w", err)
243243+ }
244244+245245+ if err := tx.Commit(); err != nil {
246246+ return fmt.Errorf("failed to commit transaction: %w", err)
247247+ }
248248+249249+ slog.Info("Backfill complete", "records", recordCount)
250250+ return nil
251251+}
+627
pkg/hold/pds/records_test.go
···11+package pds
22+33+import (
44+ "context"
55+ "os"
66+ "path/filepath"
77+ "testing"
88+99+ "github.com/bluesky-social/indigo/repo"
1010+ _ "github.com/mattn/go-sqlite3"
1111+)
1212+1313+// Tests for RecordsIndex
1414+1515+// TestNewRecordsIndex tests creating a new records index
1616+func TestNewRecordsIndex(t *testing.T) {
1717+ tmpDir := t.TempDir()
1818+ dbPath := filepath.Join(tmpDir, "records.db")
1919+2020+ ri, err := NewRecordsIndex(dbPath)
2121+ if err != nil {
2222+ t.Fatalf("NewRecordsIndex() error = %v", err)
2323+ }
2424+ defer ri.Close()
2525+2626+ if ri.db == nil {
2727+ t.Error("Expected db to be non-nil")
2828+ }
2929+3030+ // Verify database file was created
3131+ if _, err := os.Stat(dbPath); os.IsNotExist(err) {
3232+ t.Error("Expected database file to be created")
3333+ }
3434+}
3535+3636+// TestNewRecordsIndex_InvalidPath tests error handling for invalid path
3737+func TestNewRecordsIndex_InvalidPath(t *testing.T) {
3838+ // Try to create in a non-existent directory
3939+ _, err := NewRecordsIndex("/nonexistent/dir/records.db")
4040+ if err == nil {
4141+ t.Error("Expected error for invalid path")
4242+ }
4343+}
4444+4545+// TestRecordsIndex_IndexRecord tests adding records to the index
4646+func TestRecordsIndex_IndexRecord(t *testing.T) {
4747+ tmpDir := t.TempDir()
4848+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
4949+ if err != nil {
5050+ t.Fatalf("NewRecordsIndex() error = %v", err)
5151+ }
5252+ defer ri.Close()
5353+5454+ // Index a record
5555+ err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123")
5656+ if err != nil {
5757+ t.Fatalf("IndexRecord() error = %v", err)
5858+ }
5959+6060+ // Verify it was indexed
6161+ count, err := ri.Count("io.atcr.hold.crew")
6262+ if err != nil {
6363+ t.Fatalf("Count() error = %v", err)
6464+ }
6565+ if count != 1 {
6666+ t.Errorf("Expected count 1, got %d", count)
6767+ }
6868+}
6969+7070+// TestRecordsIndex_IndexRecord_Upsert tests updating an existing record
7171+func TestRecordsIndex_IndexRecord_Upsert(t *testing.T) {
7272+ tmpDir := t.TempDir()
7373+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
7474+ if err != nil {
7575+ t.Fatalf("NewRecordsIndex() error = %v", err)
7676+ }
7777+ defer ri.Close()
7878+7979+ // Index a record
8080+ err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123")
8181+ if err != nil {
8282+ t.Fatalf("IndexRecord() first call error = %v", err)
8383+ }
8484+8585+ // Update the same record with new CID
8686+ err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei456")
8787+ if err != nil {
8888+ t.Fatalf("IndexRecord() second call error = %v", err)
8989+ }
9090+9191+ // Count should still be 1 (upsert, not insert)
9292+ count, err := ri.Count("io.atcr.hold.crew")
9393+ if err != nil {
9494+ t.Fatalf("Count() error = %v", err)
9595+ }
9696+ if count != 1 {
9797+ t.Errorf("Expected count 1 after upsert, got %d", count)
9898+ }
9999+100100+ // Verify the CID was updated
101101+ records, _, err := ri.ListRecords("io.atcr.hold.crew", 10, "", false)
102102+ if err != nil {
103103+ t.Fatalf("ListRecords() error = %v", err)
104104+ }
105105+ if len(records) != 1 {
106106+ t.Fatalf("Expected 1 record, got %d", len(records))
107107+ }
108108+ if records[0].Cid != "bafyrei456" {
109109+ t.Errorf("Expected CID bafyrei456, got %s", records[0].Cid)
110110+ }
111111+}
112112+113113+// TestRecordsIndex_DeleteRecord tests removing a record from the index
114114+func TestRecordsIndex_DeleteRecord(t *testing.T) {
115115+ tmpDir := t.TempDir()
116116+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
117117+ if err != nil {
118118+ t.Fatalf("NewRecordsIndex() error = %v", err)
119119+ }
120120+ defer ri.Close()
121121+122122+ // Index a record
123123+ err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123")
124124+ if err != nil {
125125+ t.Fatalf("IndexRecord() error = %v", err)
126126+ }
127127+128128+ // Delete it
129129+ err = ri.DeleteRecord("io.atcr.hold.crew", "abc123")
130130+ if err != nil {
131131+ t.Fatalf("DeleteRecord() error = %v", err)
132132+ }
133133+134134+ // Verify it was deleted
135135+ count, err := ri.Count("io.atcr.hold.crew")
136136+ if err != nil {
137137+ t.Fatalf("Count() error = %v", err)
138138+ }
139139+ if count != 0 {
140140+ t.Errorf("Expected count 0 after delete, got %d", count)
141141+ }
142142+}
143143+144144+// TestRecordsIndex_DeleteRecord_NotExists tests deleting a non-existent record
145145+func TestRecordsIndex_DeleteRecord_NotExists(t *testing.T) {
146146+ tmpDir := t.TempDir()
147147+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
148148+ if err != nil {
149149+ t.Fatalf("NewRecordsIndex() error = %v", err)
150150+ }
151151+ defer ri.Close()
152152+153153+ // Delete a record that doesn't exist - should not error
154154+ err = ri.DeleteRecord("io.atcr.hold.crew", "nonexistent")
155155+ if err != nil {
156156+ t.Errorf("DeleteRecord() should not error for non-existent record, got: %v", err)
157157+ }
158158+}
159159+160160+// TestRecordsIndex_Close tests clean shutdown
161161+func TestRecordsIndex_Close(t *testing.T) {
162162+ tmpDir := t.TempDir()
163163+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
164164+ if err != nil {
165165+ t.Fatalf("NewRecordsIndex() error = %v", err)
166166+ }
167167+168168+ err = ri.Close()
169169+ if err != nil {
170170+ t.Errorf("Close() error = %v", err)
171171+ }
172172+173173+ // Double close should not panic (nil check)
174174+ ri.db = nil
175175+ err = ri.Close()
176176+ if err != nil {
177177+ t.Errorf("Close() on nil db error = %v", err)
178178+ }
179179+}
180180+181181+// TestRecordsIndex_ListRecords_Empty tests listing an empty collection
182182+func TestRecordsIndex_ListRecords_Empty(t *testing.T) {
183183+ tmpDir := t.TempDir()
184184+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
185185+ if err != nil {
186186+ t.Fatalf("NewRecordsIndex() error = %v", err)
187187+ }
188188+ defer ri.Close()
189189+190190+ records, cursor, err := ri.ListRecords("io.atcr.hold.crew", 10, "", false)
191191+ if err != nil {
192192+ t.Fatalf("ListRecords() error = %v", err)
193193+ }
194194+195195+ if len(records) != 0 {
196196+ t.Errorf("Expected empty records, got %d", len(records))
197197+ }
198198+ if cursor != "" {
199199+ t.Errorf("Expected empty cursor, got %s", cursor)
200200+ }
201201+}
202202+203203+// TestRecordsIndex_ListRecords_Basic tests basic listing
204204+func TestRecordsIndex_ListRecords_Basic(t *testing.T) {
205205+ tmpDir := t.TempDir()
206206+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
207207+ if err != nil {
208208+ t.Fatalf("NewRecordsIndex() error = %v", err)
209209+ }
210210+ defer ri.Close()
211211+212212+ // Add some records
213213+ records := []struct {
214214+ rkey string
215215+ cid string
216216+ }{
217217+ {"aaa", "cid1"},
218218+ {"bbb", "cid2"},
219219+ {"ccc", "cid3"},
220220+ }
221221+ for _, r := range records {
222222+ if err := ri.IndexRecord("io.atcr.hold.crew", r.rkey, r.cid); err != nil {
223223+ t.Fatalf("IndexRecord() error = %v", err)
224224+ }
225225+ }
226226+227227+ // List all
228228+ result, cursor, err := ri.ListRecords("io.atcr.hold.crew", 10, "", false)
229229+ if err != nil {
230230+ t.Fatalf("ListRecords() error = %v", err)
231231+ }
232232+233233+ if len(result) != 3 {
234234+ t.Errorf("Expected 3 records, got %d", len(result))
235235+ }
236236+ if cursor != "" {
237237+ t.Errorf("Expected no cursor when all records returned, got %s", cursor)
238238+ }
239239+}
240240+241241+// TestRecordsIndex_ListRecords_DefaultOrder tests newest-first ordering (DESC)
242242+func TestRecordsIndex_ListRecords_DefaultOrder(t *testing.T) {
243243+ tmpDir := t.TempDir()
244244+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
245245+ if err != nil {
246246+ t.Fatalf("NewRecordsIndex() error = %v", err)
247247+ }
248248+ defer ri.Close()
249249+250250+ // Add records with different rkeys (TIDs are lexicographically ordered by time)
251251+ rkeys := []string{"3m3aaaaaaaaa", "3m3bbbbbbbbb", "3m3ccccccccc"}
252252+ for _, rkey := range rkeys {
253253+ if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey); err != nil {
254254+ t.Fatalf("IndexRecord() error = %v", err)
255255+ }
256256+ }
257257+258258+ // List with default order (newest first = DESC)
259259+ records, _, err := ri.ListRecords("io.atcr.hold.crew", 10, "", false)
260260+ if err != nil {
261261+ t.Fatalf("ListRecords() error = %v", err)
262262+ }
263263+264264+ // Should be in descending order
265265+ if len(records) != 3 {
266266+ t.Fatalf("Expected 3 records, got %d", len(records))
267267+ }
268268+ if records[0].Rkey != "3m3ccccccccc" {
269269+ t.Errorf("Expected first record rkey=3m3ccccccccc, got %s", records[0].Rkey)
270270+ }
271271+ if records[1].Rkey != "3m3bbbbbbbbb" {
272272+ t.Errorf("Expected second record rkey=3m3bbbbbbbbb, got %s", records[1].Rkey)
273273+ }
274274+ if records[2].Rkey != "3m3aaaaaaaaa" {
275275+ t.Errorf("Expected third record rkey=3m3aaaaaaaaa, got %s", records[2].Rkey)
276276+ }
277277+}
278278+279279+// TestRecordsIndex_ListRecords_ReverseOrder tests oldest-first ordering (ASC)
280280+func TestRecordsIndex_ListRecords_ReverseOrder(t *testing.T) {
281281+ tmpDir := t.TempDir()
282282+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
283283+ if err != nil {
284284+ t.Fatalf("NewRecordsIndex() error = %v", err)
285285+ }
286286+ defer ri.Close()
287287+288288+ // Add records
289289+ rkeys := []string{"3m3aaaaaaaaa", "3m3bbbbbbbbb", "3m3ccccccccc"}
290290+ for _, rkey := range rkeys {
291291+ if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey); err != nil {
292292+ t.Fatalf("IndexRecord() error = %v", err)
293293+ }
294294+ }
295295+296296+ // List with reverse=true (oldest first = ASC)
297297+ records, _, err := ri.ListRecords("io.atcr.hold.crew", 10, "", true)
298298+ if err != nil {
299299+ t.Fatalf("ListRecords() error = %v", err)
300300+ }
301301+302302+ // Should be in ascending order
303303+ if len(records) != 3 {
304304+ t.Fatalf("Expected 3 records, got %d", len(records))
305305+ }
306306+ if records[0].Rkey != "3m3aaaaaaaaa" {
307307+ t.Errorf("Expected first record rkey=3m3aaaaaaaaa, got %s", records[0].Rkey)
308308+ }
309309+ if records[1].Rkey != "3m3bbbbbbbbb" {
310310+ t.Errorf("Expected second record rkey=3m3bbbbbbbbb, got %s", records[1].Rkey)
311311+ }
312312+ if records[2].Rkey != "3m3ccccccccc" {
313313+ t.Errorf("Expected third record rkey=3m3ccccccccc, got %s", records[2].Rkey)
314314+ }
315315+}
316316+317317+// TestRecordsIndex_ListRecords_Limit tests the limit parameter
318318+func TestRecordsIndex_ListRecords_Limit(t *testing.T) {
319319+ tmpDir := t.TempDir()
320320+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
321321+ if err != nil {
322322+ t.Fatalf("NewRecordsIndex() error = %v", err)
323323+ }
324324+ defer ri.Close()
325325+326326+ // Add 5 records
327327+ for i := 0; i < 5; i++ {
328328+ rkey := string(rune('a' + i))
329329+ if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey); err != nil {
330330+ t.Fatalf("IndexRecord() error = %v", err)
331331+ }
332332+ }
333333+334334+ // List with limit=2
335335+ records, cursor, err := ri.ListRecords("io.atcr.hold.crew", 2, "", false)
336336+ if err != nil {
337337+ t.Fatalf("ListRecords() error = %v", err)
338338+ }
339339+340340+ if len(records) != 2 {
341341+ t.Errorf("Expected 2 records with limit=2, got %d", len(records))
342342+ }
343343+ if cursor == "" {
344344+ t.Error("Expected cursor when more records exist")
345345+ }
346346+}
347347+348348+// TestRecordsIndex_ListRecords_Cursor tests pagination with cursor
349349+func TestRecordsIndex_ListRecords_Cursor(t *testing.T) {
350350+ tmpDir := t.TempDir()
351351+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
352352+ if err != nil {
353353+ t.Fatalf("NewRecordsIndex() error = %v", err)
354354+ }
355355+ defer ri.Close()
356356+357357+ // Add 5 records
358358+ rkeys := []string{"a", "b", "c", "d", "e"}
359359+ for _, rkey := range rkeys {
360360+ if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey); err != nil {
361361+ t.Fatalf("IndexRecord() error = %v", err)
362362+ }
363363+ }
364364+365365+ // First page (default order = DESC, so e, d first)
366366+ page1, cursor1, err := ri.ListRecords("io.atcr.hold.crew", 2, "", false)
367367+ if err != nil {
368368+ t.Fatalf("ListRecords() page 1 error = %v", err)
369369+ }
370370+ if len(page1) != 2 {
371371+ t.Fatalf("Expected 2 records in page 1, got %d", len(page1))
372372+ }
373373+ if cursor1 == "" {
374374+ t.Fatal("Expected cursor after page 1")
375375+ }
376376+377377+ // Second page using cursor
378378+ page2, cursor2, err := ri.ListRecords("io.atcr.hold.crew", 2, cursor1, false)
379379+ if err != nil {
380380+ t.Fatalf("ListRecords() page 2 error = %v", err)
381381+ }
382382+ if len(page2) != 2 {
383383+ t.Errorf("Expected 2 records in page 2, got %d", len(page2))
384384+ }
385385+386386+ // Third page
387387+ page3, cursor3, err := ri.ListRecords("io.atcr.hold.crew", 2, cursor2, false)
388388+ if err != nil {
389389+ t.Fatalf("ListRecords() page 3 error = %v", err)
390390+ }
391391+ if len(page3) != 1 {
392392+ t.Errorf("Expected 1 record in page 3, got %d", len(page3))
393393+ }
394394+ if cursor3 != "" {
395395+ t.Errorf("Expected no cursor after last page, got %s", cursor3)
396396+ }
397397+398398+ // Verify no duplicates across pages
399399+ seen := make(map[string]bool)
400400+ for _, r := range page1 {
401401+ if seen[r.Rkey] {
402402+ t.Errorf("Duplicate record: %s", r.Rkey)
403403+ }
404404+ seen[r.Rkey] = true
405405+ }
406406+ for _, r := range page2 {
407407+ if seen[r.Rkey] {
408408+ t.Errorf("Duplicate record: %s", r.Rkey)
409409+ }
410410+ seen[r.Rkey] = true
411411+ }
412412+ for _, r := range page3 {
413413+ if seen[r.Rkey] {
414414+ t.Errorf("Duplicate record: %s", r.Rkey)
415415+ }
416416+ seen[r.Rkey] = true
417417+ }
418418+ if len(seen) != 5 {
419419+ t.Errorf("Expected 5 unique records, got %d", len(seen))
420420+ }
421421+}
422422+423423+// TestRecordsIndex_ListRecords_CursorReverse tests pagination with reverse order
424424+func TestRecordsIndex_ListRecords_CursorReverse(t *testing.T) {
425425+ tmpDir := t.TempDir()
426426+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
427427+ if err != nil {
428428+ t.Fatalf("NewRecordsIndex() error = %v", err)
429429+ }
430430+ defer ri.Close()
431431+432432+ // Add 5 records
433433+ rkeys := []string{"a", "b", "c", "d", "e"}
434434+ for _, rkey := range rkeys {
435435+ if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey); err != nil {
436436+ t.Fatalf("IndexRecord() error = %v", err)
437437+ }
438438+ }
439439+440440+ // First page (reverse = ASC, so a, b first)
441441+ page1, cursor1, err := ri.ListRecords("io.atcr.hold.crew", 2, "", true)
442442+ if err != nil {
443443+ t.Fatalf("ListRecords() page 1 error = %v", err)
444444+ }
445445+ if len(page1) != 2 {
446446+ t.Fatalf("Expected 2 records in page 1, got %d", len(page1))
447447+ }
448448+ if page1[0].Rkey != "a" {
449449+ t.Errorf("Expected first record a, got %s", page1[0].Rkey)
450450+ }
451451+ if page1[1].Rkey != "b" {
452452+ t.Errorf("Expected second record b, got %s", page1[1].Rkey)
453453+ }
454454+455455+ // Second page using cursor
456456+ page2, _, err := ri.ListRecords("io.atcr.hold.crew", 2, cursor1, true)
457457+ if err != nil {
458458+ t.Fatalf("ListRecords() page 2 error = %v", err)
459459+ }
460460+ if len(page2) != 2 {
461461+ t.Errorf("Expected 2 records in page 2, got %d", len(page2))
462462+ }
463463+ if page2[0].Rkey != "c" {
464464+ t.Errorf("Expected first record c, got %s", page2[0].Rkey)
465465+ }
466466+}
467467+468468+// TestRecordsIndex_Count tests counting records in a collection
469469+func TestRecordsIndex_Count(t *testing.T) {
470470+ tmpDir := t.TempDir()
471471+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
472472+ if err != nil {
473473+ t.Fatalf("NewRecordsIndex() error = %v", err)
474474+ }
475475+ defer ri.Close()
476476+477477+ // Add records to two collections
478478+ for i := 0; i < 3; i++ {
479479+ ri.IndexRecord("io.atcr.hold.crew", string(rune('a'+i)), "cid1")
480480+ }
481481+ for i := 0; i < 5; i++ {
482482+ ri.IndexRecord("io.atcr.hold.captain", string(rune('a'+i)), "cid2")
483483+ }
484484+485485+ // Count crew
486486+ count, err := ri.Count("io.atcr.hold.crew")
487487+ if err != nil {
488488+ t.Fatalf("Count() error = %v", err)
489489+ }
490490+ if count != 3 {
491491+ t.Errorf("Expected crew count 3, got %d", count)
492492+ }
493493+494494+ // Count captain
495495+ count, err = ri.Count("io.atcr.hold.captain")
496496+ if err != nil {
497497+ t.Fatalf("Count() error = %v", err)
498498+ }
499499+ if count != 5 {
500500+ t.Errorf("Expected captain count 5, got %d", count)
501501+ }
502502+}
503503+504504+// TestRecordsIndex_Count_Empty tests counting an empty collection
505505+func TestRecordsIndex_Count_Empty(t *testing.T) {
506506+ tmpDir := t.TempDir()
507507+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
508508+ if err != nil {
509509+ t.Fatalf("NewRecordsIndex() error = %v", err)
510510+ }
511511+ defer ri.Close()
512512+513513+ count, err := ri.Count("io.atcr.nonexistent")
514514+ if err != nil {
515515+ t.Fatalf("Count() error = %v", err)
516516+ }
517517+ if count != 0 {
518518+ t.Errorf("Expected count 0 for empty collection, got %d", count)
519519+ }
520520+}
521521+522522+// TestRecordsIndex_TotalCount tests total count across all collections
523523+func TestRecordsIndex_TotalCount(t *testing.T) {
524524+ tmpDir := t.TempDir()
525525+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
526526+ if err != nil {
527527+ t.Fatalf("NewRecordsIndex() error = %v", err)
528528+ }
529529+ defer ri.Close()
530530+531531+ // Add records to multiple collections
532532+ ri.IndexRecord("io.atcr.hold.crew", "a", "cid1")
533533+ ri.IndexRecord("io.atcr.hold.crew", "b", "cid2")
534534+ ri.IndexRecord("io.atcr.hold.captain", "self", "cid3")
535535+ ri.IndexRecord("io.atcr.manifest", "abc123", "cid4")
536536+537537+ count, err := ri.TotalCount()
538538+ if err != nil {
539539+ t.Fatalf("TotalCount() error = %v", err)
540540+ }
541541+ if count != 4 {
542542+ t.Errorf("Expected total count 4, got %d", count)
543543+ }
544544+}
545545+546546+// TestRecordsIndex_BackfillFromRepo_Empty tests backfill with empty repo
547547+func TestRecordsIndex_BackfillFromRepo_Empty(t *testing.T) {
548548+ // This test requires a mock repo which is complex to set up
549549+ // Skip for now - the integration tests in server_test.go will cover this
550550+ t.Skip("Requires mock repo setup - covered by integration tests")
551551+}
552552+553553+// TestRecordsIndex_BackfillFromRepo tests backfill from MST
554554+func TestRecordsIndex_BackfillFromRepo(t *testing.T) {
555555+ // This test requires a real repo with MST data
556556+ // Skip unit test - covered by integration tests in server_test.go
557557+ t.Skip("Requires real repo with MST - covered by integration tests")
558558+}
559559+560560+// TestRecordsIndex_BackfillFromRepo_SkipsWhenSynced tests backfill skip logic
561561+func TestRecordsIndex_BackfillFromRepo_SkipsWhenSynced(t *testing.T) {
562562+ // Create a mock scenario where counts match
563563+ // This is tested via the count comparison logic
564564+ tmpDir := t.TempDir()
565565+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
566566+ if err != nil {
567567+ t.Fatalf("NewRecordsIndex() error = %v", err)
568568+ }
569569+ defer ri.Close()
570570+571571+ // The skip logic depends on count comparison in BackfillFromRepo
572572+ // which requires a real repo. Skip for now.
573573+ t.Skip("Requires mock repo - covered by integration tests")
574574+}
575575+576576+// TestRecordsIndex_MultipleCollections tests isolation between collections
577577+func TestRecordsIndex_MultipleCollections(t *testing.T) {
578578+ tmpDir := t.TempDir()
579579+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
580580+ if err != nil {
581581+ t.Fatalf("NewRecordsIndex() error = %v", err)
582582+ }
583583+ defer ri.Close()
584584+585585+ // Add records to different collections with same rkeys
586586+ ri.IndexRecord("io.atcr.hold.crew", "abc", "cid-crew")
587587+ ri.IndexRecord("io.atcr.hold.captain", "abc", "cid-captain")
588588+ ri.IndexRecord("io.atcr.manifest", "abc", "cid-manifest")
589589+590590+ // Listing should only return records from requested collection
591591+ records, _, err := ri.ListRecords("io.atcr.hold.crew", 10, "", false)
592592+ if err != nil {
593593+ t.Fatalf("ListRecords() error = %v", err)
594594+ }
595595+ if len(records) != 1 {
596596+ t.Errorf("Expected 1 crew record, got %d", len(records))
597597+ }
598598+ if records[0].Cid != "cid-crew" {
599599+ t.Errorf("Expected cid-crew, got %s", records[0].Cid)
600600+ }
601601+602602+ // Delete from one collection shouldn't affect others
603603+ ri.DeleteRecord("io.atcr.hold.crew", "abc")
604604+605605+ count, _ := ri.Count("io.atcr.hold.captain")
606606+ if count != 1 {
607607+ t.Errorf("Expected captain count 1 after deleting crew, got %d", count)
608608+ }
609609+}
610610+611611+// mockRepo is a minimal mock for testing backfill
612612+// Note: Full backfill testing requires integration tests with real repo
613613+type mockRepo struct {
614614+ records map[string]string // key -> cid
615615+}
616616+617617+func (m *mockRepo) ForEach(ctx context.Context, prefix string, fn func(string, interface{}) error) error {
618618+ for k, v := range m.records {
619619+ if err := fn(k, v); err != nil {
620620+ if err == repo.ErrDoneIterating {
621621+ return nil
622622+ }
623623+ return err
624624+ }
625625+ }
626626+ return nil
627627+}
+97-2
pkg/hold/pds/server.go
···4141 uid models.Uid
4242 signingKey *atcrypto.PrivateKeyK256
4343 enableBlueskyPosts bool
4444+ recordsIndex *RecordsIndex
4445}
45464647// NewHoldPDS creates or opens a hold PDS with SQLite carstore
···9899 slog.Info("New hold repo - will be initialized in Bootstrap")
99100 }
100101102102+ // Initialize records index for efficient listing queries
103103+ // Uses same database as carstore for simplicity
104104+ var recordsIndex *RecordsIndex
105105+ if dbPath != ":memory:" {
106106+ recordsDbPath := dbPath + "/db.sqlite3"
107107+ recordsIndex, err = NewRecordsIndex(recordsDbPath)
108108+ if err != nil {
109109+ return nil, fmt.Errorf("failed to create records index: %w", err)
110110+ }
111111+ }
112112+101113 return &HoldPDS{
102114 did: did,
103115 PublicURL: publicURL,
···107119 uid: uid,
108120 signingKey: signingKey,
109121 enableBlueskyPosts: enableBlueskyPosts,
122122+ recordsIndex: recordsIndex,
110123 }, nil
111124}
112125···123136// RepomgrRef returns a reference to the RepoManager for event handler setup
124137func (p *HoldPDS) RepomgrRef() *RepoManager {
125138 return p.repomgr
139139+}
140140+141141+// RecordsIndex returns the records index for efficient listing
142142+func (p *HoldPDS) RecordsIndex() *RecordsIndex {
143143+ return p.recordsIndex
144144+}
145145+146146+// Carstore returns the carstore for repo operations
147147+func (p *HoldPDS) Carstore() carstore.CarStore {
148148+ return p.carstore
149149+}
150150+151151+// UID returns the user ID for this hold
152152+func (p *HoldPDS) UID() models.Uid {
153153+ return p.uid
126154}
127155128156// Bootstrap initializes the hold with the captain record, owner as first crew member, and profile
···268296 return result, nil
269297}
270298271271-// Close closes the carstore
299299+// Close closes the carstore and records index
272300func (p *HoldPDS) Close() error {
273273- // TODO: Close session properly
301301+ if p.recordsIndex != nil {
302302+ if err := p.recordsIndex.Close(); err != nil {
303303+ return fmt.Errorf("failed to close records index: %w", err)
304304+ }
305305+ }
274306 return nil
275307}
308308+309309+// CreateRecordsIndexEventHandler creates an event handler that indexes records
310310+// and also calls the provided broadcaster handler
311311+func (p *HoldPDS) CreateRecordsIndexEventHandler(broadcasterHandler func(context.Context, *RepoEvent)) func(context.Context, *RepoEvent) {
312312+ return func(ctx context.Context, event *RepoEvent) {
313313+ // Index/delete records based on event operations
314314+ if p.recordsIndex != nil {
315315+ for _, op := range event.Ops {
316316+ switch op.Kind {
317317+ case EvtKindCreateRecord, EvtKindUpdateRecord:
318318+ // Index the record
319319+ cidStr := ""
320320+ if op.RecCid != nil {
321321+ cidStr = op.RecCid.String()
322322+ }
323323+ if err := p.recordsIndex.IndexRecord(op.Collection, op.Rkey, cidStr); err != nil {
324324+ slog.Warn("Failed to index record", "collection", op.Collection, "rkey", op.Rkey, "error", err)
325325+ }
326326+ case EvtKindDeleteRecord:
327327+ // Remove from index
328328+ if err := p.recordsIndex.DeleteRecord(op.Collection, op.Rkey); err != nil {
329329+ slog.Warn("Failed to delete record from index", "collection", op.Collection, "rkey", op.Rkey, "error", err)
330330+ }
331331+ }
332332+ }
333333+ }
334334+335335+ // Call the broadcaster handler
336336+ if broadcasterHandler != nil {
337337+ broadcasterHandler(ctx, event)
338338+ }
339339+ }
340340+}
341341+342342+// BackfillRecordsIndex populates the records index from existing MST data
343343+func (p *HoldPDS) BackfillRecordsIndex(ctx context.Context) error {
344344+ if p.recordsIndex == nil {
345345+ return nil // No index to backfill
346346+ }
347347+348348+ // Create session to read repo
349349+ session, err := p.carstore.ReadOnlySession(p.uid)
350350+ if err != nil {
351351+ return fmt.Errorf("failed to create session: %w", err)
352352+ }
353353+354354+ head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
355355+ if err != nil {
356356+ return fmt.Errorf("failed to get repo head: %w", err)
357357+ }
358358+359359+ if !head.Defined() {
360360+ slog.Debug("No repo head, skipping backfill")
361361+ return nil
362362+ }
363363+364364+ repoHandle, err := repo.OpenRepo(ctx, session, head)
365365+ if err != nil {
366366+ return fmt.Errorf("failed to open repo: %w", err)
367367+ }
368368+369369+ return p.recordsIndex.BackfillFromRepo(ctx, repoHandle)
370370+}
+328
pkg/hold/pds/server_test.go
···620620 }
621621 }
622622}
623623+624624+// Tests for RecordsIndex feature
625625+626626+// TestHoldPDS_RecordsIndex_Nil tests that RecordsIndex is nil for :memory: database
627627+func TestHoldPDS_RecordsIndex_Nil(t *testing.T) {
628628+ ctx := context.Background()
629629+ tmpDir := t.TempDir()
630630+ keyPath := filepath.Join(tmpDir, "signing-key")
631631+632632+ // Create with :memory: database
633633+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
634634+ if err != nil {
635635+ t.Fatalf("NewHoldPDS failed: %v", err)
636636+ }
637637+ defer pds.Close()
638638+639639+ // RecordsIndex should be nil for :memory:
640640+ if pds.RecordsIndex() != nil {
641641+ t.Error("Expected RecordsIndex() to be nil for :memory: database")
642642+ }
643643+}
644644+645645+// TestHoldPDS_RecordsIndex_NonNil tests that RecordsIndex is created for file database
646646+func TestHoldPDS_RecordsIndex_NonNil(t *testing.T) {
647647+ ctx := context.Background()
648648+ tmpDir := t.TempDir()
649649+ dbPath := filepath.Join(tmpDir, "pds.db")
650650+ keyPath := filepath.Join(tmpDir, "signing-key")
651651+652652+ // Create with file database
653653+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
654654+ if err != nil {
655655+ t.Fatalf("NewHoldPDS failed: %v", err)
656656+ }
657657+ defer pds.Close()
658658+659659+ // RecordsIndex should be non-nil for file database
660660+ if pds.RecordsIndex() == nil {
661661+ t.Error("Expected RecordsIndex() to be non-nil for file database")
662662+ }
663663+}
664664+665665+// TestHoldPDS_Carstore tests the Carstore getter
666666+func TestHoldPDS_Carstore(t *testing.T) {
667667+ ctx := context.Background()
668668+ tmpDir := t.TempDir()
669669+ keyPath := filepath.Join(tmpDir, "signing-key")
670670+671671+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
672672+ if err != nil {
673673+ t.Fatalf("NewHoldPDS failed: %v", err)
674674+ }
675675+ defer pds.Close()
676676+677677+ if pds.Carstore() == nil {
678678+ t.Error("Expected Carstore() to be non-nil")
679679+ }
680680+}
681681+682682+// TestHoldPDS_UID tests the UID getter
683683+func TestHoldPDS_UID(t *testing.T) {
684684+ ctx := context.Background()
685685+ tmpDir := t.TempDir()
686686+ keyPath := filepath.Join(tmpDir, "signing-key")
687687+688688+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
689689+ if err != nil {
690690+ t.Fatalf("NewHoldPDS failed: %v", err)
691691+ }
692692+ defer pds.Close()
693693+694694+ // UID should be 1 for single-user PDS
695695+ if pds.UID() != 1 {
696696+ t.Errorf("Expected UID() to be 1, got %d", pds.UID())
697697+ }
698698+}
699699+700700+// TestHoldPDS_CreateRecordsIndexEventHandler tests event handler wrapper
701701+func TestHoldPDS_CreateRecordsIndexEventHandler(t *testing.T) {
702702+ ctx := context.Background()
703703+ tmpDir := t.TempDir()
704704+ dbPath := filepath.Join(tmpDir, "pds.db")
705705+ keyPath := filepath.Join(tmpDir, "signing-key")
706706+707707+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
708708+ if err != nil {
709709+ t.Fatalf("NewHoldPDS failed: %v", err)
710710+ }
711711+ defer pds.Close()
712712+713713+ // Track if broadcaster was called
714714+ broadcasterCalled := false
715715+ broadcasterHandler := func(ctx context.Context, event *RepoEvent) {
716716+ broadcasterCalled = true
717717+ }
718718+719719+ // Create handler
720720+ handler := pds.CreateRecordsIndexEventHandler(broadcasterHandler)
721721+ if handler == nil {
722722+ t.Fatal("Expected handler to be non-nil")
723723+ }
724724+725725+ // Create a test event with create operation
726726+ event := &RepoEvent{
727727+ Ops: []RepoOp{
728728+ {
729729+ Kind: EvtKindCreateRecord,
730730+ Collection: "io.atcr.hold.crew",
731731+ Rkey: "testrkey",
732732+ RecCid: nil, // Will be nil string
733733+ },
734734+ },
735735+ }
736736+737737+ // Call handler
738738+ handler(ctx, event)
739739+740740+ // Verify broadcaster was called
741741+ if !broadcasterCalled {
742742+ t.Error("Expected broadcaster handler to be called")
743743+ }
744744+745745+ // Verify record was indexed
746746+ if pds.RecordsIndex() != nil {
747747+ count, err := pds.RecordsIndex().Count("io.atcr.hold.crew")
748748+ if err != nil {
749749+ t.Fatalf("Count() error = %v", err)
750750+ }
751751+ if count != 1 {
752752+ t.Errorf("Expected 1 indexed record, got %d", count)
753753+ }
754754+ }
755755+}
756756+757757+// TestHoldPDS_CreateRecordsIndexEventHandler_Delete tests delete operation
758758+func TestHoldPDS_CreateRecordsIndexEventHandler_Delete(t *testing.T) {
759759+ ctx := context.Background()
760760+ tmpDir := t.TempDir()
761761+ dbPath := filepath.Join(tmpDir, "pds.db")
762762+ keyPath := filepath.Join(tmpDir, "signing-key")
763763+764764+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
765765+ if err != nil {
766766+ t.Fatalf("NewHoldPDS failed: %v", err)
767767+ }
768768+ defer pds.Close()
769769+770770+ handler := pds.CreateRecordsIndexEventHandler(nil)
771771+772772+ // First, create a record
773773+ createEvent := &RepoEvent{
774774+ Ops: []RepoOp{
775775+ {
776776+ Kind: EvtKindCreateRecord,
777777+ Collection: "io.atcr.hold.crew",
778778+ Rkey: "testrkey",
779779+ },
780780+ },
781781+ }
782782+ handler(ctx, createEvent)
783783+784784+ // Verify it was indexed
785785+ count, _ := pds.RecordsIndex().Count("io.atcr.hold.crew")
786786+ if count != 1 {
787787+ t.Fatalf("Expected 1 record after create, got %d", count)
788788+ }
789789+790790+ // Now delete it
791791+ deleteEvent := &RepoEvent{
792792+ Ops: []RepoOp{
793793+ {
794794+ Kind: EvtKindDeleteRecord,
795795+ Collection: "io.atcr.hold.crew",
796796+ Rkey: "testrkey",
797797+ },
798798+ },
799799+ }
800800+ handler(ctx, deleteEvent)
801801+802802+ // Verify it was removed from index
803803+ count, _ = pds.RecordsIndex().Count("io.atcr.hold.crew")
804804+ if count != 0 {
805805+ t.Errorf("Expected 0 records after delete, got %d", count)
806806+ }
807807+}
808808+809809+// TestHoldPDS_CreateRecordsIndexEventHandler_NilBroadcaster tests with nil broadcaster
810810+func TestHoldPDS_CreateRecordsIndexEventHandler_NilBroadcaster(t *testing.T) {
811811+ ctx := context.Background()
812812+ tmpDir := t.TempDir()
813813+ dbPath := filepath.Join(tmpDir, "pds.db")
814814+ keyPath := filepath.Join(tmpDir, "signing-key")
815815+816816+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
817817+ if err != nil {
818818+ t.Fatalf("NewHoldPDS failed: %v", err)
819819+ }
820820+ defer pds.Close()
821821+822822+ // Create handler with nil broadcaster (should not panic)
823823+ handler := pds.CreateRecordsIndexEventHandler(nil)
824824+825825+ event := &RepoEvent{
826826+ Ops: []RepoOp{
827827+ {
828828+ Kind: EvtKindCreateRecord,
829829+ Collection: "io.atcr.hold.crew",
830830+ Rkey: "testrkey",
831831+ },
832832+ },
833833+ }
834834+835835+ // Should not panic
836836+ handler(ctx, event)
837837+838838+ // Verify record was still indexed
839839+ count, _ := pds.RecordsIndex().Count("io.atcr.hold.crew")
840840+ if count != 1 {
841841+ t.Errorf("Expected 1 indexed record, got %d", count)
842842+ }
843843+}
844844+845845+// TestHoldPDS_BackfillRecordsIndex tests backfilling the records index from MST
846846+func TestHoldPDS_BackfillRecordsIndex(t *testing.T) {
847847+ ctx := context.Background()
848848+ tmpDir := t.TempDir()
849849+ dbPath := filepath.Join(tmpDir, "pds.db")
850850+ keyPath := filepath.Join(tmpDir, "signing-key")
851851+852852+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
853853+ if err != nil {
854854+ t.Fatalf("NewHoldPDS failed: %v", err)
855855+ }
856856+ defer pds.Close()
857857+858858+ // Bootstrap to create some records in MST (captain + crew)
859859+ ownerDID := "did:plc:testowner"
860860+ err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
861861+ if err != nil {
862862+ t.Fatalf("Bootstrap failed: %v", err)
863863+ }
864864+865865+ // Clear the index to simulate out-of-sync state
866866+ _, err = pds.RecordsIndex().db.Exec("DELETE FROM records")
867867+ if err != nil {
868868+ t.Fatalf("Failed to clear index: %v", err)
869869+ }
870870+871871+ // Verify index is empty
872872+ count, _ := pds.RecordsIndex().TotalCount()
873873+ if count != 0 {
874874+ t.Fatalf("Expected empty index, got %d", count)
875875+ }
876876+877877+ // Backfill
878878+ err = pds.BackfillRecordsIndex(ctx)
879879+ if err != nil {
880880+ t.Fatalf("BackfillRecordsIndex failed: %v", err)
881881+ }
882882+883883+ // Verify records were backfilled
884884+ // Bootstrap creates: 1 captain + 1 crew + 1 profile = 3 records
885885+ count, _ = pds.RecordsIndex().TotalCount()
886886+ if count < 2 {
887887+ t.Errorf("Expected at least 2 records after backfill (captain + crew), got %d", count)
888888+ }
889889+}
890890+891891+// TestHoldPDS_BackfillRecordsIndex_NilIndex tests backfill with nil index
892892+func TestHoldPDS_BackfillRecordsIndex_NilIndex(t *testing.T) {
893893+ ctx := context.Background()
894894+ tmpDir := t.TempDir()
895895+ keyPath := filepath.Join(tmpDir, "signing-key")
896896+897897+ // Use :memory: to get nil index
898898+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", ":memory:", keyPath, false)
899899+ if err != nil {
900900+ t.Fatalf("NewHoldPDS failed: %v", err)
901901+ }
902902+ defer pds.Close()
903903+904904+ // Backfill should be no-op and not error
905905+ err = pds.BackfillRecordsIndex(ctx)
906906+ if err != nil {
907907+ t.Errorf("BackfillRecordsIndex should not error with nil index, got: %v", err)
908908+ }
909909+}
910910+911911+// TestHoldPDS_BackfillRecordsIndex_SkipsWhenSynced tests backfill skip when already synced
912912+func TestHoldPDS_BackfillRecordsIndex_SkipsWhenSynced(t *testing.T) {
913913+ ctx := context.Background()
914914+ tmpDir := t.TempDir()
915915+ dbPath := filepath.Join(tmpDir, "pds.db")
916916+ keyPath := filepath.Join(tmpDir, "signing-key")
917917+918918+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
919919+ if err != nil {
920920+ t.Fatalf("NewHoldPDS failed: %v", err)
921921+ }
922922+ defer pds.Close()
923923+924924+ // Bootstrap to create records
925925+ err = pds.Bootstrap(ctx, nil, "did:plc:testowner", true, false, "")
926926+ if err != nil {
927927+ t.Fatalf("Bootstrap failed: %v", err)
928928+ }
929929+930930+ // Backfill once to sync
931931+ err = pds.BackfillRecordsIndex(ctx)
932932+ if err != nil {
933933+ t.Fatalf("First BackfillRecordsIndex failed: %v", err)
934934+ }
935935+936936+ count1, _ := pds.RecordsIndex().TotalCount()
937937+938938+ // Backfill again - should skip (counts match)
939939+ err = pds.BackfillRecordsIndex(ctx)
940940+ if err != nil {
941941+ t.Fatalf("Second BackfillRecordsIndex failed: %v", err)
942942+ }
943943+944944+ count2, _ := pds.RecordsIndex().TotalCount()
945945+946946+ // Count should be unchanged
947947+ if count1 != count2 {
948948+ t.Errorf("Expected count to remain %d after second backfill, got %d", count1, count2)
949949+ }
950950+}
+1-1
pkg/hold/pds/status_test.go
···6363 "repo": did,
6464 "collection": atproto.BskyPostCollection,
6565 "limit": "100",
6666- "reverse": "true", // Most recent first
6666+ // Default order (reverse=false) is newest first (DESC by rkey)
6767 })
6868 w := httptest.NewRecorder()
6969 handler.HandleListRecords(w, req)
+132-34
pkg/hold/pds/xrpc.go
···479479// HandleListRecords lists records in a collection
480480// Spec: https://docs.bsky.app/docs/api/com-atproto-repo-list-records
481481// Supports pagination via limit, cursor, and reverse parameters
482482+// Uses SQL index for efficient pagination (following official ATProto PDS pattern)
482483func (h *XRPCHandler) HandleListRecords(w http.ResponseWriter, r *http.Request) {
483484 repoDID := r.URL.Query().Get("repo")
484485 collection := r.URL.Query().Get("collection")
···507508 cursor := r.URL.Query().Get("cursor")
508509 reverse := r.URL.Query().Get("reverse") == "true"
509510510510- // Generic implementation using repo.ForEach
511511+ // Use records index if available (efficient SQL-based pagination)
512512+ if h.pds.recordsIndex != nil {
513513+ h.handleListRecordsIndexed(w, r, collection, limit, cursor, reverse)
514514+ return
515515+ }
516516+517517+ // Fallback: MST-based listing (legacy path for tests or in-memory mode)
518518+ h.handleListRecordsMST(w, r, collection, limit, cursor, reverse)
519519+}
520520+521521+// handleListRecordsIndexed uses the SQL records index for efficient pagination
522522+func (h *XRPCHandler) handleListRecordsIndexed(w http.ResponseWriter, r *http.Request, collection string, limit int, cursor string, reverse bool) {
523523+ // Query the index
524524+ indexedRecords, nextCursor, err := h.pds.recordsIndex.ListRecords(collection, limit, cursor, reverse)
525525+ if err != nil {
526526+ slog.Error("Failed to list records from index", "error", err, "collection", collection)
527527+ http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
528528+ return
529529+ }
530530+531531+ // Create session to fetch full record data
511532 session, err := h.pds.carstore.ReadOnlySession(h.pds.uid)
512533 if err != nil {
513534 http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
···534555 return
535556 }
536557537537- // Initialize as empty slice (not nil) to ensure JSON encodes as [] not null
558558+ // Fetch full record data for each indexed record
538559 records := []map[string]any{}
539539- var nextCursor string
540540- skipUntilCursor := cursor != ""
560560+ for _, rec := range indexedRecords {
561561+ // Construct the record path
562562+ recordPath := rec.Collection + "/" + rec.Rkey
563563+564564+ // Get the record bytes
565565+ recordCID, recBytes, err := repoHandle.GetRecordBytes(r.Context(), recordPath)
566566+ if err != nil {
567567+ slog.Warn("Failed to get indexed record, skipping", "path", recordPath, "error", err)
568568+ continue
569569+ }
570570+571571+ // Decode using lexutil (type registry handles unmarshaling)
572572+ recordValue, err := lexutil.CborDecodeValue(*recBytes)
573573+ if err != nil {
574574+ slog.Warn("Failed to decode indexed record, skipping", "path", recordPath, "error", err)
575575+ continue
576576+ }
577577+578578+ records = append(records, map[string]any{
579579+ "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), rec.Collection, rec.Rkey),
580580+ "cid": recordCID.String(),
581581+ "value": recordValue,
582582+ })
583583+ }
584584+585585+ response := map[string]any{
586586+ "records": records,
587587+ }
588588+589589+ // Include cursor in response if there are more records
590590+ if nextCursor != "" {
591591+ response["cursor"] = nextCursor
592592+ }
593593+594594+ w.Header().Set("Content-Type", "application/json")
595595+ json.NewEncoder(w).Encode(response)
596596+}
597597+598598+// handleListRecordsMST uses the legacy MST-based listing (fallback for tests)
599599+func (h *XRPCHandler) handleListRecordsMST(w http.ResponseWriter, r *http.Request, collection string, limit int, cursor string, reverse bool) {
600600+ // Generic implementation using repo.ForEach
601601+ session, err := h.pds.carstore.ReadOnlySession(h.pds.uid)
602602+ if err != nil {
603603+ http.Error(w, fmt.Sprintf("failed to create session: %v", err), http.StatusInternalServerError)
604604+ return
605605+ }
541606542542- // Iterate over all records in the collection
607607+ head, err := h.pds.carstore.GetUserRepoHead(r.Context(), h.pds.uid)
608608+ if err != nil {
609609+ http.Error(w, fmt.Sprintf("failed to get repo head: %v", err), http.StatusInternalServerError)
610610+ return
611611+ }
612612+613613+ if !head.Defined() {
614614+ // Empty repo, return empty list
615615+ response := map[string]any{"records": []any{}}
616616+ w.Header().Set("Content-Type", "application/json")
617617+ json.NewEncoder(w).Encode(response)
618618+ return
619619+ }
620620+621621+ repoHandle, err := repo.OpenRepo(r.Context(), session, head)
622622+ if err != nil {
623623+ http.Error(w, fmt.Sprintf("failed to open repo: %v", err), http.StatusInternalServerError)
624624+ return
625625+ }
626626+627627+ // Collect all records in the collection first.
628628+ // MST only supports forward iteration, so for newest-first (default) we must
629629+ // collect all records, reverse, then apply cursor/limit.
630630+ allRecords := []map[string]any{}
631631+543632 err = repoHandle.ForEach(r.Context(), collection, func(k string, v cid.Cid) error {
544633 // k is like "io.atcr.hold.captain/self" or "io.atcr.hold.crew/3m3by7msdln22"
545634 parts := strings.Split(k, "/")
···552641 rkey := parts[len(parts)-1]
553642554643 // Filter: only include records that match the requested collection
555555- // MST keys are sorted lexicographically, so once we hit a different
556556- // collection prefix, all remaining keys will also be outside our range
557644 if actualCollection != collection {
558645 return repo.ErrDoneIterating // Stop walking the tree
559646 }
560647561561- // Handle cursor-based pagination
562562- if skipUntilCursor {
563563- if rkey == cursor {
564564- skipUntilCursor = false // Found cursor, start including records after this
565565- }
566566- return nil // Skip this record
567567- }
568568-569569- // Check if we've hit the limit
570570- if len(records) >= limit {
571571- // Set next cursor to current rkey
572572- nextCursor = rkey
573573- return repo.ErrDoneIterating // Stop iteration
574574- }
575575-576648 // Get the record bytes
577649 recordCID, recBytes, err := repoHandle.GetRecordBytes(r.Context(), k)
578650 if err != nil {
···585657 return fmt.Errorf("failed to decode record: %v", err)
586658 }
587659588588- records = append(records, map[string]any{
660660+ allRecords = append(allRecords, map[string]any{
589661 "uri": fmt.Sprintf("at://%s/%s/%s", h.pds.DID(), actualCollection, rkey),
590662 "cid": recordCID.String(),
591663 "value": recordValue,
664664+ "rkey": rkey,
592665 })
593666 return nil
594667 })
595668596669 if err != nil {
597597- // ErrDoneIterating is expected when we stop walking early (reached collection boundary or hit limit)
598598- // Check using strings.Contains because the error may be wrapped
599670 if err == repo.ErrDoneIterating || strings.Contains(err.Error(), "done iterating") {
600600- // Successfully stopped at collection boundary or hit pagination limit, continue with collected records
671671+ // Successfully stopped at collection boundary
601672 } else if strings.Contains(err.Error(), "not found") {
602602- // If the collection doesn't exist yet, return empty list
603603- records = []map[string]any{}
673673+ allRecords = []map[string]any{}
604674 } else {
605675 http.Error(w, fmt.Sprintf("failed to list records: %v", err), http.StatusInternalServerError)
606676 return
607677 }
608678 }
609679610610- // Default order is newest-first (reverse chronological), which requires
611611- // reversing the MST's lexicographic order. When reverse=true, keep MST order.
612612- if !reverse && len(records) > 0 {
613613- for i, j := 0, len(records)-1; i < j; i, j = i+1, j-1 {
614614- records[i], records[j] = records[j], records[i]
680680+ // Default order is newest-first (reverse chronological).
681681+ // MST iterates oldest-first, so reverse for default order.
682682+ if !reverse && len(allRecords) > 0 {
683683+ for i, j := 0, len(allRecords)-1; i < j; i, j = i+1, j-1 {
684684+ allRecords[i], allRecords[j] = allRecords[j], allRecords[i]
685685+ }
686686+ }
687687+688688+ // Apply cursor and limit
689689+ records := []map[string]any{}
690690+ var nextCursor string
691691+ skipUntilCursor := cursor != ""
692692+693693+ for _, rec := range allRecords {
694694+ rkey := rec["rkey"].(string)
695695+696696+ if skipUntilCursor {
697697+ if rkey == cursor {
698698+ skipUntilCursor = false
699699+ }
700700+ continue
615701 }
702702+703703+ if len(records) >= limit {
704704+ nextCursor = rkey
705705+ break
706706+ }
707707+708708+ delete(rec, "rkey")
709709+ records = append(records, rec)
710710+ }
711711+712712+ if skipUntilCursor {
713713+ records = []map[string]any{}
714714+ nextCursor = ""
616715 }
617716618717 response := map[string]any{
619718 "records": records,
620719 }
621720622622- // Include cursor in response if there are more records
623721 if nextCursor != "" {
624722 response["cursor"] = nextCursor
625723 }
+296
pkg/hold/pds/xrpc_test.go
···29293030// setupTestXRPCHandler creates a fresh PDS instance and handler for each test
3131// Bootstraps the PDS and suppresses logging to avoid log spam
3232+// Uses :memory: database which disables RecordsIndex (uses MST fallback path)
3233func setupTestXRPCHandler(t *testing.T) (*XRPCHandler, context.Context) {
3334 t.Helper()
3435···7273 mockClient := &mockPDSClient{}
73747475 // Create mock s3 service and storage driver (not needed for most PDS tests)
7676+ mockS3 := s3.S3Service{}
7777+7878+ // Create XRPC handler with mock HTTP client
7979+ handler := NewXRPCHandler(pds, mockS3, nil, nil, mockClient)
8080+8181+ return handler, ctx
8282+}
8383+8484+// setupTestXRPCHandlerWithIndex creates a handler with file-based database
8585+// to enable RecordsIndex (vs :memory: which disables it)
8686+func setupTestXRPCHandlerWithIndex(t *testing.T) (*XRPCHandler, context.Context) {
8787+ t.Helper()
8888+8989+ ctx := context.Background()
9090+ tmpDir := t.TempDir()
9191+9292+ // Use file-based database to enable RecordsIndex
9393+ dbPath := filepath.Join(tmpDir, "pds.db")
9494+ keyPath := filepath.Join(tmpDir, "signing-key")
9595+9696+ // Copy shared signing key instead of generating a new one
9797+ if err := os.WriteFile(keyPath, sharedTestKey, 0600); err != nil {
9898+ t.Fatalf("Failed to copy shared signing key: %v", err)
9999+ }
100100+101101+ pds, err := NewHoldPDS(ctx, "did:web:hold.example.com", "https://hold.example.com", dbPath, keyPath, false)
102102+ if err != nil {
103103+ t.Fatalf("Failed to create test PDS: %v", err)
104104+ }
105105+106106+ // Verify RecordsIndex is enabled
107107+ if pds.RecordsIndex() == nil {
108108+ t.Fatal("Expected RecordsIndex to be non-nil for file-based database")
109109+ }
110110+111111+ // Bootstrap with a test owner, suppressing stdout to avoid log spam
112112+ ownerDID := "did:plc:testowner123"
113113+114114+ // Redirect stdout to suppress bootstrap logging
115115+ oldStdout := os.Stdout
116116+ r, w, _ := os.Pipe()
117117+ os.Stdout = w
118118+119119+ err = pds.Bootstrap(ctx, nil, ownerDID, true, false, "")
120120+121121+ // Restore stdout
122122+ w.Close()
123123+ os.Stdout = oldStdout
124124+ io.ReadAll(r) // Drain the pipe
125125+126126+ if err != nil {
127127+ t.Fatalf("Failed to bootstrap PDS: %v", err)
128128+ }
129129+130130+ // Wire up records indexing event handler
131131+ indexingHandler := pds.CreateRecordsIndexEventHandler(nil)
132132+ pds.RepomgrRef().SetEventHandler(indexingHandler, true)
133133+134134+ // Backfill index from MST (bootstrap created records but didn't index them)
135135+ if err := pds.BackfillRecordsIndex(ctx); err != nil {
136136+ t.Fatalf("Failed to backfill records index: %v", err)
137137+ }
138138+139139+ // Create mock PDS client for DPoP validation
140140+ mockClient := &mockPDSClient{}
141141+142142+ // Create mock s3 service and storage driver
75143 mockS3 := s3.S3Service{}
7614477145 // Create XRPC handler with mock HTTP client
···744812 t.Errorf("Expected status 400, got %d", w.Code)
745813 }
746814 })
815815+ }
816816+}
817817+818818+// Tests for HandleListRecords with RecordsIndex (indexed path)
819819+// These tests use file-based database to enable the SQL-based indexing
820820+821821+// TestHandleListRecords_Indexed tests listing with RecordsIndex enabled
822822+func TestHandleListRecords_Indexed(t *testing.T) {
823823+ handler, ctx := setupTestXRPCHandlerWithIndex(t)
824824+ holdDID := "did:web:hold.example.com"
825825+826826+ // Add crew members (will be indexed via event handler)
827827+ memberDIDs := []string{
828828+ "did:plc:member1",
829829+ "did:plc:member2",
830830+ "did:plc:member3",
831831+ }
832832+833833+ for _, did := range memberDIDs {
834834+ _, err := handler.pds.AddCrewMember(ctx, did, "reader", []string{"blob:read"})
835835+ if err != nil {
836836+ t.Fatalf("Failed to add crew member %s: %v", did, err)
837837+ }
838838+ }
839839+840840+ // Test listing crew records via indexed path
841841+ req := makeXRPCGetRequest(atproto.RepoListRecords, map[string]string{
842842+ "repo": holdDID,
843843+ "collection": atproto.CrewCollection,
844844+ })
845845+ w := httptest.NewRecorder()
846846+847847+ handler.HandleListRecords(w, req)
848848+849849+ result := assertJSONResponse(t, w, http.StatusOK)
850850+851851+ // Should have 4 crew records: 1 from bootstrap + 3 added
852852+ expectedCount := len(memberDIDs) + 1
853853+ if records, ok := result["records"].([]any); !ok {
854854+ t.Error("Expected records array in response")
855855+ } else if len(records) != expectedCount {
856856+ t.Errorf("Expected %d crew records, got %d", expectedCount, len(records))
857857+ } else {
858858+ // Verify each record has required fields
859859+ for i, rec := range records {
860860+ record, ok := rec.(map[string]any)
861861+ if !ok {
862862+ t.Errorf("Record %d: expected map, got %T", i, rec)
863863+ continue
864864+ }
865865+866866+ if uri, ok := record["uri"].(string); !ok || uri == "" {
867867+ t.Errorf("Record %d: expected uri string", i)
868868+ }
869869+870870+ if cid, ok := record["cid"].(string); !ok || cid == "" {
871871+ t.Errorf("Record %d: expected cid string", i)
872872+ }
873873+874874+ if value, ok := record["value"].(map[string]any); !ok {
875875+ t.Errorf("Record %d: expected value object", i)
876876+ } else {
877877+ if recordType, ok := value["$type"].(string); !ok || recordType != atproto.CrewCollection {
878878+ t.Errorf("Record %d: expected $type=%s, got %v", i, atproto.CrewCollection, value["$type"])
879879+ }
880880+ }
881881+ }
882882+ }
883883+}
884884+885885+// TestHandleListRecords_Indexed_Pagination tests pagination with indexed path
886886+func TestHandleListRecords_Indexed_Pagination(t *testing.T) {
887887+ handler, ctx := setupTestXRPCHandlerWithIndex(t)
888888+ holdDID := "did:web:hold.example.com"
889889+890890+ // Add 4 more crew members for total of 5
891891+ for i := 0; i < 4; i++ {
892892+ _, err := handler.pds.AddCrewMember(ctx, fmt.Sprintf("did:plc:member%d", i), "reader", []string{"blob:read"})
893893+ if err != nil {
894894+ t.Fatalf("Failed to add crew member: %v", err)
895895+ }
896896+ }
897897+898898+ // Test with limit=2
899899+ req := makeXRPCGetRequest(atproto.RepoListRecords, map[string]string{
900900+ "repo": holdDID,
901901+ "collection": atproto.CrewCollection,
902902+ "limit": "2",
903903+ })
904904+ w := httptest.NewRecorder()
905905+906906+ handler.HandleListRecords(w, req)
907907+908908+ result := assertJSONResponse(t, w, http.StatusOK)
909909+910910+ // Verify we got exactly 2 records
911911+ records, ok := result["records"].([]any)
912912+ if !ok {
913913+ t.Fatal("Expected records array in response")
914914+ }
915915+916916+ if len(records) != 2 {
917917+ t.Errorf("Expected 2 records with limit=2, got %d", len(records))
918918+ }
919919+920920+ // Verify cursor is present (there are more records)
921921+ cursor, ok := result["cursor"].(string)
922922+ if !ok || cursor == "" {
923923+ t.Fatal("Expected cursor in response when there are more records")
924924+ }
925925+926926+ // Test pagination with cursor
927927+ req2 := makeXRPCGetRequest(atproto.RepoListRecords, map[string]string{
928928+ "repo": holdDID,
929929+ "collection": atproto.CrewCollection,
930930+ "limit": "2",
931931+ "cursor": cursor,
932932+ })
933933+ w2 := httptest.NewRecorder()
934934+935935+ handler.HandleListRecords(w2, req2)
936936+937937+ result2 := assertJSONResponse(t, w2, http.StatusOK)
938938+939939+ records2, ok := result2["records"].([]any)
940940+ if !ok {
941941+ t.Fatal("Expected records array in paginated response")
942942+ }
943943+944944+ // Should get the next page of records
945945+ if len(records2) == 0 {
946946+ t.Error("Expected records in paginated response")
947947+ }
948948+949949+ // Verify no duplicates
950950+ seen := make(map[string]bool)
951951+ for _, r := range records {
952952+ rec := r.(map[string]any)
953953+ uri := rec["uri"].(string)
954954+ seen[uri] = true
955955+ }
956956+ for _, r := range records2 {
957957+ rec := r.(map[string]any)
958958+ uri := rec["uri"].(string)
959959+ if seen[uri] {
960960+ t.Errorf("Duplicate record in pagination: %s", uri)
961961+ }
962962+ }
963963+}
964964+965965+// TestHandleListRecords_Indexed_Reverse tests reverse ordering with indexed path
966966+func TestHandleListRecords_Indexed_Reverse(t *testing.T) {
967967+ handler, ctx := setupTestXRPCHandlerWithIndex(t)
968968+ holdDID := "did:web:hold.example.com"
969969+970970+ // Add crew members
971971+ for i := 0; i < 3; i++ {
972972+ _, err := handler.pds.AddCrewMember(ctx, fmt.Sprintf("did:plc:member%d", i), "reader", []string{"blob:read"})
973973+ if err != nil {
974974+ t.Fatalf("Failed to add crew member: %v", err)
975975+ }
976976+ }
977977+978978+ // Get normal order (default = newest first)
979979+ req1 := makeXRPCGetRequest(atproto.RepoListRecords, map[string]string{
980980+ "repo": holdDID,
981981+ "collection": atproto.CrewCollection,
982982+ })
983983+ w1 := httptest.NewRecorder()
984984+ handler.HandleListRecords(w1, req1)
985985+ result1 := assertJSONResponse(t, w1, http.StatusOK)
986986+987987+ // Get reverse order (oldest first)
988988+ req2 := makeXRPCGetRequest(atproto.RepoListRecords, map[string]string{
989989+ "repo": holdDID,
990990+ "collection": atproto.CrewCollection,
991991+ "reverse": "true",
992992+ })
993993+ w2 := httptest.NewRecorder()
994994+ handler.HandleListRecords(w2, req2)
995995+ result2 := assertJSONResponse(t, w2, http.StatusOK)
996996+997997+ records1 := result1["records"].([]any)
998998+ records2 := result2["records"].([]any)
999999+10001000+ if len(records1) != len(records2) {
10011001+ t.Fatalf("Expected same number of records, got %d vs %d", len(records1), len(records2))
10021002+ }
10031003+10041004+ if len(records1) > 1 {
10051005+ // First record in normal order should be last in reverse order
10061006+ first1 := records1[0].(map[string]any)["uri"].(string)
10071007+ last2 := records2[len(records2)-1].(map[string]any)["uri"].(string)
10081008+10091009+ if first1 != last2 {
10101010+ t.Error("Expected first record in default order to be last in reverse order")
10111011+ }
10121012+ }
10131013+}
10141014+10151015+// TestHandleListRecords_Indexed_EmptyCollection tests empty collection with indexed path
10161016+func TestHandleListRecords_Indexed_EmptyCollection(t *testing.T) {
10171017+ handler, _ := setupTestXRPCHandlerWithIndex(t)
10181018+ holdDID := "did:web:hold.example.com"
10191019+10201020+ // List a collection that doesn't exist
10211021+ req := makeXRPCGetRequest(atproto.RepoListRecords, map[string]string{
10221022+ "repo": holdDID,
10231023+ "collection": "io.atcr.nonexistent",
10241024+ })
10251025+ w := httptest.NewRecorder()
10261026+10271027+ handler.HandleListRecords(w, req)
10281028+10291029+ result := assertJSONResponse(t, w, http.StatusOK)
10301030+10311031+ records, ok := result["records"].([]any)
10321032+ if !ok {
10331033+ t.Fatal("Expected records array in response")
10341034+ }
10351035+10361036+ if len(records) != 0 {
10371037+ t.Errorf("Expected 0 records for empty collection, got %d", len(records))
10381038+ }
10391039+10401040+ // Should not have cursor for empty results
10411041+ if _, ok := result["cursor"]; ok {
10421042+ t.Error("Expected no cursor for empty collection")
7471043 }
7481044}
7491045