···290290 WHEN h.owner_did = ?1 THEN 'owner'
291291 WHEN c.member_did IS NOT NULL THEN 'crew'
292292 WHEN h.allow_all_crew = 1 THEN 'eligible'
293293- WHEN h.public = 1 THEN 'public'
294293 ELSE 'none'
295294 END as membership,
296295 c.permissions
297296 FROM hold_captain_records h
298297 LEFT JOIN hold_crew_members c ON h.hold_did = c.hold_did AND c.member_did = ?1
299299- WHERE h.public = 1
300300- OR h.allow_all_crew = 1
298298+ WHERE h.allow_all_crew = 1
301299 OR h.owner_did = ?1
302300 OR c.member_did IS NOT NULL
303301 ORDER BY
···142142 <span class="label-text">Storage Hold</span>
143143 </label>
144144 <select id="default-hold" name="hold_did" class="select select-bordered w-full" autocomplete="off">
145145- <option value=""{{ if eq .CurrentHoldDID "" }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option>
145145+ <option value="{{ .AppViewDefaultHoldDID }}"{{ if or (eq .CurrentHoldDID "") (eq .CurrentHoldDID .AppViewDefaultHoldDID) }} selected{{ end }}>AppView Default ({{ .AppViewDefaultHoldDisplay }}{{ if .AppViewDefaultRegion }}, {{ .AppViewDefaultRegion }}{{ end }})</option>
146146147147 {{ if .ShowCurrentHold }}
148148 <option value="{{ .CurrentHoldDID }}" selected>Current ({{ .CurrentHoldDisplay }})</option>
···178178 </optgroup>
179179 {{ end }}
180180181181- {{ if .PublicHolds }}
182182- <optgroup label="Public Holds">
183183- {{ range .PublicHolds }}
184184- <option value="{{ .DID }}" {{ if eq $.CurrentHoldDID .DID }}selected{{ end }}>
185185- {{ .DisplayName }}{{ if .Region }} ({{ .Region }}){{ end }}
186186- </option>
187187- {{ end }}
188188- </optgroup>
189189- {{ end }}
190181 </select>
191182 <p class="text-sm text-base-content/60 mt-1">Your images will be stored on the selected hold</p>
192183 </fieldset>
+15-26
pkg/hold/admin/handlers.go
···9393 return
9494 }
95959696- // Calculate total storage by summing quota for all crew members
9696+ // Calculate total storage with a single bulk query
9797 var totalSize int64
9898 uniqueDigests := 0
9999100100- crew, err := ui.pds.ListCrewMembers(ctx)
100100+ allQuotas, err := ui.pds.GetAllUserQuotas(ctx)
101101 if err != nil {
102102- slog.Warn("Failed to list crew for stats", "error", err)
102102+ slog.Warn("Failed to get all user quotas", "error", err)
103103 } else {
104104- // Get usage for each crew member
105105- for _, member := range crew {
106106- quotaStats, err := ui.pds.GetQuotaForUser(ctx, member.Record.Member)
107107- if err != nil {
108108- continue
109109- }
110110- totalSize += quotaStats.TotalSize
111111- uniqueDigests += quotaStats.UniqueBlobs
104104+ for _, q := range allQuotas {
105105+ totalSize += q.TotalSize
106106+ uniqueDigests += q.UniqueBlobs
112107 }
113108 }
114109···152147 }
153148 }
154149155155- // Get all crew members and their usage
156156- crew, err := ui.pds.ListCrewMembers(ctx)
150150+ // Get all user quotas in a single bulk query
151151+ allQuotas, err := ui.pds.GetAllUserQuotas(ctx)
157152 if err != nil {
158158- slog.Error("Failed to list crew members for top users", "error", err)
153153+ slog.Error("Failed to get all user quotas", "error", err)
159154 http.Error(w, "Failed to load top users", http.StatusInternalServerError)
160155 return
161156 }
162157163158 var users []UserUsage
164164- for _, member := range crew {
165165- quotaStats, err := ui.pds.GetQuotaForUser(ctx, member.Record.Member)
166166- if err != nil {
167167- slog.Warn("Failed to get quota for user", "did", member.Record.Member, "error", err)
168168- continue
169169- }
170170-159159+ for did, q := range allQuotas {
171160 users = append(users, UserUsage{
172172- DID: member.Record.Member,
173173- Handle: resolveHandle(ctx, member.Record.Member),
174174- Usage: quotaStats.TotalSize,
175175- UsageHuman: formatHumanBytes(quotaStats.TotalSize),
176176- BlobCount: quotaStats.UniqueBlobs,
161161+ DID: did,
162162+ Handle: resolveHandle(ctx, did),
163163+ Usage: q.TotalSize,
164164+ UsageHuman: formatHumanBytes(q.TotalSize),
165165+ BlobCount: q.UniqueBlobs,
177166 })
178167 }
179168
+13-10
pkg/hold/admin/handlers_crew.go
···99 "time"
10101111 "atcr.io/pkg/atproto"
1212+ "atcr.io/pkg/hold/pds"
1213 "github.com/go-chi/chi/v5"
1314)
1415···5657 return nil, err
5758 }
58595959- userUsage := make(map[string]int64)
6060- for _, member := range crew {
6161- quotaStats, err := ui.pds.GetQuotaForUser(ctx, member.Record.Member)
6262- if err != nil {
6363- slog.Warn("Failed to get quota for crew member", "did", member.Record.Member, "error", err)
6464- continue
6565- }
6666- userUsage[member.Record.Member] = quotaStats.TotalSize
6060+ // Single bulk query for all user quotas
6161+ allQuotas, err := ui.pds.GetAllUserQuotas(ctx)
6262+ if err != nil {
6363+ slog.Warn("Failed to get all user quotas for crew views", "error", err)
6464+ allQuotas = make(map[string]*pds.QuotaStats)
6765 }
68666967 defaultTier := "default"
···8886 AddedAt: parseTime(member.Record.AddedAt),
8987 }
90888989+ usage := int64(0)
9090+ if q, ok := allQuotas[member.Record.Member]; ok {
9191+ usage = q.TotalSize
9292+ }
9393+9194 if ui.quotaMgr != nil && ui.quotaMgr.IsEnabled() {
9295 if limit := ui.quotaMgr.GetTierLimit(tier); limit != nil {
9396 view.TierLimit = formatHumanBytes(*limit)
9497 if *limit > 0 {
9595- view.UsagePercent = int(float64(userUsage[view.DID]) / float64(*limit) * 100)
9898+ view.UsagePercent = int(float64(usage) / float64(*limit) * 100)
9699 }
97100 } else {
98101 view.TierLimit = "Unlimited"
···101104 view.TierLimit = "Unlimited"
102105 }
103106104104- view.CurrentUsage = userUsage[view.DID]
107107+ view.CurrentUsage = usage
105108 view.UsageHuman = formatHumanBytes(view.CurrentUsage)
106109107110 crewViews = append(crewViews, view)
+2-2
pkg/hold/pds/delete_test.go
···215215216216 // Index the record
217217 if pds.recordsIndex != nil {
218218- err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID)
218218+ err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID, "", 0)
219219 if err != nil {
220220 t.Fatalf("Failed to index test post: %v", err)
221221 }
···304304305305 // Index the record
306306 if pds.recordsIndex != nil {
307307- err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID)
307307+ err = pds.recordsIndex.IndexRecord(atproto.BskyPostCollection, rkey, "testcid", aliceDID, "", 0)
308308 if err != nil {
309309 t.Fatalf("Failed to index test post: %v", err)
310310 }
+27-87
pkg/hold/pds/layer.go
···8989 Tier string `json:"tier,omitempty"` // quota tier (e.g., 'deckhand', 'bosun', 'quartermaster')
9090}
91919292-// GetQuotaForUser calculates storage quota for a specific user
9393-// It iterates through all layer records, filters by userDid, deduplicates by digest,
9494-// and sums the sizes of unique blobs.
9292+// GetQuotaForUser calculates storage quota for a specific user.
9393+// Uses SQL aggregation over the denormalized digest/size columns in the records index.
9594func (p *HoldPDS) GetQuotaForUser(ctx context.Context, userDID string) (*QuotaStats, error) {
9695 if p.recordsIndex == nil {
9796 return nil, fmt.Errorf("records index not available")
9897 }
9998100100- // Get session for reading record data
101101- session, err := p.carstore.ReadOnlySession(p.uid)
9999+ uniqueBlobs, totalSize, err := p.recordsIndex.QuotaForDID(atproto.LayerCollection, userDID)
102100 if err != nil {
103103- return nil, fmt.Errorf("failed to create session: %w", err)
101101+ return nil, fmt.Errorf("failed to query quota: %w", err)
104102 }
105103106106- head, err := p.carstore.GetUserRepoHead(ctx, p.uid)
107107- if err != nil {
108108- return nil, fmt.Errorf("failed to get repo head: %w", err)
109109- }
104104+ return &QuotaStats{
105105+ UserDID: userDID,
106106+ UniqueBlobs: uniqueBlobs,
107107+ TotalSize: totalSize,
108108+ }, nil
109109+}
110110111111- if !head.Defined() {
112112- // Empty repo - return zero stats
113113- return &QuotaStats{UserDID: userDID}, nil
111111+// GetAllUserQuotas returns quota stats for all users in a single SQL query.
112112+// Used by admin endpoints to avoid N+1 per-user quota lookups.
113113+func (p *HoldPDS) GetAllUserQuotas(ctx context.Context) (map[string]*QuotaStats, error) {
114114+ if p.recordsIndex == nil {
115115+ return nil, fmt.Errorf("records index not available")
114116 }
115117116116- repoHandle, err := repo.OpenRepo(ctx, session, head)
118118+ quotas, err := p.recordsIndex.QuotasByDID(atproto.LayerCollection)
117119 if err != nil {
118118- return nil, fmt.Errorf("failed to open repo: %w", err)
120120+ return nil, fmt.Errorf("failed to query all quotas: %w", err)
119121 }
120122121121- // Track unique digests and their sizes
122122- digestSizes := make(map[string]int64)
123123-124124- // Iterate all layer records via the index
125125- cursor := ""
126126- batchSize := 1000 // Process in batches
127127-128128- for {
129129- records, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, true)
130130- if err != nil {
131131- return nil, fmt.Errorf("failed to list layer records: %w", err)
132132- }
133133-134134- for _, rec := range records {
135135- // Construct record path and get the record data
136136- recordPath := rec.Collection + "/" + rec.Rkey
137137-138138- _, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath)
139139- if err != nil {
140140- // Skip records we can't read
141141- continue
142142- }
143143-144144- // Decode the layer record
145145- recordValue, err := lexutil.CborDecodeValue(*recBytes)
146146- if err != nil {
147147- continue
148148- }
149149-150150- layerRecord, ok := recordValue.(*atproto.LayerRecord)
151151- if !ok {
152152- continue
153153- }
154154-155155- // Filter by userDID
156156- if layerRecord.UserDID != userDID {
157157- continue
158158- }
159159-160160- // Deduplicate by digest - keep the size (could be different pushes of same blob)
161161- // Store the size - we only count each unique digest once
162162- if _, exists := digestSizes[layerRecord.Digest]; !exists {
163163- digestSizes[layerRecord.Digest] = layerRecord.Size
164164- }
123123+ result := make(map[string]*QuotaStats, len(quotas))
124124+ for did, q := range quotas {
125125+ result[did] = &QuotaStats{
126126+ UserDID: did,
127127+ UniqueBlobs: q.UniqueBlobs,
128128+ TotalSize: q.TotalSize,
165129 }
166166-167167- if nextCursor == "" {
168168- break
169169- }
170170- cursor = nextCursor
171130 }
172172-173173- // Calculate totals
174174- var totalSize int64
175175- for _, size := range digestSizes {
176176- totalSize += size
177177- }
178178-179179- return &QuotaStats{
180180- UserDID: userDID,
181181- UniqueBlobs: len(digestSizes),
182182- TotalSize: totalSize,
183183- }, nil
131131+ return result, nil
184132}
185133186134// GetQuotaForUserWithTier calculates quota with tier-aware limits
···262210263211 var records []*atproto.LayerRecord
264212265265- // Iterate all layer records via the index
213213+ // Iterate layer records for this user via the index (filtered by DID in SQL)
266214 cursor := ""
267267- batchSize := 1000 // Process in batches
215215+ batchSize := 1000
268216269217 for {
270270- indexRecords, nextCursor, err := p.recordsIndex.ListRecords(atproto.LayerCollection, batchSize, cursor, true)
218218+ indexRecords, nextCursor, err := p.recordsIndex.ListRecordsByDID(atproto.LayerCollection, userDID, batchSize, cursor)
271219 if err != nil {
272220 return nil, fmt.Errorf("failed to list layer records: %w", err)
273221 }
274222275223 for _, rec := range indexRecords {
276276- // Construct record path and get the record data
277224 recordPath := rec.Collection + "/" + rec.Rkey
278225279226 _, recBytes, err := repoHandle.GetRecordBytes(ctx, recordPath)
280227 if err != nil {
281281- // Skip records we can't read
282228 continue
283229 }
284230285285- // Decode the layer record
286231 recordValue, err := lexutil.CborDecodeValue(*recBytes)
287232 if err != nil {
288233 continue
···290235291236 layerRecord, ok := recordValue.(*atproto.LayerRecord)
292237 if !ok {
293293- continue
294294- }
295295-296296- // Filter by userDID
297297- if layerRecord.UserDID != userDID {
298238 continue
299239 }
300240
+94-13
pkg/hold/pds/records.go
···3636 rkey TEXT NOT NULL,
3737 cid TEXT NOT NULL,
3838 did TEXT,
3939+ digest TEXT,
4040+ size INTEGER,
3941 PRIMARY KEY (collection, rkey)
4042);
4143CREATE INDEX IF NOT EXISTS idx_records_collection_rkey ON records(collection, rkey);
···5456 return nil, fmt.Errorf("failed to open records database: %w", err)
5557 }
56585757- // Check if table exists and has the did column
5959+ // Check if table exists and has required columns
5860 needsRebuild := false
5961 var tableName string
6062 err = db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='records'`).Scan(&tableName)
6163 if err == nil {
6262- // Table exists, check for did column
6364 var colCount int
6565+ // Check for did column
6466 err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('records') WHERE name='did'`).Scan(&colCount)
6567 if err != nil || colCount == 0 {
6668 needsRebuild = true
6769 slog.Info("Records index schema outdated, rebuilding with did column")
6870 }
7171+ // Check for digest column (added for SQL-based quota queries)
7272+ if !needsRebuild {
7373+ err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('records') WHERE name='digest'`).Scan(&colCount)
7474+ if err != nil || colCount == 0 {
7575+ needsRebuild = true
7676+ slog.Info("Records index schema outdated, rebuilding with digest/size columns")
7777+ }
7878+ }
6979 }
70807181 if needsRebuild {
···91101// NewRecordsIndexWithDB creates a records index using an existing *sql.DB connection.
92102// The caller is responsible for the DB lifecycle.
93103func NewRecordsIndexWithDB(db *sql.DB) (*RecordsIndex, error) {
9494- // Check if table exists and has the did column
104104+ // Check if table exists and has required columns
95105 needsRebuild := false
96106 var tableName string
97107 err := db.QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='records'`).Scan(&tableName)
98108 if err == nil {
99109 var colCount int
110110+ // Check for did column
100111 err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('records') WHERE name='did'`).Scan(&colCount)
101112 if err != nil || colCount == 0 {
102113 needsRebuild = true
103114 slog.Info("Records index schema outdated, rebuilding with did column")
104115 }
116116+ // Check for digest column (added for SQL-based quota queries)
117117+ if !needsRebuild {
118118+ err = db.QueryRow(`SELECT COUNT(*) FROM pragma_table_info('records') WHERE name='digest'`).Scan(&colCount)
119119+ if err != nil || colCount == 0 {
120120+ needsRebuild = true
121121+ slog.Info("Records index schema outdated, rebuilding with digest/size columns")
122122+ }
123123+ }
105124 }
106125107126 if needsRebuild {
···129148}
130149131150// IndexRecord adds or updates a record in the index
132132-// did parameter is optional - pass empty string if not applicable
133133-func (ri *RecordsIndex) IndexRecord(collection, rkey, cidStr, did string) error {
151151+// did, digest, size parameters are optional - pass empty/zero if not applicable
152152+func (ri *RecordsIndex) IndexRecord(collection, rkey, cidStr, did, digest string, size int64) error {
134153 _, err := ri.db.Exec(`
135135- INSERT OR REPLACE INTO records (collection, rkey, cid, did)
136136- VALUES (?, ?, ?, ?)
137137- `, collection, rkey, cidStr, sql.NullString{String: did, Valid: did != ""})
154154+ INSERT OR REPLACE INTO records (collection, rkey, cid, did, digest, size)
155155+ VALUES (?, ?, ?, ?, ?, ?)
156156+ `, collection, rkey, cidStr,
157157+ sql.NullString{String: did, Valid: did != ""},
158158+ sql.NullString{String: digest, Valid: digest != ""},
159159+ sql.NullInt64{Int64: size, Valid: size > 0})
138160 return err
139161}
140162···328350 defer tx.Rollback()
329351330352 stmt, err := tx.Prepare(`
331331- INSERT OR REPLACE INTO records (collection, rkey, cid, did)
332332- VALUES (?, ?, ?, ?)
353353+ INSERT OR REPLACE INTO records (collection, rkey, cid, did, digest, size)
354354+ VALUES (?, ?, ?, ?, ?, ?)
333355 `)
334356 if err != nil {
335357 return fmt.Errorf("failed to prepare statement: %w", err)
···345367 }
346368 collection, rkey := parts[0], parts[1]
347369348348- // Extract DID from record content based on collection type
349349- var did string
370370+ // Extract fields from record content based on collection type
371371+ var did, digest string
372372+ var size int64
350373 _, recBytes, err := repoHandle.GetRecordBytes(ctx, key)
351374 if err == nil && recBytes != nil {
352375 did = extractDIDFromRecord(collection, *recBytes)
376376+ digest, size = extractLayerFieldsFromRecord(collection, *recBytes)
353377 }
354378355355- _, err = stmt.Exec(collection, rkey, c.String(), sql.NullString{String: did, Valid: did != ""})
379379+ _, err = stmt.Exec(collection, rkey, c.String(),
380380+ sql.NullString{String: did, Valid: did != ""},
381381+ sql.NullString{String: digest, Valid: digest != ""},
382382+ sql.NullInt64{Int64: size, Valid: size > 0})
356383 if err != nil {
357384 return fmt.Errorf("failed to index record %s: %w", key, err)
358385 }
···390417 }
391418 }
392419 return out
420420+}
421421+422422+// QuotaForDID returns unique blob count and total size for a single DID in a collection.
423423+// Uses SQL aggregation over the denormalized digest/size columns.
424424+func (ri *RecordsIndex) QuotaForDID(collection, did string) (uniqueBlobs int, totalSize int64, err error) {
425425+ err = ri.db.QueryRow(`
426426+ SELECT COUNT(*), COALESCE(SUM(size), 0)
427427+ FROM (SELECT DISTINCT digest, size FROM records WHERE collection = ? AND did = ? AND digest IS NOT NULL)
428428+ `, collection, did).Scan(&uniqueBlobs, &totalSize)
429429+ return
430430+}
431431+432432+// QuotasByDID returns unique blob count and total size grouped by DID.
433433+// Single query replaces N individual quota lookups.
434434+func (ri *RecordsIndex) QuotasByDID(collection string) (map[string]QuotaResult, error) {
435435+ rows, err := ri.db.Query(`
436436+ SELECT did, COUNT(*), COALESCE(SUM(size), 0)
437437+ FROM (SELECT DISTINCT did, digest, size FROM records WHERE collection = ? AND did IS NOT NULL AND digest IS NOT NULL)
438438+ GROUP BY did
439439+ `, collection)
440440+ if err != nil {
441441+ return nil, fmt.Errorf("failed to query quotas by DID: %w", err)
442442+ }
443443+ defer rows.Close()
444444+445445+ result := make(map[string]QuotaResult)
446446+ for rows.Next() {
447447+ var did string
448448+ var qr QuotaResult
449449+ if err := rows.Scan(&did, &qr.UniqueBlobs, &qr.TotalSize); err != nil {
450450+ return nil, fmt.Errorf("failed to scan quota row: %w", err)
451451+ }
452452+ result[did] = qr
453453+ }
454454+ return result, rows.Err()
455455+}
456456+457457+// QuotaResult holds aggregated quota data from SQL queries
458458+type QuotaResult struct {
459459+ UniqueBlobs int
460460+ TotalSize int64
461461+}
462462+463463+// extractLayerFieldsFromRecord extracts digest and size from layer records.
464464+// Returns empty/zero for non-layer records.
465465+func extractLayerFieldsFromRecord(collection string, recBytes []byte) (string, int64) {
466466+ if collection != atproto.LayerCollection {
467467+ return "", 0
468468+ }
469469+ var rec atproto.LayerRecord
470470+ if err := rec.UnmarshalCBOR(bytes.NewReader(recBytes)); err != nil {
471471+ return "", 0
472472+ }
473473+ return rec.Digest, rec.Size
393474}
394475395476// extractDIDFromRecord extracts the associated DID from a record based on its collection type
+154-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", "", "", 0)
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", "", "", 0)
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", "", "", 0)
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", "", "", 0)
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, "", "", 0); 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, "", "", 0); 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, "", "", 0); 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, "", "", 0); 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, "", "", 0); 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, "", "", 0); 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", "", "", 0)
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", "", "", 0)
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", "", "", 0)
531531+ ri.IndexRecord("io.atcr.hold.crew", "b", "cid2", "", "", 0)
532532+ ri.IndexRecord("io.atcr.hold.captain", "self", "cid3", "", "", 0)
533533+ ri.IndexRecord("io.atcr.manifest", "abc123", "cid4", "", "", 0)
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", "", "", 0)
585585+ ri.IndexRecord("io.atcr.hold.captain", "abc", "cid-captain", "", "", 0)
586586+ ri.IndexRecord("io.atcr.manifest", "abc", "cid-manifest", "", "", 0)
587587588588 // Listing should only return records from requested collection
589589 records, _, err := ri.ListRecords("io.atcr.hold.crew", 10, "", false)
···605605 t.Errorf("Expected captain count 1 after deleting crew, got %d", count)
606606 }
607607}
608608+609609+// TestRecordsIndex_QuotaForDID tests single-user quota aggregation
610610+func TestRecordsIndex_QuotaForDID(t *testing.T) {
611611+ tmpDir := t.TempDir()
612612+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
613613+ if err != nil {
614614+ t.Fatalf("NewRecordsIndex() error = %v", err)
615615+ }
616616+ defer ri.Close()
617617+618618+ // Add layer records for two users, with some duplicate digests
619619+ ri.IndexRecord("io.atcr.hold.layer", "r1", "cid1", "did:plc:alice", "sha256:aaa", 100)
620620+ ri.IndexRecord("io.atcr.hold.layer", "r2", "cid2", "did:plc:alice", "sha256:bbb", 200)
621621+ ri.IndexRecord("io.atcr.hold.layer", "r3", "cid3", "did:plc:alice", "sha256:aaa", 100) // duplicate digest
622622+ ri.IndexRecord("io.atcr.hold.layer", "r4", "cid4", "did:plc:bob", "sha256:ccc", 300)
623623+624624+ // Alice should have 2 unique blobs, total 300 bytes
625625+ uniqueBlobs, totalSize, err := ri.QuotaForDID("io.atcr.hold.layer", "did:plc:alice")
626626+ if err != nil {
627627+ t.Fatalf("QuotaForDID() error = %v", err)
628628+ }
629629+ if uniqueBlobs != 2 {
630630+ t.Errorf("Expected 2 unique blobs for alice, got %d", uniqueBlobs)
631631+ }
632632+ if totalSize != 300 {
633633+ t.Errorf("Expected total size 300 for alice, got %d", totalSize)
634634+ }
635635+636636+ // Bob should have 1 unique blob, total 300 bytes
637637+ uniqueBlobs, totalSize, err = ri.QuotaForDID("io.atcr.hold.layer", "did:plc:bob")
638638+ if err != nil {
639639+ t.Fatalf("QuotaForDID() error = %v", err)
640640+ }
641641+ if uniqueBlobs != 1 {
642642+ t.Errorf("Expected 1 unique blob for bob, got %d", uniqueBlobs)
643643+ }
644644+ if totalSize != 300 {
645645+ t.Errorf("Expected total size 300 for bob, got %d", totalSize)
646646+ }
647647+648648+ // Unknown user should have 0
649649+ uniqueBlobs, totalSize, err = ri.QuotaForDID("io.atcr.hold.layer", "did:plc:unknown")
650650+ if err != nil {
651651+ t.Fatalf("QuotaForDID() error = %v", err)
652652+ }
653653+ if uniqueBlobs != 0 || totalSize != 0 {
654654+ t.Errorf("Expected 0/0 for unknown user, got %d/%d", uniqueBlobs, totalSize)
655655+ }
656656+}
657657+658658+// TestRecordsIndex_QuotasByDID tests bulk quota aggregation
659659+func TestRecordsIndex_QuotasByDID(t *testing.T) {
660660+ tmpDir := t.TempDir()
661661+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
662662+ if err != nil {
663663+ t.Fatalf("NewRecordsIndex() error = %v", err)
664664+ }
665665+ defer ri.Close()
666666+667667+ // Add layer records for multiple users
668668+ ri.IndexRecord("io.atcr.hold.layer", "r1", "cid1", "did:plc:alice", "sha256:aaa", 100)
669669+ ri.IndexRecord("io.atcr.hold.layer", "r2", "cid2", "did:plc:alice", "sha256:bbb", 200)
670670+ ri.IndexRecord("io.atcr.hold.layer", "r3", "cid3", "did:plc:bob", "sha256:ccc", 500)
671671+ // Non-layer record should be excluded
672672+ ri.IndexRecord("io.atcr.hold.crew", "r4", "cid4", "did:plc:alice", "", 0)
673673+674674+ quotas, err := ri.QuotasByDID("io.atcr.hold.layer")
675675+ if err != nil {
676676+ t.Fatalf("QuotasByDID() error = %v", err)
677677+ }
678678+679679+ if len(quotas) != 2 {
680680+ t.Fatalf("Expected 2 users in quotas, got %d", len(quotas))
681681+ }
682682+683683+ alice := quotas["did:plc:alice"]
684684+ if alice.UniqueBlobs != 2 {
685685+ t.Errorf("Expected 2 unique blobs for alice, got %d", alice.UniqueBlobs)
686686+ }
687687+ if alice.TotalSize != 300 {
688688+ t.Errorf("Expected total size 300 for alice, got %d", alice.TotalSize)
689689+ }
690690+691691+ bob := quotas["did:plc:bob"]
692692+ if bob.UniqueBlobs != 1 {
693693+ t.Errorf("Expected 1 unique blob for bob, got %d", bob.UniqueBlobs)
694694+ }
695695+ if bob.TotalSize != 500 {
696696+ t.Errorf("Expected total size 500 for bob, got %d", bob.TotalSize)
697697+ }
698698+}
699699+700700+// TestRecordsIndex_QuotasByDID_Empty tests bulk quota with no records
701701+func TestRecordsIndex_QuotasByDID_Empty(t *testing.T) {
702702+ tmpDir := t.TempDir()
703703+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
704704+ if err != nil {
705705+ t.Fatalf("NewRecordsIndex() error = %v", err)
706706+ }
707707+ defer ri.Close()
708708+709709+ quotas, err := ri.QuotasByDID("io.atcr.hold.layer")
710710+ if err != nil {
711711+ t.Fatalf("QuotasByDID() error = %v", err)
712712+ }
713713+ if len(quotas) != 0 {
714714+ t.Errorf("Expected empty quotas map, got %d entries", len(quotas))
715715+ }
716716+}
717717+718718+// TestRecordsIndex_QuotaForDID_IgnoresNullDigest tests that records without digest are excluded
719719+func TestRecordsIndex_QuotaForDID_IgnoresNullDigest(t *testing.T) {
720720+ tmpDir := t.TempDir()
721721+ ri, err := NewRecordsIndex(filepath.Join(tmpDir, "records.db"))
722722+ if err != nil {
723723+ t.Fatalf("NewRecordsIndex() error = %v", err)
724724+ }
725725+ defer ri.Close()
726726+727727+ // Record with digest+size
728728+ ri.IndexRecord("io.atcr.hold.layer", "r1", "cid1", "did:plc:alice", "sha256:aaa", 100)
729729+ // Record without digest (e.g. old data before migration)
730730+ ri.IndexRecord("io.atcr.hold.layer", "r2", "cid2", "did:plc:alice", "", 0)
731731+732732+ uniqueBlobs, totalSize, err := ri.QuotaForDID("io.atcr.hold.layer", "did:plc:alice")
733733+ if err != nil {
734734+ t.Fatalf("QuotaForDID() error = %v", err)
735735+ }
736736+ if uniqueBlobs != 1 {
737737+ t.Errorf("Expected 1 unique blob (ignoring null digest), got %d", uniqueBlobs)
738738+ }
739739+ if totalSize != 100 {
740740+ t.Errorf("Expected total size 100, got %d", totalSize)
741741+ }
742742+}
+14-2
pkg/hold/pds/server.go
···411411 if op.RecCid != nil {
412412 cidStr = op.RecCid.String()
413413 }
414414- // Extract DID from record based on collection type
414414+ // Extract fields from record based on collection type
415415 did := extractDIDFromOp(op)
416416- if err := p.recordsIndex.IndexRecord(op.Collection, op.Rkey, cidStr, did); err != nil {
416416+ digest, size := extractLayerFieldsFromOp(op)
417417+ if err := p.recordsIndex.IndexRecord(op.Collection, op.Rkey, cidStr, did, digest, size); err != nil {
417418 slog.Warn("Failed to index record", "collection", op.Collection, "rkey", op.Rkey, "error", err)
418419 }
419420 case EvtKindDeleteRecord:
···452453 }
453454 }
454455 return ""
456456+}
457457+458458+// extractLayerFieldsFromOp extracts digest and size from a layer record operation
459459+func extractLayerFieldsFromOp(op RepoOp) (string, int64) {
460460+ if op.Record == nil || op.Collection != atproto.LayerCollection {
461461+ return "", 0
462462+ }
463463+ if rec, ok := op.Record.(*atproto.LayerRecord); ok {
464464+ return rec.Digest, rec.Size
465465+ }
466466+ return "", 0
455467}
456468457469// BackfillRecordsIndex populates the records index from existing MST data