mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-02 08:16:57 +00:00
First half of replacing manifests.id with a node-independent identity. Nothing depends on the column yet: id is still the primary key, and layers and manifest_references still reference it. Getting the column in place and filled first means the eventual swap operates on data that is already complete and already proven unique, instead of doing the fill and three table rebuilds in one step. The value cannot be computed by the migration. It is a truncated sha256, SQLite has no hash builtin, and go-libsql exposes no way to register one. So the fill uses machinery that already exists: the Jetstream backfill re-upserts every manifest across the protocol on startup, and both upsert paths now write manifest_key. That only works because of one extra clause. Both upserts guard their DO UPDATE with a WHERE that skips rows where nothing changed, which on a backfill re-run is nearly every row, so they would have skipped the very manifests that need filling. Adding "OR manifests.manifest_key IS NULL" is what makes an otherwise no-op pass populate the column. Verified by removing it: the backfill then fills zero of three manifests instead of three of three. The index is UNIQUE even though the column is nullable. SQLite treats NULLs as distinct, so unfilled rows coexist while every filled row is checked. That makes production data verify the 16-byte truncation rather than us assuming it: if two manifests ever derived the same key, it fails loudly at insert instead of silently attaching one manifest's layers to another after the swap. AppView logs the unfilled count at startup, since there is no single moment at which this becomes complete and the follow-up migration is only safe at zero. ManifestKey replaces the old fat "did|repo|digest" map key rather than sitting beside it; they were always the same question, answered without asking the database. The jetstream tests hand-maintained their own CREATE TABLE statements, which is the drift problem moved into a test: the copy had already fallen behind (it still had tags.id) and only failed once a query touched the difference. They use db.InitDB now, with foreign keys switched off to preserve the behavior the hand-rolled schema had. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
107 lines
2.9 KiB
Go
107 lines
2.9 KiB
Go
package jetstream
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"testing"
|
|
"time"
|
|
|
|
"atcr.io/pkg/atproto"
|
|
"github.com/bluesky-social/indigo/atproto/identity"
|
|
"github.com/bluesky-social/indigo/atproto/syntax"
|
|
)
|
|
|
|
// countingDirectory wraps fakeDirectory and counts LookupDID calls per DID,
|
|
// so tests can assert verification results are memoized within a batch.
|
|
type countingDirectory struct {
|
|
fakeDirectory
|
|
lookups map[string]int
|
|
}
|
|
|
|
func (d *countingDirectory) LookupDID(ctx context.Context, did syntax.DID) (*identity.Identity, error) {
|
|
d.lookups[did.String()]++
|
|
return d.fakeDirectory.LookupDID(ctx, did)
|
|
}
|
|
|
|
func TestBatchCaptains_VerifiesHoldService(t *testing.T) {
|
|
db := setupTestDB(t)
|
|
defer db.Close()
|
|
|
|
realHold := "did:web:realhold.example.com"
|
|
notAHold := "did:plc:notahold"
|
|
unresolvable := "did:plc:unresolvable"
|
|
|
|
dir := &countingDirectory{
|
|
fakeDirectory: fakeDirectory{byDID: map[string]*identity.Identity{
|
|
realHold: holdIdentity(realHold, "https://realhold.example.com"),
|
|
notAHold: {
|
|
DID: syntax.DID(notAHold),
|
|
Services: map[string]identity.ServiceEndpoint{
|
|
"atproto_pds": {Type: "AtprotoPersonalDataServer", URL: "https://pds.example.com"},
|
|
},
|
|
},
|
|
}},
|
|
lookups: map[string]int{},
|
|
}
|
|
atproto.SetDirectory(dir)
|
|
defer atproto.SetDirectory(nil)
|
|
|
|
worker := &BackfillWorker{db: db}
|
|
|
|
captainValue, _ := json.Marshal(map[string]any{
|
|
"$type": "io.atcr.hold.captain",
|
|
"owner": "did:plc:owner123",
|
|
"public": true,
|
|
"allowAllCrew": true,
|
|
"enableBlueskyPosts": false,
|
|
"deployedAt": time.Now().Format(time.RFC3339),
|
|
})
|
|
captainRecord := func(holdDID string) atproto.Record {
|
|
return atproto.Record{
|
|
URI: "at://" + holdDID + "/io.atcr.hold.captain/self",
|
|
Value: captainValue,
|
|
}
|
|
}
|
|
|
|
records := []atproto.Record{
|
|
captainRecord(realHold),
|
|
captainRecord(realHold), // duplicate DID: exercises the verification memo
|
|
captainRecord(notAHold),
|
|
captainRecord(unresolvable),
|
|
}
|
|
|
|
count, err := worker.batchCaptains(context.Background(), realHold, records)
|
|
if err != nil {
|
|
t.Fatalf("batchCaptains failed: %v", err)
|
|
}
|
|
if count != 2 {
|
|
t.Errorf("batchCaptains processed %d records, want 2 (both from the real hold)", count)
|
|
}
|
|
|
|
rows, err := db.Query(`SELECT hold_did FROM hold_captain_records`)
|
|
if err != nil {
|
|
t.Fatalf("Failed to query captain records: %v", err)
|
|
}
|
|
defer rows.Close()
|
|
|
|
var cached []string
|
|
for rows.Next() {
|
|
var did string
|
|
if err := rows.Scan(&did); err != nil {
|
|
t.Fatalf("Failed to scan captain record: %v", err)
|
|
}
|
|
cached = append(cached, did)
|
|
}
|
|
if len(cached) != 1 || cached[0] != realHold {
|
|
t.Errorf("cached captain records = %v, want only %q", cached, realHold)
|
|
}
|
|
|
|
// Each DID should be verified exactly once per batch, regardless of how
|
|
// many of its records appear.
|
|
for _, did := range []string{realHold, notAHold, unresolvable} {
|
|
if got := dir.lookups[did]; got != 1 {
|
|
t.Errorf("LookupDID(%s) called %d times, want 1 (memoized)", did, got)
|
|
}
|
|
}
|
|
}
|