A social RSS reader built on the AT Protocol. glean.at
glean atproto atmosphere rss feed social app
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

Refactor database access

+638 -633
+2 -2
.env.example
··· 2 2 GLEAN_DB=glean.db 3 3 GLEAN_JETSTREAM=wss://jetstream.glean.at 4 4 GLEAN_PLC_URL=https://didplc.glean.at 5 - GLEAN_SYNC_INTERVAL=30m 6 - GLEAN_CLUSTER_INTERVAL=1h 5 + GLEAN_SYNC_INTERVAL=10m 6 + GLEAN_CLUSTER_INTERVAL=15m 7 7 GLEAN_FETCH_INTERVAL=5m 8 8 GLEAN_COLLECTION_DIR_URL=https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription 9 9 # Leave empty for localhost OAuth (development)
+3 -3
internal/atproto/stream_handler.go
··· 23 23 } 24 24 25 25 type StreamDBHandler struct { 26 - articles *db.DB 27 - users *db.DB 26 + articles *db.ArticleStore 27 + users *db.UserStore 28 28 logger *slog.Logger 29 29 } 30 30 31 - func NewStreamDBHandler(articles, users *db.DB, logger *slog.Logger) *StreamDBHandler { 31 + func NewStreamDBHandler(articles *db.ArticleStore, users *db.UserStore, logger *slog.Logger) *StreamDBHandler { 32 32 return &StreamDBHandler{articles: articles, users: users, logger: logger} 33 33 } 34 34
+3 -3
internal/atproto/sync.go
··· 20 20 ) 21 21 22 22 type Sync struct { 23 - articles *db.DB 24 - users *db.DB 23 + articles *db.ArticleStore 24 + users *db.UserStore 25 25 client *Client 26 26 logger *slog.Logger 27 27 } 28 28 29 - func NewSync(articles, users *db.DB, client *Client, logger *slog.Logger) *Sync { 29 + func NewSync(articles *db.ArticleStore, users *db.UserStore, client *Client, logger *slog.Logger) *Sync { 30 30 return &Sync{articles: articles, users: users, client: client, logger: logger} 31 31 } 32 32
+2
internal/cluster/jaccard.go
··· 24 24 } 25 25 } 26 26 27 + // Engine uses *sql.DB directly because it performs cross-schema transactions 28 + // across main, articles, and recs. Typed stores would add overhead without benefit here. 27 29 type Engine struct { 28 30 db *sql.DB 29 31 logger *slog.Logger
+32 -32
internal/cluster/jaccard_test.go
··· 46 46 {"did:test:carol", "carol"}, 47 47 } 48 48 for _, u := range users { 49 - _, err := dbs.Users.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, u.did, u.handle) 49 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, u.did, u.handle) 50 50 assert.NilError(t, err) 51 51 } 52 52 ··· 58 58 {"https://e.com/feed", "Feed E"}, 59 59 } 60 60 for _, f := range feeds { 61 - _, err := dbs.Articles.ExecContext(ctx, `INSERT INTO feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, '', 'rss', 2)`, f.url, f.title, f.url) 61 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type, subscriber_count) VALUES (?, ?, ?, '', 'rss', 2)`, f.url, f.title, f.url) 62 62 assert.NilError(t, err) 63 63 } 64 64 ··· 74 74 {"did:test:carol", "https://c.com/feed"}, 75 75 } 76 76 for _, s := range subs { 77 - _, err := dbs.Articles.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, s.user, s.feed) 77 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, s.user, s.feed) 78 78 assert.NilError(t, err) 79 79 } 80 80 } ··· 86 86 {"did:test:bob", "did:test:carol"}, 87 87 } 88 88 for _, f := range follows { 89 - _, err := dbs.Users.ExecContext(ctx, `INSERT OR IGNORE INTO follows (user_did, target_did) VALUES (?, ?)`, f.user, f.target) 89 + _, err := dbs.DB().ExecContext(ctx, `INSERT OR IGNORE INTO follows (user_did, target_did) VALUES (?, ?)`, f.user, f.target) 90 90 assert.NilError(t, err) 91 91 } 92 92 } 93 93 94 94 func newTestEngine(dbs *db.Databases) *Engine { 95 - return NewEngine(dbs.Users.DB, slog.Default()) 95 + return NewEngine(dbs.DB(), slog.Default()) 96 96 } 97 97 98 98 func TestComputeFeedSimilarity(t *testing.T) { ··· 105 105 assert.NilError(t, err) 106 106 107 107 var count int 108 - err = dbs.Users.QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.feed_similarity`).Scan(&count) 108 + err = dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.feed_similarity`).Scan(&count) 109 109 assert.NilError(t, err) 110 110 assert.Assert(t, count > 0, "expected feed similarity pairs") 111 111 } ··· 120 120 assert.NilError(t, err) 121 121 122 122 var count int 123 - err = dbs.Users.QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.user_similarity`).Scan(&count) 123 + err = dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.user_similarity`).Scan(&count) 124 124 assert.NilError(t, err) 125 125 assert.Assert(t, count > 0, "expected user similarity pairs") 126 126 } ··· 222 222 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions)) 223 223 224 224 var count int 225 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, 225 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, 226 226 `SELECT COUNT(*) FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count)) 227 227 assert.Equal(t, count, 2) 228 228 229 229 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions)) 230 230 231 231 var shownCount int 232 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, 232 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, 233 233 `SELECT shown_count FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&shownCount)) 234 234 assert.Equal(t, shownCount, 2, "shown_count should increment on repeated impression") 235 235 } ··· 247 247 assert.NilError(t, engine.MarkImpressionActed(ctx, "did:test:alice", "feed", "https://a.com/feed")) 248 248 249 249 var acted bool 250 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, 250 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, 251 251 `SELECT acted FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&acted)) 252 252 assert.Assert(t, acted, "impression should be marked as acted") 253 253 } ··· 262 262 assert.NilError(t, engine.ComputeFollowDistances(ctx)) 263 263 264 264 var d1, d2 int 265 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, 265 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, 266 266 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 1`).Scan(&d1)) 267 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, 267 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, 268 268 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 2`).Scan(&d2)) 269 269 assert.Assert(t, d1 >= 2, "expected at least 2 direct follow distances") 270 270 assert.Assert(t, d2 >= 1, "expected at least 1 two-hop distance (alice -> bob -> carol)") 271 271 272 272 var dist int 273 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, 273 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, 274 274 `SELECT distance FROM recs.follow_distances WHERE user_a = 'did:test:alice' AND user_b = 'did:test:carol'`).Scan(&dist)) 275 275 assert.Equal(t, dist, 2, "alice should be 2 hops from carol") 276 276 } ··· 282 282 283 283 engine := newTestEngine(dbs) 284 284 285 - _, err := dbs.Users.ExecContext(ctx, ` 285 + _, err := dbs.DB().ExecContext(ctx, ` 286 286 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted) 287 287 VALUES ('did:test:alice', 'feed', 'https://stale.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 0) 288 288 `) ··· 302 302 303 303 engine := newTestEngine(dbs) 304 304 305 - _, err := dbs.Users.ExecContext(ctx, ` 305 + _, err := dbs.DB().ExecContext(ctx, ` 306 306 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted) 307 307 VALUES ('did:test:alice', 'feed', 'https://recent.com/feed', datetime('now'), datetime('now'), 5, 0) 308 308 `) ··· 322 322 323 323 engine := newTestEngine(dbs) 324 324 325 - _, err := dbs.Users.ExecContext(ctx, ` 325 + _, err := dbs.DB().ExecContext(ctx, ` 326 326 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted) 327 327 VALUES ('did:test:alice', 'feed', 'https://acted.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 1) 328 328 `) ··· 397 397 398 398 engine := newTestEngine(dbs) 399 399 400 - _, err := dbs.Users.ExecContext(ctx, ` 400 + _, err := dbs.DB().ExecContext(ctx, ` 401 401 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted) 402 402 VALUES ('did:test:alice', 'feed', 'https://a.com/feed', datetime('now'), datetime('now'), 1, 1) 403 403 `) 404 404 assert.NilError(t, err) 405 405 for i := range minActionsTune { 406 - _, err = dbs.Users.ExecContext(ctx, ` 406 + _, err = dbs.DB().ExecContext(ctx, ` 407 407 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted) 408 408 VALUES ('did:test:alice', 'feed', ?, datetime('now'), datetime('now'), 1, 1) 409 409 `, fmt.Sprintf("https://%d.com/feed", i)) ··· 425 425 engine := newTestEngine(dbs) 426 426 assert.NilError(t, engine.ComputeFollowDistances(ctx)) 427 427 428 - _, err := dbs.Articles.ExecContext(ctx, `UPDATE feeds SET subscriber_count = 2 WHERE feed_url = 'https://a.com/feed'`) 428 + _, err := dbs.DB().ExecContext(ctx, `UPDATE articles.feeds SET subscriber_count = 2 WHERE feed_url = 'https://a.com/feed'`) 429 429 assert.NilError(t, err) 430 - _, err = dbs.Articles.ExecContext(ctx, `UPDATE feeds SET subscriber_count = 2 WHERE feed_url = 'https://b.com/feed'`) 430 + _, err = dbs.DB().ExecContext(ctx, `UPDATE articles.feeds SET subscriber_count = 2 WHERE feed_url = 'https://b.com/feed'`) 431 431 assert.NilError(t, err) 432 432 433 - _, err = dbs.Users.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:newuser", "newuser") 433 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:newuser", "newuser") 434 434 assert.NilError(t, err) 435 435 436 436 recs, err := engine.ColdStartRecommendations(ctx, "did:test:newuser", 10) ··· 475 475 assert.NilError(t, engine.DismissArticle(ctx, "did:test:alice", "https://a.com/article1", "not_interested")) 476 476 477 477 var count int 478 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, 478 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, 479 479 `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'article'`).Scan(&count)) 480 480 assert.Equal(t, count, 1) 481 481 } ··· 489 489 assert.NilError(t, engine.ComputeSignalProfiles(ctx)) 490 490 491 491 var count int 492 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.user_signal_profiles`).Scan(&count)) 492 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.user_signal_profiles`).Scan(&count)) 493 493 assert.Assert(t, count >= 3, "expected signal profiles for all users") 494 494 } 495 495 ··· 504 504 assert.NilError(t, engine.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason2")) 505 505 506 506 var count int 507 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, 507 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, 508 508 `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'feed'`).Scan(&count)) 509 509 assert.Equal(t, count, 1, "duplicate dismiss should not create extra rows") 510 510 } ··· 513 513 ctx := context.Background() 514 514 dbs := setupClusterTestDB(t) 515 515 516 - _, err := dbs.Users.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:alice", "alice") 516 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:alice", "alice") 517 517 assert.NilError(t, err) 518 - _, err = dbs.Users.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:bob", "bob") 518 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:bob", "bob") 519 519 assert.NilError(t, err) 520 520 521 - _, err = dbs.Articles.ExecContext(ctx, `INSERT INTO feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`, 521 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`, 522 522 "https://go.com/feed", "Go Blog", "https://go.com", "programming language golang software development") 523 523 assert.NilError(t, err) 524 - _, err = dbs.Articles.ExecContext(ctx, `INSERT INTO feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`, 524 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type) VALUES (?, ?, ?, ?, 'rss')`, 525 525 "https://rust.com/feed", "Rust Blog", "https://rust.com", "programming language rust software development") 526 526 assert.NilError(t, err) 527 527 528 - _, err = dbs.Articles.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://go.com/feed") 528 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:alice", "https://go.com/feed") 529 529 assert.NilError(t, err) 530 - _, err = dbs.Articles.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://rust.com/feed") 530 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, "did:test:bob", "https://rust.com/feed") 531 531 assert.NilError(t, err) 532 532 533 533 engine := newTestEngine(dbs) 534 534 assert.NilError(t, engine.ComputeFeedSimilarity(ctx)) 535 535 536 536 var count int 537 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.feed_similarity`).Scan(&count)) 537 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM recs.feed_similarity`).Scan(&count)) 538 538 assert.Assert(t, count >= 0, "description-based similarity should produce pairs") 539 539 540 540 if count > 0 { 541 541 var jaccard float64 542 - assert.NilError(t, dbs.Users.QueryRowContext(ctx, 542 + assert.NilError(t, dbs.DB().QueryRowContext(ctx, 543 543 `SELECT jaccard FROM recs.feed_similarity WHERE feed_a = ? AND feed_b = ?`, 544 544 "https://go.com/feed", "https://rust.com/feed").Scan(&jaccard)) 545 545 assert.Assert(t, jaccard > 0, "description word overlap should boost similarity")
+101 -92
internal/db/article.go
··· 10 10 "pkg.rbrt.fr/glean/internal/feed" 11 11 ) 12 12 13 + type ArticleStore struct { 14 + db *DB 15 + } 16 + 17 + func NewArticleStore(db *DB) *ArticleStore { 18 + return &ArticleStore{db: db} 19 + } 20 + 13 21 type Article struct { 14 22 ID int64 15 23 FeedURL string ··· 37 45 ReadAt sql.NullTime 38 46 } 39 47 40 - func (db *DB) UpsertArticlesBatch(ctx context.Context, articles []feed.Article) error { 48 + func (s *ArticleStore) UpsertArticlesBatch(ctx context.Context, articles []feed.Article) error { 41 49 if len(articles) == 0 { 42 50 return nil 43 51 } 44 52 45 - tx, err := db.BeginTx(ctx, nil) 53 + tx, err := s.db.BeginTx(ctx, nil) 46 54 if err != nil { 47 55 return err 48 56 } 49 57 defer tx.Rollback() 50 58 51 59 stmt, err := tx.PrepareContext(ctx, ` 52 - INSERT INTO articles (feed_url, guid, title, url, author, summary, content, published, updated) 60 + INSERT INTO articles.articles (feed_url, guid, title, url, author, summary, content, published, updated) 53 61 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) 54 62 ON CONFLICT(feed_url, guid) DO NOTHING 55 63 `) ··· 79 87 return tx.Commit() 80 88 } 81 89 82 - func (db *DB) GetArticle(ctx context.Context, id int64) (*Article, error) { 90 + func (s *ArticleStore) GetArticle(ctx context.Context, id int64) (*Article, error) { 83 91 a := &Article{} 84 - err := db.QueryRowContext(ctx, ` 92 + err := s.db.QueryRowContext(ctx, ` 85 93 SELECT id, feed_url, guid, title, url, author, summary, content, full_content, published, updated, fetched_at 86 - FROM articles WHERE id = ? 94 + FROM articles.articles WHERE id = ? 87 95 `, id).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author, 88 96 &a.Summary, &a.Content, &a.FullContent, &a.Published, &a.Updated, &a.FetchedAt) 89 97 if err != nil { ··· 92 100 return a, nil 93 101 } 94 102 95 - func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { 103 + func (s *ArticleStore) ListArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { 96 104 var query string 97 105 var args []any 98 106 ··· 103 111 COALESCE(r.is_read, 0), 104 112 COALESCE(lc.cnt, 0), 105 113 COALESCE(ul.liked, 0) 106 - FROM articles a 107 - LEFT JOIN feeds f ON a.feed_url = f.feed_url 108 - LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id 109 - LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM likes GROUP BY feed_url, article_url) lc 114 + FROM articles.articles a 115 + LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url 116 + LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id 117 + LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc 110 118 ON lc.feed_url = a.feed_url AND lc.article_url = a.url 111 - LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM likes WHERE author_did = ?) ul 119 + LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul 112 120 ON ul.feed_url = a.feed_url AND ul.article_url = a.url 113 121 WHERE a.feed_url = ? 114 122 ` ··· 120 128 COALESCE(r.is_read, 0), 121 129 COALESCE(lc.cnt, 0), 122 130 COALESCE(ul.liked, 0) 123 - FROM articles a 124 - JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 125 - LEFT JOIN feeds f ON a.feed_url = f.feed_url 126 - LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id 127 - LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM likes GROUP BY feed_url, article_url) lc 131 + FROM articles.articles a 132 + JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 133 + LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url 134 + LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id 135 + LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc 128 136 ON lc.feed_url = a.feed_url AND lc.article_url = a.url 129 - LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM likes WHERE author_did = ?) ul 137 + LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul 130 138 ON ul.feed_url = a.feed_url AND ul.article_url = a.url 131 139 WHERE 1=1 132 140 ` ··· 137 145 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?` 138 146 args = append(args, limit, offset) 139 147 140 - rows, err := db.QueryContext(ctx, query, args...) 148 + rows, err := s.db.QueryContext(ctx, query, args...) 141 149 if err != nil { 142 150 return nil, err 143 151 } ··· 156 164 return articles, rows.Err() 157 165 } 158 166 159 - func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { 167 + func (s *ArticleStore) ListUnreadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { 160 168 var query string 161 169 var args []any 162 170 ··· 167 175 COALESCE(r.is_read, 0), 168 176 COALESCE(lc.cnt, 0), 169 177 COALESCE(ul.liked, 0) 170 - FROM articles a 171 - LEFT JOIN feeds f ON a.feed_url = f.feed_url 172 - LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id 173 - LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM likes GROUP BY feed_url, article_url) lc 178 + FROM articles.articles a 179 + LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url 180 + LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id 181 + LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc 174 182 ON lc.feed_url = a.feed_url AND lc.article_url = a.url 175 - LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM likes WHERE author_did = ?) ul 183 + LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul 176 184 ON ul.feed_url = a.feed_url AND ul.article_url = a.url 177 185 WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL) 178 186 ` ··· 184 192 COALESCE(r.is_read, 0), 185 193 COALESCE(lc.cnt, 0), 186 194 COALESCE(ul.liked, 0) 187 - FROM articles a 188 - JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 189 - LEFT JOIN feeds f ON a.feed_url = f.feed_url 190 - LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id 191 - LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM likes GROUP BY feed_url, article_url) lc 195 + FROM articles.articles a 196 + JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 197 + LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url 198 + LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id 199 + LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc 192 200 ON lc.feed_url = a.feed_url AND lc.article_url = a.url 193 - LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM likes WHERE author_did = ?) ul 201 + LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul 194 202 ON ul.feed_url = a.feed_url AND ul.article_url = a.url 195 203 WHERE (r.is_read = 0 OR r.is_read IS NULL) 196 204 ` ··· 201 209 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?` 202 210 args = append(args, limit, offset) 203 211 204 - rows, err := db.QueryContext(ctx, query, args...) 212 + rows, err := s.db.QueryContext(ctx, query, args...) 205 213 if err != nil { 206 214 return nil, err 207 215 } ··· 220 228 return articles, rows.Err() 221 229 } 222 230 223 - func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { 231 + func (s *ArticleStore) ListReadArticles(ctx context.Context, userDID, feedURL string, limit, offset int) ([]*Article, error) { 224 232 var query string 225 233 var args []any 226 234 ··· 231 239 COALESCE(r.is_read, 0), 232 240 COALESCE(lc.cnt, 0), 233 241 COALESCE(ul.liked, 0) 234 - FROM articles a 235 - LEFT JOIN feeds f ON a.feed_url = f.feed_url 236 - JOIN read_state r ON r.user_did = ? AND r.article_id = a.id 237 - LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM likes GROUP BY feed_url, article_url) lc 242 + FROM articles.articles a 243 + LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url 244 + JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id 245 + LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc 238 246 ON lc.feed_url = a.feed_url AND lc.article_url = a.url 239 - LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM likes WHERE author_did = ?) ul 247 + LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul 240 248 ON ul.feed_url = a.feed_url AND ul.article_url = a.url 241 249 WHERE r.is_read = 1 AND a.feed_url = ? 242 250 ` ··· 248 256 COALESCE(r.is_read, 0), 249 257 COALESCE(lc.cnt, 0), 250 258 COALESCE(ul.liked, 0) 251 - FROM articles a 252 - JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 253 - LEFT JOIN feeds f ON a.feed_url = f.feed_url 254 - JOIN read_state r ON r.user_did = ? AND r.article_id = a.id 255 - LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM likes GROUP BY feed_url, article_url) lc 259 + FROM articles.articles a 260 + JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 261 + LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url 262 + JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id 263 + LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc 256 264 ON lc.feed_url = a.feed_url AND lc.article_url = a.url 257 - LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM likes WHERE author_did = ?) ul 265 + LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul 258 266 ON ul.feed_url = a.feed_url AND ul.article_url = a.url 259 267 WHERE r.is_read = 1 260 268 ` ··· 265 273 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?` 266 274 args = append(args, limit, offset) 267 275 268 - rows, err := db.QueryContext(ctx, query, args...) 276 + rows, err := s.db.QueryContext(ctx, query, args...) 269 277 if err != nil { 270 278 return nil, err 271 279 } ··· 284 292 return articles, rows.Err() 285 293 } 286 294 287 - func (db *DB) MarkArticleRead(ctx context.Context, userDID string, articleID int64) error { 288 - _, err := db.ExecContext(ctx, ` 289 - INSERT INTO read_state (user_did, article_id, is_read, read_at) 295 + func (s *ArticleStore) MarkArticleRead(ctx context.Context, userDID string, articleID int64) error { 296 + _, err := s.db.ExecContext(ctx, ` 297 + INSERT INTO articles.read_state (user_did, article_id, is_read, read_at) 290 298 VALUES (?, ?, 1, CURRENT_TIMESTAMP) 291 299 ON CONFLICT(user_did, article_id) DO UPDATE SET 292 300 is_read = 1, read_at = CURRENT_TIMESTAMP ··· 294 302 return err 295 303 } 296 304 297 - func (db *DB) MarkArticleUnread(ctx context.Context, userDID string, articleID int64) error { 298 - _, err := db.ExecContext(ctx, ` 299 - INSERT INTO read_state (user_did, article_id, is_read) 305 + func (s *ArticleStore) MarkArticleUnread(ctx context.Context, userDID string, articleID int64) error { 306 + _, err := s.db.ExecContext(ctx, ` 307 + INSERT INTO articles.read_state (user_did, article_id, is_read) 300 308 VALUES (?, ?, 0) 301 309 ON CONFLICT(user_did, article_id) DO UPDATE SET 302 310 is_read = 0, read_at = NULL ··· 304 312 return err 305 313 } 306 314 307 - func (db *DB) MarkAllRead(ctx context.Context, userDID, feedURL string) error { 308 - _, err := db.ExecContext(ctx, ` 309 - INSERT INTO read_state (user_did, article_id, is_read, read_at) 315 + func (s *ArticleStore) MarkAllRead(ctx context.Context, userDID, feedURL string) error { 316 + _, err := s.db.ExecContext(ctx, ` 317 + INSERT INTO articles.read_state (user_did, article_id, is_read, read_at) 310 318 SELECT ?, a.id, 1, CURRENT_TIMESTAMP 311 - FROM articles a 319 + FROM articles.articles a 312 320 WHERE a.feed_url = ? 313 321 ON CONFLICT(user_did, article_id) DO UPDATE SET 314 322 is_read = 1, read_at = CURRENT_TIMESTAMP ··· 316 324 return err 317 325 } 318 326 319 - func (db *DB) MarkAllSubscribedRead(ctx context.Context, userDID string) error { 320 - _, err := db.ExecContext(ctx, ` 321 - INSERT INTO read_state (user_did, article_id, is_read, read_at) 327 + func (s *ArticleStore) MarkAllSubscribedRead(ctx context.Context, userDID string) error { 328 + _, err := s.db.ExecContext(ctx, ` 329 + INSERT INTO articles.read_state (user_did, article_id, is_read, read_at) 322 330 SELECT ?, a.id, 1, CURRENT_TIMESTAMP 323 - FROM articles a 324 - JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 331 + FROM articles.articles a 332 + JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 325 333 ON CONFLICT(user_did, article_id) DO UPDATE SET 326 334 is_read = 1, read_at = CURRENT_TIMESTAMP 327 335 `, userDID, userDID) 328 336 return err 329 337 } 330 338 331 - func (db *DB) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) { 339 + func (s *ArticleStore) GetReadState(ctx context.Context, userDID string, articleID int64) (*ReadState, error) { 332 340 rs := &ReadState{} 333 - err := db.QueryRowContext(ctx, ` 341 + err := s.db.QueryRowContext(ctx, ` 334 342 SELECT user_did, article_id, is_read, read_at 335 - FROM read_state WHERE user_did = ? AND article_id = ? 343 + FROM articles.read_state WHERE user_did = ? AND article_id = ? 336 344 `, userDID, articleID).Scan(&rs.UserDID, &rs.ArticleID, &rs.IsRead, &rs.ReadAt) 337 345 if err == sql.ErrNoRows { 338 346 return &ReadState{UserDID: userDID, ArticleID: articleID}, nil ··· 343 351 return rs, nil 344 352 } 345 353 346 - func (db *DB) GetUnreadCount(ctx context.Context, userDID, feedURL string) (int, error) { 354 + func (s *ArticleStore) GetUnreadCount(ctx context.Context, userDID, feedURL string) (int, error) { 347 355 var count int 348 356 if feedURL != "" { 349 - err := db.QueryRowContext(ctx, ` 357 + err := s.db.QueryRowContext(ctx, ` 350 358 SELECT COUNT(*) 351 - FROM articles a 352 - LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id 359 + FROM articles.articles a 360 + LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id 353 361 WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL) 354 362 `, userDID, feedURL).Scan(&count) 355 363 return count, err 356 364 } 357 - err := db.QueryRowContext(ctx, ` 365 + err := s.db.QueryRowContext(ctx, ` 358 366 SELECT COUNT(*) 359 - FROM articles a 360 - JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 361 - LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id 367 + FROM articles.articles a 368 + JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 369 + LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id 362 370 WHERE r.is_read = 0 OR r.is_read IS NULL 363 371 `, userDID, userDID).Scan(&count) 364 372 return count, err 365 373 } 366 374 367 - func (db *DB) UpdateArticleFullContent(ctx context.Context, id int64, fullContent string) error { 368 - _, err := db.ExecContext(ctx, ` 369 - UPDATE articles SET full_content = ? WHERE id = ? 375 + func (s *ArticleStore) UpdateArticleFullContent(ctx context.Context, id int64, fullContent string) error { 376 + _, err := s.db.ExecContext(ctx, ` 377 + UPDATE articles.articles SET full_content = ? WHERE id = ? 370 378 `, fullContent, id) 371 379 return err 372 380 } 373 381 374 - func (db *DB) GetArticleByURL(ctx context.Context, url string) (*Article, error) { 382 + func (s *ArticleStore) GetArticleByURL(ctx context.Context, url string) (*Article, error) { 375 383 a := &Article{} 376 - err := db.QueryRowContext(ctx, ` 384 + err := s.db.QueryRowContext(ctx, ` 377 385 SELECT id, feed_url, guid, title, url, author, summary, content, full_content, published, updated, fetched_at 378 - FROM articles WHERE url = ? 386 + FROM articles.articles WHERE url = ? 379 387 LIMIT 1 380 388 `, url).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author, 381 389 &a.Summary, &a.Content, &a.FullContent, &a.Published, &a.Updated, &a.FetchedAt) ··· 385 393 return a, nil 386 394 } 387 395 388 - func (db *DB) CountNewArticles(ctx context.Context, userDID string, since time.Time) (int, error) { 396 + func (s *ArticleStore) CountNewArticles(ctx context.Context, userDID string, since time.Time) (int, error) { 389 397 var count int 390 - err := db.QueryRowContext(ctx, ` 398 + err := s.db.QueryRowContext(ctx, ` 391 399 SELECT COUNT(*) 392 - FROM articles a 393 - JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 400 + FROM articles.articles a 401 + JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 394 402 WHERE a.fetched_at > ? 395 403 `, userDID, since).Scan(&count) 396 404 return count, err ··· 407 415 return b.String() 408 416 } 409 417 410 - func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit, offset int) ([]*Article, error) { 418 + func (s *ArticleStore) SearchArticles(ctx context.Context, userDID, query string, limit, offset int) ([]*Article, error) { 411 419 if strings.TrimSpace(query) == "" { 412 420 return nil, nil 413 421 } ··· 419 427 420 428 columnQuery := "{title summary} : " + safeQuery 421 429 422 - rows, err := db.QueryContext(ctx, ` 430 + rows, err := s.db.QueryContext(ctx, ` 423 431 SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content, 424 432 a.published, a.updated, a.fetched_at, 425 433 COALESCE(r.is_read, 0), 426 434 COALESCE(lc.cnt, 0), 427 435 COALESCE(ul.liked, 0) 428 - FROM articles_fts ft 429 - JOIN articles a ON a.id = ft.rowid 430 - JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 431 - LEFT JOIN feeds f ON a.feed_url = f.feed_url 432 - LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id 433 - LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM likes GROUP BY feed_url, article_url) lc 436 + FROM ( 437 + SELECT rowid, rank FROM articles.articles_fts WHERE articles_fts MATCH ? 438 + ) ft 439 + JOIN articles.articles a ON a.id = ft.rowid 440 + JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ? 441 + LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url 442 + LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id 443 + LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc 434 444 ON lc.feed_url = a.feed_url AND lc.article_url = a.url 435 - LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM likes WHERE author_did = ?) ul 445 + LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul 436 446 ON ul.feed_url = a.feed_url AND ul.article_url = a.url 437 - WHERE articles_fts MATCH ? 438 447 ORDER BY ft.rank 439 448 LIMIT ? OFFSET ? 440 - `, userDID, userDID, userDID, columnQuery, limit, offset) 449 + `, columnQuery, userDID, userDID, userDID, limit, offset) 441 450 if err != nil { 442 451 return nil, err 443 452 }
+103 -99
internal/db/article_test.go
··· 9 9 "gotest.tools/v3/assert" 10 10 ) 11 11 12 - func setupTestDB(t *testing.T) *DB { 12 + func setupTestDB(t *testing.T) *Databases { 13 13 t.Helper() 14 14 f, err := os.CreateTemp("", "glean-test-*.db") 15 15 assert.NilError(t, err) 16 16 assert.NilError(t, f.Close()) 17 17 path := f.Name() 18 - t.Cleanup(func() { _ = os.Remove(path) }) 18 + t.Cleanup(func() { 19 + for _, suffix := range []string{"", "_users", "_users-shm", "_users-wal", "_articles", "_articles-shm", "_articles-wal", "_recs", "_recs-shm", "_recs-wal"} { 20 + _ = os.Remove(path + suffix) 21 + } 22 + }) 19 23 20 - db, err := Open(path) 24 + dbs, err := OpenAll(path) 21 25 assert.NilError(t, err) 22 - t.Cleanup(func() { _ = db.Close() }) 23 - return db 26 + t.Cleanup(func() { _ = dbs.Close() }) 27 + return dbs 24 28 } 25 29 26 - func seedArticleReadState(t *testing.T, ctx context.Context, db *DB) (userDID string, feedURL string, readArticleID, unreadArticleID int64) { 30 + func seedArticleReadState(t *testing.T, ctx context.Context, dbs *Databases) (userDID string, feedURL string, readArticleID, unreadArticleID int64) { 27 31 t.Helper() 28 32 29 33 userDID = "did:test:user1" 30 34 feedURL = "https://example.com/feed.xml" 31 35 32 - _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "user1") 36 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "user1") 33 37 assert.NilError(t, err) 34 38 35 - _, err = db.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Test Feed") 39 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Test Feed") 36 40 assert.NilError(t, err) 37 41 38 - _, err = db.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, userDID, feedURL) 42 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, userDID, feedURL) 39 43 assert.NilError(t, err) 40 44 41 - res, err := db.ExecContext(ctx, `INSERT INTO articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`, 45 + res, err := dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`, 42 46 feedURL, "guid-read", "Read Article", "https://example.com/read") 43 47 assert.NilError(t, err) 44 48 readArticleID, _ = res.LastInsertId() 45 49 46 - res, err = db.ExecContext(ctx, `INSERT INTO articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`, 50 + res, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.articles (feed_url, guid, title, url) VALUES (?, ?, ?, ?)`, 47 51 feedURL, "guid-unread", "Unread Article", "https://example.com/unread") 48 52 assert.NilError(t, err) 49 53 unreadArticleID, _ = res.LastInsertId() 50 54 51 - err = db.MarkArticleRead(ctx, userDID, readArticleID) 55 + err = dbs.Articles.MarkArticleRead(ctx, userDID, readArticleID) 52 56 assert.NilError(t, err) 53 57 54 58 return userDID, feedURL, readArticleID, unreadArticleID ··· 56 60 57 61 func TestListReadArticles_ReturnsOnlyRead(t *testing.T) { 58 62 ctx := context.Background() 59 - db := setupTestDB(t) 60 - userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, db) 63 + dbs := setupTestDB(t) 64 + userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, dbs) 61 65 62 - articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0) 66 + results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0) 63 67 assert.NilError(t, err) 64 - assert.Equal(t, len(articles), 1) 65 - assert.Equal(t, articles[0].ID, readID) 66 - assert.Equal(t, articles[0].IsRead, sql.NullBool{Bool: true, Valid: true}) 68 + assert.Equal(t, len(results), 1) 69 + assert.Equal(t, results[0].ID, readID) 70 + assert.Equal(t, results[0].IsRead, sql.NullBool{Bool: true, Valid: true}) 67 71 68 72 _ = unreadID 69 73 } 70 74 71 75 func TestListReadArticles_ExcludesUnread(t *testing.T) { 72 76 ctx := context.Background() 73 - db := setupTestDB(t) 74 - userDID, feedURL, _, unreadID := seedArticleReadState(t, ctx, db) 77 + dbs := setupTestDB(t) 78 + userDID, feedURL, _, unreadID := seedArticleReadState(t, ctx, dbs) 75 79 76 - articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0) 80 + results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0) 77 81 assert.NilError(t, err) 78 - for _, a := range articles { 82 + for _, a := range results { 79 83 assert.Assert(t, a.ID != unreadID, "unread article should not appear in read list") 80 84 } 81 85 } 82 86 83 87 func TestListUnreadArticles_ReturnsOnlyUnread(t *testing.T) { 84 88 ctx := context.Background() 85 - db := setupTestDB(t) 86 - userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, db) 89 + dbs := setupTestDB(t) 90 + userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, dbs) 87 91 88 - articles, err := db.ListUnreadArticles(ctx, userDID, feedURL, 10, 0) 92 + results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, 10, 0) 89 93 assert.NilError(t, err) 90 - assert.Equal(t, len(articles), 1) 91 - assert.Equal(t, articles[0].ID, unreadID) 92 - assert.Equal(t, articles[0].IsRead.Bool, false) 94 + assert.Equal(t, len(results), 1) 95 + assert.Equal(t, results[0].ID, unreadID) 96 + assert.Equal(t, results[0].IsRead.Bool, false) 93 97 94 98 _ = readID 95 99 } 96 100 97 101 func TestListArticles_ReturnsAll(t *testing.T) { 98 102 ctx := context.Background() 99 - db := setupTestDB(t) 100 - userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db) 103 + dbs := setupTestDB(t) 104 + userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs) 101 105 102 - articles, err := db.ListArticles(ctx, userDID, feedURL, 10, 0) 106 + results, err := dbs.Articles.ListArticles(ctx, userDID, feedURL, 10, 0) 103 107 assert.NilError(t, err) 104 - assert.Equal(t, len(articles), 2) 108 + assert.Equal(t, len(results), 2) 105 109 } 106 110 107 111 func TestMarkArticleRead_ToggleUnread(t *testing.T) { 108 112 ctx := context.Background() 109 - db := setupTestDB(t) 110 - userDID, _, _, unreadID := seedArticleReadState(t, ctx, db) 113 + dbs := setupTestDB(t) 114 + userDID, _, _, unreadID := seedArticleReadState(t, ctx, dbs) 111 115 112 - err := db.MarkArticleRead(ctx, userDID, unreadID) 116 + err := dbs.Articles.MarkArticleRead(ctx, userDID, unreadID) 113 117 assert.NilError(t, err) 114 118 115 - state, err := db.GetReadState(ctx, userDID, unreadID) 119 + state, err := dbs.Articles.GetReadState(ctx, userDID, unreadID) 116 120 assert.NilError(t, err) 117 121 assert.Equal(t, state.IsRead, true) 118 122 119 - err = db.MarkArticleUnread(ctx, userDID, unreadID) 123 + err = dbs.Articles.MarkArticleUnread(ctx, userDID, unreadID) 120 124 assert.NilError(t, err) 121 125 122 - state, err = db.GetReadState(ctx, userDID, unreadID) 126 + state, err = dbs.Articles.GetReadState(ctx, userDID, unreadID) 123 127 assert.NilError(t, err) 124 128 assert.Equal(t, state.IsRead, false) 125 129 } 126 130 127 131 func TestListReadArticles_EmptyWhenNoneRead(t *testing.T) { 128 132 ctx := context.Background() 129 - db := setupTestDB(t) 130 - userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db) 133 + dbs := setupTestDB(t) 134 + userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs) 131 135 132 - articles, err := db.ListUnreadArticles(ctx, userDID, feedURL, 10, 0) 136 + results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, 10, 0) 133 137 assert.NilError(t, err) 134 - assert.Equal(t, len(articles), 1) 138 + assert.Equal(t, len(results), 1) 135 139 } 136 140 137 141 func TestListReadArticles_WithFeedURLFilter(t *testing.T) { 138 142 ctx := context.Background() 139 - db := setupTestDB(t) 140 - userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db) 143 + dbs := setupTestDB(t) 144 + userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs) 141 145 142 - articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0) 146 + results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0) 143 147 assert.NilError(t, err) 144 - assert.Equal(t, len(articles), 1) 148 + assert.Equal(t, len(results), 1) 145 149 146 - articles, err = db.ListReadArticles(ctx, userDID, "https://other.com/feed", 10, 0) 150 + results, err = dbs.Articles.ListReadArticles(ctx, userDID, "https://other.com/feed", 10, 0) 147 151 assert.NilError(t, err) 148 - assert.Equal(t, len(articles), 0) 152 + assert.Equal(t, len(results), 0) 149 153 } 150 154 151 155 func TestGetUnreadCount(t *testing.T) { 152 156 ctx := context.Background() 153 - db := setupTestDB(t) 154 - userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db) 157 + dbs := setupTestDB(t) 158 + userDID, feedURL, _, _ := seedArticleReadState(t, ctx, dbs) 155 159 156 - count, err := db.GetUnreadCount(ctx, userDID, feedURL) 160 + count, err := dbs.Articles.GetUnreadCount(ctx, userDID, feedURL) 157 161 assert.NilError(t, err) 158 162 assert.Equal(t, count, 1) 159 163 160 - count, err = db.GetUnreadCount(ctx, userDID, "") 164 + count, err = dbs.Articles.GetUnreadCount(ctx, userDID, "") 161 165 assert.NilError(t, err) 162 166 assert.Equal(t, count, 1) 163 167 } 164 168 165 169 func TestUpdateArticleFullContent(t *testing.T) { 166 170 ctx := context.Background() 167 - db := setupTestDB(t) 168 - _, _, _, articleID := seedArticleReadState(t, ctx, db) 171 + dbs := setupTestDB(t) 172 + _, _, _, articleID := seedArticleReadState(t, ctx, dbs) 169 173 170 - err := db.UpdateArticleFullContent(ctx, articleID, "<p>Scraped content</p>") 174 + err := dbs.Articles.UpdateArticleFullContent(ctx, articleID, "<p>Scraped content</p>") 171 175 assert.NilError(t, err) 172 176 173 - article, err := db.GetArticle(ctx, articleID) 177 + article, err := dbs.Articles.GetArticle(ctx, articleID) 174 178 assert.NilError(t, err) 175 179 assert.Equal(t, article.FullContent.String, "<p>Scraped content</p>") 176 180 assert.Assert(t, article.FullContent.Valid) ··· 178 182 179 183 func TestGetArticle_IncludesFullContent(t *testing.T) { 180 184 ctx := context.Background() 181 - db := setupTestDB(t) 182 - _, _, _, articleID := seedArticleReadState(t, ctx, db) 185 + dbs := setupTestDB(t) 186 + _, _, _, articleID := seedArticleReadState(t, ctx, dbs) 183 187 184 - article, err := db.GetArticle(ctx, articleID) 188 + article, err := dbs.Articles.GetArticle(ctx, articleID) 185 189 assert.NilError(t, err) 186 190 assert.Assert(t, !article.FullContent.Valid) 187 191 } 188 192 189 - func seedSearchData(t *testing.T, ctx context.Context, database *DB) (userDID, feedURL string) { 193 + func seedSearchData(t *testing.T, ctx context.Context, dbs *Databases) (userDID, feedURL string) { 190 194 t.Helper() 191 195 192 196 userDID = "did:test:searcher" 193 197 feedURL = "https://search.example.com/feed.xml" 194 198 195 - _, err := database.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "searcher") 199 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "searcher") 196 200 assert.NilError(t, err) 197 201 198 - _, err = database.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Tech Blog") 202 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title) VALUES (?, ?)`, feedURL, "Tech Blog") 199 203 assert.NilError(t, err) 200 204 201 - _, err = database.ExecContext(ctx, `INSERT INTO subscriptions (user_did, feed_url) VALUES (?, ?)`, userDID, feedURL) 205 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO articles.subscriptions (user_did, feed_url) VALUES (?, ?)`, userDID, feedURL) 202 206 assert.NilError(t, err) 203 207 204 208 articles := []struct { ··· 209 213 {"g3", "Python Data Science", "NumPy and Pandas tutorial", "Python is popular for data analysis"}, 210 214 } 211 215 for _, a := range articles { 212 - _, err := database.ExecContext(ctx, ` 213 - INSERT INTO articles (feed_url, guid, title, summary, content) VALUES (?, ?, ?, ?, ?) 216 + _, err := dbs.DB().ExecContext(ctx, ` 217 + INSERT INTO articles.articles (feed_url, guid, title, summary, content) VALUES (?, ?, ?, ?, ?) 214 218 `, feedURL, a.guid, a.title, a.summary, a.content) 215 219 assert.NilError(t, err) 216 220 } ··· 220 224 221 225 func TestSearchArticles_FindsByTitle(t *testing.T) { 222 226 ctx := context.Background() 223 - db := setupTestDB(t) 224 - userDID, _ := seedSearchData(t, ctx, db) 227 + dbs := setupTestDB(t) 228 + userDID, _ := seedSearchData(t, ctx, dbs) 225 229 226 - results, err := db.SearchArticles(ctx, userDID, "Go Programming", 10, 0) 230 + results, err := dbs.Articles.SearchArticles(ctx, userDID, "Go Programming", 10, 0) 227 231 assert.NilError(t, err) 228 232 assert.Equal(t, len(results), 1) 229 233 assert.Equal(t, results[0].Title, "Go Programming Basics") ··· 231 235 232 236 func TestSearchArticles_FindsBySummary(t *testing.T) { 233 237 ctx := context.Background() 234 - db := setupTestDB(t) 235 - userDID, _ := seedSearchData(t, ctx, db) 238 + dbs := setupTestDB(t) 239 + userDID, _ := seedSearchData(t, ctx, dbs) 236 240 237 - results, err := db.SearchArticles(ctx, userDID, "ownership", 10, 0) 241 + results, err := dbs.Articles.SearchArticles(ctx, userDID, "ownership", 10, 0) 238 242 assert.NilError(t, err) 239 243 assert.Equal(t, len(results), 1) 240 244 assert.Equal(t, results[0].Title, "Rust Memory Safety") ··· 242 246 243 247 func TestSearchArticles_IgnoresContentOnlyMatch(t *testing.T) { 244 248 ctx := context.Background() 245 - db := setupTestDB(t) 246 - userDID, _ := seedSearchData(t, ctx, db) 249 + dbs := setupTestDB(t) 250 + userDID, _ := seedSearchData(t, ctx, dbs) 247 251 248 - results, err := db.SearchArticles(ctx, userDID, "garbage collection", 10, 0) 252 + results, err := dbs.Articles.SearchArticles(ctx, userDID, "garbage collection", 10, 0) 249 253 assert.NilError(t, err) 250 254 assert.Equal(t, len(results), 0) 251 255 } 252 256 253 257 func TestSearchArticles_NoResults(t *testing.T) { 254 258 ctx := context.Background() 255 - db := setupTestDB(t) 256 - userDID, _ := seedSearchData(t, ctx, db) 259 + dbs := setupTestDB(t) 260 + userDID, _ := seedSearchData(t, ctx, dbs) 257 261 258 - results, err := db.SearchArticles(ctx, userDID, "nonexistent_xyz", 10, 0) 262 + results, err := dbs.Articles.SearchArticles(ctx, userDID, "nonexistent_xyz", 10, 0) 259 263 assert.NilError(t, err) 260 264 assert.Equal(t, len(results), 0) 261 265 } 262 266 263 267 func TestSearchArticles_MultipleMatches(t *testing.T) { 264 268 ctx := context.Background() 265 - db := setupTestDB(t) 266 - userDID, _ := seedSearchData(t, ctx, db) 269 + dbs := setupTestDB(t) 270 + userDID, _ := seedSearchData(t, ctx, dbs) 267 271 268 - results, err := db.SearchArticles(ctx, userDID, "Python", 10, 0) 272 + results, err := dbs.Articles.SearchArticles(ctx, userDID, "Python", 10, 0) 269 273 assert.NilError(t, err) 270 274 assert.Assert(t, len(results) >= 1) 271 275 ··· 281 285 282 286 func TestSearchArticles_ScopedToSubscriptions(t *testing.T) { 283 287 ctx := context.Background() 284 - db := setupTestDB(t) 285 - userDID, feedURL := seedSearchData(t, ctx, db) 288 + dbs := setupTestDB(t) 289 + userDID, feedURL := seedSearchData(t, ctx, dbs) 286 290 287 291 otherFeed := "https://other.example.com/feed.xml" 288 - _, err := db.ExecContext(ctx, `INSERT INTO feeds (feed_url, title) VALUES (?, ?)`, otherFeed, "Other Feed") 292 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO articles.feeds (feed_url, title) VALUES (?, ?)`, otherFeed, "Other Feed") 289 293 assert.NilError(t, err) 290 294 291 - _, err = db.ExecContext(ctx, ` 292 - INSERT INTO articles (feed_url, guid, title) VALUES (?, ?, ?) 295 + _, err = dbs.DB().ExecContext(ctx, ` 296 + INSERT INTO articles.articles (feed_url, guid, title) VALUES (?, ?, ?) 293 297 `, otherFeed, "other-1", "Go Concurrency Tips") 294 298 assert.NilError(t, err) 295 299 296 - results, err := db.SearchArticles(ctx, userDID, "Go", 10, 0) 300 + results, err := dbs.Articles.SearchArticles(ctx, userDID, "Go", 10, 0) 297 301 assert.NilError(t, err) 298 302 for _, a := range results { 299 303 assert.Equal(t, a.FeedURL, feedURL) ··· 302 306 303 307 func TestSearchArticles_Pagination(t *testing.T) { 304 308 ctx := context.Background() 305 - db := setupTestDB(t) 306 - userDID, _ := seedSearchData(t, ctx, db) 309 + dbs := setupTestDB(t) 310 + userDID, _ := seedSearchData(t, ctx, dbs) 307 311 308 - results, err := db.SearchArticles(ctx, userDID, "Go", 1, 0) 312 + results, err := dbs.Articles.SearchArticles(ctx, userDID, "Go", 1, 0) 309 313 assert.NilError(t, err) 310 314 assert.Equal(t, len(results), 1) 311 315 312 - results2, err := db.SearchArticles(ctx, userDID, "Go", 1, 1) 316 + results2, err := dbs.Articles.SearchArticles(ctx, userDID, "Go", 1, 1) 313 317 assert.NilError(t, err) 314 318 assert.Assert(t, len(results2) == 0 || results2[0].ID != results[0].ID) 315 319 } 316 320 317 321 func TestSearchArticles_EmptyQuery(t *testing.T) { 318 322 ctx := context.Background() 319 - db := setupTestDB(t) 320 - userDID, _ := seedSearchData(t, ctx, db) 323 + dbs := setupTestDB(t) 324 + userDID, _ := seedSearchData(t, ctx, dbs) 321 325 322 - results, err := db.SearchArticles(ctx, userDID, "", 10, 0) 326 + results, err := dbs.Articles.SearchArticles(ctx, userDID, "", 10, 0) 323 327 assert.NilError(t, err) 324 328 assert.Equal(t, len(results), 0) 325 329 } 326 330 327 331 func TestSearchArticles_SpecialCharactersNoError(t *testing.T) { 328 332 ctx := context.Background() 329 - db := setupTestDB(t) 330 - userDID, _ := seedSearchData(t, ctx, db) 333 + dbs := setupTestDB(t) 334 + userDID, _ := seedSearchData(t, ctx, dbs) 331 335 332 - _, err := db.SearchArticles(ctx, userDID, "test.example.com/path?q=1&b=2", 10, 0) 336 + _, err := dbs.Articles.SearchArticles(ctx, userDID, "test.example.com/path?q=1&b=2", 10, 0) 333 337 assert.NilError(t, err) 334 338 } 335 339 336 340 func TestSearchArticles_OnlySpecialCharacters(t *testing.T) { 337 341 ctx := context.Background() 338 - db := setupTestDB(t) 339 - userDID, _ := seedSearchData(t, ctx, db) 342 + dbs := setupTestDB(t) 343 + userDID, _ := seedSearchData(t, ctx, dbs) 340 344 341 - results, err := db.SearchArticles(ctx, userDID, "...///:::!!!", 10, 0) 345 + results, err := dbs.Articles.SearchArticles(ctx, userDID, "...///:::!!!", 10, 0) 342 346 assert.NilError(t, err) 343 347 assert.Equal(t, len(results), 0) 344 348 }
+61 -61
internal/db/batch_test.go
··· 10 10 11 11 func TestBatchCreateUsers_InsertsAll(t *testing.T) { 12 12 ctx := context.Background() 13 - db := setupTestDB(t) 13 + dbs := setupTestDB(t) 14 14 15 - users := []UserData{ 15 + data := []UserData{ 16 16 {DID: "did:test:u1", Handle: "user1", DisplayName: "User One", AvatarURL: "https://avatar1.png"}, 17 17 {DID: "did:test:u2", Handle: "user2", DisplayName: "User Two"}, 18 18 } 19 - err := db.BatchCreateUsers(ctx, users) 19 + err := dbs.Users.BatchCreateUsers(ctx, data) 20 20 assert.NilError(t, err) 21 21 22 - u1, err := db.GetUser(ctx, "did:test:u1") 22 + u1, err := dbs.Users.GetUser(ctx, "did:test:u1") 23 23 assert.NilError(t, err) 24 24 assert.Equal(t, u1.Handle, "user1") 25 25 assert.Equal(t, u1.DisplayName.String, "User One") 26 26 27 - u2, err := db.GetUser(ctx, "did:test:u2") 27 + u2, err := dbs.Users.GetUser(ctx, "did:test:u2") 28 28 assert.NilError(t, err) 29 29 assert.Equal(t, u2.Handle, "user2") 30 30 assert.Equal(t, u2.DisplayName.String, "User Two") ··· 32 32 33 33 func TestBatchCreateUsers_UpsertsExisting(t *testing.T) { 34 34 ctx := context.Background() 35 - db := setupTestDB(t) 35 + dbs := setupTestDB(t) 36 36 37 - _, err := db.CreateUser(ctx, "did:test:u1", "old-handle", "", "") 37 + _, err := dbs.Users.CreateUser(ctx, "did:test:u1", "old-handle", "", "") 38 38 assert.NilError(t, err) 39 39 40 - users := []UserData{ 40 + data := []UserData{ 41 41 {DID: "did:test:u1", Handle: "new-handle", DisplayName: "New Name"}, 42 42 } 43 - err = db.BatchCreateUsers(ctx, users) 43 + err = dbs.Users.BatchCreateUsers(ctx, data) 44 44 assert.NilError(t, err) 45 45 46 - u, err := db.GetUser(ctx, "did:test:u1") 46 + u, err := dbs.Users.GetUser(ctx, "did:test:u1") 47 47 assert.NilError(t, err) 48 48 assert.Equal(t, u.Handle, "new-handle") 49 49 assert.Equal(t, u.DisplayName.String, "New Name") ··· 51 51 52 52 func TestBatchCreateUsers_Empty(t *testing.T) { 53 53 ctx := context.Background() 54 - db := setupTestDB(t) 54 + dbs := setupTestDB(t) 55 55 56 - err := db.BatchCreateUsers(ctx, nil) 56 + err := dbs.Users.BatchCreateUsers(ctx, nil) 57 57 assert.NilError(t, err) 58 58 } 59 59 60 60 func TestBatchCreateUsers_DoesNotOverwriteWithEmpty(t *testing.T) { 61 61 ctx := context.Background() 62 - db := setupTestDB(t) 62 + dbs := setupTestDB(t) 63 63 64 - _, err := db.CreateUser(ctx, "did:test:u1", "handle", "Existing Name", "https://avatar.png") 64 + _, err := dbs.Users.CreateUser(ctx, "did:test:u1", "handle", "Existing Name", "https://avatar.png") 65 65 assert.NilError(t, err) 66 66 67 - users := []UserData{ 67 + data := []UserData{ 68 68 {DID: "did:test:u1", Handle: "", DisplayName: "", AvatarURL: ""}, 69 69 } 70 - err = db.BatchCreateUsers(ctx, users) 70 + err = dbs.Users.BatchCreateUsers(ctx, data) 71 71 assert.NilError(t, err) 72 72 73 - u, err := db.GetUser(ctx, "did:test:u1") 73 + u, err := dbs.Users.GetUser(ctx, "did:test:u1") 74 74 assert.NilError(t, err) 75 75 assert.Equal(t, u.Handle, "handle") 76 76 assert.Equal(t, u.DisplayName.String, "Existing Name") 77 77 assert.Equal(t, u.AvatarURL.String, "https://avatar.png") 78 78 } 79 79 80 - func seedSubscriptionData(t *testing.T, ctx context.Context, database *DB) (userDID string) { 80 + func seedSubscriptionData(t *testing.T, ctx context.Context, dbs *Databases) (userDID string) { 81 81 t.Helper() 82 82 userDID = "did:test:subuser" 83 - _, err := database.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "subuser") 83 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "subuser") 84 84 assert.NilError(t, err) 85 85 return userDID 86 86 } 87 87 88 88 func TestBatchUpsertFeeds_InsertsAll(t *testing.T) { 89 89 ctx := context.Background() 90 - db := setupTestDB(t) 90 + dbs := setupTestDB(t) 91 91 92 92 feeds := []*Feed{ 93 93 {FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")}, 94 94 {FeedURL: "https://b.com/feed.xml", Title: NullStr("Feed B")}, 95 95 } 96 - err := db.BatchUpsertFeeds(ctx, feeds) 96 + err := dbs.Articles.BatchUpsertFeeds(ctx, feeds) 97 97 assert.NilError(t, err) 98 98 99 - f, err := db.GetFeed(ctx, "https://a.com/feed.xml") 99 + f, err := dbs.Articles.GetFeed(ctx, "https://a.com/feed.xml") 100 100 assert.NilError(t, err) 101 101 assert.Equal(t, f.Title.String, "Feed A") 102 102 103 - f, err = db.GetFeed(ctx, "https://b.com/feed.xml") 103 + f, err = dbs.Articles.GetFeed(ctx, "https://b.com/feed.xml") 104 104 assert.NilError(t, err) 105 105 assert.Equal(t, f.Title.String, "Feed B") 106 106 } 107 107 108 108 func TestBatchUpsertFeeds_UpdatesExisting(t *testing.T) { 109 109 ctx := context.Background() 110 - db := setupTestDB(t) 110 + dbs := setupTestDB(t) 111 111 112 - err := db.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Old Title")}) 112 + err := dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Old Title")}) 113 113 assert.NilError(t, err) 114 114 115 115 feeds := []*Feed{ 116 116 {FeedURL: "https://a.com/feed.xml", Title: NullStr("New Title")}, 117 117 } 118 - err = db.BatchUpsertFeeds(ctx, feeds) 118 + err = dbs.Articles.BatchUpsertFeeds(ctx, feeds) 119 119 assert.NilError(t, err) 120 120 121 - f, err := db.GetFeed(ctx, "https://a.com/feed.xml") 121 + f, err := dbs.Articles.GetFeed(ctx, "https://a.com/feed.xml") 122 122 assert.NilError(t, err) 123 123 assert.Equal(t, f.Title.String, "New Title") 124 124 } 125 125 126 126 func TestBatchReconcileSubscriptions_CreatesNew(t *testing.T) { 127 127 ctx := context.Background() 128 - database := setupTestDB(t) 129 - userDID := seedSubscriptionData(t, ctx, database) 128 + dbs := setupTestDB(t) 129 + userDID := seedSubscriptionData(t, ctx, dbs) 130 130 131 - _ = database.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")}) 132 - _ = database.UpsertFeed(ctx, &Feed{FeedURL: "https://b.com/feed.xml", Title: NullStr("Feed B")}) 131 + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")}) 132 + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://b.com/feed.xml", Title: NullStr("Feed B")}) 133 133 134 134 subs := []SubData{ 135 135 {FeedURL: "https://a.com/feed.xml", Title: "Feed A", URI: "at://uri1", CID: "cid1"}, 136 136 {FeedURL: "https://b.com/feed.xml", Title: "Feed B", URI: "at://uri2", CID: "cid2"}, 137 137 } 138 - err := database.BatchReconcileSubscriptions(ctx, userDID, subs) 138 + err := dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs) 139 139 assert.NilError(t, err) 140 140 141 - subs2, err := database.ListSubscriptions(ctx, userDID, "", 10, 0) 141 + subs2, err := dbs.Articles.ListSubscriptions(ctx, userDID, "", 10, 0) 142 142 assert.NilError(t, err) 143 143 assert.Equal(t, len(subs2), 2) 144 144 145 - f, err := database.GetFeed(ctx, "https://a.com/feed.xml") 145 + f, err := dbs.Articles.GetFeed(ctx, "https://a.com/feed.xml") 146 146 assert.NilError(t, err) 147 147 assert.Equal(t, f.SubscriberCount, 1) 148 148 } 149 149 150 150 func TestBatchReconcileSubscriptions_BackfillsURI(t *testing.T) { 151 151 ctx := context.Background() 152 - database := setupTestDB(t) 153 - userDID := seedSubscriptionData(t, ctx, database) 152 + dbs := setupTestDB(t) 153 + userDID := seedSubscriptionData(t, ctx, dbs) 154 154 155 - _ = database.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) 155 + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) 156 156 157 - err := database.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "", "", "") 157 + err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "", "", "") 158 158 assert.NilError(t, err) 159 159 160 160 subs := []SubData{ 161 161 {FeedURL: "https://a.com/feed.xml", URI: "at://new-uri", CID: "new-cid"}, 162 162 } 163 - err = database.BatchReconcileSubscriptions(ctx, userDID, subs) 163 + err = dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs) 164 164 assert.NilError(t, err) 165 165 166 - s, err := database.GetSubscription(ctx, userDID, "https://a.com/feed.xml") 166 + s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml") 167 167 assert.NilError(t, err) 168 168 assert.Equal(t, s.URI.String, "at://new-uri") 169 169 170 - f, err := database.GetFeed(ctx, "https://a.com/feed.xml") 170 + f, err := dbs.Articles.GetFeed(ctx, "https://a.com/feed.xml") 171 171 assert.NilError(t, err) 172 172 assert.Equal(t, f.SubscriberCount, 1) 173 173 } 174 174 175 175 func TestBatchReconcileSubscriptions_SkipsExistingWithURI(t *testing.T) { 176 176 ctx := context.Background() 177 - database := setupTestDB(t) 178 - userDID := seedSubscriptionData(t, ctx, database) 177 + dbs := setupTestDB(t) 178 + userDID := seedSubscriptionData(t, ctx, dbs) 179 179 180 - _ = database.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) 181 - err := database.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "", "at://existing", "cid") 180 + _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"}) 181 + err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "", "at://existing", "cid") 182 182 assert.NilError(t, err) 183 183 184 184 subs := []SubData{ 185 185 {FeedURL: "https://a.com/feed.xml", URI: "at://different", CID: "cid2"}, 186 186 } 187 - err = database.BatchReconcileSubscriptions(ctx, userDID, subs) 187 + err = dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs) 188 188 assert.NilError(t, err) 189 189 190 - s, err := database.GetSubscription(ctx, userDID, "https://a.com/feed.xml") 190 + s, err := dbs.Articles.GetSubscription(ctx, userDID, "https://a.com/feed.xml") 191 191 assert.NilError(t, err) 192 192 assert.Equal(t, s.URI.String, "at://existing") 193 193 } 194 194 195 195 func TestBatchCreateLikes_InsertsAll(t *testing.T) { 196 196 ctx := context.Background() 197 - database := setupTestDB(t) 197 + dbs := setupTestDB(t) 198 198 199 199 now := NullTime(time.Now()) 200 200 likes := []*Like{ 201 201 {URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now, CID: NullStr("cid1")}, 202 202 {URI: "at://like2", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/2", CreatedAt: now, CID: NullStr("cid2")}, 203 203 } 204 - err := database.BatchCreateLikes(ctx, likes) 204 + err := dbs.Articles.BatchCreateLikes(ctx, likes) 205 205 assert.NilError(t, err) 206 206 207 - exists, err := database.HasLiked(ctx, "did:test:u1", "https://a.com/feed", "https://a.com/1") 207 + exists, err := dbs.Articles.HasLiked(ctx, "did:test:u1", "https://a.com/feed", "https://a.com/1") 208 208 assert.NilError(t, err) 209 209 assert.Equal(t, exists, true) 210 210 211 - exists, err = database.HasLiked(ctx, "did:test:u1", "https://a.com/feed", "https://a.com/2") 211 + exists, err = dbs.Articles.HasLiked(ctx, "did:test:u1", "https://a.com/feed", "https://a.com/2") 212 212 assert.NilError(t, err) 213 213 assert.Equal(t, exists, true) 214 214 } 215 215 216 216 func TestBatchCreateLikes_IgnoresDuplicates(t *testing.T) { 217 217 ctx := context.Background() 218 - database := setupTestDB(t) 218 + dbs := setupTestDB(t) 219 219 220 220 now := NullTime(time.Now()) 221 221 likes := []*Like{ 222 222 {URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now}, 223 223 } 224 - err := database.BatchCreateLikes(ctx, likes) 224 + err := dbs.Articles.BatchCreateLikes(ctx, likes) 225 225 assert.NilError(t, err) 226 226 227 227 likes = append(likes, &Like{URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now}) 228 - err = database.BatchCreateLikes(ctx, likes) 228 + err = dbs.Articles.BatchCreateLikes(ctx, likes) 229 229 assert.NilError(t, err) 230 230 } 231 231 232 232 func TestBatchCreateAnnotations_InsertsAll(t *testing.T) { 233 233 ctx := context.Background() 234 - database := setupTestDB(t) 234 + dbs := setupTestDB(t) 235 235 236 236 now := NullTime(time.Now()) 237 237 annotations := []*Annotation{ 238 238 {URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", Note: NullStr("Great"), CreatedAt: now}, 239 239 {URI: "at://ann2", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/2", Note: NullStr("Nice"), CreatedAt: now}, 240 240 } 241 - err := database.BatchCreateAnnotations(ctx, annotations) 241 + err := dbs.Articles.BatchCreateAnnotations(ctx, annotations) 242 242 assert.NilError(t, err) 243 243 244 - exists, err := database.AnnotationExists(ctx, "at://ann1") 244 + exists, err := dbs.Articles.AnnotationExists(ctx, "at://ann1") 245 245 assert.NilError(t, err) 246 246 assert.Equal(t, exists, true) 247 247 248 - exists, err = database.AnnotationExists(ctx, "at://ann2") 248 + exists, err = dbs.Articles.AnnotationExists(ctx, "at://ann2") 249 249 assert.NilError(t, err) 250 250 assert.Equal(t, exists, true) 251 251 } 252 252 253 253 func TestBatchCreateAnnotations_IgnoresDuplicates(t *testing.T) { 254 254 ctx := context.Background() 255 - database := setupTestDB(t) 255 + dbs := setupTestDB(t) 256 256 257 257 now := NullTime(time.Now()) 258 258 annotations := []*Annotation{ 259 259 {URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now}, 260 260 } 261 - err := database.BatchCreateAnnotations(ctx, annotations) 261 + err := dbs.Articles.BatchCreateAnnotations(ctx, annotations) 262 262 assert.NilError(t, err) 263 263 264 264 annotations = append(annotations, &Annotation{URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now}) 265 - err = database.BatchCreateAnnotations(ctx, annotations) 265 + err = dbs.Articles.BatchCreateAnnotations(ctx, annotations) 266 266 assert.NilError(t, err) 267 267 }
-2
internal/db/db.go
··· 110 110 subscriber_count INTEGER NOT NULL DEFAULT 0, 111 111 etag TEXT, 112 112 last_modified TEXT, 113 - fetch_interval_minutes INTEGER NOT NULL DEFAULT 30, 114 - next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 115 113 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0, 116 114 error_count INTEGER NOT NULL DEFAULT 0, 117 115 favicon_url TEXT
+89 -91
internal/db/feed.go
··· 21 21 SubscriberCount int 22 22 Etag sql.NullString 23 23 LastModified sql.NullString 24 - FetchIntervalMinutes int 25 - NextFetchAt sql.NullTime 26 24 ConsecutiveEmptyFetches int 27 25 ErrorCount int 28 26 FaviconURL sql.NullString ··· 41 39 FaviconURL sql.NullString 42 40 } 43 41 44 - func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error { 45 - return db.BatchUpsertFeeds(ctx, []*Feed{feed}) 42 + func (s *ArticleStore) UpsertFeed(ctx context.Context, feed *Feed) error { 43 + return s.BatchUpsertFeeds(ctx, []*Feed{feed}) 46 44 } 47 45 48 - func (db *DB) GetFeed(ctx context.Context, feedURL string) (*Feed, error) { 46 + func (s *ArticleStore) GetFeed(ctx context.Context, feedURL string) (*Feed, error) { 49 47 f := &Feed{} 50 - err := db.QueryRowContext(ctx, ` 48 + err := s.db.QueryRowContext(ctx, ` 51 49 SELECT feed_url, title, site_url, description, feed_type, 52 50 last_fetched_at, last_error, subscriber_count, etag, last_modified, 53 - fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url 54 - FROM feeds WHERE feed_url = ? 51 + consecutive_empty_fetches, error_count, favicon_url 52 + FROM articles.feeds WHERE feed_url = ? 55 53 `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 56 54 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 57 - &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL) 55 + &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL) 58 56 if err != nil { 59 57 return nil, err 60 58 } 61 59 return f, nil 62 60 } 63 61 64 - func (db *DB) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error) { 62 + func (s *ArticleStore) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*Feed, error) { 65 63 cutoff := time.Now().Add(-olderThan) 66 - rows, err := db.QueryContext(ctx, ` 64 + rows, err := s.db.QueryContext(ctx, ` 67 65 SELECT feed_url, title, site_url, description, feed_type, 68 66 last_fetched_at, last_error, subscriber_count, etag, last_modified, 69 - fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url 70 - FROM feeds 67 + consecutive_empty_fetches, error_count, favicon_url 68 + FROM articles.feeds 71 69 WHERE subscriber_count > 0 AND error_count < 25 AND (last_fetched_at IS NULL OR last_fetched_at <= ?) 72 70 ORDER BY last_fetched_at ASC NULLS FIRST 73 71 LIMIT ? ··· 82 80 f := &Feed{} 83 81 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 84 82 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 85 - &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 83 + &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 86 84 return nil, err 87 85 } 88 86 feeds = append(feeds, f) ··· 90 88 return feeds, rows.Err() 91 89 } 92 90 93 - func (db *DB) MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error { 94 - _, err := db.ExecContext(ctx, ` 95 - UPDATE feeds SET 91 + func (s *ArticleStore) MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error { 92 + _, err := s.db.ExecContext(ctx, ` 93 + UPDATE articles.feeds SET 96 94 etag = ?, 97 95 last_modified = ?, 98 96 error_count = 0, ··· 103 101 return err 104 102 } 105 103 106 - func (db *DB) MarkFeedFetchError(ctx context.Context, feedURL, lastError string) error { 107 - _, err := db.ExecContext(ctx, ` 108 - UPDATE feeds SET 104 + func (s *ArticleStore) MarkFeedFetchError(ctx context.Context, feedURL, lastError string) error { 105 + _, err := s.db.ExecContext(ctx, ` 106 + UPDATE articles.feeds SET 109 107 error_count = error_count + 1, 110 108 last_error = ?, 111 109 last_fetched_at = CURRENT_TIMESTAMP ··· 114 112 return err 115 113 } 116 114 117 - func (db *DB) decrementSubscriberCount(ctx context.Context, feedURL string) error { 118 - _, err := db.ExecContext(ctx, ` 119 - UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ? 115 + func (s *ArticleStore) decrementSubscriberCount(ctx context.Context, feedURL string) error { 116 + _, err := s.db.ExecContext(ctx, ` 117 + UPDATE articles.feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ? 120 118 `, feedURL) 121 119 return err 122 120 } 123 121 124 - func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error { 125 - existing, err := db.GetSubscription(ctx, userDID, feedURL) 122 + func (s *ArticleStore) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error { 123 + existing, err := s.GetSubscription(ctx, userDID, feedURL) 126 124 if err == nil && existing != nil { 127 125 if !existing.URI.Valid || existing.URI.String == "" { 128 - return db.updateSubscriptionURI(ctx, userDID, feedURL, uri, cid) 126 + return s.updateSubscriptionURI(ctx, userDID, feedURL, uri, cid) 129 127 } 130 128 return ErrDuplicateSubscription 131 129 } 132 - return db.BatchReconcileSubscriptions(ctx, userDID, []SubData{{FeedURL: feedURL, Title: title, Category: category, URI: uri, CID: cid}}) 130 + return s.BatchReconcileSubscriptions(ctx, userDID, []SubData{{FeedURL: feedURL, Title: title, Category: category, URI: uri, CID: cid}}) 133 131 } 134 132 135 - func (db *DB) updateSubscriptionURI(ctx context.Context, userDID, feedURL, uri, cid string) error { 136 - _, err := db.ExecContext(ctx, ` 137 - UPDATE subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ? 133 + func (s *ArticleStore) updateSubscriptionURI(ctx context.Context, userDID, feedURL, uri, cid string) error { 134 + _, err := s.db.ExecContext(ctx, ` 135 + UPDATE articles.subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ? 138 136 `, uri, cid, userDID, feedURL) 139 137 return err 140 138 } ··· 153 151 return v 154 152 } 155 153 156 - func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error { 157 - _, err := db.ExecContext(ctx, ` 158 - DELETE FROM subscriptions WHERE user_did = ? AND feed_url = ? 154 + func (s *ArticleStore) DeleteSubscription(ctx context.Context, userDID, feedURL string) error { 155 + _, err := s.db.ExecContext(ctx, ` 156 + DELETE FROM articles.subscriptions WHERE user_did = ? AND feed_url = ? 159 157 `, userDID, feedURL) 160 158 if err != nil { 161 159 return err 162 160 } 163 - return db.decrementSubscriberCount(ctx, feedURL) 161 + return s.decrementSubscriberCount(ctx, feedURL) 164 162 } 165 163 166 - func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error { 167 - tx, err := db.BeginTx(ctx, nil) 164 + func (s *ArticleStore) DeleteAllSubscriptions(ctx context.Context, userDID string) error { 165 + tx, err := s.db.BeginTx(ctx, nil) 168 166 if err != nil { 169 167 return err 170 168 } 171 169 defer tx.Rollback() 172 170 173 - rows, err := tx.QueryContext(ctx, `SELECT feed_url FROM subscriptions WHERE user_did = ?`, userDID) 171 + rows, err := tx.QueryContext(ctx, `SELECT feed_url FROM articles.subscriptions WHERE user_did = ?`, userDID) 174 172 if err != nil { 175 173 return err 176 174 } ··· 185 183 } 186 184 rows.Close() 187 185 188 - _, err = tx.ExecContext(ctx, `DELETE FROM subscriptions WHERE user_did = ?`, userDID) 186 + _, err = tx.ExecContext(ctx, `DELETE FROM articles.subscriptions WHERE user_did = ?`, userDID) 189 187 if err != nil { 190 188 return err 191 189 } ··· 198 196 args[i] = u 199 197 } 200 198 _, err = tx.ExecContext(ctx, ` 201 - UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) 199 + UPDATE articles.feeds SET subscriber_count = MAX(subscriber_count - 1, 0) 202 200 WHERE feed_url IN (`+strings.Join(ph, ",")+`) 203 201 `, args...) 204 202 if err != nil { ··· 209 207 return tx.Commit() 210 208 } 211 209 212 - func (db *DB) GetSubscriptionByURI(ctx context.Context, userDID, uri string) (*Subscription, error) { 213 - s := &Subscription{} 214 - err := db.QueryRowContext(ctx, ` 210 + func (s *ArticleStore) GetSubscriptionByURI(ctx context.Context, userDID, uri string) (*Subscription, error) { 211 + sub := &Subscription{} 212 + err := s.db.QueryRowContext(ctx, ` 215 213 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at, 216 214 s.uri, s.cid 217 - FROM subscriptions s 218 - LEFT JOIN feeds f ON s.feed_url = f.feed_url 215 + FROM articles.subscriptions s 216 + LEFT JOIN articles.feeds f ON s.feed_url = f.feed_url 219 217 WHERE s.user_did = ? AND s.uri = ? 220 - `, userDID, uri).Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt, &s.URI, &s.CID) 218 + `, userDID, uri).Scan(&sub.ID, &sub.UserDID, &sub.FeedURL, &sub.FeedTitle, &sub.Category, &sub.AddedAt, &sub.URI, &sub.CID) 221 219 if err != nil { 222 220 return nil, err 223 221 } 224 - return s, nil 222 + return sub, nil 225 223 } 226 224 227 - func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) { 228 - s := &Subscription{} 229 - err := db.QueryRowContext(ctx, ` 225 + func (s *ArticleStore) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) { 226 + sub := &Subscription{} 227 + err := s.db.QueryRowContext(ctx, ` 230 228 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at, 231 229 s.uri, s.cid 232 - FROM subscriptions s 233 - LEFT JOIN feeds f ON s.feed_url = f.feed_url 230 + FROM articles.subscriptions s 231 + LEFT JOIN articles.feeds f ON s.feed_url = f.feed_url 234 232 WHERE s.user_did = ? AND s.feed_url = ? 235 - `, userDID, feedURL).Scan(&s.ID, &s.UserDID, &s.FeedURL, &s.FeedTitle, &s.Category, &s.AddedAt, &s.URI, &s.CID) 233 + `, userDID, feedURL).Scan(&sub.ID, &sub.UserDID, &sub.FeedURL, &sub.FeedTitle, &sub.Category, &sub.AddedAt, &sub.URI, &sub.CID) 236 234 if err != nil { 237 235 return nil, err 238 236 } 239 - return s, nil 237 + return sub, nil 240 238 } 241 239 242 - func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) { 240 + func (s *ArticleStore) ListSubscriptions(ctx context.Context, userDID, category string, limit, offset int) ([]*Subscription, error) { 243 241 query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at, 244 242 s.uri, s.cid, f.favicon_url 245 - FROM subscriptions s 246 - LEFT JOIN feeds f ON s.feed_url = f.feed_url 243 + FROM articles.subscriptions s 244 + LEFT JOIN articles.feeds f ON s.feed_url = f.feed_url 247 245 WHERE s.user_did = ?` 248 246 args := []any{userDID} 249 247 ··· 255 253 query += ` ORDER BY s.added_at DESC LIMIT ? OFFSET ?` 256 254 args = append(args, limit, offset) 257 255 258 - rows, err := db.QueryContext(ctx, query, args...) 256 + rows, err := s.db.QueryContext(ctx, query, args...) 259 257 if err != nil { 260 258 return nil, err 261 259 } ··· 272 270 return subs, rows.Err() 273 271 } 274 272 275 - func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, error) { 273 + func (s *ArticleStore) GetSubscriptionCount(ctx context.Context, userDID string) (int, error) { 276 274 var count int 277 - err := db.QueryRowContext(ctx, ` 278 - SELECT COUNT(*) FROM subscriptions WHERE user_did = ? 275 + err := s.db.QueryRowContext(ctx, ` 276 + SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ? 279 277 `, userDID).Scan(&count) 280 278 return count, err 281 279 } 282 280 283 - func (db *DB) GetCategories(ctx context.Context, userDID string) ([]string, error) { 284 - rows, err := db.QueryContext(ctx, ` 285 - SELECT DISTINCT category FROM subscriptions 281 + func (s *ArticleStore) GetCategories(ctx context.Context, userDID string) ([]string, error) { 282 + rows, err := s.db.QueryContext(ctx, ` 283 + SELECT DISTINCT category FROM articles.subscriptions 286 284 WHERE user_did = ? AND category IS NOT NULL AND category != '' 287 285 ORDER BY category 288 286 `, userDID) ··· 302 300 return categories, rows.Err() 303 301 } 304 302 305 - func (db *DB) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error { 306 - _, err := db.ExecContext(ctx, `UPDATE feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL) 303 + func (s *ArticleStore) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error { 304 + _, err := s.db.ExecContext(ctx, `UPDATE articles.feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL) 307 305 return err 308 306 } 309 307 310 - func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int) ([]*Feed, error) { 311 - rows, err := db.QueryContext(ctx, ` 308 + func (s *ArticleStore) ListDeadFeeds(ctx context.Context, userDID string, threshold int) ([]*Feed, error) { 309 + rows, err := s.db.QueryContext(ctx, ` 312 310 SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type, 313 311 f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified, 314 - f.fetch_interval_minutes, f.next_fetch_at, f.consecutive_empty_fetches, f.error_count, f.favicon_url 315 - FROM feeds f 316 - JOIN subscriptions s ON s.feed_url = f.feed_url AND s.user_did = ? 312 + f.consecutive_empty_fetches, f.error_count, f.favicon_url 313 + FROM articles.feeds f 314 + JOIN articles.subscriptions s ON s.feed_url = f.feed_url AND s.user_did = ? 317 315 WHERE f.error_count >= ? 318 316 ORDER BY f.error_count DESC 319 317 `, userDID, threshold) ··· 327 325 f := &Feed{} 328 326 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 329 327 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 330 - &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 328 + &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 331 329 return nil, err 332 330 } 333 331 feeds = append(feeds, f) ··· 335 333 return feeds, rows.Err() 336 334 } 337 335 338 - func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) { 339 - rows, err := db.QueryContext(ctx, ` 336 + func (s *ArticleStore) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) { 337 + rows, err := s.db.QueryContext(ctx, ` 340 338 SELECT feed_url, title, site_url, description, feed_type, 341 339 last_fetched_at, last_error, subscriber_count, etag, last_modified, 342 - fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url 343 - FROM feeds 340 + consecutive_empty_fetches, error_count, favicon_url 341 + FROM articles.feeds 344 342 ORDER BY subscriber_count DESC 345 343 LIMIT ? OFFSET ? 346 344 `, limit, offset) ··· 354 352 f := &Feed{} 355 353 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 356 354 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 357 - &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 355 + &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 358 356 return nil, err 359 357 } 360 358 feeds = append(feeds, f) ··· 370 368 CID string 371 369 } 372 370 373 - func (db *DB) BatchUpsertFeeds(ctx context.Context, feeds []*Feed) error { 371 + func (s *ArticleStore) BatchUpsertFeeds(ctx context.Context, feeds []*Feed) error { 374 372 if len(feeds) == 0 { 375 373 return nil 376 374 } 377 - tx, err := db.BeginTx(ctx, nil) 375 + tx, err := s.db.BeginTx(ctx, nil) 378 376 if err != nil { 379 377 return err 380 378 } 381 379 defer tx.Rollback() 382 380 383 381 stmt, err := tx.PrepareContext(ctx, ` 384 - INSERT INTO feeds (feed_url, title, site_url, description, feed_type) 382 + INSERT INTO articles.feeds (feed_url, title, site_url, description, feed_type) 385 383 VALUES (?, ?, ?, ?, ?) 386 384 ON CONFLICT(feed_url) DO UPDATE SET 387 385 title = excluded.title, ··· 402 400 return tx.Commit() 403 401 } 404 402 405 - func (db *DB) BatchReconcileSubscriptions(ctx context.Context, userDID string, subs []SubData) error { 403 + func (s *ArticleStore) BatchReconcileSubscriptions(ctx context.Context, userDID string, subs []SubData) error { 406 404 if len(subs) == 0 { 407 405 return nil 408 406 } 409 - tx, err := db.BeginTx(ctx, nil) 407 + tx, err := s.db.BeginTx(ctx, nil) 410 408 if err != nil { 411 409 return err 412 410 } 413 411 defer tx.Rollback() 414 412 415 - rows, err := tx.QueryContext(ctx, `SELECT feed_url, COALESCE(uri, '') FROM subscriptions WHERE user_did = ?`, userDID) 413 + rows, err := tx.QueryContext(ctx, `SELECT feed_url, COALESCE(uri, '') FROM articles.subscriptions WHERE user_did = ?`, userDID) 416 414 if err != nil { 417 415 return err 418 416 } ··· 428 426 rows.Close() 429 427 430 428 insertStmt, err := tx.PrepareContext(ctx, ` 431 - INSERT OR IGNORE INTO subscriptions (user_did, feed_url, title, category, uri, cid) 429 + INSERT OR IGNORE INTO articles.subscriptions (user_did, feed_url, title, category, uri, cid) 432 430 VALUES (?, ?, ?, ?, ?, ?) 433 431 `) 434 432 if err != nil { ··· 437 435 defer insertStmt.Close() 438 436 439 437 updateStmt, err := tx.PrepareContext(ctx, ` 440 - UPDATE subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ? 438 + UPDATE articles.subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ? 441 439 `) 442 440 if err != nil { 443 441 return err 444 442 } 445 443 defer updateStmt.Close() 446 444 447 - incrStmt, err := tx.PrepareContext(ctx, `UPDATE feeds SET subscriber_count = subscriber_count + 1 WHERE feed_url = ?`) 445 + incrStmt, err := tx.PrepareContext(ctx, `UPDATE articles.feeds SET subscriber_count = subscriber_count + 1 WHERE feed_url = ?`) 448 446 if err != nil { 449 447 return err 450 448 } ··· 473 471 return tx.Commit() 474 472 } 475 473 476 - func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) { 477 - rows, err := db.QueryContext(ctx, ` 474 + func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) { 475 + rows, err := s.db.QueryContext(ctx, ` 478 476 SELECT feed_url, title, site_url, description, feed_type, 479 477 last_fetched_at, last_error, subscriber_count, etag, last_modified, 480 - fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url 481 - FROM feeds 482 - WHERE feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?) 478 + consecutive_empty_fetches, error_count, favicon_url 479 + FROM articles.feeds 480 + WHERE feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?) 483 481 ORDER BY subscriber_count DESC 484 482 LIMIT ? OFFSET ? 485 483 `, userDID, limit, offset) ··· 493 491 f := &Feed{} 494 492 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType, 495 493 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified, 496 - &f.FetchIntervalMinutes, &f.NextFetchAt, &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 494 + &f.ConsecutiveEmptyFetches, &f.ErrorCount, &f.FaviconURL); err != nil { 497 495 return nil, err 498 496 } 499 497 feeds = append(feeds, f)
+16 -16
internal/db/follow.go
··· 14 14 FollowedAt sql.NullTime 15 15 } 16 16 17 - func (db *DB) UpsertFollow(ctx context.Context, userDID, targetDID, uri, cid string) error { 18 - _, err := db.ExecContext(ctx, ` 17 + func (s *UserStore) UpsertFollow(ctx context.Context, userDID, targetDID, uri, cid string) error { 18 + _, err := s.db.ExecContext(ctx, ` 19 19 INSERT INTO follows (user_did, target_did, uri, cid, followed_at) 20 20 VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP) 21 21 ON CONFLICT(user_did, target_did) DO UPDATE SET ··· 25 25 return err 26 26 } 27 27 28 - func (db *DB) DeleteFollow(ctx context.Context, userDID, targetDID string) error { 29 - _, err := db.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID) 28 + func (s *UserStore) DeleteFollow(ctx context.Context, userDID, targetDID string) error { 29 + _, err := s.db.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID) 30 30 return err 31 31 } 32 32 33 - func (db *DB) DeleteFollowByURI(ctx context.Context, uri string) error { 34 - _, err := db.ExecContext(ctx, `DELETE FROM follows WHERE uri = ?`, uri) 33 + func (s *UserStore) DeleteFollowByURI(ctx context.Context, uri string) error { 34 + _, err := s.db.ExecContext(ctx, `DELETE FROM follows WHERE uri = ?`, uri) 35 35 return err 36 36 } 37 37 38 - func (db *DB) ListFollows(ctx context.Context, userDID string, limit, offset int) ([]*Follow, error) { 39 - rows, err := db.QueryContext(ctx, ` 38 + func (s *UserStore) ListFollows(ctx context.Context, userDID string, limit, offset int) ([]*Follow, error) { 39 + rows, err := s.db.QueryContext(ctx, ` 40 40 SELECT user_did, target_did, uri, cid, followed_at 41 41 FROM follows WHERE user_did = ? 42 42 ORDER BY followed_at DESC ··· 58 58 return follows, rows.Err() 59 59 } 60 60 61 - func (db *DB) ListFollowers(ctx context.Context, targetDID string, limit, offset int) ([]*Follow, error) { 62 - rows, err := db.QueryContext(ctx, ` 61 + func (s *UserStore) ListFollowers(ctx context.Context, targetDID string, limit, offset int) ([]*Follow, error) { 62 + rows, err := s.db.QueryContext(ctx, ` 63 63 SELECT user_did, target_did, uri, cid, followed_at 64 64 FROM follows WHERE target_did = ? 65 65 ORDER BY followed_at DESC ··· 81 81 return follows, rows.Err() 82 82 } 83 83 84 - func (db *DB) IsFollowing(ctx context.Context, userDID, targetDID string) (bool, error) { 84 + func (s *UserStore) IsFollowing(ctx context.Context, userDID, targetDID string) (bool, error) { 85 85 var exists int 86 - err := db.QueryRowContext(ctx, ` 86 + err := s.db.QueryRowContext(ctx, ` 87 87 SELECT 1 FROM follows WHERE user_did = ? AND target_did = ? 88 88 `, userDID, targetDID).Scan(&exists) 89 89 if err == sql.ErrNoRows { ··· 95 95 return true, nil 96 96 } 97 97 98 - func (db *DB) GetFollowDIDs(ctx context.Context, userDID string) ([]string, error) { 99 - rows, err := db.QueryContext(ctx, ` 98 + func (s *UserStore) GetFollowDIDs(ctx context.Context, userDID string) ([]string, error) { 99 + rows, err := s.db.QueryContext(ctx, ` 100 100 SELECT target_did FROM follows WHERE user_did = ? 101 101 `, userDID) 102 102 if err != nil { ··· 115 115 return dids, rows.Err() 116 116 } 117 117 118 - func (db *DB) SyncFollows(ctx context.Context, userDID string, activeFollows map[string]Follow) error { 119 - tx, err := db.BeginTx(ctx, nil) 118 + func (s *UserStore) SyncFollows(ctx context.Context, userDID string, activeFollows map[string]Follow) error { 119 + tx, err := s.db.BeginTx(ctx, nil) 120 120 if err != nil { 121 121 return err 122 122 }
+51 -51
internal/db/follow_test.go
··· 7 7 "gotest.tools/v3/assert" 8 8 ) 9 9 10 - func seedFollowData(t *testing.T, ctx context.Context, db *DB) (userDID, targetDID string) { 10 + func seedFollowData(t *testing.T, ctx context.Context, dbs *Databases) (userDID, targetDID string) { 11 11 t.Helper() 12 12 13 13 userDID = "did:test:follower" 14 14 targetDID = "did:test:followed" 15 15 16 - _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "follower") 16 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, userDID, "follower") 17 17 assert.NilError(t, err) 18 - _, err = db.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, targetDID, "followed") 18 + _, err = dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, targetDID, "followed") 19 19 assert.NilError(t, err) 20 20 21 21 return userDID, targetDID ··· 23 23 24 24 func TestUpsertFollow(t *testing.T) { 25 25 ctx := context.Background() 26 - db := setupTestDB(t) 27 - userDID, targetDID := seedFollowData(t, ctx, db) 26 + dbs := setupTestDB(t) 27 + userDID, targetDID := seedFollowData(t, ctx, dbs) 28 28 29 - err := db.UpsertFollow(ctx, userDID, targetDID, "at://did:test:follower/app.bsky.graph.follow/123", "cid123") 29 + err := dbs.Users.UpsertFollow(ctx, userDID, targetDID, "at://did:test:follower/app.bsky.graph.follow/123", "cid123") 30 30 assert.NilError(t, err) 31 31 32 - following, err := db.IsFollowing(ctx, userDID, targetDID) 32 + following, err := dbs.Users.IsFollowing(ctx, userDID, targetDID) 33 33 assert.NilError(t, err) 34 34 assert.Equal(t, following, true) 35 35 } 36 36 37 37 func TestIsFollowing_NotFollowing(t *testing.T) { 38 38 ctx := context.Background() 39 - db := setupTestDB(t) 40 - userDID, targetDID := seedFollowData(t, ctx, db) 39 + dbs := setupTestDB(t) 40 + userDID, targetDID := seedFollowData(t, ctx, dbs) 41 41 42 - following, err := db.IsFollowing(ctx, userDID, targetDID) 42 + following, err := dbs.Users.IsFollowing(ctx, userDID, targetDID) 43 43 assert.NilError(t, err) 44 44 assert.Equal(t, following, false) 45 45 } 46 46 47 47 func TestDeleteFollow(t *testing.T) { 48 48 ctx := context.Background() 49 - db := setupTestDB(t) 50 - userDID, targetDID := seedFollowData(t, ctx, db) 49 + dbs := setupTestDB(t) 50 + userDID, targetDID := seedFollowData(t, ctx, dbs) 51 51 52 - err := db.UpsertFollow(ctx, userDID, targetDID, "at://uri", "cid") 52 + err := dbs.Users.UpsertFollow(ctx, userDID, targetDID, "at://uri", "cid") 53 53 assert.NilError(t, err) 54 54 55 - err = db.DeleteFollow(ctx, userDID, targetDID) 55 + err = dbs.Users.DeleteFollow(ctx, userDID, targetDID) 56 56 assert.NilError(t, err) 57 57 58 - following, err := db.IsFollowing(ctx, userDID, targetDID) 58 + following, err := dbs.Users.IsFollowing(ctx, userDID, targetDID) 59 59 assert.NilError(t, err) 60 60 assert.Equal(t, following, false) 61 61 } 62 62 63 63 func TestDeleteFollowByURI(t *testing.T) { 64 64 ctx := context.Background() 65 - db := setupTestDB(t) 66 - userDID, targetDID := seedFollowData(t, ctx, db) 65 + dbs := setupTestDB(t) 66 + userDID, targetDID := seedFollowData(t, ctx, dbs) 67 67 68 68 uri := "at://did:test:follower/app.bsky.graph.follow/abc" 69 - err := db.UpsertFollow(ctx, userDID, targetDID, uri, "cid") 69 + err := dbs.Users.UpsertFollow(ctx, userDID, targetDID, uri, "cid") 70 70 assert.NilError(t, err) 71 71 72 - err = db.DeleteFollowByURI(ctx, uri) 72 + err = dbs.Users.DeleteFollowByURI(ctx, uri) 73 73 assert.NilError(t, err) 74 74 75 - following, err := db.IsFollowing(ctx, userDID, targetDID) 75 + following, err := dbs.Users.IsFollowing(ctx, userDID, targetDID) 76 76 assert.NilError(t, err) 77 77 assert.Equal(t, following, false) 78 78 } 79 79 80 80 func TestListFollows(t *testing.T) { 81 81 ctx := context.Background() 82 - db := setupTestDB(t) 83 - userDID, _ := seedFollowData(t, ctx, db) 82 + dbs := setupTestDB(t) 83 + userDID, _ := seedFollowData(t, ctx, dbs) 84 84 85 85 target2 := "did:test:followed2" 86 - _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, target2, "followed2") 86 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, target2, "followed2") 87 87 assert.NilError(t, err) 88 88 89 - err = db.UpsertFollow(ctx, userDID, "did:test:followed", "uri1", "cid1") 89 + err = dbs.Users.UpsertFollow(ctx, userDID, "did:test:followed", "uri1", "cid1") 90 90 assert.NilError(t, err) 91 - err = db.UpsertFollow(ctx, userDID, target2, "uri2", "cid2") 91 + err = dbs.Users.UpsertFollow(ctx, userDID, target2, "uri2", "cid2") 92 92 assert.NilError(t, err) 93 93 94 - follows, err := db.ListFollows(ctx, userDID, 10, 0) 94 + follows, err := dbs.Users.ListFollows(ctx, userDID, 10, 0) 95 95 assert.NilError(t, err) 96 96 assert.Equal(t, len(follows), 2) 97 97 } 98 98 99 99 func TestListFollowers(t *testing.T) { 100 100 ctx := context.Background() 101 - db := setupTestDB(t) 102 - _, targetDID := seedFollowData(t, ctx, db) 101 + dbs := setupTestDB(t) 102 + _, targetDID := seedFollowData(t, ctx, dbs) 103 103 104 104 follower2 := "did:test:follower2" 105 - _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, follower2, "follower2") 105 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, follower2, "follower2") 106 106 assert.NilError(t, err) 107 107 108 - err = db.UpsertFollow(ctx, "did:test:follower", targetDID, "uri1", "cid1") 108 + err = dbs.Users.UpsertFollow(ctx, "did:test:follower", targetDID, "uri1", "cid1") 109 109 assert.NilError(t, err) 110 - err = db.UpsertFollow(ctx, follower2, targetDID, "uri2", "cid2") 110 + err = dbs.Users.UpsertFollow(ctx, follower2, targetDID, "uri2", "cid2") 111 111 assert.NilError(t, err) 112 112 113 - followers, err := db.ListFollowers(ctx, targetDID, 10, 0) 113 + followers, err := dbs.Users.ListFollowers(ctx, targetDID, 10, 0) 114 114 assert.NilError(t, err) 115 115 assert.Equal(t, len(followers), 2) 116 116 } 117 117 118 118 func TestGetFollowDIDs(t *testing.T) { 119 119 ctx := context.Background() 120 - db := setupTestDB(t) 121 - userDID, _ := seedFollowData(t, ctx, db) 120 + dbs := setupTestDB(t) 121 + userDID, _ := seedFollowData(t, ctx, dbs) 122 122 123 123 target2 := "did:test:followed2" 124 - _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, target2, "followed2") 124 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, target2, "followed2") 125 125 assert.NilError(t, err) 126 126 127 - err = db.UpsertFollow(ctx, userDID, "did:test:followed", "uri1", "cid1") 127 + err = dbs.Users.UpsertFollow(ctx, userDID, "did:test:followed", "uri1", "cid1") 128 128 assert.NilError(t, err) 129 - err = db.UpsertFollow(ctx, userDID, target2, "uri2", "cid2") 129 + err = dbs.Users.UpsertFollow(ctx, userDID, target2, "uri2", "cid2") 130 130 assert.NilError(t, err) 131 131 132 - dids, err := db.GetFollowDIDs(ctx, userDID) 132 + dids, err := dbs.Users.GetFollowDIDs(ctx, userDID) 133 133 assert.NilError(t, err) 134 134 assert.Equal(t, len(dids), 2) 135 135 } 136 136 137 137 func TestSyncFollows_AddsNewRemovesStale(t *testing.T) { 138 138 ctx := context.Background() 139 - db := setupTestDB(t) 140 - userDID, _ := seedFollowData(t, ctx, db) 139 + dbs := setupTestDB(t) 140 + userDID, _ := seedFollowData(t, ctx, dbs) 141 141 142 - err := db.UpsertFollow(ctx, userDID, "did:test:old", "old-uri", "old-cid") 142 + err := dbs.Users.UpsertFollow(ctx, userDID, "did:test:old", "old-uri", "old-cid") 143 143 assert.NilError(t, err) 144 144 145 145 activeFollows := map[string]Follow{ ··· 147 147 "did:test:new2": {URI: NullStr("uri2"), CID: NullStr("cid2")}, 148 148 } 149 149 150 - err = db.SyncFollows(ctx, userDID, activeFollows) 150 + err = dbs.Users.SyncFollows(ctx, userDID, activeFollows) 151 151 assert.NilError(t, err) 152 152 153 - stillFollowing, err := db.IsFollowing(ctx, userDID, "did:test:old") 153 + stillFollowing, err := dbs.Users.IsFollowing(ctx, userDID, "did:test:old") 154 154 assert.NilError(t, err) 155 155 assert.Equal(t, stillFollowing, false) 156 156 157 - following1, err := db.IsFollowing(ctx, userDID, "did:test:new1") 157 + following1, err := dbs.Users.IsFollowing(ctx, userDID, "did:test:new1") 158 158 assert.NilError(t, err) 159 159 assert.Equal(t, following1, true) 160 160 161 - following2, err := db.IsFollowing(ctx, userDID, "did:test:new2") 161 + following2, err := dbs.Users.IsFollowing(ctx, userDID, "did:test:new2") 162 162 assert.NilError(t, err) 163 163 assert.Equal(t, following2, true) 164 164 165 - dids, err := db.GetFollowDIDs(ctx, userDID) 165 + dids, err := dbs.Users.GetFollowDIDs(ctx, userDID) 166 166 assert.NilError(t, err) 167 167 assert.Equal(t, len(dids), 2) 168 168 } 169 169 170 170 func TestUpsertFollow_Idempotent(t *testing.T) { 171 171 ctx := context.Background() 172 - db := setupTestDB(t) 173 - userDID, targetDID := seedFollowData(t, ctx, db) 172 + dbs := setupTestDB(t) 173 + userDID, targetDID := seedFollowData(t, ctx, dbs) 174 174 175 - err := db.UpsertFollow(ctx, userDID, targetDID, "uri1", "cid1") 175 + err := dbs.Users.UpsertFollow(ctx, userDID, targetDID, "uri1", "cid1") 176 176 assert.NilError(t, err) 177 - err = db.UpsertFollow(ctx, userDID, targetDID, "uri2", "cid2") 177 + err = dbs.Users.UpsertFollow(ctx, userDID, targetDID, "uri2", "cid2") 178 178 assert.NilError(t, err) 179 179 180 - follows, err := db.ListFollows(ctx, userDID, 10, 0) 180 + follows, err := dbs.Users.ListFollows(ctx, userDID, 10, 0) 181 181 assert.NilError(t, err) 182 182 assert.Equal(t, len(follows), 1) 183 183 assert.Equal(t, follows[0].URI.String, "uri2")
+71 -85
internal/db/multi.go
··· 11 11 ) 12 12 13 13 type Databases struct { 14 - Users *DB 15 - Articles *DB 16 - Recs *DB 14 + Users *UserStore 15 + Articles *ArticleStore 16 + 17 + db *DB 17 18 } 18 19 19 20 var multiDriverSeq int64 ··· 21 22 func OpenAll(basePath string) (*Databases, error) { 22 23 articlesPath := basePath + "_articles" 23 24 recsPath := basePath + "_recs" 25 + 26 + for _, p := range []string{articlesPath, recsPath} { 27 + f, err := sql.Open("sqlite3", p+"?"+DSN) 28 + if err != nil { 29 + return nil, err 30 + } 31 + f.Close() 32 + } 24 33 25 34 seq := atomic.AddInt64(&multiDriverSeq, 1) 26 35 driverName := fmt.Sprintf("sqlite3_glean_multi_%d", seq) ··· 52 61 }, 53 62 }) 54 63 55 - usersDB, err := sql.Open(driverName, basePath+"_users?cache=shared&"+DSN) 64 + db, err := sql.Open(driverName, basePath+"_users?cache=shared&"+DSN) 56 65 if err != nil { 57 66 return nil, err 58 67 } 59 - usersDB.SetMaxOpenConns(10) 60 - usersDB.SetMaxIdleConns(5) 61 - usersDB.SetConnMaxLifetime(30 * time.Minute) 62 - users := &DB{usersDB} 68 + db.SetMaxOpenConns(10) 69 + db.SetMaxIdleConns(5) 70 + db.SetConnMaxLifetime(30 * time.Minute) 71 + d := &DB{db} 63 72 64 - articles, err := Open(articlesPath) 65 - if err != nil { 66 - users.Close() 67 - return nil, err 68 - } 69 - 70 - recs, err := Open(recsPath) 71 - if err != nil { 72 - users.Close() 73 - articles.Close() 74 - return nil, err 75 - } 76 - 77 - if err := initUsersSchema(users); err != nil { 78 - users.Close() 79 - articles.Close() 80 - recs.Close() 73 + if err := initUsersSchema(d); err != nil { 74 + d.Close() 81 75 return nil, err 82 76 } 83 77 84 - if err := initArticlesSchema(articles); err != nil { 85 - users.Close() 86 - articles.Close() 87 - recs.Close() 78 + if err := initArticlesSchema(d); err != nil { 79 + d.Close() 88 80 return nil, err 89 81 } 90 82 91 - if err := initRecsSchema(recs); err != nil { 92 - users.Close() 93 - articles.Close() 94 - recs.Close() 83 + if err := initRecsSchema(d); err != nil { 84 + d.Close() 95 85 return nil, err 96 86 } 97 87 98 88 return &Databases{ 99 - Users: users, 100 - Articles: articles, 101 - Recs: recs, 89 + Users: NewUserStore(d), 90 + Articles: NewArticleStore(d), 91 + db: d, 102 92 }, nil 103 93 } 104 94 105 95 func (d *Databases) Close() error { 106 - if d.Users != nil { 107 - _ = d.Users.Close() 108 - } 109 - if d.Articles != nil { 110 - _ = d.Articles.Close() 111 - } 112 - if d.Recs != nil { 113 - _ = d.Recs.Close() 96 + if d.db != nil { 97 + _ = d.db.Close() 114 98 } 115 99 return nil 100 + } 101 + 102 + func (d *Databases) DB() *sql.DB { 103 + return d.db.DB 116 104 } 117 105 118 106 func initUsersSchema(db *DB) error { ··· 181 169 } 182 170 183 171 var articlesSchema = []string{ 184 - `CREATE TABLE IF NOT EXISTS feeds ( 172 + `CREATE TABLE IF NOT EXISTS articles.feeds ( 185 173 feed_url TEXT PRIMARY KEY, 186 174 title TEXT, 187 175 site_url TEXT, ··· 192 180 subscriber_count INTEGER NOT NULL DEFAULT 0, 193 181 etag TEXT, 194 182 last_modified TEXT, 195 - fetch_interval_minutes INTEGER NOT NULL DEFAULT 30, 196 - next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 197 183 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0, 198 184 error_count INTEGER NOT NULL DEFAULT 0, 199 185 favicon_url TEXT 200 186 )`, 201 187 202 - `CREATE TABLE IF NOT EXISTS subscriptions ( 188 + `CREATE TABLE IF NOT EXISTS articles.subscriptions ( 203 189 id INTEGER PRIMARY KEY AUTOINCREMENT, 204 190 user_did TEXT NOT NULL, 205 191 feed_url TEXT NOT NULL, ··· 211 197 UNIQUE(user_did, feed_url) 212 198 )`, 213 199 214 - `CREATE TABLE IF NOT EXISTS articles ( 200 + `CREATE TABLE IF NOT EXISTS articles.articles ( 215 201 id INTEGER PRIMARY KEY AUTOINCREMENT, 216 202 feed_url TEXT NOT NULL, 217 203 guid TEXT NOT NULL, ··· 227 213 UNIQUE(feed_url, guid) 228 214 )`, 229 215 230 - `CREATE TABLE IF NOT EXISTS read_state ( 216 + `CREATE TABLE IF NOT EXISTS articles.read_state ( 231 217 user_did TEXT NOT NULL, 232 218 article_id INTEGER NOT NULL, 233 219 is_read BOOLEAN NOT NULL DEFAULT 0, ··· 235 221 PRIMARY KEY (user_did, article_id) 236 222 )`, 237 223 238 - `CREATE TABLE IF NOT EXISTS annotations ( 224 + `CREATE TABLE IF NOT EXISTS articles.annotations ( 239 225 id INTEGER PRIMARY KEY AUTOINCREMENT, 240 226 uri TEXT NOT NULL UNIQUE, 241 227 author_did TEXT NOT NULL, ··· 249 235 cid TEXT 250 236 )`, 251 237 252 - `CREATE TABLE IF NOT EXISTS likes ( 238 + `CREATE TABLE IF NOT EXISTS articles.likes ( 253 239 id INTEGER PRIMARY KEY AUTOINCREMENT, 254 240 uri TEXT NOT NULL UNIQUE, 255 241 author_did TEXT NOT NULL, ··· 260 246 UNIQUE(author_did, feed_url, article_url) 261 247 )`, 262 248 263 - `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`, 264 - `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)`, 265 - `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`, 266 - `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`, 267 - `CREATE INDEX IF NOT EXISTS idx_likes_author_feed ON likes(author_did, feed_url, created_at)`, 268 - `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`, 269 - `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`, 270 - `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`, 271 - `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`, 272 - `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`, 273 - `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`, 274 - `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`, 275 - `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`, 276 - `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`, 277 - `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`, 249 + `CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_feed ON subscriptions(feed_url)`, 250 + `CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)`, 251 + `CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_user ON subscriptions(user_did)`, 252 + `CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_uri ON subscriptions(uri)`, 253 + `CREATE INDEX IF NOT EXISTS articles.idx_likes_author_feed ON likes(author_did, feed_url, created_at)`, 254 + `CREATE INDEX IF NOT EXISTS articles.idx_articles_feed ON articles(feed_url)`, 255 + `CREATE INDEX IF NOT EXISTS articles.idx_articles_published ON articles(published DESC)`, 256 + `CREATE INDEX IF NOT EXISTS articles.idx_articles_url ON articles(url)`, 257 + `CREATE INDEX IF NOT EXISTS articles.idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`, 258 + `CREATE INDEX IF NOT EXISTS articles.idx_annotations_article ON annotations(article_url)`, 259 + `CREATE INDEX IF NOT EXISTS articles.idx_annotations_author ON annotations(author_did)`, 260 + `CREATE INDEX IF NOT EXISTS articles.idx_annotations_created_at ON annotations(created_at DESC)`, 261 + `CREATE INDEX IF NOT EXISTS articles.idx_likes_article ON likes(feed_url, article_url)`, 262 + `CREATE INDEX IF NOT EXISTS articles.idx_likes_author ON likes(author_did)`, 263 + `CREATE INDEX IF NOT EXISTS articles.idx_likes_created_at ON likes(created_at DESC)`, 278 264 279 - `CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`, 280 - `CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN 265 + `CREATE VIRTUAL TABLE IF NOT EXISTS articles.articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`, 266 + `CREATE TRIGGER IF NOT EXISTS articles.articles_ai AFTER INSERT ON articles BEGIN 281 267 INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author); 282 268 END`, 283 - `CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN 269 + `CREATE TRIGGER IF NOT EXISTS articles.articles_ad AFTER DELETE ON articles BEGIN 284 270 INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author); 285 271 END`, 286 - `CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN 272 + `CREATE TRIGGER IF NOT EXISTS articles.articles_au AFTER UPDATE ON articles BEGIN 287 273 INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author); 288 274 INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author); 289 275 END`, 290 276 } 291 277 292 278 var recsSchema = []string{ 293 - `CREATE TABLE IF NOT EXISTS feed_similarity ( 279 + `CREATE TABLE IF NOT EXISTS recs.feed_similarity ( 294 280 feed_a TEXT NOT NULL, 295 281 feed_b TEXT NOT NULL, 296 282 jaccard REAL NOT NULL, ··· 299 285 CHECK(feed_a < feed_b) 300 286 )`, 301 287 302 - `CREATE TABLE IF NOT EXISTS user_similarity ( 288 + `CREATE TABLE IF NOT EXISTS recs.user_similarity ( 303 289 user_a TEXT NOT NULL, 304 290 user_b TEXT NOT NULL, 305 291 jaccard REAL NOT NULL, ··· 311 297 CHECK(user_a < user_b) 312 298 )`, 313 299 314 - `CREATE TABLE IF NOT EXISTS dismissed_recommendations ( 300 + `CREATE TABLE IF NOT EXISTS recs.dismissed_recommendations ( 315 301 user_did TEXT NOT NULL, 316 302 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')), 317 303 target_id TEXT NOT NULL, ··· 320 306 PRIMARY KEY (user_did, target_type, target_id) 321 307 )`, 322 308 323 - `CREATE TABLE IF NOT EXISTS recommendation_impressions ( 309 + `CREATE TABLE IF NOT EXISTS recs.recommendation_impressions ( 324 310 user_did TEXT NOT NULL, 325 311 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')), 326 312 target_id TEXT NOT NULL, ··· 331 317 PRIMARY KEY (user_did, target_type, target_id) 332 318 )`, 333 319 334 - `CREATE TABLE IF NOT EXISTS follow_distances ( 320 + `CREATE TABLE IF NOT EXISTS recs.follow_distances ( 335 321 user_a TEXT NOT NULL, 336 322 user_b TEXT NOT NULL, 337 323 distance INTEGER NOT NULL CHECK(distance IN (1, 2)), 338 324 PRIMARY KEY (user_a, user_b) 339 325 )`, 340 326 341 - `CREATE TABLE IF NOT EXISTS user_signal_weights ( 327 + `CREATE TABLE IF NOT EXISTS recs.user_signal_weights ( 342 328 user_did TEXT PRIMARY KEY, 343 329 w_sub REAL NOT NULL DEFAULT 1.0, 344 330 w_like REAL NOT NULL DEFAULT 0.5, ··· 349 335 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 350 336 )`, 351 337 352 - `CREATE TABLE IF NOT EXISTS user_signal_profiles ( 338 + `CREATE TABLE IF NOT EXISTS recs.user_signal_profiles ( 353 339 user_did TEXT PRIMARY KEY, 354 340 total_likes INTEGER NOT NULL DEFAULT 0, 355 341 total_tags INTEGER NOT NULL DEFAULT 0, ··· 357 343 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP 358 344 )`, 359 345 360 - `CREATE INDEX IF NOT EXISTS idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`, 361 - `CREATE INDEX IF NOT EXISTS idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`, 362 - `CREATE INDEX IF NOT EXISTS idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`, 363 - `CREATE INDEX IF NOT EXISTS idx_follow_distances_b ON follow_distances(user_b)`, 364 - `CREATE INDEX IF NOT EXISTS idx_follow_distances_a_dist ON follow_distances(user_a, distance)`, 365 - `CREATE INDEX IF NOT EXISTS idx_user_similarity_b ON user_similarity(user_b)`, 366 - `CREATE INDEX IF NOT EXISTS idx_user_similarity_a ON user_similarity(user_a)`, 346 + `CREATE INDEX IF NOT EXISTS recs.idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`, 347 + `CREATE INDEX IF NOT EXISTS recs.idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`, 348 + `CREATE INDEX IF NOT EXISTS recs.idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`, 349 + `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_b ON follow_distances(user_b)`, 350 + `CREATE INDEX IF NOT EXISTS recs.idx_follow_distances_a_dist ON follow_distances(user_a, distance)`, 351 + `CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_b ON user_similarity(user_b)`, 352 + `CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_a ON user_similarity(user_a)`, 367 353 }
+2 -2
internal/db/oauth_store.go
··· 14 14 db *DB 15 15 } 16 16 17 - func NewOAuthStore(db *DB) *OAuthStore { 18 - return &OAuthStore{db: db} 17 + func NewOAuthStore(dbs *Databases) *OAuthStore { 18 + return &OAuthStore{db: dbs.db} 19 19 } 20 20 21 21 func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
+60 -60
internal/db/social.go
··· 35 35 CID sql.NullString 36 36 } 37 37 38 - func (db *DB) CreateAnnotation(ctx context.Context, a *Annotation) error { 39 - return db.BatchCreateAnnotations(ctx, []*Annotation{a}) 38 + func (s *ArticleStore) CreateAnnotation(ctx context.Context, a *Annotation) error { 39 + return s.BatchCreateAnnotations(ctx, []*Annotation{a}) 40 40 } 41 41 42 - func (db *DB) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) { 42 + func (s *ArticleStore) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) { 43 43 a := &Annotation{} 44 - err := db.QueryRowContext(ctx, ` 44 + err := s.db.QueryRowContext(ctx, ` 45 45 SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, ar.id, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid 46 - FROM annotations a 46 + FROM articles.annotations a 47 47 LEFT JOIN users u ON a.author_did = u.did 48 - LEFT JOIN articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url 48 + LEFT JOIN articles.articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url 49 49 WHERE a.id = ? 50 50 `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, &a.ArticleID, 51 51 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID) ··· 55 55 return a, nil 56 56 } 57 57 58 - func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error { 59 - _, err := db.ExecContext(ctx, `DELETE FROM annotations WHERE uri = ?`, uri) 58 + func (s *ArticleStore) DeleteAnnotation(ctx context.Context, uri string) error { 59 + _, err := s.db.ExecContext(ctx, `DELETE FROM articles.annotations WHERE uri = ?`, uri) 60 60 return err 61 61 } 62 62 63 - func (db *DB) AnnotationExists(ctx context.Context, uri string) (bool, error) { 63 + func (s *ArticleStore) AnnotationExists(ctx context.Context, uri string) (bool, error) { 64 64 var exists int 65 - err := db.QueryRowContext(ctx, `SELECT 1 FROM annotations WHERE uri = ?`, uri).Scan(&exists) 65 + err := s.db.QueryRowContext(ctx, `SELECT 1 FROM articles.annotations WHERE uri = ?`, uri).Scan(&exists) 66 66 if err == sql.ErrNoRows { 67 67 return false, nil 68 68 } ··· 72 72 return true, nil 73 73 } 74 74 75 - func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) { 75 + func (s *ArticleStore) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDID string, limit, offset int) ([]*Annotation, error) { 76 76 var conds []string 77 77 var args []any 78 78 ··· 90 90 } 91 91 92 92 query := `SELECT a.id, a.uri, a.author_did, COALESCE(u.handle, ''), a.feed_url, a.article_url, ar.id, a.quote, a.note, a.tags, a.rating, a.created_at, a.cid 93 - FROM annotations a 93 + FROM articles.annotations a 94 94 LEFT JOIN users u ON a.author_did = u.did 95 - LEFT JOIN articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url` 95 + LEFT JOIN articles.articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url` 96 96 if len(conds) > 0 { 97 97 query += ` WHERE ` + strings.Join(conds, " AND ") 98 98 } 99 99 query += ` ORDER BY a.created_at DESC LIMIT ? OFFSET ?` 100 100 args = append(args, limit, offset) 101 101 102 - rows, err := db.QueryContext(ctx, query, args...) 102 + rows, err := s.db.QueryContext(ctx, query, args...) 103 103 if err != nil { 104 104 return nil, err 105 105 } ··· 117 117 return annotations, rows.Err() 118 118 } 119 119 120 - func (db *DB) BatchCreateLikes(ctx context.Context, likes []*Like) error { 120 + func (s *ArticleStore) BatchCreateLikes(ctx context.Context, likes []*Like) error { 121 121 if len(likes) == 0 { 122 122 return nil 123 123 } 124 - tx, err := db.BeginTx(ctx, nil) 124 + tx, err := s.db.BeginTx(ctx, nil) 125 125 if err != nil { 126 126 return err 127 127 } 128 128 defer tx.Rollback() 129 129 130 130 stmt, err := tx.PrepareContext(ctx, ` 131 - INSERT OR IGNORE INTO likes (uri, author_did, feed_url, article_url, created_at, cid) 131 + INSERT OR IGNORE INTO articles.likes (uri, author_did, feed_url, article_url, created_at, cid) 132 132 VALUES (?, ?, ?, ?, ?, ?) 133 133 `) 134 134 if err != nil { ··· 144 144 return tx.Commit() 145 145 } 146 146 147 - func (db *DB) BatchCreateAnnotations(ctx context.Context, annotations []*Annotation) error { 147 + func (s *ArticleStore) BatchCreateAnnotations(ctx context.Context, annotations []*Annotation) error { 148 148 if len(annotations) == 0 { 149 149 return nil 150 150 } 151 - tx, err := db.BeginTx(ctx, nil) 151 + tx, err := s.db.BeginTx(ctx, nil) 152 152 if err != nil { 153 153 return err 154 154 } 155 155 defer tx.Rollback() 156 156 157 157 stmt, err := tx.PrepareContext(ctx, ` 158 - INSERT OR IGNORE INTO annotations (uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid) 158 + INSERT OR IGNORE INTO articles.annotations (uri, author_did, feed_url, article_url, quote, note, tags, rating, created_at, cid) 159 159 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 160 160 `) 161 161 if err != nil { ··· 171 171 return tx.Commit() 172 172 } 173 173 174 - func (db *DB) CreateLike(ctx context.Context, l *Like) error { 175 - exists, err := db.HasLiked(ctx, l.AuthorDID, l.FeedURL, l.ArticleURL) 174 + func (s *ArticleStore) CreateLike(ctx context.Context, l *Like) error { 175 + exists, err := s.HasLiked(ctx, l.AuthorDID, l.FeedURL, l.ArticleURL) 176 176 if err != nil { 177 177 return err 178 178 } 179 179 if exists { 180 180 return ErrDuplicateLike 181 181 } 182 - return db.BatchCreateLikes(ctx, []*Like{l}) 182 + return s.BatchCreateLikes(ctx, []*Like{l}) 183 183 } 184 184 185 - func (db *DB) DeleteLike(ctx context.Context, uri string) error { 186 - _, err := db.ExecContext(ctx, `DELETE FROM likes WHERE uri = ?`, uri) 185 + func (s *ArticleStore) DeleteLike(ctx context.Context, uri string) error { 186 + _, err := s.db.ExecContext(ctx, `DELETE FROM articles.likes WHERE uri = ?`, uri) 187 187 return err 188 188 } 189 189 190 - func (db *DB) DeleteLikeByUserArticle(ctx context.Context, authorDID, feedURL, articleURL string) error { 191 - _, err := db.ExecContext(ctx, ` 192 - DELETE FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ? 190 + func (s *ArticleStore) DeleteLikeByUserArticle(ctx context.Context, authorDID, feedURL, articleURL string) error { 191 + _, err := s.db.ExecContext(ctx, ` 192 + DELETE FROM articles.likes WHERE author_did = ? AND feed_url = ? AND article_url = ? 193 193 `, authorDID, feedURL, articleURL) 194 194 return err 195 195 } 196 196 197 - func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, offset int) ([]*Like, error) { 197 + func (s *ArticleStore) ListLikes(ctx context.Context, authorDID, feedURL string, limit, offset int) ([]*Like, error) { 198 198 var conds []string 199 199 var args []any 200 200 ··· 207 207 args = append(args, feedURL) 208 208 } 209 209 210 - query := `SELECT id, uri, author_did, feed_url, article_url, created_at, cid FROM likes` 210 + query := `SELECT id, uri, author_did, feed_url, article_url, created_at, cid FROM articles.likes` 211 211 if len(conds) > 0 { 212 212 query += ` WHERE ` + strings.Join(conds, " AND ") 213 213 } 214 214 query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?` 215 215 args = append(args, limit, offset) 216 216 217 - rows, err := db.QueryContext(ctx, query, args...) 217 + rows, err := s.db.QueryContext(ctx, query, args...) 218 218 if err != nil { 219 219 return nil, err 220 220 } ··· 231 231 return likes, rows.Err() 232 232 } 233 233 234 - func (db *DB) GetLikeCount(ctx context.Context, feedURL, articleURL string) (int, error) { 234 + func (s *ArticleStore) GetLikeCount(ctx context.Context, feedURL, articleURL string) (int, error) { 235 235 var count int 236 - err := db.QueryRowContext(ctx, ` 237 - SELECT COUNT(*) FROM likes WHERE feed_url = ? AND article_url = ? 236 + err := s.db.QueryRowContext(ctx, ` 237 + SELECT COUNT(*) FROM articles.likes WHERE feed_url = ? AND article_url = ? 238 238 `, feedURL, articleURL).Scan(&count) 239 239 return count, err 240 240 } 241 241 242 - func (db *DB) GetLike(ctx context.Context, authorDID, feedURL, articleURL string) (*Like, error) { 242 + func (s *ArticleStore) GetLike(ctx context.Context, authorDID, feedURL, articleURL string) (*Like, error) { 243 243 l := &Like{} 244 - err := db.QueryRowContext(ctx, ` 245 - SELECT id, uri, author_did, feed_url, article_url, created_at, cid FROM likes 244 + err := s.db.QueryRowContext(ctx, ` 245 + SELECT id, uri, author_did, feed_url, article_url, created_at, cid FROM articles.likes 246 246 WHERE author_did = ? AND feed_url = ? AND article_url = ? 247 247 `, authorDID, feedURL, articleURL).Scan(&l.ID, &l.URI, &l.AuthorDID, &l.FeedURL, &l.ArticleURL, &l.CreatedAt, &l.CID) 248 248 if err != nil { ··· 251 251 return l, nil 252 252 } 253 253 254 - func (db *DB) HasLiked(ctx context.Context, authorDID, feedURL, articleURL string) (bool, error) { 254 + func (s *ArticleStore) HasLiked(ctx context.Context, authorDID, feedURL, articleURL string) (bool, error) { 255 255 var exists int 256 - err := db.QueryRowContext(ctx, ` 257 - SELECT 1 FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ? 256 + err := s.db.QueryRowContext(ctx, ` 257 + SELECT 1 FROM articles.likes WHERE author_did = ? AND feed_url = ? AND article_url = ? 258 258 `, authorDID, feedURL, articleURL).Scan(&exists) 259 259 if err == sql.ErrNoRows { 260 260 return false, nil ··· 279 279 HasLiked bool 280 280 } 281 281 282 - func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) { 283 - rows, err := db.QueryContext(ctx, ` 282 + func (s *ArticleStore) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) { 283 + rows, err := s.db.QueryContext(ctx, ` 284 284 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), 285 285 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), 286 286 COALESCE(f.favicon_url, ''), 287 287 COUNT(DISTINCT l.id) AS like_count, 288 288 COUNT(DISTINCT a.id) AS annotation_count, 289 289 COALESCE(MAX(CASE WHEN ul.id IS NOT NULL THEN 1 ELSE 0 END), 0) 290 - FROM likes l 291 - JOIN articles ar ON ar.url = l.article_url AND ar.feed_url = l.feed_url 292 - LEFT JOIN feeds f ON f.feed_url = l.feed_url 293 - LEFT JOIN annotations a ON a.feed_url = l.feed_url AND a.article_url = l.article_url AND a.created_at >= ? 294 - LEFT JOIN likes ul ON ul.feed_url = l.feed_url AND ul.article_url = l.article_url AND ul.author_did = ? 290 + FROM articles.likes l 291 + JOIN articles.articles ar ON ar.url = l.article_url AND ar.feed_url = l.feed_url 292 + LEFT JOIN articles.feeds f ON f.feed_url = l.feed_url 293 + LEFT JOIN articles.annotations a ON a.feed_url = l.feed_url AND a.article_url = l.article_url AND a.created_at >= ? 294 + LEFT JOIN articles.likes ul ON ul.feed_url = l.feed_url AND ul.article_url = l.article_url AND ul.author_did = ? 295 295 WHERE l.created_at >= ? 296 296 AND l.author_did IN ( 297 297 SELECT CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END ··· 323 323 return results, rows.Err() 324 324 } 325 325 326 - func (db *DB) ListTrendingArticles(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) { 327 - rows, err := db.QueryContext(ctx, ` 326 + func (s *ArticleStore) ListTrendingArticles(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) { 327 + rows, err := s.db.QueryContext(ctx, ` 328 328 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''), 329 329 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''), 330 330 COALESCE(f.favicon_url, ''), 331 331 COUNT(DISTINCT l.id) AS like_count, 332 332 COUNT(DISTINCT a.id) AS annotation_count, 333 333 COALESCE(MAX(CASE WHEN ul.id IS NOT NULL THEN 1 ELSE 0 END), 0) 334 - FROM likes l 335 - JOIN articles ar ON ar.url = l.article_url AND ar.feed_url = l.feed_url 336 - LEFT JOIN feeds f ON f.feed_url = l.feed_url 337 - LEFT JOIN annotations a ON a.feed_url = l.feed_url AND a.article_url = l.article_url AND a.created_at >= ? 338 - LEFT JOIN likes ul ON ul.feed_url = l.feed_url AND ul.article_url = l.article_url AND ul.author_did = ? 334 + FROM articles.likes l 335 + JOIN articles.articles ar ON ar.url = l.article_url AND ar.feed_url = l.feed_url 336 + LEFT JOIN articles.feeds f ON f.feed_url = l.feed_url 337 + LEFT JOIN articles.annotations a ON a.feed_url = l.feed_url AND a.article_url = l.article_url AND a.created_at >= ? 338 + LEFT JOIN articles.likes ul ON ul.feed_url = l.feed_url AND ul.article_url = l.article_url AND ul.author_did = ? 339 339 WHERE l.created_at >= ? 340 340 GROUP BY ar.id 341 341 -- Future-published articles (e.g., scheduled) sort last ··· 360 360 return results, rows.Err() 361 361 } 362 362 363 - func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) { 364 - rows, err := db.QueryContext(ctx, ` 363 + func (s *ArticleStore) ListLikedArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) { 364 + rows, err := s.db.QueryContext(ctx, ` 365 365 SELECT DISTINCT a.id, a.feed_url, a.guid, a.title, a.url, a.author, a.summary, a.content, 366 366 a.published, a.updated, a.fetched_at, 367 367 COALESCE(f.title, ''), 368 368 COALESCE(r.is_read, 0), 369 369 COALESCE(lc.cnt, 0), 370 370 1 371 - FROM likes l 372 - JOIN articles a ON a.url = l.article_url AND a.feed_url = l.feed_url 373 - LEFT JOIN feeds f ON f.feed_url = a.feed_url 371 + FROM articles.likes l 372 + JOIN articles.articles a ON a.url = l.article_url AND a.feed_url = l.feed_url 373 + LEFT JOIN articles.feeds f ON f.feed_url = a.feed_url 374 374 LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id 375 - LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM likes GROUP BY feed_url, article_url) lc 375 + LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc 376 376 ON lc.feed_url = a.feed_url AND lc.article_url = a.url 377 377 WHERE l.author_did = ? 378 378 ORDER BY l.created_at DESC
+8 -8
internal/db/store.go
··· 8 8 ) 9 9 10 10 type FeedStoreAdapter struct { 11 - db *DB 11 + store *ArticleStore 12 12 } 13 13 14 - func NewFeedStoreAdapter(db *DB) *FeedStoreAdapter { 15 - return &FeedStoreAdapter{db: db} 14 + func NewFeedStoreAdapter(store *ArticleStore) *FeedStoreAdapter { 15 + return &FeedStoreAdapter{store: store} 16 16 } 17 17 18 18 func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*feed.Feed, error) { 19 - dbFeeds, err := a.db.GetFeedsToFetch(ctx, olderThan, limit) 19 + dbFeeds, err := a.store.GetFeedsToFetch(ctx, olderThan, limit) 20 20 if err != nil { 21 21 return nil, err 22 22 } ··· 37 37 } 38 38 39 39 func (a *FeedStoreAdapter) RecordFetchError(ctx context.Context, feedURL, lastError string) error { 40 - return a.db.MarkFeedFetchError(ctx, feedURL, lastError) 40 + return a.store.MarkFeedFetchError(ctx, feedURL, lastError) 41 41 } 42 42 43 43 func (a *FeedStoreAdapter) StoreFetchResult(ctx context.Context, feedURL, etag, lastModified string, articles []feed.Article, faviconURL string) error { 44 - if err := a.db.MarkFeedFetched(ctx, feedURL, etag, lastModified); err != nil { 44 + if err := a.store.MarkFeedFetched(ctx, feedURL, etag, lastModified); err != nil { 45 45 return err 46 46 } 47 47 if len(articles) > 0 { 48 - if err := a.db.UpsertArticlesBatch(ctx, articles); err != nil { 48 + if err := a.store.UpsertArticlesBatch(ctx, articles); err != nil { 49 49 return err 50 50 } 51 51 } 52 52 if faviconURL != "" { 53 - if err := a.db.UpdateFeedFavicon(ctx, feedURL, faviconURL); err != nil { 53 + if err := a.store.UpdateFeedFavicon(ctx, feedURL, faviconURL); err != nil { 54 54 return err 55 55 } 56 56 }
+23 -15
internal/db/user.go
··· 21 21 AvatarURL string 22 22 } 23 23 24 - func (db *DB) BatchCreateUsers(ctx context.Context, users []UserData) error { 24 + type UserStore struct { 25 + db *DB 26 + } 27 + 28 + func NewUserStore(db *DB) *UserStore { 29 + return &UserStore{db: db} 30 + } 31 + 32 + func (s *UserStore) BatchCreateUsers(ctx context.Context, users []UserData) error { 25 33 if len(users) == 0 { 26 34 return nil 27 35 } 28 - tx, err := db.BeginTx(ctx, nil) 36 + tx, err := s.db.BeginTx(ctx, nil) 29 37 if err != nil { 30 38 return err 31 39 } ··· 53 61 return tx.Commit() 54 62 } 55 63 56 - func (db *DB) CreateUser(ctx context.Context, did, handle, displayName, avatarURL string) (*User, error) { 57 - err := db.BatchCreateUsers(ctx, []UserData{{DID: did, Handle: handle, DisplayName: displayName, AvatarURL: avatarURL}}) 64 + func (s *UserStore) CreateUser(ctx context.Context, did, handle, displayName, avatarURL string) (*User, error) { 65 + err := s.BatchCreateUsers(ctx, []UserData{{DID: did, Handle: handle, DisplayName: displayName, AvatarURL: avatarURL}}) 58 66 if err != nil { 59 67 return nil, err 60 68 } 61 - return db.GetUser(ctx, did) 69 + return s.GetUser(ctx, did) 62 70 } 63 71 64 - func (db *DB) GetUser(ctx context.Context, did string) (*User, error) { 72 + func (s *UserStore) GetUser(ctx context.Context, did string) (*User, error) { 65 73 u := &User{} 66 - err := db.QueryRowContext(ctx, ` 74 + err := s.db.QueryRowContext(ctx, ` 67 75 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at 68 76 FROM users WHERE did = ? 69 77 `, did).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt) ··· 73 81 return u, nil 74 82 } 75 83 76 - func (db *DB) GetUserByHandle(ctx context.Context, handle string) (*User, error) { 84 + func (s *UserStore) GetUserByHandle(ctx context.Context, handle string) (*User, error) { 77 85 u := &User{} 78 - err := db.QueryRowContext(ctx, ` 86 + err := s.db.QueryRowContext(ctx, ` 79 87 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at 80 88 FROM users WHERE handle = ? 81 89 `, handle).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt) ··· 85 93 return u, nil 86 94 } 87 95 88 - func (db *DB) ListUserDIDs(ctx context.Context) (map[string]bool, error) { 89 - rows, err := db.QueryContext(ctx, `SELECT did FROM users`) 96 + func (s *UserStore) ListUserDIDs(ctx context.Context) (map[string]bool, error) { 97 + rows, err := s.db.QueryContext(ctx, `SELECT did FROM users`) 90 98 if err != nil { 91 99 return nil, err 92 100 } ··· 103 111 return dids, rows.Err() 104 112 } 105 113 106 - func (db *DB) UpdateUserProfile(ctx context.Context, did, displayName, avatarURL string) error { 107 - _, err := db.ExecContext(ctx, ` 114 + func (s *UserStore) UpdateUserProfile(ctx context.Context, did, displayName, avatarURL string) error { 115 + _, err := s.db.ExecContext(ctx, ` 108 116 UPDATE users SET 109 117 display_name = COALESCE(NULLIF(?, ''), display_name), 110 118 avatar_url = COALESCE(NULLIF(?, ''), avatar_url), ··· 114 122 return err 115 123 } 116 124 117 - func (db *DB) ListUsers(ctx context.Context) ([]*User, error) { 118 - rows, err := db.QueryContext(ctx, ` 125 + func (s *UserStore) ListUsers(ctx context.Context) ([]*User, error) { 126 + rows, err := s.db.QueryContext(ctx, ` 119 127 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at 120 128 FROM users ORDER BY updated_at DESC 121 129 `)
+8 -8
internal/db/user_test.go
··· 9 9 10 10 func TestUpdateUserProfile_SetsFields(t *testing.T) { 11 11 ctx := context.Background() 12 - db := setupTestDB(t) 12 + dbs := setupTestDB(t) 13 13 14 - _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:profile", "tester") 14 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle) VALUES (?, ?)`, "did:test:profile", "tester") 15 15 assert.NilError(t, err) 16 16 17 - err = db.UpdateUserProfile(ctx, "did:test:profile", "Display Name", "https://cdn.bsky.app/img/avatar.png") 17 + err = dbs.Users.UpdateUserProfile(ctx, "did:test:profile", "Display Name", "https://cdn.bsky.app/img/avatar.png") 18 18 assert.NilError(t, err) 19 19 20 - u, err := db.GetUser(ctx, "did:test:profile") 20 + u, err := dbs.Users.GetUser(ctx, "did:test:profile") 21 21 assert.NilError(t, err) 22 22 assert.Equal(t, u.DisplayName.String, "Display Name") 23 23 assert.Equal(t, u.AvatarURL.String, "https://cdn.bsky.app/img/avatar.png") ··· 25 25 26 26 func TestUpdateUserProfile_DoesNotOverwriteWithEmpty(t *testing.T) { 27 27 ctx := context.Background() 28 - db := setupTestDB(t) 28 + dbs := setupTestDB(t) 29 29 30 - _, err := db.ExecContext(ctx, `INSERT INTO users (did, handle, display_name, avatar_url) VALUES (?, ?, ?, ?)`, 30 + _, err := dbs.DB().ExecContext(ctx, `INSERT INTO users (did, handle, display_name, avatar_url) VALUES (?, ?, ?, ?)`, 31 31 "did:test:profile2", "tester2", "Existing Name", "https://old.avatar/url") 32 32 assert.NilError(t, err) 33 33 34 - err = db.UpdateUserProfile(ctx, "did:test:profile2", "", "") 34 + err = dbs.Users.UpdateUserProfile(ctx, "did:test:profile2", "", "") 35 35 assert.NilError(t, err) 36 36 37 - u, err := db.GetUser(ctx, "did:test:profile2") 37 + u, err := dbs.Users.GetUser(ctx, "did:test:profile2") 38 38 assert.NilError(t, err) 39 39 assert.Equal(t, u.DisplayName.String, "Existing Name") 40 40 assert.Equal(t, u.AvatarURL.String, "https://old.avatar/url")
+2 -2
internal/server/server.go
··· 73 73 } 74 74 75 75 func New(dbs *db.Databases, clientID, callbackURL, addr string, scheduler *feed.Scheduler, engine *cluster.Engine, logger *slog.Logger) *Server { 76 - oauthStore := db.NewOAuthStore(dbs.Users) 76 + oauthStore := db.NewOAuthStore(dbs) 77 77 78 78 var config oauth.ClientConfig 79 79 if clientID == "" { ··· 202 202 s.router.Post("/auth/logout", s.handleAuthLogout) 203 203 s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata) 204 204 205 - xrpc := atproto.NewXRPCHandler(s.dbs.Articles.DB, s.engine) 205 + xrpc := atproto.NewXRPCHandler(s.dbs.DB(), s.engine) 206 206 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions) 207 207 s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations) 208 208 s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)
+1 -1
main.go
··· 47 47 storeAdapter := db.NewFeedStoreAdapter(dbs.Articles) 48 48 scheduler := feed.NewScheduler(storeAdapter, logger, *fetchInterval, 30*time.Minute) 49 49 50 - engine := cluster.NewEngine(dbs.Users.DB, logger) 50 + engine := cluster.NewEngine(dbs.DB(), logger) 51 51 52 52 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, engine, logger) 53 53