A locally focused bluesky appview

Compare changes

Choose any two refs to compare.

+19
LICENSE-MIT
··· 1 + MIT License 2 + 3 + Permission is hereby granted, free of charge, to any person obtaining a copy 4 + of this software and associated documentation files (the "Software"), to deal 5 + in the Software without restriction, including without limitation the rights 6 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 + copies of the Software, and to permit persons to whom the Software is 8 + furnished to do so, subject to the following conditions: 9 + 10 + The above copyright notice and this permission notice shall be included in all 11 + copies or substantial portions of the Software. 12 + 13 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 + SOFTWARE.
+49
README.md
··· 190 190 This takes a while on first load since its building everything. 191 191 After that, load the localhost url it gives you and it _should_ work. 192 192 193 + ## Selective Backfill 194 + 195 + If you'd like to backfill a particular repo, just hit the following endpoint: 196 + 197 + ``` 198 + curl http://localhost:4444/rescan/<DID OR HANDLE> 199 + 200 + ``` 201 + 202 + It will take a minute but it should pull all records from that user. 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 + 193 242 ## License 194 243 195 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 + }
+2 -4
docker-compose.yml
··· 1 - version: '3.8' 2 - 3 1 services: 4 2 postgres: 5 3 image: postgres:15-alpine ··· 25 23 container_name: konbini-backend 26 24 environment: 27 25 - DATABASE_URL=postgres://konbini:konbini_password@postgres:5432/konbini?sslmode=disable 28 - - BSKY_HANDLE=${BSKY_HANDLE} 29 - - BSKY_PASSWORD=${BSKY_PASSWORD} 26 + - BSKY_HANDLE=${BSKY_HANDLE:?} 27 + - BSKY_PASSWORD=${BSKY_PASSWORD:?} 30 28 ports: 31 29 - "4444:4444" 32 30 depends_on:
-1128
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 - return err 195 - } 196 - 197 - reldids := []string{repo.Did} 198 - // care about a post if its in a thread of a user we are interested in 199 - if rec.Reply != nil && rec.Reply.Parent != nil && rec.Reply.Root != nil { 200 - reldids = append(reldids, rec.Reply.Parent.Uri, rec.Reply.Root.Uri) 201 - } 202 - // TODO: maybe also care if its mentioning a user we care about or quoting a user we care about? 203 - if !b.anyRelevantIdents(reldids...) { 204 - return nil 205 - } 206 - 207 - uri := "at://" + repo.Did + "/app.bsky.feed.post/" + rkey 208 - slog.Warn("adding post", "uri", uri) 209 - 210 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 211 - if err != nil { 212 - return fmt.Errorf("invalid timestamp: %w", err) 213 - } 214 - 215 - p := Post{ 216 - Created: created.Time(), 217 - Indexed: time.Now(), 218 - Author: repo.ID, 219 - Rkey: rkey, 220 - Raw: recb, 221 - Cid: cc.String(), 222 - } 223 - 224 - if rec.Reply != nil && rec.Reply.Parent != nil { 225 - if rec.Reply.Root == nil { 226 - return fmt.Errorf("post reply had nil root") 227 - } 228 - 229 - pinfo, err := b.postInfoForUri(ctx, rec.Reply.Parent.Uri) 230 - if err != nil { 231 - return fmt.Errorf("getting reply parent: %w", err) 232 - } 233 - 234 - p.ReplyTo = pinfo.ID 235 - p.ReplyToUsr = pinfo.Author 236 - 237 - thread, err := b.postIDForUri(ctx, rec.Reply.Root.Uri) 238 - if err != nil { 239 - return fmt.Errorf("getting thread root: %w", err) 240 - } 241 - 242 - p.InThread = thread 243 - 244 - if p.ReplyToUsr == b.s.myrepo.ID { 245 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, p.Author, uri, cc, NotifKindReply); err != nil { 246 - slog.Warn("failed to create notification", "uri", uri, "error", err) 247 - } 248 - } 249 - } 250 - 251 - if rec.Embed != nil { 252 - var rpref string 253 - if rec.Embed.EmbedRecord != nil && rec.Embed.EmbedRecord.Record != nil { 254 - rpref = rec.Embed.EmbedRecord.Record.Uri 255 - } 256 - if rec.Embed.EmbedRecordWithMedia != nil && 257 - rec.Embed.EmbedRecordWithMedia.Record != nil && 258 - rec.Embed.EmbedRecordWithMedia.Record.Record != nil { 259 - rpref = rec.Embed.EmbedRecordWithMedia.Record.Record.Uri 260 - } 261 - 262 - if rpref != "" && strings.Contains(rpref, "app.bsky.feed.post") { 263 - rp, err := b.postIDForUri(ctx, rpref) 264 - if err != nil { 265 - return fmt.Errorf("getting quote subject: %w", err) 266 - } 267 - 268 - p.Reposting = rp 269 - } 270 - } 271 - 272 - if err := b.doPostCreate(ctx, &p); err != nil { 273 - return err 274 - } 275 - 276 - // Check for mentions and create notifications 277 - if rec.Facets != nil { 278 - for _, facet := range rec.Facets { 279 - for _, feature := range facet.Features { 280 - if feature.RichtextFacet_Mention != nil { 281 - mentionDid := feature.RichtextFacet_Mention.Did 282 - // This is a mention 283 - mentionedRepo, err := b.getOrCreateRepo(ctx, mentionDid) 284 - if err != nil { 285 - slog.Warn("failed to get repo for mention", "did", mentionDid, "error", err) 286 - continue 287 - } 288 - 289 - // Create notification if the mentioned user is the current user 290 - if mentionedRepo.ID == b.s.myrepo.ID { 291 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, p.Author, uri, cc, NotifKindMention); err != nil { 292 - slog.Warn("failed to create mention notification", "uri", uri, "error", err) 293 - } 294 - } 295 - } 296 - } 297 - } 298 - } 299 - 300 - b.postInfoCache.Add(uri, cachedPostInfo{ 301 - ID: p.ID, 302 - Author: p.Author, 303 - }) 304 - 305 - return nil 306 - } 307 - 308 - func (b *PostgresBackend) doPostCreate(ctx context.Context, p *Post) error { 309 - /* 310 - if err := b.db.Clauses(clause.OnConflict{ 311 - Columns: []clause.Column{{Name: "author"}, {Name: "rkey"}}, 312 - DoUpdates: clause.AssignmentColumns([]string{"cid", "not_found", "raw", "created", "indexed"}), 313 - }).Create(p).Error; err != nil { 314 - return err 315 - } 316 - */ 317 - 318 - query := ` 319 - INSERT INTO posts (author, rkey, cid, not_found, raw, created, indexed, reposting, reply_to, reply_to_usr, in_thread) 320 - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) 321 - ON CONFLICT (author, rkey) 322 - DO UPDATE SET 323 - cid = $3, 324 - not_found = $4, 325 - raw = $5, 326 - created = $6, 327 - indexed = $7, 328 - reposting = $8, 329 - reply_to = $9, 330 - reply_to_usr = $10, 331 - in_thread = $11 332 - RETURNING id 333 - ` 334 - 335 - // Execute the query with parameters from the Post struct 336 - if err := b.pgx.QueryRow( 337 - ctx, 338 - query, 339 - p.Author, 340 - p.Rkey, 341 - p.Cid, 342 - p.NotFound, 343 - p.Raw, 344 - p.Created, 345 - p.Indexed, 346 - p.Reposting, 347 - p.ReplyTo, 348 - p.ReplyToUsr, 349 - p.InThread, 350 - ).Scan(&p.ID); err != nil { 351 - return err 352 - } 353 - 354 - return nil 355 - } 356 - 357 - func (b *PostgresBackend) HandleCreateLike(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 358 - var rec bsky.FeedLike 359 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 360 - return err 361 - } 362 - 363 - if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) { 364 - return nil 365 - } 366 - 367 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 368 - if err != nil { 369 - return fmt.Errorf("invalid timestamp: %w", err) 370 - } 371 - 372 - pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri) 373 - if err != nil { 374 - return fmt.Errorf("getting like subject: %w", err) 375 - } 376 - 377 - 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 { 378 - pgErr, ok := err.(*pgconn.PgError) 379 - if ok && pgErr.Code == "23505" { 380 - return nil 381 - } 382 - return err 383 - } 384 - 385 - // Create notification if the liked post belongs to the current user 386 - if pinfo.Author == b.s.myrepo.ID { 387 - uri := fmt.Sprintf("at://%s/app.bsky.feed.like/%s", repo.Did, rkey) 388 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, repo.ID, uri, cc, NotifKindLike); err != nil { 389 - slog.Warn("failed to create like notification", "uri", uri, "error", err) 390 - } 391 - } 392 - 393 - return nil 394 - } 395 - 396 - func (b *PostgresBackend) HandleCreateRepost(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 397 - var rec bsky.FeedRepost 398 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 399 - return err 400 - } 401 - 402 - if !b.anyRelevantIdents(repo.Did, rec.Subject.Uri) { 403 - return nil 404 - } 405 - 406 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 407 - if err != nil { 408 - return fmt.Errorf("invalid timestamp: %w", err) 409 - } 410 - 411 - pinfo, err := b.postInfoForUri(ctx, rec.Subject.Uri) 412 - if err != nil { 413 - return fmt.Errorf("getting repost subject: %w", err) 414 - } 415 - 416 - 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 { 417 - pgErr, ok := err.(*pgconn.PgError) 418 - if ok && pgErr.Code == "23505" { 419 - return nil 420 - } 421 - return err 422 - } 423 - 424 - // Create notification if the reposted post belongs to the current user 425 - if pinfo.Author == b.s.myrepo.ID { 426 - uri := fmt.Sprintf("at://%s/app.bsky.feed.repost/%s", repo.Did, rkey) 427 - if err := b.s.AddNotification(ctx, b.s.myrepo.ID, repo.ID, uri, cc, NotifKindRepost); err != nil { 428 - slog.Warn("failed to create repost notification", "uri", uri, "error", err) 429 - } 430 - } 431 - 432 - return nil 433 - } 434 - 435 - func (b *PostgresBackend) HandleCreateFollow(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 436 - var rec bsky.GraphFollow 437 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 438 - return err 439 - } 440 - 441 - if !b.anyRelevantIdents(repo.Did, rec.Subject) { 442 - return nil 443 - } 444 - 445 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 446 - if err != nil { 447 - return fmt.Errorf("invalid timestamp: %w", err) 448 - } 449 - 450 - subj, err := b.getOrCreateRepo(ctx, rec.Subject) 451 - if err != nil { 452 - return err 453 - } 454 - 455 - 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 { 456 - return err 457 - } 458 - 459 - return nil 460 - } 461 - 462 - func (b *PostgresBackend) HandleCreateBlock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 463 - var rec bsky.GraphBlock 464 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 465 - return err 466 - } 467 - 468 - if !b.anyRelevantIdents(repo.Did, rec.Subject) { 469 - return nil 470 - } 471 - 472 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 473 - if err != nil { 474 - return fmt.Errorf("invalid timestamp: %w", err) 475 - } 476 - 477 - subj, err := b.getOrCreateRepo(ctx, rec.Subject) 478 - if err != nil { 479 - return err 480 - } 481 - 482 - if err := b.db.Create(&Block{ 483 - Created: created.Time(), 484 - Indexed: time.Now(), 485 - Author: repo.ID, 486 - Rkey: rkey, 487 - Subject: subj.ID, 488 - }).Error; err != nil { 489 - return err 490 - } 491 - 492 - return nil 493 - } 494 - 495 - func (b *PostgresBackend) HandleCreateList(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 496 - var rec bsky.GraphList 497 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 498 - return err 499 - } 500 - 501 - if !b.anyRelevantIdents(repo.Did) { 502 - return nil 503 - } 504 - 505 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 506 - if err != nil { 507 - return fmt.Errorf("invalid timestamp: %w", err) 508 - } 509 - 510 - if err := b.db.Create(&List{ 511 - Created: created.Time(), 512 - Indexed: time.Now(), 513 - Author: repo.ID, 514 - Rkey: rkey, 515 - Raw: recb, 516 - }).Error; err != nil { 517 - return err 518 - } 519 - 520 - return nil 521 - } 522 - 523 - func (b *PostgresBackend) HandleCreateListitem(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 524 - var rec bsky.GraphListitem 525 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 526 - return err 527 - } 528 - if !b.anyRelevantIdents(repo.Did) { 529 - return nil 530 - } 531 - 532 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 533 - if err != nil { 534 - return fmt.Errorf("invalid timestamp: %w", err) 535 - } 536 - 537 - subj, err := b.getOrCreateRepo(ctx, rec.Subject) 538 - if err != nil { 539 - return err 540 - } 541 - 542 - list, err := b.getOrCreateList(ctx, rec.List) 543 - if err != nil { 544 - return err 545 - } 546 - 547 - if err := b.db.Create(&ListItem{ 548 - Created: created.Time(), 549 - Indexed: time.Now(), 550 - Author: repo.ID, 551 - Rkey: rkey, 552 - Subject: subj.ID, 553 - List: list.ID, 554 - }).Error; err != nil { 555 - return err 556 - } 557 - 558 - return nil 559 - } 560 - 561 - func (b *PostgresBackend) HandleCreateListblock(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 562 - var rec bsky.GraphListblock 563 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 564 - return err 565 - } 566 - 567 - if !b.anyRelevantIdents(repo.Did, rec.Subject) { 568 - return nil 569 - } 570 - 571 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 572 - if err != nil { 573 - return fmt.Errorf("invalid timestamp: %w", err) 574 - } 575 - 576 - list, err := b.getOrCreateList(ctx, rec.Subject) 577 - if err != nil { 578 - return err 579 - } 580 - 581 - if err := b.db.Create(&ListBlock{ 582 - Created: created.Time(), 583 - Indexed: time.Now(), 584 - Author: repo.ID, 585 - Rkey: rkey, 586 - List: list.ID, 587 - }).Error; err != nil { 588 - return err 589 - } 590 - 591 - return nil 592 - } 593 - 594 - func (b *PostgresBackend) HandleCreateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error { 595 - if !b.anyRelevantIdents(repo.Did) { 596 - return nil 597 - } 598 - 599 - if err := b.db.Create(&Profile{ 600 - //Created: created.Time(), 601 - Indexed: time.Now(), 602 - Repo: repo.ID, 603 - Raw: recb, 604 - Rev: rev, 605 - }).Error; err != nil { 606 - return err 607 - } 608 - 609 - return nil 610 - } 611 - 612 - func (b *PostgresBackend) HandleUpdateProfile(ctx context.Context, repo *Repo, rkey, rev string, recb []byte, cc cid.Cid) error { 613 - if !b.anyRelevantIdents(repo.Did) { 614 - return nil 615 - } 616 - 617 - if err := b.db.Create(&Profile{ 618 - Indexed: time.Now(), 619 - Repo: repo.ID, 620 - Raw: recb, 621 - Rev: rev, 622 - }).Error; err != nil { 623 - return err 624 - } 625 - 626 - return nil 627 - } 628 - 629 - func (b *PostgresBackend) HandleCreateFeedGenerator(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 630 - if !b.anyRelevantIdents(repo.Did) { 631 - return nil 632 - } 633 - 634 - var rec bsky.FeedGenerator 635 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 636 - return err 637 - } 638 - 639 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 640 - if err != nil { 641 - return fmt.Errorf("invalid timestamp: %w", err) 642 - } 643 - 644 - if err := b.db.Create(&FeedGenerator{ 645 - Created: created.Time(), 646 - Indexed: time.Now(), 647 - Author: repo.ID, 648 - Rkey: rkey, 649 - Did: rec.Did, 650 - Raw: recb, 651 - }).Error; err != nil { 652 - return err 653 - } 654 - 655 - return nil 656 - } 657 - 658 - func (b *PostgresBackend) HandleCreateThreadgate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 659 - if !b.anyRelevantIdents(repo.Did) { 660 - return nil 661 - } 662 - var rec bsky.FeedThreadgate 663 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 664 - return err 665 - } 666 - 667 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 668 - if err != nil { 669 - return fmt.Errorf("invalid timestamp: %w", err) 670 - } 671 - 672 - pid, err := b.postIDForUri(ctx, rec.Post) 673 - if err != nil { 674 - return err 675 - } 676 - 677 - if err := b.db.Create(&ThreadGate{ 678 - Created: created.Time(), 679 - Indexed: time.Now(), 680 - Author: repo.ID, 681 - Rkey: rkey, 682 - Post: pid, 683 - }).Error; err != nil { 684 - return err 685 - } 686 - 687 - return nil 688 - } 689 - 690 - func (b *PostgresBackend) HandleCreateChatDeclaration(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 691 - // TODO: maybe track these? 692 - return nil 693 - } 694 - 695 - func (b *PostgresBackend) HandleCreatePostGate(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 696 - if !b.anyRelevantIdents(repo.Did) { 697 - return nil 698 - } 699 - var rec bsky.FeedPostgate 700 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 701 - return err 702 - } 703 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 704 - if err != nil { 705 - return fmt.Errorf("invalid timestamp: %w", err) 706 - } 707 - 708 - refPost, err := b.postInfoForUri(ctx, rec.Post) 709 - if err != nil { 710 - return err 711 - } 712 - 713 - if err := b.db.Create(&PostGate{ 714 - Created: created.Time(), 715 - Indexed: time.Now(), 716 - Author: repo.ID, 717 - Rkey: rkey, 718 - Subject: refPost.ID, 719 - Raw: recb, 720 - }).Error; err != nil { 721 - return err 722 - } 723 - 724 - return nil 725 - } 726 - 727 - func (b *PostgresBackend) HandleCreateStarterPack(ctx context.Context, repo *Repo, rkey string, recb []byte, cc cid.Cid) error { 728 - if !b.anyRelevantIdents(repo.Did) { 729 - return nil 730 - } 731 - var rec bsky.GraphStarterpack 732 - if err := rec.UnmarshalCBOR(bytes.NewReader(recb)); err != nil { 733 - return err 734 - } 735 - created, err := syntax.ParseDatetimeLenient(rec.CreatedAt) 736 - if err != nil { 737 - return fmt.Errorf("invalid timestamp: %w", err) 738 - } 739 - 740 - list, err := b.getOrCreateList(ctx, rec.List) 741 - if err != nil { 742 - return err 743 - } 744 - 745 - if err := b.db.Create(&StarterPack{ 746 - Created: created.Time(), 747 - Indexed: time.Now(), 748 - Author: repo.ID, 749 - Rkey: rkey, 750 - Raw: recb, 751 - List: list.ID, 752 - }).Error; err != nil { 753 - return err 754 - } 755 - 756 - return nil 757 - } 758 - 759 - func (b *PostgresBackend) HandleUpdate(ctx context.Context, repo string, rev string, path string, rec *[]byte, cid *cid.Cid) error { 760 - start := time.Now() 761 - 762 - rr, err := b.getOrCreateRepo(ctx, repo) 763 - if err != nil { 764 - return fmt.Errorf("get user failed: %w", err) 765 - } 766 - 767 - lrev, err := b.revForRepo(rr) 768 - if err != nil { 769 - return err 770 - } 771 - if lrev != "" { 772 - if rev < lrev { 773 - //slog.Info("skipping old rev create", "did", rr.Did, "rev", rev, "oldrev", lrev, "path", path) 774 - return nil 775 - } 776 - } 777 - 778 - parts := strings.Split(path, "/") 779 - if len(parts) != 2 { 780 - return fmt.Errorf("invalid path in HandleCreate: %q", path) 781 - } 782 - col := parts[0] 783 - rkey := parts[1] 784 - 785 - defer func() { 786 - handleOpHist.WithLabelValues("update", col).Observe(float64(time.Since(start).Milliseconds())) 787 - }() 788 - 789 - if rkey == "" { 790 - fmt.Printf("messed up path: %q\n", rkey) 791 - } 792 - 793 - switch col { 794 - /* 795 - case "app.bsky.feed.post": 796 - if err := s.HandleCreatePost(ctx, rr, rkey, *rec, *cid); err != nil { 797 - return err 798 - } 799 - case "app.bsky.feed.like": 800 - if err := s.HandleCreateLike(ctx, rr, rkey, *rec, *cid); err != nil { 801 - return err 802 - } 803 - case "app.bsky.feed.repost": 804 - if err := s.HandleCreateRepost(ctx, rr, rkey, *rec, *cid); err != nil { 805 - return err 806 - } 807 - case "app.bsky.graph.follow": 808 - if err := s.HandleCreateFollow(ctx, rr, rkey, *rec, *cid); err != nil { 809 - return err 810 - } 811 - case "app.bsky.graph.block": 812 - if err := s.HandleCreateBlock(ctx, rr, rkey, *rec, *cid); err != nil { 813 - return err 814 - } 815 - case "app.bsky.graph.list": 816 - if err := s.HandleCreateList(ctx, rr, rkey, *rec, *cid); err != nil { 817 - return err 818 - } 819 - case "app.bsky.graph.listitem": 820 - if err := s.HandleCreateListitem(ctx, rr, rkey, *rec, *cid); err != nil { 821 - return err 822 - } 823 - case "app.bsky.graph.listblock": 824 - if err := s.HandleCreateListblock(ctx, rr, rkey, *rec, *cid); err != nil { 825 - return err 826 - } 827 - */ 828 - case "app.bsky.actor.profile": 829 - if err := b.HandleUpdateProfile(ctx, rr, rkey, rev, *rec, *cid); err != nil { 830 - return err 831 - } 832 - /* 833 - case "app.bsky.feed.generator": 834 - if err := s.HandleCreateFeedGenerator(ctx, rr, rkey, *rec, *cid); err != nil { 835 - return err 836 - } 837 - case "app.bsky.feed.threadgate": 838 - if err := s.HandleCreateThreadgate(ctx, rr, rkey, *rec, *cid); err != nil { 839 - return err 840 - } 841 - case "chat.bsky.actor.declaration": 842 - if err := s.HandleCreateChatDeclaration(ctx, rr, rkey, *rec, *cid); err != nil { 843 - return err 844 - } 845 - */ 846 - default: 847 - slog.Debug("unrecognized record type in update", "repo", repo, "path", path, "rev", rev) 848 - } 849 - 850 - return nil 851 - } 852 - 853 - func (b *PostgresBackend) HandleDelete(ctx context.Context, repo string, rev string, path string) error { 854 - start := time.Now() 855 - 856 - rr, err := b.getOrCreateRepo(ctx, repo) 857 - if err != nil { 858 - return fmt.Errorf("get user failed: %w", err) 859 - } 860 - 861 - lrev, ok := b.revCache.Get(rr.ID) 862 - if ok { 863 - if rev < lrev { 864 - //slog.Info("skipping old rev delete", "did", rr.Did, "rev", rev, "oldrev", lrev) 865 - return nil 866 - } 867 - } 868 - 869 - parts := strings.Split(path, "/") 870 - if len(parts) != 2 { 871 - return fmt.Errorf("invalid path in HandleDelete: %q", path) 872 - } 873 - col := parts[0] 874 - rkey := parts[1] 875 - 876 - defer func() { 877 - handleOpHist.WithLabelValues("create", col).Observe(float64(time.Since(start).Milliseconds())) 878 - }() 879 - 880 - switch col { 881 - case "app.bsky.feed.post": 882 - if err := b.HandleDeletePost(ctx, rr, rkey); err != nil { 883 - return err 884 - } 885 - case "app.bsky.feed.like": 886 - if err := b.HandleDeleteLike(ctx, rr, rkey); err != nil { 887 - return err 888 - } 889 - case "app.bsky.feed.repost": 890 - if err := b.HandleDeleteRepost(ctx, rr, rkey); err != nil { 891 - return err 892 - } 893 - case "app.bsky.graph.follow": 894 - if err := b.HandleDeleteFollow(ctx, rr, rkey); err != nil { 895 - return err 896 - } 897 - case "app.bsky.graph.block": 898 - if err := b.HandleDeleteBlock(ctx, rr, rkey); err != nil { 899 - return err 900 - } 901 - case "app.bsky.graph.list": 902 - if err := b.HandleDeleteList(ctx, rr, rkey); err != nil { 903 - return err 904 - } 905 - case "app.bsky.graph.listitem": 906 - if err := b.HandleDeleteListitem(ctx, rr, rkey); err != nil { 907 - return err 908 - } 909 - case "app.bsky.graph.listblock": 910 - if err := b.HandleDeleteListblock(ctx, rr, rkey); err != nil { 911 - return err 912 - } 913 - case "app.bsky.actor.profile": 914 - if err := b.HandleDeleteProfile(ctx, rr, rkey); err != nil { 915 - return err 916 - } 917 - case "app.bsky.feed.generator": 918 - if err := b.HandleDeleteFeedGenerator(ctx, rr, rkey); err != nil { 919 - return err 920 - } 921 - case "app.bsky.feed.threadgate": 922 - if err := b.HandleDeleteThreadgate(ctx, rr, rkey); err != nil { 923 - return err 924 - } 925 - default: 926 - slog.Warn("delete unrecognized record type", "repo", repo, "path", path, "rev", rev) 927 - } 928 - 929 - b.revCache.Add(rr.ID, rev) 930 - return nil 931 - } 932 - 933 - func (b *PostgresBackend) HandleDeletePost(ctx context.Context, repo *Repo, rkey string) error { 934 - var p Post 935 - if err := b.db.Find(&p, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 936 - return err 937 - } 938 - 939 - if p.ID == 0 { 940 - //slog.Warn("delete of unknown post record", "repo", repo.Did, "rkey", rkey) 941 - return nil 942 - } 943 - 944 - if err := b.db.Delete(&Post{}, p.ID).Error; err != nil { 945 - return err 946 - } 947 - 948 - return nil 949 - } 950 - 951 - func (b *PostgresBackend) HandleDeleteLike(ctx context.Context, repo *Repo, rkey string) error { 952 - var like Like 953 - if err := b.db.Find(&like, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 954 - return err 955 - } 956 - 957 - if like.ID == 0 { 958 - //slog.Warn("delete of missing like", "repo", repo.Did, "rkey", rkey) 959 - return nil 960 - } 961 - 962 - if err := b.db.Exec("DELETE FROM likes WHERE id = ?", like.ID).Error; err != nil { 963 - return err 964 - } 965 - 966 - return nil 967 - } 968 - 969 - func (b *PostgresBackend) HandleDeleteRepost(ctx context.Context, repo *Repo, rkey string) error { 970 - var repost Repost 971 - if err := b.db.Find(&repost, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 972 - return err 973 - } 974 - 975 - if repost.ID == 0 { 976 - //return fmt.Errorf("delete of missing repost: %s %s", repo.Did, rkey) 977 - return nil 978 - } 979 - 980 - if err := b.db.Exec("DELETE FROM reposts WHERE id = ?", repost.ID).Error; err != nil { 981 - return err 982 - } 983 - 984 - return nil 985 - } 986 - 987 - func (b *PostgresBackend) HandleDeleteFollow(ctx context.Context, repo *Repo, rkey string) error { 988 - var follow Follow 989 - if err := b.db.Find(&follow, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 990 - return err 991 - } 992 - 993 - if follow.ID == 0 { 994 - //slog.Warn("delete of missing follow", "repo", repo.Did, "rkey", rkey) 995 - return nil 996 - } 997 - 998 - if err := b.db.Exec("DELETE FROM follows WHERE id = ?", follow.ID).Error; err != nil { 999 - return err 1000 - } 1001 - 1002 - return nil 1003 - } 1004 - 1005 - func (b *PostgresBackend) HandleDeleteBlock(ctx context.Context, repo *Repo, rkey string) error { 1006 - var block Block 1007 - if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1008 - return err 1009 - } 1010 - 1011 - if block.ID == 0 { 1012 - //slog.Warn("delete of missing block", "repo", repo.Did, "rkey", rkey) 1013 - return nil 1014 - } 1015 - 1016 - if err := b.db.Exec("DELETE FROM blocks WHERE id = ?", block.ID).Error; err != nil { 1017 - return err 1018 - } 1019 - 1020 - return nil 1021 - } 1022 - 1023 - func (b *PostgresBackend) HandleDeleteList(ctx context.Context, repo *Repo, rkey string) error { 1024 - var list List 1025 - if err := b.db.Find(&list, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1026 - return err 1027 - } 1028 - 1029 - if list.ID == 0 { 1030 - return nil 1031 - //return fmt.Errorf("delete of missing list: %s %s", repo.Did, rkey) 1032 - } 1033 - 1034 - if err := b.db.Exec("DELETE FROM lists WHERE id = ?", list.ID).Error; err != nil { 1035 - return err 1036 - } 1037 - 1038 - return nil 1039 - } 1040 - 1041 - func (b *PostgresBackend) HandleDeleteListitem(ctx context.Context, repo *Repo, rkey string) error { 1042 - var item ListItem 1043 - if err := b.db.Find(&item, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1044 - return err 1045 - } 1046 - 1047 - if item.ID == 0 { 1048 - return nil 1049 - //return fmt.Errorf("delete of missing listitem: %s %s", repo.Did, rkey) 1050 - } 1051 - 1052 - if err := b.db.Exec("DELETE FROM list_items WHERE id = ?", item.ID).Error; err != nil { 1053 - return err 1054 - } 1055 - 1056 - return nil 1057 - } 1058 - 1059 - func (b *PostgresBackend) HandleDeleteListblock(ctx context.Context, repo *Repo, rkey string) error { 1060 - var block ListBlock 1061 - if err := b.db.Find(&block, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1062 - return err 1063 - } 1064 - 1065 - if block.ID == 0 { 1066 - return nil 1067 - //return fmt.Errorf("delete of missing listblock: %s %s", repo.Did, rkey) 1068 - } 1069 - 1070 - if err := b.db.Exec("DELETE FROM list_blocks WHERE id = ?", block.ID).Error; err != nil { 1071 - return err 1072 - } 1073 - 1074 - return nil 1075 - } 1076 - 1077 - func (b *PostgresBackend) HandleDeleteFeedGenerator(ctx context.Context, repo *Repo, rkey string) error { 1078 - var feedgen FeedGenerator 1079 - if err := b.db.Find(&feedgen, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1080 - return err 1081 - } 1082 - 1083 - if feedgen.ID == 0 { 1084 - return nil 1085 - //return fmt.Errorf("delete of missing feedgen: %s %s", repo.Did, rkey) 1086 - } 1087 - 1088 - if err := b.db.Exec("DELETE FROM feed_generators WHERE id = ?", feedgen.ID).Error; err != nil { 1089 - return err 1090 - } 1091 - 1092 - return nil 1093 - } 1094 - 1095 - func (b *PostgresBackend) HandleDeleteThreadgate(ctx context.Context, repo *Repo, rkey string) error { 1096 - var threadgate ThreadGate 1097 - if err := b.db.Find(&threadgate, "author = ? AND rkey = ?", repo.ID, rkey).Error; err != nil { 1098 - return err 1099 - } 1100 - 1101 - if threadgate.ID == 0 { 1102 - return nil 1103 - //return fmt.Errorf("delete of missing threadgate: %s %s", repo.Did, rkey) 1104 - } 1105 - 1106 - if err := b.db.Exec("DELETE FROM thread_gates WHERE id = ?", threadgate.ID).Error; err != nil { 1107 - return err 1108 - } 1109 - 1110 - return nil 1111 - } 1112 - 1113 - func (b *PostgresBackend) HandleDeleteProfile(ctx context.Context, repo *Repo, rkey string) error { 1114 - var profile Profile 1115 - if err := b.db.Find(&profile, "repo = ?", repo.ID).Error; err != nil { 1116 - return err 1117 - } 1118 - 1119 - if profile.ID == 0 { 1120 - return nil 1121 - } 1122 - 1123 - if err := b.db.Exec("DELETE FROM profiles WHERE id = ?", profile.ID).Error; err != nil { 1124 - return err 1125 - } 1126 - 1127 - return nil 1128 - }
+1 -1
frontend/Dockerfile
··· 1 1 # Build stage 2 - FROM node:18-alpine AS builder 2 + FROM node:24-alpine AS builder 3 3 4 4 WORKDIR /app 5 5
+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=
+59 -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 ··· 26 27 e.Use(middleware.CORS()) 27 28 e.GET("/debug", s.handleGetDebugInfo) 28 29 e.GET("/reldids", s.handleGetRelevantDids) 30 + e.GET("/rescan/:did", s.handleRescanDid) 29 31 30 32 views := e.Group("/api") 31 33 views.GET("/me", s.handleGetMe) ··· 55 57 56 58 func (s *Server) handleGetRelevantDids(e echo.Context) error { 57 59 return e.JSON(200, map[string]any{ 58 - "dids": s.backend.relevantDids, 60 + "dids": s.backend.GetRelevantDids(), 59 61 }) 60 62 } 61 63 64 + func (s *Server) handleRescanDid(e echo.Context) error { 65 + didparam := e.Param("did") 66 + 67 + ctx := e.Request().Context() 68 + did, err := s.resolveAccountIdent(ctx, didparam) 69 + if err != nil { 70 + return err 71 + } 72 + 73 + if err := s.rescanRepo(ctx, did); err != nil { 74 + return err 75 + } 76 + 77 + return e.JSON(200, map[string]any{"status": "ok"}) 78 + } 79 + 62 80 func (s *Server) handleGetMe(e echo.Context) error { 63 81 ctx := e.Request().Context() 64 82 ··· 88 106 89 107 postUri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", did, rkey) 90 108 91 - p, err := s.backend.getPostByUri(ctx, postUri, "*") 109 + p, err := s.backend.GetPostByUri(ctx, postUri, "*") 92 110 if err != nil { 93 111 return err 94 112 } ··· 117 135 return err 118 136 } 119 137 120 - r, err := s.backend.getOrCreateRepo(ctx, accdid) 138 + r, err := s.backend.GetOrCreateRepo(ctx, accdid) 121 139 if err != nil { 122 140 return err 123 141 } 124 142 125 143 var profile models.Profile 126 - 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 { 127 145 return err 128 146 } 129 147 130 148 if profile.Raw == nil || len(profile.Raw) == 0 { 131 - s.addMissingProfile(ctx, accdid) 149 + s.backend.TrackMissingRecord(accdid, false) 132 150 return e.JSON(404, map[string]any{ 133 151 "error": "missing profile info for user", 134 152 }) ··· 152 170 return err 153 171 } 154 172 155 - r, err := s.backend.getOrCreateRepo(ctx, accdid) 173 + r, err := s.backend.GetOrCreateRepo(ctx, accdid) 156 174 if err != nil { 157 175 return err 158 176 } ··· 171 189 } 172 190 173 191 var dbposts []models.Post 174 - 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 { 175 193 return err 176 194 } 177 195 ··· 241 259 func (s *Server) handleGetFollowingFeed(e echo.Context) error { 242 260 ctx := e.Request().Context() 243 261 244 - myr, err := s.backend.getOrCreateRepo(ctx, s.mydid) 262 + myr, err := s.backend.GetOrCreateRepo(ctx, s.mydid) 245 263 if err != nil { 246 264 return err 247 265 } ··· 259 277 tcursor = t 260 278 } 261 279 var dbposts []models.Post 262 - 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 { 263 281 return err 264 282 } 265 283 ··· 279 297 280 298 func (s *Server) getAuthorInfo(ctx context.Context, r *models.Repo) (*authorInfo, error) { 281 299 var profile models.Profile 282 - 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 { 283 301 return nil, err 284 302 } 285 303 ··· 289 307 } 290 308 291 309 if profile.Raw == nil || len(profile.Raw) == 0 { 292 - s.addMissingProfile(ctx, r.Did) 310 + s.backend.TrackMissingRecord(r.Did, false) 293 311 return &authorInfo{ 294 312 Handle: resp.Handle.String(), 295 313 Did: r.Did, ··· 316 334 317 335 go func() { 318 336 defer wg.Done() 319 - 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 { 320 338 slog.Error("failed to get likes count", "post", pid, "error", err) 321 339 } 322 340 }() 323 341 324 342 go func() { 325 343 defer wg.Done() 326 - 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 { 327 345 slog.Error("failed to get reposts count", "post", pid, "error", err) 328 346 } 329 347 }() 330 348 331 349 go func() { 332 350 defer wg.Done() 333 - 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 { 334 352 slog.Error("failed to get replies count", "post", pid, "error", err) 335 353 } 336 354 }() ··· 349 367 go func(ix int) { 350 368 defer wg.Done() 351 369 p := dbposts[ix] 352 - r, err := s.backend.getRepoByID(ctx, p.Author) 370 + r, err := s.backend.GetRepoByID(ctx, p.Author) 353 371 if err != nil { 354 372 fmt.Println("failed to get repo: ", err) 355 373 posts[ix] = postResponse{ ··· 361 379 362 380 uri := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", r.Did, p.Rkey) 363 381 if len(p.Raw) == 0 || p.NotFound { 364 - s.addMissingPost(ctx, uri) 382 + s.backend.TrackMissingRecord(uri, false) 365 383 posts[ix] = postResponse{ 366 384 Uri: uri, 367 385 Missing: true, ··· 417 435 418 436 func (s *Server) checkViewerLike(ctx context.Context, pid uint) *viewerLike { 419 437 var like Like 420 - 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 { 421 439 slog.Error("failed to lookup like", "error", err) 422 440 return nil 423 441 } ··· 494 512 quotedURI := embedRecord.Record.Uri 495 513 quotedCid := embedRecord.Record.Cid 496 514 497 - quotedPost, err := s.backend.getPostByUri(ctx, quotedURI, "*") 515 + quotedPost, err := s.backend.GetPostByUri(ctx, quotedURI, "*") 498 516 if err != nil { 499 517 slog.Warn("failed to get quoted post", "uri", quotedURI, "error", err) 500 - s.addMissingPost(ctx, quotedURI) 518 + s.backend.TrackMissingRecord(quotedURI, false) 501 519 return s.buildQuoteFallback(quotedURI, quotedCid) 502 520 } 503 521 504 522 if quotedPost == nil || quotedPost.Raw == nil || len(quotedPost.Raw) == 0 || quotedPost.NotFound { 505 - s.addMissingPost(ctx, quotedURI) 523 + s.backend.TrackMissingRecord(quotedURI, false) 506 524 return s.buildQuoteFallback(quotedURI, quotedCid) 507 525 } 508 526 ··· 512 530 return s.buildQuoteFallback(quotedURI, quotedCid) 513 531 } 514 532 515 - quotedRepo, err := s.backend.getRepoByID(ctx, quotedPost.Author) 533 + quotedRepo, err := s.backend.GetRepoByID(ctx, quotedPost.Author) 516 534 if err != nil { 517 535 slog.Warn("failed to get quoted post author", "error", err) 518 536 return s.buildQuoteFallback(quotedURI, quotedCid) ··· 559 577 560 578 // Get the requested post to find the thread root 561 579 var requestedPost models.Post 562 - if err := s.backend.db.Find(&requestedPost, "id = ?", postID).Error; err != nil { 580 + if err := s.db.Find(&requestedPost, "id = ?", postID).Error; err != nil { 563 581 return err 564 582 } 565 583 ··· 578 596 // Get all posts in this thread 579 597 var dbposts []models.Post 580 598 query := "SELECT * FROM posts WHERE id = ? OR in_thread = ? ORDER BY created ASC" 581 - 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 { 582 600 return err 583 601 } 584 602 585 603 // Build response for each post 586 604 posts := []postResponse{} 587 605 for _, p := range dbposts { 588 - r, err := s.backend.getRepoByID(ctx, p.Author) 606 + r, err := s.backend.GetRepoByID(ctx, p.Author) 589 607 if err != nil { 590 608 return err 591 609 } ··· 659 677 660 678 // Get all likes for this post 661 679 var likes []models.Like 662 - if err := s.backend.db.Find(&likes, "subject = ?", postID).Error; err != nil { 680 + if err := s.db.Find(&likes, "subject = ?", postID).Error; err != nil { 663 681 return err 664 682 } 665 683 666 684 users := []engagementUser{} 667 685 for _, like := range likes { 668 - r, err := s.backend.getRepoByID(ctx, like.Author) 686 + r, err := s.backend.GetRepoByID(ctx, like.Author) 669 687 if err != nil { 670 688 slog.Error("failed to get repo for like author", "error", err) 671 689 continue ··· 680 698 681 699 // Get profile if available 682 700 var profile models.Profile 683 - s.backend.db.Find(&profile, "repo = ?", r.ID) 701 + s.db.Find(&profile, "repo = ?", r.ID) 684 702 685 703 var prof *bsky.ActorProfile 686 704 if len(profile.Raw) > 0 { ··· 689 707 prof = &p 690 708 } 691 709 } else { 692 - s.addMissingProfile(ctx, r.Did) 710 + s.backend.TrackMissingRecord(r.Did, false) 693 711 } 694 712 695 713 users = append(users, engagementUser{ ··· 719 737 720 738 // Get all reposts for this post 721 739 var reposts []models.Repost 722 - if err := s.backend.db.Find(&reposts, "subject = ?", postID).Error; err != nil { 740 + if err := s.db.Find(&reposts, "subject = ?", postID).Error; err != nil { 723 741 return err 724 742 } 725 743 726 744 users := []engagementUser{} 727 745 for _, repost := range reposts { 728 - r, err := s.backend.getRepoByID(ctx, repost.Author) 746 + r, err := s.backend.GetRepoByID(ctx, repost.Author) 729 747 if err != nil { 730 748 slog.Error("failed to get repo for repost author", "error", err) 731 749 continue ··· 740 758 741 759 // Get profile if available 742 760 var profile models.Profile 743 - s.backend.db.Find(&profile, "repo = ?", r.ID) 761 + s.db.Find(&profile, "repo = ?", r.ID) 744 762 745 763 var prof *bsky.ActorProfile 746 764 if len(profile.Raw) > 0 { ··· 749 767 prof = &p 750 768 } 751 769 } else { 752 - s.addMissingProfile(ctx, r.Did) 770 + s.backend.TrackMissingRecord(r.Did, false) 753 771 } 754 772 755 773 users = append(users, engagementUser{ ··· 779 797 780 798 // Get all replies to this post 781 799 var replies []models.Post 782 - 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 { 783 801 return err 784 802 } 785 803 ··· 793 811 } 794 812 seen[reply.Author] = true 795 813 796 - r, err := s.backend.getRepoByID(ctx, reply.Author) 814 + r, err := s.backend.GetRepoByID(ctx, reply.Author) 797 815 if err != nil { 798 816 slog.Error("failed to get repo for reply author", "error", err) 799 817 continue ··· 808 826 809 827 // Get profile if available 810 828 var profile models.Profile 811 - s.backend.db.Find(&profile, "repo = ?", r.ID) 829 + s.db.Find(&profile, "repo = ?", r.ID) 812 830 813 831 var prof *bsky.ActorProfile 814 832 if len(profile.Raw) > 0 { ··· 817 835 prof = &p 818 836 } 819 837 } else { 820 - s.addMissingProfile(ctx, r.Did) 838 + s.backend.TrackMissingRecord(r.Did, false) 821 839 } 822 840 823 841 users = append(users, engagementUser{ ··· 915 933 query := `SELECT * FROM notifications WHERE "for" = ?` 916 934 if cursorID > 0 { 917 935 query += ` AND id < ?` 918 - 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 { 919 937 return err 920 938 } 921 939 } else { 922 - 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 { 923 941 return err 924 942 } 925 943 } ··· 928 946 results := []notificationResponse{} 929 947 for _, notif := range notifications { 930 948 // Get author info 931 - author, err := s.backend.getRepoByID(ctx, notif.Author) 949 + author, err := s.backend.GetRepoByID(ctx, notif.Author) 932 950 if err != nil { 933 951 slog.Error("failed to get repo for notification author", "error", err) 934 952 continue ··· 949 967 } 950 968 951 969 // Try to get source post preview for reply/mention notifications 952 - if notif.Kind == NotifKindReply || notif.Kind == NotifKindMention { 970 + if notif.Kind == backend.NotifKindReply || notif.Kind == backend.NotifKindMention { 953 971 // Parse URI to get post 954 - p, err := s.backend.getPostByUri(ctx, notif.Source, "*") 972 + p, err := s.backend.GetPostByUri(ctx, notif.Source, "*") 955 973 if err == nil && p.Raw != nil && len(p.Raw) > 0 { 956 974 var fp bsky.FeedPost 957 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) {
+15 -24
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 - missingActorCallback func(string) 14 - missingPostCallback func(string) 15 - missingFeedGeneratorCallback func(string) 11 + db *gorm.DB 12 + dir identity.Directory 13 + backend *backend.PostgresBackend 16 14 } 17 15 18 16 // NewHydrator creates a new Hydrator 19 - func NewHydrator(db *gorm.DB, dir identity.Directory) *Hydrator { 17 + func NewHydrator(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Hydrator { 20 18 return &Hydrator{ 21 - db: db, 22 - dir: dir, 19 + db: db, 20 + dir: dir, 21 + backend: backend, 23 22 } 24 23 } 25 24 26 - func (h *Hydrator) SetMissingActorCallback(fn func(string)) { 27 - h.missingActorCallback = fn 25 + // AddMissingRecord reports a missing record that needs to be fetched 26 + func (h *Hydrator) AddMissingRecord(identifier string, wait bool) { 27 + if h.backend != nil { 28 + h.backend.TrackMissingRecord(identifier, wait) 29 + } 28 30 } 29 31 32 + // addMissingActor is a convenience method for adding missing actors 30 33 func (h *Hydrator) addMissingActor(did string) { 31 - if h.missingActorCallback != nil { 32 - h.missingActorCallback(did) 33 - } 34 - } 35 - 36 - func (h *Hydrator) SetMissingFeedGeneratorCallback(fn func(string)) { 37 - h.missingFeedGeneratorCallback = fn 38 - } 39 - 40 - func (h *Hydrator) AddMissingFeedGenerator(uri string) { 41 - if h.missingFeedGeneratorCallback != nil { 42 - h.missingFeedGeneratorCallback(uri) 43 - } 34 + h.AddMissingRecord(did, false) 44 35 } 45 36 46 37 // HydrateCtx contains context for hydration operations
+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 + }
+105 -141
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 } 329 + 330 + s.backend.AddRelevantDid(did) 367 331 368 332 c := &xrpclib.Client{ 369 333 Host: resp.PDSEndpoint(),
-211
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 - } 28 - 29 - func (s *Server) addMissingRecord(ctx context.Context, rec MissingRecord) { 30 - select { 31 - case s.missingRecords <- rec: 32 - case <-ctx.Done(): 33 - } 34 - } 35 - 36 - // Legacy methods for backward compatibility 37 - func (s *Server) addMissingProfile(ctx context.Context, did string) { 38 - s.addMissingRecord(ctx, MissingRecord{ 39 - Type: MissingRecordTypeProfile, 40 - Identifier: did, 41 - }) 42 - } 43 - 44 - func (s *Server) addMissingPost(ctx context.Context, uri string) { 45 - slog.Info("adding missing post to fetch queue", "uri", uri) 46 - s.addMissingRecord(ctx, MissingRecord{ 47 - Type: MissingRecordTypePost, 48 - Identifier: uri, 49 - }) 50 - } 51 - 52 - func (s *Server) addMissingFeedGenerator(ctx context.Context, uri string) { 53 - slog.Info("adding missing feed generator to fetch queue", "uri", uri) 54 - s.addMissingRecord(ctx, MissingRecord{ 55 - Type: MissingRecordTypeFeedGenerator, 56 - Identifier: uri, 57 - }) 58 - } 59 - 60 - func (s *Server) missingRecordFetcher() { 61 - for rec := range s.missingRecords { 62 - var err error 63 - switch rec.Type { 64 - case MissingRecordTypeProfile: 65 - err = s.fetchMissingProfile(context.TODO(), rec.Identifier) 66 - case MissingRecordTypePost: 67 - err = s.fetchMissingPost(context.TODO(), rec.Identifier) 68 - case MissingRecordTypeFeedGenerator: 69 - err = s.fetchMissingFeedGenerator(context.TODO(), rec.Identifier) 70 - default: 71 - slog.Error("unknown missing record type", "type", rec.Type) 72 - continue 73 - } 74 - 75 - if err != nil { 76 - slog.Warn("failed to fetch missing record", "type", rec.Type, "identifier", rec.Identifier, "error", err) 77 - } 78 - } 79 - } 80 - 81 - func (s *Server) fetchMissingProfile(ctx context.Context, did string) error { 82 - repo, err := s.backend.getOrCreateRepo(ctx, did) 83 - if err != nil { 84 - return err 85 - } 86 - 87 - resp, err := s.dir.LookupDID(ctx, syntax.DID(did)) 88 - if err != nil { 89 - return err 90 - } 91 - 92 - c := &xrpclib.Client{ 93 - Host: resp.PDSEndpoint(), 94 - } 95 - 96 - rec, err := atproto.RepoGetRecord(ctx, c, "", "app.bsky.actor.profile", did, "self") 97 - if err != nil { 98 - return err 99 - } 100 - 101 - prof, ok := rec.Value.Val.(*bsky.ActorProfile) 102 - if !ok { 103 - return fmt.Errorf("record we got back wasnt a profile somehow") 104 - } 105 - 106 - buf := new(bytes.Buffer) 107 - if err := prof.MarshalCBOR(buf); err != nil { 108 - return err 109 - } 110 - 111 - cc, err := cid.Decode(*rec.Cid) 112 - if err != nil { 113 - return err 114 - } 115 - 116 - return s.backend.HandleUpdateProfile(ctx, repo, "self", "", buf.Bytes(), cc) 117 - } 118 - 119 - func (s *Server) fetchMissingPost(ctx context.Context, uri string) error { 120 - puri, err := syntax.ParseATURI(uri) 121 - if err != nil { 122 - return fmt.Errorf("invalid AT URI: %s", uri) 123 - } 124 - 125 - did := puri.Authority().String() 126 - collection := puri.Collection().String() 127 - rkey := puri.RecordKey().String() 128 - 129 - repo, err := s.backend.getOrCreateRepo(ctx, did) 130 - if err != nil { 131 - return err 132 - } 133 - 134 - resp, err := s.dir.LookupDID(ctx, syntax.DID(did)) 135 - if err != nil { 136 - return err 137 - } 138 - 139 - c := &xrpclib.Client{ 140 - Host: resp.PDSEndpoint(), 141 - } 142 - 143 - rec, err := atproto.RepoGetRecord(ctx, c, "", collection, did, rkey) 144 - if err != nil { 145 - return err 146 - } 147 - 148 - post, ok := rec.Value.Val.(*bsky.FeedPost) 149 - if !ok { 150 - return fmt.Errorf("record we got back wasn't a post somehow") 151 - } 152 - 153 - buf := new(bytes.Buffer) 154 - if err := post.MarshalCBOR(buf); err != nil { 155 - return err 156 - } 157 - 158 - cc, err := cid.Decode(*rec.Cid) 159 - if err != nil { 160 - return err 161 - } 162 - 163 - return s.backend.HandleCreatePost(ctx, repo, rkey, buf.Bytes(), cc) 164 - } 165 - 166 - func (s *Server) fetchMissingFeedGenerator(ctx context.Context, uri string) error { 167 - puri, err := syntax.ParseATURI(uri) 168 - if err != nil { 169 - return fmt.Errorf("invalid AT URI: %s", uri) 170 - } 171 - 172 - did := puri.Authority().String() 173 - collection := puri.Collection().String() 174 - rkey := puri.RecordKey().String() 175 - 176 - repo, err := s.backend.getOrCreateRepo(ctx, did) 177 - if err != nil { 178 - return err 179 - } 180 - 181 - resp, err := s.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 s.backend.HandleCreateFeedGenerator(ctx, repo, rkey, buf.Bytes(), cc) 211 - }
+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 + }
-412
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) didIsRelevant(did string) bool { 371 - b.rdLk.Lock() 372 - defer b.rdLk.Unlock() 373 - return b.relevantDids[did] 374 - } 375 - 376 - func (b *PostgresBackend) anyRelevantIdents(idents ...string) bool { 377 - for _, id := range idents { 378 - if strings.HasPrefix(id, "did:") { 379 - if b.didIsRelevant(id) { 380 - return true 381 - } 382 - } else if strings.HasPrefix(id, "at://") { 383 - puri, err := syntax.ParseATURI(id) 384 - if err != nil { 385 - continue 386 - } 387 - 388 - if b.didIsRelevant(puri.Authority().String()) { 389 - return true 390 - } 391 - } 392 - } 393 - 394 - return false 395 - } 396 - 397 - func (b *PostgresBackend) getRepoByID(ctx context.Context, id uint) (*models.Repo, error) { 398 - var r models.Repo 399 - if err := b.db.Find(&r, "id = ?", id).Error; err != nil { 400 - return nil, err 401 - } 402 - 403 - return &r, nil 404 - } 405 - 406 - func (b *PostgresBackend) TrackMissingActor(did string) { 407 - b.s.addMissingProfile(context.TODO(), did) 408 - } 409 - 410 - func (b *PostgresBackend) TrackMissingFeedGenerator(uri string) { 411 - b.s.addMissingFeedGenerator(context.TODO(), uri) 412 - }
+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
+38 -19
xrpc/feed/getFeed.go
··· 5 5 "log/slog" 6 6 "net/http" 7 7 "strconv" 8 + "strings" 9 + "sync" 8 10 9 11 "github.com/bluesky-social/indigo/api/bsky" 10 12 "github.com/bluesky-social/indigo/atproto/identity" ··· 64 66 } 65 67 66 68 if feedGen.ID == 0 { 67 - hydrator.AddMissingFeedGenerator(feedURI) 69 + hydrator.AddMissingRecord(feedURI, true) 68 70 return c.JSON(http.StatusNotFound, map[string]any{ 69 71 "error": "NotFound", 70 72 "message": "feed generator not found", ··· 149 151 } 150 152 151 153 // Hydrate the posts from the skeleton 152 - posts := make([]*bsky.FeedDefs_FeedViewPost, 0, len(skeleton.Feed)) 153 - for _, skeletonPost := range skeleton.Feed { 154 - postURI, err := syntax.ParseATURI(skeletonPost.Post) 155 - if err != nil { 156 - slog.Warn("invalid post URI in skeleton", "uri", skeletonPost.Post, "error", err) 157 - continue 158 - } 154 + posts := make([]*bsky.FeedDefs_FeedViewPost, len(skeleton.Feed)) 155 + var wg sync.WaitGroup 156 + for i := range skeleton.Feed { 157 + wg.Add(1) 158 + go func(ix int) { 159 + defer wg.Done() 160 + skeletonPost := skeleton.Feed[ix] 161 + postURI, err := syntax.ParseATURI(skeletonPost.Post) 162 + if err != nil { 163 + slog.Warn("invalid post URI in skeleton", "uri", skeletonPost.Post, "error", err) 164 + return 165 + } 159 166 160 - postInfo, err := hydrator.HydratePost(ctx, string(postURI), viewer) 161 - if err != nil { 162 - slog.Warn("failed to hydrate post", "uri", postURI, "error", err) 163 - continue 164 - } 167 + postInfo, err := hydrator.HydratePost(ctx, postURI.String(), viewer) 168 + if err != nil { 169 + if strings.Contains(err.Error(), "post not found") { 170 + hydrator.AddMissingRecord(postURI.String(), true) 171 + postInfo, err = hydrator.HydratePost(ctx, postURI.String(), viewer) 172 + if err != nil { 173 + slog.Error("failed to hydrate post after fetch missing", "uri", postURI, "error", err) 174 + return 175 + } 176 + } else { 177 + slog.Warn("failed to hydrate post", "uri", postURI, "error", err) 178 + return 179 + } 180 + } 165 181 166 - authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 167 - if err != nil { 168 - slog.Warn("failed to hydrate author", "did", postInfo.Author, "error", err) 169 - continue 170 - } 182 + authorInfo, err := hydrator.HydrateActor(ctx, postInfo.Author) 183 + if err != nil { 184 + hydrator.AddMissingRecord(postInfo.Author, false) 185 + slog.Warn("failed to hydrate author", "did", postInfo.Author, "error", err) 186 + return 187 + } 171 188 172 - posts = append(posts, views.FeedViewPost(postInfo, authorInfo)) 189 + posts[ix] = views.FeedViewPost(postInfo, authorInfo) 190 + }(i) 173 191 } 192 + wg.Wait() 174 193 175 194 output := &bsky.FeedGetFeed_Output{ 176 195 Feed: posts,
+1 -1
xrpc/feed/getFeedGenerator.go
··· 68 68 69 69 if err != nil || feedGen.ID == 0 { 70 70 // Track this missing feed generator for fetching 71 - hydrator.AddMissingFeedGenerator(feedURI) 71 + hydrator.AddMissingRecord(feedURI, true) 72 72 73 73 return c.JSON(http.StatusNotFound, map[string]any{ 74 74 "error": "NotFound",
+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 {
+15 -10
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" ··· 31 34 type Backend interface { 32 35 // Add methods as needed for data access 33 36 34 - TrackMissingActor(did string) 35 - TrackMissingFeedGenerator(uri string) 37 + TrackMissingRecord(identifier string, wait bool) 38 + GetOrCreateRepo(ctx context.Context, did string) (*models.Repo, error) 36 39 } 37 40 38 41 // NewServer creates a new XRPC server 39 - func NewServer(db *gorm.DB, dir identity.Directory, backend Backend) *Server { 42 + func NewServer(db *gorm.DB, dir identity.Directory, backend *backend.PostgresBackend) *Server { 40 43 e := echo.New() 41 44 e.HidePort = true 42 45 e.HideBanner = true ··· 57 60 db: db, 58 61 dir: dir, 59 62 backend: backend, 60 - hydrator: hydration.NewHydrator(db, dir), 63 + hydrator: hydration.NewHydrator(db, dir, backend), 61 64 } 62 65 63 - s.hydrator.SetMissingActorCallback(backend.TrackMissingActor) 64 - s.hydrator.SetMissingFeedGeneratorCallback(backend.TrackMissingFeedGenerator) 65 - 66 66 // Register XRPC endpoints 67 67 s.registerEndpoints() 68 68 ··· 78 78 // registerEndpoints registers all XRPC endpoints 79 79 func (s *Server) registerEndpoints() { 80 80 // XRPC endpoints follow the pattern: /xrpc/<namespace>.<method> 81 + 82 + s.e.GET("/.well-known/did.json", func(c echo.Context) error { 83 + return c.File("did.json") 84 + }) 85 + 81 86 xrpcGroup := s.e.Group("/xrpc") 82 87 83 88 // com.atproto.identity.* ··· 91 96 // app.bsky.actor.* 92 97 xrpcGroup.GET("/app.bsky.actor.getProfile", func(c echo.Context) error { 93 98 return actor.HandleGetProfile(c, s.hydrator) 94 - }) 99 + }, s.optionalAuth) 95 100 xrpcGroup.GET("/app.bsky.actor.getProfiles", func(c echo.Context) error { 96 101 return actor.HandleGetProfiles(c, s.db, s.hydrator) 97 - }) 102 + }, s.optionalAuth) 98 103 xrpcGroup.GET("/app.bsky.actor.getPreferences", func(c echo.Context) error { 99 104 return actor.HandleGetPreferences(c, s.db, s.hydrator) 100 105 }, s.requireAuth) ··· 128 133 }, s.requireAuth) 129 134 xrpcGroup.GET("/app.bsky.feed.getFeed", func(c echo.Context) error { 130 135 return feed.HandleGetFeed(c, s.db, s.hydrator, s.dir) 131 - }) 136 + }, s.optionalAuth) 132 137 xrpcGroup.GET("/app.bsky.feed.getFeedGenerator", func(c echo.Context) error { 133 138 return feed.HandleGetFeedGenerator(c, s.db, s.hydrator, s.dir) 134 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 }