A locally focused bluesky appview

Compare changes

Choose any two refs to compare.

+38
README.md
··· 201 201 202 202 It will take a minute but it should pull all records from that user. 203 203 204 + ## Upstream Firehose Configuration 205 + 206 + Konbini supports both standard firehose endpoints as well as jetstream. If 207 + bandwidth and CPU usage is a concern, and you trust the jetstream endpoint, 208 + then it may be worth trying that out. 209 + 210 + The configuration file is formatted as follows: 211 + 212 + ```json 213 + { 214 + "backends": [ 215 + { 216 + "type": "jetstream", 217 + "host": "jetstream1.us-west.bsky.network" 218 + } 219 + ] 220 + } 221 + ``` 222 + 223 + The default (implicit) configuration file looks like this: 224 + 225 + ```json 226 + { 227 + "backends": [ 228 + { 229 + "type": "firehose", 230 + "host": "bsky.network" 231 + } 232 + ] 233 + } 234 + ``` 235 + 236 + Note that this is an array of backends, you can specify multiple upstreams, and 237 + konbini will read from all of them. The main intended purpose of this is to be 238 + able to subscribe directly to PDSs. PDSs currently only support the full 239 + firehose endpoint, not jetstream, so be sure to specify a type of "firehose" 240 + for individual PDS endpoints. 241 + 204 242 ## License 205 243 206 244 MIT (whyrusleeping)
+536
backend/backend.go
··· 1 + package backend 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "strings" 8 + "sync" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/api/bsky" 13 + "github.com/bluesky-social/indigo/atproto/identity" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + "github.com/bluesky-social/indigo/util" 16 + "github.com/bluesky-social/indigo/xrpc" 17 + lru "github.com/hashicorp/golang-lru/v2" 18 + "github.com/jackc/pgx/v5" 19 + "github.com/jackc/pgx/v5/pgconn" 20 + "github.com/jackc/pgx/v5/pgxpool" 21 + . "github.com/whyrusleeping/konbini/models" 22 + "github.com/whyrusleeping/market/models" 23 + "gorm.io/gorm" 24 + "gorm.io/gorm/clause" 25 + "gorm.io/gorm/logger" 26 + ) 27 + 28 + // PostgresBackend handles database operations 29 + type PostgresBackend struct { 30 + db *gorm.DB 31 + pgx *pgxpool.Pool 32 + 33 + dir identity.Directory 34 + 35 + client *xrpc.Client 36 + 37 + mydid string 38 + myrepo *models.Repo 39 + 40 + relevantDids map[string]bool 41 + rdLk sync.Mutex 42 + 43 + revCache *lru.TwoQueueCache[uint, string] 44 + 45 + repoCache *lru.TwoQueueCache[string, *Repo] 46 + reposLk sync.Mutex 47 + 48 + didByIDCache *lru.TwoQueueCache[uint, string] 49 + 50 + postInfoCache *lru.TwoQueueCache[string, cachedPostInfo] 51 + 52 + missingRecords chan MissingRecord 53 + } 54 + 55 + type cachedPostInfo struct { 56 + ID uint 57 + Author uint 58 + } 59 + 60 + // NewPostgresBackend creates a new PostgresBackend 61 + func NewPostgresBackend(mydid string, db *gorm.DB, pgx *pgxpool.Pool, client *xrpc.Client, dir identity.Directory) (*PostgresBackend, error) { 62 + rc, _ := lru.New2Q[string, *Repo](1_000_000) 63 + pc, _ := lru.New2Q[string, cachedPostInfo](1_000_000) 64 + revc, _ := lru.New2Q[uint, string](1_000_000) 65 + dbic, _ := lru.New2Q[uint, string](1_000_000) 66 + 67 + b := &PostgresBackend{ 68 + client: client, 69 + mydid: mydid, 70 + db: db, 71 + pgx: pgx, 72 + relevantDids: make(map[string]bool), 73 + repoCache: rc, 74 + postInfoCache: pc, 75 + revCache: revc, 76 + didByIDCache: dbic, 77 + dir: dir, 78 + 79 + missingRecords: make(chan MissingRecord, 1000), 80 + } 81 + 82 + r, err := b.GetOrCreateRepo(context.TODO(), mydid) 83 + if err != nil { 84 + return nil, err 85 + } 86 + 87 + b.myrepo = r 88 + 89 + go b.missingRecordFetcher() 90 + return b, nil 91 + } 92 + 93 + // TrackMissingRecord implements the RecordTracker interface 94 + func (b *PostgresBackend) TrackMissingRecord(identifier string, wait bool) { 95 + mr := MissingRecord{ 96 + Type: mrTypeFromIdent(identifier), 97 + Identifier: identifier, 98 + Wait: wait, 99 + } 100 + 101 + b.addMissingRecord(context.TODO(), mr) 102 + } 103 + 104 + func mrTypeFromIdent(ident string) MissingRecordType { 105 + if strings.HasPrefix(ident, "did:") { 106 + return MissingRecordTypeProfile 107 + } 108 + 109 + puri, _ := syntax.ParseATURI(ident) 110 + switch puri.Collection().String() { 111 + case "app.bsky.feed.post": 112 + return MissingRecordTypePost 113 + case "app.bsky.feed.generator": 114 + return MissingRecordTypeFeedGenerator 115 + default: 116 + return MissingRecordTypeUnknown 117 + } 118 + 119 + } 120 + 121 + // DidToID converts a DID to a database ID 122 + func (b *PostgresBackend) DidToID(ctx context.Context, did string) (uint, error) { 123 + r, err := b.GetOrCreateRepo(ctx, did) 124 + if err != nil { 125 + return 0, err 126 + } 127 + return r.ID, nil 128 + } 129 + 130 + func (b *PostgresBackend) GetOrCreateRepo(ctx context.Context, did string) (*Repo, error) { 131 + r, ok := b.repoCache.Get(did) 132 + if !ok { 133 + b.reposLk.Lock() 134 + 135 + r, ok = b.repoCache.Get(did) 136 + if !ok { 137 + r = &Repo{} 138 + r.Did = did 139 + b.repoCache.Add(did, r) 140 + } 141 + 142 + b.reposLk.Unlock() 143 + } 144 + 145 + r.Lk.Lock() 146 + defer r.Lk.Unlock() 147 + if r.Setup { 148 + return r, nil 149 + } 150 + 151 + row := b.pgx.QueryRow(ctx, "SELECT id, created_at, did FROM repos WHERE did = $1", did) 152 + 153 + err := row.Scan(&r.ID, &r.CreatedAt, &r.Did) 154 + if err == nil { 155 + // found it! 156 + r.Setup = true 157 + return r, nil 158 + } 159 + 160 + if err != pgx.ErrNoRows { 161 + return nil, err 162 + } 163 + 164 + r.Did = did 165 + if err := b.db.Create(r).Error; err != nil { 166 + return nil, err 167 + } 168 + 169 + r.Setup = true 170 + 171 + return r, nil 172 + } 173 + 174 + func (b *PostgresBackend) GetOrCreateList(ctx context.Context, uri string) (*List, error) { 175 + puri, err := util.ParseAtUri(uri) 176 + if err != nil { 177 + return nil, err 178 + } 179 + 180 + r, err := b.GetOrCreateRepo(ctx, puri.Did) 181 + if err != nil { 182 + return nil, err 183 + } 184 + 185 + // TODO: needs upsert treatment when we actually find the list 186 + var list List 187 + if err := b.db.FirstOrCreate(&list, map[string]any{ 188 + "author": r.ID, 189 + "rkey": puri.Rkey, 190 + }).Error; err != nil { 191 + return nil, err 192 + } 193 + return &list, nil 194 + } 195 + 196 + func (b *PostgresBackend) postIDForUri(ctx context.Context, uri string) (uint, error) { 197 + // getPostByUri implicitly fills the cache 198 + p, err := b.postInfoForUri(ctx, uri) 199 + if err != nil { 200 + return 0, err 201 + } 202 + 203 + return p.ID, nil 204 + } 205 + 206 + func (b *PostgresBackend) postInfoForUri(ctx context.Context, uri string) (cachedPostInfo, error) { 207 + v, ok := b.postInfoCache.Get(uri) 208 + if ok { 209 + return v, nil 210 + } 211 + 212 + // getPostByUri implicitly fills the cache 213 + p, err := b.getOrCreatePostBare(ctx, uri) 214 + if err != nil { 215 + return cachedPostInfo{}, err 216 + } 217 + 218 + return cachedPostInfo{ID: p.ID, Author: p.Author}, nil 219 + } 220 + 221 + func (b *PostgresBackend) tryLoadPostInfo(ctx context.Context, uid uint, rkey string) (*Post, error) { 222 + var p Post 223 + q := "SELECT id, author FROM posts WHERE author = $1 AND rkey = $2" 224 + if err := b.pgx.QueryRow(ctx, q, uid, rkey).Scan(&p.ID, &p.Author); err != nil { 225 + if errors.Is(err, pgx.ErrNoRows) { 226 + return nil, nil 227 + } 228 + return nil, err 229 + } 230 + 231 + return &p, nil 232 + } 233 + 234 + func (b *PostgresBackend) getOrCreatePostBare(ctx context.Context, uri string) (*Post, error) { 235 + puri, err := util.ParseAtUri(uri) 236 + if err != nil { 237 + return nil, err 238 + } 239 + 240 + r, err := b.GetOrCreateRepo(ctx, puri.Did) 241 + if err != nil { 242 + return nil, err 243 + } 244 + 245 + post, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey) 246 + if err != nil { 247 + return nil, err 248 + } 249 + 250 + if post == nil { 251 + post = &Post{ 252 + Rkey: puri.Rkey, 253 + Author: r.ID, 254 + NotFound: true, 255 + } 256 + 257 + err := b.pgx.QueryRow(ctx, "INSERT INTO posts (rkey, author, not_found) VALUES ($1, $2, $3) RETURNING id", puri.Rkey, r.ID, true).Scan(&post.ID) 258 + if err != nil { 259 + pgErr, ok := err.(*pgconn.PgError) 260 + if !ok || pgErr.Code != "23505" { 261 + return nil, err 262 + } 263 + 264 + out, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey) 265 + if err != nil { 266 + return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err) 267 + } 268 + if out == nil { 269 + return nil, fmt.Errorf("postgres is lying to us: %d %s", r.ID, puri.Rkey) 270 + } 271 + 272 + post = out 273 + } 274 + 275 + } 276 + 277 + b.postInfoCache.Add(uri, cachedPostInfo{ 278 + ID: post.ID, 279 + Author: post.Author, 280 + }) 281 + 282 + return post, nil 283 + } 284 + 285 + func (b *PostgresBackend) GetPostByUri(ctx context.Context, uri string, fields string) (*Post, error) { 286 + puri, err := util.ParseAtUri(uri) 287 + if err != nil { 288 + return nil, err 289 + } 290 + 291 + r, err := b.GetOrCreateRepo(ctx, puri.Did) 292 + if err != nil { 293 + return nil, err 294 + } 295 + 296 + q := "SELECT " + fields + " FROM posts WHERE author = ? AND rkey = ?" 297 + 298 + var post Post 299 + if err := b.db.Raw(q, r.ID, puri.Rkey).Scan(&post).Error; err != nil { 300 + return nil, err 301 + } 302 + 303 + if post.ID == 0 { 304 + post.Rkey = puri.Rkey 305 + post.Author = r.ID 306 + post.NotFound = true 307 + 308 + if err := b.db.Session(&gorm.Session{ 309 + Logger: logger.Default.LogMode(logger.Silent), 310 + }).Create(&post).Error; err != nil { 311 + if !errors.Is(err, gorm.ErrDuplicatedKey) { 312 + return nil, err 313 + } 314 + if err := b.db.Find(&post, "author = ? AND rkey = ?", r.ID, puri.Rkey).Error; err != nil { 315 + return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err) 316 + } 317 + } 318 + 319 + } 320 + 321 + b.postInfoCache.Add(uri, cachedPostInfo{ 322 + ID: post.ID, 323 + Author: post.Author, 324 + }) 325 + 326 + return &post, nil 327 + } 328 + 329 + func (b *PostgresBackend) revForRepo(rr *Repo) (string, error) { 330 + lrev, ok := b.revCache.Get(rr.ID) 331 + if ok { 332 + return lrev, nil 333 + } 334 + 335 + var rev string 336 + if err := b.pgx.QueryRow(context.TODO(), "SELECT COALESCE(rev, '') FROM sync_infos WHERE repo = $1", rr.ID).Scan(&rev); err != nil { 337 + if errors.Is(err, pgx.ErrNoRows) { 338 + return "", nil 339 + } 340 + return "", err 341 + } 342 + 343 + if rev != "" { 344 + b.revCache.Add(rr.ID, rev) 345 + } 346 + return rev, nil 347 + } 348 + 349 + func (b *PostgresBackend) AddRelevantDid(did string) { 350 + b.rdLk.Lock() 351 + defer b.rdLk.Unlock() 352 + b.relevantDids[did] = true 353 + } 354 + 355 + func (b *PostgresBackend) DidIsRelevant(did string) bool { 356 + b.rdLk.Lock() 357 + defer b.rdLk.Unlock() 358 + return b.relevantDids[did] 359 + } 360 + 361 + func (b *PostgresBackend) anyRelevantIdents(idents ...string) bool { 362 + for _, id := range idents { 363 + if strings.HasPrefix(id, "did:") { 364 + if b.DidIsRelevant(id) { 365 + return true 366 + } 367 + } else if strings.HasPrefix(id, "at://") { 368 + puri, err := syntax.ParseATURI(id) 369 + if err != nil { 370 + continue 371 + } 372 + 373 + if b.DidIsRelevant(puri.Authority().String()) { 374 + return true 375 + } 376 + } 377 + } 378 + 379 + return false 380 + } 381 + 382 + func (b *PostgresBackend) GetRelevantDids() []string { 383 + b.rdLk.Lock() 384 + var out []string 385 + for k := range b.relevantDids { 386 + out = append(out, k) 387 + } 388 + b.rdLk.Unlock() 389 + return out 390 + } 391 + 392 + func (b *PostgresBackend) GetRepoByID(ctx context.Context, id uint) (*models.Repo, error) { 393 + var r models.Repo 394 + if err := b.db.Find(&r, "id = ?", id).Error; err != nil { 395 + return nil, err 396 + } 397 + 398 + return &r, nil 399 + } 400 + 401 + func (b *PostgresBackend) DidFromID(ctx context.Context, uid uint) (string, error) { 402 + val, ok := b.didByIDCache.Get(uid) 403 + if ok { 404 + return val, nil 405 + } 406 + 407 + r, err := b.GetRepoByID(ctx, uid) 408 + if err != nil { 409 + return "", err 410 + } 411 + 412 + b.didByIDCache.Add(uid, r.Did) 413 + return r.Did, nil 414 + } 415 + 416 + func (b *PostgresBackend) checkPostExists(ctx context.Context, repo *Repo, rkey string) (bool, error) { 417 + var id uint 418 + var notfound bool 419 + if err := b.pgx.QueryRow(ctx, "SELECT id, not_found FROM posts WHERE author = $1 AND rkey = $2", repo.ID, rkey).Scan(&id, &notfound); err != nil { 420 + if errors.Is(err, pgx.ErrNoRows) { 421 + return false, nil 422 + } 423 + return false, err 424 + } 425 + 426 + if id != 0 && !notfound { 427 + return true, nil 428 + } 429 + 430 + return false, nil 431 + } 432 + 433 + func (b *PostgresBackend) LoadRelevantDids() error { 434 + ctx := context.TODO() 435 + 436 + if err := b.ensureFollowsScraped(ctx, b.mydid); err != nil { 437 + return fmt.Errorf("failed to scrape follows: %w", err) 438 + } 439 + 440 + r, err := b.GetOrCreateRepo(ctx, b.mydid) 441 + if err != nil { 442 + return err 443 + } 444 + 445 + var dids []string 446 + if err := b.db.Raw("select did from follows left join repos on follows.subject = repos.id where follows.author = ?", r.ID).Scan(&dids).Error; err != nil { 447 + return err 448 + } 449 + 450 + b.relevantDids[b.mydid] = true 451 + for _, d := range dids { 452 + fmt.Println("adding did: ", d) 453 + b.relevantDids[d] = true 454 + } 455 + 456 + return nil 457 + } 458 + 459 + type SyncInfo struct { 460 + Repo uint `gorm:"index"` 461 + FollowsSynced bool 462 + Rev string 463 + } 464 + 465 + func (b *PostgresBackend) ensureFollowsScraped(ctx context.Context, user string) error { 466 + r, err := b.GetOrCreateRepo(ctx, user) 467 + if err != nil { 468 + return err 469 + } 470 + 471 + var si SyncInfo 472 + if err := b.db.Find(&si, "repo = ?", r.ID).Error; err != nil { 473 + return err 474 + } 475 + 476 + // not found 477 + if si.Repo == 0 { 478 + if err := b.db.Create(&SyncInfo{ 479 + Repo: r.ID, 480 + }).Error; err != nil { 481 + return err 482 + } 483 + } 484 + 485 + if si.FollowsSynced { 486 + return nil 487 + } 488 + 489 + var follows []Follow 490 + var cursor string 491 + for { 492 + resp, err := atproto.RepoListRecords(ctx, b.client, "app.bsky.graph.follow", cursor, 100, b.mydid, false) 493 + if err != nil { 494 + return err 495 + } 496 + 497 + for _, rec := range resp.Records { 498 + if fol, ok := rec.Value.Val.(*bsky.GraphFollow); ok { 499 + fr, err := b.GetOrCreateRepo(ctx, fol.Subject) 500 + if err != nil { 501 + return err 502 + } 503 + 504 + puri, err := syntax.ParseATURI(rec.Uri) 505 + if err != nil { 506 + return err 507 + } 508 + 509 + follows = append(follows, Follow{ 510 + Created: time.Now(), 511 + Indexed: time.Now(), 512 + Rkey: puri.RecordKey().String(), 513 + Author: r.ID, 514 + Subject: fr.ID, 515 + }) 516 + } 517 + } 518 + 519 + if resp.Cursor == nil || len(resp.Records) == 0 { 520 + break 521 + } 522 + cursor = *resp.Cursor 523 + } 524 + 525 + if err := b.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(follows, 200).Error; err != nil { 526 + return err 527 + } 528 + 529 + if err := b.db.Model(SyncInfo{}).Where("repo = ?", r.ID).Update("follows_synced", true).Error; err != nil { 530 + return err 531 + } 532 + 533 + fmt.Println("Got follows: ", len(follows)) 534 + 535 + return nil 536 + }
+1208
backend/events.go
··· 1 + package backend 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "fmt" 8 + "log/slog" 9 + "strings" 10 + "time" 11 + 12 + "github.com/bluesky-social/indigo/api/atproto" 13 + "github.com/bluesky-social/indigo/api/bsky" 14 + "github.com/bluesky-social/indigo/atproto/syntax" 15 + lexutil "github.com/bluesky-social/indigo/lex/util" 16 + "github.com/bluesky-social/indigo/repo" 17 + jsmodels "github.com/bluesky-social/jetstream/pkg/models" 18 + "github.com/ipfs/go-cid" 19 + "github.com/jackc/pgx/v5/pgconn" 20 + "github.com/prometheus/client_golang/prometheus" 21 + "github.com/prometheus/client_golang/prometheus/promauto" 22 + 23 + . "github.com/whyrusleeping/konbini/models" 24 + ) 25 + 26 + var handleOpHist = promauto.NewHistogramVec(prometheus.HistogramOpts{ 27 + Name: "handle_op_duration", 28 + Help: "A histogram of op handling durations", 29 + Buckets: prometheus.ExponentialBuckets(1, 2, 15), 30 + }, []string{"op", "collection"}) 31 + 32 + func (b *PostgresBackend) HandleEvent(ctx context.Context, evt *atproto.SyncSubscribeRepos_Commit) error { 33 + r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(evt.Blocks)) 34 + if err != nil { 35 + return fmt.Errorf("failed to read event repo: %w", err) 36 + } 37 + 38 + for _, op := range evt.Ops { 39 + switch op.Action { 40 + case "create": 41 + c, rec, err := r.GetRecordBytes(ctx, op.Path) 42 + if err != nil { 43 + return err 44 + } 45 + if err := b.HandleCreate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil { 46 + return fmt.Errorf("create record failed: %w", err) 47 + } 48 + case "update": 49 + c, rec, err := r.GetRecordBytes(ctx, op.Path) 50 + if err != nil { 51 + return err 52 + } 53 + if err := b.HandleUpdate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil { 54 + return fmt.Errorf("update record failed: %w", err) 55 + } 56 + case "delete": 57 + if err := b.HandleDelete(ctx, evt.Repo, evt.Rev, op.Path); err != nil { 58 + return fmt.Errorf("delete record failed: %w", err) 59 + } 60 + } 61 + } 62 + 63 + // TODO: sync with the Since field to make sure we don't miss events we care about 64 + /* 65 + if err := bf.Store.UpdateRev(ctx, evt.Repo, evt.Rev); err != nil { 66 + return fmt.Errorf("failed to update rev: %w", err) 67 + } 68 + */ 69 + 70 + return nil 71 + } 72 + 73 + func cborBytesFromEvent(evt *jsmodels.Event) ([]byte, error) { 74 + val, err := lexutil.NewFromType(evt.Commit.Collection) 75 + if err != nil { 76 + return nil, fmt.Errorf("failed to load event record type: %w", err) 77 + } 78 + 79 + if err := json.Unmarshal(evt.Commit.Record, val); err != nil { 80 + return nil, err 81 + } 82 + 83 + cval, ok := val.(lexutil.CBOR) 84 + if !ok { 85 + return nil, fmt.Errorf("decoded type was not cbor marshalable") 86 + } 87 + 88 + buf := new(bytes.Buffer) 89 + if err := cval.MarshalCBOR(buf); err != nil { 90 + return nil, fmt.Errorf("failed to marshal event to cbor: %w", err) 91 + } 92 + 93 + rec := buf.Bytes() 94 + return rec, nil 95 + } 96 + 97 + func (b *PostgresBackend) HandleEventJetstream(ctx context.Context, evt *jsmodels.Event) error { 98 + 99 + path := evt.Commit.Collection + "/" + evt.Commit.RKey 100 + switch evt.Commit.Operation { 101 + case jsmodels.CommitOperationCreate: 102 + rec, err := cborBytesFromEvent(evt) 103 + if err != nil { 104 + return err 105 + } 106 + 107 + c, err := cid.Decode(evt.Commit.CID) 108 + if err != nil { 109 + return err 110 + } 111 + 112 + if err := b.HandleCreate(ctx, evt.Did, evt.Commit.Rev, path, &rec, &c); err != nil { 113 + return fmt.Errorf("create record failed: %w", err) 114 + } 115 + case jsmodels.CommitOperationUpdate: 116 + rec, err := cborBytesFromEvent(evt) 117 + if err != nil { 118 + return err 119 + } 120 + 121 + c, err := cid.Decode(evt.Commit.CID) 122 + if err != nil { 123 + return err 124 + } 125 + 126 + if err := b.HandleUpdate(ctx, evt.Did, evt.Commit.Rev, path, &rec, &c); err != nil { 127 + return fmt.Errorf("update record failed: %w", err) 128 + } 129 + case jsmodels.CommitOperationDelete: 130 + if err := b.HandleDelete(ctx, evt.Did, evt.Commit.Rev, path); err != nil { 131 + return fmt.Errorf("delete record failed: %w", err) 132 + } 133 + } 134 + 135 + return nil 136 + } 137 + 138 + func (b *PostgresBackend) HandleCreate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error { 139 + start := time.Now() 140 + 141 + rr, err := b.GetOrCreateRepo(ctx, repo) 142 + if err != nil { 143 + return fmt.Errorf("get user failed: %w", err) 144 + } 145 + 146 + lrev, err := b.revForRepo(rr) 147 + if err != nil { 148 + return err 149 + } 150 + if lrev != "" { 151 + if rev < lrev { 152 + slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path) 153 + return nil 154 + } 155 + } 156 + 157 + parts := strings.Split(path, "/") 158 + if len(parts) != 2 { 159 + return fmt.Errorf("invalid path in HandleCreate: %q", path) 160 + } 161 + col := parts[0] 162 + rkey := parts[1] 163 + 164 + defer func() { 165 + handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds())) 166 + }() 167 + 168 + if rkey == "" { 169 + fmt.Printf("messed up path: %q\n", rkey) 170 + } 171 + 172 + switch col { 173 + case "app.bsky.feed.post": 174 + if err := b.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil { 175 + return err 176 + } 177 + case "app.bsky.feed.like": 178 + if err := b.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil { 179 + return err 180 + } 181 + case "app.bsky.feed.repost": 182 + if err := b.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil { 183 + return err 184 + } 185 + case "app.bsky.graph.follow": 186 + if err := b.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil { 187 + return err 188 + } 189 + case "app.bsky.graph.block": 190 + if err := b.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil { 191 + return err 192 + } 193 + case "app.bsky.graph.list": 194 + if err := b.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil { 195 + return err 196 + } 197 + case "app.bsky.graph.listitem": 198 + if err := b.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil { 199 + return err 200 + } 201 + case "app.bsky.graph.listblock": 202 + if err := b.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil { 203 + return err 204 + } 205 + case "app.bsky.actor.profile": 206 + if err := b.HandleCreateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil { 207 + return err 208 + } 209 + case "app.bsky.feed.generator": 210 + if err := b.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil { 211 + return err 212 + } 213 + case "app.bsky.feed.threadgate": 214 + if err := b.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil { 215 + return err 216 + } 217 + case "chat.bsky.actor.declaration": 218 + if err := b.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil { 219 + return err 220 + } 221 + case "app.bsky.feed.postgate": 222 + if err := b.HandleCreatePostGate(ctx, rr, rkey, *rec, *cid); err != nil { 223 + return err 224 + } 225 + case "app.bsky.graph.starterpack": 226 + if err := b.HandleCreateStarterPack(ctx, rr, rkey, *rec, *cid); err != nil { 227 + return err 228 + } 229 + default: 230 + slog.Debug("unrecognized record type", "repo", repo, "path", path, "rev", rev) 231 + } 232 + 233 + b.revCache.Add(rr.ID, rev) 234 + return nil 235 + } 236 + 237 + func (b *PostgresBackend) HandleCreatePost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 238 + exists, err := b.checkPostExists(ctx, repo, rkey) 239 + if err != nil { 240 + return err 241 + } 242 + 243 + // still technically a race condition if two creates for the same post happen concurrently... probably fine 244 + if exists { 245 + return nil 246 + } 247 + 248 + var rec bsky.FeedPost 249 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 250 + uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey 251 + slog.Warn("skipping post with malformed data", "uri", uri, "error", err) 252 + return nil // Skip this post rather than failing the entire event 253 + } 254 + 255 + reldids := []string{repo.Did} 256 + // care about a post if its in a thread of a user we are interested in 257 + if rec.Reply != nil && rec.Reply.Parent != nil && rec.Reply.Root != nil { 258 + reldids = append(reldids, rec.Reply.Parent.Uri, rec.Reply.Root.Uri) 259 + } 260 + // TODO: maybe also care if its mentioning a user we care about or quoting a user we care about? 261 + if !b.anyRelevantIdents(reldids...) { 262 + return nil 263 + } 264 + 265 + uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey 266 + slog.Warn("adding post", "uri", uri) 267 + 268 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 269 + if err != nil { 270 + return fmt.Errorf("invalid timestamp: %w", err) 271 + } 272 + 273 + p := Post{ 274 + Created: created.Time(), 275 + Indexed: time.Now(), 276 + Author: repo.ID, 277 + Rkey: rkey, 278 + Raw: recb, 279 + Cid: cc.String(), 280 + } 281 + 282 + if rec.Reply != nil && rec.Reply.Parent != nil { 283 + if rec.Reply.Root == nil { 284 + return fmt.Errorf("post reply had nil root") 285 + } 286 + 287 + pinfo, err := b.postInfoForUri(ctx, rec.Reply.Parent.Uri) 288 + if err != nil { 289 + return fmt.Errorf("getting reply parent: %w", err) 290 + } 291 + 292 + p.ReplyTo = pinfo.ID 293 + p.ReplyToUsr = pinfo.Author 294 + 295 + thread, err := b.postIDForUri(ctx, rec.Reply.Root.Uri) 296 + if err != nil { 297 + return fmt.Errorf("getting thread root: %w", err) 298 + } 299 + 300 + p.InThread = thread 301 + 302 + r, err := b.GetOrCreateRepo(ctx, b.mydid) 303 + if err != nil { 304 + return err 305 + } 306 + 307 + if p.ReplyToUsr == r.ID { 308 + if err := b.AddNotification(ctx, r.ID, p.Author, uri, cc, NotifKindReply); err != nil { 309 + slog.Warn("failed to create notification", "uri", uri, "error", err) 310 + } 311 + } 312 + } 313 + 314 + if rec.Embed != nil { 315 + var rpref string 316 + if rec.Embed.EmbedRecord != nil && rec.Embed.EmbedRecord.Record != nil { 317 + rpref = rec.Embed.EmbedRecord.Record.Uri 318 + } 319 + if rec.Embed.EmbedRecordWithMedia != nil && 320 + rec.Embed.EmbedRecordWithMedia.Record != nil && 321 + rec.Embed.EmbedRecordWithMedia.Record.Record != nil { 322 + rpref = rec.Embed.EmbedRecordWithMedia.Record.Record.Uri 323 + } 324 + 325 + if rpref != "" && strings.Contains(rpref, "app.bsky.feed.post") { 326 + rp, err := b.postIDForUri(ctx, rpref) 327 + if err != nil { 328 + return fmt.Errorf("getting quote subject: %w", err) 329 + } 330 + 331 + p.Reposting = rp 332 + } 333 + } 334 + 335 + if err := b.doPostCreate(ctx, &p); err != nil { 336 + return err 337 + } 338 + 339 + // Check for mentions and create notifications 340 + if rec.Facets != nil { 341 + for _, facet := range rec.Facets { 342 + for _, feature := range facet.Features { 343 + if feature.RichtextFacet_Mention != nil { 344 + mentionDid := feature.RichtextFacet_Mention.Did 345 + // This is a mention 346 + mentionedRepo, err := b.GetOrCreateRepo(ctx, mentionDid) 347 + if err != nil { 348 + slog.Warn("failed to get repo for mention", "did", mentionDid, "error", err) 349 + continue 350 + } 351 + 352 + // Create notification if the mentioned user is the current user 353 + if mentionedRepo.ID == b.myrepo.ID { 354 + if err := b.AddNotification(ctx, b.myrepo.ID, p.Author, uri, cc, NotifKindMention); err != nil { 355 + slog.Warn("failed to create mention notification", "uri", uri, "error", err) 356 + } 357 + } 358 + } 359 + } 360 + } 361 + } 362 + 363 + b.postInfoCache.Add(uri, cachedPostInfo{ 364 + ID: p.ID, 365 + Author: p.Author, 366 + }) 367 + 368 + return nil 369 + } 370 + 371 + func (b *PostgresBackend) doPostCreate(ctx context.Context, p *Post) error { 372 + /* 373 + if err := b.db.Clauses(clause.OnConflict{ 374 + Columns: []clause.Column{{Name: "author"}, {Name: "rkey"}}, 375 + DoUpdates: clause.AssignmentColumns([]string{"cid", "not_found", "raw", "created", "indexed"}), 376 + }).Create(p).Error; err != nil { 377 + return err 378 + } 379 + */ 380 + 381 + query := ` 382 + INSERT INTO posts (author, rkey, cid, not_found, raw, created, indexed, reposting, reply_to, reply_to_usr, in_thread) 383 + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) 384 + ON CONFLICT (author, rkey) 385 + DO UPDATE SET 386 + cid = $3, 387 + not_found = $4, 388 + raw = $5, 389 + created = $6, 390 + indexed = $7, 391 + reposting = $8, 392 + reply_to = $9, 393 + reply_to_usr = $10, 394 + in_thread = $11 395 + RETURNING id 396 + ` 397 + 398 + // Execute the query with parameters from the Post struct 399 + if err := b.pgx.QueryRow( 400 + ctx, 401 + query, 402 + p.Author, 403 + p.Rkey, 404 + p.Cid, 405 + p.NotFound, 406 + p.Raw, 407 + p.Created, 408 + p.Indexed, 409 + p.Reposting, 410 + p.ReplyTo, 411 + p.ReplyToUsr, 412 + p.InThread, 413 + ).Scan(&p.ID); err != nil { 414 + return err 415 + } 416 + 417 + return nil 418 + } 419 + 420 + func (b *PostgresBackend) HandleCreateLike(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 421 + var rec bsky.FeedLike 422 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 423 + return err 424 + } 425 + 426 + if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) { 427 + return nil 428 + } 429 + 430 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 431 + if err != nil { 432 + return fmt.Errorf("invalid timestamp: %w", err) 433 + } 434 + 435 + pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri) 436 + if err != nil { 437 + return fmt.Errorf("getting like subject: %w", err) 438 + } 439 + 440 + if _, err := b.pgx.Exec(ctx, `INSERT INTO "likes" ("created","indexed","author","rkey","subject","cid") VALUES ($1, $2, $3, $4, $5, $6)`, created.Time(), time.Now(), repo.ID, rkey, pinfo.ID, cc.String()); err != nil { 441 + pgErr, ok := err.(*pgconn.PgError) 442 + if ok && pgErr.Code == "23505" { 443 + return nil 444 + } 445 + return err 446 + } 447 + 448 + // Create notification if the liked post belongs to the current user 449 + if pinfo.Author == b.myrepo.ID { 450 + uri := fmt.Sprintf("at://%s/app.bsky.feed.like/%s", repo.Did, rkey) 451 + if err := b.AddNotification(ctx, b.myrepo.ID, repo.ID, uri, cc, NotifKindLike); err != nil { 452 + slog.Warn("failed to create like notification", "uri", uri, "error", err) 453 + } 454 + } 455 + 456 + return nil 457 + } 458 + 459 + func (b *PostgresBackend) HandleCreateRepost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 460 + var rec bsky.FeedRepost 461 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 462 + return err 463 + } 464 + 465 + if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) { 466 + return nil 467 + } 468 + 469 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 470 + if err != nil { 471 + return fmt.Errorf("invalid timestamp: %w", err) 472 + } 473 + 474 + pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri) 475 + if err != nil { 476 + return fmt.Errorf("getting repost subject: %w", err) 477 + } 478 + 479 + if _, err := b.pgx.Exec(ctx, `INSERT INTO "reposts" ("created","indexed","author","rkey","subject") VALUES ($1, $2, $3, $4, $5)`, created.Time(), time.Now(), repo.ID, rkey, pinfo.ID); err != nil { 480 + pgErr, ok := err.(*pgconn.PgError) 481 + if ok && pgErr.Code == "23505" { 482 + return nil 483 + } 484 + return err 485 + } 486 + 487 + // Create notification if the reposted post belongs to the current user 488 + if pinfo.Author == b.myrepo.ID { 489 + uri := fmt.Sprintf("at://%s/app.bsky.feed.repost/%s", repo.Did, rkey) 490 + if err := b.AddNotification(ctx, b.myrepo.ID, repo.ID, uri, cc, NotifKindRepost); err != nil { 491 + slog.Warn("failed to create repost notification", "uri", uri, "error", err) 492 + } 493 + } 494 + 495 + return nil 496 + } 497 + 498 + func (b *PostgresBackend) HandleCreateFollow(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 499 + var rec bsky.GraphFollow 500 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 501 + return err 502 + } 503 + 504 + if !b.anyRelevantIdents(repo.Did, rec.Subject) { 505 + return nil 506 + } 507 + 508 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 509 + if err != nil { 510 + return fmt.Errorf("invalid timestamp: %w", err) 511 + } 512 + 513 + subj, err := b.GetOrCreateRepo(ctx, rec.Subject) 514 + if err != nil { 515 + return err 516 + } 517 + 518 + if _, err := b.pgx.Exec(ctx, "INSERT INTO follows (created, indexed, author, rkey, subject) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", created.Time(), time.Now(), repo.ID, rkey, subj.ID); err != nil { 519 + return err 520 + } 521 + 522 + return nil 523 + } 524 + 525 + func (b *PostgresBackend) HandleCreateBlock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 526 + var rec bsky.GraphBlock 527 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 528 + return err 529 + } 530 + 531 + if !b.anyRelevantIdents(repo.Did, rec.Subject) { 532 + return nil 533 + } 534 + 535 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 536 + if err != nil { 537 + return fmt.Errorf("invalid timestamp: %w", err) 538 + } 539 + 540 + subj, err := b.GetOrCreateRepo(ctx, rec.Subject) 541 + if err != nil { 542 + return err 543 + } 544 + 545 + if err := b.db.Create(&Block{ 546 + Created: created.Time(), 547 + Indexed: time.Now(), 548 + Author: repo.ID, 549 + Rkey: rkey, 550 + Subject: subj.ID, 551 + }).Error; err != nil { 552 + return err 553 + } 554 + 555 + return nil 556 + } 557 + 558 + func (b *PostgresBackend) HandleCreateList(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 559 + var rec bsky.GraphList 560 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 561 + return err 562 + } 563 + 564 + if !b.anyRelevantIdents(repo.Did) { 565 + return nil 566 + } 567 + 568 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 569 + if err != nil { 570 + return fmt.Errorf("invalid timestamp: %w", err) 571 + } 572 + 573 + if err := b.db.Create(&List{ 574 + Created: created.Time(), 575 + Indexed: time.Now(), 576 + Author: repo.ID, 577 + Rkey: rkey, 578 + Raw: recb, 579 + }).Error; err != nil { 580 + return err 581 + } 582 + 583 + return nil 584 + } 585 + 586 + func (b *PostgresBackend) HandleCreateListitem(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 587 + var rec bsky.GraphListitem 588 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 589 + return err 590 + } 591 + if !b.anyRelevantIdents(repo.Did) { 592 + return nil 593 + } 594 + 595 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 596 + if err != nil { 597 + return fmt.Errorf("invalid timestamp: %w", err) 598 + } 599 + 600 + subj, err := b.GetOrCreateRepo(ctx, rec.Subject) 601 + if err != nil { 602 + return err 603 + } 604 + 605 + list, err := b.GetOrCreateList(ctx, rec.List) 606 + if err != nil { 607 + return err 608 + } 609 + 610 + if err := b.db.Create(&ListItem{ 611 + Created: created.Time(), 612 + Indexed: time.Now(), 613 + Author: repo.ID, 614 + Rkey: rkey, 615 + Subject: subj.ID, 616 + List: list.ID, 617 + }).Error; err != nil { 618 + return err 619 + } 620 + 621 + return nil 622 + } 623 + 624 + func (b *PostgresBackend) HandleCreateListblock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 625 + var rec bsky.GraphListblock 626 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 627 + return err 628 + } 629 + 630 + if !b.anyRelevantIdents(repo.Did, rec.Subject) { 631 + return nil 632 + } 633 + 634 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 635 + if err != nil { 636 + return fmt.Errorf("invalid timestamp: %w", err) 637 + } 638 + 639 + list, err := b.GetOrCreateList(ctx, rec.Subject) 640 + if err != nil { 641 + return err 642 + } 643 + 644 + if err := b.db.Create(&ListBlock{ 645 + Created: created.Time(), 646 + Indexed: time.Now(), 647 + Author: repo.ID, 648 + Rkey: rkey, 649 + List: list.ID, 650 + }).Error; err != nil { 651 + return err 652 + } 653 + 654 + return nil 655 + } 656 + 657 + func (b *PostgresBackend) HandleCreateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error { 658 + if !b.anyRelevantIdents(repo.Did) { 659 + return nil 660 + } 661 + 662 + if err := b.db.Create(&Profile{ 663 + //Created: created.Time(), 664 + Indexed: time.Now(), 665 + Repo: repo.ID, 666 + Raw: recb, 667 + Rev: rev, 668 + }).Error; err != nil { 669 + return err 670 + } 671 + 672 + return nil 673 + } 674 + 675 + func (b *PostgresBackend) HandleUpdateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error { 676 + if !b.anyRelevantIdents(repo.Did) { 677 + return nil 678 + } 679 + 680 + if err := b.db.Create(&Profile{ 681 + Indexed: time.Now(), 682 + Repo: repo.ID, 683 + Raw: recb, 684 + Rev: rev, 685 + }).Error; err != nil { 686 + return err 687 + } 688 + 689 + return nil 690 + } 691 + 692 + func (b *PostgresBackend) HandleCreateFeedGenerator(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 693 + if !b.anyRelevantIdents(repo.Did) { 694 + return nil 695 + } 696 + 697 + var rec bsky.FeedGenerator 698 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 699 + return err 700 + } 701 + 702 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 703 + if err != nil { 704 + return fmt.Errorf("invalid timestamp: %w", err) 705 + } 706 + 707 + if err := b.db.Create(&FeedGenerator{ 708 + Created: created.Time(), 709 + Indexed: time.Now(), 710 + Author: repo.ID, 711 + Rkey: rkey, 712 + Did: rec.Did, 713 + Raw: recb, 714 + }).Error; err != nil { 715 + return err 716 + } 717 + 718 + return nil 719 + } 720 + 721 + func (b *PostgresBackend) HandleCreateThreadgate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 722 + if !b.anyRelevantIdents(repo.Did) { 723 + return nil 724 + } 725 + var rec bsky.FeedThreadgate 726 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 727 + return err 728 + } 729 + 730 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 731 + if err != nil { 732 + return fmt.Errorf("invalid timestamp: %w", err) 733 + } 734 + 735 + pid, err := b.postIDForUri(ctx, rec.Post) 736 + if err != nil { 737 + return err 738 + } 739 + 740 + if err := b.db.Create(&ThreadGate{ 741 + Created: created.Time(), 742 + Indexed: time.Now(), 743 + Author: repo.ID, 744 + Rkey: rkey, 745 + Post: pid, 746 + }).Error; err != nil { 747 + return err 748 + } 749 + 750 + return nil 751 + } 752 + 753 + func (b *PostgresBackend) HandleCreateChatDeclaration(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 754 + // TODO: maybe track these? 755 + return nil 756 + } 757 + 758 + func (b *PostgresBackend) HandleCreatePostGate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 759 + if !b.anyRelevantIdents(repo.Did) { 760 + return nil 761 + } 762 + var rec bsky.FeedPostgate 763 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 764 + return err 765 + } 766 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 767 + if err != nil { 768 + return fmt.Errorf("invalid timestamp: %w", err) 769 + } 770 + 771 + refPost, err := b.postInfoForUri(ctx, rec.Post) 772 + if err != nil { 773 + return err 774 + } 775 + 776 + if err := b.db.Create(&PostGate{ 777 + Created: created.Time(), 778 + Indexed: time.Now(), 779 + Author: repo.ID, 780 + Rkey: rkey, 781 + Subject: refPost.ID, 782 + Raw: recb, 783 + }).Error; err != nil { 784 + return err 785 + } 786 + 787 + return nil 788 + } 789 + 790 + func (b *PostgresBackend) HandleCreateStarterPack(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 791 + if !b.anyRelevantIdents(repo.Did) { 792 + return nil 793 + } 794 + var rec bsky.GraphStarterpack 795 + if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 796 + return err 797 + } 798 + created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 799 + if err != nil { 800 + return fmt.Errorf("invalid timestamp: %w", err) 801 + } 802 + 803 + list, err := b.GetOrCreateList(ctx, rec.List) 804 + if err != nil { 805 + return err 806 + } 807 + 808 + if err := b.db.Create(&StarterPack{ 809 + Created: created.Time(), 810 + Indexed: time.Now(), 811 + Author: repo.ID, 812 + Rkey: rkey, 813 + Raw: recb, 814 + List: list.ID, 815 + }).Error; err != nil { 816 + return err 817 + } 818 + 819 + return nil 820 + } 821 + 822 + func (b *PostgresBackend) HandleUpdate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error { 823 + start := time.Now() 824 + 825 + rr, err := b.GetOrCreateRepo(ctx, repo) 826 + if err != nil { 827 + return fmt.Errorf("get user failed: %w", err) 828 + } 829 + 830 + lrev, err := b.revForRepo(rr) 831 + if err != nil { 832 + return err 833 + } 834 + if lrev != "" { 835 + if rev < lrev { 836 + //slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path) 837 + return nil 838 + } 839 + } 840 + 841 + parts := strings.Split(path, "/") 842 + if len(parts) != 2 { 843 + return fmt.Errorf("invalid path in HandleCreate: %q", path) 844 + } 845 + col := parts[0] 846 + rkey := parts[1] 847 + 848 + defer func() { 849 + handleOpHist.WithLabelValues("update", col).Observe(float64(time.Since(start).Milliseconds())) 850 + }() 851 + 852 + if rkey == "" { 853 + fmt.Printf("messed up path: %q\n", rkey) 854 + } 855 + 856 + switch col { 857 + /* 858 + case "app.bsky.feed.post": 859 + if err := s.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil { 860 + return err 861 + } 862 + case "app.bsky.feed.like": 863 + if err := s.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil { 864 + return err 865 + } 866 + case "app.bsky.feed.repost": 867 + if err := s.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil { 868 + return err 869 + } 870 + case "app.bsky.graph.follow": 871 + if err := s.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil { 872 + return err 873 + } 874 + case "app.bsky.graph.block": 875 + if err := s.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil { 876 + return err 877 + } 878 + case "app.bsky.graph.list": 879 + if err := s.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil { 880 + return err 881 + } 882 + case "app.bsky.graph.listitem": 883 + if err := s.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil { 884 + return err 885 + } 886 + case "app.bsky.graph.listblock": 887 + if err := s.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil { 888 + return err 889 + } 890 + */ 891 + case "app.bsky.actor.profile": 892 + if err := b.HandleUpdateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil { 893 + return err 894 + } 895 + /* 896 + case "app.bsky.feed.generator": 897 + if err := s.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil { 898 + return err 899 + } 900 + case "app.bsky.feed.threadgate": 901 + if err := s.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil { 902 + return err 903 + } 904 + case "chat.bsky.actor.declaration": 905 + if err := s.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil { 906 + return err 907 + } 908 + */ 909 + default: 910 + slog.Debug("unrecognized record type in update", "repo", repo, "path", path, "rev", rev) 911 + } 912 + 913 + return nil 914 + } 915 + 916 + func (b *PostgresBackend) HandleDelete(ctx context.Context, repo string, rev string, path string) error { 917 + start := time.Now() 918 + 919 + rr, err := b.GetOrCreateRepo(ctx, repo) 920 + if err != nil { 921 + return fmt.Errorf("get user failed: %w", err) 922 + } 923 + 924 + lrev, ok := b.revCache.Get(rr.ID) 925 + if ok { 926 + if rev < lrev { 927 + //slog.Info("skipping old rev delete", "did", rr.Did, "rev", rev, "oldrev", lrev) 928 + return nil 929 + } 930 + } 931 + 932 + parts := strings.Split(path, "/") 933 + if len(parts) != 2 { 934 + return fmt.Errorf("invalid path in HandleDelete: %q", path) 935 + } 936 + col := parts[0] 937 + rkey := parts[1] 938 + 939 + defer func() { 940 + handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds())) 941 + }() 942 + 943 + switch col { 944 + case "app.bsky.feed.post": 945 + if err := b.HandleDeletePost(ctx, rr, rkey); err != nil { 946 + return err 947 + } 948 + case "app.bsky.feed.like": 949 + if err := b.HandleDeleteLike(ctx, rr, rkey); err != nil { 950 + return err 951 + } 952 + case "app.bsky.feed.repost": 953 + if err := b.HandleDeleteRepost(ctx, rr, rkey); err != nil { 954 + return err 955 + } 956 + case "app.bsky.graph.follow": 957 + if err := b.HandleDeleteFollow(ctx, rr, rkey); err != nil { 958 + return err 959 + } 960 + case "app.bsky.graph.block": 961 + if err := b.HandleDeleteBlock(ctx, rr, rkey); err != nil { 962 + return err 963 + } 964 + case "app.bsky.graph.list": 965 + if err := b.HandleDeleteList(ctx, rr, rkey); err != nil { 966 + return err 967 + } 968 + case "app.bsky.graph.listitem": 969 + if err := b.HandleDeleteListitem(ctx, rr, rkey); err != nil { 970 + return err 971 + } 972 + case "app.bsky.graph.listblock": 973 + if err := b.HandleDeleteListblock(ctx, rr, rkey); err != nil { 974 + return err 975 + } 976 + case "app.bsky.actor.profile": 977 + if err := b.HandleDeleteProfile(ctx, rr, rkey); err != nil { 978 + return err 979 + } 980 + case "app.bsky.feed.generator": 981 + if err := b.HandleDeleteFeedGenerator(ctx, rr, rkey); err != nil { 982 + return err 983 + } 984 + case "app.bsky.feed.threadgate": 985 + if err := b.HandleDeleteThreadgate(ctx, rr, rkey); err != nil { 986 + return err 987 + } 988 + default: 989 + slog.Warn("delete unrecognized record type", "repo", repo, "path", path, "rev", rev) 990 + } 991 + 992 + b.revCache.Add(rr.ID, rev) 993 + return nil 994 + } 995 + 996 + func (b *PostgresBackend) HandleDeletePost(ctx context.Context, repo *Repo, rkey string) error { 997 + var p Post 998 + if err := b.db.Find(&p, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 999 + return err 1000 + } 1001 + 1002 + if p.ID == 0 { 1003 + //slog.Warn("delete of unknown post record", "repo", repo.Did, "rkey", rkey) 1004 + return nil 1005 + } 1006 + 1007 + if err := b.db.Delete(&Post{}, p.ID).Error; err != nil { 1008 + return err 1009 + } 1010 + 1011 + return nil 1012 + } 1013 + 1014 + func (b *PostgresBackend) HandleDeleteLike(ctx context.Context, repo *Repo, rkey string) error { 1015 + var like Like 1016 + if err := b.db.Find(&like, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1017 + return err 1018 + } 1019 + 1020 + if like.ID == 0 { 1021 + //slog.Warn("delete of missing like", "repo", repo.Did, "rkey", rkey) 1022 + return nil 1023 + } 1024 + 1025 + if err := b.db.Exec("DELETE FROM likes WHERE id = ?", like.ID).Error; err != nil { 1026 + return err 1027 + } 1028 + 1029 + return nil 1030 + } 1031 + 1032 + func (b *PostgresBackend) HandleDeleteRepost(ctx context.Context, repo *Repo, rkey string) error { 1033 + var repost Repost 1034 + if err := b.db.Find(&repost, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1035 + return err 1036 + } 1037 + 1038 + if repost.ID == 0 { 1039 + //return fmt.Errorf("delete of missing repost: %s %s", repo.Did, rkey) 1040 + return nil 1041 + } 1042 + 1043 + if err := b.db.Exec("DELETE FROM reposts WHERE id = ?", repost.ID).Error; err != nil { 1044 + return err 1045 + } 1046 + 1047 + return nil 1048 + } 1049 + 1050 + func (b *PostgresBackend) HandleDeleteFollow(ctx context.Context, repo *Repo, rkey string) error { 1051 + var follow Follow 1052 + if err := b.db.Find(&follow, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1053 + return err 1054 + } 1055 + 1056 + if follow.ID == 0 { 1057 + //slog.Warn("delete of missing follow", "repo", repo.Did, "rkey", rkey) 1058 + return nil 1059 + } 1060 + 1061 + if err := b.db.Exec("DELETE FROM follows WHERE id = ?", follow.ID).Error; err != nil { 1062 + return err 1063 + } 1064 + 1065 + return nil 1066 + } 1067 + 1068 + func (b *PostgresBackend) HandleDeleteBlock(ctx context.Context, repo *Repo, rkey string) error { 1069 + var block Block 1070 + if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1071 + return err 1072 + } 1073 + 1074 + if block.ID == 0 { 1075 + //slog.Warn("delete of missing block", "repo", repo.Did, "rkey", rkey) 1076 + return nil 1077 + } 1078 + 1079 + if err := b.db.Exec("DELETE FROM blocks WHERE id = ?", block.ID).Error; err != nil { 1080 + return err 1081 + } 1082 + 1083 + return nil 1084 + } 1085 + 1086 + func (b *PostgresBackend) HandleDeleteList(ctx context.Context, repo *Repo, rkey string) error { 1087 + var list List 1088 + if err := b.db.Find(&list, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1089 + return err 1090 + } 1091 + 1092 + if list.ID == 0 { 1093 + return nil 1094 + //return fmt.Errorf("delete of missing list: %s %s", repo.Did, rkey) 1095 + } 1096 + 1097 + if err := b.db.Exec("DELETE FROM lists WHERE id = ?", list.ID).Error; err != nil { 1098 + return err 1099 + } 1100 + 1101 + return nil 1102 + } 1103 + 1104 + func (b *PostgresBackend) HandleDeleteListitem(ctx context.Context, repo *Repo, rkey string) error { 1105 + var item ListItem 1106 + if err := b.db.Find(&item, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1107 + return err 1108 + } 1109 + 1110 + if item.ID == 0 { 1111 + return nil 1112 + //return fmt.Errorf("delete of missing listitem: %s %s", repo.Did, rkey) 1113 + } 1114 + 1115 + if err := b.db.Exec("DELETE FROM list_items WHERE id = ?", item.ID).Error; err != nil { 1116 + return err 1117 + } 1118 + 1119 + return nil 1120 + } 1121 + 1122 + func (b *PostgresBackend) HandleDeleteListblock(ctx context.Context, repo *Repo, rkey string) error { 1123 + var block ListBlock 1124 + if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1125 + return err 1126 + } 1127 + 1128 + if block.ID == 0 { 1129 + return nil 1130 + //return fmt.Errorf("delete of missing listblock: %s %s", repo.Did, rkey) 1131 + } 1132 + 1133 + if err := b.db.Exec("DELETE FROM list_blocks WHERE id = ?", block.ID).Error; err != nil { 1134 + return err 1135 + } 1136 + 1137 + return nil 1138 + } 1139 + 1140 + func (b *PostgresBackend) HandleDeleteFeedGenerator(ctx context.Context, repo *Repo, rkey string) error { 1141 + var feedgen FeedGenerator 1142 + if err := b.db.Find(&feedgen, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1143 + return err 1144 + } 1145 + 1146 + if feedgen.ID == 0 { 1147 + return nil 1148 + //return fmt.Errorf("delete of missing feedgen: %s %s", repo.Did, rkey) 1149 + } 1150 + 1151 + if err := b.db.Exec("DELETE FROM feed_generators WHERE id = ?", feedgen.ID).Error; err != nil { 1152 + return err 1153 + } 1154 + 1155 + return nil 1156 + } 1157 + 1158 + func (b *PostgresBackend) HandleDeleteThreadgate(ctx context.Context, repo *Repo, rkey string) error { 1159 + var threadgate ThreadGate 1160 + if err := b.db.Find(&threadgate, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1161 + return err 1162 + } 1163 + 1164 + if threadgate.ID == 0 { 1165 + return nil 1166 + //return fmt.Errorf("delete of missing threadgate: %s %s", repo.Did, rkey) 1167 + } 1168 + 1169 + if err := b.db.Exec("DELETE FROM thread_gates WHERE id = ?", threadgate.ID).Error; err != nil { 1170 + return err 1171 + } 1172 + 1173 + return nil 1174 + } 1175 + 1176 + func (b *PostgresBackend) HandleDeleteProfile(ctx context.Context, repo *Repo, rkey string) error { 1177 + var profile Profile 1178 + if err := b.db.Find(&profile, "repo = ?", repo.ID).Error; err != nil { 1179 + return err 1180 + } 1181 + 1182 + if profile.ID == 0 { 1183 + return nil 1184 + } 1185 + 1186 + if err := b.db.Exec("DELETE FROM profiles WHERE id = ?", profile.ID).Error; err != nil { 1187 + return err 1188 + } 1189 + 1190 + return nil 1191 + } 1192 + 1193 + const ( 1194 + NotifKindReply = "reply" 1195 + NotifKindLike = "like" 1196 + NotifKindMention = "mention" 1197 + NotifKindRepost = "repost" 1198 + ) 1199 + 1200 + func (b *PostgresBackend) AddNotification(ctx context.Context, forUser, author uint, recordUri string, recordCid cid.Cid, kind string) error { 1201 + return b.db.Create(&Notification{ 1202 + For: forUser, 1203 + Author: author, 1204 + Source: recordUri, 1205 + SourceCid: recordCid.String(), 1206 + Kind: kind, 1207 + }).Error 1208 + }
+12
backend/interface.go
··· 1 + package backend 2 + 3 + // RecordTracker is an interface for tracking missing records that need to be fetched 4 + type RecordTracker interface { 5 + // TrackMissingRecord queues a missing record for fetching 6 + // identifier can be: 7 + // - A DID (e.g., "did:plc:...") for actors/profiles 8 + // - An AT-URI (e.g., "at://did:plc:.../app.bsky.feed.post/...") for posts 9 + // - An AT-URI (e.g., "at://did:plc:.../app.bsky.feed.generator/...") for feed generators 10 + // wait: if true, blocks until the record is fetched 11 + TrackMissingRecord(identifier string, wait bool) 12 + }
+211
backend/missing.go
··· 1 + package backend 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "fmt" 7 + "log/slog" 8 + 9 + "github.com/bluesky-social/indigo/api/atproto" 10 + "github.com/bluesky-social/indigo/api/bsky" 11 + "github.com/bluesky-social/indigo/atproto/syntax" 12 + xrpclib "github.com/bluesky-social/indigo/xrpc" 13 + "github.com/ipfs/go-cid" 14 + ) 15 + 16 + type MissingRecordType string 17 + 18 + const ( 19 + MissingRecordTypeProfile MissingRecordType = "profile" 20 + MissingRecordTypePost MissingRecordType = "post" 21 + MissingRecordTypeFeedGenerator MissingRecordType = "feedgenerator" 22 + MissingRecordTypeUnknown MissingRecordType = "unknown" 23 + ) 24 + 25 + type MissingRecord struct { 26 + Type MissingRecordType 27 + Identifier string // DID for profiles, AT-URI for posts/feedgens 28 + Wait bool 29 + 30 + waitch chan struct{} 31 + } 32 + 33 + func (b *PostgresBackend) addMissingRecord(ctx context.Context, rec MissingRecord) { 34 + if rec.Wait { 35 + rec.waitch = make(chan struct{}) 36 + } 37 + 38 + select { 39 + case b.missingRecords <- rec: 40 + case <-ctx.Done(): 41 + } 42 + 43 + if rec.Wait { 44 + select { 45 + case <-rec.waitch: 46 + case <-ctx.Done(): 47 + } 48 + } 49 + } 50 + 51 + func (b *PostgresBackend) missingRecordFetcher() { 52 + for rec := range b.missingRecords { 53 + var err error 54 + switch rec.Type { 55 + case MissingRecordTypeProfile: 56 + err = b.fetchMissingProfile(context.TODO(), rec.Identifier) 57 + case MissingRecordTypePost: 58 + err = b.fetchMissingPost(context.TODO(), rec.Identifier) 59 + case MissingRecordTypeFeedGenerator: 60 + err = b.fetchMissingFeedGenerator(context.TODO(), rec.Identifier) 61 + default: 62 + slog.Error("unknown missing record type", "type", rec.Type) 63 + continue 64 + } 65 + 66 + if err != nil { 67 + slog.Warn("failed to fetch missing record", "type", rec.Type, "identifier", rec.Identifier, "error", err) 68 + } 69 + 70 + if rec.Wait { 71 + close(rec.waitch) 72 + } 73 + } 74 + } 75 + 76 + func (b *PostgresBackend) fetchMissingProfile(ctx context.Context, did string) error { 77 + b.AddRelevantDid(did) 78 + 79 + repo, err := b.GetOrCreateRepo(ctx, did) 80 + if err != nil { 81 + return err 82 + } 83 + 84 + resp, err := b.dir.LookupDID(ctx, syntax.DID(did)) 85 + if err != nil { 86 + return err 87 + } 88 + 89 + c := &xrpclib.Client{ 90 + Host: resp.PDSEndpoint(), 91 + } 92 + 93 + rec, err := atproto.RepoGetRecord(ctx, c, "", "app.bsky.actor.profile", did, "self") 94 + if err != nil { 95 + return err 96 + } 97 + 98 + prof, ok := rec.Value.Val.(*bsky.ActorProfile) 99 + if !ok { 100 + return fmt.Errorf("record we got back wasnt a profile somehow") 101 + } 102 + 103 + buf := new(bytes.Buffer) 104 + if err := prof.MarshalCBOR(buf); err != nil { 105 + return err 106 + } 107 + 108 + cc, err := cid.Decode(*rec.Cid) 109 + if err != nil { 110 + return err 111 + } 112 + 113 + return b.HandleUpdateProfile(ctx, repo, "self", "", buf.Bytes(), cc) 114 + } 115 + 116 + func (b *PostgresBackend) fetchMissingPost(ctx context.Context, uri string) error { 117 + puri, err := syntax.ParseATURI(uri) 118 + if err != nil { 119 + return fmt.Errorf("invalid AT URI: %s", uri) 120 + } 121 + 122 + did := puri.Authority().String() 123 + collection := puri.Collection().String() 124 + rkey := puri.RecordKey().String() 125 + 126 + b.AddRelevantDid(did) 127 + 128 + repo, err := b.GetOrCreateRepo(ctx, did) 129 + if err != nil { 130 + return err 131 + } 132 + 133 + resp, err := b.dir.LookupDID(ctx, syntax.DID(did)) 134 + if err != nil { 135 + return err 136 + } 137 + 138 + c := &xrpclib.Client{ 139 + Host: resp.PDSEndpoint(), 140 + } 141 + 142 + rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey) 143 + if err != nil { 144 + return err 145 + } 146 + 147 + post, ok := rec.Value.Val.(*bsky.FeedPost) 148 + if !ok { 149 + return fmt.Errorf("record we got back wasn't a post somehow") 150 + } 151 + 152 + buf := new(bytes.Buffer) 153 + if err := post.MarshalCBOR(buf); err != nil { 154 + return err 155 + } 156 + 157 + cc, err := cid.Decode(*rec.Cid) 158 + if err != nil { 159 + return err 160 + } 161 + 162 + return b.HandleCreatePost(ctx, repo, rkey, buf.Bytes(), cc) 163 + } 164 + 165 + func (b *PostgresBackend) fetchMissingFeedGenerator(ctx context.Context, uri string) error { 166 + puri, err := syntax.ParseATURI(uri) 167 + if err != nil { 168 + return fmt.Errorf("invalid AT URI: %s", uri) 169 + } 170 + 171 + did := puri.Authority().String() 172 + collection := puri.Collection().String() 173 + rkey := puri.RecordKey().String() 174 + b.AddRelevantDid(did) 175 + 176 + repo, err := b.GetOrCreateRepo(ctx, did) 177 + if err != nil { 178 + return err 179 + } 180 + 181 + resp, err := b.dir.LookupDID(ctx, syntax.DID(did)) 182 + if err != nil { 183 + return err 184 + } 185 + 186 + c := &xrpclib.Client{ 187 + Host: resp.PDSEndpoint(), 188 + } 189 + 190 + rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey) 191 + if err != nil { 192 + return err 193 + } 194 + 195 + feedGen, ok := rec.Value.Val.(*bsky.FeedGenerator) 196 + if !ok { 197 + return fmt.Errorf("record we got back wasn't a feed generator somehow") 198 + } 199 + 200 + buf := new(bytes.Buffer) 201 + if err := feedGen.MarshalCBOR(buf); err != nil { 202 + return err 203 + } 204 + 205 + cc, err := cid.Decode(*rec.Cid) 206 + if err != nil { 207 + return err 208 + } 209 + 210 + return b.HandleCreateFeedGenerator(ctx, repo, rkey, buf.Bytes(), cc) 211 + }
-1130
events.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "log/slog" 8 - "strings" 9 - "sync" 10 - "time" 11 - 12 - "github.com/bluesky-social/indigo/api/atproto" 13 - "github.com/bluesky-social/indigo/api/bsky" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - "github.com/bluesky-social/indigo/repo" 16 - lru "github.com/hashicorp/golang-lru/v2" 17 - "github.com/ipfs/go-cid" 18 - "github.com/jackc/pgx/v5/pgconn" 19 - "github.com/jackc/pgx/v5/pgxpool" 20 - "gorm.io/gorm" 21 - 22 - . "github.com/whyrusleeping/konbini/models" 23 - ) 24 - 25 - type PostgresBackend struct { 26 - db *gorm.DB 27 - pgx *pgxpool.Pool 28 - s *Server 29 - 30 - relevantDids map[string]bool 31 - rdLk sync.Mutex 32 - 33 - revCache *lru.TwoQueueCache[uint, string] 34 - 35 - repoCache *lru.TwoQueueCache[string, *Repo] 36 - reposLk sync.Mutex 37 - 38 - postInfoCache *lru.TwoQueueCache[string, cachedPostInfo] 39 - } 40 - 41 - func (b *PostgresBackend) HandleEvent(ctx context.Context, evt *atproto.SyncSubscribeRepos_Commit) error { 42 - r, err := repo.ReadRepoFromCar(ctx, bytes.NewReader(evt.Blocks)) 43 - if err != nil { 44 - return fmt.Errorf("failed to read event repo: %w", err) 45 - } 46 - 47 - for _, op := range evt.Ops { 48 - switch op.Action { 49 - case "create": 50 - c, rec, err := r.GetRecordBytes(ctx, op.Path) 51 - if err != nil { 52 - return err 53 - } 54 - if err := b.HandleCreate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil { 55 - return fmt.Errorf("create record failed: %w", err) 56 - } 57 - case "update": 58 - c, rec, err := r.GetRecordBytes(ctx, op.Path) 59 - if err != nil { 60 - return err 61 - } 62 - if err := b.HandleUpdate(ctx, evt.Repo, evt.Rev, op.Path, rec, &c); err != nil { 63 - return fmt.Errorf("update record failed: %w", err) 64 - } 65 - case "delete": 66 - if err := b.HandleDelete(ctx, evt.Repo, evt.Rev, op.Path); err != nil { 67 - return fmt.Errorf("delete record failed: %w", err) 68 - } 69 - } 70 - } 71 - 72 - // TODO: sync with the Since field to make sure we don't miss events we care about 73 - /* 74 - if err := bf.Store.UpdateRev(ctx, evt.Repo, evt.Rev); err != nil { 75 - return fmt.Errorf("failed to update rev: %w", err) 76 - } 77 - */ 78 - 79 - return nil 80 - } 81 - 82 - func (b *PostgresBackend) HandleCreate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error { 83 - start := time.Now() 84 - 85 - rr, err := b.getOrCreateRepo(ctx, repo) 86 - if err != nil { 87 - return fmt.Errorf("get user failed: %w", err) 88 - } 89 - 90 - lrev, err := b.revForRepo(rr) 91 - if err != nil { 92 - return err 93 - } 94 - if lrev != "" { 95 - if rev < lrev { 96 - slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path) 97 - return nil 98 - } 99 - } 100 - 101 - parts := strings.Split(path, "/") 102 - if len(parts) != 2 { 103 - return fmt.Errorf("invalid path in HandleCreate: %q", path) 104 - } 105 - col := parts[0] 106 - rkey := parts[1] 107 - 108 - defer func() { 109 - handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds())) 110 - }() 111 - 112 - if rkey == "" { 113 - fmt.Printf("messed up path: %q\n", rkey) 114 - } 115 - 116 - switch col { 117 - case "app.bsky.feed.post": 118 - if err := b.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil { 119 - return err 120 - } 121 - case "app.bsky.feed.like": 122 - if err := b.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil { 123 - return err 124 - } 125 - case "app.bsky.feed.repost": 126 - if err := b.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil { 127 - return err 128 - } 129 - case "app.bsky.graph.follow": 130 - if err := b.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil { 131 - return err 132 - } 133 - case "app.bsky.graph.block": 134 - if err := b.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil { 135 - return err 136 - } 137 - case "app.bsky.graph.list": 138 - if err := b.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil { 139 - return err 140 - } 141 - case "app.bsky.graph.listitem": 142 - if err := b.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil { 143 - return err 144 - } 145 - case "app.bsky.graph.listblock": 146 - if err := b.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil { 147 - return err 148 - } 149 - case "app.bsky.actor.profile": 150 - if err := b.HandleCreateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil { 151 - return err 152 - } 153 - case "app.bsky.feed.generator": 154 - if err := b.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil { 155 - return err 156 - } 157 - case "app.bsky.feed.threadgate": 158 - if err := b.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil { 159 - return err 160 - } 161 - case "chat.bsky.actor.declaration": 162 - if err := b.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil { 163 - return err 164 - } 165 - case "app.bsky.feed.postgate": 166 - if err := b.HandleCreatePostGate(ctx, rr, rkey, *rec, *cid); err != nil { 167 - return err 168 - } 169 - case "app.bsky.graph.starterpack": 170 - if err := b.HandleCreateStarterPack(ctx, rr, rkey, *rec, *cid); err != nil { 171 - return err 172 - } 173 - default: 174 - slog.Debug("unrecognized record type", "repo", repo, "path", path, "rev", rev) 175 - } 176 - 177 - b.revCache.Add(rr.ID, rev) 178 - return nil 179 - } 180 - 181 - func (b *PostgresBackend) HandleCreatePost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 182 - exists, err := b.checkPostExists(ctx, repo, rkey) 183 - if err != nil { 184 - return err 185 - } 186 - 187 - // still technically a race condition if two creates for the same post happen concurrently... probably fine 188 - if exists { 189 - return nil 190 - } 191 - 192 - var rec bsky.FeedPost 193 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 194 - uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey 195 - slog.Warn("skipping post with malformed data", "uri", uri, "error", err) 196 - return nil // Skip this post rather than failing the entire event 197 - } 198 - 199 - reldids := []string{repo.Did} 200 - // care about a post if its in a thread of a user we are interested in 201 - if rec.Reply != nil && rec.Reply.Parent != nil && rec.Reply.Root != nil { 202 - reldids = append(reldids, rec.Reply.Parent.Uri, rec.Reply.Root.Uri) 203 - } 204 - // TODO: maybe also care if its mentioning a user we care about or quoting a user we care about? 205 - if !b.anyRelevantIdents(reldids...) { 206 - return nil 207 - } 208 - 209 - uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey 210 - slog.Warn("adding post", "uri", uri) 211 - 212 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 213 - if err != nil { 214 - return fmt.Errorf("invalid timestamp: %w", err) 215 - } 216 - 217 - p := Post{ 218 - Created: created.Time(), 219 - Indexed: time.Now(), 220 - Author: repo.ID, 221 - Rkey: rkey, 222 - Raw: recb, 223 - Cid: cc.String(), 224 - } 225 - 226 - if rec.Reply != nil && rec.Reply.Parent != nil { 227 - if rec.Reply.Root == nil { 228 - return fmt.Errorf("post reply had nil root") 229 - } 230 - 231 - pinfo, err := b.postInfoForUri(ctx, rec.Reply.Parent.Uri) 232 - if err != nil { 233 - return fmt.Errorf("getting reply parent: %w", err) 234 - } 235 - 236 - p.ReplyTo = pinfo.ID 237 - p.ReplyToUsr = pinfo.Author 238 - 239 - thread, err := b.postIDForUri(ctx, rec.Reply.Root.Uri) 240 - if err != nil { 241 - return fmt.Errorf("getting thread root: %w", err) 242 - } 243 - 244 - p.InThread = thread 245 - 246 - if p.ReplyToUsr == b.s.myrepo.ID { 247 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, p.Author, uri, cc, NotifKindReply); err != nil { 248 - slog.Warn("failed to create notification", "uri", uri, "error", err) 249 - } 250 - } 251 - } 252 - 253 - if rec.Embed != nil { 254 - var rpref string 255 - if rec.Embed.EmbedRecord != nil && rec.Embed.EmbedRecord.Record != nil { 256 - rpref = rec.Embed.EmbedRecord.Record.Uri 257 - } 258 - if rec.Embed.EmbedRecordWithMedia != nil && 259 - rec.Embed.EmbedRecordWithMedia.Record != nil && 260 - rec.Embed.EmbedRecordWithMedia.Record.Record != nil { 261 - rpref = rec.Embed.EmbedRecordWithMedia.Record.Record.Uri 262 - } 263 - 264 - if rpref != "" && strings.Contains(rpref, "app.bsky.feed.post") { 265 - rp, err := b.postIDForUri(ctx, rpref) 266 - if err != nil { 267 - return fmt.Errorf("getting quote subject: %w", err) 268 - } 269 - 270 - p.Reposting = rp 271 - } 272 - } 273 - 274 - if err := b.doPostCreate(ctx, &p); err != nil { 275 - return err 276 - } 277 - 278 - // Check for mentions and create notifications 279 - if rec.Facets != nil { 280 - for _, facet := range rec.Facets { 281 - for _, feature := range facet.Features { 282 - if feature.RichtextFacet_Mention != nil { 283 - mentionDid := feature.RichtextFacet_Mention.Did 284 - // This is a mention 285 - mentionedRepo, err := b.getOrCreateRepo(ctx, mentionDid) 286 - if err != nil { 287 - slog.Warn("failed to get repo for mention", "did", mentionDid, "error", err) 288 - continue 289 - } 290 - 291 - // Create notification if the mentioned user is the current user 292 - if mentionedRepo.ID == b.s.myrepo.ID { 293 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, p.Author, uri, cc, NotifKindMention); err != nil { 294 - slog.Warn("failed to create mention notification", "uri", uri, "error", err) 295 - } 296 - } 297 - } 298 - } 299 - } 300 - } 301 - 302 - b.postInfoCache.Add(uri, cachedPostInfo{ 303 - ID: p.ID, 304 - Author: p.Author, 305 - }) 306 - 307 - return nil 308 - } 309 - 310 - func (b *PostgresBackend) doPostCreate(ctx context.Context, p *Post) error { 311 - /* 312 - if err := b.db.Clauses(clause.OnConflict{ 313 - Columns: []clause.Column{{Name: "author"}, {Name: "rkey"}}, 314 - DoUpdates: clause.AssignmentColumns([]string{"cid", "not_found", "raw", "created", "indexed"}), 315 - }).Create(p).Error; err != nil { 316 - return err 317 - } 318 - */ 319 - 320 - query := ` 321 - INSERT INTO posts (author, rkey, cid, not_found, raw, created, indexed, reposting, reply_to, reply_to_usr, in_thread) 322 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) 323 - ON CONFLICT (author, rkey) 324 - DO UPDATE SET 325 - cid = $3, 326 - not_found = $4, 327 - raw = $5, 328 - created = $6, 329 - indexed = $7, 330 - reposting = $8, 331 - reply_to = $9, 332 - reply_to_usr = $10, 333 - in_thread = $11 334 - RETURNING id 335 - ` 336 - 337 - // Execute the query with parameters from the Post struct 338 - if err := b.pgx.QueryRow( 339 - ctx, 340 - query, 341 - p.Author, 342 - p.Rkey, 343 - p.Cid, 344 - p.NotFound, 345 - p.Raw, 346 - p.Created, 347 - p.Indexed, 348 - p.Reposting, 349 - p.ReplyTo, 350 - p.ReplyToUsr, 351 - p.InThread, 352 - ).Scan(&p.ID); err != nil { 353 - return err 354 - } 355 - 356 - return nil 357 - } 358 - 359 - func (b *PostgresBackend) HandleCreateLike(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 360 - var rec bsky.FeedLike 361 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 362 - return err 363 - } 364 - 365 - if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) { 366 - return nil 367 - } 368 - 369 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 370 - if err != nil { 371 - return fmt.Errorf("invalid timestamp: %w", err) 372 - } 373 - 374 - pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri) 375 - if err != nil { 376 - return fmt.Errorf("getting like subject: %w", err) 377 - } 378 - 379 - if _, err := b.pgx.Exec(ctx, `INSERT INTO "likes" ("created","indexed","author","rkey","subject","cid") VALUES ($1, $2, $3, $4, $5, $6)`, created.Time(), time.Now(), repo.ID, rkey, pinfo.ID, cc.String()); err != nil { 380 - pgErr, ok := err.(*pgconn.PgError) 381 - if ok && pgErr.Code == "23505" { 382 - return nil 383 - } 384 - return err 385 - } 386 - 387 - // Create notification if the liked post belongs to the current user 388 - if pinfo.Author == b.s.myrepo.ID { 389 - uri := fmt.Sprintf("at://%s/app.bsky.feed.like/%s", repo.Did, rkey) 390 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, repo.ID, uri, cc, NotifKindLike); err != nil { 391 - slog.Warn("failed to create like notification", "uri", uri, "error", err) 392 - } 393 - } 394 - 395 - return nil 396 - } 397 - 398 - func (b *PostgresBackend) HandleCreateRepost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 399 - var rec bsky.FeedRepost 400 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 401 - return err 402 - } 403 - 404 - if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) { 405 - return nil 406 - } 407 - 408 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 409 - if err != nil { 410 - return fmt.Errorf("invalid timestamp: %w", err) 411 - } 412 - 413 - pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri) 414 - if err != nil { 415 - return fmt.Errorf("getting repost subject: %w", err) 416 - } 417 - 418 - if _, err := b.pgx.Exec(ctx, `INSERT INTO "reposts" ("created","indexed","author","rkey","subject") VALUES ($1, $2, $3, $4, $5)`, created.Time(), time.Now(), repo.ID, rkey, pinfo.ID); err != nil { 419 - pgErr, ok := err.(*pgconn.PgError) 420 - if ok && pgErr.Code == "23505" { 421 - return nil 422 - } 423 - return err 424 - } 425 - 426 - // Create notification if the reposted post belongs to the current user 427 - if pinfo.Author == b.s.myrepo.ID { 428 - uri := fmt.Sprintf("at://%s/app.bsky.feed.repost/%s", repo.Did, rkey) 429 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, repo.ID, uri, cc, NotifKindRepost); err != nil { 430 - slog.Warn("failed to create repost notification", "uri", uri, "error", err) 431 - } 432 - } 433 - 434 - return nil 435 - } 436 - 437 - func (b *PostgresBackend) HandleCreateFollow(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 438 - var rec bsky.GraphFollow 439 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 440 - return err 441 - } 442 - 443 - if !b.anyRelevantIdents(repo.Did, rec.Subject) { 444 - return nil 445 - } 446 - 447 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 448 - if err != nil { 449 - return fmt.Errorf("invalid timestamp: %w", err) 450 - } 451 - 452 - subj, err := b.getOrCreateRepo(ctx, rec.Subject) 453 - if err != nil { 454 - return err 455 - } 456 - 457 - if _, err := b.pgx.Exec(ctx, "INSERT INTO follows (created, indexed, author, rkey, subject) VALUES ($1, $2, $3, $4, $5) ON CONFLICT DO NOTHING", created.Time(), time.Now(), repo.ID, rkey, subj.ID); err != nil { 458 - return err 459 - } 460 - 461 - return nil 462 - } 463 - 464 - func (b *PostgresBackend) HandleCreateBlock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 465 - var rec bsky.GraphBlock 466 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 467 - return err 468 - } 469 - 470 - if !b.anyRelevantIdents(repo.Did, rec.Subject) { 471 - return nil 472 - } 473 - 474 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 475 - if err != nil { 476 - return fmt.Errorf("invalid timestamp: %w", err) 477 - } 478 - 479 - subj, err := b.getOrCreateRepo(ctx, rec.Subject) 480 - if err != nil { 481 - return err 482 - } 483 - 484 - if err := b.db.Create(&Block{ 485 - Created: created.Time(), 486 - Indexed: time.Now(), 487 - Author: repo.ID, 488 - Rkey: rkey, 489 - Subject: subj.ID, 490 - }).Error; err != nil { 491 - return err 492 - } 493 - 494 - return nil 495 - } 496 - 497 - func (b *PostgresBackend) HandleCreateList(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 498 - var rec bsky.GraphList 499 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 500 - return err 501 - } 502 - 503 - if !b.anyRelevantIdents(repo.Did) { 504 - return nil 505 - } 506 - 507 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 508 - if err != nil { 509 - return fmt.Errorf("invalid timestamp: %w", err) 510 - } 511 - 512 - if err := b.db.Create(&List{ 513 - Created: created.Time(), 514 - Indexed: time.Now(), 515 - Author: repo.ID, 516 - Rkey: rkey, 517 - Raw: recb, 518 - }).Error; err != nil { 519 - return err 520 - } 521 - 522 - return nil 523 - } 524 - 525 - func (b *PostgresBackend) HandleCreateListitem(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 526 - var rec bsky.GraphListitem 527 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 528 - return err 529 - } 530 - if !b.anyRelevantIdents(repo.Did) { 531 - return nil 532 - } 533 - 534 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 535 - if err != nil { 536 - return fmt.Errorf("invalid timestamp: %w", err) 537 - } 538 - 539 - subj, err := b.getOrCreateRepo(ctx, rec.Subject) 540 - if err != nil { 541 - return err 542 - } 543 - 544 - list, err := b.getOrCreateList(ctx, rec.List) 545 - if err != nil { 546 - return err 547 - } 548 - 549 - if err := b.db.Create(&ListItem{ 550 - Created: created.Time(), 551 - Indexed: time.Now(), 552 - Author: repo.ID, 553 - Rkey: rkey, 554 - Subject: subj.ID, 555 - List: list.ID, 556 - }).Error; err != nil { 557 - return err 558 - } 559 - 560 - return nil 561 - } 562 - 563 - func (b *PostgresBackend) HandleCreateListblock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 564 - var rec bsky.GraphListblock 565 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 566 - return err 567 - } 568 - 569 - if !b.anyRelevantIdents(repo.Did, rec.Subject) { 570 - return nil 571 - } 572 - 573 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 574 - if err != nil { 575 - return fmt.Errorf("invalid timestamp: %w", err) 576 - } 577 - 578 - list, err := b.getOrCreateList(ctx, rec.Subject) 579 - if err != nil { 580 - return err 581 - } 582 - 583 - if err := b.db.Create(&ListBlock{ 584 - Created: created.Time(), 585 - Indexed: time.Now(), 586 - Author: repo.ID, 587 - Rkey: rkey, 588 - List: list.ID, 589 - }).Error; err != nil { 590 - return err 591 - } 592 - 593 - return nil 594 - } 595 - 596 - func (b *PostgresBackend) HandleCreateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error { 597 - if !b.anyRelevantIdents(repo.Did) { 598 - return nil 599 - } 600 - 601 - if err := b.db.Create(&Profile{ 602 - //Created: created.Time(), 603 - Indexed: time.Now(), 604 - Repo: repo.ID, 605 - Raw: recb, 606 - Rev: rev, 607 - }).Error; err != nil { 608 - return err 609 - } 610 - 611 - return nil 612 - } 613 - 614 - func (b *PostgresBackend) HandleUpdateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error { 615 - if !b.anyRelevantIdents(repo.Did) { 616 - return nil 617 - } 618 - 619 - if err := b.db.Create(&Profile{ 620 - Indexed: time.Now(), 621 - Repo: repo.ID, 622 - Raw: recb, 623 - Rev: rev, 624 - }).Error; err != nil { 625 - return err 626 - } 627 - 628 - return nil 629 - } 630 - 631 - func (b *PostgresBackend) HandleCreateFeedGenerator(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 632 - if !b.anyRelevantIdents(repo.Did) { 633 - return nil 634 - } 635 - 636 - var rec bsky.FeedGenerator 637 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 638 - return err 639 - } 640 - 641 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 642 - if err != nil { 643 - return fmt.Errorf("invalid timestamp: %w", err) 644 - } 645 - 646 - if err := b.db.Create(&FeedGenerator{ 647 - Created: created.Time(), 648 - Indexed: time.Now(), 649 - Author: repo.ID, 650 - Rkey: rkey, 651 - Did: rec.Did, 652 - Raw: recb, 653 - }).Error; err != nil { 654 - return err 655 - } 656 - 657 - return nil 658 - } 659 - 660 - func (b *PostgresBackend) HandleCreateThreadgate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 661 - if !b.anyRelevantIdents(repo.Did) { 662 - return nil 663 - } 664 - var rec bsky.FeedThreadgate 665 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 666 - return err 667 - } 668 - 669 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 670 - if err != nil { 671 - return fmt.Errorf("invalid timestamp: %w", err) 672 - } 673 - 674 - pid, err := b.postIDForUri(ctx, rec.Post) 675 - if err != nil { 676 - return err 677 - } 678 - 679 - if err := b.db.Create(&ThreadGate{ 680 - Created: created.Time(), 681 - Indexed: time.Now(), 682 - Author: repo.ID, 683 - Rkey: rkey, 684 - Post: pid, 685 - }).Error; err != nil { 686 - return err 687 - } 688 - 689 - return nil 690 - } 691 - 692 - func (b *PostgresBackend) HandleCreateChatDeclaration(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 693 - // TODO: maybe track these? 694 - return nil 695 - } 696 - 697 - func (b *PostgresBackend) HandleCreatePostGate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 698 - if !b.anyRelevantIdents(repo.Did) { 699 - return nil 700 - } 701 - var rec bsky.FeedPostgate 702 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 703 - return err 704 - } 705 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 706 - if err != nil { 707 - return fmt.Errorf("invalid timestamp: %w", err) 708 - } 709 - 710 - refPost, err := b.postInfoForUri(ctx, rec.Post) 711 - if err != nil { 712 - return err 713 - } 714 - 715 - if err := b.db.Create(&PostGate{ 716 - Created: created.Time(), 717 - Indexed: time.Now(), 718 - Author: repo.ID, 719 - Rkey: rkey, 720 - Subject: refPost.ID, 721 - Raw: recb, 722 - }).Error; err != nil { 723 - return err 724 - } 725 - 726 - return nil 727 - } 728 - 729 - func (b *PostgresBackend) HandleCreateStarterPack(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 730 - if !b.anyRelevantIdents(repo.Did) { 731 - return nil 732 - } 733 - var rec bsky.GraphStarterpack 734 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 735 - return err 736 - } 737 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 738 - if err != nil { 739 - return fmt.Errorf("invalid timestamp: %w", err) 740 - } 741 - 742 - list, err := b.getOrCreateList(ctx, rec.List) 743 - if err != nil { 744 - return err 745 - } 746 - 747 - if err := b.db.Create(&StarterPack{ 748 - Created: created.Time(), 749 - Indexed: time.Now(), 750 - Author: repo.ID, 751 - Rkey: rkey, 752 - Raw: recb, 753 - List: list.ID, 754 - }).Error; err != nil { 755 - return err 756 - } 757 - 758 - return nil 759 - } 760 - 761 - func (b *PostgresBackend) HandleUpdate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error { 762 - start := time.Now() 763 - 764 - rr, err := b.getOrCreateRepo(ctx, repo) 765 - if err != nil { 766 - return fmt.Errorf("get user failed: %w", err) 767 - } 768 - 769 - lrev, err := b.revForRepo(rr) 770 - if err != nil { 771 - return err 772 - } 773 - if lrev != "" { 774 - if rev < lrev { 775 - //slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path) 776 - return nil 777 - } 778 - } 779 - 780 - parts := strings.Split(path, "/") 781 - if len(parts) != 2 { 782 - return fmt.Errorf("invalid path in HandleCreate: %q", path) 783 - } 784 - col := parts[0] 785 - rkey := parts[1] 786 - 787 - defer func() { 788 - handleOpHist.WithLabelValues("update", col).Observe(float64(time.Since(start).Milliseconds())) 789 - }() 790 - 791 - if rkey == "" { 792 - fmt.Printf("messed up path: %q\n", rkey) 793 - } 794 - 795 - switch col { 796 - /* 797 - case "app.bsky.feed.post": 798 - if err := s.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil { 799 - return err 800 - } 801 - case "app.bsky.feed.like": 802 - if err := s.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil { 803 - return err 804 - } 805 - case "app.bsky.feed.repost": 806 - if err := s.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil { 807 - return err 808 - } 809 - case "app.bsky.graph.follow": 810 - if err := s.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil { 811 - return err 812 - } 813 - case "app.bsky.graph.block": 814 - if err := s.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil { 815 - return err 816 - } 817 - case "app.bsky.graph.list": 818 - if err := s.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil { 819 - return err 820 - } 821 - case "app.bsky.graph.listitem": 822 - if err := s.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil { 823 - return err 824 - } 825 - case "app.bsky.graph.listblock": 826 - if err := s.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil { 827 - return err 828 - } 829 - */ 830 - case "app.bsky.actor.profile": 831 - if err := b.HandleUpdateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil { 832 - return err 833 - } 834 - /* 835 - case "app.bsky.feed.generator": 836 - if err := s.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil { 837 - return err 838 - } 839 - case "app.bsky.feed.threadgate": 840 - if err := s.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil { 841 - return err 842 - } 843 - case "chat.bsky.actor.declaration": 844 - if err := s.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil { 845 - return err 846 - } 847 - */ 848 - default: 849 - slog.Debug("unrecognized record type in update", "repo", repo, "path", path, "rev", rev) 850 - } 851 - 852 - return nil 853 - } 854 - 855 - func (b *PostgresBackend) HandleDelete(ctx context.Context, repo string, rev string, path string) error { 856 - start := time.Now() 857 - 858 - rr, err := b.getOrCreateRepo(ctx, repo) 859 - if err != nil { 860 - return fmt.Errorf("get user failed: %w", err) 861 - } 862 - 863 - lrev, ok := b.revCache.Get(rr.ID) 864 - if ok { 865 - if rev < lrev { 866 - //slog.Info("skipping old rev delete", "did", rr.Did, "rev", rev, "oldrev", lrev) 867 - return nil 868 - } 869 - } 870 - 871 - parts := strings.Split(path, "/") 872 - if len(parts) != 2 { 873 - return fmt.Errorf("invalid path in HandleDelete: %q", path) 874 - } 875 - col := parts[0] 876 - rkey := parts[1] 877 - 878 - defer func() { 879 - handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds())) 880 - }() 881 - 882 - switch col { 883 - case "app.bsky.feed.post": 884 - if err := b.HandleDeletePost(ctx, rr, rkey); err != nil { 885 - return err 886 - } 887 - case "app.bsky.feed.like": 888 - if err := b.HandleDeleteLike(ctx, rr, rkey); err != nil { 889 - return err 890 - } 891 - case "app.bsky.feed.repost": 892 - if err := b.HandleDeleteRepost(ctx, rr, rkey); err != nil { 893 - return err 894 - } 895 - case "app.bsky.graph.follow": 896 - if err := b.HandleDeleteFollow(ctx, rr, rkey); err != nil { 897 - return err 898 - } 899 - case "app.bsky.graph.block": 900 - if err := b.HandleDeleteBlock(ctx, rr, rkey); err != nil { 901 - return err 902 - } 903 - case "app.bsky.graph.list": 904 - if err := b.HandleDeleteList(ctx, rr, rkey); err != nil { 905 - return err 906 - } 907 - case "app.bsky.graph.listitem": 908 - if err := b.HandleDeleteListitem(ctx, rr, rkey); err != nil { 909 - return err 910 - } 911 - case "app.bsky.graph.listblock": 912 - if err := b.HandleDeleteListblock(ctx, rr, rkey); err != nil { 913 - return err 914 - } 915 - case "app.bsky.actor.profile": 916 - if err := b.HandleDeleteProfile(ctx, rr, rkey); err != nil { 917 - return err 918 - } 919 - case "app.bsky.feed.generator": 920 - if err := b.HandleDeleteFeedGenerator(ctx, rr, rkey); err != nil { 921 - return err 922 - } 923 - case "app.bsky.feed.threadgate": 924 - if err := b.HandleDeleteThreadgate(ctx, rr, rkey); err != nil { 925 - return err 926 - } 927 - default: 928 - slog.Warn("delete unrecognized record type", "repo", repo, "path", path, "rev", rev) 929 - } 930 - 931 - b.revCache.Add(rr.ID, rev) 932 - return nil 933 - } 934 - 935 - func (b *PostgresBackend) HandleDeletePost(ctx context.Context, repo *Repo, rkey string) error { 936 - var p Post 937 - if err := b.db.Find(&p, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 938 - return err 939 - } 940 - 941 - if p.ID == 0 { 942 - //slog.Warn("delete of unknown post record", "repo", repo.Did, "rkey", rkey) 943 - return nil 944 - } 945 - 946 - if err := b.db.Delete(&Post{}, p.ID).Error; err != nil { 947 - return err 948 - } 949 - 950 - return nil 951 - } 952 - 953 - func (b *PostgresBackend) HandleDeleteLike(ctx context.Context, repo *Repo, rkey string) error { 954 - var like Like 955 - if err := b.db.Find(&like, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 956 - return err 957 - } 958 - 959 - if like.ID == 0 { 960 - //slog.Warn("delete of missing like", "repo", repo.Did, "rkey", rkey) 961 - return nil 962 - } 963 - 964 - if err := b.db.Exec("DELETE FROM likes WHERE id = ?", like.ID).Error; err != nil { 965 - return err 966 - } 967 - 968 - return nil 969 - } 970 - 971 - func (b *PostgresBackend) HandleDeleteRepost(ctx context.Context, repo *Repo, rkey string) error { 972 - var repost Repost 973 - if err := b.db.Find(&repost, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 974 - return err 975 - } 976 - 977 - if repost.ID == 0 { 978 - //return fmt.Errorf("delete of missing repost: %s %s", repo.Did, rkey) 979 - return nil 980 - } 981 - 982 - if err := b.db.Exec("DELETE FROM reposts WHERE id = ?", repost.ID).Error; err != nil { 983 - return err 984 - } 985 - 986 - return nil 987 - } 988 - 989 - func (b *PostgresBackend) HandleDeleteFollow(ctx context.Context, repo *Repo, rkey string) error { 990 - var follow Follow 991 - if err := b.db.Find(&follow, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 992 - return err 993 - } 994 - 995 - if follow.ID == 0 { 996 - //slog.Warn("delete of missing follow", "repo", repo.Did, "rkey", rkey) 997 - return nil 998 - } 999 - 1000 - if err := b.db.Exec("DELETE FROM follows WHERE id = ?", follow.ID).Error; err != nil { 1001 - return err 1002 - } 1003 - 1004 - return nil 1005 - } 1006 - 1007 - func (b *PostgresBackend) HandleDeleteBlock(ctx context.Context, repo *Repo, rkey string) error { 1008 - var block Block 1009 - if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1010 - return err 1011 - } 1012 - 1013 - if block.ID == 0 { 1014 - //slog.Warn("delete of missing block", "repo", repo.Did, "rkey", rkey) 1015 - return nil 1016 - } 1017 - 1018 - if err := b.db.Exec("DELETE FROM blocks WHERE id = ?", block.ID).Error; err != nil { 1019 - return err 1020 - } 1021 - 1022 - return nil 1023 - } 1024 - 1025 - func (b *PostgresBackend) HandleDeleteList(ctx context.Context, repo *Repo, rkey string) error { 1026 - var list List 1027 - if err := b.db.Find(&list, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1028 - return err 1029 - } 1030 - 1031 - if list.ID == 0 { 1032 - return nil 1033 - //return fmt.Errorf("delete of missing list: %s %s", repo.Did, rkey) 1034 - } 1035 - 1036 - if err := b.db.Exec("DELETE FROM lists WHERE id = ?", list.ID).Error; err != nil { 1037 - return err 1038 - } 1039 - 1040 - return nil 1041 - } 1042 - 1043 - func (b *PostgresBackend) HandleDeleteListitem(ctx context.Context, repo *Repo, rkey string) error { 1044 - var item ListItem 1045 - if err := b.db.Find(&item, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1046 - return err 1047 - } 1048 - 1049 - if item.ID == 0 { 1050 - return nil 1051 - //return fmt.Errorf("delete of missing listitem: %s %s", repo.Did, rkey) 1052 - } 1053 - 1054 - if err := b.db.Exec("DELETE FROM list_items WHERE id = ?", item.ID).Error; err != nil { 1055 - return err 1056 - } 1057 - 1058 - return nil 1059 - } 1060 - 1061 - func (b *PostgresBackend) HandleDeleteListblock(ctx context.Context, repo *Repo, rkey string) error { 1062 - var block ListBlock 1063 - if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1064 - return err 1065 - } 1066 - 1067 - if block.ID == 0 { 1068 - return nil 1069 - //return fmt.Errorf("delete of missing listblock: %s %s", repo.Did, rkey) 1070 - } 1071 - 1072 - if err := b.db.Exec("DELETE FROM list_blocks WHERE id = ?", block.ID).Error; err != nil { 1073 - return err 1074 - } 1075 - 1076 - return nil 1077 - } 1078 - 1079 - func (b *PostgresBackend) HandleDeleteFeedGenerator(ctx context.Context, repo *Repo, rkey string) error { 1080 - var feedgen FeedGenerator 1081 - if err := b.db.Find(&feedgen, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1082 - return err 1083 - } 1084 - 1085 - if feedgen.ID == 0 { 1086 - return nil 1087 - //return fmt.Errorf("delete of missing feedgen: %s %s", repo.Did, rkey) 1088 - } 1089 - 1090 - if err := b.db.Exec("DELETE FROM feed_generators WHERE id = ?", feedgen.ID).Error; err != nil { 1091 - return err 1092 - } 1093 - 1094 - return nil 1095 - } 1096 - 1097 - func (b *PostgresBackend) HandleDeleteThreadgate(ctx context.Context, repo *Repo, rkey string) error { 1098 - var threadgate ThreadGate 1099 - if err := b.db.Find(&threadgate, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1100 - return err 1101 - } 1102 - 1103 - if threadgate.ID == 0 { 1104 - return nil 1105 - //return fmt.Errorf("delete of missing threadgate: %s %s", repo.Did, rkey) 1106 - } 1107 - 1108 - if err := b.db.Exec("DELETE FROM thread_gates WHERE id = ?", threadgate.ID).Error; err != nil { 1109 - return err 1110 - } 1111 - 1112 - return nil 1113 - } 1114 - 1115 - func (b *PostgresBackend) HandleDeleteProfile(ctx context.Context, repo *Repo, rkey string) error { 1116 - var profile Profile 1117 - if err := b.db.Find(&profile, "repo = ?", repo.ID).Error; err != nil { 1118 - return err 1119 - } 1120 - 1121 - if profile.ID == 0 { 1122 - return nil 1123 - } 1124 - 1125 - if err := b.db.Exec("DELETE FROM profiles WHERE id = ?", profile.ID).Error; err != nil { 1126 - return err 1127 - } 1128 - 1129 - return nil 1130 - }
+18 -9
go.mod
··· 3 3 go 1.25.1 4 4 5 5 require ( 6 - github.com/bluesky-social/indigo v0.0.0-20250909204019-c5eaa30f683f 7 - github.com/golang-jwt/jwt/v5 v5.2.2 6 + github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe 7 + github.com/bluesky-social/jetstream v0.0.0-20251009222037-7d7efa58d7f1 8 8 github.com/gorilla/websocket v1.5.1 9 9 github.com/hashicorp/golang-lru/v2 v2.0.7 10 10 github.com/ipfs/go-cid v0.4.1 11 11 github.com/jackc/pgx/v5 v5.6.0 12 12 github.com/labstack/echo/v4 v4.11.3 13 13 github.com/labstack/gommon v0.4.1 14 + github.com/lestrrat-go/jwx/v2 v2.0.12 15 + github.com/multiformats/go-multihash v0.2.3 14 16 github.com/prometheus/client_golang v1.19.1 15 17 github.com/urfave/cli/v2 v2.27.7 18 + github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e 16 19 github.com/whyrusleeping/market v0.0.0-20250711215409-cc684a207f15 20 + go.opentelemetry.io/otel v1.34.0 21 + go.opentelemetry.io/otel/exporters/jaeger v1.17.0 22 + go.opentelemetry.io/otel/sdk v1.34.0 17 23 gorm.io/gorm v1.31.0 18 24 ) 19 25 ··· 25 31 github.com/cespare/xxhash/v2 v2.3.0 // indirect 26 32 github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect 27 33 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect 34 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 28 35 github.com/felixge/httpsnoop v1.0.4 // indirect 29 36 github.com/go-logr/logr v1.4.2 // indirect 30 37 github.com/go-logr/stdr v1.2.2 // indirect 38 + github.com/go-redis/cache/v9 v9.0.0 // indirect 31 39 github.com/goccy/go-json v0.10.5 // indirect 32 40 github.com/gogo/protobuf v1.3.2 // indirect 33 41 github.com/golang-jwt/jwt v3.2.2+incompatible // indirect ··· 53 61 github.com/ipfs/go-metrics-interface v0.0.1 // indirect 54 62 github.com/ipfs/go-peertaskqueue v0.8.1 // indirect 55 63 github.com/ipfs/go-verifcid v0.0.3 // indirect 56 - github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 // indirect 64 + github.com/ipld/go-car v0.6.2 // indirect 57 65 github.com/ipld/go-codec-dagpb v1.6.0 // indirect 58 66 github.com/ipld/go-ipld-prime v0.21.0 // indirect 59 67 github.com/jackc/pgpassfile v1.0.0 // indirect ··· 62 70 github.com/jbenet/goprocess v0.1.4 // indirect 63 71 github.com/jinzhu/inflection v1.0.0 // indirect 64 72 github.com/jinzhu/now v1.1.5 // indirect 73 + github.com/klauspost/compress v1.17.9 // indirect 65 74 github.com/klauspost/cpuid/v2 v2.2.7 // indirect 66 75 github.com/lestrrat-go/blackmagic v1.0.1 // indirect 67 76 github.com/lestrrat-go/httpcc v1.0.1 // indirect 68 77 github.com/lestrrat-go/httprc v1.0.4 // indirect 69 78 github.com/lestrrat-go/iter v1.0.2 // indirect 70 - github.com/lestrrat-go/jwx/v2 v2.0.12 // indirect 71 79 github.com/lestrrat-go/option v1.0.1 // indirect 72 80 github.com/libp2p/go-libp2p v0.25.1 // indirect 73 81 github.com/mattn/go-colorable v0.1.13 // indirect ··· 79 87 github.com/multiformats/go-base36 v0.2.0 // indirect 80 88 github.com/multiformats/go-multiaddr v0.8.0 // indirect 81 89 github.com/multiformats/go-multibase v0.2.0 // indirect 82 - github.com/multiformats/go-multihash v0.2.3 // indirect 83 90 github.com/multiformats/go-varint v0.0.7 // indirect 84 91 github.com/opentracing/opentracing-go v1.2.0 // indirect 85 92 github.com/orandin/slog-gorm v1.3.2 // indirect 86 93 github.com/polydawn/refmt v0.89.1-0.20221221234430-40501e09de1f // indirect 87 94 github.com/prometheus/client_model v0.6.1 // indirect 88 - github.com/prometheus/common v0.48.0 // indirect 89 - github.com/prometheus/procfs v0.12.0 // indirect 95 + github.com/prometheus/common v0.54.0 // indirect 96 + github.com/prometheus/procfs v0.15.1 // indirect 97 + github.com/redis/go-redis/v9 v9.3.0 // indirect 90 98 github.com/russross/blackfriday/v2 v2.1.0 // indirect 91 99 github.com/segmentio/asm v1.2.0 // indirect 92 100 github.com/spaolacci/murmur3 v1.1.0 // indirect 93 101 github.com/valyala/bytebufferpool v1.0.0 // indirect 94 102 github.com/valyala/fasttemplate v1.2.2 // indirect 95 - github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e // indirect 103 + github.com/vmihailenco/go-tinylfu v0.2.2 // indirect 104 + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 105 + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 96 106 github.com/whyrusleeping/go-did v0.0.0-20230824162731-404d1707d5d6 // indirect 97 107 github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect 98 108 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 99 109 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 100 110 go.opentelemetry.io/auto/sdk v1.1.0 // indirect 101 111 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect 102 - go.opentelemetry.io/otel v1.34.0 // indirect 103 112 go.opentelemetry.io/otel/metric v1.34.0 // indirect 104 113 go.opentelemetry.io/otel/trace v1.34.0 // indirect 105 114 go.uber.org/atomic v1.11.0 // indirect
+155 -10
go.sum
··· 6 6 github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= 7 7 github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 8 8 github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 9 - github.com/bluesky-social/indigo v0.0.0-20250909204019-c5eaa30f683f h1:FugOoTzh0nCMTWGqNGsjttFWVPcwxaaGD3p/nE9V8qY= 10 - github.com/bluesky-social/indigo v0.0.0-20250909204019-c5eaa30f683f/go.mod h1:n6QE1NDPFoi7PRbMUZmc2y7FibCqiVU4ePpsvhHUBR8= 9 + github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe h1:VBhaqE5ewQgXbY5SfSWFZC/AwHFo7cHxZKFYi2ce9Yo= 10 + github.com/bluesky-social/indigo v0.0.0-20251009212240-20524de167fe/go.mod h1:RuQVrCGm42QNsgumKaR6se+XkFKfCPNwdCiTvqKRUck= 11 + github.com/bluesky-social/jetstream v0.0.0-20251009222037-7d7efa58d7f1 h1:ovcRKN1iXZnY5WApVg+0Hw2RkwMH0ziA7lSAA8vellU= 12 + github.com/bluesky-social/jetstream v0.0.0-20251009222037-7d7efa58d7f1/go.mod h1:5PtGi4r/PjEVBBl+0xWuQn4mBEjr9h6xsfDBADS6cHs= 11 13 github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874 h1:N7oVaKyGp8bttX0bfZGmcGkjz7DLQXhAn3DNd3T0ous= 12 14 github.com/bradfitz/gomemcache v0.0.0-20230905024940-24af94b03874/go.mod h1:r5xuitiExdLAJ09PR7vBVENGvp4ZuTBeWTGtxuX3K+c= 15 + github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 16 + github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 17 + github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 18 + github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 13 19 github.com/carlmjohnson/versioninfo v0.22.5 h1:O00sjOLUAFxYQjlN/bzYTuZiS0y6fWDQjMRvwtKgwwc= 14 20 github.com/carlmjohnson/versioninfo v0.22.5/go.mod h1:QT9mph3wcVfISUKd0i9sZfVrPviHuSF+cUtLjm2WSf8= 21 + github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 22 + github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 15 23 github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 16 24 github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 25 + github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= 26 + github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= 27 + github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= 17 28 github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= 18 29 github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= 19 30 github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 31 + github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 20 32 github.com/cskr/pubsub v1.0.2 h1:vlOzMhl6PFn60gRlTQQsIfVwaPB/B/8MziK8FhEPt/0= 21 33 github.com/cskr/pubsub v1.0.2/go.mod h1:/8MzYXk/NJAz782G8RPkFzXTZVu63VotefPnR9TIRis= 22 34 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= ··· 25 37 github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 26 38 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 27 39 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 40 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 41 + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 28 42 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= 29 43 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 30 44 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 31 45 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 46 + github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= 47 + github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= 48 + github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwVZI= 49 + github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= 32 50 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 51 + github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 33 52 github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 34 53 github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 35 54 github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= 36 55 github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= 56 + github.com/go-redis/cache/v9 v9.0.0 h1:0thdtFo0xJi0/WXbRVu8B066z8OvVymXTJGaXrVWnN0= 57 + github.com/go-redis/cache/v9 v9.0.0/go.mod h1:cMwi1N8ASBOufbIvk7cdXe2PbPjK/WMRL95FFHWsSgI= 37 58 github.com/go-redis/redis v6.15.9+incompatible h1:K0pv1D7EQUjfyoMql+r/jZqCLizCGKFlFgcHWWmHQjg= 38 59 github.com/go-redis/redis v6.15.9+incompatible/go.mod h1:NAIEuMOZ/fxfXJIrKDQDz8wamY7mA7PouImQ2Jvg6kA= 60 + github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= 39 61 github.com/go-yaml/yaml v2.1.0+incompatible/go.mod h1:w2MrLa16VYP0jy6N7M5kHaCkaLENm+P+Tv+MfurjSw0= 40 62 github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= 41 63 github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= ··· 44 66 github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 45 67 github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= 46 68 github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= 47 - github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= 48 - github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 69 + github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 70 + github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 71 + github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 72 + github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 73 + github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 74 + github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 75 + github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 76 + github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= 77 + github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= 78 + github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 79 + github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 80 + github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 81 + github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 82 + github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 83 + github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 49 84 github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 50 85 github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 51 86 github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= 52 87 github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= 88 + github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= 53 89 github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= 54 90 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 55 91 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= ··· 69 105 github.com/hashicorp/golang-lru/arc/v2 v2.0.6/go.mod h1:cfdDIX05DWvYV6/shsxDfa/OVcRieOt+q4FnM8x+Xno= 70 106 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 71 107 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 108 + github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= 72 109 github.com/huin/goupnp v1.0.3 h1:N8No57ls+MnjlB+JPiCVSOyy/ot7MJTqlo7rn+NYSqQ= 73 110 github.com/huin/goupnp v1.0.3/go.mod h1:ZxNlw5WqJj6wSsRK5+YfflQGXYfccj5VgQsMNixHM7Y= 111 + github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= 74 112 github.com/ipfs/bbloom v0.0.4 h1:Gi+8EGJ2y5qiD5FbsbpX/TMNcJw8gSqr7eyjHa4Fhvs= 75 113 github.com/ipfs/bbloom v0.0.4/go.mod h1:cS9YprKXpoZ9lT0n/Mw/a6/aFV6DTjTLYHeA+gyqMG0= 76 114 github.com/ipfs/go-bitswap v0.11.0 h1:j1WVvhDX1yhG32NTC9xfxnqycqYIlhzEzLXG/cU1HyQ= ··· 122 160 github.com/ipfs/go-peertaskqueue v0.8.1/go.mod h1:Oxxd3eaK279FxeydSPPVGHzbwVeHjatZ2GA8XD+KbPU= 123 161 github.com/ipfs/go-verifcid v0.0.3 h1:gmRKccqhWDocCRkC+a59g5QW7uJw5bpX9HWBevXa0zs= 124 162 github.com/ipfs/go-verifcid v0.0.3/go.mod h1:gcCtGniVzelKrbk9ooUSX/pM3xlH73fZZJDzQJRvOUw= 125 - github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4 h1:oFo19cBmcP0Cmg3XXbrr0V/c+xU9U1huEZp8+OgBzdI= 126 - github.com/ipld/go-car v0.6.1-0.20230509095817-92d28eb23ba4/go.mod h1:6nkFF8OmR5wLKBzRKi7/YFJpyYR7+oEn1DX+mMWnlLA= 163 + github.com/ipld/go-car v0.6.2 h1:Hlnl3Awgnq8icK+ze3iRghk805lu8YNq3wlREDTF2qc= 164 + github.com/ipld/go-car v0.6.2/go.mod h1:oEGXdwp6bmxJCZ+rARSkDliTeYnVzv3++eXajZ+Bmr8= 127 165 github.com/ipld/go-car/v2 v2.13.1 h1:KnlrKvEPEzr5IZHKTXLAEub+tPrzeAFQVRlSQvuxBO4= 128 166 github.com/ipld/go-car/v2 v2.13.1/go.mod h1:QkdjjFNGit2GIkpQ953KBwowuoukoM75nP/JI1iDJdo= 129 167 github.com/ipld/go-codec-dagpb v1.6.0 h1:9nYazfyu9B1p3NAgfVdpRco3Fs2nFC72DqVsMj6rOcc= ··· 151 189 github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= 152 190 github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 153 191 github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 192 + github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= 193 + github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= 194 + github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 154 195 github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM= 155 196 github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws= 156 197 github.com/koron/go-ssdp v0.0.3 h1:JivLMY45N76b4p/vsWGOKewBQu6uf39y8l+AQ7sDKx8= 157 198 github.com/koron/go-ssdp v0.0.3/go.mod h1:b2MxI6yh02pKrsyNoQUsk4+YNikaGhe4894J+Q5lDvA= 158 199 github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 200 + github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= 159 201 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 160 202 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 161 203 github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= ··· 231 273 github.com/multiformats/go-multistream v0.4.1/go.mod h1:Mz5eykRVAjJWckE2U78c6xqdtyNUEhKSM0Lwar2p77Q= 232 274 github.com/multiformats/go-varint v0.0.7 h1:sWSGR+f/eu5ABZA2ZpYKBILXTTs9JWpdEM/nEGOHFS8= 233 275 github.com/multiformats/go-varint v0.0.7/go.mod h1:r8PUYw/fD/SjBCiKOoDlGF6QawOELpZAu9eioSos/OU= 276 + github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= 277 + github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 278 + github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 279 + github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= 280 + github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= 281 + github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= 282 + github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 283 + github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 284 + github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= 285 + github.com/onsi/ginkgo/v2 v2.1.4/go.mod h1:um6tUpWM/cxCK3/FK8BXqEiUMUwRgSM4JXG47RKZmLU= 286 + github.com/onsi/ginkgo/v2 v2.1.6/go.mod h1:MEH45j8TBi6u9BMogfbp0stKC5cdGjumZj5Y7AG4VIk= 287 + github.com/onsi/ginkgo/v2 v2.3.0/go.mod h1:Eew0uilEqZmIEZr8JrvYlvOM7Rr6xzTmMV8AyFNU9d0= 288 + github.com/onsi/ginkgo/v2 v2.4.0/go.mod h1:iHkDK1fKGcBoEHT5W7YBq4RFWaQulw+caOMkAt4OrFo= 289 + github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= 290 + github.com/onsi/ginkgo/v2 v2.7.0/go.mod h1:yjiuMwPokqY1XauOgju45q3sJt6VzQ/Fict1LFVcsAo= 291 + github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= 292 + github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= 293 + github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= 294 + github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= 295 + github.com/onsi/gomega v1.20.1/go.mod h1:DtrZpjmvpn2mPm4YWQa0/ALMDj9v4YxLgojwPeREyVo= 296 + github.com/onsi/gomega v1.21.1/go.mod h1:iYAIXgPSaDHak0LCMA+AWBpIKBr8WZicMxnE8luStNc= 297 + github.com/onsi/gomega v1.22.1/go.mod h1:x6n7VNe4hw0vkyYUM4mjIXx3JbLiPaBPNgB7PRQ1tuM= 298 + github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 299 + github.com/onsi/gomega v1.24.1/go.mod h1:3AOiACssS3/MajrniINInwbfOOtfZvplPzuRSmvt1jM= 300 + github.com/onsi/gomega v1.25.0 h1:Vw7br2PCDYijJHSfBOWhov+8cAnUf8MfMaIOV323l6Y= 301 + github.com/onsi/gomega v1.25.0/go.mod h1:r+zV744Re+DiYCIPRlYOTxn0YkOLcAnW8k1xXdMPGhM= 234 302 github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= 235 303 github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= 236 304 github.com/orandin/slog-gorm v1.3.2 h1:C0lKDQPAx/pF+8K2HL7bdShPwOEJpPM0Bn80zTzxU1g= ··· 246 314 github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 247 315 github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 248 316 github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 249 - github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE= 250 - github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= 251 - github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= 252 - github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= 317 + github.com/prometheus/common v0.54.0 h1:ZlZy0BgJhTwVZUn7dLOkwCZHUkrAqd3WYtcFCWnM1D8= 318 + github.com/prometheus/common v0.54.0/go.mod h1:/TQgMJP5CuVYveyT7n/0Ix8yLNNXy9yRSkhnLTHPDIQ= 319 + github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 320 + github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 321 + github.com/redis/go-redis/v9 v9.0.0-rc.4/go.mod h1:Vo3EsyWnicKnSKCA7HhgnvnyA74wOA69Cd2Meli5mmA= 322 + github.com/redis/go-redis/v9 v9.3.0 h1:RiVDjmig62jIWp7Kk4XVLs0hzV6pI3PyTnnL0cnn0u0= 323 + github.com/redis/go-redis/v9 v9.3.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 253 324 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= 325 + github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= 254 326 github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 255 327 github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 256 328 github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= ··· 267 339 github.com/spaolacci/murmur3 v1.1.0/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= 268 340 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 269 341 github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 342 + github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= 270 343 github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 271 344 github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= 272 345 github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 273 346 github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 347 + github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= 274 348 github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 275 349 github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 276 350 github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 277 351 github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 352 + github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 278 353 github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 279 354 github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= 280 355 github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= ··· 285 360 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 286 361 github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQD0Loo= 287 362 github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ= 363 + github.com/vmihailenco/go-tinylfu v0.2.2 h1:H1eiG6HM36iniK6+21n9LLpzx1G9R3DJa2UjUjbynsI= 364 + github.com/vmihailenco/go-tinylfu v0.2.2/go.mod h1:CutYi2Q9puTxfcolkliPq4npPuofg9N9t8JVrjzwa3Q= 365 + github.com/vmihailenco/msgpack/v5 v5.3.4/go.mod h1:7xyJ9e+0+9SaZT0Wt1RGleJXzli6Q/V5KbhBonMG9jc= 366 + github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 367 + github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 368 + github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 369 + github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 288 370 github.com/warpfork/go-testmark v0.12.1 h1:rMgCpJfwy1sJ50x0M0NgyphxYYPMOODIJHhsXyEHU0s= 289 371 github.com/warpfork/go-testmark v0.12.1/go.mod h1:kHwy7wfvGSPh1rQJYKayD4AbtNaeyZdcGi9tNJTaa5Y= 290 372 github.com/warpfork/go-wish v0.0.0-20220906213052-39a1cc7a02d0 h1:GDDkbFiaK8jsSDJfjId/PEGEShv6ugrt4kYsC5UIDaQ= ··· 302 384 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 303 385 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 304 386 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 387 + github.com/yuin/goldmark v1.4.1/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 305 388 github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 306 389 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 307 390 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= ··· 313 396 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q= 314 397 go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= 315 398 go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= 399 + go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= 400 + go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= 316 401 go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= 317 402 go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= 403 + go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= 404 + go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= 318 405 go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= 319 406 go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= 320 407 go.uber.org/atomic v1.6.0/go.mod h1:sABNBOSYdrvTF6hTgEIbc7YasKWGhgEQZyfxyTvoXHQ= ··· 338 425 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 339 426 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 340 427 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 428 + golang.org/x/crypto v0.1.0/go.mod h1:RecgLatLF4+eUMCP1PoPZQb+cVrJcOPbHkTkbkB9sbw= 341 429 golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw= 342 430 golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= 343 431 golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc= ··· 348 436 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 349 437 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 350 438 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 439 + golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY= 351 440 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 441 + golang.org/x/mod v0.6.0/go.mod h1:4mET923SAdbXp2ki8ey+zGs1SLqsuM2Y0uvdZR/fUNI= 442 + golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 352 443 golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 353 444 golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4= 354 445 golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 446 + golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 355 447 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 356 448 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 357 449 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 358 450 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 451 + golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 359 452 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 360 453 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 361 454 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 455 + golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= 456 + golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 457 + golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 458 + golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 362 459 golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 460 + golang.org/x/net v0.1.0/go.mod h1:Cx3nUiGt4eDBEyega/BKRp+/AlGL8hYe7U9odMt2Cco= 461 + golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= 462 + golang.org/x/net v0.3.0/go.mod h1:MBQ8lrhLObU/6UmLb4fmbmk5OcyYmqtbGd/9yIeKjEE= 463 + golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= 363 464 golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 364 465 golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 365 466 golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= 366 467 golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 468 + golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 367 469 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 368 470 golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 369 471 golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= ··· 372 474 golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 373 475 golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= 374 476 golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 477 + golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 375 478 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 376 479 golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 480 + golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 481 + golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 482 + golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 483 + golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 484 + golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 377 485 golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 378 486 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 487 + golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 379 488 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 489 + golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 380 490 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 381 491 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 382 492 golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 493 + golang.org/x/sys v0.0.0-20211019181941-9d821ace8654/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 494 + golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 495 + golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 383 496 golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 497 + golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 384 498 golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 385 499 golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 386 500 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 501 + golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 502 + golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 503 + golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 504 + golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 387 505 golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 388 506 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 389 507 golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= ··· 392 510 golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 393 511 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 394 512 golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 513 + golang.org/x/term v0.1.0/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 514 + golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= 515 + golang.org/x/term v0.3.0/go.mod h1:q750SLmJuPmVoN1blW3UFBPREJfb1KmY3vwxfr+nFDA= 516 + golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= 395 517 golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 396 518 golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 397 519 golang.org/x/term v0.11.0/go.mod h1:zC9APTIj3jG3FdV/Ons+XE1riIZXG4aZ4GTHiPZJPIU= 398 520 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 399 521 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 522 + golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 400 523 golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 524 + golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 525 + golang.org/x/text v0.5.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 526 + golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 401 527 golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 402 528 golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 403 529 golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= ··· 413 539 golang.org/x/tools v0.0.0-20191029190741-b9c20aec41a5/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 414 540 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 415 541 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 542 + golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 416 543 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 417 544 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 545 + golang.org/x/tools v0.1.10/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E= 418 546 golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 547 + golang.org/x/tools v0.2.0/go.mod h1:y4OqIKeOV/fWJetJ8bXPU1sEVniLMIyDAZWeHdV+NTA= 548 + golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ= 419 549 golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 420 550 golang.org/x/tools v0.29.0 h1:Xx0h3TtM9rzQpQuR4dKLrdglAmCEN5Oi+P74JdhdzXE= 421 551 golang.org/x/tools v0.29.0/go.mod h1:KMQVMRsVxU6nHCFXrBPhDB8XncLNLM0lIy/F14RP588= ··· 425 555 golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 426 556 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 427 557 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 558 + google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 559 + google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 560 + google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 561 + google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 562 + google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 563 + google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 564 + google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 565 + google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= 566 + google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= 428 567 google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM= 429 568 google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 430 569 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 431 570 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 571 + gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 432 572 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 433 573 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 434 574 gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= 575 + gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= 576 + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 577 + gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 435 578 gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 579 + gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 436 580 gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 581 + gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 437 582 gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 438 583 gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 439 584 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+42 -41
handlers.go
··· 17 17 "github.com/labstack/gommon/log" 18 18 "github.com/whyrusleeping/market/models" 19 19 20 + "github.com/whyrusleeping/konbini/backend" 20 21 . "github.com/whyrusleeping/konbini/models" 21 22 ) 22 23 ··· 56 57 57 58 func (s *Server) handleGetRelevantDids(e echo.Context) error { 58 59 return e.JSON(200, map[string]any{ 59 - "dids": s.backend.relevantDids, 60 + "dids": s.backend.GetRelevantDids(), 60 61 }) 61 62 } 62 63 ··· 105 106 106 107 postUri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey) 107 108 108 - p, err := s.backend.getPostByUri(ctx, postUri, "*") 109 + p, err := s.backend.GetPostByUri(ctx, postUri, "*") 109 110 if err != nil { 110 111 return err 111 112 } ··· 134 135 return err 135 136 } 136 137 137 - r, err := s.backend.getOrCreateRepo(ctx, accdid) 138 + r, err := s.backend.GetOrCreateRepo(ctx, accdid) 138 139 if err != nil { 139 140 return err 140 141 } 141 142 142 143 var profile models.Profile 143 - if err := s.backend.db.Find(&profile, "repo = ?", r.ID).Error; err != nil { 144 + if err := s.db.Find(&profile, "repo = ?", r.ID).Error; err != nil { 144 145 return err 145 146 } 146 147 147 148 if profile.Raw == nil || len(profile.Raw) == 0 { 148 - s.addMissingProfile(ctx, accdid) 149 + s.backend.TrackMissingRecord(accdid, false) 149 150 return e.JSON(404, map[string]any{ 150 151 "error": "missing profile info for user", 151 152 }) ··· 169 170 return err 170 171 } 171 172 172 - r, err := s.backend.getOrCreateRepo(ctx, accdid) 173 + r, err := s.backend.GetOrCreateRepo(ctx, accdid) 173 174 if err != nil { 174 175 return err 175 176 } ··· 188 189 } 189 190 190 191 var dbposts []models.Post 191 - if err := s.backend.db.Raw("SELECT * FROM posts WHERE author = ? AND created < ? ORDER BY created DESC LIMIT ?", r.ID, tcursor, limit).Scan(&dbposts).Error; err != nil { 192 + if err := s.db.Raw("SELECT * FROM posts WHERE author = ? AND created < ? ORDER BY created DESC LIMIT ?", r.ID, tcursor, limit).Scan(&dbposts).Error; err != nil { 192 193 return err 193 194 } 194 195 ··· 258 259 func (s *Server) handleGetFollowingFeed(e echo.Context) error { 259 260 ctx := e.Request().Context() 260 261 261 - myr, err := s.backend.getOrCreateRepo(ctx, s.mydid) 262 + myr, err := s.backend.GetOrCreateRepo(ctx, s.mydid) 262 263 if err != nil { 263 264 return err 264 265 } ··· 276 277 tcursor = t 277 278 } 278 279 var dbposts []models.Post 279 - if err := s.backend.db.Raw("select * from posts where reply_to = 0 AND author IN (select subject from follows where author = ?) AND created < ? order by created DESC limit ?", myr.ID, tcursor, limit).Scan(&dbposts).Error; err != nil { 280 + if err := s.db.Raw("select * from posts where reply_to = 0 AND author IN (select subject from follows where author = ?) AND created < ? order by created DESC limit ?", myr.ID, tcursor, limit).Scan(&dbposts).Error; err != nil { 280 281 return err 281 282 } 282 283 ··· 296 297 297 298 func (s *Server) getAuthorInfo(ctx context.Context, r *models.Repo) (*authorInfo, error) { 298 299 var profile models.Profile 299 - if err := s.backend.db.Find(&profile, "repo = ?", r.ID).Error; err != nil { 300 + if err := s.db.Find(&profile, "repo = ?", r.ID).Error; err != nil { 300 301 return nil, err 301 302 } 302 303 ··· 306 307 } 307 308 308 309 if profile.Raw == nil || len(profile.Raw) == 0 { 309 - s.addMissingProfile(ctx, r.Did) 310 + s.backend.TrackMissingRecord(r.Did, false) 310 311 return &authorInfo{ 311 312 Handle: resp.Handle.String(), 312 313 Did: r.Did, ··· 333 334 334 335 go func() { 335 336 defer wg.Done() 336 - if err := s.backend.db.Raw("SELECT count(*) FROM likes WHERE subject = ?", pid).Scan(&pc.Likes).Error; err != nil { 337 + if err := s.db.Raw("SELECT count(*) FROM likes WHERE subject = ?", pid).Scan(&pc.Likes).Error; err != nil { 337 338 slog.Error("failed to get likes count", "post", pid, "error", err) 338 339 } 339 340 }() 340 341 341 342 go func() { 342 343 defer wg.Done() 343 - if err := s.backend.db.Raw("SELECT count(*) FROM reposts WHERE subject = ?", pid).Scan(&pc.Reposts).Error; err != nil { 344 + if err := s.db.Raw("SELECT count(*) FROM reposts WHERE subject = ?", pid).Scan(&pc.Reposts).Error; err != nil { 344 345 slog.Error("failed to get reposts count", "post", pid, "error", err) 345 346 } 346 347 }() 347 348 348 349 go func() { 349 350 defer wg.Done() 350 - if err := s.backend.db.Raw("SELECT count(*) FROM posts WHERE reply_to = ?", pid).Scan(&pc.Replies).Error; err != nil { 351 + if err := s.db.Raw("SELECT count(*) FROM posts WHERE reply_to = ?", pid).Scan(&pc.Replies).Error; err != nil { 351 352 slog.Error("failed to get replies count", "post", pid, "error", err) 352 353 } 353 354 }() ··· 366 367 go func(ix int) { 367 368 defer wg.Done() 368 369 p := dbposts[ix] 369 - r, err := s.backend.getRepoByID(ctx, p.Author) 370 + r, err := s.backend.GetRepoByID(ctx, p.Author) 370 371 if err != nil { 371 372 fmt.Println("failed to get repo: ", err) 372 373 posts[ix] = postResponse{ ··· 378 379 379 380 uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey) 380 381 if len(p.Raw) == 0 || p.NotFound { 381 - s.addMissingPost(ctx, uri) 382 + s.backend.TrackMissingRecord(uri, false) 382 383 posts[ix] = postResponse{ 383 384 Uri: uri, 384 385 Missing: true, ··· 434 435 435 436 func (s *Server) checkViewerLike(ctx context.Context, pid uint) *viewerLike { 436 437 var like Like 437 - if err := s.backend.db.Raw("SELECT * FROM likes WHERE subject = ? AND author = ?", pid, s.myrepo.ID).Scan(&like).Error; err != nil { 438 + if err := s.db.Raw("SELECT * FROM likes WHERE subject = ? AND author = ?", pid, s.myrepo.ID).Scan(&like).Error; err != nil { 438 439 slog.Error("failed to lookup like", "error", err) 439 440 return nil 440 441 } ··· 511 512 quotedURI := embedRecord.Record.Uri 512 513 quotedCid := embedRecord.Record.Cid 513 514 514 - quotedPost, err := s.backend.getPostByUri(ctx, quotedURI, "*") 515 + quotedPost, err := s.backend.GetPostByUri(ctx, quotedURI, "*") 515 516 if err != nil { 516 517 slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err) 517 - s.addMissingPost(ctx, quotedURI) 518 + s.backend.TrackMissingRecord(quotedURI, false) 518 519 return s.buildQuoteFallback(quotedURI, quotedCid) 519 520 } 520 521 521 522 if quotedPost == nil || quotedPost.Raw == nil || len(quotedPost.Raw) == 0 || quotedPost.NotFound { 522 - s.addMissingPost(ctx, quotedURI) 523 + s.backend.TrackMissingRecord(quotedURI, false) 523 524 return s.buildQuoteFallback(quotedURI, quotedCid) 524 525 } 525 526 ··· 529 530 return s.buildQuoteFallback(quotedURI, quotedCid) 530 531 } 531 532 532 - quotedRepo, err := s.backend.getRepoByID(ctx, quotedPost.Author) 533 + quotedRepo, err := s.backend.GetRepoByID(ctx, quotedPost.Author) 533 534 if err != nil { 534 535 slog.Warn("failed to get quoted post author", "error", err) 535 536 return s.buildQuoteFallback(quotedURI, quotedCid) ··· 576 577 577 578 // Get the requested post to find the thread root 578 579 var requestedPost models.Post 579 - if err := s.backend.db.Find(&requestedPost, "id = ?", postID).Error; err != nil { 580 + if err := s.db.Find(&requestedPost, "id = ?", postID).Error; err != nil { 580 581 return err 581 582 } 582 583 ··· 595 596 // Get all posts in this thread 596 597 var dbposts []models.Post 597 598 query := "SELECT * FROM posts WHERE id = ? OR in_thread = ? ORDER BY created ASC" 598 - if err := s.backend.db.Raw(query, rootPostID, rootPostID).Scan(&dbposts).Error; err != nil { 599 + if err := s.db.Raw(query, rootPostID, rootPostID).Scan(&dbposts).Error; err != nil { 599 600 return err 600 601 } 601 602 602 603 // Build response for each post 603 604 posts := []postResponse{} 604 605 for _, p := range dbposts { 605 - r, err := s.backend.getRepoByID(ctx, p.Author) 606 + r, err := s.backend.GetRepoByID(ctx, p.Author) 606 607 if err != nil { 607 608 return err 608 609 } ··· 676 677 677 678 // Get all likes for this post 678 679 var likes []models.Like 679 - if err := s.backend.db.Find(&likes, "subject = ?", postID).Error; err != nil { 680 + if err := s.db.Find(&likes, "subject = ?", postID).Error; err != nil { 680 681 return err 681 682 } 682 683 683 684 users := []engagementUser{} 684 685 for _, like := range likes { 685 - r, err := s.backend.getRepoByID(ctx, like.Author) 686 + r, err := s.backend.GetRepoByID(ctx, like.Author) 686 687 if err != nil { 687 688 slog.Error("failed to get repo for like author", "error", err) 688 689 continue ··· 697 698 698 699 // Get profile if available 699 700 var profile models.Profile 700 - s.backend.db.Find(&profile, "repo = ?", r.ID) 701 + s.db.Find(&profile, "repo = ?", r.ID) 701 702 702 703 var prof *bsky.ActorProfile 703 704 if len(profile.Raw) > 0 { ··· 706 707 prof = &p 707 708 } 708 709 } else { 709 - s.addMissingProfile(ctx, r.Did) 710 + s.backend.TrackMissingRecord(r.Did, false) 710 711 } 711 712 712 713 users = append(users, engagementUser{ ··· 736 737 737 738 // Get all reposts for this post 738 739 var reposts []models.Repost 739 - if err := s.backend.db.Find(&reposts, "subject = ?", postID).Error; err != nil { 740 + if err := s.db.Find(&reposts, "subject = ?", postID).Error; err != nil { 740 741 return err 741 742 } 742 743 743 744 users := []engagementUser{} 744 745 for _, repost := range reposts { 745 - r, err := s.backend.getRepoByID(ctx, repost.Author) 746 + r, err := s.backend.GetRepoByID(ctx, repost.Author) 746 747 if err != nil { 747 748 slog.Error("failed to get repo for repost author", "error", err) 748 749 continue ··· 757 758 758 759 // Get profile if available 759 760 var profile models.Profile 760 - s.backend.db.Find(&profile, "repo = ?", r.ID) 761 + s.db.Find(&profile, "repo = ?", r.ID) 761 762 762 763 var prof *bsky.ActorProfile 763 764 if len(profile.Raw) > 0 { ··· 766 767 prof = &p 767 768 } 768 769 } else { 769 - s.addMissingProfile(ctx, r.Did) 770 + s.backend.TrackMissingRecord(r.Did, false) 770 771 } 771 772 772 773 users = append(users, engagementUser{ ··· 796 797 797 798 // Get all replies to this post 798 799 var replies []models.Post 799 - if err := s.backend.db.Find(&replies, "reply_to = ?", postID).Error; err != nil { 800 + if err := s.db.Find(&replies, "reply_to = ?", postID).Error; err != nil { 800 801 return err 801 802 } 802 803 ··· 810 811 } 811 812 seen[reply.Author] = true 812 813 813 - r, err := s.backend.getRepoByID(ctx, reply.Author) 814 + r, err := s.backend.GetRepoByID(ctx, reply.Author) 814 815 if err != nil { 815 816 slog.Error("failed to get repo for reply author", "error", err) 816 817 continue ··· 825 826 826 827 // Get profile if available 827 828 var profile models.Profile 828 - s.backend.db.Find(&profile, "repo = ?", r.ID) 829 + s.db.Find(&profile, "repo = ?", r.ID) 829 830 830 831 var prof *bsky.ActorProfile 831 832 if len(profile.Raw) > 0 { ··· 834 835 prof = &p 835 836 } 836 837 } else { 837 - s.addMissingProfile(ctx, r.Did) 838 + s.backend.TrackMissingRecord(r.Did, false) 838 839 } 839 840 840 841 users = append(users, engagementUser{ ··· 932 933 query := `SELECT * FROM notifications WHERE "for" = ?` 933 934 if cursorID > 0 { 934 935 query += ` AND id < ?` 935 - if err := s.backend.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, cursorID, limit).Scan(&notifications).Error; err != nil { 936 + if err := s.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, cursorID, limit).Scan(&notifications).Error; err != nil { 936 937 return err 937 938 } 938 939 } else { 939 - if err := s.backend.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, limit).Scan(&notifications).Error; err != nil { 940 + if err := s.db.Raw(query+" ORDER BY created_at DESC LIMIT ?", s.myrepo.ID, limit).Scan(&notifications).Error; err != nil { 940 941 return err 941 942 } 942 943 } ··· 945 946 results := []notificationResponse{} 946 947 for _, notif := range notifications { 947 948 // Get author info 948 - author, err := s.backend.getRepoByID(ctx, notif.Author) 949 + author, err := s.backend.GetRepoByID(ctx, notif.Author) 949 950 if err != nil { 950 951 slog.Error("failed to get repo for notification author", "error", err) 951 952 continue ··· 966 967 } 967 968 968 969 // Try to get source post preview for reply/mention notifications 969 - if notif.Kind == NotifKindReply || notif.Kind == NotifKindMention { 970 + if notif.Kind == backend.NotifKindReply || notif.Kind == backend.NotifKindMention { 970 971 // Parse URI to get post 971 - p, err := s.backend.getPostByUri(ctx, notif.Source, "*") 972 + p, err := s.backend.GetPostByUri(ctx, notif.Source, "*") 972 973 if err == nil && p.Raw != nil && len(p.Raw) > 0 { 973 974 var fp bsky.FeedPost 974 975 if err := fp.UnmarshalCBOR(bytes.NewReader(p.Raw)); err == nil {
+113 -11
hydration/actor.go
··· 10 10 11 11 "github.com/bluesky-social/indigo/api/bsky" 12 12 "github.com/bluesky-social/indigo/atproto/syntax" 13 + "github.com/whyrusleeping/market/models" 13 14 ) 14 15 15 16 // ActorInfo contains hydrated actor information ··· 21 22 22 23 // HydrateActor hydrates full actor information 23 24 func (h *Hydrator) HydrateActor(ctx context.Context, did string) (*ActorInfo, error) { 25 + ctx, span := tracer.Start(ctx, "hydrateActor") 26 + defer span.End() 27 + 24 28 // Look up handle 25 29 resp, err := h.dir.LookupDID(ctx, syntax.DID(did)) 26 30 if err != nil { ··· 60 64 FollowCount int64 61 65 FollowerCount int64 62 66 PostCount int64 67 + ViewerState *bsky.ActorDefs_ViewerState 63 68 } 64 69 65 - func (h *Hydrator) HydrateActorDetailed(ctx context.Context, did string) (*ActorInfoDetailed, error) { 70 + func (h *Hydrator) HydrateActorDetailed(ctx context.Context, did string, viewer string) (*ActorInfoDetailed, error) { 66 71 act, err := h.HydrateActor(ctx, did) 67 72 if err != nil { 68 73 return nil, err ··· 73 78 } 74 79 75 80 var wg sync.WaitGroup 76 - wg.Add(3) 77 - go func() { 78 - defer wg.Done() 81 + wg.Go(func() { 79 82 c, err := h.getFollowCountForUser(ctx, did) 80 83 if err != nil { 81 84 slog.Error("failed to get follow count", "did", did, "error", err) 82 85 } 83 86 actd.FollowCount = c 84 - }() 85 - go func() { 86 - defer wg.Done() 87 + }) 88 + wg.Go(func() { 87 89 c, err := h.getFollowerCountForUser(ctx, did) 88 90 if err != nil { 89 91 slog.Error("failed to get follower count", "did", did, "error", err) 90 92 } 91 93 actd.FollowerCount = c 92 - }() 93 - go func() { 94 - defer wg.Done() 94 + }) 95 + wg.Go(func() { 95 96 c, err := h.getPostCountForUser(ctx, did) 96 97 if err != nil { 97 98 slog.Error("failed to get post count", "did", did, "error", err) 98 99 } 99 100 actd.PostCount = c 100 - }() 101 + }) 102 + 103 + if viewer != "" { 104 + wg.Go(func() { 105 + vs, err := h.getProfileViewerState(ctx, did, viewer) 106 + if err != nil { 107 + slog.Error("failed to get viewer state", "did", did, "viewer", viewer, "error", err) 108 + } 109 + actd.ViewerState = vs 110 + }) 111 + } 112 + 101 113 wg.Wait() 102 114 103 115 return &actd, nil 116 + } 117 + 118 + func (h *Hydrator) getProfileViewerState(ctx context.Context, did, viewer string) (*bsky.ActorDefs_ViewerState, error) { 119 + vs := &bsky.ActorDefs_ViewerState{} 120 + 121 + var wg sync.WaitGroup 122 + 123 + // Check if viewer is blocked by the target account 124 + wg.Go(func() { 125 + blockedBy, err := h.getBlockPair(ctx, did, viewer) 126 + if err != nil { 127 + slog.Error("failed to get blockedBy relationship", "did", did, "viewer", viewer, "error", err) 128 + return 129 + } 130 + 131 + if blockedBy != nil { 132 + v := true 133 + vs.BlockedBy = &v 134 + } 135 + }) 136 + 137 + // Check if viewer is blocking the target account 138 + wg.Go(func() { 139 + blocking, err := h.getBlockPair(ctx, viewer, did) 140 + if err != nil { 141 + slog.Error("failed to get blocking relationship", "did", did, "viewer", viewer, "error", err) 142 + return 143 + } 144 + 145 + if blocking != nil { 146 + uri := fmt.Sprintf("at://%s/app.bsky.graph.block/%s", viewer, blocking.Rkey) 147 + vs.Blocking = &uri 148 + } 149 + }) 150 + 151 + // Check if viewer is following the target account 152 + wg.Go(func() { 153 + following, err := h.getFollowPair(ctx, viewer, did) 154 + if err != nil { 155 + slog.Error("failed to get following relationship", "did", did, "viewer", viewer, "error", err) 156 + return 157 + } 158 + 159 + if following != nil { 160 + uri := fmt.Sprintf("at://%s/app.bsky.graph.follow/%s", viewer, following.Rkey) 161 + vs.Following = &uri 162 + } 163 + }) 164 + 165 + // Check if target account is following the viewer 166 + wg.Go(func() { 167 + followedBy, err := h.getFollowPair(ctx, did, viewer) 168 + if err != nil { 169 + slog.Error("failed to get followedBy relationship", "did", did, "viewer", viewer, "error", err) 170 + return 171 + } 172 + 173 + if followedBy != nil { 174 + uri := fmt.Sprintf("at://%s/app.bsky.graph.follow/%s", did, followedBy.Rkey) 175 + vs.FollowedBy = &uri 176 + } 177 + }) 178 + 179 + wg.Wait() 180 + 181 + return vs, nil 182 + } 183 + 184 + func (h *Hydrator) getBlockPair(ctx context.Context, a, b string) (*models.Block, error) { 185 + var blk models.Block 186 + if err := h.db.Raw("SELECT * FROM blocks WHERE author = (SELECT id FROM repos WHERE did = ?) AND subject = (SELECT id FROM repos WHERE did = ?)", a, b).Scan(&blk).Error; err != nil { 187 + return nil, err 188 + } 189 + if blk.ID == 0 { 190 + return nil, nil 191 + } 192 + 193 + return &blk, nil 194 + } 195 + 196 + func (h *Hydrator) getFollowPair(ctx context.Context, a, b string) (*models.Follow, error) { 197 + var fol models.Follow 198 + if err := h.db.Raw("SELECT * FROM follows WHERE author = (SELECT id FROM repos WHERE did = ?) AND subject = (SELECT id FROM repos WHERE did = ?)", a, b).Scan(&fol).Error; err != nil { 199 + return nil, err 200 + } 201 + if fol.ID == 0 { 202 + return nil, nil 203 + } 204 + 205 + return &fol, nil 104 206 } 105 207 106 208 func (h *Hydrator) getFollowCountForUser(ctx context.Context, did string) (int64, error) {
+10 -18
hydration/hydrator.go
··· 2 2 3 3 import ( 4 4 "github.com/bluesky-social/indigo/atproto/identity" 5 + "github.com/whyrusleeping/konbini/backend" 5 6 "gorm.io/gorm" 6 7 ) 7 8 8 9 // Hydrator handles data hydration from the database 9 10 type Hydrator struct { 10 - db *gorm.DB 11 - dir identity.Directory 12 - 13 - missingRecordCallback func(string, bool) 11 + db *gorm.DB 12 + dir identity.Directory 13 + backend *backend.PostgresBackend 14 14 } 15 15 16 16 // NewHydrator creates a new Hydrator 17 - func NewHydrator(db *gorm.DB, dir identity.Directory) *Hydrator { 17 + func NewHydrator(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Hydrator { 18 18 return &Hydrator{ 19 - db: db, 20 - dir: dir, 19 + db: db, 20 + dir: dir, 21 + backend: backend, 21 22 } 22 23 } 23 24 24 - // SetMissingRecordCallback sets the callback for when a record is missing 25 - // The callback receives an identifier which can be: 26 - // - A DID (e.g., "did:plc:...") for actors/profiles 27 - // - An AT-URI (e.g., "at://did:plc:.../app.bsky.feed.post/...") for posts 28 - // - An AT-URI (e.g., "at://did:plc:.../app.bsky.feed.generator/...") for feed generators 29 - func (h *Hydrator) SetMissingRecordCallback(fn func(string, bool)) { 30 - h.missingRecordCallback = fn 31 - } 32 - 33 25 // AddMissingRecord reports a missing record that needs to be fetched 34 26 func (h *Hydrator) AddMissingRecord(identifier string, wait bool) { 35 - if h.missingRecordCallback != nil { 36 - h.missingRecordCallback(identifier, wait) 27 + if h.backend != nil { 28 + h.backend.TrackMissingRecord(identifier, wait) 37 29 } 38 30 } 39 31
+388 -39
hydration/post.go
··· 5 5 "context" 6 6 "fmt" 7 7 "log/slog" 8 + "sync" 8 9 9 10 "github.com/bluesky-social/indigo/api/bsky" 11 + "github.com/bluesky-social/indigo/lex/util" 12 + "github.com/whyrusleeping/market/models" 13 + "go.opentelemetry.io/otel" 10 14 ) 11 15 16 + var tracer = otel.Tracer("hydrator") 17 + 12 18 // PostInfo contains hydrated post information 13 19 type PostInfo struct { 20 + ID uint 14 21 URI string 15 22 Cid string 16 23 Post *bsky.FeedPost ··· 22 29 RepostCount int 23 30 ReplyCount int 24 31 ViewerLike string // URI of viewer's like, if any 32 + 33 + EmbedInfo *bsky.FeedDefs_PostView_Embed 25 34 } 26 35 27 36 const fakeCid = "bafyreiapw4hagb5ehqgoeho4v23vf7fhlqey4b7xvjpy76krgkqx7xlolu" 28 37 29 38 // HydratePost hydrates a single post by URI 30 39 func (h *Hydrator) HydratePost(ctx context.Context, uri string, viewerDID string) (*PostInfo, error) { 31 - // Query post from database 32 - var dbPost struct { 33 - ID uint 34 - Cid string 35 - Raw []byte 36 - NotFound bool 37 - ReplyTo uint 38 - ReplyToUsr uint 39 - InThread uint 40 - AuthorID uint 40 + ctx, span := tracer.Start(ctx, "hydratePost") 41 + defer span.End() 42 + 43 + p, err := h.backend.GetPostByUri(ctx, uri, "*") 44 + if err != nil { 45 + return nil, err 41 46 } 42 47 43 - err := h.db.Raw(` 44 - SELECT p.id, p.cid, p.raw, p.not_found, p.reply_to, p.reply_to_usr, p.in_thread, p.author as author_id 45 - FROM posts p 46 - WHERE p.id = ( 47 - SELECT id FROM posts 48 - WHERE author = (SELECT id FROM repos WHERE did = ?) 49 - AND rkey = ? 50 - ) 51 - `, extractDIDFromURI(uri), extractRkeyFromURI(uri)).Scan(&dbPost).Error 48 + return h.HydratePostDB(ctx, uri, p, viewerDID) 49 + } 52 50 51 + func (h *Hydrator) HydratePostDB(ctx context.Context, uri string, dbPost *models.Post, viewerDID string) (*PostInfo, error) { 52 + autoFetch, _ := ctx.Value("auto-fetch").(bool) 53 + 54 + authorDid := extractDIDFromURI(uri) 55 + r, err := h.backend.GetOrCreateRepo(ctx, authorDid) 53 56 if err != nil { 54 - return nil, fmt.Errorf("failed to query post: %w", err) 57 + return nil, err 55 58 } 56 59 57 60 if dbPost.NotFound || len(dbPost.Raw) == 0 { 58 - return nil, fmt.Errorf("post not found") 61 + if autoFetch { 62 + h.AddMissingRecord(uri, true) 63 + if err := h.db.Raw(`SELECT * FROM posts WHERE author = ? AND rkey = ?`, r.ID, extractRkeyFromURI(uri)).Scan(&dbPost).Error; err != nil { 64 + return nil, fmt.Errorf("failed to query post: %w", err) 65 + } 66 + if dbPost.NotFound || len(dbPost.Raw) == 0 { 67 + return nil, fmt.Errorf("post not found") 68 + } 69 + } else { 70 + return nil, fmt.Errorf("post not found") 71 + } 59 72 } 60 73 61 74 // Unmarshal post record ··· 64 77 return nil, fmt.Errorf("failed to unmarshal post: %w", err) 65 78 } 66 79 67 - // Get author DID 68 - var authorDID string 69 - h.db.Raw("SELECT did FROM repos WHERE id = ?", dbPost.AuthorID).Scan(&authorDID) 80 + var wg sync.WaitGroup 81 + 82 + authorDID := r.Did 70 83 71 84 // Get engagement counts 72 85 var likes, reposts, replies int 73 - h.db.Raw("SELECT COUNT(*) FROM likes WHERE subject = ?", dbPost.ID).Scan(&likes) 74 - h.db.Raw("SELECT COUNT(*) FROM reposts WHERE subject = ?", dbPost.ID).Scan(&reposts) 75 - h.db.Raw("SELECT COUNT(*) FROM posts WHERE reply_to = ?", dbPost.ID).Scan(&replies) 86 + wg.Go(func() { 87 + _, span := tracer.Start(ctx, "likeCounts") 88 + defer span.End() 89 + h.db.Raw("SELECT COUNT(*) FROM likes WHERE subject = ?", dbPost.ID).Scan(&likes) 90 + }) 91 + wg.Go(func() { 92 + _, span := tracer.Start(ctx, "repostCounts") 93 + defer span.End() 94 + h.db.Raw("SELECT COUNT(*) FROM reposts WHERE subject = ?", dbPost.ID).Scan(&reposts) 95 + }) 96 + wg.Go(func() { 97 + _, span := tracer.Start(ctx, "replyCounts") 98 + defer span.End() 99 + h.db.Raw("SELECT COUNT(*) FROM posts WHERE reply_to = ?", dbPost.ID).Scan(&replies) 100 + }) 101 + 102 + // Check if viewer liked this post 103 + var likeRkey string 104 + if viewerDID != "" { 105 + wg.Go(func() { 106 + _, span := tracer.Start(ctx, "viewerLikeState") 107 + defer span.End() 108 + h.db.Raw(` 109 + SELECT l.rkey FROM likes l 110 + WHERE l.subject = ? 111 + AND l.author = (SELECT id FROM repos WHERE did = ?) 112 + `, dbPost.ID, viewerDID).Scan(&likeRkey) 113 + }) 114 + } 115 + 116 + var ei *bsky.FeedDefs_PostView_Embed 117 + if feedPost.Embed != nil { 118 + wg.Go(func() { 119 + ei = h.formatEmbed(ctx, feedPost.Embed, authorDID, viewerDID) 120 + }) 121 + } 122 + 123 + wg.Wait() 76 124 77 125 info := &PostInfo{ 126 + ID: dbPost.ID, 78 127 URI: uri, 79 128 Cid: dbPost.Cid, 80 129 Post: &feedPost, ··· 85 134 LikeCount: likes, 86 135 RepostCount: reposts, 87 136 ReplyCount: replies, 137 + EmbedInfo: ei, 138 + } 139 + 140 + if likeRkey != "" { 141 + info.ViewerLike = fmt.Sprintf("at://%s/app.bsky.feed.like/%s", viewerDID, likeRkey) 88 142 } 89 143 90 144 if info.Cid == "" { ··· 92 146 info.Cid = fakeCid 93 147 } 94 148 95 - // Check if viewer liked this post 96 - if viewerDID != "" { 97 - var likeRkey string 98 - h.db.Raw(` 99 - SELECT l.rkey FROM likes l 100 - WHERE l.subject = ? 101 - AND l.author = (SELECT id FROM repos WHERE did = ?) 102 - `, dbPost.ID, viewerDID).Scan(&likeRkey) 103 - if likeRkey != "" { 104 - info.ViewerLike = fmt.Sprintf("at://%s/app.bsky.feed.like/%s", viewerDID, likeRkey) 105 - } 106 - } 149 + // Hydrate embed 107 150 108 151 return info, nil 109 152 } ··· 150 193 } 151 194 return "" 152 195 } 196 + 197 + func (h *Hydrator) formatEmbed(ctx context.Context, embed *bsky.FeedPost_Embed, authorDID string, viewerDID string) *bsky.FeedDefs_PostView_Embed { 198 + if embed == nil { 199 + return nil 200 + } 201 + _, span := tracer.Start(ctx, "formatEmbed") 202 + defer span.End() 203 + 204 + result := &bsky.FeedDefs_PostView_Embed{} 205 + 206 + // Handle images 207 + if embed.EmbedImages != nil { 208 + viewImages := make([]*bsky.EmbedImages_ViewImage, len(embed.EmbedImages.Images)) 209 + for i, img := range embed.EmbedImages.Images { 210 + // Convert blob to CDN URLs 211 + fullsize := "" 212 + thumb := "" 213 + if img.Image != nil { 214 + // CDN URL format for feed images 215 + cid := img.Image.Ref.String() 216 + fullsize = fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@jpeg", authorDID, cid) 217 + thumb = fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid) 218 + } 219 + 220 + viewImages[i] = &bsky.EmbedImages_ViewImage{ 221 + Alt: img.Alt, 222 + AspectRatio: img.AspectRatio, 223 + Fullsize: fullsize, 224 + Thumb: thumb, 225 + } 226 + } 227 + result.EmbedImages_View = &bsky.EmbedImages_View{ 228 + LexiconTypeID: "app.bsky.embed.images#view", 229 + Images: viewImages, 230 + } 231 + return result 232 + } 233 + 234 + // Handle external links 235 + if embed.EmbedExternal != nil && embed.EmbedExternal.External != nil { 236 + // Convert blob thumb to CDN URL if present 237 + var thumbURL *string 238 + if embed.EmbedExternal.External.Thumb != nil { 239 + // CDN URL for external link thumbnails 240 + cid := embed.EmbedExternal.External.Thumb.Ref.String() 241 + url := fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid) 242 + thumbURL = &url 243 + } 244 + 245 + result.EmbedExternal_View = &bsky.EmbedExternal_View{ 246 + LexiconTypeID: "app.bsky.embed.external#view", 247 + External: &bsky.EmbedExternal_ViewExternal{ 248 + Uri: embed.EmbedExternal.External.Uri, 249 + Title: embed.EmbedExternal.External.Title, 250 + Description: embed.EmbedExternal.External.Description, 251 + Thumb: thumbURL, 252 + }, 253 + } 254 + return result 255 + } 256 + 257 + // Handle video 258 + if embed.EmbedVideo != nil && embed.EmbedVideo.Video != nil { 259 + cid := embed.EmbedVideo.Video.Ref.String() 260 + // URL-encode the DID (replace : with %3A) 261 + encodedDID := "" 262 + for _, ch := range authorDID { 263 + if ch == ':' { 264 + encodedDID += "%3A" 265 + } else { 266 + encodedDID += string(ch) 267 + } 268 + } 269 + 270 + playlist := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/playlist.m3u8", encodedDID, cid) 271 + thumbnail := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/thumbnail.jpg", encodedDID, cid) 272 + 273 + result.EmbedVideo_View = &bsky.EmbedVideo_View{ 274 + LexiconTypeID: "app.bsky.embed.video#view", 275 + Cid: cid, 276 + Playlist: playlist, 277 + Thumbnail: &thumbnail, 278 + Alt: embed.EmbedVideo.Alt, 279 + AspectRatio: embed.EmbedVideo.AspectRatio, 280 + } 281 + return result 282 + } 283 + 284 + // Handle record (quote posts, etc.) 285 + if embed.EmbedRecord != nil && embed.EmbedRecord.Record != nil { 286 + rec := embed.EmbedRecord.Record 287 + 288 + result.EmbedRecord_View = &bsky.EmbedRecord_View{ 289 + LexiconTypeID: "app.bsky.embed.record#view", 290 + Record: h.hydrateEmbeddedRecord(ctx, rec.Uri, viewerDID), 291 + } 292 + return result 293 + } 294 + 295 + // Handle record with media (quote post with images/external) 296 + if embed.EmbedRecordWithMedia != nil { 297 + recordView := &bsky.EmbedRecordWithMedia_View{ 298 + LexiconTypeID: "app.bsky.embed.recordWithMedia#view", 299 + } 300 + 301 + // Hydrate the record part 302 + if embed.EmbedRecordWithMedia.Record != nil && embed.EmbedRecordWithMedia.Record.Record != nil { 303 + recordView.Record = &bsky.EmbedRecord_View{ 304 + LexiconTypeID: "app.bsky.embed.record#view", 305 + Record: h.hydrateEmbeddedRecord(ctx, embed.EmbedRecordWithMedia.Record.Record.Uri, viewerDID), 306 + } 307 + } 308 + 309 + // Hydrate the media part (images or external) 310 + if embed.EmbedRecordWithMedia.Media != nil { 311 + if embed.EmbedRecordWithMedia.Media.EmbedImages != nil { 312 + viewImages := make([]*bsky.EmbedImages_ViewImage, len(embed.EmbedRecordWithMedia.Media.EmbedImages.Images)) 313 + for i, img := range embed.EmbedRecordWithMedia.Media.EmbedImages.Images { 314 + fullsize := "" 315 + thumb := "" 316 + if img.Image != nil { 317 + cid := img.Image.Ref.String() 318 + fullsize = fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@jpeg", authorDID, cid) 319 + thumb = fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid) 320 + } 321 + 322 + viewImages[i] = &bsky.EmbedImages_ViewImage{ 323 + Alt: img.Alt, 324 + AspectRatio: img.AspectRatio, 325 + Fullsize: fullsize, 326 + Thumb: thumb, 327 + } 328 + } 329 + recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{ 330 + EmbedImages_View: &bsky.EmbedImages_View{ 331 + LexiconTypeID: "app.bsky.embed.images#view", 332 + Images: viewImages, 333 + }, 334 + } 335 + } else if embed.EmbedRecordWithMedia.Media.EmbedExternal != nil && embed.EmbedRecordWithMedia.Media.EmbedExternal.External != nil { 336 + var thumbURL *string 337 + if embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Thumb != nil { 338 + cid := embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Thumb.Ref.String() 339 + url := fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid) 340 + thumbURL = &url 341 + } 342 + 343 + recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{ 344 + EmbedExternal_View: &bsky.EmbedExternal_View{ 345 + LexiconTypeID: "app.bsky.embed.external#view", 346 + External: &bsky.EmbedExternal_ViewExternal{ 347 + Uri: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Uri, 348 + Title: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Title, 349 + Description: embed.EmbedRecordWithMedia.Media.EmbedExternal.External.Description, 350 + Thumb: thumbURL, 351 + }, 352 + }, 353 + } 354 + } else if embed.EmbedRecordWithMedia.Media.EmbedVideo != nil && embed.EmbedRecordWithMedia.Media.EmbedVideo.Video != nil { 355 + cid := embed.EmbedRecordWithMedia.Media.EmbedVideo.Video.Ref.String() 356 + // URL-encode the DID (replace : with %3A) 357 + encodedDID := "" 358 + for _, ch := range authorDID { 359 + if ch == ':' { 360 + encodedDID += "%3A" 361 + } else { 362 + encodedDID += string(ch) 363 + } 364 + } 365 + 366 + playlist := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/playlist.m3u8", encodedDID, cid) 367 + thumbnail := fmt.Sprintf("https://video.bsky.app/watch/%s/%s/thumbnail.jpg", encodedDID, cid) 368 + 369 + recordView.Media = &bsky.EmbedRecordWithMedia_View_Media{ 370 + EmbedVideo_View: &bsky.EmbedVideo_View{ 371 + LexiconTypeID: "app.bsky.embed.video#view", 372 + Cid: cid, 373 + Playlist: playlist, 374 + Thumbnail: &thumbnail, 375 + Alt: embed.EmbedRecordWithMedia.Media.EmbedVideo.Alt, 376 + AspectRatio: embed.EmbedRecordWithMedia.Media.EmbedVideo.AspectRatio, 377 + }, 378 + } 379 + } 380 + } 381 + 382 + result.EmbedRecordWithMedia_View = recordView 383 + return result 384 + } 385 + 386 + return nil 387 + } 388 + 389 + // hydrateEmbeddedRecord hydrates an embedded record (for quote posts, etc.) 390 + func (h *Hydrator) hydrateEmbeddedRecord(ctx context.Context, uri string, viewerDID string) *bsky.EmbedRecord_View_Record { 391 + ctx, span := tracer.Start(ctx, "hydrateEmbeddedRecord") 392 + defer span.End() 393 + 394 + // Check if it's a post URI 395 + if !isPostURI(uri) { 396 + // Could be a feed generator, list, labeler, or starter pack 397 + // For now, return not found for non-post embeds 398 + return &bsky.EmbedRecord_View_Record{ 399 + EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{ 400 + LexiconTypeID: "app.bsky.embed.record#viewNotFound", 401 + Uri: uri, 402 + }, 403 + } 404 + } 405 + 406 + // Try to hydrate the post 407 + quotedPost, err := h.HydratePost(ctx, uri, viewerDID) 408 + if err != nil { 409 + // Post not found 410 + return &bsky.EmbedRecord_View_Record{ 411 + EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{ 412 + LexiconTypeID: "app.bsky.embed.record#viewNotFound", 413 + Uri: uri, 414 + NotFound: true, 415 + }, 416 + } 417 + } 418 + 419 + // Hydrate the author 420 + authorInfo, err := h.HydrateActor(ctx, quotedPost.Author) 421 + if err != nil { 422 + // Author not found, treat as not found 423 + return &bsky.EmbedRecord_View_Record{ 424 + EmbedRecord_ViewNotFound: &bsky.EmbedRecord_ViewNotFound{ 425 + LexiconTypeID: "app.bsky.embed.record#viewNotFound", 426 + Uri: uri, 427 + NotFound: true, 428 + }, 429 + } 430 + } 431 + 432 + // TODO: Check if viewer has blocked or is blocked by the author 433 + // For now, just return the record view 434 + 435 + // Build the author profile view 436 + authorView := &bsky.ActorDefs_ProfileViewBasic{ 437 + Did: authorInfo.DID, 438 + Handle: authorInfo.Handle, 439 + } 440 + if authorInfo.Profile != nil { 441 + if authorInfo.Profile.DisplayName != nil && *authorInfo.Profile.DisplayName != "" { 442 + authorView.DisplayName = authorInfo.Profile.DisplayName 443 + } 444 + if authorInfo.Profile.Avatar != nil { 445 + avatarURL := fmt.Sprintf("https://cdn.bsky.app/img/avatar_thumbnail/plain/%s/%s@jpeg", authorInfo.DID, authorInfo.Profile.Avatar.Ref.String()) 446 + authorView.Avatar = &avatarURL 447 + } 448 + } 449 + 450 + // Build the embedded post view 451 + embedView := &bsky.EmbedRecord_ViewRecord{ 452 + LexiconTypeID: "app.bsky.embed.record#viewRecord", 453 + Uri: quotedPost.URI, 454 + Cid: quotedPost.Cid, 455 + Author: authorView, 456 + Value: &util.LexiconTypeDecoder{ 457 + Val: quotedPost.Post, 458 + }, 459 + IndexedAt: quotedPost.Post.CreatedAt, 460 + } 461 + 462 + // Add engagement counts 463 + if quotedPost.LikeCount > 0 { 464 + lc := int64(quotedPost.LikeCount) 465 + embedView.LikeCount = &lc 466 + } 467 + if quotedPost.RepostCount > 0 { 468 + rc := int64(quotedPost.RepostCount) 469 + embedView.RepostCount = &rc 470 + } 471 + if quotedPost.ReplyCount > 0 { 472 + rpc := int64(quotedPost.ReplyCount) 473 + embedView.ReplyCount = &rpc 474 + } 475 + 476 + // Note: We don't recursively hydrate embeds for quoted posts to avoid deep nesting 477 + // The official app also doesn't show embeds within quoted posts 478 + 479 + return &bsky.EmbedRecord_View_Record{ 480 + EmbedRecord_ViewRecord: embedView, 481 + } 482 + } 483 + 484 + // isPostURI checks if a URI is a post URI 485 + func isPostURI(uri string) bool { 486 + return len(uri) > 5 && uri[:5] == "at://" && ( 487 + // Check if it contains /app.bsky.feed.post/ 488 + len(uri) > 25 && uri[len(uri)-25:len(uri)-12] == "/app.bsky.feed.post/" || 489 + // More flexible check 490 + contains(uri, "/app.bsky.feed.post/")) 491 + } 492 + 493 + // contains checks if a string contains a substring 494 + func contains(s, substr string) bool { 495 + for i := 0; i <= len(s)-len(substr); i++ { 496 + if s[i:i+len(substr)] == substr { 497 + return true 498 + } 499 + } 500 + return false 501 + }
+10
hydration/utils.go
··· 5 5 "fmt" 6 6 7 7 "github.com/bluesky-social/indigo/atproto/syntax" 8 + "github.com/whyrusleeping/market/models" 8 9 ) 9 10 10 11 func (h *Hydrator) NormalizeUri(ctx context.Context, uri string) (string, error) { ··· 27 28 28 29 return fmt.Sprintf("at://%s/%s/%s", did, puri.Collection().String(), puri.RecordKey().String()), nil 29 30 } 31 + 32 + func (h *Hydrator) UriForPost(ctx context.Context, p *models.Post) (string, error) { 33 + did, err := h.backend.DidFromID(ctx, p.Author) 34 + if err != nil { 35 + return "", err 36 + } 37 + 38 + return fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, p.Rkey), nil 39 + }
+104 -142
main.go
··· 3 3 import ( 4 4 "bytes" 5 5 "context" 6 + "encoding/json" 6 7 "fmt" 7 8 "log" 8 9 "log/slog" ··· 17 18 18 19 "github.com/bluesky-social/indigo/api/atproto" 19 20 "github.com/bluesky-social/indigo/atproto/identity" 21 + "github.com/bluesky-social/indigo/atproto/identity/redisdir" 20 22 "github.com/bluesky-social/indigo/atproto/syntax" 21 - "github.com/bluesky-social/indigo/cmd/relay/stream" 22 - "github.com/bluesky-social/indigo/cmd/relay/stream/schedulers/parallel" 23 23 "github.com/bluesky-social/indigo/repo" 24 24 "github.com/bluesky-social/indigo/util/cliutil" 25 25 xrpclib "github.com/bluesky-social/indigo/xrpc" 26 - "github.com/gorilla/websocket" 27 - lru "github.com/hashicorp/golang-lru/v2" 28 26 "github.com/ipfs/go-cid" 29 27 "github.com/jackc/pgx/v5/pgxpool" 30 28 "github.com/prometheus/client_golang/prometheus" 31 29 "github.com/prometheus/client_golang/prometheus/promauto" 32 30 "github.com/urfave/cli/v2" 31 + "github.com/whyrusleeping/konbini/backend" 33 32 "github.com/whyrusleeping/konbini/xrpc" 33 + "go.opentelemetry.io/otel" 34 + "go.opentelemetry.io/otel/attribute" 35 + "go.opentelemetry.io/otel/exporters/jaeger" 36 + "go.opentelemetry.io/otel/sdk/resource" 37 + tracesdk "go.opentelemetry.io/otel/sdk/trace" 38 + semconv "go.opentelemetry.io/otel/semconv/v1.20.0" 39 + "gorm.io/gorm" 34 40 "gorm.io/gorm/logger" 35 41 36 42 . "github.com/whyrusleeping/konbini/models" 37 43 ) 38 44 39 - var handleOpHist = promauto.NewHistogramVec(prometheus.HistogramOpts{ 40 - Name: "handle_op_duration", 41 - Help: "A histogram of op handling durations", 42 - Buckets: prometheus.ExponentialBuckets(1, 2, 15), 43 - }, []string{"op", "collection"}) 44 - 45 45 var firehoseCursorGauge = promauto.NewGaugeVec(prometheus.GaugeOpts{ 46 46 Name: "firehose_cursor", 47 47 }, []string{"stage"}) ··· 56 56 Name: "db-url", 57 57 EnvVars: []string{"DATABASE_URL"}, 58 58 }, 59 + &cli.BoolFlag{ 60 + Name: "jaeger", 61 + }, 59 62 &cli.StringFlag{ 60 63 Name: "handle", 61 64 }, ··· 63 66 Name: "max-db-connections", 64 67 Value: runtime.NumCPU(), 65 68 }, 69 + &cli.StringFlag{ 70 + Name: "redis-url", 71 + }, 72 + &cli.StringFlag{ 73 + Name: "sync-config", 74 + }, 66 75 } 67 76 app.Action = func(cctx *cli.Context) error { 68 77 db, err := cliutil.SetupDatabase(cctx.String("db-url"), cctx.Int("max-db-connections")) ··· 77 86 Colorful: true, 78 87 }) 79 88 89 + if cctx.Bool("jaeger") { 90 + // Use Jaeger native exporter sending to port 14268 91 + jaegerUrl := "http://localhost:14268/api/traces" 92 + exp, err := jaeger.New(jaeger.WithCollectorEndpoint(jaeger.WithEndpoint(jaegerUrl))) 93 + if err != nil { 94 + return err 95 + } 96 + 97 + env := os.Getenv("ENV") 98 + if env == "" { 99 + env = "development" 100 + } 101 + 102 + tp := tracesdk.NewTracerProvider( 103 + // Always be sure to batch in production. 104 + tracesdk.WithBatcher(exp), 105 + // Record information about this application in a Resource. 106 + tracesdk.WithResource(resource.NewWithAttributes( 107 + semconv.SchemaURL, 108 + semconv.ServiceNameKey.String("konbini"), 109 + attribute.String("env", env), // DataDog 110 + attribute.String("environment", env), // Others 111 + attribute.Int64("ID", 1), 112 + )), 113 + ) 114 + 115 + otel.SetTracerProvider(tp) 116 + } 117 + 80 118 db.AutoMigrate(Repo{}) 81 119 db.AutoMigrate(Post{}) 82 120 db.AutoMigrate(Follow{}) ··· 92 130 db.AutoMigrate(Image{}) 93 131 db.AutoMigrate(PostGate{}) 94 132 db.AutoMigrate(StarterPack{}) 95 - db.AutoMigrate(SyncInfo{}) 133 + db.AutoMigrate(backend.SyncInfo{}) 96 134 db.AutoMigrate(Notification{}) 135 + db.AutoMigrate(NotificationSeen{}) 97 136 db.AutoMigrate(SequenceTracker{}) 137 + db.Exec("CREATE INDEX IF NOT EXISTS reposts_subject_idx ON reposts (subject)") 138 + db.Exec("CREATE INDEX IF NOT EXISTS posts_reply_to_idx ON posts (reply_to)") 139 + db.Exec("CREATE INDEX IF NOT EXISTS posts_in_thread_idx ON posts (in_thread)") 98 140 99 141 ctx := context.TODO() 100 - 101 - rc, _ := lru.New2Q[string, *Repo](1_000_000) 102 - pc, _ := lru.New2Q[string, cachedPostInfo](1_000_000) 103 - revc, _ := lru.New2Q[uint, string](1_000_000) 104 142 105 143 cfg, err := pgxpool.ParseConfig(cctx.String("db-url")) 106 144 if err != nil { ··· 125 163 126 164 dir := identity.DefaultDirectory() 127 165 166 + if redisURL := cctx.String("redis-url"); redisURL != "" { 167 + rdir, err := redisdir.NewRedisDirectory(dir, redisURL, time.Minute, time.Second*10, time.Second*10, 100_000) 168 + if err != nil { 169 + return err 170 + } 171 + dir = rdir 172 + } 173 + 128 174 resp, err := dir.LookupHandle(ctx, syntax.Handle(handle)) 129 175 if err != nil { 130 176 return err ··· 155 201 client: cc, 156 202 dir: dir, 157 203 158 - missingRecords: make(chan MissingRecord, 1024), 204 + db: db, 159 205 } 160 - fmt.Println("MY DID: ", s.mydid) 161 206 162 - pgb := &PostgresBackend{ 163 - relevantDids: make(map[string]bool), 164 - s: s, 165 - db: db, 166 - postInfoCache: pc, 167 - repoCache: rc, 168 - revCache: revc, 169 - pgx: pool, 207 + pgb, err := backend.NewPostgresBackend(mydid, db, pool, cc, dir) 208 + if err != nil { 209 + return err 170 210 } 211 + 171 212 s.backend = pgb 172 213 173 - myrepo, err := s.backend.getOrCreateRepo(ctx, mydid) 214 + myrepo, err := s.backend.GetOrCreateRepo(ctx, mydid) 174 215 if err != nil { 175 216 return fmt.Errorf("failed to get repo record for our own did: %w", err) 176 217 } 177 218 s.myrepo = myrepo 178 219 179 - if err := s.backend.loadRelevantDids(); err != nil { 220 + if err := s.backend.LoadRelevantDids(); err != nil { 180 221 return fmt.Errorf("failed to load relevant dids set: %w", err) 181 222 } 182 223 ··· 200 241 http.ListenAndServe(":4445", nil) 201 242 }() 202 243 203 - go s.missingRecordFetcher() 244 + sc := SyncConfig{ 245 + Backends: []SyncBackend{ 246 + { 247 + Type: "firehose", 248 + Host: "bsky.network", 249 + }, 250 + }, 251 + } 204 252 205 - seqno, err := loadLastSeq(db, "firehose_seq") 206 - if err != nil { 207 - fmt.Println("failed to load sequence number, starting over", err) 253 + if scfn := cctx.String("sync-config"); scfn != "" { 254 + { 255 + scfi, err := os.Open(scfn) 256 + if err != nil { 257 + return err 258 + } 259 + defer scfi.Close() 260 + 261 + var lsc SyncConfig 262 + if err := json.NewDecoder(scfi).Decode(&lsc); err != nil { 263 + return err 264 + } 265 + sc = lsc 266 + } 208 267 } 209 268 210 - return s.startLiveTail(ctx, int(seqno), 10, 20) 269 + /* 270 + sc.Backends[0] = SyncBackend{ 271 + Type: "jetstream", 272 + Host: "jetstream1.us-west.bsky.network", 273 + } 274 + */ 275 + 276 + return s.StartSyncEngine(ctx, &sc) 277 + 211 278 } 212 279 213 280 app.RunAndExitOnError() 214 281 } 215 282 216 283 type Server struct { 217 - backend *PostgresBackend 284 + backend *backend.PostgresBackend 218 285 219 286 dir identity.Directory 220 287 ··· 225 292 seqLk sync.Mutex 226 293 lastSeq int64 227 294 228 - mpLk sync.Mutex 229 - missingRecords chan MissingRecord 295 + mpLk sync.Mutex 296 + 297 + db *gorm.DB 230 298 } 231 299 232 300 func (s *Server) getXrpcClient() (*xrpclib.Client, error) { ··· 234 302 return s.client, nil 235 303 } 236 304 237 - func (s *Server) startLiveTail(ctx context.Context, curs int, parWorkers, maxQ int) error { 238 - slog.Info("starting live tail") 239 - 240 - // Connect to the Relay websocket 241 - urlStr := fmt.Sprintf("wss://bsky.network/xrpc/com.atproto.sync.subscribeRepos?cursor=%d", curs) 242 - 243 - d := websocket.DefaultDialer 244 - con, _, err := d.Dial(urlStr, http.Header{ 245 - "User-Agent": []string{"market/0.0.1"}, 246 - }) 247 - if err != nil { 248 - return fmt.Errorf("failed to connect to relay: %w", err) 249 - } 250 - 251 - var lelk sync.Mutex 252 - lastEvent := time.Now() 253 - 254 - go func() { 255 - for range time.Tick(time.Second) { 256 - lelk.Lock() 257 - let := lastEvent 258 - lelk.Unlock() 259 - 260 - if time.Since(let) > time.Second*30 { 261 - slog.Error("firehose connection timed out") 262 - con.Close() 263 - return 264 - } 265 - 266 - } 267 - 268 - }() 269 - 270 - var cclk sync.Mutex 271 - var completeCursor int64 272 - 273 - rsc := &stream.RepoStreamCallbacks{ 274 - RepoCommit: func(evt *atproto.SyncSubscribeRepos_Commit) error { 275 - ctx := context.Background() 276 - 277 - firehoseCursorGauge.WithLabelValues("ingest").Set(float64(evt.Seq)) 278 - 279 - s.seqLk.Lock() 280 - if evt.Seq > s.lastSeq { 281 - curs = int(evt.Seq) 282 - s.lastSeq = evt.Seq 283 - 284 - if evt.Seq%1000 == 0 { 285 - if err := storeLastSeq(s.backend.db, "firehose_seq", evt.Seq); err != nil { 286 - fmt.Println("failed to store seqno: ", err) 287 - } 288 - } 289 - } 290 - s.seqLk.Unlock() 291 - 292 - lelk.Lock() 293 - lastEvent = time.Now() 294 - lelk.Unlock() 295 - 296 - if err := s.backend.HandleEvent(ctx, evt); err != nil { 297 - return fmt.Errorf("handle event (%s,%d): %w", evt.Repo, evt.Seq, err) 298 - } 299 - 300 - cclk.Lock() 301 - if evt.Seq > completeCursor { 302 - completeCursor = evt.Seq 303 - firehoseCursorGauge.WithLabelValues("complete").Set(float64(evt.Seq)) 304 - } 305 - cclk.Unlock() 306 - 307 - return nil 308 - }, 309 - RepoInfo: func(info *atproto.SyncSubscribeRepos_Info) error { 310 - return nil 311 - }, 312 - // TODO: all the other event types 313 - Error: func(errf *stream.ErrorFrame) error { 314 - return fmt.Errorf("error frame: %s: %s", errf.Error, errf.Message) 315 - }, 316 - } 317 - 318 - sched := parallel.NewScheduler(parWorkers, maxQ, con.RemoteAddr().String(), rsc.EventHandler) 319 - 320 - //s.eventScheduler = sched 321 - //s.streamFinished = make(chan struct{}) 322 - 323 - return stream.HandleRepoStream(ctx, con, sched, slog.Default()) 324 - } 325 - 326 305 func (s *Server) resolveAccountIdent(ctx context.Context, acc string) (string, error) { 327 306 unesc, err := url.PathUnescape(acc) 328 307 if err != nil { ··· 342 321 return resp.DID.String(), nil 343 322 } 344 323 345 - const ( 346 - NotifKindReply = "reply" 347 - NotifKindLike = "like" 348 - NotifKindMention = "mention" 349 - NotifKindRepost = "repost" 350 - ) 351 - 352 - func (s *Server) AddNotification(ctx context.Context, forUser, author uint, recordUri string, recordCid cid.Cid, kind string) error { 353 - return s.backend.db.Create(&Notification{ 354 - For: forUser, 355 - Author: author, 356 - Source: recordUri, 357 - SourceCid: recordCid.String(), 358 - Kind: kind, 359 - }).Error 360 - } 361 - 362 324 func (s *Server) rescanRepo(ctx context.Context, did string) error { 363 325 resp, err := s.dir.LookupDID(ctx, syntax.DID(did)) 364 326 if err != nil { 365 327 return err 366 328 } 367 329 368 - s.backend.addRelevantDid(did) 330 + s.backend.AddRelevantDid(did) 369 331 370 332 c := &xrpclib.Client{ 371 333 Host: resp.PDSEndpoint(),
-234
missing.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "log/slog" 8 - 9 - "github.com/bluesky-social/indigo/api/atproto" 10 - "github.com/bluesky-social/indigo/api/bsky" 11 - "github.com/bluesky-social/indigo/atproto/syntax" 12 - xrpclib "github.com/bluesky-social/indigo/xrpc" 13 - "github.com/ipfs/go-cid" 14 - ) 15 - 16 - type MissingRecordType string 17 - 18 - const ( 19 - MissingRecordTypeProfile MissingRecordType = "profile" 20 - MissingRecordTypePost MissingRecordType = "post" 21 - MissingRecordTypeFeedGenerator MissingRecordType = "feedgenerator" 22 - ) 23 - 24 - type MissingRecord struct { 25 - Type MissingRecordType 26 - Identifier string // DID for profiles, AT-URI for posts/feedgens 27 - Wait bool 28 - 29 - waitch chan struct{} 30 - } 31 - 32 - func (s *Server) addMissingRecord(ctx context.Context, rec MissingRecord) { 33 - if rec.Wait { 34 - rec.waitch = make(chan struct{}) 35 - } 36 - 37 - select { 38 - case s.missingRecords <- rec: 39 - case <-ctx.Done(): 40 - } 41 - 42 - if rec.Wait { 43 - select { 44 - case <-rec.waitch: 45 - case <-ctx.Done(): 46 - } 47 - } 48 - } 49 - 50 - // Legacy methods for backward compatibility 51 - func (s *Server) addMissingProfile(ctx context.Context, did string) { 52 - s.addMissingRecord(ctx, MissingRecord{ 53 - Type: MissingRecordTypeProfile, 54 - Identifier: did, 55 - }) 56 - } 57 - 58 - func (s *Server) addMissingPost(ctx context.Context, uri string) { 59 - slog.Info("adding missing post to fetch queue", "uri", uri) 60 - s.addMissingRecord(ctx, MissingRecord{ 61 - Type: MissingRecordTypePost, 62 - Identifier: uri, 63 - }) 64 - } 65 - 66 - func (s *Server) addMissingFeedGenerator(ctx context.Context, uri string) { 67 - slog.Info("adding missing feed generator to fetch queue", "uri", uri) 68 - s.addMissingRecord(ctx, MissingRecord{ 69 - Type: MissingRecordTypeFeedGenerator, 70 - Identifier: uri, 71 - }) 72 - } 73 - 74 - func (s *Server) missingRecordFetcher() { 75 - for rec := range s.missingRecords { 76 - var err error 77 - switch rec.Type { 78 - case MissingRecordTypeProfile: 79 - err = s.fetchMissingProfile(context.TODO(), rec.Identifier) 80 - case MissingRecordTypePost: 81 - err = s.fetchMissingPost(context.TODO(), rec.Identifier) 82 - case MissingRecordTypeFeedGenerator: 83 - err = s.fetchMissingFeedGenerator(context.TODO(), rec.Identifier) 84 - default: 85 - slog.Error("unknown missing record type", "type", rec.Type) 86 - continue 87 - } 88 - 89 - if err != nil { 90 - slog.Warn("failed to fetch missing record", "type", rec.Type, "identifier", rec.Identifier, "error", err) 91 - } 92 - 93 - if rec.Wait { 94 - close(rec.waitch) 95 - } 96 - } 97 - } 98 - 99 - func (s *Server) fetchMissingProfile(ctx context.Context, did string) error { 100 - s.backend.addRelevantDid(did) 101 - 102 - repo, err := s.backend.getOrCreateRepo(ctx, did) 103 - if err != nil { 104 - return err 105 - } 106 - 107 - resp, err := s.dir.LookupDID(ctx, syntax.DID(did)) 108 - if err != nil { 109 - return err 110 - } 111 - 112 - c := &xrpclib.Client{ 113 - Host: resp.PDSEndpoint(), 114 - } 115 - 116 - rec, err := atproto.RepoGetRecord(ctx, c, "", "app.bsky.actor.profile", did, "self") 117 - if err != nil { 118 - return err 119 - } 120 - 121 - prof, ok := rec.Value.Val.(*bsky.ActorProfile) 122 - if !ok { 123 - return fmt.Errorf("record we got back wasnt a profile somehow") 124 - } 125 - 126 - buf := new(bytes.Buffer) 127 - if err := prof.MarshalCBOR(buf); err != nil { 128 - return err 129 - } 130 - 131 - cc, err := cid.Decode(*rec.Cid) 132 - if err != nil { 133 - return err 134 - } 135 - 136 - return s.backend.HandleUpdateProfile(ctx, repo, "self", "", buf.Bytes(), cc) 137 - } 138 - 139 - func (s *Server) fetchMissingPost(ctx context.Context, uri string) error { 140 - puri, err := syntax.ParseATURI(uri) 141 - if err != nil { 142 - return fmt.Errorf("invalid AT URI: %s", uri) 143 - } 144 - 145 - did := puri.Authority().String() 146 - collection := puri.Collection().String() 147 - rkey := puri.RecordKey().String() 148 - 149 - s.backend.addRelevantDid(did) 150 - 151 - repo, err := s.backend.getOrCreateRepo(ctx, did) 152 - if err != nil { 153 - return err 154 - } 155 - 156 - resp, err := s.dir.LookupDID(ctx, syntax.DID(did)) 157 - if err != nil { 158 - return err 159 - } 160 - 161 - c := &xrpclib.Client{ 162 - Host: resp.PDSEndpoint(), 163 - } 164 - 165 - rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey) 166 - if err != nil { 167 - return err 168 - } 169 - 170 - post, ok := rec.Value.Val.(*bsky.FeedPost) 171 - if !ok { 172 - return fmt.Errorf("record we got back wasn't a post somehow") 173 - } 174 - 175 - buf := new(bytes.Buffer) 176 - if err := post.MarshalCBOR(buf); err != nil { 177 - return err 178 - } 179 - 180 - cc, err := cid.Decode(*rec.Cid) 181 - if err != nil { 182 - return err 183 - } 184 - 185 - return s.backend.HandleCreatePost(ctx, repo, rkey, buf.Bytes(), cc) 186 - } 187 - 188 - func (s *Server) fetchMissingFeedGenerator(ctx context.Context, uri string) error { 189 - puri, err := syntax.ParseATURI(uri) 190 - if err != nil { 191 - return fmt.Errorf("invalid AT URI: %s", uri) 192 - } 193 - 194 - did := puri.Authority().String() 195 - collection := puri.Collection().String() 196 - rkey := puri.RecordKey().String() 197 - s.backend.addRelevantDid(did) 198 - 199 - repo, err := s.backend.getOrCreateRepo(ctx, did) 200 - if err != nil { 201 - return err 202 - } 203 - 204 - resp, err := s.dir.LookupDID(ctx, syntax.DID(did)) 205 - if err != nil { 206 - return err 207 - } 208 - 209 - c := &xrpclib.Client{ 210 - Host: resp.PDSEndpoint(), 211 - } 212 - 213 - rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey) 214 - if err != nil { 215 - return err 216 - } 217 - 218 - feedGen, ok := rec.Value.Val.(*bsky.FeedGenerator) 219 - if !ok { 220 - return fmt.Errorf("record we got back wasn't a feed generator somehow") 221 - } 222 - 223 - buf := new(bytes.Buffer) 224 - if err := feedGen.MarshalCBOR(buf); err != nil { 225 - return err 226 - } 227 - 228 - cc, err := cid.Decode(*rec.Cid) 229 - if err != nil { 230 - return err 231 - } 232 - 233 - return s.backend.HandleCreateFeedGenerator(ctx, repo, rkey, buf.Bytes(), cc) 234 - }
+5
models/models.go
··· 47 47 Key string `gorm:"uniqueIndex"` 48 48 IntVal int64 49 49 } 50 + 51 + type NotificationSeen struct { 52 + Repo uint `gorm:"uniqueindex"` 53 + SeenAt time.Time 54 + }
-437
pgbackend.go
··· 1 - package main 2 - 3 - import ( 4 - "context" 5 - "errors" 6 - "fmt" 7 - "strings" 8 - "time" 9 - 10 - "github.com/bluesky-social/indigo/api/atproto" 11 - "github.com/bluesky-social/indigo/api/bsky" 12 - "github.com/bluesky-social/indigo/atproto/syntax" 13 - "github.com/bluesky-social/indigo/util" 14 - "github.com/jackc/pgx/v5" 15 - "github.com/jackc/pgx/v5/pgconn" 16 - "github.com/whyrusleeping/market/models" 17 - "gorm.io/gorm" 18 - "gorm.io/gorm/clause" 19 - "gorm.io/gorm/logger" 20 - 21 - . "github.com/whyrusleeping/konbini/models" 22 - ) 23 - 24 - func (b *PostgresBackend) getOrCreateRepo(ctx context.Context, did string) (*Repo, error) { 25 - r, ok := b.repoCache.Get(did) 26 - if !ok { 27 - b.reposLk.Lock() 28 - 29 - r, ok = b.repoCache.Get(did) 30 - if !ok { 31 - r = &Repo{} 32 - r.Did = did 33 - b.repoCache.Add(did, r) 34 - } 35 - 36 - b.reposLk.Unlock() 37 - } 38 - 39 - r.Lk.Lock() 40 - defer r.Lk.Unlock() 41 - if r.Setup { 42 - return r, nil 43 - } 44 - 45 - row := b.pgx.QueryRow(ctx, "SELECT id, created_at, did FROM repos WHERE did = $1", did) 46 - 47 - err := row.Scan(&r.ID, &r.CreatedAt, &r.Did) 48 - if err == nil { 49 - // found it! 50 - r.Setup = true 51 - return r, nil 52 - } 53 - 54 - if err != pgx.ErrNoRows { 55 - return nil, err 56 - } 57 - 58 - r.Did = did 59 - if err := b.db.Create(r).Error; err != nil { 60 - return nil, err 61 - } 62 - 63 - r.Setup = true 64 - 65 - return r, nil 66 - } 67 - 68 - func (b *PostgresBackend) getOrCreateList(ctx context.Context, uri string) (*List, error) { 69 - puri, err := util.ParseAtUri(uri) 70 - if err != nil { 71 - return nil, err 72 - } 73 - 74 - r, err := b.getOrCreateRepo(ctx, puri.Did) 75 - if err != nil { 76 - return nil, err 77 - } 78 - 79 - // TODO: needs upsert treatment when we actually find the list 80 - var list List 81 - if err := b.db.FirstOrCreate(&list, map[string]any{ 82 - "author": r.ID, 83 - "rkey": puri.Rkey, 84 - }).Error; err != nil { 85 - return nil, err 86 - } 87 - return &list, nil 88 - } 89 - 90 - type cachedPostInfo struct { 91 - ID uint 92 - Author uint 93 - } 94 - 95 - func (b *PostgresBackend) postIDForUri(ctx context.Context, uri string) (uint, error) { 96 - // getPostByUri implicitly fills the cache 97 - p, err := b.postInfoForUri(ctx, uri) 98 - if err != nil { 99 - return 0, err 100 - } 101 - 102 - return p.ID, nil 103 - } 104 - 105 - func (b *PostgresBackend) postInfoForUri(ctx context.Context, uri string) (cachedPostInfo, error) { 106 - v, ok := b.postInfoCache.Get(uri) 107 - if ok { 108 - return v, nil 109 - } 110 - 111 - // getPostByUri implicitly fills the cache 112 - p, err := b.getOrCreatePostBare(ctx, uri) 113 - if err != nil { 114 - return cachedPostInfo{}, err 115 - } 116 - 117 - return cachedPostInfo{ID: p.ID, Author: p.Author}, nil 118 - } 119 - 120 - func (b *PostgresBackend) tryLoadPostInfo(ctx context.Context, uid uint, rkey string) (*Post, error) { 121 - var p Post 122 - q := "SELECT id, author FROM posts WHERE author = $1 AND rkey = $2" 123 - if err := b.pgx.QueryRow(ctx, q, uid, rkey).Scan(&p.ID, &p.Author); err != nil { 124 - if errors.Is(err, pgx.ErrNoRows) { 125 - return nil, nil 126 - } 127 - return nil, err 128 - } 129 - 130 - return &p, nil 131 - } 132 - 133 - func (b *PostgresBackend) getOrCreatePostBare(ctx context.Context, uri string) (*Post, error) { 134 - puri, err := util.ParseAtUri(uri) 135 - if err != nil { 136 - return nil, err 137 - } 138 - 139 - r, err := b.getOrCreateRepo(ctx, puri.Did) 140 - if err != nil { 141 - return nil, err 142 - } 143 - 144 - post, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey) 145 - if err != nil { 146 - return nil, err 147 - } 148 - 149 - if post == nil { 150 - post = &Post{ 151 - Rkey: puri.Rkey, 152 - Author: r.ID, 153 - NotFound: true, 154 - } 155 - 156 - err := b.pgx.QueryRow(ctx, "INSERT INTO posts (rkey, author, not_found) VALUES ($1, $2, $3) RETURNING id", puri.Rkey, r.ID, true).Scan(&post.ID) 157 - if err != nil { 158 - pgErr, ok := err.(*pgconn.PgError) 159 - if !ok || pgErr.Code != "23505" { 160 - return nil, err 161 - } 162 - 163 - out, err := b.tryLoadPostInfo(ctx, r.ID, puri.Rkey) 164 - if err != nil { 165 - return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err) 166 - } 167 - if out == nil { 168 - return nil, fmt.Errorf("postgres is lying to us: %d %s", r.ID, puri.Rkey) 169 - } 170 - 171 - post = out 172 - } 173 - 174 - } 175 - 176 - b.postInfoCache.Add(uri, cachedPostInfo{ 177 - ID: post.ID, 178 - Author: post.Author, 179 - }) 180 - 181 - return post, nil 182 - } 183 - 184 - func (b *PostgresBackend) getPostByUri(ctx context.Context, uri string, fields string) (*Post, error) { 185 - puri, err := util.ParseAtUri(uri) 186 - if err != nil { 187 - return nil, err 188 - } 189 - 190 - r, err := b.getOrCreateRepo(ctx, puri.Did) 191 - if err != nil { 192 - return nil, err 193 - } 194 - 195 - q := "SELECT " + fields + " FROM posts WHERE author = ? AND rkey = ?" 196 - 197 - var post Post 198 - if err := b.db.Raw(q, r.ID, puri.Rkey).Scan(&post).Error; err != nil { 199 - return nil, err 200 - } 201 - 202 - if post.ID == 0 { 203 - post.Rkey = puri.Rkey 204 - post.Author = r.ID 205 - post.NotFound = true 206 - 207 - if err := b.db.Session(&gorm.Session{ 208 - Logger: logger.Default.LogMode(logger.Silent), 209 - }).Create(&post).Error; err != nil { 210 - if !errors.Is(err, gorm.ErrDuplicatedKey) { 211 - return nil, err 212 - } 213 - if err := b.db.Find(&post, "author = ? AND rkey = ?", r.ID, puri.Rkey).Error; err != nil { 214 - return nil, fmt.Errorf("got duplicate post and still couldnt find it: %w", err) 215 - } 216 - } 217 - 218 - } 219 - 220 - b.postInfoCache.Add(uri, cachedPostInfo{ 221 - ID: post.ID, 222 - Author: post.Author, 223 - }) 224 - 225 - return &post, nil 226 - } 227 - 228 - func (b *PostgresBackend) revForRepo(rr *Repo) (string, error) { 229 - lrev, ok := b.revCache.Get(rr.ID) 230 - if ok { 231 - return lrev, nil 232 - } 233 - 234 - var rev string 235 - if err := b.pgx.QueryRow(context.TODO(), "SELECT COALESCE(rev, '') FROM sync_infos WHERE repo = $1", rr.ID).Scan(&rev); err != nil { 236 - if errors.Is(err, pgx.ErrNoRows) { 237 - return "", nil 238 - } 239 - return "", err 240 - } 241 - 242 - if rev != "" { 243 - b.revCache.Add(rr.ID, rev) 244 - } 245 - return rev, nil 246 - } 247 - 248 - func (b *PostgresBackend) ensureFollowsScraped(ctx context.Context, user string) error { 249 - r, err := b.getOrCreateRepo(ctx, user) 250 - if err != nil { 251 - return err 252 - } 253 - 254 - var si SyncInfo 255 - if err := b.db.Find(&si, "repo = ?", r.ID).Error; err != nil { 256 - return err 257 - } 258 - 259 - // not found 260 - if si.Repo == 0 { 261 - if err := b.db.Create(&SyncInfo{ 262 - Repo: r.ID, 263 - }).Error; err != nil { 264 - return err 265 - } 266 - } 267 - 268 - if si.FollowsSynced { 269 - return nil 270 - } 271 - 272 - var follows []Follow 273 - var cursor string 274 - for { 275 - resp, err := atproto.RepoListRecords(ctx, b.s.client, "app.bsky.graph.follow", cursor, 100, b.s.mydid, false) 276 - if err != nil { 277 - return err 278 - } 279 - 280 - for _, rec := range resp.Records { 281 - if fol, ok := rec.Value.Val.(*bsky.GraphFollow); ok { 282 - fr, err := b.getOrCreateRepo(ctx, fol.Subject) 283 - if err != nil { 284 - return err 285 - } 286 - 287 - puri, err := syntax.ParseATURI(rec.Uri) 288 - if err != nil { 289 - return err 290 - } 291 - 292 - follows = append(follows, Follow{ 293 - Created: time.Now(), 294 - Indexed: time.Now(), 295 - Rkey: puri.RecordKey().String(), 296 - Author: r.ID, 297 - Subject: fr.ID, 298 - }) 299 - } 300 - } 301 - 302 - if resp.Cursor == nil || len(resp.Records) == 0 { 303 - break 304 - } 305 - cursor = *resp.Cursor 306 - } 307 - 308 - if err := b.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(follows, 200).Error; err != nil { 309 - return err 310 - } 311 - 312 - if err := b.db.Model(SyncInfo{}).Where("repo = ?", r.ID).Update("follows_synced", true).Error; err != nil { 313 - return err 314 - } 315 - 316 - fmt.Println("Got follows: ", len(follows)) 317 - 318 - return nil 319 - } 320 - 321 - func (b *PostgresBackend) loadRelevantDids() error { 322 - ctx := context.TODO() 323 - 324 - if err := b.ensureFollowsScraped(ctx, b.s.mydid); err != nil { 325 - return fmt.Errorf("failed to scrape follows: %w", err) 326 - } 327 - 328 - r, err := b.getOrCreateRepo(ctx, b.s.mydid) 329 - if err != nil { 330 - return err 331 - } 332 - 333 - var dids []string 334 - if err := b.db.Raw("select did from follows left join repos on follows.subject = repos.id where follows.author = ?", r.ID).Scan(&dids).Error; err != nil { 335 - return err 336 - } 337 - 338 - b.relevantDids[b.s.mydid] = true 339 - for _, d := range dids { 340 - fmt.Println("adding did: ", d) 341 - b.relevantDids[d] = true 342 - } 343 - 344 - return nil 345 - } 346 - 347 - type SyncInfo struct { 348 - Repo uint `gorm:"index"` 349 - FollowsSynced bool 350 - Rev string 351 - } 352 - 353 - func (b *PostgresBackend) checkPostExists(ctx context.Context, repo *Repo, rkey string) (bool, error) { 354 - var id uint 355 - var notfound bool 356 - if err := b.pgx.QueryRow(ctx, "SELECT id, not_found FROM posts WHERE author = $1 AND rkey = $2", repo.ID, rkey).Scan(&id, &notfound); err != nil { 357 - if errors.Is(err, pgx.ErrNoRows) { 358 - return false, nil 359 - } 360 - return false, err 361 - } 362 - 363 - if id != 0 && !notfound { 364 - return true, nil 365 - } 366 - 367 - return false, nil 368 - } 369 - 370 - func (b *PostgresBackend) addRelevantDid(did string) { 371 - b.rdLk.Lock() 372 - defer b.rdLk.Unlock() 373 - b.relevantDids[did] = true 374 - } 375 - 376 - func (b *PostgresBackend) didIsRelevant(did string) bool { 377 - b.rdLk.Lock() 378 - defer b.rdLk.Unlock() 379 - return b.relevantDids[did] 380 - } 381 - 382 - func (b *PostgresBackend) anyRelevantIdents(idents ...string) bool { 383 - for _, id := range idents { 384 - if strings.HasPrefix(id, "did:") { 385 - if b.didIsRelevant(id) { 386 - return true 387 - } 388 - } else if strings.HasPrefix(id, "at://") { 389 - puri, err := syntax.ParseATURI(id) 390 - if err != nil { 391 - continue 392 - } 393 - 394 - if b.didIsRelevant(puri.Authority().String()) { 395 - return true 396 - } 397 - } 398 - } 399 - 400 - return false 401 - } 402 - 403 - func (b *PostgresBackend) getRepoByID(ctx context.Context, id uint) (*models.Repo, error) { 404 - var r models.Repo 405 - if err := b.db.Find(&r, "id = ?", id).Error; err != nil { 406 - return nil, err 407 - } 408 - 409 - return &r, nil 410 - } 411 - 412 - func (b *PostgresBackend) TrackMissingRecord(identifier string, wait bool) { 413 - b.s.addMissingRecord(context.TODO(), MissingRecord{ 414 - Type: inferRecordType(identifier), 415 - Identifier: identifier, 416 - Wait: wait, 417 - }) 418 - } 419 - 420 - // inferRecordType determines the record type based on the identifier format 421 - func inferRecordType(identifier string) MissingRecordType { 422 - if strings.HasPrefix(identifier, "did:") { 423 - return MissingRecordTypeProfile 424 - } 425 - 426 - if strings.HasPrefix(identifier, "at://") { 427 - if strings.Contains(identifier, "/app.bsky.feed.post/") { 428 - return MissingRecordTypePost 429 - } 430 - if strings.Contains(identifier, "/app.bsky.feed.generator/") { 431 - return MissingRecordTypeFeedGenerator 432 - } 433 - } 434 - 435 - // Default to post if we can't determine 436 - return MissingRecordTypePost 437 - }
+8
sync-config-jetstream.json
··· 1 + { 2 + "backends": [ 3 + { 4 + "type": "jetstream", 5 + "host": "jetstream1.us-west.bsky.network" 6 + } 7 + ] 8 + }
+281
sync.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log/slog" 7 + "net/http" 8 + "sync" 9 + "time" 10 + 11 + "github.com/bluesky-social/indigo/api/atproto" 12 + "github.com/bluesky-social/indigo/cmd/relay/stream" 13 + "github.com/bluesky-social/indigo/cmd/relay/stream/schedulers/parallel" 14 + jsclient "github.com/bluesky-social/jetstream/pkg/client" 15 + jsparallel "github.com/bluesky-social/jetstream/pkg/client/schedulers/parallel" 16 + "github.com/bluesky-social/jetstream/pkg/models" 17 + "github.com/gorilla/websocket" 18 + ) 19 + 20 + type SyncConfig struct { 21 + Backends []SyncBackend `json:"backends"` 22 + } 23 + 24 + type SyncBackend struct { 25 + Type string `json:"type"` 26 + Host string `json:"host"` 27 + MaxWorkers int `json:"max_workers,omitempty"` 28 + } 29 + 30 + func (s *Server) StartSyncEngine(ctx context.Context, sc *SyncConfig) error { 31 + for _, be := range sc.Backends { 32 + switch be.Type { 33 + case "firehose": 34 + go s.runSyncFirehose(ctx, be) 35 + case "jetstream": 36 + go s.runSyncJetstream(ctx, be) 37 + default: 38 + return fmt.Errorf("unrecognized sync backend type: %q", be.Type) 39 + } 40 + } 41 + 42 + <-ctx.Done() 43 + return fmt.Errorf("exiting sync routine") 44 + } 45 + 46 + const failureTimeInterval = time.Second * 5 47 + 48 + func (s *Server) runSyncFirehose(ctx context.Context, be SyncBackend) { 49 + var failures int 50 + for { 51 + seqno, err := loadLastSeq(s.db, be.Host) 52 + if err != nil { 53 + fmt.Println("failed to load sequence number, starting over", err) 54 + } 55 + 56 + maxWorkers := 10 57 + if be.MaxWorkers != 0 { 58 + maxWorkers = be.MaxWorkers 59 + } 60 + 61 + start := time.Now() 62 + if err := s.startLiveTail(ctx, be.Host, int(seqno), maxWorkers, 20); err != nil { 63 + slog.Error("firehose connection lost", "host", be.Host, "error", err) 64 + } 65 + 66 + elapsed := time.Since(start) 67 + 68 + if elapsed > failureTimeInterval { 69 + failures = 0 70 + continue 71 + } 72 + failures++ 73 + 74 + delay := delayForFailureCount(failures) 75 + slog.Warn("retrying connection after delay", "host", be.Host, "delay", delay) 76 + } 77 + } 78 + 79 + func (s *Server) runSyncJetstream(ctx context.Context, be SyncBackend) { 80 + var failures int 81 + for { 82 + // Load last cursor (stored as sequence number in same table) 83 + cursor, err := loadLastSeq(s.db, be.Host) 84 + if err != nil { 85 + slog.Warn("failed to load jetstream cursor, starting from live", "error", err) 86 + cursor = 0 87 + } 88 + 89 + maxWorkers := 10 90 + if be.MaxWorkers != 0 { 91 + maxWorkers = be.MaxWorkers 92 + } 93 + 94 + start := time.Now() 95 + if err := s.startJetstreamTail(ctx, be.Host, cursor, maxWorkers); err != nil { 96 + slog.Error("jetstream connection lost", "host", be.Host, "error", err) 97 + } 98 + 99 + elapsed := time.Since(start) 100 + 101 + if elapsed > failureTimeInterval { 102 + failures = 0 103 + continue 104 + } 105 + failures++ 106 + 107 + delay := delayForFailureCount(failures) 108 + slog.Warn("retrying jetstream connection after delay", "host", be.Host, "delay", delay) 109 + time.Sleep(delay) 110 + } 111 + } 112 + 113 + func delayForFailureCount(n int) time.Duration { 114 + if n < 5 { 115 + return (time.Second * 5) + (time.Second * 2 * time.Duration(n)) 116 + } 117 + 118 + return time.Second * 30 119 + } 120 + 121 + func (s *Server) startLiveTail(ctx context.Context, host string, curs int, parWorkers, maxQ int) error { 122 + ctx, cancel := context.WithCancel(ctx) 123 + defer cancel() 124 + 125 + slog.Info("starting live tail") 126 + 127 + // Connect to the Relay websocket 128 + urlStr := fmt.Sprintf("wss://%s/xrpc/com.atproto.sync.subscribeRepos?cursor=%d", host, curs) 129 + 130 + d := websocket.DefaultDialer 131 + con, _, err := d.Dial(urlStr, http.Header{ 132 + "User-Agent": []string{"konbini/0.0.1"}, 133 + }) 134 + if err != nil { 135 + return fmt.Errorf("failed to connect to relay: %w", err) 136 + } 137 + 138 + var lelk sync.Mutex 139 + lastEvent := time.Now() 140 + 141 + go func() { 142 + tick := time.NewTicker(time.Second) 143 + defer tick.Stop() 144 + for { 145 + select { 146 + case <-tick.C: 147 + lelk.Lock() 148 + let := lastEvent 149 + lelk.Unlock() 150 + 151 + if time.Since(let) > time.Second*30 { 152 + slog.Error("firehose connection timed out") 153 + con.Close() 154 + return 155 + } 156 + case <-ctx.Done(): 157 + return 158 + } 159 + } 160 + }() 161 + 162 + var cclk sync.Mutex 163 + var completeCursor int64 164 + 165 + rsc := &stream.RepoStreamCallbacks{ 166 + RepoCommit: func(evt *atproto.SyncSubscribeRepos_Commit) error { 167 + ctx := context.Background() 168 + 169 + firehoseCursorGauge.WithLabelValues("ingest").Set(float64(evt.Seq)) 170 + 171 + s.seqLk.Lock() 172 + if evt.Seq > s.lastSeq { 173 + curs = int(evt.Seq) 174 + s.lastSeq = evt.Seq 175 + 176 + if evt.Seq%1000 == 0 { 177 + if err := storeLastSeq(s.db, host, evt.Seq); err != nil { 178 + fmt.Println("failed to store seqno: ", err) 179 + } 180 + } 181 + } 182 + s.seqLk.Unlock() 183 + 184 + lelk.Lock() 185 + lastEvent = time.Now() 186 + lelk.Unlock() 187 + 188 + if err := s.backend.HandleEvent(ctx, evt); err != nil { 189 + return fmt.Errorf("handle event (%s,%d): %w", evt.Repo, evt.Seq, err) 190 + } 191 + 192 + cclk.Lock() 193 + if evt.Seq > completeCursor { 194 + completeCursor = evt.Seq 195 + firehoseCursorGauge.WithLabelValues("complete").Set(float64(evt.Seq)) 196 + } 197 + cclk.Unlock() 198 + 199 + return nil 200 + }, 201 + RepoInfo: func(info *atproto.SyncSubscribeRepos_Info) error { 202 + return nil 203 + }, 204 + // TODO: all the other event types 205 + Error: func(errf *stream.ErrorFrame) error { 206 + return fmt.Errorf("error frame: %s: %s", errf.Error, errf.Message) 207 + }, 208 + } 209 + 210 + sched := parallel.NewScheduler(parWorkers, maxQ, con.RemoteAddr().String(), rsc.EventHandler) 211 + 212 + return stream.HandleRepoStream(ctx, con, sched, slog.Default()) 213 + } 214 + 215 + func (s *Server) startJetstreamTail(ctx context.Context, host string, cursor int64, parWorkers int) error { 216 + ctx, cancel := context.WithCancel(ctx) 217 + defer cancel() 218 + 219 + slog.Info("starting jetstream tail", "host", host, "cursor", cursor) 220 + 221 + // Create a scheduler for parallel processing 222 + lastStored := int64(0) 223 + sched := jsparallel.NewScheduler( 224 + parWorkers, 225 + host, 226 + slog.Default(), 227 + func(ctx context.Context, event *models.Event) error { 228 + // Update cursor tracking 229 + s.seqLk.Lock() 230 + if event.TimeUS > s.lastSeq { 231 + s.lastSeq = event.TimeUS 232 + if event.TimeUS-lastStored > 1_000_000 { 233 + // Store checkpoint periodically 234 + if err := storeLastSeq(s.db, host, event.TimeUS); err != nil { 235 + slog.Error("failed to store jetstream cursor", "error", err) 236 + } 237 + lastStored = event.TimeUS 238 + } 239 + } 240 + s.seqLk.Unlock() 241 + 242 + // Update metrics 243 + firehoseCursorGauge.WithLabelValues("ingest").Set(float64(event.TimeUS)) 244 + 245 + // Convert Jetstream event to ATProto event format 246 + if event.Commit != nil { 247 + 248 + if err := s.backend.HandleEventJetstream(ctx, event); err != nil { 249 + return fmt.Errorf("handle event (%s,%d): %w", event.Did, event.TimeUS, err) 250 + } 251 + 252 + firehoseCursorGauge.WithLabelValues("complete").Set(float64(event.TimeUS)) 253 + } 254 + 255 + return nil 256 + }, 257 + ) 258 + 259 + // Configure Jetstream client 260 + config := jsclient.DefaultClientConfig() 261 + config.WebsocketURL = fmt.Sprintf("wss://%s/subscribe", host) 262 + 263 + // Prepare cursor pointer 264 + var cursorPtr *int64 265 + if cursor > 0 { 266 + cursorPtr = &cursor 267 + } 268 + 269 + // Create and connect client 270 + client, err := jsclient.NewClient( 271 + config, 272 + slog.Default(), 273 + sched, 274 + ) 275 + if err != nil { 276 + return fmt.Errorf("create jetstream client: %w", err) 277 + } 278 + 279 + // Start reading from Jetstream 280 + return client.ConnectAndRead(ctx, cursorPtr) 281 + }
+5
views/actor.go
··· 89 89 view.FollowsCount = &actor.FollowCount 90 90 view.PostsCount = &actor.PostCount 91 91 92 + // Add viewer state if available 93 + if actor.ViewerState != nil { 94 + view.Viewer = actor.ViewerState 95 + } 96 + 92 97 return view 93 98 } 94 99
+3 -86
views/feed.go
··· 42 42 } 43 43 } 44 44 45 - // Add embed handling 46 - if post.Post.Embed != nil { 47 - view.Embed = formatEmbed(post.Post.Embed, post.Author) 45 + // Add embed if it was hydrated 46 + if post.EmbedInfo != nil { 47 + view.Embed = post.EmbedInfo 48 48 } 49 49 50 50 return view ··· 68 68 // For now leaving them as interface{} to be handled by handlers 69 69 70 70 return view 71 - } 72 - 73 - func formatEmbed(embed *bsky.FeedPost_Embed, authorDID string) *bsky.FeedDefs_PostView_Embed { 74 - if embed == nil { 75 - return nil 76 - } 77 - 78 - result := &bsky.FeedDefs_PostView_Embed{} 79 - 80 - // Handle images 81 - if embed.EmbedImages != nil { 82 - viewImages := make([]*bsky.EmbedImages_ViewImage, len(embed.EmbedImages.Images)) 83 - for i, img := range embed.EmbedImages.Images { 84 - // Convert blob to CDN URLs 85 - fullsize := "" 86 - thumb := "" 87 - if img.Image != nil { 88 - // CDN URL format for feed images 89 - cid := img.Image.Ref.String() 90 - fullsize = fmt.Sprintf("https://cdn.bsky.app/img/feed_fullsize/plain/%s/%s@jpeg", authorDID, cid) 91 - thumb = fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid) 92 - } 93 - 94 - viewImages[i] = &bsky.EmbedImages_ViewImage{ 95 - Alt: img.Alt, 96 - AspectRatio: img.AspectRatio, 97 - Fullsize: fullsize, 98 - Thumb: thumb, 99 - } 100 - } 101 - result.EmbedImages_View = &bsky.EmbedImages_View{ 102 - LexiconTypeID: "app.bsky.embed.images#view", 103 - Images: viewImages, 104 - } 105 - return result 106 - } 107 - 108 - // Handle external links 109 - if embed.EmbedExternal != nil && embed.EmbedExternal.External != nil { 110 - // Convert blob thumb to CDN URL if present 111 - var thumbURL *string 112 - if embed.EmbedExternal.External.Thumb != nil { 113 - // CDN URL for external link thumbnails 114 - cid := embed.EmbedExternal.External.Thumb.Ref.String() 115 - url := fmt.Sprintf("https://cdn.bsky.app/img/feed_thumbnail/plain/%s/%s@jpeg", authorDID, cid) 116 - thumbURL = &url 117 - } 118 - 119 - result.EmbedExternal_View = &bsky.EmbedExternal_View{ 120 - LexiconTypeID: "app.bsky.embed.external#view", 121 - External: &bsky.EmbedExternal_ViewExternal{ 122 - Uri: embed.EmbedExternal.External.Uri, 123 - Title: embed.EmbedExternal.External.Title, 124 - Description: embed.EmbedExternal.External.Description, 125 - Thumb: thumbURL, 126 - }, 127 - } 128 - return result 129 - } 130 - 131 - // Handle video 132 - if embed.EmbedVideo != nil { 133 - // TODO: Implement video embed view 134 - // This would require converting video blob to CDN URLs and playlist URLs 135 - return nil 136 - } 137 - 138 - // Handle record (quote posts, etc.) 139 - if embed.EmbedRecord != nil { 140 - // TODO: Implement record embed view 141 - // This requires hydrating the embedded record, which is complex 142 - // For now, return nil to skip these embeds 143 - return nil 144 - } 145 - 146 - // Handle record with media (quote post with images/external) 147 - if embed.EmbedRecordWithMedia != nil { 148 - // TODO: Implement record with media embed view 149 - // This combines record hydration with media conversion 150 - return nil 151 - } 152 - 153 - return nil 154 71 } 155 72 156 73 // GeneratorView builds a feed generator view (app.bsky.feed.defs#generatorView)
+3 -1
xrpc/actor/getProfile.go
··· 20 20 21 21 ctx := c.Request().Context() 22 22 23 + viewer, _ := c.Get("viewer").(string) 24 + 23 25 // Resolve actor to DID 24 26 did, err := hydrator.ResolveDID(ctx, actorParam) 25 27 if err != nil { ··· 30 32 } 31 33 32 34 // Hydrate actor info 33 - actorInfo, err := hydrator.HydrateActorDetailed(ctx, did) 35 + actorInfo, err := hydrator.HydrateActorDetailed(ctx, did, viewer) 34 36 if err != nil { 35 37 return c.JSON(http.StatusNotFound, map[string]interface{}{ 36 38 "error": "ActorNotFound",
+2 -1
xrpc/actor/getProfiles.go
··· 27 27 } 28 28 29 29 ctx := c.Request().Context() 30 + viewer, _ := c.Get("viewer").(string) 30 31 31 32 // Resolve all actors to DIDs and hydrate profiles 32 33 profiles := make([]*bsky.ActorDefs_ProfileViewDetailed, 0, len(actors)) ··· 39 40 } 40 41 41 42 // Hydrate actor info 42 - actorInfo, err := hydrator.HydrateActorDetailed(ctx, did) 43 + actorInfo, err := hydrator.HydrateActorDetailed(ctx, did, viewer) 43 44 if err != nil { 44 45 // Skip actors that can't be hydrated 45 46 continue
+42 -6
xrpc/feed/getAuthorFeed.go
··· 5 5 "log/slog" 6 6 "net/http" 7 7 "strconv" 8 + "strings" 8 9 "sync" 9 10 "time" 10 11 11 12 "github.com/bluesky-social/indigo/api/bsky" 13 + "github.com/bluesky-social/indigo/atproto/syntax" 12 14 "github.com/labstack/echo/v4" 13 15 "github.com/whyrusleeping/konbini/hydration" 14 16 "github.com/whyrusleeping/konbini/views" ··· 126 128 } 127 129 128 130 func hydratePostRows(ctx context.Context, hydrator *hydration.Hydrator, viewer string, rows []postRow) []*bsky.FeedDefs_FeedViewPost { 131 + ctx, span := tracer.Start(ctx, "hydratePostRows") 132 + defer span.End() 133 + 129 134 // Hydrate posts 130 135 var wg sync.WaitGroup 131 136 ··· 136 141 go func(i int, row postRow) { 137 142 defer wg.Done() 138 143 139 - postInfo, err := hydrator.HydratePost(ctx, row.URI, viewer) 144 + puri, err := syntax.ParseATURI(row.URI) 140 145 if err != nil { 141 - slog.Error("failed to hydrate post", "uri", row.URI, "error", err) 146 + slog.Error("row had invalid uri", "uri", row.URI, "error", err) 142 147 return 143 148 } 144 149 145 - // Hydrate author 146 - authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 147 - if err != nil { 148 - slog.Error("failed to hydrate actor", "actor", postInfo.Author, "error", err) 150 + var subwg sync.WaitGroup 151 + 152 + var postInfo *hydration.PostInfo 153 + subwg.Go(func() { 154 + pi, err := hydrator.HydratePost(ctx, row.URI, viewer) 155 + if err != nil { 156 + if strings.Contains(err.Error(), "post not found") { 157 + hydrator.AddMissingRecord(row.URI, true) 158 + pi, err = hydrator.HydratePost(ctx, row.URI, viewer) 159 + if err != nil { 160 + slog.Error("failed to hydrate post after fetch missing", "uri", row.URI, "error", err) 161 + return 162 + } 163 + } else { 164 + slog.Warn("failed to hydrate post", "uri", row.URI, "error", err) 165 + return 166 + } 167 + } 168 + postInfo = pi 169 + }) 170 + 171 + var authorInfo *hydration.ActorInfo 172 + subwg.Go(func() { 173 + ai, err := hydrator.HydrateActor(ctx, puri.Authority().String()) 174 + if err != nil { 175 + hydrator.AddMissingRecord(postInfo.Author, false) 176 + slog.Warn("failed to hydrate author", "did", postInfo.Author, "error", err) 177 + return 178 + } 179 + authorInfo = ai 180 + }) 181 + 182 + subwg.Wait() 183 + 184 + if postInfo == nil || authorInfo == nil { 149 185 return 150 186 } 151 187
+2 -1
xrpc/feed/getFeed.go
··· 151 151 } 152 152 153 153 // Hydrate the posts from the skeleton 154 - posts := make([]*bsky.FeedDefs_FeedViewPost, 0, len(skeleton.Feed)) 154 + posts := make([]*bsky.FeedDefs_FeedViewPost, len(skeleton.Feed)) 155 155 var wg sync.WaitGroup 156 156 for i := range skeleton.Feed { 157 157 wg.Add(1) ··· 181 181 182 182 authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 183 183 if err != nil { 184 + hydrator.AddMissingRecord(postInfo.Author, false) 184 185 slog.Warn("failed to hydrate author", "did", postInfo.Author, "error", err) 185 186 return 186 187 }
+11 -11
xrpc/feed/getPostThread.go
··· 15 15 func HandleGetPostThread(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 16 16 uriParam := c.QueryParam("uri") 17 17 if uriParam == "" { 18 - return c.JSON(http.StatusBadRequest, map[string]interface{}{ 18 + return c.JSON(http.StatusBadRequest, map[string]any{ 19 19 "error": "InvalidRequest", 20 20 "message": "uri parameter is required", 21 21 }) ··· 27 27 // Hydrate the requested post 28 28 postInfo, err := hydrator.HydratePost(ctx, uriParam, viewer) 29 29 if err != nil { 30 - return c.JSON(http.StatusNotFound, map[string]interface{}{ 30 + return c.JSON(http.StatusNotFound, map[string]any{ 31 31 "error": "NotFound", 32 32 "message": "post not found", 33 33 }) ··· 74 74 uri: uri, 75 75 replyTo: tp.ReplyTo, 76 76 inThread: tp.InThread, 77 - replies: []interface{}{}, 77 + replies: []any{}, 78 78 } 79 79 } 80 80 ··· 98 98 } 99 99 100 100 if rootNode == nil { 101 - return c.JSON(http.StatusNotFound, map[string]interface{}{ 101 + return c.JSON(http.StatusNotFound, map[string]any{ 102 102 "error": "NotFound", 103 103 "message": "thread root not found", 104 104 }) ··· 107 107 // Build the response by traversing the tree 108 108 thread := buildThreadView(ctx, db, rootNode, postsByID, hydrator, viewer, nil) 109 109 110 - return c.JSON(http.StatusOK, map[string]interface{}{ 110 + return c.JSON(http.StatusOK, map[string]any{ 111 111 "thread": thread, 112 112 }) 113 113 } ··· 117 117 uri string 118 118 replyTo uint 119 119 inThread uint 120 - replies []interface{} 120 + replies []any 121 121 } 122 122 123 - func buildThreadView(ctx context.Context, db *gorm.DB, node *threadPostNode, allNodes map[uint]*threadPostNode, hydrator *hydration.Hydrator, viewer string, parent interface{}) interface{} { 123 + func buildThreadView(ctx context.Context, db *gorm.DB, node *threadPostNode, allNodes map[uint]*threadPostNode, hydrator *hydration.Hydrator, viewer string, parent any) any { 124 124 // Hydrate this post 125 125 postInfo, err := hydrator.HydratePost(ctx, node.uri, viewer) 126 126 if err != nil { 127 127 // Return a notFound post 128 - return map[string]interface{}{ 128 + return map[string]any{ 129 129 "$type": "app.bsky.feed.defs#notFoundPost", 130 130 "uri": node.uri, 131 131 } ··· 134 134 // Hydrate author 135 135 authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 136 136 if err != nil { 137 - return map[string]interface{}{ 137 + return map[string]any{ 138 138 "$type": "app.bsky.feed.defs#notFoundPost", 139 139 "uri": node.uri, 140 140 } 141 141 } 142 142 143 143 // Build replies 144 - var replies []interface{} 144 + var replies []any 145 145 for _, replyNode := range node.replies { 146 146 if rn, ok := replyNode.(*threadPostNode); ok { 147 147 replyView := buildThreadView(ctx, db, rn, allNodes, hydrator, viewer, nil) ··· 150 150 } 151 151 152 152 // Build the thread view post 153 - var repliesForView interface{} 153 + var repliesForView any 154 154 if len(replies) > 0 { 155 155 repliesForView = replies 156 156 }
+34 -16
xrpc/feed/getTimeline.go
··· 1 1 package feed 2 2 3 3 import ( 4 + "context" 4 5 "net/http" 5 6 "strconv" 6 7 "time" 7 8 8 9 "github.com/labstack/echo/v4" 9 10 "github.com/whyrusleeping/konbini/hydration" 11 + "go.opentelemetry.io/otel" 10 12 "gorm.io/gorm" 11 13 ) 14 + 15 + var tracer = otel.Tracer("xrpc/feed") 12 16 13 17 // HandleGetTimeline implements app.bsky.feed.getTimeline 14 18 func HandleGetTimeline(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 19 + ctx := c.Request().Context() 20 + ctx, span := tracer.Start(ctx, "getTimeline") 21 + defer span.End() 22 + 15 23 viewer := getUserDID(c) 16 24 if viewer == "" { 17 25 return c.JSON(http.StatusUnauthorized, map[string]any{ ··· 36 44 } 37 45 } 38 46 39 - ctx := c.Request().Context() 40 - 41 47 // Get viewer's repo ID 42 48 var viewerRepoID uint 43 49 if err := db.Raw("SELECT id FROM repos WHERE did = ?", viewer).Scan(&viewerRepoID).Error; err != nil { ··· 48 54 } 49 55 50 56 // Query posts from followed users 51 - var rows []postRow 52 - err := db.Raw(` 53 - SELECT 54 - 'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri, 55 - p.author as author_id 56 - FROM posts p 57 - JOIN repos r ON r.id = p.author 58 - WHERE p.reply_to = 0 59 - AND p.author IN (SELECT subject FROM follows WHERE author = ?) 60 - AND p.created < ? 61 - AND p.not_found = false 62 - ORDER BY p.created DESC 63 - LIMIT ? 64 - `, viewerRepoID, cursor, limit).Scan(&rows).Error 65 57 58 + rows, err := getTimelinePosts(ctx, db, viewerRepoID, cursor, limit) 66 59 if err != nil { 67 60 return c.JSON(http.StatusInternalServerError, map[string]any{ 68 61 "error": "InternalError", ··· 94 87 "cursor": nextCursor, 95 88 }) 96 89 } 90 + 91 + func getTimelinePosts(ctx context.Context, db *gorm.DB, uid uint, cursor time.Time, limit int) ([]postRow, error) { 92 + ctx, span := tracer.Start(ctx, "getTimelineQuery") 93 + defer span.End() 94 + 95 + var rows []postRow 96 + err := db.Raw(` 97 + SELECT 98 + 'at://' || r.did || '/app.bsky.feed.post/' || p.rkey as uri, 99 + p.author as author_id 100 + FROM posts p 101 + JOIN repos r ON r.id = p.author 102 + WHERE p.reply_to = 0 103 + AND p.author IN (SELECT subject FROM follows WHERE author = ?) 104 + AND p.created < ? 105 + AND p.not_found = false 106 + ORDER BY p.created DESC 107 + LIMIT ? 108 + `, uid, cursor, limit).Scan(&rows).Error 109 + 110 + if err != nil { 111 + return nil, err 112 + } 113 + return rows, nil 114 + }
+92 -11
xrpc/notification/listNotifications.go
··· 13 13 lexutil "github.com/bluesky-social/indigo/lex/util" 14 14 "github.com/labstack/echo/v4" 15 15 "github.com/whyrusleeping/konbini/hydration" 16 + models "github.com/whyrusleeping/konbini/models" 16 17 "github.com/whyrusleeping/konbini/views" 17 - "github.com/whyrusleeping/market/models" 18 18 "gorm.io/gorm" 19 + "gorm.io/gorm/clause" 19 20 ) 20 21 21 22 // HandleListNotifications implements app.bsky.notification.listNotifications 22 23 func HandleListNotifications(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 23 24 viewer := getUserDID(c) 24 25 if viewer == "" { 25 - return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 26 + return c.JSON(http.StatusUnauthorized, map[string]any{ 26 27 "error": "AuthenticationRequired", 27 28 "message": "authentication required", 28 29 }) ··· 77 78 } 78 79 query += ` ORDER BY n.created_at DESC LIMIT ?` 79 80 80 - var queryArgs []interface{} 81 + var queryArgs []any 81 82 queryArgs = append(queryArgs, viewer) 82 83 if cursor > 0 { 83 84 queryArgs = append(queryArgs, cursor) ··· 85 86 queryArgs = append(queryArgs, limit) 86 87 87 88 if err := db.Raw(query, queryArgs...).Scan(&rows).Error; err != nil { 88 - return c.JSON(http.StatusInternalServerError, map[string]interface{}{ 89 + return c.JSON(http.StatusInternalServerError, map[string]any{ 89 90 "error": "InternalError", 90 91 "message": "failed to query notifications", 91 92 }) ··· 130 131 cursorPtr = &cursor 131 132 } 132 133 134 + var lastSeen time.Time 135 + if err := db.Raw("SELECT seen_at FROM notification_seens WHERE repo = (select id from repos where did = ?)", viewer).Scan(&lastSeen).Error; err != nil { 136 + return err 137 + } 138 + 139 + var lastSeenStr *string 140 + if !lastSeen.IsZero() { 141 + s := lastSeen.Format(time.RFC3339) 142 + lastSeenStr = &s 143 + } 144 + 133 145 output := &bsky.NotificationListNotifications_Output{ 134 146 Notifications: notifications, 135 147 Cursor: cursorPtr, 148 + SeenAt: lastSeenStr, 136 149 } 137 150 138 151 return c.JSON(http.StatusOK, output) ··· 142 155 func HandleGetUnreadCount(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 143 156 viewer := getUserDID(c) 144 157 if viewer == "" { 145 - return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 158 + return c.JSON(http.StatusUnauthorized, map[string]any{ 146 159 "error": "AuthenticationRequired", 147 160 "message": "authentication required", 148 161 }) 149 162 } 150 163 151 - // For now, return 0 - we'd need to track read state in the database 152 - return c.JSON(http.StatusOK, map[string]interface{}{ 153 - "count": 0, 164 + var repo models.Repo 165 + if err := db.Find(&repo, "did = ?", viewer).Error; err != nil { 166 + return err 167 + } 168 + 169 + var lastSeen time.Time 170 + if err := db.Raw("SELECT seen_at FROM notification_seens WHERE repo = ?", repo.ID).Scan(&lastSeen).Error; err != nil { 171 + return err 172 + } 173 + 174 + var count int 175 + query := `SELECT count(*) FROM notifications WHERE created_at > ? AND for = ?` 176 + if err := db.Raw(query, lastSeen, repo.ID).Scan(&count).Error; err != nil { 177 + return c.JSON(http.StatusInternalServerError, map[string]any{ 178 + "error": "InternalError", 179 + "message": "failed to count unread notifications", 180 + }) 181 + } 182 + 183 + return c.JSON(http.StatusOK, map[string]any{ 184 + "count": count, 154 185 }) 155 186 } 156 187 ··· 158 189 func HandleUpdateSeen(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 159 190 viewer := getUserDID(c) 160 191 if viewer == "" { 161 - return c.JSON(http.StatusUnauthorized, map[string]interface{}{ 192 + return c.JSON(http.StatusUnauthorized, map[string]any{ 162 193 "error": "AuthenticationRequired", 163 194 "message": "authentication required", 164 195 }) 165 196 } 166 197 167 - // For now, just return success - we'd need to track seen timestamps in the database 168 - return c.JSON(http.StatusOK, map[string]interface{}{}) 198 + var body bsky.NotificationUpdateSeen_Input 199 + if err := c.Bind(&body); err != nil { 200 + return c.JSON(http.StatusBadRequest, map[string]any{ 201 + "error": "InvalidRequest", 202 + "message": "invalid request body", 203 + }) 204 + } 205 + 206 + // Parse the seenAt timestamp 207 + seenAt, err := time.Parse(time.RFC3339, body.SeenAt) 208 + if err != nil { 209 + return c.JSON(http.StatusBadRequest, map[string]any{ 210 + "error": "InvalidRequest", 211 + "message": "invalid seenAt timestamp", 212 + }) 213 + } 214 + 215 + // Get the viewer's repo ID 216 + var repoID uint 217 + if err := db.Raw("SELECT id FROM repos WHERE did = ?", viewer).Scan(&repoID).Error; err != nil { 218 + return c.JSON(http.StatusInternalServerError, map[string]any{ 219 + "error": "InternalError", 220 + "message": "failed to find viewer repo", 221 + }) 222 + } 223 + 224 + if repoID == 0 { 225 + return c.JSON(http.StatusInternalServerError, map[string]any{ 226 + "error": "InternalError", 227 + "message": "viewer repo not found", 228 + }) 229 + } 230 + 231 + // Upsert the NotificationSeen record 232 + notifSeen := models.NotificationSeen{ 233 + Repo: repoID, 234 + SeenAt: seenAt, 235 + } 236 + 237 + err = db.Clauses(clause.OnConflict{ 238 + Columns: []clause.Column{{Name: "repo"}}, 239 + DoUpdates: clause.AssignmentColumns([]string{"seen_at"}), 240 + }).Create(&notifSeen).Error 241 + 242 + if err != nil { 243 + return c.JSON(http.StatusInternalServerError, map[string]any{ 244 + "error": "InternalError", 245 + "message": "failed to update seen timestamp", 246 + }) 247 + } 248 + 249 + return c.JSON(http.StatusOK, map[string]any{}) 169 250 } 170 251 171 252 func getUserDID(c echo.Context) string {
+9 -7
xrpc/server.go
··· 1 1 package xrpc 2 2 3 3 import ( 4 + "context" 4 5 "log/slog" 5 6 "net/http" 6 7 7 8 "github.com/bluesky-social/indigo/atproto/identity" 8 9 "github.com/labstack/echo/v4" 9 10 "github.com/labstack/echo/v4/middleware" 11 + "github.com/whyrusleeping/konbini/backend" 10 12 "github.com/whyrusleeping/konbini/hydration" 13 + "github.com/whyrusleeping/konbini/models" 11 14 "github.com/whyrusleeping/konbini/xrpc/actor" 12 15 "github.com/whyrusleeping/konbini/xrpc/feed" 13 16 "github.com/whyrusleeping/konbini/xrpc/graph" ··· 32 35 // Add methods as needed for data access 33 36 34 37 TrackMissingRecord(identifier string, wait bool) 38 + GetOrCreateRepo(ctx context.Context, did string) (*models.Repo, error) 35 39 } 36 40 37 41 // NewServer creates a new XRPC server 38 - func NewServer(db *gorm.DB, dir identity.Directory, backend Backend) *Server { 42 + func NewServer(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Server { 39 43 e := echo.New() 40 44 e.HidePort = true 41 45 e.HideBanner = true ··· 56 60 db: db, 57 61 dir: dir, 58 62 backend: backend, 59 - hydrator: hydration.NewHydrator(db, dir), 63 + hydrator: hydration.NewHydrator(db, dir, backend), 60 64 } 61 65 62 - s.hydrator.SetMissingRecordCallback(backend.TrackMissingRecord) 63 - 64 66 // Register XRPC endpoints 65 67 s.registerEndpoints() 66 68 ··· 94 96 // app.bsky.actor.* 95 97 xrpcGroup.GET("/app.bsky.actor.getProfile", func(c echo.Context) error { 96 98 return actor.HandleGetProfile(c, s.hydrator) 97 - }) 99 + }, s.optionalAuth) 98 100 xrpcGroup.GET("/app.bsky.actor.getProfiles", func(c echo.Context) error { 99 101 return actor.HandleGetProfiles(c, s.db, s.hydrator) 100 - }) 102 + }, s.optionalAuth) 101 103 xrpcGroup.GET("/app.bsky.actor.getPreferences", func(c echo.Context) error { 102 104 return actor.HandleGetPreferences(c, s.db, s.hydrator) 103 105 }, s.requireAuth) ··· 131 133 }, s.requireAuth) 132 134 xrpcGroup.GET("/app.bsky.feed.getFeed", func(c echo.Context) error { 133 135 return feed.HandleGetFeed(c, s.db, s.hydrator, s.dir) 134 - }) 136 + }, s.optionalAuth) 135 137 xrpcGroup.GET("/app.bsky.feed.getFeedGenerator", func(c echo.Context) error { 136 138 return feed.HandleGetFeedGenerator(c, s.db, s.hydrator, s.dir) 137 139 })
+160 -131
xrpc/unspecced/getPostThreadV2.go
··· 1 1 package unspecced 2 2 3 3 import ( 4 + "bytes" 4 5 "context" 5 6 "fmt" 6 7 "log/slog" 7 8 "net/http" 8 9 "strconv" 10 + "sync" 9 11 10 12 "github.com/bluesky-social/indigo/api/bsky" 11 13 "github.com/labstack/echo/v4" 12 14 "github.com/whyrusleeping/konbini/hydration" 13 15 "github.com/whyrusleeping/konbini/views" 16 + "github.com/whyrusleeping/market/models" 17 + "go.opentelemetry.io/otel" 14 18 "gorm.io/gorm" 15 19 ) 16 20 21 + var tracer = otel.Tracer("xrpc/unspecced") 22 + 17 23 // HandleGetPostThreadV2 implements app.bsky.unspecced.getPostThreadV2 18 24 func HandleGetPostThreadV2(c echo.Context, db *gorm.DB, hydrator *hydration.Hydrator) error { 19 - ctx := c.Request().Context() 25 + ctx, span := tracer.Start(c.Request().Context(), "getPostThreadV2") 26 + defer span.End() 27 + ctx = context.WithValue(ctx, "auto-fetch", true) 20 28 21 29 // Parse parameters 22 30 anchorRaw := c.QueryParam("anchor") ··· 68 76 }) 69 77 } 70 78 71 - // Determine the root post ID for the thread 72 - rootPostID := anchorPostInfo.InThread 73 - if rootPostID == 0 { 74 - // This post is the root - get its ID 75 - var postID uint 76 - db.Raw(` 77 - SELECT id FROM posts 78 - WHERE author = (SELECT id FROM repos WHERE did = ?) 79 - AND rkey = ? 80 - `, extractDIDFromURI(anchorUri), extractRkeyFromURI(anchorUri)).Scan(&postID) 81 - rootPostID = postID 79 + threadID := anchorPostInfo.InThread 80 + if threadID == 0 { 81 + threadID = anchorPostInfo.ID 82 82 } 83 83 84 - // Query all posts in this thread 85 - type threadPostRow struct { 86 - ID uint 87 - Rkey string 88 - ReplyTo uint 89 - InThread uint 90 - AuthorDid string 84 + var threadPosts []*models.Post 85 + if err := db.Raw("SELECT * FROM posts WHERE in_thread = ? OR id = ?", threadID, anchorPostInfo.ID).Scan(&threadPosts).Error; err != nil { 86 + return err 91 87 } 92 - var threadPosts []threadPostRow 93 - db.Raw(` 94 - SELECT p.id, p.rkey, p.reply_to, p.in_thread, r.did as author_did 95 - FROM posts p 96 - JOIN repos r ON r.id = p.author 97 - WHERE (p.id = ? OR p.in_thread = ?) 98 - AND p.not_found = false 99 - ORDER BY p.created ASC 100 - `, rootPostID, rootPostID).Scan(&threadPosts) 101 88 102 - // Build a map of posts by ID 103 - postsByID := make(map[uint]*threadNode) 104 - for _, tp := range threadPosts { 105 - uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", tp.AuthorDid, tp.Rkey) 106 - postsByID[tp.ID] = &threadNode{ 107 - id: tp.ID, 108 - uri: uri, 109 - replyTo: tp.ReplyTo, 110 - inThread: tp.InThread, 111 - children: []*threadNode{}, 112 - } 113 - } 114 - 115 - // Build parent-child relationships 116 - for _, node := range postsByID { 117 - if node.replyTo != 0 { 118 - parent := postsByID[node.replyTo] 119 - if parent != nil { 120 - parent.children = append(parent.children, node) 121 - } 122 - } 123 - } 124 - 125 - // Find the anchor node 126 - anchorID := uint(0) 127 - for id, node := range postsByID { 128 - if node.uri == anchorUri { 129 - anchorID = id 130 - break 131 - } 132 - } 89 + fmt.Println("GOT THREAD POSTS: ", len(threadPosts)) 133 90 134 - if anchorID == 0 { 135 - return c.JSON(http.StatusNotFound, map[string]interface{}{ 136 - "error": "NotFound", 137 - "message": "anchor post not found in thread", 138 - }) 91 + treeNodes, err := buildThreadTree(ctx, hydrator, db, threadPosts) 92 + if err != nil { 93 + return fmt.Errorf("failed to construct tree: %w", err) 139 94 } 140 95 141 - anchorNode := postsByID[anchorID] 96 + anchor := treeNodes[anchorPostInfo.ID] 142 97 143 98 // Build flat thread items list 144 99 var threadItems []*bsky.UnspeccedGetPostThreadV2_ThreadItem ··· 146 101 147 102 // Add parents if requested 148 103 if above { 149 - parents := collectParents(anchorNode, postsByID) 150 - for i := len(parents) - 1; i >= 0; i-- { 151 - depth := int64(-(len(parents) - i)) 152 - item := buildThreadItem(ctx, hydrator, parents[i], depth, viewer) 104 + parent := anchor.parent 105 + depth := int64(-1) 106 + for parent != nil { 107 + if parent.missing { 108 + fmt.Println("Parent missing: ", depth) 109 + item := &bsky.UnspeccedGetPostThreadV2_ThreadItem{ 110 + Depth: depth, 111 + Uri: parent.uri, 112 + Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{ 113 + UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{ 114 + LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound", 115 + }, 116 + }, 117 + } 118 + 119 + threadItems = append(threadItems, item) 120 + break 121 + } 122 + 123 + item := buildThreadItem(ctx, hydrator, parent, depth, viewer) 153 124 if item != nil { 154 125 threadItems = append(threadItems, item) 155 126 } 127 + 128 + parent = parent.parent 129 + depth-- 156 130 } 157 131 } 158 132 159 133 // Add anchor post (depth 0) 160 - anchorItem := buildThreadItem(ctx, hydrator, anchorNode, 0, viewer) 134 + anchorItem := buildThreadItem(ctx, hydrator, anchor, 0, viewer) 161 135 if anchorItem != nil { 162 136 threadItems = append(threadItems, anchorItem) 163 137 } 164 138 165 139 // Add replies below anchor 166 140 if below > 0 { 167 - replies, hasMore := collectReplies(ctx, hydrator, anchorNode, 1, below, branchingFactor, sort, viewer) 141 + replies, err := collectReplies(ctx, hydrator, anchor, 0, below, branchingFactor, sort, viewer) 142 + if err != nil { 143 + return err 144 + } 168 145 threadItems = append(threadItems, replies...) 169 - hasOtherReplies = hasMore 146 + //hasOtherReplies = hasMore 170 147 } 171 148 172 149 return c.JSON(http.StatusOK, &bsky.UnspeccedGetPostThreadV2_Output{ ··· 175 152 }) 176 153 } 177 154 178 - type threadNode struct { 179 - id uint 180 - uri string 181 - replyTo uint 182 - inThread uint 183 - children []*threadNode 184 - } 155 + func collectReplies(ctx context.Context, hydrator *hydration.Hydrator, curnode *threadTree, depth int64, below int64, branchingFactor int64, sort string, viewer string) ([]*bsky.UnspeccedGetPostThreadV2_ThreadItem, error) { 156 + if below == 0 { 157 + return nil, nil 158 + } 185 159 186 - func collectParents(node *threadNode, allNodes map[uint]*threadNode) []*threadNode { 187 - var parents []*threadNode 188 - current := node 189 - for current.replyTo != 0 { 190 - parent := allNodes[current.replyTo] 191 - if parent == nil { 192 - break 193 - } 194 - parents = append(parents, parent) 195 - current = parent 160 + type parThreadResults struct { 161 + node *bsky.UnspeccedGetPostThreadV2_ThreadItem 162 + children []*bsky.UnspeccedGetPostThreadV2_ThreadItem 196 163 } 197 - return parents 198 - } 164 + 165 + results := make([]parThreadResults, len(curnode.children)) 166 + 167 + var wg sync.WaitGroup 168 + for i := range curnode.children { 169 + ix := i 170 + wg.Go(func() { 171 + child := curnode.children[ix] 172 + 173 + results[ix].node = buildThreadItem(ctx, hydrator, child, depth+1, viewer) 174 + if child.missing { 175 + return 176 + } 199 177 200 - func collectReplies(ctx context.Context, hydrator *hydration.Hydrator, node *threadNode, currentDepth, maxDepth, branchingFactor int64, sort string, viewer string) ([]*bsky.UnspeccedGetPostThreadV2_ThreadItem, bool) { 201 - var items []*bsky.UnspeccedGetPostThreadV2_ThreadItem 202 - hasMore := false 178 + sub, err := collectReplies(ctx, hydrator, child, depth+1, below-1, branchingFactor, sort, viewer) 179 + if err != nil { 180 + slog.Error("failed to collect replies", "node", child.uri, "error", err) 181 + return 182 + } 203 183 204 - if currentDepth > maxDepth { 205 - return items, false 184 + results[ix].children = sub 185 + }) 206 186 } 207 187 208 - // Sort children based on sort parameter 209 - children := node.children 210 - // TODO: Actually sort based on the sort parameter (newest/oldest/top) 211 - // For now, just use the order we have 188 + wg.Wait() 212 189 213 - // Limit to branchingFactor 214 - limit := int(branchingFactor) 215 - if len(children) > limit { 216 - hasMore = true 217 - children = children[:limit] 190 + var out []*bsky.UnspeccedGetPostThreadV2_ThreadItem 191 + for _, res := range results { 192 + out = append(out, res.node) 193 + out = append(out, res.children...) 218 194 } 219 195 220 - for _, child := range children { 221 - item := buildThreadItem(ctx, hydrator, child, currentDepth, viewer) 222 - if item != nil { 223 - items = append(items, item) 196 + return out, nil 197 + } 224 198 225 - // Recursively collect replies 226 - if currentDepth < maxDepth { 227 - childReplies, childHasMore := collectReplies(ctx, hydrator, child, currentDepth+1, maxDepth, branchingFactor, sort, viewer) 228 - items = append(items, childReplies...) 229 - if childHasMore { 230 - hasMore = true 231 - } 232 - } 199 + func buildThreadItem(ctx context.Context, hydrator *hydration.Hydrator, node *threadTree, depth int64, viewer string) *bsky.UnspeccedGetPostThreadV2_ThreadItem { 200 + if node.missing { 201 + return &bsky.UnspeccedGetPostThreadV2_ThreadItem{ 202 + Depth: depth, 203 + Uri: node.uri, 204 + Value: &bsky.UnspeccedGetPostThreadV2_ThreadItem_Value{ 205 + UnspeccedDefs_ThreadItemNotFound: &bsky.UnspeccedDefs_ThreadItemNotFound{ 206 + LexiconTypeID: "app.bsky.unspecced.defs#threadItemNotFound", 207 + }, 208 + }, 233 209 } 234 210 } 235 211 236 - return items, hasMore 237 - } 238 - 239 - func buildThreadItem(ctx context.Context, hydrator *hydration.Hydrator, node *threadNode, depth int64, viewer string) *bsky.UnspeccedGetPostThreadV2_ThreadItem { 240 212 // Hydrate the post 241 - postInfo, err := hydrator.HydratePost(ctx, node.uri, viewer) 213 + postInfo, err := hydrator.HydratePostDB(ctx, node.uri, node.val, viewer) 242 214 if err != nil { 215 + slog.Error("failed to hydrate post in thread item", "uri", node.uri, "error", err) 243 216 // Return not found item 244 217 return &bsky.UnspeccedGetPostThreadV2_ThreadItem{ 245 218 Depth: depth, ··· 255 228 // Hydrate author 256 229 authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 257 230 if err != nil { 231 + slog.Error("failed to hydrate actor in thread item", "author", postInfo.Author, "error", err) 258 232 return &bsky.UnspeccedGetPostThreadV2_ThreadItem{ 259 233 Depth: depth, 260 234 Uri: node.uri, ··· 318 292 return string(parts) 319 293 } 320 294 321 - func extractRkeyFromURI(uri string) string { 322 - // URI format: at://did:plc:xxx/collection/rkey 323 - if len(uri) < 5 || uri[:5] != "at://" { 324 - return "" 295 + type threadTree struct { 296 + parent *threadTree 297 + children []*threadTree 298 + 299 + val *models.Post 300 + 301 + missing bool 302 + 303 + uri string 304 + cid string 305 + } 306 + 307 + func buildThreadTree(ctx context.Context, hydrator *hydration.Hydrator, db *gorm.DB, posts []*models.Post) (map[uint]*threadTree, error) { 308 + nodes := make(map[uint]*threadTree) 309 + for _, p := range posts { 310 + puri, err := hydrator.UriForPost(ctx, p) 311 + if err != nil { 312 + return nil, err 313 + } 314 + 315 + t := &threadTree{ 316 + val: p, 317 + uri: puri, 318 + } 319 + 320 + nodes[p.ID] = t 325 321 } 326 - // Find last slash 327 - for i := len(uri) - 1; i >= 5; i-- { 328 - if uri[i] == '/' { 329 - return uri[i+1:] 322 + 323 + missing := make(map[uint]*threadTree) 324 + for _, node := range nodes { 325 + if node.val.ReplyTo == 0 { 326 + continue 330 327 } 328 + 329 + pnode, ok := nodes[node.val.ReplyTo] 330 + if !ok { 331 + pnode = &threadTree{ 332 + missing: true, 333 + } 334 + missing[node.val.ReplyTo] = pnode 335 + 336 + var bspost bsky.FeedPost 337 + if err := bspost.UnmarshalCBOR(bytes.NewReader(node.val.Raw)); err != nil { 338 + return nil, err 339 + } 340 + 341 + if bspost.Reply == nil || bspost.Reply.Parent == nil { 342 + return nil, fmt.Errorf("node with parent had no parent in object") 343 + } 344 + 345 + pnode.uri = bspost.Reply.Parent.Uri 346 + pnode.cid = bspost.Reply.Parent.Cid 347 + 348 + /* Maybe we could force hydrate these? 349 + hydrator.AddMissingRecord(puri, true) 350 + */ 351 + } 352 + 353 + pnode.children = append(pnode.children, node) 354 + node.parent = pnode 331 355 } 332 - return "" 356 + 357 + for k, v := range missing { 358 + nodes[k] = v 359 + } 360 + 361 + return nodes, nil 333 362 }