···243243docker logs -f atcr-appview
244244```
245245246246+#### Enable debug logging
247247+248248+Toggle debug logging at runtime without restarting the container:
249249+250250+```bash
251251+# Enable debug logging (auto-reverts after 30 minutes)
252252+docker kill -s SIGUSR1 atcr-appview
253253+docker kill -s SIGUSR1 atcr-hold
254254+255255+# Manually disable before timeout
256256+docker kill -s SIGUSR1 atcr-appview
257257+```
258258+259259+When toggled, you'll see:
260260+```
261261+level=INFO msg="Log level changed" from=INFO to=DEBUG trigger=SIGUSR1 auto_revert_in=30m0s
262262+```
263263+264264+**Note:** Despite the command name, `docker kill -s SIGUSR1` does NOT stop the container. It sends a user-defined signal that the application handles to toggle debug mode.
265265+246266#### Restart services
247267248268```bash
+52
lexicons/io/atcr/hold/stats.json
···11+{
22+ "lexicon": 1,
33+ "id": "io.atcr.hold.stats",
44+ "defs": {
55+ "main": {
66+ "type": "record",
77+ "key": "any",
88+ "description": "Repository statistics stored in the hold's embedded PDS. Tracks pull/push counts per owner+repository combination. Record key is deterministic: base32(sha256(ownerDID + \"/\" + repository)[:16]).",
99+ "record": {
1010+ "type": "object",
1111+ "required": ["ownerDid", "repository", "pullCount", "pushCount", "updatedAt"],
1212+ "properties": {
1313+ "ownerDid": {
1414+ "type": "string",
1515+ "format": "did",
1616+ "description": "DID of the image owner (e.g., did:plc:xyz123)"
1717+ },
1818+ "repository": {
1919+ "type": "string",
2020+ "description": "Repository name (e.g., myapp)",
2121+ "maxLength": 256
2222+ },
2323+ "pullCount": {
2424+ "type": "integer",
2525+ "minimum": 0,
2626+ "description": "Number of manifest downloads"
2727+ },
2828+ "pushCount": {
2929+ "type": "integer",
3030+ "minimum": 0,
3131+ "description": "Number of manifest uploads"
3232+ },
3333+ "lastPull": {
3434+ "type": "string",
3535+ "format": "datetime",
3636+ "description": "RFC3339 timestamp of last pull"
3737+ },
3838+ "lastPush": {
3939+ "type": "string",
4040+ "format": "datetime",
4141+ "description": "RFC3339 timestamp of last push"
4242+ },
4343+ "updatedAt": {
4444+ "type": "string",
4545+ "format": "datetime",
4646+ "description": "RFC3339 timestamp of when this record was last updated"
4747+ }
4848+ }
4949+ }
5050+ }
5151+ }
5252+}
+10
pkg/atproto/lexicon.go
···665665 return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
666666}
667667668668+// CrewRecordKey generates a deterministic rkey from member DID
669669+// Uses same pattern as StatsRecordKey for consistency
670670+// This enables O(1) crew membership lookups via getRecord instead of O(n) pagination
671671+func CrewRecordKey(memberDID string) string {
672672+ hash := sha256.Sum256([]byte(memberDID))
673673+ // Use first 16 bytes (128 bits) for collision resistance
674674+ // Encode with base32 (alphanumeric, lowercase, no padding) for ATProto rkey compatibility
675675+ return strings.ToLower(base32.StdEncoding.WithPadding(base32.NoPadding).EncodeToString(hash[:16]))
676676+}
677677+668678// TangledProfileRecord represents a Tangled profile for the hold
669679// Collection: sh.tangled.actor.profile (singleton record at rkey "self")
670680// Stored in the hold's embedded PDS
+44-56
pkg/auth/hold_remote.go
···324324}
325325326326// isCrewMemberNoCache queries XRPC without caching (internal helper)
327327-// Handles pagination to check all crew records, not just the first page
327327+// Uses O(1) lookup via getRecord with hash-based rkey instead of pagination
328328func (a *RemoteHoldAuthorizer) isCrewMemberNoCache(ctx context.Context, holdDID, userDID string) (bool, error) {
329329 // Resolve DID to URL
330330 holdURL := atproto.ResolveHoldURL(holdDID)
331331332332- // Paginate through all crew records
333333- cursor := ""
334334- for {
335335- // Build XRPC request URL with pagination
336336- // GET /xrpc/com.atproto.repo.listRecords?repo={did}&collection=io.atcr.hold.crew&limit=100
337337- xrpcURL := fmt.Sprintf("%s%s?repo=%s&collection=%s&limit=100",
338338- holdURL, atproto.RepoListRecords, url.QueryEscape(holdDID), url.QueryEscape(atproto.CrewCollection))
339339- if cursor != "" {
340340- xrpcURL += "&cursor=" + url.QueryEscape(cursor)
341341- }
332332+ // Generate deterministic rkey from member DID (hash-based)
333333+ rkey := atproto.CrewRecordKey(userDID)
342334343343- req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
344344- if err != nil {
345345- return false, err
346346- }
335335+ // Build XRPC request URL for direct record lookup
336336+ // GET /xrpc/com.atproto.repo.getRecord?repo={did}&collection=io.atcr.hold.crew&rkey={hash}
337337+ xrpcURL := fmt.Sprintf("%s%s?repo=%s&collection=%s&rkey=%s",
338338+ holdURL, atproto.RepoGetRecord, url.QueryEscape(holdDID), url.QueryEscape(atproto.CrewCollection), url.QueryEscape(rkey))
347339348348- resp, err := a.httpClient.Do(req)
349349- if err != nil {
350350- return false, fmt.Errorf("XRPC request failed: %w", err)
351351- }
340340+ req, err := http.NewRequestWithContext(ctx, "GET", xrpcURL, nil)
341341+ if err != nil {
342342+ return false, err
343343+ }
352344353353- if resp.StatusCode != http.StatusOK {
354354- body, _ := io.ReadAll(resp.Body)
355355- resp.Body.Close()
356356- return false, fmt.Errorf("XRPC request failed: status %d: %s", resp.StatusCode, string(body))
357357- }
345345+ resp, err := a.httpClient.Do(req)
346346+ if err != nil {
347347+ return false, fmt.Errorf("XRPC request failed: %w", err)
348348+ }
349349+ defer resp.Body.Close()
358350359359- // Parse response
360360- var xrpcResp struct {
361361- Cursor string `json:"cursor"`
362362- Records []struct {
363363- URI string `json:"uri"`
364364- CID string `json:"cid"`
365365- Value struct {
366366- Type string `json:"$type"`
367367- Member string `json:"member"`
368368- Role string `json:"role"`
369369- Permissions []string `json:"permissions"`
370370- AddedAt string `json:"addedAt"`
371371- } `json:"value"`
372372- } `json:"records"`
373373- }
351351+ // 404 means not a crew member (record doesn't exist)
352352+ if resp.StatusCode == http.StatusNotFound {
353353+ return false, nil
354354+ }
374355375375- if err := json.NewDecoder(resp.Body).Decode(&xrpcResp); err != nil {
376376- resp.Body.Close()
377377- return false, fmt.Errorf("failed to decode XRPC response: %w", err)
378378- }
379379- resp.Body.Close()
356356+ if resp.StatusCode != http.StatusOK {
357357+ body, _ := io.ReadAll(resp.Body)
358358+ return false, fmt.Errorf("XRPC request failed: status %d: %s", resp.StatusCode, string(body))
359359+ }
380360381381- // Check if userDID is in this page of crew records
382382- for _, record := range xrpcResp.Records {
383383- if record.Value.Member == userDID {
384384- // TODO: Check expiration if set
385385- return true, nil
386386- }
387387- }
361361+ // Parse response to verify the member DID matches
362362+ var xrpcResp struct {
363363+ URI string `json:"uri"`
364364+ CID string `json:"cid"`
365365+ Value struct {
366366+ Type string `json:"$type"`
367367+ Member string `json:"member"`
368368+ Role string `json:"role"`
369369+ Permissions []string `json:"permissions"`
370370+ AddedAt string `json:"addedAt"`
371371+ } `json:"value"`
372372+ }
388373389389- // Check if there are more pages
390390- if xrpcResp.Cursor == "" || len(xrpcResp.Records) == 0 {
391391- break
392392- }
393393- cursor = xrpcResp.Cursor
374374+ if err := json.NewDecoder(resp.Body).Decode(&xrpcResp); err != nil {
375375+ return false, fmt.Errorf("failed to decode XRPC response: %w", err)
394376 }
395377378378+ // Verify the member DID matches (sanity check)
379379+ if xrpcResp.Value.Member == userDID {
380380+ return true, nil
381381+ }
382382+383383+ // Hash collision or invalid record - treat as not a member
396384 return false, nil
397385}
398386
···55 "context"
66 "errors"
77 "fmt"
88+ "log/slog"
89 "strings"
910 "time"
1011···1415)
15161617// AddCrewMember adds a new crew member to the hold and commits to carstore
1818+// Uses deterministic rkey based on member DID hash for O(1) lookups and automatic deduplication
1919+// If the member already exists, updates their record (upsert behavior)
1720func (p *HoldPDS) AddCrewMember(ctx context.Context, memberDID, role string, permissions []string) (cid.Cid, error) {
1821 crewRecord := &atproto.CrewRecord{
1922 Type: atproto.CrewCollection,
···2326 AddedAt: time.Now().Format(time.RFC3339),
2427 }
25282626- // Use repomgr for crew operations - auto-generated rkey is fine
2727- _, recordCID, err := p.repomgr.CreateRecord(ctx, p.uid, atproto.CrewCollection, crewRecord)
2929+ // Use deterministic rkey based on member DID hash
3030+ // UpsertRecord handles create-or-update automatically
3131+ rkey := atproto.CrewRecordKey(memberDID)
3232+ _, recordCID, _, err := p.repomgr.UpsertRecord(ctx, p.uid, atproto.CrewCollection, rkey, crewRecord)
2833 if err != nil {
2929- return cid.Undef, fmt.Errorf("failed to create crew record: %w", err)
3434+ return cid.Undef, fmt.Errorf("failed to upsert crew record: %w", err)
3035 }
31363237 return recordCID, nil
···4752 }
48534954 return recordCID, crewRecord, nil
5555+}
5656+5757+// GetCrewMemberByDID retrieves a crew member by their DID using O(1) lookup
5858+// Uses deterministic rkey based on member DID hash
5959+func (p *HoldPDS) GetCrewMemberByDID(ctx context.Context, memberDID string) (cid.Cid, *atproto.CrewRecord, error) {
6060+ rkey := atproto.CrewRecordKey(memberDID)
6161+ return p.GetCrewMember(ctx, rkey)
5062}
51635264// CrewMemberWithKey pairs a crew record with its rkey and CID
···138150 return crew, nil
139151}
140152141141-// RemoveCrewMember removes a crew member
153153+// RemoveCrewMember removes a crew member by rkey
142154func (p *HoldPDS) RemoveCrewMember(ctx context.Context, rkey string) error {
143155 // Use repomgr.DeleteRecord - it will automatically commit!
144156 // This fixes the bug where deletions weren't being committed
···150162 return nil
151163}
152164165165+// RemoveCrewMemberByDID removes a crew member by their DID using O(1) lookup
166166+func (p *HoldPDS) RemoveCrewMemberByDID(ctx context.Context, memberDID string) error {
167167+ rkey := atproto.CrewRecordKey(memberDID)
168168+ return p.RemoveCrewMember(ctx, rkey)
169169+}
170170+153171// UpdateCrewMemberTier updates a crew member's tier
154154-// Since ATProto records are immutable, this finds the member's record by DID,
155155-// deletes it, and recreates it with the new tier value.
172172+// Uses O(1) lookup via hash-based rkey and PutRecord for atomic upsert
156173func (p *HoldPDS) UpdateCrewMemberTier(ctx context.Context, memberDID, tier string) error {
157157- // Find the crew member's record by iterating over crew records
158158- members, err := p.ListCrewMembers(ctx)
174174+ // O(1) lookup using hash-based rkey
175175+ _, existing, err := p.GetCrewMemberByDID(ctx, memberDID)
159176 if err != nil {
160160- return fmt.Errorf("failed to list crew members: %w", err)
161161- }
162162-163163- // Find the member with matching DID
164164- var targetMember *CrewMemberWithKey
165165- for _, m := range members {
166166- if m.Record.Member == memberDID {
167167- targetMember = m
168168- break
169169- }
170170- }
171171-172172- if targetMember == nil {
173173- return fmt.Errorf("crew member not found: %s", memberDID)
177177+ return fmt.Errorf("crew member not found: %w", err)
174178 }
175179176180 // If tier is already the same, no update needed
177177- if targetMember.Record.Tier == tier {
181181+ if existing.Tier == tier {
178182 return nil
179183 }
180184181181- // Delete the old record
182182- if err := p.RemoveCrewMember(ctx, targetMember.Rkey); err != nil {
183183- return fmt.Errorf("failed to remove old crew record: %w", err)
184184- }
185185-186186- // Create new record with updated tier
185185+ // Create updated record (PutRecord handles upsert with same rkey)
187186 newRecord := &atproto.CrewRecord{
188187 Type: atproto.CrewCollection,
189189- Member: targetMember.Record.Member,
190190- Role: targetMember.Record.Role,
191191- Permissions: targetMember.Record.Permissions,
188188+ Member: existing.Member,
189189+ Role: existing.Role,
190190+ Permissions: existing.Permissions,
192191 Tier: tier,
193193- AddedAt: targetMember.Record.AddedAt, // Preserve original add time
192192+ AddedAt: existing.AddedAt, // Preserve original add time
194193 }
195194196196- _, _, err = p.repomgr.CreateRecord(ctx, p.uid, atproto.CrewCollection, newRecord)
195195+ rkey := atproto.CrewRecordKey(memberDID)
196196+ _, _, err = p.repomgr.PutRecord(ctx, p.uid, atproto.CrewCollection, rkey, newRecord)
197197 if err != nil {
198198- return fmt.Errorf("failed to create updated crew record: %w", err)
198198+ return fmt.Errorf("failed to update crew record: %w", err)
199199 }
200200201201 return nil
202202}
203203+204204+// TODO(crew-migration): Remove this migration code after all holds have been upgraded (added 2026-01-06)
205205+// This migrates TID-based crew records to hash-based rkeys for O(1) lookups
206206+207207+// MigrateCrewRecordsToHashRkeys migrates old TID-based crew records to hash-based rkeys
208208+// This is idempotent - records that already have hash-based rkeys are skipped
209209+// Returns the number of records migrated
210210+func (p *HoldPDS) MigrateCrewRecordsToHashRkeys(ctx context.Context) (int, error) {
211211+ // List all crew members (includes both TID and hash-based rkeys)
212212+ members, err := p.ListCrewMembers(ctx)
213213+ if err != nil {
214214+ return 0, fmt.Errorf("failed to list crew members: %w", err)
215215+ }
216216+217217+ slog.Info("Starting crew record migration", "totalRecords", len(members))
218218+219219+ migrated := 0
220220+ duplicatesDeleted := 0
221221+ alreadyHashBased := 0
222222+ seen := make(map[string]bool) // Track seen member DIDs to handle duplicates
223223+224224+ for _, m := range members {
225225+ memberDID := m.Record.Member
226226+ expectedRkey := atproto.CrewRecordKey(memberDID)
227227+228228+ // Skip if already using hash-based rkey
229229+ if m.Rkey == expectedRkey {
230230+ seen[memberDID] = true
231231+ alreadyHashBased++
232232+ continue
233233+ }
234234+235235+ // This is a TID-based record that needs migration
236236+ slog.Info("Migrating crew record to hash-based rkey",
237237+ "memberDID", memberDID,
238238+ "oldRkey", m.Rkey,
239239+ "newRkey", expectedRkey)
240240+241241+ // Check if we already have a hash-based record for this DID (duplicate handling)
242242+ if seen[memberDID] {
243243+ // Already migrated this DID, just delete the old TID record
244244+ slog.Info("Deleting duplicate TID-based crew record",
245245+ "memberDID", memberDID,
246246+ "rkey", m.Rkey)
247247+ if err := p.RemoveCrewMember(ctx, m.Rkey); err != nil {
248248+ slog.Warn("Failed to delete duplicate crew record",
249249+ "rkey", m.Rkey,
250250+ "error", err)
251251+ } else {
252252+ duplicatesDeleted++
253253+ }
254254+ continue
255255+ }
256256+257257+ // Create new record with hash-based rkey (PutRecord handles upsert)
258258+ newRecord := &atproto.CrewRecord{
259259+ Type: atproto.CrewCollection,
260260+ Member: m.Record.Member,
261261+ Role: m.Record.Role,
262262+ Permissions: m.Record.Permissions,
263263+ Tier: m.Record.Tier,
264264+ AddedAt: m.Record.AddedAt,
265265+ }
266266+267267+ _, _, err := p.repomgr.PutRecord(ctx, p.uid, atproto.CrewCollection, expectedRkey, newRecord)
268268+ if err != nil {
269269+ slog.Error("Failed to create hash-based crew record",
270270+ "memberDID", memberDID,
271271+ "error", err)
272272+ continue
273273+ }
274274+275275+ // Delete the old TID-based record
276276+ if err := p.RemoveCrewMember(ctx, m.Rkey); err != nil {
277277+ slog.Warn("Failed to delete old TID-based crew record",
278278+ "rkey", m.Rkey,
279279+ "error", err)
280280+ // Continue anyway - the new record is created
281281+ }
282282+283283+ seen[memberDID] = true
284284+ migrated++
285285+ }
286286+287287+ slog.Info("Crew record migration complete",
288288+ "migrated", migrated,
289289+ "duplicatesDeleted", duplicatesDeleted,
290290+ "alreadyHashBased", alreadyHashBased,
291291+ "totalRecords", len(members))
292292+293293+ return migrated, nil
294294+}
+127-12
pkg/hold/pds/records.go
···11package pds
2233import (
44+ "bytes"
45 "context"
56 "database/sql"
67 "fmt"
78 "log/slog"
89 "strings"
9101111+ "atcr.io/pkg/atproto"
1012 "github.com/bluesky-social/indigo/repo"
1113 "github.com/ipfs/go-cid"
1214 _ "github.com/mattn/go-sqlite3"
···2426 Collection string
2527 Rkey string
2628 Cid string
2929+ Did string // Associated DID (member for crew, userDid for layers, ownerDid for stats)
2730}
28312932const recordsSchema = `
···3134 collection TEXT NOT NULL,
3235 rkey TEXT NOT NULL,
3336 cid TEXT NOT NULL,
3737+ did TEXT,
3438 PRIMARY KEY (collection, rkey)
3539);
3640CREATE INDEX IF NOT EXISTS idx_records_collection_rkey ON records(collection, rkey);
4141+CREATE INDEX IF NOT EXISTS idx_records_collection_did ON records(collection, did);
3742`
38434444+// Schema version for migration detection
4545+const recordsSchemaVersion = 2
4646+3947// NewRecordsIndex creates or opens a records index
4848+// If the schema is outdated (missing did column), drops and rebuilds the table
4049func NewRecordsIndex(dbPath string) (*RecordsIndex, error) {
4150 db, err := sql.Open("sqlite3", dbPath)
4251 if err != nil {
4352 return nil, fmt.Errorf("failed to open records database: %w", err)
4453 }
45545555+ // Check if table exists and has the did column
5656+ needsRebuild := false
5757+ var tableName string
5858+ err = db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='records'`).Scan(&tableName)
5959+ if err == nil {
6060+ // Table exists, check for did column
6161+ var colCount int
6262+ err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('records') WHERE name='did'`).Scan(&colCount)
6363+ if err != nil || colCount == 0 {
6464+ needsRebuild = true
6565+ slog.Info("Records index schema outdated, rebuilding with did column")
6666+ }
6767+ }
6868+6969+ if needsRebuild {
7070+ // Drop old table
7171+ _, err = db.Exec(`DROP TABLE IF EXISTS records`)
7272+ if err != nil {
7373+ db.Close()
7474+ return nil, fmt.Errorf("failed to drop old records table: %w", err)
7575+ }
7676+ }
7777+4678 // Create schema
4779 _, err = db.Exec(recordsSchema)
4880 if err != nil {
···6294}
63956496// IndexRecord adds or updates a record in the index
6565-func (ri *RecordsIndex) IndexRecord(collection, rkey, cidStr string) error {
9797+// did parameter is optional - pass empty string if not applicable
9898+func (ri *RecordsIndex) IndexRecord(collection, rkey, cidStr, did string) error {
6699 _, err := ri.db.Exec(`
6767- INSERT OR REPLACE INTO records (collection, rkey, cid)
6868- VALUES (?, ?, ?)
6969- `, collection, rkey, cidStr)
100100+ INSERT OR REPLACE INTO records (collection, rkey, cid, did)
101101+ VALUES (?, ?, ?, ?)
102102+ `, collection, rkey, cidStr, sql.NullString{String: did, Valid: did != ""})
70103 return err
71104}
72105···90123 // Oldest first (ascending order)
91124 if cursor != "" {
92125 query = `
9393- SELECT collection, rkey, cid FROM records
126126+ SELECT collection, rkey, cid, COALESCE(did, '') FROM records
94127 WHERE collection = ? AND rkey > ?
95128 ORDER BY rkey ASC
96129 LIMIT ?
···98131 args = []any{collection, cursor, limit + 1}
99132 } else {
100133 query = `
101101- SELECT collection, rkey, cid FROM records
134134+ SELECT collection, rkey, cid, COALESCE(did, '') FROM records
102135 WHERE collection = ?
103136 ORDER BY rkey ASC
104137 LIMIT ?
···109142 // Newest first (descending order) - default
110143 if cursor != "" {
111144 query = `
112112- SELECT collection, rkey, cid FROM records
145145+ SELECT collection, rkey, cid, COALESCE(did, '') FROM records
113146 WHERE collection = ? AND rkey < ?
114147 ORDER BY rkey DESC
115148 LIMIT ?
···117150 args = []any{collection, cursor, limit + 1}
118151 } else {
119152 query = `
120120- SELECT collection, rkey, cid FROM records
153153+ SELECT collection, rkey, cid, COALESCE(did, '') FROM records
121154 WHERE collection = ?
122155 ORDER BY rkey DESC
123156 LIMIT ?
···135168 var records []Record
136169 for rows.Next() {
137170 var rec Record
138138- if err := rows.Scan(&rec.Collection, &rec.Rkey, &rec.Cid); err != nil {
171171+ if err := rows.Scan(&rec.Collection, &rec.Rkey, &rec.Cid, &rec.Did); err != nil {
139172 return nil, "", fmt.Errorf("failed to scan record: %w", err)
140173 }
141174 records = append(records, rec)
···156189 return records, nextCursor, nil
157190}
158191192192+// ListRecordsByDID returns records for a collection filtered by DID with pagination support
193193+func (ri *RecordsIndex) ListRecordsByDID(collection, did string, limit int, cursor string) ([]Record, string, error) {
194194+ var query string
195195+ var args []any
196196+197197+ if cursor != "" {
198198+ query = `
199199+ SELECT collection, rkey, cid, COALESCE(did, '') FROM records
200200+ WHERE collection = ? AND did = ? AND rkey < ?
201201+ ORDER BY rkey DESC
202202+ LIMIT ?
203203+ `
204204+ args = []any{collection, did, cursor, limit + 1}
205205+ } else {
206206+ query = `
207207+ SELECT collection, rkey, cid, COALESCE(did, '') FROM records
208208+ WHERE collection = ? AND did = ?
209209+ ORDER BY rkey DESC
210210+ LIMIT ?
211211+ `
212212+ args = []any{collection, did, limit + 1}
213213+ }
214214+215215+ rows, err := ri.db.Query(query, args...)
216216+ if err != nil {
217217+ return nil, "", fmt.Errorf("failed to query records: %w", err)
218218+ }
219219+ defer rows.Close()
220220+221221+ var records []Record
222222+ for rows.Next() {
223223+ var rec Record
224224+ if err := rows.Scan(&rec.Collection, &rec.Rkey, &rec.Cid, &rec.Did); err != nil {
225225+ return nil, "", fmt.Errorf("failed to scan record: %w", err)
226226+ }
227227+ records = append(records, rec)
228228+ }
229229+230230+ if err := rows.Err(); err != nil {
231231+ return nil, "", fmt.Errorf("error iterating records: %w", err)
232232+ }
233233+234234+ // Determine next cursor
235235+ var nextCursor string
236236+ if len(records) > limit {
237237+ nextCursor = records[limit-1].Rkey
238238+ records = records[:limit]
239239+ }
240240+241241+ return records, nextCursor, nil
242242+}
243243+159244// Count returns the number of records in a collection
160245func (ri *RecordsIndex) Count(collection string) (int, error) {
161246 var count int
···174259175260// BackfillFromRepo populates the records index from an existing MST repo
176261// Compares MST count with index count - only backfills if they differ
262262+// Extracts DID from record content for crew, layer, and stats records
177263func (ri *RecordsIndex) BackfillFromRepo(ctx context.Context, repoHandle *repo.Repo) error {
178264 // Count records in MST
179265 mstCount := 0
···207293 defer tx.Rollback()
208294209295 stmt, err := tx.Prepare(`
210210- INSERT OR REPLACE INTO records (collection, rkey, cid)
211211- VALUES (?, ?, ?)
296296+ INSERT OR REPLACE INTO records (collection, rkey, cid, did)
297297+ VALUES (?, ?, ?, ?)
212298 `)
213299 if err != nil {
214300 return fmt.Errorf("failed to prepare statement: %w", err)
···224310 }
225311 collection, rkey := parts[0], parts[1]
226312227227- _, err := stmt.Exec(collection, rkey, c.String())
313313+ // Extract DID from record content based on collection type
314314+ var did string
315315+ _, recBytes, err := repoHandle.GetRecordBytes(ctx, key)
316316+ if err == nil && recBytes != nil {
317317+ did = extractDIDFromRecord(collection, *recBytes)
318318+ }
319319+320320+ _, err = stmt.Exec(collection, rkey, c.String(), sql.NullString{String: did, Valid: did != ""})
228321 if err != nil {
229322 return fmt.Errorf("failed to index record %s: %w", key, err)
230323 }
···249342 slog.Info("Backfill complete", "records", recordCount)
250343 return nil
251344}
345345+346346+// extractDIDFromRecord extracts the associated DID from a record based on its collection type
347347+func extractDIDFromRecord(collection string, recBytes []byte) string {
348348+ switch collection {
349349+ case atproto.CrewCollection:
350350+ var rec atproto.CrewRecord
351351+ if err := rec.UnmarshalCBOR(bytes.NewReader(recBytes)); err == nil {
352352+ return rec.Member
353353+ }
354354+ case atproto.LayerCollection:
355355+ var rec atproto.LayerRecord
356356+ if err := rec.UnmarshalCBOR(bytes.NewReader(recBytes)); err == nil {
357357+ return rec.UserDID
358358+ }
359359+ case atproto.StatsCollection:
360360+ var rec atproto.StatsRecord
361361+ if err := rec.UnmarshalCBOR(bytes.NewReader(recBytes)); err == nil {
362362+ return rec.OwnerDID
363363+ }
364364+ }
365365+ return ""
366366+}
+19-19
pkg/hold/pds/records_test.go
···5050 defer ri.Close()
51515252 // Index a record
5353- err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123")
5353+ err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123", "")
5454 if err != nil {
5555 t.Fatalf("IndexRecord() error = %v", err)
5656 }
···7575 defer ri.Close()
76767777 // Index a record
7878- err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123")
7878+ err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123", "")
7979 if err != nil {
8080 t.Fatalf("IndexRecord() first call error = %v", err)
8181 }
82828383 // Update the same record with new CID
8484- err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei456")
8484+ err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei456", "")
8585 if err != nil {
8686 t.Fatalf("IndexRecord() second call error = %v", err)
8787 }
···118118 defer ri.Close()
119119120120 // Index a record
121121- err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123")
121121+ err = ri.IndexRecord("io.atcr.hold.crew", "abc123", "bafyrei123", "")
122122 if err != nil {
123123 t.Fatalf("IndexRecord() error = %v", err)
124124 }
···217217 {"ccc", "cid3"},
218218 }
219219 for _, r := range records {
220220- if err := ri.IndexRecord("io.atcr.hold.crew", r.rkey, r.cid); err != nil {
220220+ if err := ri.IndexRecord("io.atcr.hold.crew", r.rkey, r.cid, ""); err != nil {
221221 t.Fatalf("IndexRecord() error = %v", err)
222222 }
223223 }
···248248 // Add records with different rkeys (TIDs are lexicographically ordered by time)
249249 rkeys := []string{"3m3aaaaaaaaa", "3m3bbbbbbbbb", "3m3ccccccccc"}
250250 for _, rkey := range rkeys {
251251- if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey); err != nil {
251251+ if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, ""); err != nil {
252252 t.Fatalf("IndexRecord() error = %v", err)
253253 }
254254 }
···286286 // Add records
287287 rkeys := []string{"3m3aaaaaaaaa", "3m3bbbbbbbbb", "3m3ccccccccc"}
288288 for _, rkey := range rkeys {
289289- if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey); err != nil {
289289+ if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, ""); err != nil {
290290 t.Fatalf("IndexRecord() error = %v", err)
291291 }
292292 }
···324324 // Add 5 records
325325 for i := range 5 {
326326 rkey := string(rune('a' + i))
327327- if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey); err != nil {
327327+ if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, ""); err != nil {
328328 t.Fatalf("IndexRecord() error = %v", err)
329329 }
330330 }
···355355 // Add 5 records
356356 rkeys := []string{"a", "b", "c", "d", "e"}
357357 for _, rkey := range rkeys {
358358- if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey); err != nil {
358358+ if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, ""); err != nil {
359359 t.Fatalf("IndexRecord() error = %v", err)
360360 }
361361 }
···430430 // Add 5 records
431431 rkeys := []string{"a", "b", "c", "d", "e"}
432432 for _, rkey := range rkeys {
433433- if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey); err != nil {
433433+ if err := ri.IndexRecord("io.atcr.hold.crew", rkey, "cid-"+rkey, ""); err != nil {
434434 t.Fatalf("IndexRecord() error = %v", err)
435435 }
436436 }
···474474475475 // Add records to two collections
476476 for i := range 3 {
477477- ri.IndexRecord("io.atcr.hold.crew", string(rune('a'+i)), "cid1")
477477+ ri.IndexRecord("io.atcr.hold.crew", string(rune('a'+i)), "cid1", "")
478478 }
479479 for i := range 5 {
480480- ri.IndexRecord("io.atcr.hold.captain", string(rune('a'+i)), "cid2")
480480+ ri.IndexRecord("io.atcr.hold.captain", string(rune('a'+i)), "cid2", "")
481481 }
482482483483 // Count crew
···527527 defer ri.Close()
528528529529 // Add records to multiple collections
530530- ri.IndexRecord("io.atcr.hold.crew", "a", "cid1")
531531- ri.IndexRecord("io.atcr.hold.crew", "b", "cid2")
532532- ri.IndexRecord("io.atcr.hold.captain", "self", "cid3")
533533- ri.IndexRecord("io.atcr.manifest", "abc123", "cid4")
530530+ ri.IndexRecord("io.atcr.hold.crew", "a", "cid1", "")
531531+ ri.IndexRecord("io.atcr.hold.crew", "b", "cid2", "")
532532+ ri.IndexRecord("io.atcr.hold.captain", "self", "cid3", "")
533533+ ri.IndexRecord("io.atcr.manifest", "abc123", "cid4", "")
534534535535 count, err := ri.TotalCount()
536536 if err != nil {
···581581 defer ri.Close()
582582583583 // Add records to different collections with same rkeys
584584- ri.IndexRecord("io.atcr.hold.crew", "abc", "cid-crew")
585585- ri.IndexRecord("io.atcr.hold.captain", "abc", "cid-captain")
586586- ri.IndexRecord("io.atcr.manifest", "abc", "cid-manifest")
584584+ ri.IndexRecord("io.atcr.hold.crew", "abc", "cid-crew", "")
585585+ ri.IndexRecord("io.atcr.hold.captain", "abc", "cid-captain", "")
586586+ ri.IndexRecord("io.atcr.manifest", "abc", "cid-manifest", "")
587587588588 // Listing should only return records from requested collection
589589 records, _, err := ri.ListRecords("io.atcr.hold.crew", 10, "", false)
+88
pkg/hold/pds/repomgr.go
···382382 return rpath, cc, nil
383383}
384384385385+// UpsertRecord creates or updates a record with an explicit rkey.
386386+// If the record doesn't exist, it creates it. If it exists, it updates it.
387387+// Returns the collection path (e.g., "io.atcr.captain/self"), CID, and whether it was created (true) or updated (false).
388388+func (rm *RepoManager) UpsertRecord(ctx context.Context, user models.Uid, collection, rkey string, rec cbg.CBORMarshaler) (string, cid.Cid, bool, error) {
389389+ ctx, span := otel.Tracer("repoman").Start(ctx, "UpsertRecord")
390390+ defer span.End()
391391+392392+ unlock := rm.lockUser(ctx, user)
393393+ defer unlock()
394394+395395+ rev, err := rm.cs.GetUserRepoRev(ctx, user)
396396+ if err != nil {
397397+ return "", cid.Undef, false, err
398398+ }
399399+400400+ ds, err := rm.cs.NewDeltaSession(ctx, user, &rev)
401401+ if err != nil {
402402+ return "", cid.Undef, false, err
403403+ }
404404+405405+ head := ds.BaseCid()
406406+ r, err := repo.OpenRepo(ctx, ds, head)
407407+ if err != nil {
408408+ return "", cid.Undef, false, err
409409+ }
410410+411411+ rpath := collection + "/" + rkey
412412+413413+ // Check if record exists
414414+ _, _, err = r.GetRecordBytes(ctx, rpath)
415415+ recordExists := err == nil
416416+417417+ var cc cid.Cid
418418+ var evtKind EventKind
419419+ if recordExists {
420420+ // Update existing record
421421+ cc, err = r.UpdateRecord(ctx, rpath, rec)
422422+ evtKind = EvtKindUpdateRecord
423423+ } else {
424424+ // Create new record
425425+ cc, err = r.PutRecord(ctx, rpath, rec)
426426+ evtKind = EvtKindCreateRecord
427427+ }
428428+ if err != nil {
429429+ return "", cid.Undef, false, err
430430+ }
431431+432432+ nroot, nrev, err := r.Commit(ctx, rm.kmgr.SignForUser)
433433+ if err != nil {
434434+ return "", cid.Undef, false, err
435435+ }
436436+437437+ rslice, err := ds.CloseWithRoot(ctx, nroot, nrev)
438438+ if err != nil {
439439+ return "", cid.Undef, false, fmt.Errorf("close with root: %w", err)
440440+ }
441441+442442+ var oldroot *cid.Cid
443443+ if head.Defined() {
444444+ oldroot = &head
445445+ }
446446+447447+ if rm.events != nil {
448448+ op := RepoOp{
449449+ Kind: evtKind,
450450+ Collection: collection,
451451+ Rkey: rkey,
452452+ RecCid: &cc,
453453+ }
454454+455455+ if rm.hydrateRecords {
456456+ op.Record = rec
457457+ }
458458+459459+ rm.events(ctx, &RepoEvent{
460460+ User: user,
461461+ OldRoot: oldroot,
462462+ NewRoot: nroot,
463463+ Rev: nrev,
464464+ Since: &rev,
465465+ Ops: []RepoOp{op},
466466+ RepoSlice: rslice,
467467+ })
468468+ }
469469+470470+ return rpath, cc, !recordExists, nil
471471+}
472472+385473func (rm *RepoManager) DeleteRecord(ctx context.Context, user models.Uid, collection, rkey string) error {
386474 ctx, span := otel.Tracer("repoman").Start(ctx, "DeleteRecord")
387475 defer span.End()
+33-1
pkg/hold/pds/server.go
···225225 }
226226 }
227227228228+ // TODO(crew-migration): Remove this call after all holds have been upgraded (added 2026-01-06)
229229+ // Migrate TID-based crew records to hash-based rkeys for O(1) lookups
230230+ if migrated, err := p.MigrateCrewRecordsToHashRkeys(ctx); err != nil {
231231+ slog.Warn("Crew record migration failed", "error", err)
232232+ } else if migrated > 0 {
233233+ slog.Info("Migrated crew records to hash-based rkeys", "count", migrated)
234234+ }
235235+228236 // Create Bluesky profile record (idempotent - check if exists first)
229237 // This runs even if captain exists (for existing holds being upgraded)
230238 // Skip if no storage driver (e.g., in tests)
···319327 if op.RecCid != nil {
320328 cidStr = op.RecCid.String()
321329 }
322322- if err := p.recordsIndex.IndexRecord(op.Collection, op.Rkey, cidStr); err != nil {
330330+ // Extract DID from record based on collection type
331331+ did := extractDIDFromOp(op)
332332+ if err := p.recordsIndex.IndexRecord(op.Collection, op.Rkey, cidStr, did); err != nil {
323333 slog.Warn("Failed to index record", "collection", op.Collection, "rkey", op.Rkey, "error", err)
324334 }
325335 case EvtKindDeleteRecord:
···336346 broadcasterHandler(ctx, event)
337347 }
338348 }
349349+}
350350+351351+// extractDIDFromOp extracts the associated DID from a repo operation based on collection type
352352+func extractDIDFromOp(op RepoOp) string {
353353+ if op.Record == nil {
354354+ return ""
355355+ }
356356+ switch op.Collection {
357357+ case atproto.CrewCollection:
358358+ if rec, ok := op.Record.(*atproto.CrewRecord); ok {
359359+ return rec.Member
360360+ }
361361+ case atproto.LayerCollection:
362362+ if rec, ok := op.Record.(*atproto.LayerRecord); ok {
363363+ return rec.UserDID
364364+ }
365365+ case atproto.StatsCollection:
366366+ if rec, ok := op.Record.(*atproto.StatsRecord); ok {
367367+ return rec.OwnerDID
368368+ }
369369+ }
370370+ return ""
339371}
340372341373// BackfillRecordsIndex populates the records index from existing MST data
+4-5
pkg/hold/pds/server_test.go
···524524 }
525525526526 // Verify crew wasn't duplicated (Bootstrap adds owner as crew, but they already exist)
527527+ // With hash-based rkeys, AddCrewMember uses PutRecord which upserts - no duplicates possible
527528 crewAfter, err := pds.ListCrewMembers(ctx)
528529 if err != nil {
529530 t.Fatalf("ListCrewMembers failed after bootstrap: %v", err)
530531 }
531532532532- // Should have 2 crew members now: original + one added by bootstrap
533533- // (Bootstrap doesn't check for duplicates currently)
534534- if len(crewAfter) != 2 {
535535- t.Logf("Note: Bootstrap added owner as crew even though they already existed")
536536- t.Logf("Crew count after bootstrap: %d", len(crewAfter))
533533+ // Should still have 1 crew member (hash-based rkey ensures upsert, not duplicate)
534534+ if len(crewAfter) != 1 {
535535+ t.Errorf("Expected 1 crew member after bootstrap (upsert), got %d", len(crewAfter))
537536 }
538537}
539538
+106-1
pkg/logging/logger.go
···11// Package logging provides centralized structured logging using slog
22// with configurable log levels. Call InitLogger() from main() to configure.
33+//
44+// Dynamic debug logging:
55+// Send SIGUSR1 to toggle debug mode at runtime (auto-reverts after 30 minutes).
66+// Example: docker kill -s SIGUSR1 <container>
37package logging
4859import (
610 "io"
711 "log/slog"
812 "os"
1313+ "os/signal"
914 "strings"
1515+ "sync"
1616+ "sync/atomic"
1717+ "syscall"
1818+ "time"
1919+)
2020+2121+const debugTimeout = 30 * time.Minute
2222+2323+var (
2424+ levelVar *slog.LevelVar
2525+ originalLevel slog.Level
2626+ debugEnabled atomic.Bool
2727+ revertTimer *time.Timer
2828+ revertMu sync.Mutex
1029)
11301231// InitLogger initializes the global slog default logger with the specified log level.
1332// Valid levels: debug, info, warn, error (case-insensitive)
1433// If level is empty or invalid, defaults to INFO.
1534// Call this from main() at startup.
3535+//
3636+// Also starts a signal handler for SIGUSR1 to toggle debug mode at runtime.
1637func InitLogger(level string) {
1738 var logLevel slog.Level
1839···2950 logLevel = slog.LevelInfo
3051 }
31525353+ // Store original level for toggle-back and use LevelVar for dynamic changes
5454+ originalLevel = logLevel
5555+ levelVar = new(slog.LevelVar)
5656+ levelVar.Set(logLevel)
5757+3258 opts := &slog.HandlerOptions{
3333- Level: logLevel,
5959+ Level: levelVar,
3460 }
35613662 handler := slog.NewTextHandler(os.Stdout, opts)
3763 slog.SetDefault(slog.New(handler))
6464+6565+ // Start signal handler for dynamic debug toggle
6666+ go handleDebugSignal()
6767+}
6868+6969+func handleDebugSignal() {
7070+ sigChan := make(chan os.Signal, 1)
7171+ signal.Notify(sigChan, syscall.SIGUSR1)
7272+7373+ for range sigChan {
7474+ ToggleDebug()
7575+ }
7676+}
7777+7878+// ToggleDebug toggles between the original log level and DEBUG.
7979+// When enabling debug, starts a 30-minute timer that auto-reverts.
8080+// Typically called via SIGUSR1 signal.
8181+func ToggleDebug() {
8282+ revertMu.Lock()
8383+ defer revertMu.Unlock()
8484+8585+ wasDebug := debugEnabled.Swap(!debugEnabled.Load())
8686+8787+ // Cancel any existing revert timer
8888+ if revertTimer != nil {
8989+ revertTimer.Stop()
9090+ revertTimer = nil
9191+ }
9292+9393+ if wasDebug {
9494+ // Turning debug OFF
9595+ levelVar.Set(originalLevel)
9696+ slog.Info("Log level changed",
9797+ "from", "DEBUG",
9898+ "to", levelToString(originalLevel),
9999+ "trigger", "SIGUSR1")
100100+ } else {
101101+ // Turning debug ON - start auto-revert timer
102102+ levelVar.Set(slog.LevelDebug)
103103+ revertTimer = time.AfterFunc(debugTimeout, autoRevert)
104104+ slog.Info("Log level changed",
105105+ "from", levelToString(originalLevel),
106106+ "to", "DEBUG",
107107+ "trigger", "SIGUSR1",
108108+ "auto_revert_in", debugTimeout)
109109+ }
110110+}
111111+112112+func autoRevert() {
113113+ revertMu.Lock()
114114+ defer revertMu.Unlock()
115115+116116+ if !debugEnabled.Load() {
117117+ return // Already reverted manually
118118+ }
119119+120120+ debugEnabled.Store(false)
121121+ levelVar.Set(originalLevel)
122122+ revertTimer = nil
123123+124124+ slog.Info("Log level changed",
125125+ "from", "DEBUG",
126126+ "to", levelToString(originalLevel),
127127+ "trigger", "auto-revert")
128128+}
129129+130130+func levelToString(l slog.Level) string {
131131+ switch l {
132132+ case slog.LevelDebug:
133133+ return "DEBUG"
134134+ case slog.LevelInfo:
135135+ return "INFO"
136136+ case slog.LevelWarn:
137137+ return "WARN"
138138+ case slog.LevelError:
139139+ return "ERROR"
140140+ default:
141141+ return l.String()
142142+ }
38143}
3914440145// SetupTestLogger configures logging for tests to reduce noise.