···2929 return annotations, rows.Err()
3030}
31313232+// GetRepositoryAnnotationsByDID retrieves all annotations for every
3333+// repository owned by a DID, grouped as map[repository]map[key]value.
3434+// Used by bulk-fetch paths to avoid issuing one query per repository.
3535+func GetRepositoryAnnotationsByDID(db DBTX, did string) (map[string]map[string]string, error) {
3636+ rows, err := db.Query(`
3737+ SELECT repository, key, value
3838+ FROM repository_annotations
3939+ WHERE did = ?
4040+ `, did)
4141+ if err != nil {
4242+ return nil, err
4343+ }
4444+ defer rows.Close()
4545+4646+ out := make(map[string]map[string]string)
4747+ for rows.Next() {
4848+ var repo, key, value string
4949+ if err := rows.Scan(&repo, &key, &value); err != nil {
5050+ return nil, err
5151+ }
5252+ m, ok := out[repo]
5353+ if !ok {
5454+ m = make(map[string]string)
5555+ out[repo] = m
5656+ }
5757+ m[key] = value
5858+ }
5959+ return out, rows.Err()
6060+}
6161+3262// UpsertRepositoryAnnotations upserts annotations for a repository.
3363// Stale keys not present in the new map are deleted.
3464// Unchanged values are skipped to avoid unnecessary writes.
+152-82
pkg/appview/db/queries.go
···241241// GetUserRepositories fetches all repositories for a user.
242242// viewerDID scopes results to repositories whose manifests live on holds the
243243// viewer can access (empty viewerDID = anonymous → public + self-service only).
244244+//
245245+// Implementation: one summary query for the accessible repository set, then
246246+// four bulk queries (tags, manifests, annotations, repo_pages) all keyed by
247247+// did. Results are grouped in Go and assembled per repo. Total: 5 queries
248248+// regardless of how many repos the user owns.
244249func GetUserRepositories(db DBTX, did string, viewerDID string) ([]Repository, error) {
245245- // Get repository summary.
246246- // Both tags and manifests are filtered via join onto manifests.hold_endpoint
247247- // so repositories where every row lives on an inaccessible hold drop out.
250250+ // Step 1: summary query. Both tags and manifests are filtered via join
251251+ // onto manifests.hold_endpoint so repositories where every row lives on
252252+ // an inaccessible hold drop out.
248253 rows, err := db.Query(`
249254 SELECT
250255 repository,
···264269 GROUP BY repository
265270 ORDER BY last_push DESC
266271 `, did, viewerDID, viewerDID, did, viewerDID, viewerDID)
267267-268272 if err != nil {
269273 return nil, err
270274 }
271271- defer rows.Close()
272275273273- var repos []Repository
276276+ type repoSummary struct {
277277+ Name string
278278+ TagCount int
279279+ ManifestCount int
280280+ LastPushStr string
281281+ }
282282+ var summaries []repoSummary
274283 for rows.Next() {
275275- var r Repository
276276- var lastPushStr string
277277- if err := rows.Scan(&r.Name, &r.TagCount, &r.ManifestCount, &lastPushStr); err != nil {
284284+ var s repoSummary
285285+ if err := rows.Scan(&s.Name, &s.TagCount, &s.ManifestCount, &s.LastPushStr); err != nil {
286286+ rows.Close()
278287 return nil, err
279288 }
289289+ summaries = append(summaries, s)
290290+ }
291291+ rows.Close()
280292281281- // Parse the timestamp string into time.Time
282282- if lastPushStr != "" {
283283- // Try multiple timestamp formats
284284- formats := []string{
285285- time.RFC3339Nano, // 2006-01-02T15:04:05.999999999Z07:00
286286- "2006-01-02 15:04:05.999999999-07:00", // SQLite with microseconds and timezone
287287- "2006-01-02 15:04:05.999999999", // SQLite with microseconds
288288- time.RFC3339, // 2006-01-02T15:04:05Z07:00
289289- "2006-01-02 15:04:05", // SQLite default
290290- }
293293+ if len(summaries) == 0 {
294294+ return nil, nil
295295+ }
291296292292- for _, format := range formats {
293293- if t, err := time.Parse(format, lastPushStr); err == nil {
294294- r.LastPush = t
295295- break
296296- }
297297- }
298298- }
297297+ // Build the set of accessible repo names for filtering bulk-fetched rows
298298+ // against repos that the viewer can't see (rows for repos owned by `did`
299299+ // but stored on inaccessible holds).
300300+ accessible := make(map[string]bool, len(summaries))
301301+ for _, s := range summaries {
302302+ accessible[s.Name] = true
303303+ }
299304300300- // Get tags for this repo
301301- tagRows, err := db.Query(`
302302- SELECT id, tag, digest, created_at
303303- FROM tags
304304- WHERE did = ? AND repository = ?
305305- ORDER BY created_at DESC
306306- `, did, r.Name)
307307-308308- if err != nil {
309309- return nil, err
310310- }
311311-312312- for tagRows.Next() {
313313- var t Tag
314314- t.DID = did
315315- t.Repository = r.Name
316316- if err := tagRows.Scan(&t.ID, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
317317- tagRows.Close()
318318- return nil, err
319319- }
320320- r.Tags = append(r.Tags, t)
321321- }
322322- tagRows.Close()
323323-324324- // Get manifests for this repo
325325- manifestRows, err := db.Query(`
326326- SELECT id, digest, hold_endpoint, schema_version, media_type,
327327- config_digest, config_size, artifact_type, created_at
328328- FROM manifests
329329- WHERE did = ? AND repository = ?
330330- ORDER BY created_at DESC
331331- `, did, r.Name)
305305+ // Step 2: bulk-fetch tags for all repos owned by did, grouped by repo.
306306+ tagsByRepo, err := bulkTagsByRepo(db, did, accessible)
307307+ if err != nil {
308308+ return nil, err
309309+ }
332310333333- if err != nil {
334334- return nil, err
335335- }
311311+ // Step 3: bulk-fetch manifests, grouped by repo.
312312+ manifestsByRepo, err := bulkManifestsByRepo(db, did, accessible)
313313+ if err != nil {
314314+ return nil, err
315315+ }
336316337337- for manifestRows.Next() {
338338- var m Manifest
339339- m.DID = did
340340- m.Repository = r.Name
341341-342342- if err := manifestRows.Scan(&m.ID, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
343343- &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil {
344344- manifestRows.Close()
345345- return nil, err
346346- }
317317+ // Step 4: bulk-fetch annotations, grouped by repo.
318318+ annotationsByRepo, err := GetRepositoryAnnotationsByDID(db, did)
319319+ if err != nil {
320320+ return nil, err
321321+ }
347322348348- r.Manifests = append(r.Manifests, m)
349349- }
350350- manifestRows.Close()
323323+ // Step 5: bulk-fetch repo pages (existing helper), keyed by repo.
324324+ pages, err := GetRepoPagesByDID(db, did)
325325+ if err != nil {
326326+ return nil, err
327327+ }
328328+ pagesByRepo := make(map[string]*RepoPage, len(pages))
329329+ for i := range pages {
330330+ pagesByRepo[pages[i].Repository] = &pages[i]
331331+ }
351332352352- // Fetch repository-level annotations from annotations table
353353- annotations, err := GetRepositoryAnnotations(db, did, r.Name)
354354- if err != nil {
355355- return nil, err
333333+ // Assemble results in summary order (preserves last_push DESC).
334334+ repos := make([]Repository, 0, len(summaries))
335335+ for _, s := range summaries {
336336+ r := Repository{
337337+ Name: s.Name,
338338+ TagCount: s.TagCount,
339339+ ManifestCount: s.ManifestCount,
340340+ LastPush: parseRepoTimestamp(s.LastPushStr),
341341+ Tags: tagsByRepo[s.Name],
342342+ Manifests: manifestsByRepo[s.Name],
356343 }
357344345345+ annotations := annotationsByRepo[s.Name]
358346 r.Title = annotations["org.opencontainers.image.title"]
359347 r.Description = annotations["org.opencontainers.image.description"]
360348 r.SourceURL = annotations["org.opencontainers.image.source"]
···363351 r.IconURL = annotations["io.atcr.icon"]
364352 r.ReadmeURL = annotations["io.atcr.readme"]
365353366366- // Check for repo page avatar (overrides annotation icon)
367367- repoPage, err := GetRepoPage(db, did, r.Name)
368368- if err == nil && repoPage != nil && repoPage.AvatarCID != "" {
369369- r.IconURL = BlobCDNURL(did, repoPage.AvatarCID)
354354+ // Repo page avatar overrides annotation icon when present.
355355+ if page, ok := pagesByRepo[s.Name]; ok && page.AvatarCID != "" {
356356+ r.IconURL = BlobCDNURL(did, page.AvatarCID)
370357 }
371358372359 repos = append(repos, r)
373360 }
374361375362 return repos, nil
363363+}
364364+365365+// bulkTagsByRepo fetches every tag owned by did and groups by repository,
366366+// dropping repos not in the accessible set. Result preserves created_at DESC
367367+// ordering within each repo.
368368+func bulkTagsByRepo(db DBTX, did string, accessible map[string]bool) (map[string][]Tag, error) {
369369+ rows, err := db.Query(`
370370+ SELECT id, repository, tag, digest, created_at
371371+ FROM tags
372372+ WHERE did = ?
373373+ ORDER BY repository, created_at DESC
374374+ `, did)
375375+ if err != nil {
376376+ return nil, err
377377+ }
378378+ defer rows.Close()
379379+380380+ out := make(map[string][]Tag)
381381+ for rows.Next() {
382382+ var t Tag
383383+ t.DID = did
384384+ if err := rows.Scan(&t.ID, &t.Repository, &t.Tag, &t.Digest, &t.CreatedAt); err != nil {
385385+ return nil, err
386386+ }
387387+ if !accessible[t.Repository] {
388388+ continue
389389+ }
390390+ out[t.Repository] = append(out[t.Repository], t)
391391+ }
392392+ return out, rows.Err()
393393+}
394394+395395+// bulkManifestsByRepo fetches every manifest owned by did and groups by
396396+// repository, dropping repos not in the accessible set. Result preserves
397397+// created_at DESC ordering within each repo.
398398+func bulkManifestsByRepo(db DBTX, did string, accessible map[string]bool) (map[string][]Manifest, error) {
399399+ rows, err := db.Query(`
400400+ SELECT id, repository, digest, hold_endpoint, schema_version, media_type,
401401+ config_digest, config_size, artifact_type, created_at
402402+ FROM manifests
403403+ WHERE did = ?
404404+ ORDER BY repository, created_at DESC
405405+ `, did)
406406+ if err != nil {
407407+ return nil, err
408408+ }
409409+ defer rows.Close()
410410+411411+ out := make(map[string][]Manifest)
412412+ for rows.Next() {
413413+ var m Manifest
414414+ m.DID = did
415415+ if err := rows.Scan(&m.ID, &m.Repository, &m.Digest, &m.HoldEndpoint, &m.SchemaVersion,
416416+ &m.MediaType, &m.ConfigDigest, &m.ConfigSize, &m.ArtifactType, &m.CreatedAt); err != nil {
417417+ return nil, err
418418+ }
419419+ if !accessible[m.Repository] {
420420+ continue
421421+ }
422422+ out[m.Repository] = append(out[m.Repository], m)
423423+ }
424424+ return out, rows.Err()
425425+}
426426+427427+// parseRepoTimestamp tolerates the several timestamp formats SQLite/libsql
428428+// can return for MAX(created_at) depending on driver and schema history.
429429+func parseRepoTimestamp(s string) time.Time {
430430+ if s == "" {
431431+ return time.Time{}
432432+ }
433433+ formats := []string{
434434+ time.RFC3339Nano, // 2006-01-02T15:04:05.999999999Z07:00
435435+ "2006-01-02 15:04:05.999999999-07:00", // SQLite with microseconds and timezone
436436+ "2006-01-02 15:04:05.999999999", // SQLite with microseconds
437437+ time.RFC3339, // 2006-01-02T15:04:05Z07:00
438438+ "2006-01-02 15:04:05", // SQLite default
439439+ }
440440+ for _, format := range formats {
441441+ if t, err := time.Parse(format, s); err == nil {
442442+ return t
443443+ }
444444+ }
445445+ return time.Time{}
376446}
377447378448// GetRepositoryMetadata retrieves metadata for a repository from annotations table
+160
pkg/appview/db/queries_test.go
···16071607 t.Errorf("crew viewer: expected both repos, got %d: %v", len(repos), repos)
16081608 }
16091609}
16101610+16111611+// TestGetUserRepositories_BulkGrouping verifies that the bulk-fetch
16121612+// implementation correctly groups tags, manifests, annotations, and repo-page
16131613+// avatars per repository — and that ordering (last_push DESC for repos,
16141614+// created_at DESC for tags/manifests within a repo) is preserved.
16151615+//
16161616+// Regression guard for the previous N+1 implementation, which issued one
16171617+// query per repo and per relation.
16181618+func TestGetUserRepositories_BulkGrouping(t *testing.T) {
16191619+ db, err := InitDB("file:TestGetUserRepositories_BulkGrouping?mode=memory&cache=shared", LibsqlConfig{})
16201620+ if err != nil {
16211621+ t.Fatalf("init db: %v", err)
16221622+ }
16231623+ defer db.Close()
16241624+16251625+ user := &User{DID: "did:plc:owner", Handle: "owner.test", PDSEndpoint: "https://pds.example", LastSeen: time.Now()}
16261626+ if err := UpsertUser(db, user); err != nil {
16271627+ t.Fatalf("upsert user: %v", err)
16281628+ }
16291629+ if err := UpsertCaptainRecord(db, &HoldCaptainRecord{
16301630+ HoldDID: "did:web:hold.example", OwnerDID: "did:plc:holdowner", Public: true,
16311631+ }); err != nil {
16321632+ t.Fatalf("seed captain: %v", err)
16331633+ }
16341634+16351635+ now := time.Now().UTC().Truncate(time.Second)
16361636+ mediaType := "application/vnd.oci.image.manifest.v1+json"
16371637+16381638+ // repoA: two manifests (oldest then newer) and two tags. last_push = now+10s.
16391639+ manifestA1, err := InsertManifest(db, &Manifest{
16401640+ DID: user.DID, Repository: "repoA", Digest: "sha256:a1",
16411641+ HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
16421642+ CreatedAt: now,
16431643+ })
16441644+ if err != nil {
16451645+ t.Fatalf("insert manifest a1: %v", err)
16461646+ }
16471647+ manifestA2, err := InsertManifest(db, &Manifest{
16481648+ DID: user.DID, Repository: "repoA", Digest: "sha256:a2",
16491649+ HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
16501650+ CreatedAt: now.Add(5 * time.Second),
16511651+ })
16521652+ if err != nil {
16531653+ t.Fatalf("insert manifest a2: %v", err)
16541654+ }
16551655+ if err := UpsertTag(db, &Tag{DID: user.DID, Repository: "repoA", Tag: "v1", Digest: "sha256:a1", CreatedAt: now.Add(8 * time.Second)}); err != nil {
16561656+ t.Fatalf("upsert tag v1: %v", err)
16571657+ }
16581658+ if err := UpsertTag(db, &Tag{DID: user.DID, Repository: "repoA", Tag: "v2", Digest: "sha256:a2", CreatedAt: now.Add(10 * time.Second)}); err != nil {
16591659+ t.Fatalf("upsert tag v2: %v", err)
16601660+ }
16611661+16621662+ // repoB: one manifest, one tag. last_push = now+1s (older than repoA → repoA sorts first).
16631663+ if _, err := InsertManifest(db, &Manifest{
16641664+ DID: user.DID, Repository: "repoB", Digest: "sha256:b1",
16651665+ HoldEndpoint: "did:web:hold.example", SchemaVersion: 2, MediaType: mediaType,
16661666+ CreatedAt: now.Add(1 * time.Second),
16671667+ }); err != nil {
16681668+ t.Fatalf("insert manifest b1: %v", err)
16691669+ }
16701670+ if err := UpsertTag(db, &Tag{DID: user.DID, Repository: "repoB", Tag: "latest", Digest: "sha256:b1", CreatedAt: now.Add(1 * time.Second)}); err != nil {
16711671+ t.Fatalf("upsert tag b latest: %v", err)
16721672+ }
16731673+16741674+ // Annotations only on repoA, plus a repo-page avatar on repoB to exercise the icon override.
16751675+ if err := UpsertRepositoryAnnotations(db, user.DID, "repoA", map[string]string{
16761676+ "org.opencontainers.image.title": "Repo A Title",
16771677+ "org.opencontainers.image.description": "alpha",
16781678+ "io.atcr.icon": "https://example.com/a.png",
16791679+ }); err != nil {
16801680+ t.Fatalf("upsert annotations: %v", err)
16811681+ }
16821682+ if err := UpsertRepoPage(db, user.DID, "repoB", "", "bafyrepob", false, now, now); err != nil {
16831683+ t.Fatalf("upsert repo page: %v", err)
16841684+ }
16851685+16861686+ repos, err := GetUserRepositories(db, user.DID, "")
16871687+ if err != nil {
16881688+ t.Fatalf("GetUserRepositories: %v", err)
16891689+ }
16901690+16911691+ // Order: repoA first (newer last_push), then repoB.
16921692+ if len(repos) != 2 {
16931693+ t.Fatalf("expected 2 repos, got %d: %#v", len(repos), repos)
16941694+ }
16951695+ if repos[0].Name != "repoA" || repos[1].Name != "repoB" {
16961696+ t.Fatalf("expected order [repoA, repoB] (last_push DESC), got [%s, %s]", repos[0].Name, repos[1].Name)
16971697+ }
16981698+16991699+ // repoA grouping
17001700+ a := repos[0]
17011701+ if len(a.Tags) != 2 {
17021702+ t.Errorf("repoA: expected 2 tags, got %d", len(a.Tags))
17031703+ }
17041704+ // tags ordered created_at DESC → v2 first
17051705+ if len(a.Tags) >= 2 && (a.Tags[0].Tag != "v2" || a.Tags[1].Tag != "v1") {
17061706+ t.Errorf("repoA tags out of order, want [v2, v1] got [%s, %s]", a.Tags[0].Tag, a.Tags[1].Tag)
17071707+ }
17081708+ if len(a.Manifests) != 2 {
17091709+ t.Errorf("repoA: expected 2 manifests, got %d", len(a.Manifests))
17101710+ }
17111711+ // manifests ordered created_at DESC → a2 first
17121712+ if len(a.Manifests) >= 2 && (a.Manifests[0].ID != manifestA2 || a.Manifests[1].ID != manifestA1) {
17131713+ t.Errorf("repoA manifests out of order, want [a2, a1] got [%d, %d]", a.Manifests[0].ID, a.Manifests[1].ID)
17141714+ }
17151715+ if a.Title != "Repo A Title" || a.Description != "alpha" {
17161716+ t.Errorf("repoA annotations not applied: title=%q desc=%q", a.Title, a.Description)
17171717+ }
17181718+ if a.IconURL != "https://example.com/a.png" {
17191719+ t.Errorf("repoA icon: expected annotation URL, got %q", a.IconURL)
17201720+ }
17211721+17221722+ // repoB grouping + page-avatar override
17231723+ b := repos[1]
17241724+ if len(b.Tags) != 1 || b.Tags[0].Tag != "latest" {
17251725+ t.Errorf("repoB tags: %#v", b.Tags)
17261726+ }
17271727+ if len(b.Manifests) != 1 || b.Manifests[0].Digest != "sha256:b1" {
17281728+ t.Errorf("repoB manifests: %#v", b.Manifests)
17291729+ }
17301730+ if b.IconURL == "" {
17311731+ t.Errorf("repoB icon should be derived from repo-page avatar CID, got empty")
17321732+ }
17331733+17341734+ // Cross-repo isolation: tags/manifests for repoB must not leak into repoA and vice versa.
17351735+ for _, tag := range a.Tags {
17361736+ if tag.Repository != "repoA" {
17371737+ t.Errorf("repoA tag has wrong repository: %#v", tag)
17381738+ }
17391739+ }
17401740+ for _, m := range b.Manifests {
17411741+ if m.Repository != "repoB" {
17421742+ t.Errorf("repoB manifest has wrong repository: %#v", m)
17431743+ }
17441744+ }
17451745+}
17461746+17471747+// TestGetUserRepositories_Empty verifies the bulk-fetch path short-circuits
17481748+// cleanly when the summary query returns no rows (no extra queries issued,
17491749+// nil slice returned).
17501750+func TestGetUserRepositories_Empty(t *testing.T) {
17511751+ db, err := InitDB("file:TestGetUserRepositories_Empty?mode=memory&cache=shared", LibsqlConfig{})
17521752+ if err != nil {
17531753+ t.Fatalf("init db: %v", err)
17541754+ }
17551755+ defer db.Close()
17561756+17571757+ user := &User{DID: "did:plc:nobody", Handle: "nobody.test", PDSEndpoint: "https://pds.example", LastSeen: time.Now()}
17581758+ if err := UpsertUser(db, user); err != nil {
17591759+ t.Fatalf("upsert user: %v", err)
17601760+ }
17611761+17621762+ repos, err := GetUserRepositories(db, user.DID, "")
17631763+ if err != nil {
17641764+ t.Fatalf("GetUserRepositories empty: %v", err)
17651765+ }
17661766+ if repos != nil {
17671767+ t.Errorf("expected nil slice for user with no repos, got %#v", repos)
17681768+ }
17691769+}
+207-98
pkg/labeler/db.go
···1919const LabelVersion int64 = labeling.ATPROTO_LABEL_VERSION
20202121const schema = `
2222+CREATE TABLE IF NOT EXISTS takedowns (
2323+ id INTEGER PRIMARY KEY AUTOINCREMENT,
2424+ input TEXT NOT NULL,
2525+ subject_did TEXT NOT NULL,
2626+ subject_repo TEXT NOT NULL DEFAULT '',
2727+ subject_handle TEXT NOT NULL DEFAULT '',
2828+ reason TEXT NOT NULL DEFAULT '',
2929+ created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
3030+ created_by TEXT NOT NULL DEFAULT '',
3131+ reversed_at TIMESTAMP,
3232+ reversed_by TEXT NOT NULL DEFAULT ''
3333+);
3434+CREATE INDEX IF NOT EXISTS idx_takedowns_active ON takedowns(reversed_at, created_at DESC);
3535+CREATE INDEX IF NOT EXISTS idx_takedowns_subject ON takedowns(subject_did, subject_repo);
2236CREATE TABLE IF NOT EXISTS labels (
2337 id INTEGER PRIMARY KEY AUTOINCREMENT,
2438 src TEXT NOT NULL,
···3145 ver INTEGER NOT NULL DEFAULT 1,
3246 sig BLOB NOT NULL,
3347 subject_did TEXT NOT NULL,
3434- subject_repo TEXT NOT NULL DEFAULT ''
4848+ subject_repo TEXT NOT NULL DEFAULT '',
4949+ takedown_id INTEGER REFERENCES takedowns(id)
3550);
3651CREATE INDEX IF NOT EXISTS idx_labels_subject ON labels(subject_did, subject_repo);
3752CREATE INDEX IF NOT EXISTS idx_labels_cts ON labels(cts DESC);
3853CREATE INDEX IF NOT EXISTS idx_labels_uri ON labels(uri);
5454+CREATE INDEX IF NOT EXISTS idx_labels_takedown ON labels(takedown_id);
3955`
40564157// Label represents an ATProto label record stored locally. Its on-the-wire representation
4258// is produced by ToLabeling() which round-trips through indigo's labeling package so the
4359// signature stays valid byte-for-byte.
6060+//
6161+// TakedownID is a labeler-internal pointer to the takedown event that produced this
6262+// label (positive or negation). It's never serialized into ATProto wire format.
4463type Label struct {
4564 ID int64
4665 Src string
···5473 Sig []byte
5574 SubjectDID string
5675 SubjectRepo string
7676+ TakedownID *int64
7777+}
7878+7979+// Takedown is a single operator-issued takedown action. Each Takedown owns one or more
8080+// Label rows linked by takedown_id. Reversal sets reversed_at / reversed_by in place.
8181+type Takedown struct {
8282+ ID int64
8383+ Input string
8484+ SubjectDID string
8585+ SubjectRepo string
8686+ SubjectHandle string
8787+ Reason string
8888+ CreatedAt time.Time
8989+ CreatedBy string
9090+ ReversedAt *time.Time
9191+ ReversedBy string
9292+ LabelCount int
5793}
58945995// LibsqlSync configures optional embedded-replica sync to a remote libSQL database.
···233269 s := l.Exp.UTC().Format(time.RFC3339)
234270 expStr = &s
235271 }
272272+ var takedownID any
273273+ if l.TakedownID != nil {
274274+ takedownID = *l.TakedownID
275275+ }
236276 result, err := db.Exec(
237237- `INSERT INTO labels (src, uri, cid, val, neg, cts, exp, ver, sig, subject_did, subject_repo)
238238- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
277277+ `INSERT INTO labels (src, uri, cid, val, neg, cts, exp, ver, sig, subject_did, subject_repo, takedown_id)
278278+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
239279 l.Src, l.URI, nullableString(l.CID), l.Val, l.Neg,
240280 l.Cts.UTC().Format(time.RFC3339), expStr, l.Ver, l.Sig,
241241- l.SubjectDID, l.SubjectRepo,
281281+ l.SubjectDID, l.SubjectRepo, takedownID,
242282 )
243283 if err != nil {
244284 return 0, fmt.Errorf("failed to insert label: %w", err)
···261301// GetLabelsSince returns labels with id > cursor, ordered by id ascending.
262302func GetLabelsSince(db *sql.DB, cursor int64, limit int) ([]Label, error) {
263303 rows, err := db.Query(
264264- `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo
304304+ `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo, takedown_id
265305 FROM labels WHERE id > ? ORDER BY id ASC LIMIT ?`,
266306 cursor, limit,
267307 )
···284324 return seq.Int64, nil
285325}
286326287287-// ListActiveTakedowns returns active (non-negated) takedown labels.
288288-func ListActiveTakedowns(db *sql.DB, limit, offset int) ([]Label, int, error) {
327327+// CreateTakedown inserts a takedown event row and returns its id. The id should then
328328+// be stamped onto every label produced by this takedown (positive labels at issue time,
329329+// negation labels at reversal time) so the audit trail stays linked.
330330+func CreateTakedown(db *sql.DB, t *Takedown) (int64, error) {
331331+ if t.CreatedAt.IsZero() {
332332+ t.CreatedAt = time.Now().UTC()
333333+ }
334334+ result, err := db.Exec(
335335+ `INSERT INTO takedowns (input, subject_did, subject_repo, subject_handle, reason, created_at, created_by)
336336+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
337337+ t.Input, t.SubjectDID, t.SubjectRepo, t.SubjectHandle, t.Reason,
338338+ t.CreatedAt.UTC().Format(time.RFC3339), t.CreatedBy,
339339+ )
340340+ if err != nil {
341341+ return 0, fmt.Errorf("failed to insert takedown: %w", err)
342342+ }
343343+ id, err := result.LastInsertId()
344344+ if err != nil {
345345+ return 0, err
346346+ }
347347+ t.ID = id
348348+ return id, nil
349349+}
350350+351351+// GetTakedown loads a single takedown row by id. Returns sql.ErrNoRows when missing.
352352+func GetTakedown(db *sql.DB, id int64) (*Takedown, error) {
353353+ row := db.QueryRow(
354354+ `SELECT t.id, t.input, t.subject_did, t.subject_repo, t.subject_handle, t.reason,
355355+ t.created_at, t.created_by, t.reversed_at, t.reversed_by,
356356+ (SELECT COUNT(*) FROM labels l WHERE l.takedown_id = t.id AND l.neg = 0)
357357+ FROM takedowns t WHERE t.id = ?`,
358358+ id,
359359+ )
360360+ return scanTakedown(row.Scan)
361361+}
362362+363363+// TakedownFilter scopes ListTakedowns to active, reversed, or all rows.
364364+type TakedownFilter int
365365+366366+const (
367367+ TakedownAll TakedownFilter = iota // every takedown row, regardless of reversal state
368368+ TakedownActive // only takedowns whose reversed_at is NULL
369369+ TakedownReversed // only takedowns whose reversed_at is set
370370+)
371371+372372+// ListTakedowns returns takedown events ordered by created_at DESC, scoped by filter.
373373+// The total count reflects the same filter.
374374+func ListTakedowns(db *sql.DB, filter TakedownFilter, limit, offset int) ([]Takedown, int, error) {
375375+ where := ""
376376+ switch filter {
377377+ case TakedownActive:
378378+ where = "WHERE reversed_at IS NULL"
379379+ case TakedownReversed:
380380+ where = "WHERE reversed_at IS NOT NULL"
381381+ }
382382+289383 var total int
290290- err := db.QueryRow(
291291- `SELECT COUNT(*) FROM labels l1
292292- WHERE l1.val = '!takedown' AND l1.neg = 0
293293- AND NOT EXISTS (
294294- SELECT 1 FROM labels l2
295295- WHERE l2.src = l1.src AND l2.uri = l1.uri AND l2.val = l1.val
296296- AND l2.neg = 1 AND l2.id > l1.id
297297- )
298298- AND (l1.exp IS NULL OR l1.exp > CURRENT_TIMESTAMP)`,
299299- ).Scan(&total)
300300- if err != nil {
384384+ if err := db.QueryRow(`SELECT COUNT(*) FROM takedowns ` + where).Scan(&total); err != nil {
301385 return nil, 0, err
302386 }
303387304388 rows, err := db.Query(
305305- `SELECT l1.id, l1.src, l1.uri, COALESCE(l1.cid, ''), l1.val, l1.neg, l1.cts, l1.exp, l1.ver, l1.sig, l1.subject_did, l1.subject_repo
306306- FROM labels l1
307307- WHERE l1.val = '!takedown' AND l1.neg = 0
308308- AND NOT EXISTS (
309309- SELECT 1 FROM labels l2
310310- WHERE l2.src = l1.src AND l2.uri = l1.uri AND l2.val = l1.val
311311- AND l2.neg = 1 AND l2.id > l1.id
312312- )
313313- AND (l1.exp IS NULL OR l1.exp > CURRENT_TIMESTAMP)
314314- ORDER BY l1.cts DESC LIMIT ? OFFSET ?`,
389389+ `SELECT t.id, t.input, t.subject_did, t.subject_repo, t.subject_handle, t.reason,
390390+ t.created_at, t.created_by, t.reversed_at, t.reversed_by,
391391+ (SELECT COUNT(*) FROM labels l WHERE l.takedown_id = t.id AND l.neg = 0)
392392+ FROM takedowns t `+where+`
393393+ ORDER BY t.created_at DESC LIMIT ? OFFSET ?`,
315394 limit, offset,
316395 )
317396 if err != nil {
···319398 }
320399 defer rows.Close()
321400322322- labels, err := scanLabels(rows)
323323- return labels, total, err
401401+ var out []Takedown
402402+ for rows.Next() {
403403+ t, err := scanTakedown(rows.Scan)
404404+ if err != nil {
405405+ return nil, 0, err
406406+ }
407407+ out = append(out, *t)
408408+ }
409409+ return out, total, rows.Err()
324410}
325411326326-// GetLabelsForRepo returns all labels for a specific DID + repository.
327327-func GetLabelsForRepo(db *sql.DB, did, repo string) ([]Label, error) {
328328- rows, err := db.Query(
329329- `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo
330330- FROM labels
331331- WHERE subject_did = ? AND subject_repo = ?
332332- ORDER BY cts DESC`,
333333- did, repo,
412412+// MarkTakedownReversed sets the reversed_at / reversed_by fields on the takedown row.
413413+// Refuses to overwrite an existing reversal.
414414+func MarkTakedownReversed(db *sql.DB, id int64, by string, at time.Time) error {
415415+ if at.IsZero() {
416416+ at = time.Now().UTC()
417417+ }
418418+ res, err := db.Exec(
419419+ `UPDATE takedowns SET reversed_at = ?, reversed_by = ?
420420+ WHERE id = ? AND reversed_at IS NULL`,
421421+ at.UTC().Format(time.RFC3339), by, id,
334422 )
335423 if err != nil {
336336- return nil, err
424424+ return fmt.Errorf("failed to mark takedown reversed: %w", err)
337425 }
338338- defer rows.Close()
339339- return scanLabels(rows)
340340-}
341341-342342-// newNegationLabel constructs an unsigned negation label awaiting Sign().
343343-func newNegationLabel(src, uri, val, did, repo string) *Label {
344344- return &Label{
345345- Src: src,
346346- URI: uri,
347347- Val: val,
348348- Neg: true,
349349- Cts: time.Now().UTC(),
350350- SubjectDID: did,
351351- SubjectRepo: repo,
426426+ n, err := res.RowsAffected()
427427+ if err != nil {
428428+ return err
429429+ }
430430+ if n == 0 {
431431+ return fmt.Errorf("takedown %d not found or already reversed", id)
352432 }
433433+ return nil
353434}
354435355355-// NegateRepoLabels signs+inserts negation labels for all active takedown labels on (DID, repo).
356356-func NegateRepoLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did, repo string) ([]Label, error) {
436436+// GetLabelsByTakedown returns all labels (positive + negations) linked to a takedown.
437437+func GetLabelsByTakedown(db *sql.DB, takedownID int64) ([]Label, error) {
357438 rows, err := db.Query(
358358- `SELECT uri FROM labels
359359- WHERE subject_did = ? AND subject_repo = ? AND val = '!takedown' AND neg = 0`,
360360- did, repo,
439439+ `SELECT id, src, uri, COALESCE(cid, ''), val, neg, cts, exp, ver, sig, subject_did, subject_repo, takedown_id
440440+ FROM labels WHERE takedown_id = ? ORDER BY id ASC`,
441441+ takedownID,
361442 )
362443 if err != nil {
363444 return nil, err
364445 }
365365- var uris []string
366366- for rows.Next() {
367367- var uri string
368368- if err := rows.Scan(&uri); err != nil {
369369- rows.Close()
370370- return nil, err
371371- }
372372- uris = append(uris, uri)
373373- }
374374- rows.Close()
375375- if err := rows.Err(); err != nil {
376376- return nil, err
377377- }
378378-379379- out := make([]Label, 0, len(uris))
380380- for _, uri := range uris {
381381- neg := newNegationLabel(src, uri, "!takedown", did, repo)
382382- if err := neg.Sign(key); err != nil {
383383- return out, err
384384- }
385385- if _, err := CreateLabel(db, neg); err != nil {
386386- return out, err
387387- }
388388- out = append(out, *neg)
389389- }
390390- return out, nil
446446+ defer rows.Close()
447447+ return scanLabels(rows)
391448}
392449393393-// NegateUserLabels signs+inserts negation labels for all active takedown labels on a DID.
394394-func NegateUserLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src, did string) ([]Label, error) {
450450+// NegateTakedownLabels signs+inserts negation labels for every active (non-negated)
451451+// label linked to the given takedown_id. Negations carry the same takedown_id so they
452452+// remain part of the takedown's audit trail.
453453+//
454454+// The NOT EXISTS subquery skips URIs that already have a later neg=1 row (from a prior
455455+// reversal call or from an external negation streamed in via subscribeLabels), so this
456456+// function is idempotent and won't emit duplicate negations.
457457+func NegateTakedownLabels(db *sql.DB, key *atcrypto.PrivateKeyK256, src string, takedownID int64) ([]Label, error) {
395458 rows, err := db.Query(
396396- `SELECT uri, subject_repo FROM labels
397397- WHERE subject_did = ? AND val = '!takedown' AND neg = 0`,
398398- did,
459459+ `SELECT l1.uri, l1.subject_did, l1.subject_repo FROM labels l1
460460+ WHERE l1.takedown_id = ? AND l1.val = '!takedown' AND l1.neg = 0
461461+ AND NOT EXISTS (
462462+ SELECT 1 FROM labels l2
463463+ WHERE l2.src = l1.src AND l2.uri = l1.uri AND l2.val = l1.val
464464+ AND l2.neg = 1 AND l2.id > l1.id
465465+ )`,
466466+ takedownID,
399467 )
400468 if err != nil {
401469 return nil, err
402470 }
403403- type uriRepo struct {
471471+ type entry struct {
404472 uri string
473473+ did string
405474 repo string
406475 }
407407- var entries []uriRepo
476476+ var entries []entry
408477 for rows.Next() {
409409- var e uriRepo
410410- if err := rows.Scan(&e.uri, &e.repo); err != nil {
478478+ var e entry
479479+ if err := rows.Scan(&e.uri, &e.did, &e.repo); err != nil {
411480 rows.Close()
412481 return nil, err
413482 }
···418487 return nil, err
419488 }
420489490490+ id := takedownID
421491 out := make([]Label, 0, len(entries))
422492 for _, e := range entries {
423423- neg := newNegationLabel(src, e.uri, "!takedown", did, e.repo)
493493+ neg := &Label{
494494+ Src: src,
495495+ URI: e.uri,
496496+ Val: "!takedown",
497497+ Neg: true,
498498+ Cts: time.Now().UTC(),
499499+ SubjectDID: e.did,
500500+ SubjectRepo: e.repo,
501501+ TakedownID: &id,
502502+ }
424503 if err := neg.Sign(key); err != nil {
425504 return out, err
426505 }
···432511 return out, nil
433512}
434513514514+func scanTakedown(scan func(...any) error) (*Takedown, error) {
515515+ var (
516516+ t Takedown
517517+ created string
518518+ revAt *string
519519+ )
520520+ if err := scan(
521521+ &t.ID, &t.Input, &t.SubjectDID, &t.SubjectRepo, &t.SubjectHandle, &t.Reason,
522522+ &created, &t.CreatedBy, &revAt, &t.ReversedBy, &t.LabelCount,
523523+ ); err != nil {
524524+ return nil, err
525525+ }
526526+ if ts, err := time.Parse(time.RFC3339, created); err == nil {
527527+ t.CreatedAt = ts
528528+ }
529529+ if revAt != nil {
530530+ if ts, err := time.Parse(time.RFC3339, *revAt); err == nil {
531531+ t.ReversedAt = &ts
532532+ }
533533+ }
534534+ return &t, nil
535535+}
536536+435537func scanLabels(rows *sql.Rows) ([]Label, error) {
436538 var labels []Label
437539 for rows.Next() {
438438- var l Label
439439- var cts string
440440- var exp *string
441441- if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.CID, &l.Val, &l.Neg, &cts, &exp, &l.Ver, &l.Sig, &l.SubjectDID, &l.SubjectRepo); err != nil {
540540+ var (
541541+ l Label
542542+ cts string
543543+ exp *string
544544+ tdID sql.NullInt64
545545+ )
546546+ if err := rows.Scan(&l.ID, &l.Src, &l.URI, &l.CID, &l.Val, &l.Neg, &cts, &exp, &l.Ver, &l.Sig, &l.SubjectDID, &l.SubjectRepo, &tdID); err != nil {
442547 return nil, err
443548 }
444549 if t, err := time.Parse(time.RFC3339, cts); err == nil {
···448553 if t, err := time.Parse(time.RFC3339, *exp); err == nil {
449554 l.Exp = &t
450555 }
556556+ }
557557+ if tdID.Valid {
558558+ id := tdID.Int64
559559+ l.TakedownID = &id
451560 }
452561 labels = append(labels, l)
453562 }