nandi/gleanpublic Fork 0
d81cbd6
Commits
Clone
git clone https://git.rickub.com/nandi/glean.git
git clone ssh://git@rickub.com/nandi/glean.git

Host key fingerprint (ed25519): SHA256:iycHnxEyq0Q7uyVpB7JlznP0G7JrTPXLYRcAU5CSLhc — verify it before your first connect.

Refactor database accessUnverified

Julien Robert committed 2026-04-23T15:38:33+02:00 Browse files
d81cbd6 parent: cd41abf
modified .env.example +2 -2
@@ -2,8 +2,8 @@ GLEAN_ADDR=:8080
22 GLEAN_DB=glean.db
33 GLEAN_JETSTREAM=wss://jetstream.glean.at
44 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
77 GLEAN_FETCH_INTERVAL=5m
88 GLEAN_COLLECTION_DIR_URL=https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription
99 # Leave empty for localhost OAuth (development)
@@ -2,8 +2,8 @@ GLEAN_ADDR=:8080
2 GLEAN_DB=glean.db2 GLEAN_DB=glean.db
3 GLEAN_JETSTREAM=wss://jetstream.glean.at3 GLEAN_JETSTREAM=wss://jetstream.glean.at
4 GLEAN_PLC_URL=https://didplc.glean.at4 GLEAN_PLC_URL=https://didplc.glean.at
5-GLEAN_SYNC_INTERVAL=30m5+GLEAN_SYNC_INTERVAL=10m
6-GLEAN_CLUSTER_INTERVAL=1h6+GLEAN_CLUSTER_INTERVAL=15m
7 GLEAN_FETCH_INTERVAL=5m7 GLEAN_FETCH_INTERVAL=5m
8 GLEAN_COLLECTION_DIR_URL=https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription8 GLEAN_COLLECTION_DIR_URL=https://lightrail.microcosm.blue/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription
9 # Leave empty for localhost OAuth (development)9 # Leave empty for localhost OAuth (development)
modified internal/atproto/stream_handler.go +3 -3
@@ -23,12 +23,12 @@ func isSentinel(err error) bool {
2323 }
2424
2525 type StreamDBHandler struct {
26- articles *db.DB
27- users *db.DB
26+ articles *db.ArticleStore
27+ users *db.UserStore
2828 logger *slog.Logger
2929 }
3030
31-func NewStreamDBHandler(articles, users *db.DB, logger *slog.Logger) *StreamDBHandler {
31+func NewStreamDBHandler(articles *db.ArticleStore, users *db.UserStore, logger *slog.Logger) *StreamDBHandler {
3232 return &StreamDBHandler{articles: articles, users: users, logger: logger}
3333 }
3434
@@ -23,12 +23,12 @@ func isSentinel(err error) bool {
23 }23 }
24 24
25 type StreamDBHandler struct {25 type StreamDBHandler struct {
26- articles *db.DB26+ articles *db.ArticleStore
27- users *db.DB27+ users *db.UserStore
28 logger *slog.Logger28 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 return &StreamDBHandler{articles: articles, users: users, logger: logger}32 return &StreamDBHandler{articles: articles, users: users, logger: logger}
33 }33 }
34 34
modified internal/atproto/sync.go +3 -3
@@ -20,13 +20,13 @@ import (
2020 )
2121
2222 type Sync struct {
23- articles *db.DB
24- users *db.DB
23+ articles *db.ArticleStore
24+ users *db.UserStore
2525 client *Client
2626 logger *slog.Logger
2727 }
2828
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 {
3030 return &Sync{articles: articles, users: users, client: client, logger: logger}
3131 }
3232
@@ -20,13 +20,13 @@ import (
20 )20 )
21 21
22 type Sync struct {22 type Sync struct {
23- articles *db.DB23+ articles *db.ArticleStore
24- users *db.DB24+ users *db.UserStore
25 client *Client25 client *Client
26 logger *slog.Logger26 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 return &Sync{articles: articles, users: users, client: client, logger: logger}30 return &Sync{articles: articles, users: users, client: client, logger: logger}
31 }31 }
32 32
modified internal/cluster/jaccard.go +2 -0
@@ -24,6 +24,8 @@ func DefaultConfig() Config {
2424 }
2525 }
2626
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.
2729 type Engine struct {
2830 db *sql.DB
2931 logger *slog.Logger
@@ -24,6 +24,8 @@ func DefaultConfig() Config {
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 type Engine struct {29 type Engine struct {
28 db *sql.DB30 db *sql.DB
29 logger *slog.Logger31 logger *slog.Logger
modified internal/cluster/jaccard_test.go +32 -32
@@ -46,7 +46,7 @@ func seedClusterData(t *testing.T, ctx context.Context, dbs *db.Databases) {
4646 {"did:test:carol", "carol"},
4747 }
4848 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)
5050 assert.NilError(t, err)
5151 }
5252
@@ -58,7 +58,7 @@ func seedClusterData(t *testing.T, ctx context.Context, dbs *db.Databases) {
5858 {"https://e.com/feed", "Feed E"},
5959 }
6060 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)
6262 assert.NilError(t, err)
6363 }
6464
@@ -74,7 +74,7 @@ func seedClusterData(t *testing.T, ctx context.Context, dbs *db.Databases) {
7474 {"did:test:carol", "https://c.com/feed"},
7575 }
7676 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)
7878 assert.NilError(t, err)
7979 }
8080 }
@@ -86,13 +86,13 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Databases) {
8686 {"did:test:bob", "did:test:carol"},
8787 }
8888 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)
9090 assert.NilError(t, err)
9191 }
9292 }
9393
9494 func newTestEngine(dbs *db.Databases) *Engine {
95- return NewEngine(dbs.Users.DB, slog.Default())
95+ return NewEngine(dbs.DB(), slog.Default())
9696 }
9797
9898 func TestComputeFeedSimilarity(t *testing.T) {
@@ -105,7 +105,7 @@ func TestComputeFeedSimilarity(t *testing.T) {
105105 assert.NilError(t, err)
106106
107107 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)
109109 assert.NilError(t, err)
110110 assert.Assert(t, count > 0, "expected feed similarity pairs")
111111 }
@@ -120,7 +120,7 @@ func TestComputeUserSimilarity(t *testing.T) {
120120 assert.NilError(t, err)
121121
122122 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)
124124 assert.NilError(t, err)
125125 assert.Assert(t, count > 0, "expected user similarity pairs")
126126 }
@@ -222,14 +222,14 @@ func TestRecordImpressions(t *testing.T) {
222222 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))
223223
224224 var count int
225- assert.NilError(t, dbs.Users.QueryRowContext(ctx,
225+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
226226 `SELECT COUNT(*) FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count))
227227 assert.Equal(t, count, 2)
228228
229229 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))
230230
231231 var shownCount int
232- assert.NilError(t, dbs.Users.QueryRowContext(ctx,
232+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
233233 `SELECT shown_count FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&shownCount))
234234 assert.Equal(t, shownCount, 2, "shown_count should increment on repeated impression")
235235 }
@@ -247,7 +247,7 @@ func TestMarkImpressionActed(t *testing.T) {
247247 assert.NilError(t, engine.MarkImpressionActed(ctx, "did:test:alice", "feed", "https://a.com/feed"))
248248
249249 var acted bool
250- assert.NilError(t, dbs.Users.QueryRowContext(ctx,
250+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
251251 `SELECT acted FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&acted))
252252 assert.Assert(t, acted, "impression should be marked as acted")
253253 }
@@ -262,15 +262,15 @@ func TestComputeFollowDistances(t *testing.T) {
262262 assert.NilError(t, engine.ComputeFollowDistances(ctx))
263263
264264 var d1, d2 int
265- assert.NilError(t, dbs.Users.QueryRowContext(ctx,
265+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
266266 `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,
268268 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 2`).Scan(&d2))
269269 assert.Assert(t, d1 >= 2, "expected at least 2 direct follow distances")
270270 assert.Assert(t, d2 >= 1, "expected at least 1 two-hop distance (alice -> bob -> carol)")
271271
272272 var dist int
273- assert.NilError(t, dbs.Users.QueryRowContext(ctx,
273+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
274274 `SELECT distance FROM recs.follow_distances WHERE user_a = 'did:test:alice' AND user_b = 'did:test:carol'`).Scan(&dist))
275275 assert.Equal(t, dist, 2, "alice should be 2 hops from carol")
276276 }
@@ -282,7 +282,7 @@ func TestAutoDismissStale(t *testing.T) {
282282
283283 engine := newTestEngine(dbs)
284284
285- _, err := dbs.Users.ExecContext(ctx, `
285+ _, err := dbs.DB().ExecContext(ctx, `
286286 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
287287 VALUES ('did:test:alice', 'feed', 'https://stale.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 0)
288288 `)
@@ -302,7 +302,7 @@ func TestAutoDismissStale_DoesNotDismissRecent(t *testing.T) {
302302
303303 engine := newTestEngine(dbs)
304304
305- _, err := dbs.Users.ExecContext(ctx, `
305+ _, err := dbs.DB().ExecContext(ctx, `
306306 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
307307 VALUES ('did:test:alice', 'feed', 'https://recent.com/feed', datetime('now'), datetime('now'), 5, 0)
308308 `)
@@ -322,7 +322,7 @@ func TestAutoDismissStale_DoesNotDismissActed(t *testing.T) {
322322
323323 engine := newTestEngine(dbs)
324324
325- _, err := dbs.Users.ExecContext(ctx, `
325+ _, err := dbs.DB().ExecContext(ctx, `
326326 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
327327 VALUES ('did:test:alice', 'feed', 'https://acted.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 1)
328328 `)
@@ -397,13 +397,13 @@ func TestSignalWeights_RewardPenalize(t *testing.T) {
397397
398398 engine := newTestEngine(dbs)
399399
400- _, err := dbs.Users.ExecContext(ctx, `
400+ _, err := dbs.DB().ExecContext(ctx, `
401401 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
402402 VALUES ('did:test:alice', 'feed', 'https://a.com/feed', datetime('now'), datetime('now'), 1, 1)
403403 `)
404404 assert.NilError(t, err)
405405 for i := range minActionsTune {
406- _, err = dbs.Users.ExecContext(ctx, `
406+ _, err = dbs.DB().ExecContext(ctx, `
407407 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
408408 VALUES ('did:test:alice', 'feed', ?, datetime('now'), datetime('now'), 1, 1)
409409 `, fmt.Sprintf("https://%d.com/feed", i))
@@ -425,12 +425,12 @@ func TestColdStartRecommendations(t *testing.T) {
425425 engine := newTestEngine(dbs)
426426 assert.NilError(t, engine.ComputeFollowDistances(ctx))
427427
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'`)
429429 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'`)
431431 assert.NilError(t, err)
432432
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")
434434 assert.NilError(t, err)
435435
436436 recs, err := engine.ColdStartRecommendations(ctx, "did:test:newuser", 10)
@@ -475,7 +475,7 @@ func TestDismissArticle(t *testing.T) {
475475 assert.NilError(t, engine.DismissArticle(ctx, "did:test:alice", "https://a.com/article1", "not_interested"))
476476
477477 var count int
478- assert.NilError(t, dbs.Users.QueryRowContext(ctx,
478+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
479479 `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'article'`).Scan(&count))
480480 assert.Equal(t, count, 1)
481481 }
@@ -489,7 +489,7 @@ func TestComputeSignalProfiles(t *testing.T) {
489489 assert.NilError(t, engine.ComputeSignalProfiles(ctx))
490490
491491 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))
493493 assert.Assert(t, count >= 3, "expected signal profiles for all users")
494494 }
495495
@@ -504,7 +504,7 @@ func TestDismissFeed_Idempotent(t *testing.T) {
504504 assert.NilError(t, engine.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason2"))
505505
506506 var count int
507- assert.NilError(t, dbs.Users.QueryRowContext(ctx,
507+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
508508 `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'feed'`).Scan(&count))
509509 assert.Equal(t, count, 1, "duplicate dismiss should not create extra rows")
510510 }
@@ -513,33 +513,33 @@ func TestDescriptionBasedFeedSimilarity(t *testing.T) {
513513 ctx := context.Background()
514514 dbs := setupClusterTestDB(t)
515515
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")
517517 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")
519519 assert.NilError(t, err)
520520
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')`,
522522 "https://go.com/feed", "Go Blog", "https://go.com", "programming language golang software development")
523523 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')`,
525525 "https://rust.com/feed", "Rust Blog", "https://rust.com", "programming language rust software development")
526526 assert.NilError(t, err)
527527
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")
529529 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")
531531 assert.NilError(t, err)
532532
533533 engine := newTestEngine(dbs)
534534 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
535535
536536 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))
538538 assert.Assert(t, count >= 0, "description-based similarity should produce pairs")
539539
540540 if count > 0 {
541541 var jaccard float64
542- assert.NilError(t, dbs.Users.QueryRowContext(ctx,
542+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
543543 `SELECT jaccard FROM recs.feed_similarity WHERE feed_a = ? AND feed_b = ?`,
544544 "https://go.com/feed", "https://rust.com/feed").Scan(&jaccard))
545545 assert.Assert(t, jaccard > 0, "description word overlap should boost similarity")
@@ -46,7 +46,7 @@ func seedClusterData(t *testing.T, ctx context.Context, dbs *db.Databases) {
46 {"did:test:carol", "carol"},46 {"did:test:carol", "carol"},
47 }47 }
48 for _, u := range users {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 assert.NilError(t, err)50 assert.NilError(t, err)
51 }51 }
52 52
@@ -58,7 +58,7 @@ func seedClusterData(t *testing.T, ctx context.Context, dbs *db.Databases) {
58 {"https://e.com/feed", "Feed E"},58 {"https://e.com/feed", "Feed E"},
59 }59 }
60 for _, f := range feeds {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 assert.NilError(t, err)62 assert.NilError(t, err)
63 }63 }
64 64
@@ -74,7 +74,7 @@ func seedClusterData(t *testing.T, ctx context.Context, dbs *db.Databases) {
74 {"did:test:carol", "https://c.com/feed"},74 {"did:test:carol", "https://c.com/feed"},
75 }75 }
76 for _, s := range subs {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 assert.NilError(t, err)78 assert.NilError(t, err)
79 }79 }
80 }80 }
@@ -86,13 +86,13 @@ func seedFollowData(t *testing.T, ctx context.Context, dbs *db.Databases) {
86 {"did:test:bob", "did:test:carol"},86 {"did:test:bob", "did:test:carol"},
87 }87 }
88 for _, f := range follows {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 assert.NilError(t, err)90 assert.NilError(t, err)
91 }91 }
92 }92 }
93 93
94 func newTestEngine(dbs *db.Databases) *Engine {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 func TestComputeFeedSimilarity(t *testing.T) {98 func TestComputeFeedSimilarity(t *testing.T) {
@@ -105,7 +105,7 @@ func TestComputeFeedSimilarity(t *testing.T) {
105 assert.NilError(t, err)105 assert.NilError(t, err)
106 106
107 var count int107 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 assert.NilError(t, err)109 assert.NilError(t, err)
110 assert.Assert(t, count > 0, "expected feed similarity pairs")110 assert.Assert(t, count > 0, "expected feed similarity pairs")
111 }111 }
@@ -120,7 +120,7 @@ func TestComputeUserSimilarity(t *testing.T) {
120 assert.NilError(t, err)120 assert.NilError(t, err)
121 121
122 var count int122 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 assert.NilError(t, err)124 assert.NilError(t, err)
125 assert.Assert(t, count > 0, "expected user similarity pairs")125 assert.Assert(t, count > 0, "expected user similarity pairs")
126 }126 }
@@ -222,14 +222,14 @@ func TestRecordImpressions(t *testing.T) {
222 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))222 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))
223 223
224 var count int224 var count int
225- assert.NilError(t, dbs.Users.QueryRowContext(ctx,225+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
226 `SELECT COUNT(*) FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count))226 `SELECT COUNT(*) FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice'`).Scan(&count))
227 assert.Equal(t, count, 2)227 assert.Equal(t, count, 2)
228 228
229 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))229 assert.NilError(t, engine.RecordImpressions(ctx, "did:test:alice", impressions))
230 230
231 var shownCount int231 var shownCount int
232- assert.NilError(t, dbs.Users.QueryRowContext(ctx,232+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
233 `SELECT shown_count FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&shownCount))233 `SELECT shown_count FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&shownCount))
234 assert.Equal(t, shownCount, 2, "shown_count should increment on repeated impression")234 assert.Equal(t, shownCount, 2, "shown_count should increment on repeated impression")
235 }235 }
@@ -247,7 +247,7 @@ func TestMarkImpressionActed(t *testing.T) {
247 assert.NilError(t, engine.MarkImpressionActed(ctx, "did:test:alice", "feed", "https://a.com/feed"))247 assert.NilError(t, engine.MarkImpressionActed(ctx, "did:test:alice", "feed", "https://a.com/feed"))
248 248
249 var acted bool249 var acted bool
250- assert.NilError(t, dbs.Users.QueryRowContext(ctx,250+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
251 `SELECT acted FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&acted))251 `SELECT acted FROM recs.recommendation_impressions WHERE user_did = 'did:test:alice' AND target_id = 'https://a.com/feed'`).Scan(&acted))
252 assert.Assert(t, acted, "impression should be marked as acted")252 assert.Assert(t, acted, "impression should be marked as acted")
253 }253 }
@@ -262,15 +262,15 @@ func TestComputeFollowDistances(t *testing.T) {
262 assert.NilError(t, engine.ComputeFollowDistances(ctx))262 assert.NilError(t, engine.ComputeFollowDistances(ctx))
263 263
264 var d1, d2 int264 var d1, d2 int
265- assert.NilError(t, dbs.Users.QueryRowContext(ctx,265+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
266 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 1`).Scan(&d1))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 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 2`).Scan(&d2))268 `SELECT COUNT(*) FROM recs.follow_distances WHERE distance = 2`).Scan(&d2))
269 assert.Assert(t, d1 >= 2, "expected at least 2 direct follow distances")269 assert.Assert(t, d1 >= 2, "expected at least 2 direct follow distances")
270 assert.Assert(t, d2 >= 1, "expected at least 1 two-hop distance (alice -> bob -> carol)")270 assert.Assert(t, d2 >= 1, "expected at least 1 two-hop distance (alice -> bob -> carol)")
271 271
272 var dist int272 var dist int
273- assert.NilError(t, dbs.Users.QueryRowContext(ctx,273+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
274 `SELECT distance FROM recs.follow_distances WHERE user_a = 'did:test:alice' AND user_b = 'did:test:carol'`).Scan(&dist))274 `SELECT distance FROM recs.follow_distances WHERE user_a = 'did:test:alice' AND user_b = 'did:test:carol'`).Scan(&dist))
275 assert.Equal(t, dist, 2, "alice should be 2 hops from carol")275 assert.Equal(t, dist, 2, "alice should be 2 hops from carol")
276 }276 }
@@ -282,7 +282,7 @@ func TestAutoDismissStale(t *testing.T) {
282 282
283 engine := newTestEngine(dbs)283 engine := newTestEngine(dbs)
284 284
285- _, err := dbs.Users.ExecContext(ctx, `285+ _, err := dbs.DB().ExecContext(ctx, `
286 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)286 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
287 VALUES ('did:test:alice', 'feed', 'https://stale.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 0)287 VALUES ('did:test:alice', 'feed', 'https://stale.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 0)
288 `)288 `)
@@ -302,7 +302,7 @@ func TestAutoDismissStale_DoesNotDismissRecent(t *testing.T) {
302 302
303 engine := newTestEngine(dbs)303 engine := newTestEngine(dbs)
304 304
305- _, err := dbs.Users.ExecContext(ctx, `305+ _, err := dbs.DB().ExecContext(ctx, `
306 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)306 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
307 VALUES ('did:test:alice', 'feed', 'https://recent.com/feed', datetime('now'), datetime('now'), 5, 0)307 VALUES ('did:test:alice', 'feed', 'https://recent.com/feed', datetime('now'), datetime('now'), 5, 0)
308 `)308 `)
@@ -322,7 +322,7 @@ func TestAutoDismissStale_DoesNotDismissActed(t *testing.T) {
322 322
323 engine := newTestEngine(dbs)323 engine := newTestEngine(dbs)
324 324
325- _, err := dbs.Users.ExecContext(ctx, `325+ _, err := dbs.DB().ExecContext(ctx, `
326 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)326 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
327 VALUES ('did:test:alice', 'feed', 'https://acted.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 1)327 VALUES ('did:test:alice', 'feed', 'https://acted.com/feed', datetime('now', '-31 days'), datetime('now'), 20, 1)
328 `)328 `)
@@ -397,13 +397,13 @@ func TestSignalWeights_RewardPenalize(t *testing.T) {
397 397
398 engine := newTestEngine(dbs)398 engine := newTestEngine(dbs)
399 399
400- _, err := dbs.Users.ExecContext(ctx, `400+ _, err := dbs.DB().ExecContext(ctx, `
401 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)401 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
402 VALUES ('did:test:alice', 'feed', 'https://a.com/feed', datetime('now'), datetime('now'), 1, 1)402 VALUES ('did:test:alice', 'feed', 'https://a.com/feed', datetime('now'), datetime('now'), 1, 1)
403 `)403 `)
404 assert.NilError(t, err)404 assert.NilError(t, err)
405 for i := range minActionsTune {405 for i := range minActionsTune {
406- _, err = dbs.Users.ExecContext(ctx, `406+ _, err = dbs.DB().ExecContext(ctx, `
407 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)407 INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count, acted)
408 VALUES ('did:test:alice', 'feed', ?, datetime('now'), datetime('now'), 1, 1)408 VALUES ('did:test:alice', 'feed', ?, datetime('now'), datetime('now'), 1, 1)
409 `, fmt.Sprintf("https://%d.com/feed", i))409 `, fmt.Sprintf("https://%d.com/feed", i))
@@ -425,12 +425,12 @@ func TestColdStartRecommendations(t *testing.T) {
425 engine := newTestEngine(dbs)425 engine := newTestEngine(dbs)
426 assert.NilError(t, engine.ComputeFollowDistances(ctx))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 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)434 assert.NilError(t, err)
435 435
436 recs, err := engine.ColdStartRecommendations(ctx, "did:test:newuser", 10)436 recs, err := engine.ColdStartRecommendations(ctx, "did:test:newuser", 10)
@@ -475,7 +475,7 @@ func TestDismissArticle(t *testing.T) {
475 assert.NilError(t, engine.DismissArticle(ctx, "did:test:alice", "https://a.com/article1", "not_interested"))475 assert.NilError(t, engine.DismissArticle(ctx, "did:test:alice", "https://a.com/article1", "not_interested"))
476 476
477 var count int477 var count int
478- assert.NilError(t, dbs.Users.QueryRowContext(ctx,478+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
479 `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'article'`).Scan(&count))479 `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'article'`).Scan(&count))
480 assert.Equal(t, count, 1)480 assert.Equal(t, count, 1)
481 }481 }
@@ -489,7 +489,7 @@ func TestComputeSignalProfiles(t *testing.T) {
489 assert.NilError(t, engine.ComputeSignalProfiles(ctx))489 assert.NilError(t, engine.ComputeSignalProfiles(ctx))
490 490
491 var count int491 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 assert.Assert(t, count >= 3, "expected signal profiles for all users")493 assert.Assert(t, count >= 3, "expected signal profiles for all users")
494 }494 }
495 495
@@ -504,7 +504,7 @@ func TestDismissFeed_Idempotent(t *testing.T) {
504 assert.NilError(t, engine.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason2"))504 assert.NilError(t, engine.DismissFeed(ctx, "did:test:alice", "https://a.com/feed", "reason2"))
505 505
506 var count int506 var count int
507- assert.NilError(t, dbs.Users.QueryRowContext(ctx,507+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
508 `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'feed'`).Scan(&count))508 `SELECT COUNT(*) FROM recs.dismissed_recommendations WHERE user_did = 'did:test:alice' AND target_type = 'feed'`).Scan(&count))
509 assert.Equal(t, count, 1, "duplicate dismiss should not create extra rows")509 assert.Equal(t, count, 1, "duplicate dismiss should not create extra rows")
510 }510 }
@@ -513,33 +513,33 @@ func TestDescriptionBasedFeedSimilarity(t *testing.T) {
513 ctx := context.Background()513 ctx := context.Background()
514 dbs := setupClusterTestDB(t)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 assert.NilError(t, err)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 assert.NilError(t, err)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 "https://go.com/feed", "Go Blog", "https://go.com", "programming language golang software development")522 "https://go.com/feed", "Go Blog", "https://go.com", "programming language golang software development")
523 assert.NilError(t, err)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 "https://rust.com/feed", "Rust Blog", "https://rust.com", "programming language rust software development")525 "https://rust.com/feed", "Rust Blog", "https://rust.com", "programming language rust software development")
526 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)531 assert.NilError(t, err)
532 532
533 engine := newTestEngine(dbs)533 engine := newTestEngine(dbs)
534 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))534 assert.NilError(t, engine.ComputeFeedSimilarity(ctx))
535 535
536 var count int536 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 assert.Assert(t, count >= 0, "description-based similarity should produce pairs")538 assert.Assert(t, count >= 0, "description-based similarity should produce pairs")
539 539
540 if count > 0 {540 if count > 0 {
541 var jaccard float64541 var jaccard float64
542- assert.NilError(t, dbs.Users.QueryRowContext(ctx,542+ assert.NilError(t, dbs.DB().QueryRowContext(ctx,
543 `SELECT jaccard FROM recs.feed_similarity WHERE feed_a = ? AND feed_b = ?`,543 `SELECT jaccard FROM recs.feed_similarity WHERE feed_a = ? AND feed_b = ?`,
544 "https://go.com/feed", "https://rust.com/feed").Scan(&jaccard))544 "https://go.com/feed", "https://rust.com/feed").Scan(&jaccard))
545 assert.Assert(t, jaccard > 0, "description word overlap should boost similarity")545 assert.Assert(t, jaccard > 0, "description word overlap should boost similarity")
modified internal/db/article.go +101 -92
@@ -10,6 +10,14 @@ import (
1010 "pkg.rbrt.fr/glean/internal/feed"
1111 )
1212
13+type ArticleStore struct {
14+ db *DB
15+}
16+
17+func NewArticleStore(db *DB) *ArticleStore {
18+ return &ArticleStore{db: db}
19+}
20+
1321 type Article struct {
1422 ID int64
1523 FeedURL string
@@ -37,19 +45,19 @@ type ReadState struct {
3745 ReadAt sql.NullTime
3846 }
3947
40-func (db *DB) UpsertArticlesBatch(ctx context.Context, articles []feed.Article) error {
48+func (s *ArticleStore) UpsertArticlesBatch(ctx context.Context, articles []feed.Article) error {
4149 if len(articles) == 0 {
4250 return nil
4351 }
4452
45- tx, err := db.BeginTx(ctx, nil)
53+ tx, err := s.db.BeginTx(ctx, nil)
4654 if err != nil {
4755 return err
4856 }
4957 defer tx.Rollback()
5058
5159 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)
5361 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
5462 ON CONFLICT(feed_url, guid) DO NOTHING
5563 `)
@@ -79,11 +87,11 @@ func (db *DB) UpsertArticlesBatch(ctx context.Context, articles []feed.Article)
7987 return tx.Commit()
8088 }
8189
82-func (db *DB) GetArticle(ctx context.Context, id int64) (*Article, error) {
90+func (s *ArticleStore) GetArticle(ctx context.Context, id int64) (*Article, error) {
8391 a := &Article{}
84- err := db.QueryRowContext(ctx, `
92+ err := s.db.QueryRowContext(ctx, `
8593 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 = ?
8795 `, id).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author,
8896 &a.Summary, &a.Content, &a.FullContent, &a.Published, &a.Updated, &a.FetchedAt)
8997 if err != nil {
@@ -92,7 +100,7 @@ func (db *DB) GetArticle(ctx context.Context, id int64) (*Article, error) {
92100 return a, nil
93101 }
94102
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) {
96104 var query string
97105 var args []any
98106
@@ -103,12 +111,12 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
103111 COALESCE(r.is_read, 0),
104112 COALESCE(lc.cnt, 0),
105113 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
110118 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
112120 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
113121 WHERE a.feed_url = ?
114122 `
@@ -120,13 +128,13 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
120128 COALESCE(r.is_read, 0),
121129 COALESCE(lc.cnt, 0),
122130 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
128136 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
130138 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
131139 WHERE 1=1
132140 `
@@ -137,7 +145,7 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
137145 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`
138146 args = append(args, limit, offset)
139147
140- rows, err := db.QueryContext(ctx, query, args...)
148+ rows, err := s.db.QueryContext(ctx, query, args...)
141149 if err != nil {
142150 return nil, err
143151 }
@@ -156,7 +164,7 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
156164 return articles, rows.Err()
157165 }
158166
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) {
160168 var query string
161169 var args []any
162170
@@ -167,12 +175,12 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
167175 COALESCE(r.is_read, 0),
168176 COALESCE(lc.cnt, 0),
169177 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
174182 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
176184 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
177185 WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL)
178186 `
@@ -184,13 +192,13 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
184192 COALESCE(r.is_read, 0),
185193 COALESCE(lc.cnt, 0),
186194 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
192200 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
194202 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
195203 WHERE (r.is_read = 0 OR r.is_read IS NULL)
196204 `
@@ -201,7 +209,7 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
201209 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`
202210 args = append(args, limit, offset)
203211
204- rows, err := db.QueryContext(ctx, query, args...)
212+ rows, err := s.db.QueryContext(ctx, query, args...)
205213 if err != nil {
206214 return nil, err
207215 }
@@ -220,7 +228,7 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
220228 return articles, rows.Err()
221229 }
222230
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) {
224232 var query string
225233 var args []any
226234
@@ -231,12 +239,12 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim
231239 COALESCE(r.is_read, 0),
232240 COALESCE(lc.cnt, 0),
233241 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
238246 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
240248 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
241249 WHERE r.is_read = 1 AND a.feed_url = ?
242250 `
@@ -248,13 +256,13 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim
248256 COALESCE(r.is_read, 0),
249257 COALESCE(lc.cnt, 0),
250258 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
256264 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
258266 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
259267 WHERE r.is_read = 1
260268 `
@@ -265,7 +273,7 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim
265273 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`
266274 args = append(args, limit, offset)
267275
268- rows, err := db.QueryContext(ctx, query, args...)
276+ rows, err := s.db.QueryContext(ctx, query, args...)
269277 if err != nil {
270278 return nil, err
271279 }
@@ -284,9 +292,9 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim
284292 return articles, rows.Err()
285293 }
286294
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)
290298 VALUES (?, ?, 1, CURRENT_TIMESTAMP)
291299 ON CONFLICT(user_did, article_id) DO UPDATE SET
292300 is_read = 1, read_at = CURRENT_TIMESTAMP
@@ -294,9 +302,9 @@ func (db *DB) MarkArticleRead(ctx context.Context, userDID string, articleID int
294302 return err
295303 }
296304
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)
300308 VALUES (?, ?, 0)
301309 ON CONFLICT(user_did, article_id) DO UPDATE SET
302310 is_read = 0, read_at = NULL
@@ -304,11 +312,11 @@ func (db *DB) MarkArticleUnread(ctx context.Context, userDID string, articleID i
304312 return err
305313 }
306314
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)
310318 SELECT ?, a.id, 1, CURRENT_TIMESTAMP
311- FROM articles a
319+ FROM articles.articles a
312320 WHERE a.feed_url = ?
313321 ON CONFLICT(user_did, article_id) DO UPDATE SET
314322 is_read = 1, read_at = CURRENT_TIMESTAMP
@@ -316,23 +324,23 @@ func (db *DB) MarkAllRead(ctx context.Context, userDID, feedURL string) error {
316324 return err
317325 }
318326
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)
322330 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 = ?
325333 ON CONFLICT(user_did, article_id) DO UPDATE SET
326334 is_read = 1, read_at = CURRENT_TIMESTAMP
327335 `, userDID, userDID)
328336 return err
329337 }
330338
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) {
332340 rs := &ReadState{}
333- err := db.QueryRowContext(ctx, `
341+ err := s.db.QueryRowContext(ctx, `
334342 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 = ?
336344 `, userDID, articleID).Scan(&rs.UserDID, &rs.ArticleID, &rs.IsRead, &rs.ReadAt)
337345 if err == sql.ErrNoRows {
338346 return &ReadState{UserDID: userDID, ArticleID: articleID}, nil
@@ -343,39 +351,39 @@ func (db *DB) GetReadState(ctx context.Context, userDID string, articleID int64)
343351 return rs, nil
344352 }
345353
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) {
347355 var count int
348356 if feedURL != "" {
349- err := db.QueryRowContext(ctx, `
357+ err := s.db.QueryRowContext(ctx, `
350358 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
353361 WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL)
354362 `, userDID, feedURL).Scan(&count)
355363 return count, err
356364 }
357- err := db.QueryRowContext(ctx, `
365+ err := s.db.QueryRowContext(ctx, `
358366 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
362370 WHERE r.is_read = 0 OR r.is_read IS NULL
363371 `, userDID, userDID).Scan(&count)
364372 return count, err
365373 }
366374
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 = ?
370378 `, fullContent, id)
371379 return err
372380 }
373381
374-func (db *DB) GetArticleByURL(ctx context.Context, url string) (*Article, error) {
382+func (s *ArticleStore) GetArticleByURL(ctx context.Context, url string) (*Article, error) {
375383 a := &Article{}
376- err := db.QueryRowContext(ctx, `
384+ err := s.db.QueryRowContext(ctx, `
377385 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 = ?
379387 LIMIT 1
380388 `, url).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author,
381389 &a.Summary, &a.Content, &a.FullContent, &a.Published, &a.Updated, &a.FetchedAt)
@@ -385,12 +393,12 @@ func (db *DB) GetArticleByURL(ctx context.Context, url string) (*Article, error)
385393 return a, nil
386394 }
387395
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) {
389397 var count int
390- err := db.QueryRowContext(ctx, `
398+ err := s.db.QueryRowContext(ctx, `
391399 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 = ?
394402 WHERE a.fetched_at > ?
395403 `, userDID, since).Scan(&count)
396404 return count, err
@@ -407,7 +415,7 @@ func escapeFTS5(query string) string {
407415 return b.String()
408416 }
409417
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) {
411419 if strings.TrimSpace(query) == "" {
412420 return nil, nil
413421 }
@@ -419,25 +427,26 @@ func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit,
419427
420428 columnQuery := "{title summary} : " + safeQuery
421429
422- rows, err := db.QueryContext(ctx, `
430+ rows, err := s.db.QueryContext(ctx, `
423431 SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content,
424432 a.published, a.updated, a.fetched_at,
425433 COALESCE(r.is_read, 0),
426434 COALESCE(lc.cnt, 0),
427435 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
434444 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
436446 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
437- WHERE articles_fts MATCH ?
438447 ORDER BY ft.rank
439448 LIMIT ? OFFSET ?
440- `, userDID, userDID, userDID, columnQuery, limit, offset)
449+ `, columnQuery, userDID, userDID, userDID, limit, offset)
441450 if err != nil {
442451 return nil, err
443452 }
@@ -10,6 +10,14 @@ import (
10 "pkg.rbrt.fr/glean/internal/feed"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 type Article struct {21 type Article struct {
14 ID int6422 ID int64
15 FeedURL string23 FeedURL string
@@ -37,19 +45,19 @@ type ReadState struct {
37 ReadAt sql.NullTime45 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 if len(articles) == 0 {49 if len(articles) == 0 {
42 return nil50 return nil
43 }51 }
44 52
45- tx, err := db.BeginTx(ctx, nil)53+ tx, err := s.db.BeginTx(ctx, nil)
46 if err != nil {54 if err != nil {
47 return err55 return err
48 }56 }
49 defer tx.Rollback()57 defer tx.Rollback()
50 58
51 stmt, err := tx.PrepareContext(ctx, `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 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)61 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
54 ON CONFLICT(feed_url, guid) DO NOTHING62 ON CONFLICT(feed_url, guid) DO NOTHING
55 `)63 `)
@@ -79,11 +87,11 @@ func (db *DB) UpsertArticlesBatch(ctx context.Context, articles []feed.Article)
79 return tx.Commit()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 a := &Article{}91 a := &Article{}
84- err := db.QueryRowContext(ctx, `92+ err := s.db.QueryRowContext(ctx, `
85 SELECT id, feed_url, guid, title, url, author, summary, content, full_content, published, updated, fetched_at93 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 `, id).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author,95 `, id).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author,
88 &a.Summary, &a.Content, &a.FullContent, &a.Published, &a.Updated, &a.FetchedAt)96 &a.Summary, &a.Content, &a.FullContent, &a.Published, &a.Updated, &a.FetchedAt)
89 if err != nil {97 if err != nil {
@@ -92,7 +100,7 @@ func (db *DB) GetArticle(ctx context.Context, id int64) (*Article, error) {
92 return a, nil100 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 var query string104 var query string
97 var args []any105 var args []any
98 106
@@ -103,12 +111,12 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
103 COALESCE(r.is_read, 0),111 COALESCE(r.is_read, 0),
104 COALESCE(lc.cnt, 0),112 COALESCE(lc.cnt, 0),
105 COALESCE(ul.liked, 0)113 COALESCE(ul.liked, 0)
106- FROM articles a114+ FROM articles.articles a
107- LEFT JOIN feeds f ON a.feed_url = f.feed_url115+ LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url
108- LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id116+ LEFT JOIN articles.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) lc117+ LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc
110 ON lc.feed_url = a.feed_url AND lc.article_url = a.url118 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 = ?) ul119+ LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul
112 ON ul.feed_url = a.feed_url AND ul.article_url = a.url120 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
113 WHERE a.feed_url = ?121 WHERE a.feed_url = ?
114 `122 `
@@ -120,13 +128,13 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
120 COALESCE(r.is_read, 0),128 COALESCE(r.is_read, 0),
121 COALESCE(lc.cnt, 0),129 COALESCE(lc.cnt, 0),
122 COALESCE(ul.liked, 0)130 COALESCE(ul.liked, 0)
123- FROM articles a131+ FROM articles.articles a
124- JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?132+ JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
125- LEFT JOIN feeds f ON a.feed_url = f.feed_url133+ LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url
126- LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id134+ LEFT JOIN articles.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) lc135+ LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc
128 ON lc.feed_url = a.feed_url AND lc.article_url = a.url136 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 = ?) ul137+ LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul
130 ON ul.feed_url = a.feed_url AND ul.article_url = a.url138 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
131 WHERE 1=1139 WHERE 1=1
132 `140 `
@@ -137,7 +145,7 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
137 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`145 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`
138 args = append(args, limit, offset)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 if err != nil {149 if err != nil {
142 return nil, err150 return nil, err
143 }151 }
@@ -156,7 +164,7 @@ func (db *DB) ListArticles(ctx context.Context, userDID, feedURL string, limit,
156 return articles, rows.Err()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 var query string168 var query string
161 var args []any169 var args []any
162 170
@@ -167,12 +175,12 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
167 COALESCE(r.is_read, 0),175 COALESCE(r.is_read, 0),
168 COALESCE(lc.cnt, 0),176 COALESCE(lc.cnt, 0),
169 COALESCE(ul.liked, 0)177 COALESCE(ul.liked, 0)
170- FROM articles a178+ FROM articles.articles a
171- LEFT JOIN feeds f ON a.feed_url = f.feed_url179+ LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url
172- LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id180+ LEFT JOIN articles.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) lc181+ LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc
174 ON lc.feed_url = a.feed_url AND lc.article_url = a.url182 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 = ?) ul183+ LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul
176 ON ul.feed_url = a.feed_url AND ul.article_url = a.url184 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
177 WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL)185 WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL)
178 `186 `
@@ -184,13 +192,13 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
184 COALESCE(r.is_read, 0),192 COALESCE(r.is_read, 0),
185 COALESCE(lc.cnt, 0),193 COALESCE(lc.cnt, 0),
186 COALESCE(ul.liked, 0)194 COALESCE(ul.liked, 0)
187- FROM articles a195+ FROM articles.articles a
188- JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?196+ JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
189- LEFT JOIN feeds f ON a.feed_url = f.feed_url197+ LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url
190- LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id198+ LEFT JOIN articles.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) lc199+ LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc
192 ON lc.feed_url = a.feed_url AND lc.article_url = a.url200 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 = ?) ul201+ LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul
194 ON ul.feed_url = a.feed_url AND ul.article_url = a.url202 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
195 WHERE (r.is_read = 0 OR r.is_read IS NULL)203 WHERE (r.is_read = 0 OR r.is_read IS NULL)
196 `204 `
@@ -201,7 +209,7 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
201 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`209 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`
202 args = append(args, limit, offset)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 if err != nil {213 if err != nil {
206 return nil, err214 return nil, err
207 }215 }
@@ -220,7 +228,7 @@ func (db *DB) ListUnreadArticles(ctx context.Context, userDID, feedURL string, l
220 return articles, rows.Err()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 var query string232 var query string
225 var args []any233 var args []any
226 234
@@ -231,12 +239,12 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim
231 COALESCE(r.is_read, 0),239 COALESCE(r.is_read, 0),
232 COALESCE(lc.cnt, 0),240 COALESCE(lc.cnt, 0),
233 COALESCE(ul.liked, 0)241 COALESCE(ul.liked, 0)
234- FROM articles a242+ FROM articles.articles a
235- LEFT JOIN feeds f ON a.feed_url = f.feed_url243+ LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url
236- JOIN read_state r ON r.user_did = ? AND r.article_id = a.id244+ JOIN articles.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) lc245+ LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc
238 ON lc.feed_url = a.feed_url AND lc.article_url = a.url246 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 = ?) ul247+ LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul
240 ON ul.feed_url = a.feed_url AND ul.article_url = a.url248 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
241 WHERE r.is_read = 1 AND a.feed_url = ?249 WHERE r.is_read = 1 AND a.feed_url = ?
242 `250 `
@@ -248,13 +256,13 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim
248 COALESCE(r.is_read, 0),256 COALESCE(r.is_read, 0),
249 COALESCE(lc.cnt, 0),257 COALESCE(lc.cnt, 0),
250 COALESCE(ul.liked, 0)258 COALESCE(ul.liked, 0)
251- FROM articles a259+ FROM articles.articles a
252- JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?260+ JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
253- LEFT JOIN feeds f ON a.feed_url = f.feed_url261+ LEFT JOIN articles.feeds f ON a.feed_url = f.feed_url
254- JOIN read_state r ON r.user_did = ? AND r.article_id = a.id262+ JOIN articles.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) lc263+ LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc
256 ON lc.feed_url = a.feed_url AND lc.article_url = a.url264 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 = ?) ul265+ LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul
258 ON ul.feed_url = a.feed_url AND ul.article_url = a.url266 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
259 WHERE r.is_read = 1267 WHERE r.is_read = 1
260 `268 `
@@ -265,7 +273,7 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim
265 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`273 query += ` ORDER BY (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC LIMIT ? OFFSET ?`
266 args = append(args, limit, offset)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 if err != nil {277 if err != nil {
270 return nil, err278 return nil, err
271 }279 }
@@ -284,9 +292,9 @@ func (db *DB) ListReadArticles(ctx context.Context, userDID, feedURL string, lim
284 return articles, rows.Err()292 return articles, rows.Err()
285 }293 }
286 294
287-func (db *DB) MarkArticleRead(ctx context.Context, userDID string, articleID int64) error {295+func (s *ArticleStore) MarkArticleRead(ctx context.Context, userDID string, articleID int64) error {
288- _, err := db.ExecContext(ctx, `296+ _, err := s.db.ExecContext(ctx, `
289- INSERT INTO read_state (user_did, article_id, is_read, read_at)297+ INSERT INTO articles.read_state (user_did, article_id, is_read, read_at)
290 VALUES (?, ?, 1, CURRENT_TIMESTAMP)298 VALUES (?, ?, 1, CURRENT_TIMESTAMP)
291 ON CONFLICT(user_did, article_id) DO UPDATE SET299 ON CONFLICT(user_did, article_id) DO UPDATE SET
292 is_read = 1, read_at = CURRENT_TIMESTAMP300 is_read = 1, read_at = CURRENT_TIMESTAMP
@@ -294,9 +302,9 @@ func (db *DB) MarkArticleRead(ctx context.Context, userDID string, articleID int
294 return err302 return err
295 }303 }
296 304
297-func (db *DB) MarkArticleUnread(ctx context.Context, userDID string, articleID int64) error {305+func (s *ArticleStore) MarkArticleUnread(ctx context.Context, userDID string, articleID int64) error {
298- _, err := db.ExecContext(ctx, `306+ _, err := s.db.ExecContext(ctx, `
299- INSERT INTO read_state (user_did, article_id, is_read)307+ INSERT INTO articles.read_state (user_did, article_id, is_read)
300 VALUES (?, ?, 0)308 VALUES (?, ?, 0)
301 ON CONFLICT(user_did, article_id) DO UPDATE SET309 ON CONFLICT(user_did, article_id) DO UPDATE SET
302 is_read = 0, read_at = NULL310 is_read = 0, read_at = NULL
@@ -304,11 +312,11 @@ func (db *DB) MarkArticleUnread(ctx context.Context, userDID string, articleID i
304 return err312 return err
305 }313 }
306 314
307-func (db *DB) MarkAllRead(ctx context.Context, userDID, feedURL string) error {315+func (s *ArticleStore) MarkAllRead(ctx context.Context, userDID, feedURL string) error {
308- _, err := db.ExecContext(ctx, `316+ _, err := s.db.ExecContext(ctx, `
309- INSERT INTO read_state (user_did, article_id, is_read, read_at)317+ INSERT INTO articles.read_state (user_did, article_id, is_read, read_at)
310 SELECT ?, a.id, 1, CURRENT_TIMESTAMP318 SELECT ?, a.id, 1, CURRENT_TIMESTAMP
311- FROM articles a319+ FROM articles.articles a
312 WHERE a.feed_url = ?320 WHERE a.feed_url = ?
313 ON CONFLICT(user_did, article_id) DO UPDATE SET321 ON CONFLICT(user_did, article_id) DO UPDATE SET
314 is_read = 1, read_at = CURRENT_TIMESTAMP322 is_read = 1, read_at = CURRENT_TIMESTAMP
@@ -316,23 +324,23 @@ func (db *DB) MarkAllRead(ctx context.Context, userDID, feedURL string) error {
316 return err324 return err
317 }325 }
318 326
319-func (db *DB) MarkAllSubscribedRead(ctx context.Context, userDID string) error {327+func (s *ArticleStore) MarkAllSubscribedRead(ctx context.Context, userDID string) error {
320- _, err := db.ExecContext(ctx, `328+ _, err := s.db.ExecContext(ctx, `
321- INSERT INTO read_state (user_did, article_id, is_read, read_at)329+ INSERT INTO articles.read_state (user_did, article_id, is_read, read_at)
322 SELECT ?, a.id, 1, CURRENT_TIMESTAMP330 SELECT ?, a.id, 1, CURRENT_TIMESTAMP
323- FROM articles a331+ FROM articles.articles a
324- JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?332+ JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
325 ON CONFLICT(user_did, article_id) DO UPDATE SET333 ON CONFLICT(user_did, article_id) DO UPDATE SET
326 is_read = 1, read_at = CURRENT_TIMESTAMP334 is_read = 1, read_at = CURRENT_TIMESTAMP
327 `, userDID, userDID)335 `, userDID, userDID)
328 return err336 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 rs := &ReadState{}340 rs := &ReadState{}
333- err := db.QueryRowContext(ctx, `341+ err := s.db.QueryRowContext(ctx, `
334 SELECT user_did, article_id, is_read, read_at342 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 `, userDID, articleID).Scan(&rs.UserDID, &rs.ArticleID, &rs.IsRead, &rs.ReadAt)344 `, userDID, articleID).Scan(&rs.UserDID, &rs.ArticleID, &rs.IsRead, &rs.ReadAt)
337 if err == sql.ErrNoRows {345 if err == sql.ErrNoRows {
338 return &ReadState{UserDID: userDID, ArticleID: articleID}, nil346 return &ReadState{UserDID: userDID, ArticleID: articleID}, nil
@@ -343,39 +351,39 @@ func (db *DB) GetReadState(ctx context.Context, userDID string, articleID int64)
343 return rs, nil351 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 var count int355 var count int
348 if feedURL != "" {356 if feedURL != "" {
349- err := db.QueryRowContext(ctx, `357+ err := s.db.QueryRowContext(ctx, `
350 SELECT COUNT(*)358 SELECT COUNT(*)
351- FROM articles a359+ FROM articles.articles a
352- LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id360+ LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id
353 WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL)361 WHERE a.feed_url = ? AND (r.is_read = 0 OR r.is_read IS NULL)
354 `, userDID, feedURL).Scan(&count)362 `, userDID, feedURL).Scan(&count)
355 return count, err363 return count, err
356 }364 }
357- err := db.QueryRowContext(ctx, `365+ err := s.db.QueryRowContext(ctx, `
358 SELECT COUNT(*)366 SELECT COUNT(*)
359- FROM articles a367+ FROM articles.articles a
360- JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?368+ JOIN articles.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.id369+ LEFT JOIN articles.read_state r ON r.user_did = ? AND r.article_id = a.id
362 WHERE r.is_read = 0 OR r.is_read IS NULL370 WHERE r.is_read = 0 OR r.is_read IS NULL
363 `, userDID, userDID).Scan(&count)371 `, userDID, userDID).Scan(&count)
364 return count, err372 return count, err
365 }373 }
366 374
367-func (db *DB) UpdateArticleFullContent(ctx context.Context, id int64, fullContent string) error {375+func (s *ArticleStore) UpdateArticleFullContent(ctx context.Context, id int64, fullContent string) error {
368- _, err := db.ExecContext(ctx, `376+ _, err := s.db.ExecContext(ctx, `
369- UPDATE articles SET full_content = ? WHERE id = ?377+ UPDATE articles.articles SET full_content = ? WHERE id = ?
370 `, fullContent, id)378 `, fullContent, id)
371 return err379 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 a := &Article{}383 a := &Article{}
376- err := db.QueryRowContext(ctx, `384+ err := s.db.QueryRowContext(ctx, `
377 SELECT id, feed_url, guid, title, url, author, summary, content, full_content, published, updated, fetched_at385 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 LIMIT 1387 LIMIT 1
380 `, url).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author,388 `, url).Scan(&a.ID, &a.FeedURL, &a.GUID, &a.Title, &a.URL, &a.Author,
381 &a.Summary, &a.Content, &a.FullContent, &a.Published, &a.Updated, &a.FetchedAt)389 &a.Summary, &a.Content, &a.FullContent, &a.Published, &a.Updated, &a.FetchedAt)
@@ -385,12 +393,12 @@ func (db *DB) GetArticleByURL(ctx context.Context, url string) (*Article, error)
385 return a, nil393 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 var count int397 var count int
390- err := db.QueryRowContext(ctx, `398+ err := s.db.QueryRowContext(ctx, `
391 SELECT COUNT(*)399 SELECT COUNT(*)
392- FROM articles a400+ FROM articles.articles a
393- JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?401+ JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
394 WHERE a.fetched_at > ?402 WHERE a.fetched_at > ?
395 `, userDID, since).Scan(&count)403 `, userDID, since).Scan(&count)
396 return count, err404 return count, err
@@ -407,7 +415,7 @@ func escapeFTS5(query string) string {
407 return b.String()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 if strings.TrimSpace(query) == "" {419 if strings.TrimSpace(query) == "" {
412 return nil, nil420 return nil, nil
413 }421 }
@@ -419,25 +427,26 @@ func (db *DB) SearchArticles(ctx context.Context, userDID, query string, limit,
419 427
420 columnQuery := "{title summary} : " + safeQuery428 columnQuery := "{title summary} : " + safeQuery
421 429
422- rows, err := db.QueryContext(ctx, `430+ rows, err := s.db.QueryContext(ctx, `
423 SELECT a.id, a.feed_url, COALESCE(f.title, ''), f.favicon_url, a.guid, a.title, a.url, a.author, a.summary, a.content,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 a.published, a.updated, a.fetched_at,432 a.published, a.updated, a.fetched_at,
425 COALESCE(r.is_read, 0),433 COALESCE(r.is_read, 0),
426 COALESCE(lc.cnt, 0),434 COALESCE(lc.cnt, 0),
427 COALESCE(ul.liked, 0)435 COALESCE(ul.liked, 0)
428- FROM articles_fts ft436+ FROM (
429- JOIN articles a ON a.id = ft.rowid437+ SELECT rowid, rank FROM articles.articles_fts WHERE articles_fts MATCH ?
430- JOIN subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?438+ ) ft
431- LEFT JOIN feeds f ON a.feed_url = f.feed_url439+ JOIN articles.articles a ON a.id = ft.rowid
432- LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id440+ JOIN articles.subscriptions s ON a.feed_url = s.feed_url AND s.user_did = ?
433- LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM likes GROUP BY feed_url, article_url) lc441+ 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 ON lc.feed_url = a.feed_url AND lc.article_url = a.url444 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 = ?) ul445+ LEFT JOIN (SELECT feed_url, article_url, 1 as liked FROM articles.likes WHERE author_did = ?) ul
436 ON ul.feed_url = a.feed_url AND ul.article_url = a.url446 ON ul.feed_url = a.feed_url AND ul.article_url = a.url
437- WHERE articles_fts MATCH ?
438 ORDER BY ft.rank447 ORDER BY ft.rank
439 LIMIT ? OFFSET ?448 LIMIT ? OFFSET ?
440- `, userDID, userDID, userDID, columnQuery, limit, offset)449+ `, columnQuery, userDID, userDID, userDID, limit, offset)
441 if err != nil {450 if err != nil {
442 return nil, err451 return nil, err
443 }452 }
modified internal/db/article_test.go +103 -99
@@ -9,46 +9,50 @@ import (
99 "gotest.tools/v3/assert"
1010 )
1111
12-func setupTestDB(t *testing.T) *DB {
12+func setupTestDB(t *testing.T) *Databases {
1313 t.Helper()
1414 f, err := os.CreateTemp("", "glean-test-*.db")
1515 assert.NilError(t, err)
1616 assert.NilError(t, f.Close())
1717 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+ })
1923
20- db, err := Open(path)
24+ dbs, err := OpenAll(path)
2125 assert.NilError(t, err)
22- t.Cleanup(func() { _ = db.Close() })
23- return db
26+ t.Cleanup(func() { _ = dbs.Close() })
27+ return dbs
2428 }
2529
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) {
2731 t.Helper()
2832
2933 userDID = "did:test:user1"
3034 feedURL = "https://example.com/feed.xml"
3135
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")
3337 assert.NilError(t, err)
3438
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")
3640 assert.NilError(t, err)
3741
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)
3943 assert.NilError(t, err)
4044
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 (?, ?, ?, ?)`,
4246 feedURL, "guid-read", "Read Article", "https://example.com/read")
4347 assert.NilError(t, err)
4448 readArticleID, _ = res.LastInsertId()
4549
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 (?, ?, ?, ?)`,
4751 feedURL, "guid-unread", "Unread Article", "https://example.com/unread")
4852 assert.NilError(t, err)
4953 unreadArticleID, _ = res.LastInsertId()
5054
51- err = db.MarkArticleRead(ctx, userDID, readArticleID)
55+ err = dbs.Articles.MarkArticleRead(ctx, userDID, readArticleID)
5256 assert.NilError(t, err)
5357
5458 return userDID, feedURL, readArticleID, unreadArticleID
@@ -56,121 +60,121 @@ func seedArticleReadState(t *testing.T, ctx context.Context, db *DB) (userDID st
5660
5761 func TestListReadArticles_ReturnsOnlyRead(t *testing.T) {
5862 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)
6165
62- articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0)
66+ results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0)
6367 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})
6771
6872 _ = unreadID
6973 }
7074
7175 func TestListReadArticles_ExcludesUnread(t *testing.T) {
7276 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)
7579
76- articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0)
80+ results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0)
7781 assert.NilError(t, err)
78- for _, a := range articles {
82+ for _, a := range results {
7983 assert.Assert(t, a.ID != unreadID, "unread article should not appear in read list")
8084 }
8185 }
8286
8387 func TestListUnreadArticles_ReturnsOnlyUnread(t *testing.T) {
8488 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)
8791
88- articles, err := db.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)
92+ results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)
8993 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)
9397
9498 _ = readID
9599 }
96100
97101 func TestListArticles_ReturnsAll(t *testing.T) {
98102 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)
101105
102- articles, err := db.ListArticles(ctx, userDID, feedURL, 10, 0)
106+ results, err := dbs.Articles.ListArticles(ctx, userDID, feedURL, 10, 0)
103107 assert.NilError(t, err)
104- assert.Equal(t, len(articles), 2)
108+ assert.Equal(t, len(results), 2)
105109 }
106110
107111 func TestMarkArticleRead_ToggleUnread(t *testing.T) {
108112 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)
111115
112- err := db.MarkArticleRead(ctx, userDID, unreadID)
116+ err := dbs.Articles.MarkArticleRead(ctx, userDID, unreadID)
113117 assert.NilError(t, err)
114118
115- state, err := db.GetReadState(ctx, userDID, unreadID)
119+ state, err := dbs.Articles.GetReadState(ctx, userDID, unreadID)
116120 assert.NilError(t, err)
117121 assert.Equal(t, state.IsRead, true)
118122
119- err = db.MarkArticleUnread(ctx, userDID, unreadID)
123+ err = dbs.Articles.MarkArticleUnread(ctx, userDID, unreadID)
120124 assert.NilError(t, err)
121125
122- state, err = db.GetReadState(ctx, userDID, unreadID)
126+ state, err = dbs.Articles.GetReadState(ctx, userDID, unreadID)
123127 assert.NilError(t, err)
124128 assert.Equal(t, state.IsRead, false)
125129 }
126130
127131 func TestListReadArticles_EmptyWhenNoneRead(t *testing.T) {
128132 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)
131135
132- articles, err := db.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)
136+ results, err := dbs.Articles.ListUnreadArticles(ctx, userDID, feedURL, 10, 0)
133137 assert.NilError(t, err)
134- assert.Equal(t, len(articles), 1)
138+ assert.Equal(t, len(results), 1)
135139 }
136140
137141 func TestListReadArticles_WithFeedURLFilter(t *testing.T) {
138142 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)
141145
142- articles, err := db.ListReadArticles(ctx, userDID, feedURL, 10, 0)
146+ results, err := dbs.Articles.ListReadArticles(ctx, userDID, feedURL, 10, 0)
143147 assert.NilError(t, err)
144- assert.Equal(t, len(articles), 1)
148+ assert.Equal(t, len(results), 1)
145149
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)
147151 assert.NilError(t, err)
148- assert.Equal(t, len(articles), 0)
152+ assert.Equal(t, len(results), 0)
149153 }
150154
151155 func TestGetUnreadCount(t *testing.T) {
152156 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)
155159
156- count, err := db.GetUnreadCount(ctx, userDID, feedURL)
160+ count, err := dbs.Articles.GetUnreadCount(ctx, userDID, feedURL)
157161 assert.NilError(t, err)
158162 assert.Equal(t, count, 1)
159163
160- count, err = db.GetUnreadCount(ctx, userDID, "")
164+ count, err = dbs.Articles.GetUnreadCount(ctx, userDID, "")
161165 assert.NilError(t, err)
162166 assert.Equal(t, count, 1)
163167 }
164168
165169 func TestUpdateArticleFullContent(t *testing.T) {
166170 ctx := context.Background()
167- db := setupTestDB(t)
168- _, _, _, articleID := seedArticleReadState(t, ctx, db)
171+ dbs := setupTestDB(t)
172+ _, _, _, articleID := seedArticleReadState(t, ctx, dbs)
169173
170- err := db.UpdateArticleFullContent(ctx, articleID, "<p>Scraped content</p>")
174+ err := dbs.Articles.UpdateArticleFullContent(ctx, articleID, "<p>Scraped content</p>")
171175 assert.NilError(t, err)
172176
173- article, err := db.GetArticle(ctx, articleID)
177+ article, err := dbs.Articles.GetArticle(ctx, articleID)
174178 assert.NilError(t, err)
175179 assert.Equal(t, article.FullContent.String, "<p>Scraped content</p>")
176180 assert.Assert(t, article.FullContent.Valid)
@@ -178,27 +182,27 @@ func TestUpdateArticleFullContent(t *testing.T) {
178182
179183 func TestGetArticle_IncludesFullContent(t *testing.T) {
180184 ctx := context.Background()
181- db := setupTestDB(t)
182- _, _, _, articleID := seedArticleReadState(t, ctx, db)
185+ dbs := setupTestDB(t)
186+ _, _, _, articleID := seedArticleReadState(t, ctx, dbs)
183187
184- article, err := db.GetArticle(ctx, articleID)
188+ article, err := dbs.Articles.GetArticle(ctx, articleID)
185189 assert.NilError(t, err)
186190 assert.Assert(t, !article.FullContent.Valid)
187191 }
188192
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) {
190194 t.Helper()
191195
192196 userDID = "did:test:searcher"
193197 feedURL = "https://search.example.com/feed.xml"
194198
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")
196200 assert.NilError(t, err)
197201
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")
199203 assert.NilError(t, err)
200204
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)
202206 assert.NilError(t, err)
203207
204208 articles := []struct {
@@ -209,8 +213,8 @@ func seedSearchData(t *testing.T, ctx context.Context, database *DB) (userDID, f
209213 {"g3", "Python Data Science", "NumPy and Pandas tutorial", "Python is popular for data analysis"},
210214 }
211215 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 (?, ?, ?, ?, ?)
214218 `, feedURL, a.guid, a.title, a.summary, a.content)
215219 assert.NilError(t, err)
216220 }
@@ -220,10 +224,10 @@ func seedSearchData(t *testing.T, ctx context.Context, database *DB) (userDID, f
220224
221225 func TestSearchArticles_FindsByTitle(t *testing.T) {
222226 ctx := context.Background()
223- db := setupTestDB(t)
224- userDID, _ := seedSearchData(t, ctx, db)
227+ dbs := setupTestDB(t)
228+ userDID, _ := seedSearchData(t, ctx, dbs)
225229
226- results, err := db.SearchArticles(ctx, userDID, "Go Programming", 10, 0)
230+ results, err := dbs.Articles.SearchArticles(ctx, userDID, "Go Programming", 10, 0)
227231 assert.NilError(t, err)
228232 assert.Equal(t, len(results), 1)
229233 assert.Equal(t, results[0].Title, "Go Programming Basics")
@@ -231,10 +235,10 @@ func TestSearchArticles_FindsByTitle(t *testing.T) {
231235
232236 func TestSearchArticles_FindsBySummary(t *testing.T) {
233237 ctx := context.Background()
234- db := setupTestDB(t)
235- userDID, _ := seedSearchData(t, ctx, db)
238+ dbs := setupTestDB(t)
239+ userDID, _ := seedSearchData(t, ctx, dbs)
236240
237- results, err := db.SearchArticles(ctx, userDID, "ownership", 10, 0)
241+ results, err := dbs.Articles.SearchArticles(ctx, userDID, "ownership", 10, 0)
238242 assert.NilError(t, err)
239243 assert.Equal(t, len(results), 1)
240244 assert.Equal(t, results[0].Title, "Rust Memory Safety")
@@ -242,30 +246,30 @@ func TestSearchArticles_FindsBySummary(t *testing.T) {
242246
243247 func TestSearchArticles_IgnoresContentOnlyMatch(t *testing.T) {
244248 ctx := context.Background()
245- db := setupTestDB(t)
246- userDID, _ := seedSearchData(t, ctx, db)
249+ dbs := setupTestDB(t)
250+ userDID, _ := seedSearchData(t, ctx, dbs)
247251
248- results, err := db.SearchArticles(ctx, userDID, "garbage collection", 10, 0)
252+ results, err := dbs.Articles.SearchArticles(ctx, userDID, "garbage collection", 10, 0)
249253 assert.NilError(t, err)
250254 assert.Equal(t, len(results), 0)
251255 }
252256
253257 func TestSearchArticles_NoResults(t *testing.T) {
254258 ctx := context.Background()
255- db := setupTestDB(t)
256- userDID, _ := seedSearchData(t, ctx, db)
259+ dbs := setupTestDB(t)
260+ userDID, _ := seedSearchData(t, ctx, dbs)
257261
258- results, err := db.SearchArticles(ctx, userDID, "nonexistent_xyz", 10, 0)
262+ results, err := dbs.Articles.SearchArticles(ctx, userDID, "nonexistent_xyz", 10, 0)
259263 assert.NilError(t, err)
260264 assert.Equal(t, len(results), 0)
261265 }
262266
263267 func TestSearchArticles_MultipleMatches(t *testing.T) {
264268 ctx := context.Background()
265- db := setupTestDB(t)
266- userDID, _ := seedSearchData(t, ctx, db)
269+ dbs := setupTestDB(t)
270+ userDID, _ := seedSearchData(t, ctx, dbs)
267271
268- results, err := db.SearchArticles(ctx, userDID, "Python", 10, 0)
272+ results, err := dbs.Articles.SearchArticles(ctx, userDID, "Python", 10, 0)
269273 assert.NilError(t, err)
270274 assert.Assert(t, len(results) >= 1)
271275
@@ -281,19 +285,19 @@ func TestSearchArticles_MultipleMatches(t *testing.T) {
281285
282286 func TestSearchArticles_ScopedToSubscriptions(t *testing.T) {
283287 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)
286290
287291 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")
289293 assert.NilError(t, err)
290294
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 (?, ?, ?)
293297 `, otherFeed, "other-1", "Go Concurrency Tips")
294298 assert.NilError(t, err)
295299
296- results, err := db.SearchArticles(ctx, userDID, "Go", 10, 0)
300+ results, err := dbs.Articles.SearchArticles(ctx, userDID, "Go", 10, 0)
297301 assert.NilError(t, err)
298302 for _, a := range results {
299303 assert.Equal(t, a.FeedURL, feedURL)
@@ -302,43 +306,43 @@ func TestSearchArticles_ScopedToSubscriptions(t *testing.T) {
302306
303307 func TestSearchArticles_Pagination(t *testing.T) {
304308 ctx := context.Background()
305- db := setupTestDB(t)
306- userDID, _ := seedSearchData(t, ctx, db)
309+ dbs := setupTestDB(t)
310+ userDID, _ := seedSearchData(t, ctx, dbs)
307311
308- results, err := db.SearchArticles(ctx, userDID, "Go", 1, 0)
312+ results, err := dbs.Articles.SearchArticles(ctx, userDID, "Go", 1, 0)
309313 assert.NilError(t, err)
310314 assert.Equal(t, len(results), 1)
311315
312- results2, err := db.SearchArticles(ctx, userDID, "Go", 1, 1)
316+ results2, err := dbs.Articles.SearchArticles(ctx, userDID, "Go", 1, 1)
313317 assert.NilError(t, err)
314318 assert.Assert(t, len(results2) == 0 || results2[0].ID != results[0].ID)
315319 }
316320
317321 func TestSearchArticles_EmptyQuery(t *testing.T) {
318322 ctx := context.Background()
319- db := setupTestDB(t)
320- userDID, _ := seedSearchData(t, ctx, db)
323+ dbs := setupTestDB(t)
324+ userDID, _ := seedSearchData(t, ctx, dbs)
321325
322- results, err := db.SearchArticles(ctx, userDID, "", 10, 0)
326+ results, err := dbs.Articles.SearchArticles(ctx, userDID, "", 10, 0)
323327 assert.NilError(t, err)
324328 assert.Equal(t, len(results), 0)
325329 }
326330
327331 func TestSearchArticles_SpecialCharactersNoError(t *testing.T) {
328332 ctx := context.Background()
329- db := setupTestDB(t)
330- userDID, _ := seedSearchData(t, ctx, db)
333+ dbs := setupTestDB(t)
334+ userDID, _ := seedSearchData(t, ctx, dbs)
331335
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)
333337 assert.NilError(t, err)
334338 }
335339
336340 func TestSearchArticles_OnlySpecialCharacters(t *testing.T) {
337341 ctx := context.Background()
338- db := setupTestDB(t)
339- userDID, _ := seedSearchData(t, ctx, db)
342+ dbs := setupTestDB(t)
343+ userDID, _ := seedSearchData(t, ctx, dbs)
340344
341- results, err := db.SearchArticles(ctx, userDID, "...///:::!!!", 10, 0)
345+ results, err := dbs.Articles.SearchArticles(ctx, userDID, "...///:::!!!", 10, 0)
342346 assert.NilError(t, err)
343347 assert.Equal(t, len(results), 0)
344348 }
@@ -9,46 +9,50 @@ import (
9 "gotest.tools/v3/assert"9 "gotest.tools/v3/assert"
10 )10 )
11 11
12-func setupTestDB(t *testing.T) *DB {12+func setupTestDB(t *testing.T) *Databases {
13 t.Helper()13 t.Helper()
14 f, err := os.CreateTemp("", "glean-test-*.db")14 f, err := os.CreateTemp("", "glean-test-*.db")
15 assert.NilError(t, err)15 assert.NilError(t, err)
16 assert.NilError(t, f.Close())16 assert.NilError(t, f.Close())
17 path := f.Name()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 assert.NilError(t, err)25 assert.NilError(t, err)
22- t.Cleanup(func() { _ = db.Close() })26+ t.Cleanup(func() { _ = dbs.Close() })
23- return db27+ 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 t.Helper()31 t.Helper()
28 32
29 userDID = "did:test:user1"33 userDID = "did:test:user1"
30 feedURL = "https://example.com/feed.xml"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 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)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 feedURL, "guid-read", "Read Article", "https://example.com/read")46 feedURL, "guid-read", "Read Article", "https://example.com/read")
43 assert.NilError(t, err)47 assert.NilError(t, err)
44 readArticleID, _ = res.LastInsertId()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 feedURL, "guid-unread", "Unread Article", "https://example.com/unread")51 feedURL, "guid-unread", "Unread Article", "https://example.com/unread")
48 assert.NilError(t, err)52 assert.NilError(t, err)
49 unreadArticleID, _ = res.LastInsertId()53 unreadArticleID, _ = res.LastInsertId()
50 54
51- err = db.MarkArticleRead(ctx, userDID, readArticleID)55+ err = dbs.Articles.MarkArticleRead(ctx, userDID, readArticleID)
52 assert.NilError(t, err)56 assert.NilError(t, err)
53 57
54 return userDID, feedURL, readArticleID, unreadArticleID58 return userDID, feedURL, readArticleID, unreadArticleID
@@ -56,121 +60,121 @@ func seedArticleReadState(t *testing.T, ctx context.Context, db *DB) (userDID st
56 60
57 func TestListReadArticles_ReturnsOnlyRead(t *testing.T) {61 func TestListReadArticles_ReturnsOnlyRead(t *testing.T) {
58 ctx := context.Background()62 ctx := context.Background()
59- db := setupTestDB(t)63+ dbs := setupTestDB(t)
60- userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, db)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 assert.NilError(t, err)67 assert.NilError(t, err)
64- assert.Equal(t, len(articles), 1)68+ assert.Equal(t, len(results), 1)
65- assert.Equal(t, articles[0].ID, readID)69+ assert.Equal(t, results[0].ID, readID)
66- assert.Equal(t, articles[0].IsRead, sql.NullBool{Bool: true, Valid: true})70+ assert.Equal(t, results[0].IsRead, sql.NullBool{Bool: true, Valid: true})
67 71
68 _ = unreadID72 _ = unreadID
69 }73 }
70 74
71 func TestListReadArticles_ExcludesUnread(t *testing.T) {75 func TestListReadArticles_ExcludesUnread(t *testing.T) {
72 ctx := context.Background()76 ctx := context.Background()
73- db := setupTestDB(t)77+ dbs := setupTestDB(t)
74- userDID, feedURL, _, unreadID := seedArticleReadState(t, ctx, db)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 assert.NilError(t, err)81 assert.NilError(t, err)
78- for _, a := range articles {82+ for _, a := range results {
79 assert.Assert(t, a.ID != unreadID, "unread article should not appear in read list")83 assert.Assert(t, a.ID != unreadID, "unread article should not appear in read list")
80 }84 }
81 }85 }
82 86
83 func TestListUnreadArticles_ReturnsOnlyUnread(t *testing.T) {87 func TestListUnreadArticles_ReturnsOnlyUnread(t *testing.T) {
84 ctx := context.Background()88 ctx := context.Background()
85- db := setupTestDB(t)89+ dbs := setupTestDB(t)
86- userDID, feedURL, readID, unreadID := seedArticleReadState(t, ctx, db)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 assert.NilError(t, err)93 assert.NilError(t, err)
90- assert.Equal(t, len(articles), 1)94+ assert.Equal(t, len(results), 1)
91- assert.Equal(t, articles[0].ID, unreadID)95+ assert.Equal(t, results[0].ID, unreadID)
92- assert.Equal(t, articles[0].IsRead.Bool, false)96+ assert.Equal(t, results[0].IsRead.Bool, false)
93 97
94 _ = readID98 _ = readID
95 }99 }
96 100
97 func TestListArticles_ReturnsAll(t *testing.T) {101 func TestListArticles_ReturnsAll(t *testing.T) {
98 ctx := context.Background()102 ctx := context.Background()
99- db := setupTestDB(t)103+ dbs := setupTestDB(t)
100- userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)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 assert.NilError(t, err)107 assert.NilError(t, err)
104- assert.Equal(t, len(articles), 2)108+ assert.Equal(t, len(results), 2)
105 }109 }
106 110
107 func TestMarkArticleRead_ToggleUnread(t *testing.T) {111 func TestMarkArticleRead_ToggleUnread(t *testing.T) {
108 ctx := context.Background()112 ctx := context.Background()
109- db := setupTestDB(t)113+ dbs := setupTestDB(t)
110- userDID, _, _, unreadID := seedArticleReadState(t, ctx, db)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 assert.NilError(t, err)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 assert.NilError(t, err)120 assert.NilError(t, err)
117 assert.Equal(t, state.IsRead, true)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 assert.NilError(t, err)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 assert.NilError(t, err)127 assert.NilError(t, err)
124 assert.Equal(t, state.IsRead, false)128 assert.Equal(t, state.IsRead, false)
125 }129 }
126 130
127 func TestListReadArticles_EmptyWhenNoneRead(t *testing.T) {131 func TestListReadArticles_EmptyWhenNoneRead(t *testing.T) {
128 ctx := context.Background()132 ctx := context.Background()
129- db := setupTestDB(t)133+ dbs := setupTestDB(t)
130- userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)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 assert.NilError(t, err)137 assert.NilError(t, err)
134- assert.Equal(t, len(articles), 1)138+ assert.Equal(t, len(results), 1)
135 }139 }
136 140
137 func TestListReadArticles_WithFeedURLFilter(t *testing.T) {141 func TestListReadArticles_WithFeedURLFilter(t *testing.T) {
138 ctx := context.Background()142 ctx := context.Background()
139- db := setupTestDB(t)143+ dbs := setupTestDB(t)
140- userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)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 assert.NilError(t, err)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 assert.NilError(t, err)151 assert.NilError(t, err)
148- assert.Equal(t, len(articles), 0)152+ assert.Equal(t, len(results), 0)
149 }153 }
150 154
151 func TestGetUnreadCount(t *testing.T) {155 func TestGetUnreadCount(t *testing.T) {
152 ctx := context.Background()156 ctx := context.Background()
153- db := setupTestDB(t)157+ dbs := setupTestDB(t)
154- userDID, feedURL, _, _ := seedArticleReadState(t, ctx, db)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 assert.NilError(t, err)161 assert.NilError(t, err)
158 assert.Equal(t, count, 1)162 assert.Equal(t, count, 1)
159 163
160- count, err = db.GetUnreadCount(ctx, userDID, "")164+ count, err = dbs.Articles.GetUnreadCount(ctx, userDID, "")
161 assert.NilError(t, err)165 assert.NilError(t, err)
162 assert.Equal(t, count, 1)166 assert.Equal(t, count, 1)
163 }167 }
164 168
165 func TestUpdateArticleFullContent(t *testing.T) {169 func TestUpdateArticleFullContent(t *testing.T) {
166 ctx := context.Background()170 ctx := context.Background()
167- db := setupTestDB(t)171+ dbs := setupTestDB(t)
168- _, _, _, articleID := seedArticleReadState(t, ctx, db)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 assert.NilError(t, err)175 assert.NilError(t, err)
172 176
173- article, err := db.GetArticle(ctx, articleID)177+ article, err := dbs.Articles.GetArticle(ctx, articleID)
174 assert.NilError(t, err)178 assert.NilError(t, err)
175 assert.Equal(t, article.FullContent.String, "<p>Scraped content</p>")179 assert.Equal(t, article.FullContent.String, "<p>Scraped content</p>")
176 assert.Assert(t, article.FullContent.Valid)180 assert.Assert(t, article.FullContent.Valid)
@@ -178,27 +182,27 @@ func TestUpdateArticleFullContent(t *testing.T) {
178 182
179 func TestGetArticle_IncludesFullContent(t *testing.T) {183 func TestGetArticle_IncludesFullContent(t *testing.T) {
180 ctx := context.Background()184 ctx := context.Background()
181- db := setupTestDB(t)185+ dbs := setupTestDB(t)
182- _, _, _, articleID := seedArticleReadState(t, ctx, db)186+ _, _, _, articleID := seedArticleReadState(t, ctx, dbs)
183 187
184- article, err := db.GetArticle(ctx, articleID)188+ article, err := dbs.Articles.GetArticle(ctx, articleID)
185 assert.NilError(t, err)189 assert.NilError(t, err)
186 assert.Assert(t, !article.FullContent.Valid)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 t.Helper()194 t.Helper()
191 195
192 userDID = "did:test:searcher"196 userDID = "did:test:searcher"
193 feedURL = "https://search.example.com/feed.xml"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 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)206 assert.NilError(t, err)
203 207
204 articles := []struct {208 articles := []struct {
@@ -209,8 +213,8 @@ func seedSearchData(t *testing.T, ctx context.Context, database *DB) (userDID, f
209 {"g3", "Python Data Science", "NumPy and Pandas tutorial", "Python is popular for data analysis"},213 {"g3", "Python Data Science", "NumPy and Pandas tutorial", "Python is popular for data analysis"},
210 }214 }
211 for _, a := range articles {215 for _, a := range articles {
212- _, err := database.ExecContext(ctx, `216+ _, err := dbs.DB().ExecContext(ctx, `
213- INSERT INTO articles (feed_url, guid, title, summary, content) VALUES (?, ?, ?, ?, ?)217+ INSERT INTO articles.articles (feed_url, guid, title, summary, content) VALUES (?, ?, ?, ?, ?)
214 `, feedURL, a.guid, a.title, a.summary, a.content)218 `, feedURL, a.guid, a.title, a.summary, a.content)
215 assert.NilError(t, err)219 assert.NilError(t, err)
216 }220 }
@@ -220,10 +224,10 @@ func seedSearchData(t *testing.T, ctx context.Context, database *DB) (userDID, f
220 224
221 func TestSearchArticles_FindsByTitle(t *testing.T) {225 func TestSearchArticles_FindsByTitle(t *testing.T) {
222 ctx := context.Background()226 ctx := context.Background()
223- db := setupTestDB(t)227+ dbs := setupTestDB(t)
224- userDID, _ := seedSearchData(t, ctx, db)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 assert.NilError(t, err)231 assert.NilError(t, err)
228 assert.Equal(t, len(results), 1)232 assert.Equal(t, len(results), 1)
229 assert.Equal(t, results[0].Title, "Go Programming Basics")233 assert.Equal(t, results[0].Title, "Go Programming Basics")
@@ -231,10 +235,10 @@ func TestSearchArticles_FindsByTitle(t *testing.T) {
231 235
232 func TestSearchArticles_FindsBySummary(t *testing.T) {236 func TestSearchArticles_FindsBySummary(t *testing.T) {
233 ctx := context.Background()237 ctx := context.Background()
234- db := setupTestDB(t)238+ dbs := setupTestDB(t)
235- userDID, _ := seedSearchData(t, ctx, db)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 assert.NilError(t, err)242 assert.NilError(t, err)
239 assert.Equal(t, len(results), 1)243 assert.Equal(t, len(results), 1)
240 assert.Equal(t, results[0].Title, "Rust Memory Safety")244 assert.Equal(t, results[0].Title, "Rust Memory Safety")
@@ -242,30 +246,30 @@ func TestSearchArticles_FindsBySummary(t *testing.T) {
242 246
243 func TestSearchArticles_IgnoresContentOnlyMatch(t *testing.T) {247 func TestSearchArticles_IgnoresContentOnlyMatch(t *testing.T) {
244 ctx := context.Background()248 ctx := context.Background()
245- db := setupTestDB(t)249+ dbs := setupTestDB(t)
246- userDID, _ := seedSearchData(t, ctx, db)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 assert.NilError(t, err)253 assert.NilError(t, err)
250 assert.Equal(t, len(results), 0)254 assert.Equal(t, len(results), 0)
251 }255 }
252 256
253 func TestSearchArticles_NoResults(t *testing.T) {257 func TestSearchArticles_NoResults(t *testing.T) {
254 ctx := context.Background()258 ctx := context.Background()
255- db := setupTestDB(t)259+ dbs := setupTestDB(t)
256- userDID, _ := seedSearchData(t, ctx, db)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 assert.NilError(t, err)263 assert.NilError(t, err)
260 assert.Equal(t, len(results), 0)264 assert.Equal(t, len(results), 0)
261 }265 }
262 266
263 func TestSearchArticles_MultipleMatches(t *testing.T) {267 func TestSearchArticles_MultipleMatches(t *testing.T) {
264 ctx := context.Background()268 ctx := context.Background()
265- db := setupTestDB(t)269+ dbs := setupTestDB(t)
266- userDID, _ := seedSearchData(t, ctx, db)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 assert.NilError(t, err)273 assert.NilError(t, err)
270 assert.Assert(t, len(results) >= 1)274 assert.Assert(t, len(results) >= 1)
271 275
@@ -281,19 +285,19 @@ func TestSearchArticles_MultipleMatches(t *testing.T) {
281 285
282 func TestSearchArticles_ScopedToSubscriptions(t *testing.T) {286 func TestSearchArticles_ScopedToSubscriptions(t *testing.T) {
283 ctx := context.Background()287 ctx := context.Background()
284- db := setupTestDB(t)288+ dbs := setupTestDB(t)
285- userDID, feedURL := seedSearchData(t, ctx, db)289+ userDID, feedURL := seedSearchData(t, ctx, dbs)
286 290
287 otherFeed := "https://other.example.com/feed.xml"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 assert.NilError(t, err)293 assert.NilError(t, err)
290 294
291- _, err = db.ExecContext(ctx, `295+ _, err = dbs.DB().ExecContext(ctx, `
292- INSERT INTO articles (feed_url, guid, title) VALUES (?, ?, ?)296+ INSERT INTO articles.articles (feed_url, guid, title) VALUES (?, ?, ?)
293 `, otherFeed, "other-1", "Go Concurrency Tips")297 `, otherFeed, "other-1", "Go Concurrency Tips")
294 assert.NilError(t, err)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 assert.NilError(t, err)301 assert.NilError(t, err)
298 for _, a := range results {302 for _, a := range results {
299 assert.Equal(t, a.FeedURL, feedURL)303 assert.Equal(t, a.FeedURL, feedURL)
@@ -302,43 +306,43 @@ func TestSearchArticles_ScopedToSubscriptions(t *testing.T) {
302 306
303 func TestSearchArticles_Pagination(t *testing.T) {307 func TestSearchArticles_Pagination(t *testing.T) {
304 ctx := context.Background()308 ctx := context.Background()
305- db := setupTestDB(t)309+ dbs := setupTestDB(t)
306- userDID, _ := seedSearchData(t, ctx, db)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 assert.NilError(t, err)313 assert.NilError(t, err)
310 assert.Equal(t, len(results), 1)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 assert.NilError(t, err)317 assert.NilError(t, err)
314 assert.Assert(t, len(results2) == 0 || results2[0].ID != results[0].ID)318 assert.Assert(t, len(results2) == 0 || results2[0].ID != results[0].ID)
315 }319 }
316 320
317 func TestSearchArticles_EmptyQuery(t *testing.T) {321 func TestSearchArticles_EmptyQuery(t *testing.T) {
318 ctx := context.Background()322 ctx := context.Background()
319- db := setupTestDB(t)323+ dbs := setupTestDB(t)
320- userDID, _ := seedSearchData(t, ctx, db)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 assert.NilError(t, err)327 assert.NilError(t, err)
324 assert.Equal(t, len(results), 0)328 assert.Equal(t, len(results), 0)
325 }329 }
326 330
327 func TestSearchArticles_SpecialCharactersNoError(t *testing.T) {331 func TestSearchArticles_SpecialCharactersNoError(t *testing.T) {
328 ctx := context.Background()332 ctx := context.Background()
329- db := setupTestDB(t)333+ dbs := setupTestDB(t)
330- userDID, _ := seedSearchData(t, ctx, db)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 assert.NilError(t, err)337 assert.NilError(t, err)
334 }338 }
335 339
336 func TestSearchArticles_OnlySpecialCharacters(t *testing.T) {340 func TestSearchArticles_OnlySpecialCharacters(t *testing.T) {
337 ctx := context.Background()341 ctx := context.Background()
338- db := setupTestDB(t)342+ dbs := setupTestDB(t)
339- userDID, _ := seedSearchData(t, ctx, db)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 assert.NilError(t, err)346 assert.NilError(t, err)
343 assert.Equal(t, len(results), 0)347 assert.Equal(t, len(results), 0)
344 }348 }
modified internal/db/batch_test.go +61 -61
@@ -10,21 +10,21 @@ import (
1010
1111 func TestBatchCreateUsers_InsertsAll(t *testing.T) {
1212 ctx := context.Background()
13- db := setupTestDB(t)
13+ dbs := setupTestDB(t)
1414
15- users := []UserData{
15+ data := []UserData{
1616 {DID: "did:test:u1", Handle: "user1", DisplayName: "User One", AvatarURL: "https://avatar1.png"},
1717 {DID: "did:test:u2", Handle: "user2", DisplayName: "User Two"},
1818 }
19- err := db.BatchCreateUsers(ctx, users)
19+ err := dbs.Users.BatchCreateUsers(ctx, data)
2020 assert.NilError(t, err)
2121
22- u1, err := db.GetUser(ctx, "did:test:u1")
22+ u1, err := dbs.Users.GetUser(ctx, "did:test:u1")
2323 assert.NilError(t, err)
2424 assert.Equal(t, u1.Handle, "user1")
2525 assert.Equal(t, u1.DisplayName.String, "User One")
2626
27- u2, err := db.GetUser(ctx, "did:test:u2")
27+ u2, err := dbs.Users.GetUser(ctx, "did:test:u2")
2828 assert.NilError(t, err)
2929 assert.Equal(t, u2.Handle, "user2")
3030 assert.Equal(t, u2.DisplayName.String, "User Two")
@@ -32,18 +32,18 @@ func TestBatchCreateUsers_InsertsAll(t *testing.T) {
3232
3333 func TestBatchCreateUsers_UpsertsExisting(t *testing.T) {
3434 ctx := context.Background()
35- db := setupTestDB(t)
35+ dbs := setupTestDB(t)
3636
37- _, err := db.CreateUser(ctx, "did:test:u1", "old-handle", "", "")
37+ _, err := dbs.Users.CreateUser(ctx, "did:test:u1", "old-handle", "", "")
3838 assert.NilError(t, err)
3939
40- users := []UserData{
40+ data := []UserData{
4141 {DID: "did:test:u1", Handle: "new-handle", DisplayName: "New Name"},
4242 }
43- err = db.BatchCreateUsers(ctx, users)
43+ err = dbs.Users.BatchCreateUsers(ctx, data)
4444 assert.NilError(t, err)
4545
46- u, err := db.GetUser(ctx, "did:test:u1")
46+ u, err := dbs.Users.GetUser(ctx, "did:test:u1")
4747 assert.NilError(t, err)
4848 assert.Equal(t, u.Handle, "new-handle")
4949 assert.Equal(t, u.DisplayName.String, "New Name")
@@ -51,217 +51,217 @@ func TestBatchCreateUsers_UpsertsExisting(t *testing.T) {
5151
5252 func TestBatchCreateUsers_Empty(t *testing.T) {
5353 ctx := context.Background()
54- db := setupTestDB(t)
54+ dbs := setupTestDB(t)
5555
56- err := db.BatchCreateUsers(ctx, nil)
56+ err := dbs.Users.BatchCreateUsers(ctx, nil)
5757 assert.NilError(t, err)
5858 }
5959
6060 func TestBatchCreateUsers_DoesNotOverwriteWithEmpty(t *testing.T) {
6161 ctx := context.Background()
62- db := setupTestDB(t)
62+ dbs := setupTestDB(t)
6363
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")
6565 assert.NilError(t, err)
6666
67- users := []UserData{
67+ data := []UserData{
6868 {DID: "did:test:u1", Handle: "", DisplayName: "", AvatarURL: ""},
6969 }
70- err = db.BatchCreateUsers(ctx, users)
70+ err = dbs.Users.BatchCreateUsers(ctx, data)
7171 assert.NilError(t, err)
7272
73- u, err := db.GetUser(ctx, "did:test:u1")
73+ u, err := dbs.Users.GetUser(ctx, "did:test:u1")
7474 assert.NilError(t, err)
7575 assert.Equal(t, u.Handle, "handle")
7676 assert.Equal(t, u.DisplayName.String, "Existing Name")
7777 assert.Equal(t, u.AvatarURL.String, "https://avatar.png")
7878 }
7979
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) {
8181 t.Helper()
8282 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")
8484 assert.NilError(t, err)
8585 return userDID
8686 }
8787
8888 func TestBatchUpsertFeeds_InsertsAll(t *testing.T) {
8989 ctx := context.Background()
90- db := setupTestDB(t)
90+ dbs := setupTestDB(t)
9191
9292 feeds := []*Feed{
9393 {FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")},
9494 {FeedURL: "https://b.com/feed.xml", Title: NullStr("Feed B")},
9595 }
96- err := db.BatchUpsertFeeds(ctx, feeds)
96+ err := dbs.Articles.BatchUpsertFeeds(ctx, feeds)
9797 assert.NilError(t, err)
9898
99- f, err := db.GetFeed(ctx, "https://a.com/feed.xml")
99+ f, err := dbs.Articles.GetFeed(ctx, "https://a.com/feed.xml")
100100 assert.NilError(t, err)
101101 assert.Equal(t, f.Title.String, "Feed A")
102102
103- f, err = db.GetFeed(ctx, "https://b.com/feed.xml")
103+ f, err = dbs.Articles.GetFeed(ctx, "https://b.com/feed.xml")
104104 assert.NilError(t, err)
105105 assert.Equal(t, f.Title.String, "Feed B")
106106 }
107107
108108 func TestBatchUpsertFeeds_UpdatesExisting(t *testing.T) {
109109 ctx := context.Background()
110- db := setupTestDB(t)
110+ dbs := setupTestDB(t)
111111
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")})
113113 assert.NilError(t, err)
114114
115115 feeds := []*Feed{
116116 {FeedURL: "https://a.com/feed.xml", Title: NullStr("New Title")},
117117 }
118- err = db.BatchUpsertFeeds(ctx, feeds)
118+ err = dbs.Articles.BatchUpsertFeeds(ctx, feeds)
119119 assert.NilError(t, err)
120120
121- f, err := db.GetFeed(ctx, "https://a.com/feed.xml")
121+ f, err := dbs.Articles.GetFeed(ctx, "https://a.com/feed.xml")
122122 assert.NilError(t, err)
123123 assert.Equal(t, f.Title.String, "New Title")
124124 }
125125
126126 func TestBatchReconcileSubscriptions_CreatesNew(t *testing.T) {
127127 ctx := context.Background()
128- database := setupTestDB(t)
129- userDID := seedSubscriptionData(t, ctx, database)
128+ dbs := setupTestDB(t)
129+ userDID := seedSubscriptionData(t, ctx, dbs)
130130
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")})
133133
134134 subs := []SubData{
135135 {FeedURL: "https://a.com/feed.xml", Title: "Feed A", URI: "at://uri1", CID: "cid1"},
136136 {FeedURL: "https://b.com/feed.xml", Title: "Feed B", URI: "at://uri2", CID: "cid2"},
137137 }
138- err := database.BatchReconcileSubscriptions(ctx, userDID, subs)
138+ err := dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs)
139139 assert.NilError(t, err)
140140
141- subs2, err := database.ListSubscriptions(ctx, userDID, "", 10, 0)
141+ subs2, err := dbs.Articles.ListSubscriptions(ctx, userDID, "", 10, 0)
142142 assert.NilError(t, err)
143143 assert.Equal(t, len(subs2), 2)
144144
145- f, err := database.GetFeed(ctx, "https://a.com/feed.xml")
145+ f, err := dbs.Articles.GetFeed(ctx, "https://a.com/feed.xml")
146146 assert.NilError(t, err)
147147 assert.Equal(t, f.SubscriberCount, 1)
148148 }
149149
150150 func TestBatchReconcileSubscriptions_BackfillsURI(t *testing.T) {
151151 ctx := context.Background()
152- database := setupTestDB(t)
153- userDID := seedSubscriptionData(t, ctx, database)
152+ dbs := setupTestDB(t)
153+ userDID := seedSubscriptionData(t, ctx, dbs)
154154
155- _ = database.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"})
155+ _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"})
156156
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", "", "", "")
158158 assert.NilError(t, err)
159159
160160 subs := []SubData{
161161 {FeedURL: "https://a.com/feed.xml", URI: "at://new-uri", CID: "new-cid"},
162162 }
163- err = database.BatchReconcileSubscriptions(ctx, userDID, subs)
163+ err = dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs)
164164 assert.NilError(t, err)
165165
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")
167167 assert.NilError(t, err)
168168 assert.Equal(t, s.URI.String, "at://new-uri")
169169
170- f, err := database.GetFeed(ctx, "https://a.com/feed.xml")
170+ f, err := dbs.Articles.GetFeed(ctx, "https://a.com/feed.xml")
171171 assert.NilError(t, err)
172172 assert.Equal(t, f.SubscriberCount, 1)
173173 }
174174
175175 func TestBatchReconcileSubscriptions_SkipsExistingWithURI(t *testing.T) {
176176 ctx := context.Background()
177- database := setupTestDB(t)
178- userDID := seedSubscriptionData(t, ctx, database)
177+ dbs := setupTestDB(t)
178+ userDID := seedSubscriptionData(t, ctx, dbs)
179179
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")
182182 assert.NilError(t, err)
183183
184184 subs := []SubData{
185185 {FeedURL: "https://a.com/feed.xml", URI: "at://different", CID: "cid2"},
186186 }
187- err = database.BatchReconcileSubscriptions(ctx, userDID, subs)
187+ err = dbs.Articles.BatchReconcileSubscriptions(ctx, userDID, subs)
188188 assert.NilError(t, err)
189189
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")
191191 assert.NilError(t, err)
192192 assert.Equal(t, s.URI.String, "at://existing")
193193 }
194194
195195 func TestBatchCreateLikes_InsertsAll(t *testing.T) {
196196 ctx := context.Background()
197- database := setupTestDB(t)
197+ dbs := setupTestDB(t)
198198
199199 now := NullTime(time.Now())
200200 likes := []*Like{
201201 {URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now, CID: NullStr("cid1")},
202202 {URI: "at://like2", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/2", CreatedAt: now, CID: NullStr("cid2")},
203203 }
204- err := database.BatchCreateLikes(ctx, likes)
204+ err := dbs.Articles.BatchCreateLikes(ctx, likes)
205205 assert.NilError(t, err)
206206
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")
208208 assert.NilError(t, err)
209209 assert.Equal(t, exists, true)
210210
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")
212212 assert.NilError(t, err)
213213 assert.Equal(t, exists, true)
214214 }
215215
216216 func TestBatchCreateLikes_IgnoresDuplicates(t *testing.T) {
217217 ctx := context.Background()
218- database := setupTestDB(t)
218+ dbs := setupTestDB(t)
219219
220220 now := NullTime(time.Now())
221221 likes := []*Like{
222222 {URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now},
223223 }
224- err := database.BatchCreateLikes(ctx, likes)
224+ err := dbs.Articles.BatchCreateLikes(ctx, likes)
225225 assert.NilError(t, err)
226226
227227 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)
229229 assert.NilError(t, err)
230230 }
231231
232232 func TestBatchCreateAnnotations_InsertsAll(t *testing.T) {
233233 ctx := context.Background()
234- database := setupTestDB(t)
234+ dbs := setupTestDB(t)
235235
236236 now := NullTime(time.Now())
237237 annotations := []*Annotation{
238238 {URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", Note: NullStr("Great"), CreatedAt: now},
239239 {URI: "at://ann2", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/2", Note: NullStr("Nice"), CreatedAt: now},
240240 }
241- err := database.BatchCreateAnnotations(ctx, annotations)
241+ err := dbs.Articles.BatchCreateAnnotations(ctx, annotations)
242242 assert.NilError(t, err)
243243
244- exists, err := database.AnnotationExists(ctx, "at://ann1")
244+ exists, err := dbs.Articles.AnnotationExists(ctx, "at://ann1")
245245 assert.NilError(t, err)
246246 assert.Equal(t, exists, true)
247247
248- exists, err = database.AnnotationExists(ctx, "at://ann2")
248+ exists, err = dbs.Articles.AnnotationExists(ctx, "at://ann2")
249249 assert.NilError(t, err)
250250 assert.Equal(t, exists, true)
251251 }
252252
253253 func TestBatchCreateAnnotations_IgnoresDuplicates(t *testing.T) {
254254 ctx := context.Background()
255- database := setupTestDB(t)
255+ dbs := setupTestDB(t)
256256
257257 now := NullTime(time.Now())
258258 annotations := []*Annotation{
259259 {URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now},
260260 }
261- err := database.BatchCreateAnnotations(ctx, annotations)
261+ err := dbs.Articles.BatchCreateAnnotations(ctx, annotations)
262262 assert.NilError(t, err)
263263
264264 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)
266266 assert.NilError(t, err)
267267 }
@@ -10,21 +10,21 @@ import (
10 10
11 func TestBatchCreateUsers_InsertsAll(t *testing.T) {11 func TestBatchCreateUsers_InsertsAll(t *testing.T) {
12 ctx := context.Background()12 ctx := context.Background()
13- db := setupTestDB(t)13+ dbs := setupTestDB(t)
14 14
15- users := []UserData{15+ data := []UserData{
16 {DID: "did:test:u1", Handle: "user1", DisplayName: "User One", AvatarURL: "https://avatar1.png"},16 {DID: "did:test:u1", Handle: "user1", DisplayName: "User One", AvatarURL: "https://avatar1.png"},
17 {DID: "did:test:u2", Handle: "user2", DisplayName: "User Two"},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 assert.NilError(t, err)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 assert.NilError(t, err)23 assert.NilError(t, err)
24 assert.Equal(t, u1.Handle, "user1")24 assert.Equal(t, u1.Handle, "user1")
25 assert.Equal(t, u1.DisplayName.String, "User One")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 assert.NilError(t, err)28 assert.NilError(t, err)
29 assert.Equal(t, u2.Handle, "user2")29 assert.Equal(t, u2.Handle, "user2")
30 assert.Equal(t, u2.DisplayName.String, "User Two")30 assert.Equal(t, u2.DisplayName.String, "User Two")
@@ -32,18 +32,18 @@ func TestBatchCreateUsers_InsertsAll(t *testing.T) {
32 32
33 func TestBatchCreateUsers_UpsertsExisting(t *testing.T) {33 func TestBatchCreateUsers_UpsertsExisting(t *testing.T) {
34 ctx := context.Background()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 assert.NilError(t, err)38 assert.NilError(t, err)
39 39
40- users := []UserData{40+ data := []UserData{
41 {DID: "did:test:u1", Handle: "new-handle", DisplayName: "New Name"},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 assert.NilError(t, err)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 assert.NilError(t, err)47 assert.NilError(t, err)
48 assert.Equal(t, u.Handle, "new-handle")48 assert.Equal(t, u.Handle, "new-handle")
49 assert.Equal(t, u.DisplayName.String, "New Name")49 assert.Equal(t, u.DisplayName.String, "New Name")
@@ -51,217 +51,217 @@ func TestBatchCreateUsers_UpsertsExisting(t *testing.T) {
51 51
52 func TestBatchCreateUsers_Empty(t *testing.T) {52 func TestBatchCreateUsers_Empty(t *testing.T) {
53 ctx := context.Background()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 assert.NilError(t, err)57 assert.NilError(t, err)
58 }58 }
59 59
60 func TestBatchCreateUsers_DoesNotOverwriteWithEmpty(t *testing.T) {60 func TestBatchCreateUsers_DoesNotOverwriteWithEmpty(t *testing.T) {
61 ctx := context.Background()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 assert.NilError(t, err)65 assert.NilError(t, err)
66 66
67- users := []UserData{67+ data := []UserData{
68 {DID: "did:test:u1", Handle: "", DisplayName: "", AvatarURL: ""},68 {DID: "did:test:u1", Handle: "", DisplayName: "", AvatarURL: ""},
69 }69 }
70- err = db.BatchCreateUsers(ctx, users)70+ err = dbs.Users.BatchCreateUsers(ctx, data)
71 assert.NilError(t, err)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 assert.NilError(t, err)74 assert.NilError(t, err)
75 assert.Equal(t, u.Handle, "handle")75 assert.Equal(t, u.Handle, "handle")
76 assert.Equal(t, u.DisplayName.String, "Existing Name")76 assert.Equal(t, u.DisplayName.String, "Existing Name")
77 assert.Equal(t, u.AvatarURL.String, "https://avatar.png")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 t.Helper()81 t.Helper()
82 userDID = "did:test:subuser"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 assert.NilError(t, err)84 assert.NilError(t, err)
85 return userDID85 return userDID
86 }86 }
87 87
88 func TestBatchUpsertFeeds_InsertsAll(t *testing.T) {88 func TestBatchUpsertFeeds_InsertsAll(t *testing.T) {
89 ctx := context.Background()89 ctx := context.Background()
90- db := setupTestDB(t)90+ dbs := setupTestDB(t)
91 91
92 feeds := []*Feed{92 feeds := []*Feed{
93 {FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")},93 {FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")},
94 {FeedURL: "https://b.com/feed.xml", Title: NullStr("Feed B")},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 assert.NilError(t, err)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 assert.NilError(t, err)100 assert.NilError(t, err)
101 assert.Equal(t, f.Title.String, "Feed A")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 assert.NilError(t, err)104 assert.NilError(t, err)
105 assert.Equal(t, f.Title.String, "Feed B")105 assert.Equal(t, f.Title.String, "Feed B")
106 }106 }
107 107
108 func TestBatchUpsertFeeds_UpdatesExisting(t *testing.T) {108 func TestBatchUpsertFeeds_UpdatesExisting(t *testing.T) {
109 ctx := context.Background()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 assert.NilError(t, err)113 assert.NilError(t, err)
114 114
115 feeds := []*Feed{115 feeds := []*Feed{
116 {FeedURL: "https://a.com/feed.xml", Title: NullStr("New Title")},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 assert.NilError(t, err)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 assert.NilError(t, err)122 assert.NilError(t, err)
123 assert.Equal(t, f.Title.String, "New Title")123 assert.Equal(t, f.Title.String, "New Title")
124 }124 }
125 125
126 func TestBatchReconcileSubscriptions_CreatesNew(t *testing.T) {126 func TestBatchReconcileSubscriptions_CreatesNew(t *testing.T) {
127 ctx := context.Background()127 ctx := context.Background()
128- database := setupTestDB(t)128+ dbs := setupTestDB(t)
129- userDID := seedSubscriptionData(t, ctx, database)129+ userDID := seedSubscriptionData(t, ctx, dbs)
130 130
131- _ = database.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml", Title: NullStr("Feed A")})131+ _ = dbs.Articles.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")})132+ _ = dbs.Articles.UpsertFeed(ctx, &Feed{FeedURL: "https://b.com/feed.xml", Title: NullStr("Feed B")})
133 133
134 subs := []SubData{134 subs := []SubData{
135 {FeedURL: "https://a.com/feed.xml", Title: "Feed A", URI: "at://uri1", CID: "cid1"},135 {FeedURL: "https://a.com/feed.xml", Title: "Feed A", URI: "at://uri1", CID: "cid1"},
136 {FeedURL: "https://b.com/feed.xml", Title: "Feed B", URI: "at://uri2", CID: "cid2"},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 assert.NilError(t, err)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 assert.NilError(t, err)142 assert.NilError(t, err)
143 assert.Equal(t, len(subs2), 2)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 assert.NilError(t, err)146 assert.NilError(t, err)
147 assert.Equal(t, f.SubscriberCount, 1)147 assert.Equal(t, f.SubscriberCount, 1)
148 }148 }
149 149
150 func TestBatchReconcileSubscriptions_BackfillsURI(t *testing.T) {150 func TestBatchReconcileSubscriptions_BackfillsURI(t *testing.T) {
151 ctx := context.Background()151 ctx := context.Background()
152- database := setupTestDB(t)152+ dbs := setupTestDB(t)
153- userDID := seedSubscriptionData(t, ctx, database)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 assert.NilError(t, err)158 assert.NilError(t, err)
159 159
160 subs := []SubData{160 subs := []SubData{
161 {FeedURL: "https://a.com/feed.xml", URI: "at://new-uri", CID: "new-cid"},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 assert.NilError(t, err)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 assert.NilError(t, err)167 assert.NilError(t, err)
168 assert.Equal(t, s.URI.String, "at://new-uri")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 assert.NilError(t, err)171 assert.NilError(t, err)
172 assert.Equal(t, f.SubscriberCount, 1)172 assert.Equal(t, f.SubscriberCount, 1)
173 }173 }
174 174
175 func TestBatchReconcileSubscriptions_SkipsExistingWithURI(t *testing.T) {175 func TestBatchReconcileSubscriptions_SkipsExistingWithURI(t *testing.T) {
176 ctx := context.Background()176 ctx := context.Background()
177- database := setupTestDB(t)177+ dbs := setupTestDB(t)
178- userDID := seedSubscriptionData(t, ctx, database)178+ userDID := seedSubscriptionData(t, ctx, dbs)
179 179
180- _ = database.UpsertFeed(ctx, &Feed{FeedURL: "https://a.com/feed.xml"})180+ _ = dbs.Articles.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")181+ err := dbs.Articles.CreateSubscription(ctx, userDID, "https://a.com/feed.xml", "Feed A", "", "at://existing", "cid")
182 assert.NilError(t, err)182 assert.NilError(t, err)
183 183
184 subs := []SubData{184 subs := []SubData{
185 {FeedURL: "https://a.com/feed.xml", URI: "at://different", CID: "cid2"},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 assert.NilError(t, err)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 assert.NilError(t, err)191 assert.NilError(t, err)
192 assert.Equal(t, s.URI.String, "at://existing")192 assert.Equal(t, s.URI.String, "at://existing")
193 }193 }
194 194
195 func TestBatchCreateLikes_InsertsAll(t *testing.T) {195 func TestBatchCreateLikes_InsertsAll(t *testing.T) {
196 ctx := context.Background()196 ctx := context.Background()
197- database := setupTestDB(t)197+ dbs := setupTestDB(t)
198 198
199 now := NullTime(time.Now())199 now := NullTime(time.Now())
200 likes := []*Like{200 likes := []*Like{
201 {URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now, CID: NullStr("cid1")},201 {URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now, CID: NullStr("cid1")},
202 {URI: "at://like2", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/2", CreatedAt: now, CID: NullStr("cid2")},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 assert.NilError(t, err)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 assert.NilError(t, err)208 assert.NilError(t, err)
209 assert.Equal(t, exists, true)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 assert.NilError(t, err)212 assert.NilError(t, err)
213 assert.Equal(t, exists, true)213 assert.Equal(t, exists, true)
214 }214 }
215 215
216 func TestBatchCreateLikes_IgnoresDuplicates(t *testing.T) {216 func TestBatchCreateLikes_IgnoresDuplicates(t *testing.T) {
217 ctx := context.Background()217 ctx := context.Background()
218- database := setupTestDB(t)218+ dbs := setupTestDB(t)
219 219
220 now := NullTime(time.Now())220 now := NullTime(time.Now())
221 likes := []*Like{221 likes := []*Like{
222 {URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now},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 assert.NilError(t, err)225 assert.NilError(t, err)
226 226
227 likes = append(likes, &Like{URI: "at://like1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now})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 assert.NilError(t, err)229 assert.NilError(t, err)
230 }230 }
231 231
232 func TestBatchCreateAnnotations_InsertsAll(t *testing.T) {232 func TestBatchCreateAnnotations_InsertsAll(t *testing.T) {
233 ctx := context.Background()233 ctx := context.Background()
234- database := setupTestDB(t)234+ dbs := setupTestDB(t)
235 235
236 now := NullTime(time.Now())236 now := NullTime(time.Now())
237 annotations := []*Annotation{237 annotations := []*Annotation{
238 {URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", Note: NullStr("Great"), CreatedAt: now},238 {URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", Note: NullStr("Great"), CreatedAt: now},
239 {URI: "at://ann2", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/2", Note: NullStr("Nice"), CreatedAt: now},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 assert.NilError(t, err)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 assert.NilError(t, err)245 assert.NilError(t, err)
246 assert.Equal(t, exists, true)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 assert.NilError(t, err)249 assert.NilError(t, err)
250 assert.Equal(t, exists, true)250 assert.Equal(t, exists, true)
251 }251 }
252 252
253 func TestBatchCreateAnnotations_IgnoresDuplicates(t *testing.T) {253 func TestBatchCreateAnnotations_IgnoresDuplicates(t *testing.T) {
254 ctx := context.Background()254 ctx := context.Background()
255- database := setupTestDB(t)255+ dbs := setupTestDB(t)
256 256
257 now := NullTime(time.Now())257 now := NullTime(time.Now())
258 annotations := []*Annotation{258 annotations := []*Annotation{
259 {URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now},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 assert.NilError(t, err)262 assert.NilError(t, err)
263 263
264 annotations = append(annotations, &Annotation{URI: "at://ann1", AuthorDID: "did:test:u1", FeedURL: "https://a.com/feed", ArticleURL: "https://a.com/1", CreatedAt: now})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 assert.NilError(t, err)266 assert.NilError(t, err)
267 }267 }
modified internal/db/db.go +0 -2
@@ -110,8 +110,6 @@ var schema = []string{
110110 subscriber_count INTEGER NOT NULL DEFAULT 0,
111111 etag TEXT,
112112 last_modified TEXT,
113- fetch_interval_minutes INTEGER NOT NULL DEFAULT 30,
114- next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
115113 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
116114 error_count INTEGER NOT NULL DEFAULT 0,
117115 favicon_url TEXT
@@ -110,8 +110,6 @@ var schema = []string{
110 subscriber_count INTEGER NOT NULL DEFAULT 0,110 subscriber_count INTEGER NOT NULL DEFAULT 0,
111 etag TEXT,111 etag TEXT,
112 last_modified TEXT,112 last_modified TEXT,
113- fetch_interval_minutes INTEGER NOT NULL DEFAULT 30,
114- next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
115 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,113 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
116 error_count INTEGER NOT NULL DEFAULT 0,114 error_count INTEGER NOT NULL DEFAULT 0,
117 favicon_url TEXT115 favicon_url TEXT
modified internal/db/feed.go +89 -91
@@ -21,8 +21,6 @@ type Feed struct {
2121 SubscriberCount int
2222 Etag sql.NullString
2323 LastModified sql.NullString
24- FetchIntervalMinutes int
25- NextFetchAt sql.NullTime
2624 ConsecutiveEmptyFetches int
2725 ErrorCount int
2826 FaviconURL sql.NullString
@@ -41,33 +39,33 @@ type Subscription struct {
4139 FaviconURL sql.NullString
4240 }
4341
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})
4644 }
4745
48-func (db *DB) GetFeed(ctx context.Context, feedURL string) (*Feed, error) {
46+func (s *ArticleStore) GetFeed(ctx context.Context, feedURL string) (*Feed, error) {
4947 f := &Feed{}
50- err := db.QueryRowContext(ctx, `
48+ err := s.db.QueryRowContext(ctx, `
5149 SELECT feed_url, title, site_url, description, feed_type,
5250 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 = ?
5553 `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
5654 &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)
5856 if err != nil {
5957 return nil, err
6058 }
6159 return f, nil
6260 }
6361
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) {
6563 cutoff := time.Now().Add(-olderThan)
66- rows, err := db.QueryContext(ctx, `
64+ rows, err := s.db.QueryContext(ctx, `
6765 SELECT feed_url, title, site_url, description, feed_type,
6866 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
7169 WHERE subscriber_count > 0 AND error_count < 25 AND (last_fetched_at IS NULL OR last_fetched_at <= ?)
7270 ORDER BY last_fetched_at ASC NULLS FIRST
7371 LIMIT ?
@@ -82,7 +80,7 @@ func (db *DB) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limi
8280 f := &Feed{}
8381 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
8482 &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 {
8684 return nil, err
8785 }
8886 feeds = append(feeds, f)
@@ -90,9 +88,9 @@ func (db *DB) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limi
9088 return feeds, rows.Err()
9189 }
9290
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
9694 etag = ?,
9795 last_modified = ?,
9896 error_count = 0,
@@ -103,9 +101,9 @@ func (db *DB) MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified s
103101 return err
104102 }
105103
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
109107 error_count = error_count + 1,
110108 last_error = ?,
111109 last_fetched_at = CURRENT_TIMESTAMP
@@ -114,27 +112,27 @@ func (db *DB) MarkFeedFetchError(ctx context.Context, feedURL, lastError string)
114112 return err
115113 }
116114
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 = ?
120118 `, feedURL)
121119 return err
122120 }
123121
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)
126124 if err == nil && existing != nil {
127125 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)
129127 }
130128 return ErrDuplicateSubscription
131129 }
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}})
133131 }
134132
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 = ?
138136 `, uri, cid, userDID, feedURL)
139137 return err
140138 }
@@ -153,24 +151,24 @@ func nilIfEmpty(v string) any {
153151 return v
154152 }
155153
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 = ?
159157 `, userDID, feedURL)
160158 if err != nil {
161159 return err
162160 }
163- return db.decrementSubscriberCount(ctx, feedURL)
161+ return s.decrementSubscriberCount(ctx, feedURL)
164162 }
165163
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)
168166 if err != nil {
169167 return err
170168 }
171169 defer tx.Rollback()
172170
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)
174172 if err != nil {
175173 return err
176174 }
@@ -185,7 +183,7 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error
185183 }
186184 rows.Close()
187185
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)
189187 if err != nil {
190188 return err
191189 }
@@ -198,7 +196,7 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error
198196 args[i] = u
199197 }
200198 _, 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)
202200 WHERE feed_url IN (`+strings.Join(ph, ",")+`)
203201 `, args...)
204202 if err != nil {
@@ -209,41 +207,41 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error
209207 return tx.Commit()
210208 }
211209
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, `
215213 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
216214 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
219217 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)
221219 if err != nil {
222220 return nil, err
223221 }
224- return s, nil
222+ return sub, nil
225223 }
226224
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, `
230228 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
231229 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
234232 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)
236234 if err != nil {
237235 return nil, err
238236 }
239- return s, nil
237+ return sub, nil
240238 }
241239
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) {
243241 query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
244242 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
247245 WHERE s.user_did = ?`
248246 args := []any{userDID}
249247
@@ -255,7 +253,7 @@ func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, l
255253 query += ` ORDER BY s.added_at DESC LIMIT ? OFFSET ?`
256254 args = append(args, limit, offset)
257255
258- rows, err := db.QueryContext(ctx, query, args...)
256+ rows, err := s.db.QueryContext(ctx, query, args...)
259257 if err != nil {
260258 return nil, err
261259 }
@@ -272,17 +270,17 @@ func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, l
272270 return subs, rows.Err()
273271 }
274272
275-func (db *DB) GetSubscriptionCount(ctx context.Context, userDID string) (int, error) {
273+func (s *ArticleStore) GetSubscriptionCount(ctx context.Context, userDID string) (int, error) {
276274 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 = ?
279277 `, userDID).Scan(&count)
280278 return count, err
281279 }
282280
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
286284 WHERE user_did = ? AND category IS NOT NULL AND category != ''
287285 ORDER BY category
288286 `, userDID)
@@ -302,18 +300,18 @@ func (db *DB) GetCategories(ctx context.Context, userDID string) ([]string, erro
302300 return categories, rows.Err()
303301 }
304302
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)
307305 return err
308306 }
309307
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, `
312310 SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,
313311 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 = ?
317315 WHERE f.error_count >= ?
318316 ORDER BY f.error_count DESC
319317 `, userDID, threshold)
@@ -327,7 +325,7 @@ func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int)
327325 f := &Feed{}
328326 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
329327 &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 {
331329 return nil, err
332330 }
333331 feeds = append(feeds, f)
@@ -335,12 +333,12 @@ func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int)
335333 return feeds, rows.Err()
336334 }
337335
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, `
340338 SELECT feed_url, title, site_url, description, feed_type,
341339 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
344342 ORDER BY subscriber_count DESC
345343 LIMIT ? OFFSET ?
346344 `, limit, offset)
@@ -354,7 +352,7 @@ func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, err
354352 f := &Feed{}
355353 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
356354 &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 {
358356 return nil, err
359357 }
360358 feeds = append(feeds, f)
@@ -370,18 +368,18 @@ type SubData struct {
370368 CID string
371369 }
372370
373-func (db *DB) BatchUpsertFeeds(ctx context.Context, feeds []*Feed) error {
371+func (s *ArticleStore) BatchUpsertFeeds(ctx context.Context, feeds []*Feed) error {
374372 if len(feeds) == 0 {
375373 return nil
376374 }
377- tx, err := db.BeginTx(ctx, nil)
375+ tx, err := s.db.BeginTx(ctx, nil)
378376 if err != nil {
379377 return err
380378 }
381379 defer tx.Rollback()
382380
383381 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)
385383 VALUES (?, ?, ?, ?, ?)
386384 ON CONFLICT(feed_url) DO UPDATE SET
387385 title = excluded.title,
@@ -402,17 +400,17 @@ func (db *DB) BatchUpsertFeeds(ctx context.Context, feeds []*Feed) error {
402400 return tx.Commit()
403401 }
404402
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 {
406404 if len(subs) == 0 {
407405 return nil
408406 }
409- tx, err := db.BeginTx(ctx, nil)
407+ tx, err := s.db.BeginTx(ctx, nil)
410408 if err != nil {
411409 return err
412410 }
413411 defer tx.Rollback()
414412
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)
416414 if err != nil {
417415 return err
418416 }
@@ -428,7 +426,7 @@ func (db *DB) BatchReconcileSubscriptions(ctx context.Context, userDID string, s
428426 rows.Close()
429427
430428 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)
432430 VALUES (?, ?, ?, ?, ?, ?)
433431 `)
434432 if err != nil {
@@ -437,14 +435,14 @@ func (db *DB) BatchReconcileSubscriptions(ctx context.Context, userDID string, s
437435 defer insertStmt.Close()
438436
439437 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 = ?
441439 `)
442440 if err != nil {
443441 return err
444442 }
445443 defer updateStmt.Close()
446444
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 = ?`)
448446 if err != nil {
449447 return err
450448 }
@@ -473,13 +471,13 @@ func (db *DB) BatchReconcileSubscriptions(ctx context.Context, userDID string, s
473471 return tx.Commit()
474472 }
475473
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, `
478476 SELECT feed_url, title, site_url, description, feed_type,
479477 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 = ?)
483481 ORDER BY subscriber_count DESC
484482 LIMIT ? OFFSET ?
485483 `, userDID, limit, offset)
@@ -493,7 +491,7 @@ func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit,
493491 f := &Feed{}
494492 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
495493 &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 {
497495 return nil, err
498496 }
499497 feeds = append(feeds, f)
@@ -21,8 +21,6 @@ type Feed struct {
21 SubscriberCount int21 SubscriberCount int
22 Etag sql.NullString22 Etag sql.NullString
23 LastModified sql.NullString23 LastModified sql.NullString
24- FetchIntervalMinutes int
25- NextFetchAt sql.NullTime
26 ConsecutiveEmptyFetches int24 ConsecutiveEmptyFetches int
27 ErrorCount int25 ErrorCount int
28 FaviconURL sql.NullString26 FaviconURL sql.NullString
@@ -41,33 +39,33 @@ type Subscription struct {
41 FaviconURL sql.NullString39 FaviconURL sql.NullString
42 }40 }
43 41
44-func (db *DB) UpsertFeed(ctx context.Context, feed *Feed) error {42+func (s *ArticleStore) UpsertFeed(ctx context.Context, feed *Feed) error {
45- return db.BatchUpsertFeeds(ctx, []*Feed{feed})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 f := &Feed{}47 f := &Feed{}
50- err := db.QueryRowContext(ctx, `48+ err := s.db.QueryRowContext(ctx, `
51 SELECT feed_url, title, site_url, description, feed_type,49 SELECT feed_url, title, site_url, description, feed_type,
52 last_fetched_at, last_error, subscriber_count, etag, last_modified,50 last_fetched_at, last_error, subscriber_count, etag, last_modified,
53- fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url51+ consecutive_empty_fetches, error_count, favicon_url
54- FROM feeds WHERE feed_url = ?52+ FROM articles.feeds WHERE feed_url = ?
55 `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,53 `, feedURL).Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
56 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,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 if err != nil {56 if err != nil {
59 return nil, err57 return nil, err
60 }58 }
61 return f, nil59 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 cutoff := time.Now().Add(-olderThan)63 cutoff := time.Now().Add(-olderThan)
66- rows, err := db.QueryContext(ctx, `64+ rows, err := s.db.QueryContext(ctx, `
67 SELECT feed_url, title, site_url, description, feed_type,65 SELECT feed_url, title, site_url, description, feed_type,
68 last_fetched_at, last_error, subscriber_count, etag, last_modified,66 last_fetched_at, last_error, subscriber_count, etag, last_modified,
69- fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url67+ consecutive_empty_fetches, error_count, favicon_url
70- FROM feeds68+ FROM articles.feeds
71 WHERE subscriber_count > 0 AND error_count < 25 AND (last_fetched_at IS NULL OR last_fetched_at <= ?)69 WHERE subscriber_count > 0 AND error_count < 25 AND (last_fetched_at IS NULL OR last_fetched_at <= ?)
72 ORDER BY last_fetched_at ASC NULLS FIRST70 ORDER BY last_fetched_at ASC NULLS FIRST
73 LIMIT ?71 LIMIT ?
@@ -82,7 +80,7 @@ func (db *DB) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limi
82 f := &Feed{}80 f := &Feed{}
83 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,81 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
84 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,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 return nil, err84 return nil, err
87 }85 }
88 feeds = append(feeds, f)86 feeds = append(feeds, f)
@@ -90,9 +88,9 @@ func (db *DB) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limi
90 return feeds, rows.Err()88 return feeds, rows.Err()
91 }89 }
92 90
93-func (db *DB) MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error {91+func (s *ArticleStore) MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified string) error {
94- _, err := db.ExecContext(ctx, `92+ _, err := s.db.ExecContext(ctx, `
95- UPDATE feeds SET93+ UPDATE articles.feeds SET
96 etag = ?,94 etag = ?,
97 last_modified = ?,95 last_modified = ?,
98 error_count = 0,96 error_count = 0,
@@ -103,9 +101,9 @@ func (db *DB) MarkFeedFetched(ctx context.Context, feedURL, etag, lastModified s
103 return err101 return err
104 }102 }
105 103
106-func (db *DB) MarkFeedFetchError(ctx context.Context, feedURL, lastError string) error {104+func (s *ArticleStore) MarkFeedFetchError(ctx context.Context, feedURL, lastError string) error {
107- _, err := db.ExecContext(ctx, `105+ _, err := s.db.ExecContext(ctx, `
108- UPDATE feeds SET106+ UPDATE articles.feeds SET
109 error_count = error_count + 1,107 error_count = error_count + 1,
110 last_error = ?,108 last_error = ?,
111 last_fetched_at = CURRENT_TIMESTAMP109 last_fetched_at = CURRENT_TIMESTAMP
@@ -114,27 +112,27 @@ func (db *DB) MarkFeedFetchError(ctx context.Context, feedURL, lastError string)
114 return err112 return err
115 }113 }
116 114
117-func (db *DB) decrementSubscriberCount(ctx context.Context, feedURL string) error {115+func (s *ArticleStore) decrementSubscriberCount(ctx context.Context, feedURL string) error {
118- _, err := db.ExecContext(ctx, `116+ _, err := s.db.ExecContext(ctx, `
119- UPDATE feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?117+ UPDATE articles.feeds SET subscriber_count = MAX(subscriber_count - 1, 0) WHERE feed_url = ?
120 `, feedURL)118 `, feedURL)
121 return err119 return err
122 }120 }
123 121
124-func (db *DB) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error {122+func (s *ArticleStore) CreateSubscription(ctx context.Context, userDID, feedURL, title, category, uri, cid string) error {
125- existing, err := db.GetSubscription(ctx, userDID, feedURL)123+ existing, err := s.GetSubscription(ctx, userDID, feedURL)
126 if err == nil && existing != nil {124 if err == nil && existing != nil {
127 if !existing.URI.Valid || existing.URI.String == "" {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 return ErrDuplicateSubscription128 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 {133+func (s *ArticleStore) updateSubscriptionURI(ctx context.Context, userDID, feedURL, uri, cid string) error {
136- _, err := db.ExecContext(ctx, `134+ _, err := s.db.ExecContext(ctx, `
137- UPDATE subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?135+ UPDATE articles.subscriptions SET uri = ?, cid = ? WHERE user_did = ? AND feed_url = ?
138 `, uri, cid, userDID, feedURL)136 `, uri, cid, userDID, feedURL)
139 return err137 return err
140 }138 }
@@ -153,24 +151,24 @@ func nilIfEmpty(v string) any {
153 return v151 return v
154 }152 }
155 153
156-func (db *DB) DeleteSubscription(ctx context.Context, userDID, feedURL string) error {154+func (s *ArticleStore) DeleteSubscription(ctx context.Context, userDID, feedURL string) error {
157- _, err := db.ExecContext(ctx, `155+ _, err := s.db.ExecContext(ctx, `
158- DELETE FROM subscriptions WHERE user_did = ? AND feed_url = ?156+ DELETE FROM articles.subscriptions WHERE user_did = ? AND feed_url = ?
159 `, userDID, feedURL)157 `, userDID, feedURL)
160 if err != nil {158 if err != nil {
161 return err159 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 {164+func (s *ArticleStore) DeleteAllSubscriptions(ctx context.Context, userDID string) error {
167- tx, err := db.BeginTx(ctx, nil)165+ tx, err := s.db.BeginTx(ctx, nil)
168 if err != nil {166 if err != nil {
169 return err167 return err
170 }168 }
171 defer tx.Rollback()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 if err != nil {172 if err != nil {
175 return err173 return err
176 }174 }
@@ -185,7 +183,7 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error
185 }183 }
186 rows.Close()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 if err != nil {187 if err != nil {
190 return err188 return err
191 }189 }
@@ -198,7 +196,7 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error
198 args[i] = u196 args[i] = u
199 }197 }
200 _, err = tx.ExecContext(ctx, `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 WHERE feed_url IN (`+strings.Join(ph, ",")+`)200 WHERE feed_url IN (`+strings.Join(ph, ",")+`)
203 `, args...)201 `, args...)
204 if err != nil {202 if err != nil {
@@ -209,41 +207,41 @@ func (db *DB) DeleteAllSubscriptions(ctx context.Context, userDID string) error
209 return tx.Commit()207 return tx.Commit()
210 }208 }
211 209
212-func (db *DB) GetSubscriptionByURI(ctx context.Context, userDID, uri string) (*Subscription, error) {210+func (s *ArticleStore) GetSubscriptionByURI(ctx context.Context, userDID, uri string) (*Subscription, error) {
213- s := &Subscription{}211+ sub := &Subscription{}
214- err := db.QueryRowContext(ctx, `212+ err := s.db.QueryRowContext(ctx, `
215 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,213 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
216 s.uri, s.cid214 s.uri, s.cid
217- FROM subscriptions s215+ FROM articles.subscriptions s
218- LEFT JOIN feeds f ON s.feed_url = f.feed_url216+ LEFT JOIN articles.feeds f ON s.feed_url = f.feed_url
219 WHERE s.user_did = ? AND s.uri = ?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 if err != nil {219 if err != nil {
222 return nil, err220 return nil, err
223 }221 }
224- return s, nil222+ return sub, nil
225 }223 }
226 224
227-func (db *DB) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) {225+func (s *ArticleStore) GetSubscription(ctx context.Context, userDID, feedURL string) (*Subscription, error) {
228- s := &Subscription{}226+ sub := &Subscription{}
229- err := db.QueryRowContext(ctx, `227+ err := s.db.QueryRowContext(ctx, `
230 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,228 SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
231 s.uri, s.cid229 s.uri, s.cid
232- FROM subscriptions s230+ FROM articles.subscriptions s
233- LEFT JOIN feeds f ON s.feed_url = f.feed_url231+ LEFT JOIN articles.feeds f ON s.feed_url = f.feed_url
234 WHERE s.user_did = ? AND s.feed_url = ?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 if err != nil {234 if err != nil {
237 return nil, err235 return nil, err
238 }236 }
239- return s, nil237+ 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 query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,241 query := `SELECT s.id, s.user_did, s.feed_url, COALESCE(s.title, f.title, ''), s.category, s.added_at,
244 s.uri, s.cid, f.favicon_url242 s.uri, s.cid, f.favicon_url
245- FROM subscriptions s243+ FROM articles.subscriptions s
246- LEFT JOIN feeds f ON s.feed_url = f.feed_url244+ LEFT JOIN articles.feeds f ON s.feed_url = f.feed_url
247 WHERE s.user_did = ?`245 WHERE s.user_did = ?`
248 args := []any{userDID}246 args := []any{userDID}
249 247
@@ -255,7 +253,7 @@ func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, l
255 query += ` ORDER BY s.added_at DESC LIMIT ? OFFSET ?`253 query += ` ORDER BY s.added_at DESC LIMIT ? OFFSET ?`
256 args = append(args, limit, offset)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 if err != nil {257 if err != nil {
260 return nil, err258 return nil, err
261 }259 }
@@ -272,17 +270,17 @@ func (db *DB) ListSubscriptions(ctx context.Context, userDID, category string, l
272 return subs, rows.Err()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 var count int274 var count int
277- err := db.QueryRowContext(ctx, `275+ err := s.db.QueryRowContext(ctx, `
278- SELECT COUNT(*) FROM subscriptions WHERE user_did = ?276+ SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?
279 `, userDID).Scan(&count)277 `, userDID).Scan(&count)
280 return count, err278 return count, err
281 }279 }
282 280
283-func (db *DB) GetCategories(ctx context.Context, userDID string) ([]string, error) {281+func (s *ArticleStore) GetCategories(ctx context.Context, userDID string) ([]string, error) {
284- rows, err := db.QueryContext(ctx, `282+ rows, err := s.db.QueryContext(ctx, `
285- SELECT DISTINCT category FROM subscriptions283+ SELECT DISTINCT category FROM articles.subscriptions
286 WHERE user_did = ? AND category IS NOT NULL AND category != ''284 WHERE user_did = ? AND category IS NOT NULL AND category != ''
287 ORDER BY category285 ORDER BY category
288 `, userDID)286 `, userDID)
@@ -302,18 +300,18 @@ func (db *DB) GetCategories(ctx context.Context, userDID string) ([]string, erro
302 return categories, rows.Err()300 return categories, rows.Err()
303 }301 }
304 302
305-func (db *DB) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error {303+func (s *ArticleStore) UpdateFeedFavicon(ctx context.Context, feedURL, faviconURL string) error {
306- _, err := db.ExecContext(ctx, `UPDATE feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL)304+ _, err := s.db.ExecContext(ctx, `UPDATE articles.feeds SET favicon_url = ? WHERE feed_url = ?`, faviconURL, feedURL)
307 return err305 return err
308 }306 }
309 307
310-func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int) ([]*Feed, error) {308+func (s *ArticleStore) ListDeadFeeds(ctx context.Context, userDID string, threshold int) ([]*Feed, error) {
311- rows, err := db.QueryContext(ctx, `309+ rows, err := s.db.QueryContext(ctx, `
312 SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,310 SELECT f.feed_url, f.title, f.site_url, f.description, f.feed_type,
313 f.last_fetched_at, f.last_error, f.subscriber_count, f.etag, f.last_modified,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_url312+ f.consecutive_empty_fetches, f.error_count, f.favicon_url
315- FROM feeds f313+ FROM articles.feeds f
316- JOIN subscriptions s ON s.feed_url = f.feed_url AND s.user_did = ?314+ JOIN articles.subscriptions s ON s.feed_url = f.feed_url AND s.user_did = ?
317 WHERE f.error_count >= ?315 WHERE f.error_count >= ?
318 ORDER BY f.error_count DESC316 ORDER BY f.error_count DESC
319 `, userDID, threshold)317 `, userDID, threshold)
@@ -327,7 +325,7 @@ func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int)
327 f := &Feed{}325 f := &Feed{}
328 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,326 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
329 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,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 return nil, err329 return nil, err
332 }330 }
333 feeds = append(feeds, f)331 feeds = append(feeds, f)
@@ -335,12 +333,12 @@ func (db *DB) ListDeadFeeds(ctx context.Context, userDID string, threshold int)
335 return feeds, rows.Err()333 return feeds, rows.Err()
336 }334 }
337 335
338-func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {336+func (s *ArticleStore) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, error) {
339- rows, err := db.QueryContext(ctx, `337+ rows, err := s.db.QueryContext(ctx, `
340 SELECT feed_url, title, site_url, description, feed_type,338 SELECT feed_url, title, site_url, description, feed_type,
341 last_fetched_at, last_error, subscriber_count, etag, last_modified,339 last_fetched_at, last_error, subscriber_count, etag, last_modified,
342- fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url340+ consecutive_empty_fetches, error_count, favicon_url
343- FROM feeds341+ FROM articles.feeds
344 ORDER BY subscriber_count DESC342 ORDER BY subscriber_count DESC
345 LIMIT ? OFFSET ?343 LIMIT ? OFFSET ?
346 `, limit, offset)344 `, limit, offset)
@@ -354,7 +352,7 @@ func (db *DB) ListAllFeeds(ctx context.Context, limit, offset int) ([]*Feed, err
354 f := &Feed{}352 f := &Feed{}
355 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,353 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
356 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,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 return nil, err356 return nil, err
359 }357 }
360 feeds = append(feeds, f)358 feeds = append(feeds, f)
@@ -370,18 +368,18 @@ type SubData struct {
370 CID string368 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 if len(feeds) == 0 {372 if len(feeds) == 0 {
375 return nil373 return nil
376 }374 }
377- tx, err := db.BeginTx(ctx, nil)375+ tx, err := s.db.BeginTx(ctx, nil)
378 if err != nil {376 if err != nil {
379 return err377 return err
380 }378 }
381 defer tx.Rollback()379 defer tx.Rollback()
382 380
383 stmt, err := tx.PrepareContext(ctx, `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 VALUES (?, ?, ?, ?, ?)383 VALUES (?, ?, ?, ?, ?)
386 ON CONFLICT(feed_url) DO UPDATE SET384 ON CONFLICT(feed_url) DO UPDATE SET
387 title = excluded.title,385 title = excluded.title,
@@ -402,17 +400,17 @@ func (db *DB) BatchUpsertFeeds(ctx context.Context, feeds []*Feed) error {
402 return tx.Commit()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 if len(subs) == 0 {404 if len(subs) == 0 {
407 return nil405 return nil
408 }406 }
409- tx, err := db.BeginTx(ctx, nil)407+ tx, err := s.db.BeginTx(ctx, nil)
410 if err != nil {408 if err != nil {
411 return err409 return err
412 }410 }
413 defer tx.Rollback()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 if err != nil {414 if err != nil {
417 return err415 return err
418 }416 }
@@ -428,7 +426,7 @@ func (db *DB) BatchReconcileSubscriptions(ctx context.Context, userDID string, s
428 rows.Close()426 rows.Close()
429 427
430 insertStmt, err := tx.PrepareContext(ctx, `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 VALUES (?, ?, ?, ?, ?, ?)430 VALUES (?, ?, ?, ?, ?, ?)
433 `)431 `)
434 if err != nil {432 if err != nil {
@@ -437,14 +435,14 @@ func (db *DB) BatchReconcileSubscriptions(ctx context.Context, userDID string, s
437 defer insertStmt.Close()435 defer insertStmt.Close()
438 436
439 updateStmt, err := tx.PrepareContext(ctx, `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 if err != nil {440 if err != nil {
443 return err441 return err
444 }442 }
445 defer updateStmt.Close()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 if err != nil {446 if err != nil {
449 return err447 return err
450 }448 }
@@ -473,13 +471,13 @@ func (db *DB) BatchReconcileSubscriptions(ctx context.Context, userDID string, s
473 return tx.Commit()471 return tx.Commit()
474 }472 }
475 473
476-func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) {474+func (s *ArticleStore) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit, offset int) ([]*Feed, error) {
477- rows, err := db.QueryContext(ctx, `475+ rows, err := s.db.QueryContext(ctx, `
478 SELECT feed_url, title, site_url, description, feed_type,476 SELECT feed_url, title, site_url, description, feed_type,
479 last_fetched_at, last_error, subscriber_count, etag, last_modified,477 last_fetched_at, last_error, subscriber_count, etag, last_modified,
480- fetch_interval_minutes, next_fetch_at, consecutive_empty_fetches, error_count, favicon_url478+ consecutive_empty_fetches, error_count, favicon_url
481- FROM feeds479+ FROM articles.feeds
482- WHERE feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)480+ WHERE feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
483 ORDER BY subscriber_count DESC481 ORDER BY subscriber_count DESC
484 LIMIT ? OFFSET ?482 LIMIT ? OFFSET ?
485 `, userDID, limit, offset)483 `, userDID, limit, offset)
@@ -493,7 +491,7 @@ func (db *DB) ListUnsubscribedFeeds(ctx context.Context, userDID string, limit,
493 f := &Feed{}491 f := &Feed{}
494 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,492 if err := rows.Scan(&f.FeedURL, &f.Title, &f.SiteURL, &f.Description, &f.FeedType,
495 &f.LastFetchedAt, &f.LastError, &f.SubscriberCount, &f.Etag, &f.LastModified,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 return nil, err495 return nil, err
498 }496 }
499 feeds = append(feeds, f)497 feeds = append(feeds, f)
modified internal/db/follow.go +16 -16
@@ -14,8 +14,8 @@ type Follow struct {
1414 FollowedAt sql.NullTime
1515 }
1616
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, `
1919 INSERT INTO follows (user_did, target_did, uri, cid, followed_at)
2020 VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
2121 ON CONFLICT(user_did, target_did) DO UPDATE SET
@@ -25,18 +25,18 @@ func (db *DB) UpsertFollow(ctx context.Context, userDID, targetDID, uri, cid str
2525 return err
2626 }
2727
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)
3030 return err
3131 }
3232
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)
3535 return err
3636 }
3737
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, `
4040 SELECT user_did, target_did, uri, cid, followed_at
4141 FROM follows WHERE user_did = ?
4242 ORDER BY followed_at DESC
@@ -58,8 +58,8 @@ func (db *DB) ListFollows(ctx context.Context, userDID string, limit, offset int
5858 return follows, rows.Err()
5959 }
6060
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, `
6363 SELECT user_did, target_did, uri, cid, followed_at
6464 FROM follows WHERE target_did = ?
6565 ORDER BY followed_at DESC
@@ -81,9 +81,9 @@ func (db *DB) ListFollowers(ctx context.Context, targetDID string, limit, offset
8181 return follows, rows.Err()
8282 }
8383
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) {
8585 var exists int
86- err := db.QueryRowContext(ctx, `
86+ err := s.db.QueryRowContext(ctx, `
8787 SELECT 1 FROM follows WHERE user_did = ? AND target_did = ?
8888 `, userDID, targetDID).Scan(&exists)
8989 if err == sql.ErrNoRows {
@@ -95,8 +95,8 @@ func (db *DB) IsFollowing(ctx context.Context, userDID, targetDID string) (bool,
9595 return true, nil
9696 }
9797
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, `
100100 SELECT target_did FROM follows WHERE user_did = ?
101101 `, userDID)
102102 if err != nil {
@@ -115,8 +115,8 @@ func (db *DB) GetFollowDIDs(ctx context.Context, userDID string) ([]string, erro
115115 return dids, rows.Err()
116116 }
117117
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)
120120 if err != nil {
121121 return err
122122 }
@@ -14,8 +14,8 @@ type Follow struct {
14 FollowedAt sql.NullTime14 FollowedAt sql.NullTime
15 }15 }
16 16
17-func (db *DB) UpsertFollow(ctx context.Context, userDID, targetDID, uri, cid string) error {17+func (s *UserStore) UpsertFollow(ctx context.Context, userDID, targetDID, uri, cid string) error {
18- _, err := db.ExecContext(ctx, `18+ _, err := s.db.ExecContext(ctx, `
19 INSERT INTO follows (user_did, target_did, uri, cid, followed_at)19 INSERT INTO follows (user_did, target_did, uri, cid, followed_at)
20 VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)20 VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP)
21 ON CONFLICT(user_did, target_did) DO UPDATE SET21 ON CONFLICT(user_did, target_did) DO UPDATE SET
@@ -25,18 +25,18 @@ func (db *DB) UpsertFollow(ctx context.Context, userDID, targetDID, uri, cid str
25 return err25 return err
26 }26 }
27 27
28-func (db *DB) DeleteFollow(ctx context.Context, userDID, targetDID string) error {28+func (s *UserStore) DeleteFollow(ctx context.Context, userDID, targetDID string) error {
29- _, err := db.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID)29+ _, err := s.db.ExecContext(ctx, `DELETE FROM follows WHERE user_did = ? AND target_did = ?`, userDID, targetDID)
30 return err30 return err
31 }31 }
32 32
33-func (db *DB) DeleteFollowByURI(ctx context.Context, uri string) error {33+func (s *UserStore) DeleteFollowByURI(ctx context.Context, uri string) error {
34- _, err := db.ExecContext(ctx, `DELETE FROM follows WHERE uri = ?`, uri)34+ _, err := s.db.ExecContext(ctx, `DELETE FROM follows WHERE uri = ?`, uri)
35 return err35 return err
36 }36 }
37 37
38-func (db *DB) ListFollows(ctx context.Context, userDID string, limit, offset int) ([]*Follow, error) {38+func (s *UserStore) ListFollows(ctx context.Context, userDID string, limit, offset int) ([]*Follow, error) {
39- rows, err := db.QueryContext(ctx, `39+ rows, err := s.db.QueryContext(ctx, `
40 SELECT user_did, target_did, uri, cid, followed_at40 SELECT user_did, target_did, uri, cid, followed_at
41 FROM follows WHERE user_did = ?41 FROM follows WHERE user_did = ?
42 ORDER BY followed_at DESC42 ORDER BY followed_at DESC
@@ -58,8 +58,8 @@ func (db *DB) ListFollows(ctx context.Context, userDID string, limit, offset int
58 return follows, rows.Err()58 return follows, rows.Err()
59 }59 }
60 60
61-func (db *DB) ListFollowers(ctx context.Context, targetDID string, limit, offset int) ([]*Follow, error) {61+func (s *UserStore) ListFollowers(ctx context.Context, targetDID string, limit, offset int) ([]*Follow, error) {
62- rows, err := db.QueryContext(ctx, `62+ rows, err := s.db.QueryContext(ctx, `
63 SELECT user_did, target_did, uri, cid, followed_at63 SELECT user_did, target_did, uri, cid, followed_at
64 FROM follows WHERE target_did = ?64 FROM follows WHERE target_did = ?
65 ORDER BY followed_at DESC65 ORDER BY followed_at DESC
@@ -81,9 +81,9 @@ func (db *DB) ListFollowers(ctx context.Context, targetDID string, limit, offset
81 return follows, rows.Err()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 var exists int85 var exists int
86- err := db.QueryRowContext(ctx, `86+ err := s.db.QueryRowContext(ctx, `
87 SELECT 1 FROM follows WHERE user_did = ? AND target_did = ?87 SELECT 1 FROM follows WHERE user_did = ? AND target_did = ?
88 `, userDID, targetDID).Scan(&exists)88 `, userDID, targetDID).Scan(&exists)
89 if err == sql.ErrNoRows {89 if err == sql.ErrNoRows {
@@ -95,8 +95,8 @@ func (db *DB) IsFollowing(ctx context.Context, userDID, targetDID string) (bool,
95 return true, nil95 return true, nil
96 }96 }
97 97
98-func (db *DB) GetFollowDIDs(ctx context.Context, userDID string) ([]string, error) {98+func (s *UserStore) GetFollowDIDs(ctx context.Context, userDID string) ([]string, error) {
99- rows, err := db.QueryContext(ctx, `99+ rows, err := s.db.QueryContext(ctx, `
100 SELECT target_did FROM follows WHERE user_did = ?100 SELECT target_did FROM follows WHERE user_did = ?
101 `, userDID)101 `, userDID)
102 if err != nil {102 if err != nil {
@@ -115,8 +115,8 @@ func (db *DB) GetFollowDIDs(ctx context.Context, userDID string) ([]string, erro
115 return dids, rows.Err()115 return dids, rows.Err()
116 }116 }
117 117
118-func (db *DB) SyncFollows(ctx context.Context, userDID string, activeFollows map[string]Follow) error {118+func (s *UserStore) SyncFollows(ctx context.Context, userDID string, activeFollows map[string]Follow) error {
119- tx, err := db.BeginTx(ctx, nil)119+ tx, err := s.db.BeginTx(ctx, nil)
120 if err != nil {120 if err != nil {
121 return err121 return err
122 }122 }
modified internal/db/follow_test.go +51 -51
@@ -7,15 +7,15 @@ import (
77 "gotest.tools/v3/assert"
88 )
99
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) {
1111 t.Helper()
1212
1313 userDID = "did:test:follower"
1414 targetDID = "did:test:followed"
1515
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")
1717 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")
1919 assert.NilError(t, err)
2020
2121 return userDID, targetDID
@@ -23,123 +23,123 @@ func seedFollowData(t *testing.T, ctx context.Context, db *DB) (userDID, targetD
2323
2424 func TestUpsertFollow(t *testing.T) {
2525 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)
2828
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")
3030 assert.NilError(t, err)
3131
32- following, err := db.IsFollowing(ctx, userDID, targetDID)
32+ following, err := dbs.Users.IsFollowing(ctx, userDID, targetDID)
3333 assert.NilError(t, err)
3434 assert.Equal(t, following, true)
3535 }
3636
3737 func TestIsFollowing_NotFollowing(t *testing.T) {
3838 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)
4141
42- following, err := db.IsFollowing(ctx, userDID, targetDID)
42+ following, err := dbs.Users.IsFollowing(ctx, userDID, targetDID)
4343 assert.NilError(t, err)
4444 assert.Equal(t, following, false)
4545 }
4646
4747 func TestDeleteFollow(t *testing.T) {
4848 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)
5151
52- err := db.UpsertFollow(ctx, userDID, targetDID, "at://uri", "cid")
52+ err := dbs.Users.UpsertFollow(ctx, userDID, targetDID, "at://uri", "cid")
5353 assert.NilError(t, err)
5454
55- err = db.DeleteFollow(ctx, userDID, targetDID)
55+ err = dbs.Users.DeleteFollow(ctx, userDID, targetDID)
5656 assert.NilError(t, err)
5757
58- following, err := db.IsFollowing(ctx, userDID, targetDID)
58+ following, err := dbs.Users.IsFollowing(ctx, userDID, targetDID)
5959 assert.NilError(t, err)
6060 assert.Equal(t, following, false)
6161 }
6262
6363 func TestDeleteFollowByURI(t *testing.T) {
6464 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)
6767
6868 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")
7070 assert.NilError(t, err)
7171
72- err = db.DeleteFollowByURI(ctx, uri)
72+ err = dbs.Users.DeleteFollowByURI(ctx, uri)
7373 assert.NilError(t, err)
7474
75- following, err := db.IsFollowing(ctx, userDID, targetDID)
75+ following, err := dbs.Users.IsFollowing(ctx, userDID, targetDID)
7676 assert.NilError(t, err)
7777 assert.Equal(t, following, false)
7878 }
7979
8080 func TestListFollows(t *testing.T) {
8181 ctx := context.Background()
82- db := setupTestDB(t)
83- userDID, _ := seedFollowData(t, ctx, db)
82+ dbs := setupTestDB(t)
83+ userDID, _ := seedFollowData(t, ctx, dbs)
8484
8585 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")
8787 assert.NilError(t, err)
8888
89- err = db.UpsertFollow(ctx, userDID, "did:test:followed", "uri1", "cid1")
89+ err = dbs.Users.UpsertFollow(ctx, userDID, "did:test:followed", "uri1", "cid1")
9090 assert.NilError(t, err)
91- err = db.UpsertFollow(ctx, userDID, target2, "uri2", "cid2")
91+ err = dbs.Users.UpsertFollow(ctx, userDID, target2, "uri2", "cid2")
9292 assert.NilError(t, err)
9393
94- follows, err := db.ListFollows(ctx, userDID, 10, 0)
94+ follows, err := dbs.Users.ListFollows(ctx, userDID, 10, 0)
9595 assert.NilError(t, err)
9696 assert.Equal(t, len(follows), 2)
9797 }
9898
9999 func TestListFollowers(t *testing.T) {
100100 ctx := context.Background()
101- db := setupTestDB(t)
102- _, targetDID := seedFollowData(t, ctx, db)
101+ dbs := setupTestDB(t)
102+ _, targetDID := seedFollowData(t, ctx, dbs)
103103
104104 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")
106106 assert.NilError(t, err)
107107
108- err = db.UpsertFollow(ctx, "did:test:follower", targetDID, "uri1", "cid1")
108+ err = dbs.Users.UpsertFollow(ctx, "did:test:follower", targetDID, "uri1", "cid1")
109109 assert.NilError(t, err)
110- err = db.UpsertFollow(ctx, follower2, targetDID, "uri2", "cid2")
110+ err = dbs.Users.UpsertFollow(ctx, follower2, targetDID, "uri2", "cid2")
111111 assert.NilError(t, err)
112112
113- followers, err := db.ListFollowers(ctx, targetDID, 10, 0)
113+ followers, err := dbs.Users.ListFollowers(ctx, targetDID, 10, 0)
114114 assert.NilError(t, err)
115115 assert.Equal(t, len(followers), 2)
116116 }
117117
118118 func TestGetFollowDIDs(t *testing.T) {
119119 ctx := context.Background()
120- db := setupTestDB(t)
121- userDID, _ := seedFollowData(t, ctx, db)
120+ dbs := setupTestDB(t)
121+ userDID, _ := seedFollowData(t, ctx, dbs)
122122
123123 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")
125125 assert.NilError(t, err)
126126
127- err = db.UpsertFollow(ctx, userDID, "did:test:followed", "uri1", "cid1")
127+ err = dbs.Users.UpsertFollow(ctx, userDID, "did:test:followed", "uri1", "cid1")
128128 assert.NilError(t, err)
129- err = db.UpsertFollow(ctx, userDID, target2, "uri2", "cid2")
129+ err = dbs.Users.UpsertFollow(ctx, userDID, target2, "uri2", "cid2")
130130 assert.NilError(t, err)
131131
132- dids, err := db.GetFollowDIDs(ctx, userDID)
132+ dids, err := dbs.Users.GetFollowDIDs(ctx, userDID)
133133 assert.NilError(t, err)
134134 assert.Equal(t, len(dids), 2)
135135 }
136136
137137 func TestSyncFollows_AddsNewRemovesStale(t *testing.T) {
138138 ctx := context.Background()
139- db := setupTestDB(t)
140- userDID, _ := seedFollowData(t, ctx, db)
139+ dbs := setupTestDB(t)
140+ userDID, _ := seedFollowData(t, ctx, dbs)
141141
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")
143143 assert.NilError(t, err)
144144
145145 activeFollows := map[string]Follow{
@@ -147,37 +147,37 @@ func TestSyncFollows_AddsNewRemovesStale(t *testing.T) {
147147 "did:test:new2": {URI: NullStr("uri2"), CID: NullStr("cid2")},
148148 }
149149
150- err = db.SyncFollows(ctx, userDID, activeFollows)
150+ err = dbs.Users.SyncFollows(ctx, userDID, activeFollows)
151151 assert.NilError(t, err)
152152
153- stillFollowing, err := db.IsFollowing(ctx, userDID, "did:test:old")
153+ stillFollowing, err := dbs.Users.IsFollowing(ctx, userDID, "did:test:old")
154154 assert.NilError(t, err)
155155 assert.Equal(t, stillFollowing, false)
156156
157- following1, err := db.IsFollowing(ctx, userDID, "did:test:new1")
157+ following1, err := dbs.Users.IsFollowing(ctx, userDID, "did:test:new1")
158158 assert.NilError(t, err)
159159 assert.Equal(t, following1, true)
160160
161- following2, err := db.IsFollowing(ctx, userDID, "did:test:new2")
161+ following2, err := dbs.Users.IsFollowing(ctx, userDID, "did:test:new2")
162162 assert.NilError(t, err)
163163 assert.Equal(t, following2, true)
164164
165- dids, err := db.GetFollowDIDs(ctx, userDID)
165+ dids, err := dbs.Users.GetFollowDIDs(ctx, userDID)
166166 assert.NilError(t, err)
167167 assert.Equal(t, len(dids), 2)
168168 }
169169
170170 func TestUpsertFollow_Idempotent(t *testing.T) {
171171 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)
174174
175- err := db.UpsertFollow(ctx, userDID, targetDID, "uri1", "cid1")
175+ err := dbs.Users.UpsertFollow(ctx, userDID, targetDID, "uri1", "cid1")
176176 assert.NilError(t, err)
177- err = db.UpsertFollow(ctx, userDID, targetDID, "uri2", "cid2")
177+ err = dbs.Users.UpsertFollow(ctx, userDID, targetDID, "uri2", "cid2")
178178 assert.NilError(t, err)
179179
180- follows, err := db.ListFollows(ctx, userDID, 10, 0)
180+ follows, err := dbs.Users.ListFollows(ctx, userDID, 10, 0)
181181 assert.NilError(t, err)
182182 assert.Equal(t, len(follows), 1)
183183 assert.Equal(t, follows[0].URI.String, "uri2")
@@ -7,15 +7,15 @@ import (
7 "gotest.tools/v3/assert"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 t.Helper()11 t.Helper()
12 12
13 userDID = "did:test:follower"13 userDID = "did:test:follower"
14 targetDID = "did:test:followed"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 assert.NilError(t, err)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 assert.NilError(t, err)19 assert.NilError(t, err)
20 20
21 return userDID, targetDID21 return userDID, targetDID
@@ -23,123 +23,123 @@ func seedFollowData(t *testing.T, ctx context.Context, db *DB) (userDID, targetD
23 23
24 func TestUpsertFollow(t *testing.T) {24 func TestUpsertFollow(t *testing.T) {
25 ctx := context.Background()25 ctx := context.Background()
26- db := setupTestDB(t)26+ dbs := setupTestDB(t)
27- userDID, targetDID := seedFollowData(t, ctx, db)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 assert.NilError(t, err)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 assert.NilError(t, err)33 assert.NilError(t, err)
34 assert.Equal(t, following, true)34 assert.Equal(t, following, true)
35 }35 }
36 36
37 func TestIsFollowing_NotFollowing(t *testing.T) {37 func TestIsFollowing_NotFollowing(t *testing.T) {
38 ctx := context.Background()38 ctx := context.Background()
39- db := setupTestDB(t)39+ dbs := setupTestDB(t)
40- userDID, targetDID := seedFollowData(t, ctx, db)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 assert.NilError(t, err)43 assert.NilError(t, err)
44 assert.Equal(t, following, false)44 assert.Equal(t, following, false)
45 }45 }
46 46
47 func TestDeleteFollow(t *testing.T) {47 func TestDeleteFollow(t *testing.T) {
48 ctx := context.Background()48 ctx := context.Background()
49- db := setupTestDB(t)49+ dbs := setupTestDB(t)
50- userDID, targetDID := seedFollowData(t, ctx, db)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 assert.NilError(t, err)53 assert.NilError(t, err)
54 54
55- err = db.DeleteFollow(ctx, userDID, targetDID)55+ err = dbs.Users.DeleteFollow(ctx, userDID, targetDID)
56 assert.NilError(t, err)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 assert.NilError(t, err)59 assert.NilError(t, err)
60 assert.Equal(t, following, false)60 assert.Equal(t, following, false)
61 }61 }
62 62
63 func TestDeleteFollowByURI(t *testing.T) {63 func TestDeleteFollowByURI(t *testing.T) {
64 ctx := context.Background()64 ctx := context.Background()
65- db := setupTestDB(t)65+ dbs := setupTestDB(t)
66- userDID, targetDID := seedFollowData(t, ctx, db)66+ userDID, targetDID := seedFollowData(t, ctx, dbs)
67 67
68 uri := "at://did:test:follower/app.bsky.graph.follow/abc"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 assert.NilError(t, err)70 assert.NilError(t, err)
71 71
72- err = db.DeleteFollowByURI(ctx, uri)72+ err = dbs.Users.DeleteFollowByURI(ctx, uri)
73 assert.NilError(t, err)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 assert.NilError(t, err)76 assert.NilError(t, err)
77 assert.Equal(t, following, false)77 assert.Equal(t, following, false)
78 }78 }
79 79
80 func TestListFollows(t *testing.T) {80 func TestListFollows(t *testing.T) {
81 ctx := context.Background()81 ctx := context.Background()
82- db := setupTestDB(t)82+ dbs := setupTestDB(t)
83- userDID, _ := seedFollowData(t, ctx, db)83+ userDID, _ := seedFollowData(t, ctx, dbs)
84 84
85 target2 := "did:test:followed2"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 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)95 assert.NilError(t, err)
96 assert.Equal(t, len(follows), 2)96 assert.Equal(t, len(follows), 2)
97 }97 }
98 98
99 func TestListFollowers(t *testing.T) {99 func TestListFollowers(t *testing.T) {
100 ctx := context.Background()100 ctx := context.Background()
101- db := setupTestDB(t)101+ dbs := setupTestDB(t)
102- _, targetDID := seedFollowData(t, ctx, db)102+ _, targetDID := seedFollowData(t, ctx, dbs)
103 103
104 follower2 := "did:test:follower2"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 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)114 assert.NilError(t, err)
115 assert.Equal(t, len(followers), 2)115 assert.Equal(t, len(followers), 2)
116 }116 }
117 117
118 func TestGetFollowDIDs(t *testing.T) {118 func TestGetFollowDIDs(t *testing.T) {
119 ctx := context.Background()119 ctx := context.Background()
120- db := setupTestDB(t)120+ dbs := setupTestDB(t)
121- userDID, _ := seedFollowData(t, ctx, db)121+ userDID, _ := seedFollowData(t, ctx, dbs)
122 122
123 target2 := "did:test:followed2"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 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)130 assert.NilError(t, err)
131 131
132- dids, err := db.GetFollowDIDs(ctx, userDID)132+ dids, err := dbs.Users.GetFollowDIDs(ctx, userDID)
133 assert.NilError(t, err)133 assert.NilError(t, err)
134 assert.Equal(t, len(dids), 2)134 assert.Equal(t, len(dids), 2)
135 }135 }
136 136
137 func TestSyncFollows_AddsNewRemovesStale(t *testing.T) {137 func TestSyncFollows_AddsNewRemovesStale(t *testing.T) {
138 ctx := context.Background()138 ctx := context.Background()
139- db := setupTestDB(t)139+ dbs := setupTestDB(t)
140- userDID, _ := seedFollowData(t, ctx, db)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 assert.NilError(t, err)143 assert.NilError(t, err)
144 144
145 activeFollows := map[string]Follow{145 activeFollows := map[string]Follow{
@@ -147,37 +147,37 @@ func TestSyncFollows_AddsNewRemovesStale(t *testing.T) {
147 "did:test:new2": {URI: NullStr("uri2"), CID: NullStr("cid2")},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 assert.NilError(t, err)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 assert.NilError(t, err)154 assert.NilError(t, err)
155 assert.Equal(t, stillFollowing, false)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 assert.NilError(t, err)158 assert.NilError(t, err)
159 assert.Equal(t, following1, true)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 assert.NilError(t, err)162 assert.NilError(t, err)
163 assert.Equal(t, following2, true)163 assert.Equal(t, following2, true)
164 164
165- dids, err := db.GetFollowDIDs(ctx, userDID)165+ dids, err := dbs.Users.GetFollowDIDs(ctx, userDID)
166 assert.NilError(t, err)166 assert.NilError(t, err)
167 assert.Equal(t, len(dids), 2)167 assert.Equal(t, len(dids), 2)
168 }168 }
169 169
170 func TestUpsertFollow_Idempotent(t *testing.T) {170 func TestUpsertFollow_Idempotent(t *testing.T) {
171 ctx := context.Background()171 ctx := context.Background()
172- db := setupTestDB(t)172+ dbs := setupTestDB(t)
173- userDID, targetDID := seedFollowData(t, ctx, db)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 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)181 assert.NilError(t, err)
182 assert.Equal(t, len(follows), 1)182 assert.Equal(t, len(follows), 1)
183 assert.Equal(t, follows[0].URI.String, "uri2")183 assert.Equal(t, follows[0].URI.String, "uri2")
modified internal/db/multi.go +72 -86
@@ -11,9 +11,10 @@ import (
1111 )
1212
1313 type Databases struct {
14- Users *DB
15- Articles *DB
16- Recs *DB
14+ Users *UserStore
15+ Articles *ArticleStore
16+
17+ db *DB
1718 }
1819
1920 var multiDriverSeq int64
@@ -22,6 +23,14 @@ func OpenAll(basePath string) (*Databases, error) {
2223 articlesPath := basePath + "_articles"
2324 recsPath := basePath + "_recs"
2425
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+ }
33+
2534 seq := atomic.AddInt64(&multiDriverSeq, 1)
2635 driverName := fmt.Sprintf("sqlite3_glean_multi_%d", seq)
2736
@@ -52,69 +61,48 @@ func OpenAll(basePath string) (*Databases, error) {
5261 },
5362 })
5463
55- usersDB, err := sql.Open(driverName, basePath+"_users?cache=shared&"+DSN)
56- if err != nil {
57- return nil, err
58- }
59- usersDB.SetMaxOpenConns(10)
60- usersDB.SetMaxIdleConns(5)
61- usersDB.SetConnMaxLifetime(30 * time.Minute)
62- users := &DB{usersDB}
63-
64- articles, err := Open(articlesPath)
64+ db, err := sql.Open(driverName, basePath+"_users?cache=shared&"+DSN)
6565 if err != nil {
66- users.Close()
6766 return nil, err
6867 }
68+ db.SetMaxOpenConns(10)
69+ db.SetMaxIdleConns(5)
70+ db.SetConnMaxLifetime(30 * time.Minute)
71+ d := &DB{db}
6972
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()
8175 return nil, err
8276 }
8377
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()
8880 return nil, err
8981 }
9082
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()
9585 return nil, err
9686 }
9787
9888 return &Databases{
99- Users: users,
100- Articles: articles,
101- Recs: recs,
89+ Users: NewUserStore(d),
90+ Articles: NewArticleStore(d),
91+ db: d,
10292 }, nil
10393 }
10494
10595 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()
11498 }
11599 return nil
116100 }
117101
102+func (d *Databases) DB() *sql.DB {
103+ return d.db.DB
104+}
105+
118106 func initUsersSchema(db *DB) error {
119107 for _, s := range usersSchema {
120108 if _, err := db.Exec(s); err != nil {
@@ -181,7 +169,7 @@ var usersSchema = []string{
181169 }
182170
183171 var articlesSchema = []string{
184- `CREATE TABLE IF NOT EXISTS feeds (
172+ `CREATE TABLE IF NOT EXISTS articles.feeds (
185173 feed_url TEXT PRIMARY KEY,
186174 title TEXT,
187175 site_url TEXT,
@@ -192,14 +180,12 @@ var articlesSchema = []string{
192180 subscriber_count INTEGER NOT NULL DEFAULT 0,
193181 etag TEXT,
194182 last_modified TEXT,
195- fetch_interval_minutes INTEGER NOT NULL DEFAULT 30,
196- next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
197183 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
198184 error_count INTEGER NOT NULL DEFAULT 0,
199185 favicon_url TEXT
200186 )`,
201187
202- `CREATE TABLE IF NOT EXISTS subscriptions (
188+ `CREATE TABLE IF NOT EXISTS articles.subscriptions (
203189 id INTEGER PRIMARY KEY AUTOINCREMENT,
204190 user_did TEXT NOT NULL,
205191 feed_url TEXT NOT NULL,
@@ -211,7 +197,7 @@ var articlesSchema = []string{
211197 UNIQUE(user_did, feed_url)
212198 )`,
213199
214- `CREATE TABLE IF NOT EXISTS articles (
200+ `CREATE TABLE IF NOT EXISTS articles.articles (
215201 id INTEGER PRIMARY KEY AUTOINCREMENT,
216202 feed_url TEXT NOT NULL,
217203 guid TEXT NOT NULL,
@@ -227,7 +213,7 @@ var articlesSchema = []string{
227213 UNIQUE(feed_url, guid)
228214 )`,
229215
230- `CREATE TABLE IF NOT EXISTS read_state (
216+ `CREATE TABLE IF NOT EXISTS articles.read_state (
231217 user_did TEXT NOT NULL,
232218 article_id INTEGER NOT NULL,
233219 is_read BOOLEAN NOT NULL DEFAULT 0,
@@ -235,7 +221,7 @@ var articlesSchema = []string{
235221 PRIMARY KEY (user_did, article_id)
236222 )`,
237223
238- `CREATE TABLE IF NOT EXISTS annotations (
224+ `CREATE TABLE IF NOT EXISTS articles.annotations (
239225 id INTEGER PRIMARY KEY AUTOINCREMENT,
240226 uri TEXT NOT NULL UNIQUE,
241227 author_did TEXT NOT NULL,
@@ -249,7 +235,7 @@ var articlesSchema = []string{
249235 cid TEXT
250236 )`,
251237
252- `CREATE TABLE IF NOT EXISTS likes (
238+ `CREATE TABLE IF NOT EXISTS articles.likes (
253239 id INTEGER PRIMARY KEY AUTOINCREMENT,
254240 uri TEXT NOT NULL UNIQUE,
255241 author_did TEXT NOT NULL,
@@ -260,37 +246,37 @@ var articlesSchema = []string{
260246 UNIQUE(author_did, feed_url, article_url)
261247 )`,
262248
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)`,
278-
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
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)`,
264+
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
281267 INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
282268 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
284270 INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
285271 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
287273 INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
288274 INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
289275 END`,
290276 }
291277
292278 var recsSchema = []string{
293- `CREATE TABLE IF NOT EXISTS feed_similarity (
279+ `CREATE TABLE IF NOT EXISTS recs.feed_similarity (
294280 feed_a TEXT NOT NULL,
295281 feed_b TEXT NOT NULL,
296282 jaccard REAL NOT NULL,
@@ -299,7 +285,7 @@ var recsSchema = []string{
299285 CHECK(feed_a < feed_b)
300286 )`,
301287
302- `CREATE TABLE IF NOT EXISTS user_similarity (
288+ `CREATE TABLE IF NOT EXISTS recs.user_similarity (
303289 user_a TEXT NOT NULL,
304290 user_b TEXT NOT NULL,
305291 jaccard REAL NOT NULL,
@@ -311,7 +297,7 @@ var recsSchema = []string{
311297 CHECK(user_a < user_b)
312298 )`,
313299
314- `CREATE TABLE IF NOT EXISTS dismissed_recommendations (
300+ `CREATE TABLE IF NOT EXISTS recs.dismissed_recommendations (
315301 user_did TEXT NOT NULL,
316302 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
317303 target_id TEXT NOT NULL,
@@ -320,7 +306,7 @@ var recsSchema = []string{
320306 PRIMARY KEY (user_did, target_type, target_id)
321307 )`,
322308
323- `CREATE TABLE IF NOT EXISTS recommendation_impressions (
309+ `CREATE TABLE IF NOT EXISTS recs.recommendation_impressions (
324310 user_did TEXT NOT NULL,
325311 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
326312 target_id TEXT NOT NULL,
@@ -331,14 +317,14 @@ var recsSchema = []string{
331317 PRIMARY KEY (user_did, target_type, target_id)
332318 )`,
333319
334- `CREATE TABLE IF NOT EXISTS follow_distances (
320+ `CREATE TABLE IF NOT EXISTS recs.follow_distances (
335321 user_a TEXT NOT NULL,
336322 user_b TEXT NOT NULL,
337323 distance INTEGER NOT NULL CHECK(distance IN (1, 2)),
338324 PRIMARY KEY (user_a, user_b)
339325 )`,
340326
341- `CREATE TABLE IF NOT EXISTS user_signal_weights (
327+ `CREATE TABLE IF NOT EXISTS recs.user_signal_weights (
342328 user_did TEXT PRIMARY KEY,
343329 w_sub REAL NOT NULL DEFAULT 1.0,
344330 w_like REAL NOT NULL DEFAULT 0.5,
@@ -349,7 +335,7 @@ var recsSchema = []string{
349335 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
350336 )`,
351337
352- `CREATE TABLE IF NOT EXISTS user_signal_profiles (
338+ `CREATE TABLE IF NOT EXISTS recs.user_signal_profiles (
353339 user_did TEXT PRIMARY KEY,
354340 total_likes INTEGER NOT NULL DEFAULT 0,
355341 total_tags INTEGER NOT NULL DEFAULT 0,
@@ -357,11 +343,11 @@ var recsSchema = []string{
357343 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
358344 )`,
359345
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)`,
367353 }
@@ -11,9 +11,10 @@ import (
11 )11 )
12 12
13 type Databases struct {13 type Databases struct {
14- Users *DB14+ Users *UserStore
15- Articles *DB15+ Articles *ArticleStore
16- Recs *DB16+
17+ db *DB
17 }18 }
18 19
19 var multiDriverSeq int6420 var multiDriverSeq int64
@@ -22,6 +23,14 @@ func OpenAll(basePath string) (*Databases, error) {
22 articlesPath := basePath + "_articles"23 articlesPath := basePath + "_articles"
23 recsPath := basePath + "_recs"24 recsPath := basePath + "_recs"
24 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+ }
33+
25 seq := atomic.AddInt64(&multiDriverSeq, 1)34 seq := atomic.AddInt64(&multiDriverSeq, 1)
26 driverName := fmt.Sprintf("sqlite3_glean_multi_%d", seq)35 driverName := fmt.Sprintf("sqlite3_glean_multi_%d", seq)
27 36
@@ -52,69 +61,48 @@ func OpenAll(basePath string) (*Databases, error) {
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- if err != nil {
57- return nil, err
58- }
59- usersDB.SetMaxOpenConns(10)
60- usersDB.SetMaxIdleConns(5)
61- usersDB.SetConnMaxLifetime(30 * time.Minute)
62- users := &DB{usersDB}
63-
64- articles, err := Open(articlesPath)
65 if err != nil {65 if err != nil {
66- users.Close()
67 return nil, err66 return nil, err
68 }67 }
68+ db.SetMaxOpenConns(10)
69+ db.SetMaxIdleConns(5)
70+ db.SetConnMaxLifetime(30 * time.Minute)
71+ d := &DB{db}
69 72
70- recs, err := Open(recsPath)73+ if err := initUsersSchema(d); err != nil {
71- if err != nil {74+ d.Close()
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()
81 return nil, err75 return nil, err
82 }76 }
83 77
84- if err := initArticlesSchema(articles); err != nil {78+ if err := initArticlesSchema(d); err != nil {
85- users.Close()79+ d.Close()
86- articles.Close()
87- recs.Close()
88 return nil, err80 return nil, err
89 }81 }
90 82
91- if err := initRecsSchema(recs); err != nil {83+ if err := initRecsSchema(d); err != nil {
92- users.Close()84+ d.Close()
93- articles.Close()
94- recs.Close()
95 return nil, err85 return nil, err
96 }86 }
97 87
98 return &Databases{88 return &Databases{
99- Users: users,89+ Users: NewUserStore(d),
100- Articles: articles,90+ Articles: NewArticleStore(d),
101- Recs: recs,91+ db: d,
102 }, nil92 }, nil
103 }93 }
104 94
105 func (d *Databases) Close() error {95 func (d *Databases) Close() error {
106- if d.Users != nil {96+ if d.db != nil {
107- _ = d.Users.Close()97+ _ = d.db.Close()
108- }
109- if d.Articles != nil {
110- _ = d.Articles.Close()
111- }
112- if d.Recs != nil {
113- _ = d.Recs.Close()
114 }98 }
115 return nil99 return nil
116 }100 }
117 101
102+func (d *Databases) DB() *sql.DB {
103+ return d.db.DB
104+}
105+
118 func initUsersSchema(db *DB) error {106 func initUsersSchema(db *DB) error {
119 for _, s := range usersSchema {107 for _, s := range usersSchema {
120 if _, err := db.Exec(s); err != nil {108 if _, err := db.Exec(s); err != nil {
@@ -181,7 +169,7 @@ var usersSchema = []string{
181 }169 }
182 170
183 var articlesSchema = []string{171 var articlesSchema = []string{
184- `CREATE TABLE IF NOT EXISTS feeds (172+ `CREATE TABLE IF NOT EXISTS articles.feeds (
185 feed_url TEXT PRIMARY KEY,173 feed_url TEXT PRIMARY KEY,
186 title TEXT,174 title TEXT,
187 site_url TEXT,175 site_url TEXT,
@@ -192,14 +180,12 @@ var articlesSchema = []string{
192 subscriber_count INTEGER NOT NULL DEFAULT 0,180 subscriber_count INTEGER NOT NULL DEFAULT 0,
193 etag TEXT,181 etag TEXT,
194 last_modified TEXT,182 last_modified TEXT,
195- fetch_interval_minutes INTEGER NOT NULL DEFAULT 30,
196- next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
197 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,183 consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
198 error_count INTEGER NOT NULL DEFAULT 0,184 error_count INTEGER NOT NULL DEFAULT 0,
199 favicon_url TEXT185 favicon_url TEXT
200 )`,186 )`,
201 187
202- `CREATE TABLE IF NOT EXISTS subscriptions (188+ `CREATE TABLE IF NOT EXISTS articles.subscriptions (
203 id INTEGER PRIMARY KEY AUTOINCREMENT,189 id INTEGER PRIMARY KEY AUTOINCREMENT,
204 user_did TEXT NOT NULL,190 user_did TEXT NOT NULL,
205 feed_url TEXT NOT NULL,191 feed_url TEXT NOT NULL,
@@ -211,7 +197,7 @@ var articlesSchema = []string{
211 UNIQUE(user_did, feed_url)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 id INTEGER PRIMARY KEY AUTOINCREMENT,201 id INTEGER PRIMARY KEY AUTOINCREMENT,
216 feed_url TEXT NOT NULL,202 feed_url TEXT NOT NULL,
217 guid TEXT NOT NULL,203 guid TEXT NOT NULL,
@@ -227,7 +213,7 @@ var articlesSchema = []string{
227 UNIQUE(feed_url, guid)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 user_did TEXT NOT NULL,217 user_did TEXT NOT NULL,
232 article_id INTEGER NOT NULL,218 article_id INTEGER NOT NULL,
233 is_read BOOLEAN NOT NULL DEFAULT 0,219 is_read BOOLEAN NOT NULL DEFAULT 0,
@@ -235,7 +221,7 @@ var articlesSchema = []string{
235 PRIMARY KEY (user_did, article_id)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 id INTEGER PRIMARY KEY AUTOINCREMENT,225 id INTEGER PRIMARY KEY AUTOINCREMENT,
240 uri TEXT NOT NULL UNIQUE,226 uri TEXT NOT NULL UNIQUE,
241 author_did TEXT NOT NULL,227 author_did TEXT NOT NULL,
@@ -249,7 +235,7 @@ var articlesSchema = []string{
249 cid TEXT235 cid TEXT
250 )`,236 )`,
251 237
252- `CREATE TABLE IF NOT EXISTS likes (238+ `CREATE TABLE IF NOT EXISTS articles.likes (
253 id INTEGER PRIMARY KEY AUTOINCREMENT,239 id INTEGER PRIMARY KEY AUTOINCREMENT,
254 uri TEXT NOT NULL UNIQUE,240 uri TEXT NOT NULL UNIQUE,
255 author_did TEXT NOT NULL,241 author_did TEXT NOT NULL,
@@ -260,37 +246,37 @@ var articlesSchema = []string{
260 UNIQUE(author_did, feed_url, article_url)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)`,249+ `CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_feed ON subscriptions(feed_url)`,
264- `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)`,250+ `CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)`,
265- `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,251+ `CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_user ON subscriptions(user_did)`,
266- `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`,252+ `CREATE INDEX IF NOT EXISTS articles.idx_subscriptions_uri ON subscriptions(uri)`,
267- `CREATE INDEX IF NOT EXISTS idx_likes_author_feed ON likes(author_did, feed_url, created_at)`,253+ `CREATE INDEX IF NOT EXISTS articles.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)`,254+ `CREATE INDEX IF NOT EXISTS articles.idx_articles_feed ON articles(feed_url)`,
269- `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,255+ `CREATE INDEX IF NOT EXISTS articles.idx_articles_published ON articles(published DESC)`,
270- `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`,256+ `CREATE INDEX IF NOT EXISTS articles.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`,257+ `CREATE INDEX IF NOT EXISTS articles.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)`,258+ `CREATE INDEX IF NOT EXISTS articles.idx_annotations_article ON annotations(article_url)`,
273- `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`,259+ `CREATE INDEX IF NOT EXISTS articles.idx_annotations_author ON annotations(author_did)`,
274- `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`,260+ `CREATE INDEX IF NOT EXISTS articles.idx_annotations_created_at ON annotations(created_at DESC)`,
275- `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,261+ `CREATE INDEX IF NOT EXISTS articles.idx_likes_article ON likes(feed_url, article_url)`,
276- `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,262+ `CREATE INDEX IF NOT EXISTS articles.idx_likes_author ON likes(author_did)`,
277- `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`,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)`,265+ `CREATE VIRTUAL TABLE IF NOT EXISTS articles.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 BEGIN266+ `CREATE TRIGGER IF NOT EXISTS articles.articles_ai AFTER INSERT ON articles BEGIN
281 INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);267 INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
282 END`,268 END`,
283- `CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN269+ `CREATE TRIGGER IF NOT EXISTS articles.articles_ad AFTER DELETE ON articles BEGIN
284 INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);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 END`,271 END`,
286- `CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN272+ `CREATE TRIGGER IF NOT EXISTS articles.articles_au AFTER UPDATE ON articles BEGIN
287 INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);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 INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);274 INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
289 END`,275 END`,
290 }276 }
291 277
292 var recsSchema = []string{278 var recsSchema = []string{
293- `CREATE TABLE IF NOT EXISTS feed_similarity (279+ `CREATE TABLE IF NOT EXISTS recs.feed_similarity (
294 feed_a TEXT NOT NULL,280 feed_a TEXT NOT NULL,
295 feed_b TEXT NOT NULL,281 feed_b TEXT NOT NULL,
296 jaccard REAL NOT NULL,282 jaccard REAL NOT NULL,
@@ -299,7 +285,7 @@ var recsSchema = []string{
299 CHECK(feed_a < feed_b)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 user_a TEXT NOT NULL,289 user_a TEXT NOT NULL,
304 user_b TEXT NOT NULL,290 user_b TEXT NOT NULL,
305 jaccard REAL NOT NULL,291 jaccard REAL NOT NULL,
@@ -311,7 +297,7 @@ var recsSchema = []string{
311 CHECK(user_a < user_b)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 user_did TEXT NOT NULL,301 user_did TEXT NOT NULL,
316 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),302 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
317 target_id TEXT NOT NULL,303 target_id TEXT NOT NULL,
@@ -320,7 +306,7 @@ var recsSchema = []string{
320 PRIMARY KEY (user_did, target_type, target_id)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 user_did TEXT NOT NULL,310 user_did TEXT NOT NULL,
325 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),311 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
326 target_id TEXT NOT NULL,312 target_id TEXT NOT NULL,
@@ -331,14 +317,14 @@ var recsSchema = []string{
331 PRIMARY KEY (user_did, target_type, target_id)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 user_a TEXT NOT NULL,321 user_a TEXT NOT NULL,
336 user_b TEXT NOT NULL,322 user_b TEXT NOT NULL,
337 distance INTEGER NOT NULL CHECK(distance IN (1, 2)),323 distance INTEGER NOT NULL CHECK(distance IN (1, 2)),
338 PRIMARY KEY (user_a, user_b)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 user_did TEXT PRIMARY KEY,328 user_did TEXT PRIMARY KEY,
343 w_sub REAL NOT NULL DEFAULT 1.0,329 w_sub REAL NOT NULL DEFAULT 1.0,
344 w_like REAL NOT NULL DEFAULT 0.5,330 w_like REAL NOT NULL DEFAULT 0.5,
@@ -349,7 +335,7 @@ var recsSchema = []string{
349 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP335 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 user_did TEXT PRIMARY KEY,339 user_did TEXT PRIMARY KEY,
354 total_likes INTEGER NOT NULL DEFAULT 0,340 total_likes INTEGER NOT NULL DEFAULT 0,
355 total_tags INTEGER NOT NULL DEFAULT 0,341 total_tags INTEGER NOT NULL DEFAULT 0,
@@ -357,11 +343,11 @@ var recsSchema = []string{
357 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP343 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)`,346+ `CREATE INDEX IF NOT EXISTS recs.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)`,347+ `CREATE INDEX IF NOT EXISTS recs.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)`,348+ `CREATE INDEX IF NOT EXISTS recs.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)`,349+ `CREATE INDEX IF NOT EXISTS recs.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)`,350+ `CREATE INDEX IF NOT EXISTS recs.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)`,351+ `CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_b ON user_similarity(user_b)`,
366- `CREATE INDEX IF NOT EXISTS idx_user_similarity_a ON user_similarity(user_a)`,352+ `CREATE INDEX IF NOT EXISTS recs.idx_user_similarity_a ON user_similarity(user_a)`,
367 }353 }
modified internal/db/oauth_store.go +2 -2
@@ -14,8 +14,8 @@ type OAuthStore struct {
1414 db *DB
1515 }
1616
17-func NewOAuthStore(db *DB) *OAuthStore {
18- return &OAuthStore{db: db}
17+func NewOAuthStore(dbs *Databases) *OAuthStore {
18+ return &OAuthStore{db: dbs.db}
1919 }
2020
2121 func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
@@ -14,8 +14,8 @@ type OAuthStore struct {
14 db *DB14 db *DB
15 }15 }
16 16
17-func NewOAuthStore(db *DB) *OAuthStore {17+func NewOAuthStore(dbs *Databases) *OAuthStore {
18- return &OAuthStore{db: db}18+ return &OAuthStore{db: dbs.db}
19 }19 }
20 20
21 func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {21 func (s *OAuthStore) GetSession(ctx context.Context, did syntax.DID, sessionID string) (*oauth.ClientSessionData, error) {
modified internal/db/social.go +60 -60
@@ -35,17 +35,17 @@ type Like struct {
3535 CID sql.NullString
3636 }
3737
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})
4040 }
4141
42-func (db *DB) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) {
42+func (s *ArticleStore) GetAnnotation(ctx context.Context, id int64) (*Annotation, error) {
4343 a := &Annotation{}
44- err := db.QueryRowContext(ctx, `
44+ err := s.db.QueryRowContext(ctx, `
4545 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
4747 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
4949 WHERE a.id = ?
5050 `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, &a.ArticleID,
5151 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID)
@@ -55,14 +55,14 @@ func (db *DB) GetAnnotation(ctx context.Context, id int64) (*Annotation, error)
5555 return a, nil
5656 }
5757
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)
6060 return err
6161 }
6262
63-func (db *DB) AnnotationExists(ctx context.Context, uri string) (bool, error) {
63+func (s *ArticleStore) AnnotationExists(ctx context.Context, uri string) (bool, error) {
6464 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)
6666 if err == sql.ErrNoRows {
6767 return false, nil
6868 }
@@ -72,7 +72,7 @@ func (db *DB) AnnotationExists(ctx context.Context, uri string) (bool, error) {
7272 return true, nil
7373 }
7474
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) {
7676 var conds []string
7777 var args []any
7878
@@ -90,16 +90,16 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
9090 }
9191
9292 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
9494 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`
9696 if len(conds) > 0 {
9797 query += ` WHERE ` + strings.Join(conds, " AND ")
9898 }
9999 query += ` ORDER BY a.created_at DESC LIMIT ? OFFSET ?`
100100 args = append(args, limit, offset)
101101
102- rows, err := db.QueryContext(ctx, query, args...)
102+ rows, err := s.db.QueryContext(ctx, query, args...)
103103 if err != nil {
104104 return nil, err
105105 }
@@ -117,18 +117,18 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
117117 return annotations, rows.Err()
118118 }
119119
120-func (db *DB) BatchCreateLikes(ctx context.Context, likes []*Like) error {
120+func (s *ArticleStore) BatchCreateLikes(ctx context.Context, likes []*Like) error {
121121 if len(likes) == 0 {
122122 return nil
123123 }
124- tx, err := db.BeginTx(ctx, nil)
124+ tx, err := s.db.BeginTx(ctx, nil)
125125 if err != nil {
126126 return err
127127 }
128128 defer tx.Rollback()
129129
130130 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)
132132 VALUES (?, ?, ?, ?, ?, ?)
133133 `)
134134 if err != nil {
@@ -144,18 +144,18 @@ func (db *DB) BatchCreateLikes(ctx context.Context, likes []*Like) error {
144144 return tx.Commit()
145145 }
146146
147-func (db *DB) BatchCreateAnnotations(ctx context.Context, annotations []*Annotation) error {
147+func (s *ArticleStore) BatchCreateAnnotations(ctx context.Context, annotations []*Annotation) error {
148148 if len(annotations) == 0 {
149149 return nil
150150 }
151- tx, err := db.BeginTx(ctx, nil)
151+ tx, err := s.db.BeginTx(ctx, nil)
152152 if err != nil {
153153 return err
154154 }
155155 defer tx.Rollback()
156156
157157 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)
159159 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
160160 `)
161161 if err != nil {
@@ -171,30 +171,30 @@ func (db *DB) BatchCreateAnnotations(ctx context.Context, annotations []*Annotat
171171 return tx.Commit()
172172 }
173173
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)
176176 if err != nil {
177177 return err
178178 }
179179 if exists {
180180 return ErrDuplicateLike
181181 }
182- return db.BatchCreateLikes(ctx, []*Like{l})
182+ return s.BatchCreateLikes(ctx, []*Like{l})
183183 }
184184
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)
187187 return err
188188 }
189189
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 = ?
193193 `, authorDID, feedURL, articleURL)
194194 return err
195195 }
196196
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) {
198198 var conds []string
199199 var args []any
200200
@@ -207,14 +207,14 @@ func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, o
207207 args = append(args, feedURL)
208208 }
209209
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`
211211 if len(conds) > 0 {
212212 query += ` WHERE ` + strings.Join(conds, " AND ")
213213 }
214214 query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`
215215 args = append(args, limit, offset)
216216
217- rows, err := db.QueryContext(ctx, query, args...)
217+ rows, err := s.db.QueryContext(ctx, query, args...)
218218 if err != nil {
219219 return nil, err
220220 }
@@ -231,18 +231,18 @@ func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, o
231231 return likes, rows.Err()
232232 }
233233
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) {
235235 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 = ?
238238 `, feedURL, articleURL).Scan(&count)
239239 return count, err
240240 }
241241
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) {
243243 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
246246 WHERE author_did = ? AND feed_url = ? AND article_url = ?
247247 `, authorDID, feedURL, articleURL).Scan(&l.ID, &l.URI, &l.AuthorDID, &l.FeedURL, &l.ArticleURL, &l.CreatedAt, &l.CID)
248248 if err != nil {
@@ -251,10 +251,10 @@ func (db *DB) GetLike(ctx context.Context, authorDID, feedURL, articleURL string
251251 return l, nil
252252 }
253253
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) {
255255 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 = ?
258258 `, authorDID, feedURL, articleURL).Scan(&exists)
259259 if err == sql.ErrNoRows {
260260 return false, nil
@@ -279,19 +279,19 @@ type TrendingItem struct {
279279 HasLiked bool
280280 }
281281
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, `
284284 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),
285285 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),
286286 COALESCE(f.favicon_url, ''),
287287 COUNT(DISTINCT l.id) AS like_count,
288288 COUNT(DISTINCT a.id) AS annotation_count,
289289 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 = ?
295295 WHERE l.created_at >= ?
296296 AND l.author_did IN (
297297 SELECT CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END
@@ -323,19 +323,19 @@ func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since st
323323 return results, rows.Err()
324324 }
325325
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, `
328328 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),
329329 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),
330330 COALESCE(f.favicon_url, ''),
331331 COUNT(DISTINCT l.id) AS like_count,
332332 COUNT(DISTINCT a.id) AS annotation_count,
333333 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 = ?
339339 WHERE l.created_at >= ?
340340 GROUP BY ar.id
341341 -- Future-published articles (e.g., scheduled) sort last
@@ -360,19 +360,19 @@ func (db *DB) ListTrendingArticles(ctx context.Context, userDID, since string, l
360360 return results, rows.Err()
361361 }
362362
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, `
365365 SELECT DISTINCT a.id, a.feed_url, a.guid, a.title, a.url, a.author, a.summary, a.content,
366366 a.published, a.updated, a.fetched_at,
367367 COALESCE(f.title, ''),
368368 COALESCE(r.is_read, 0),
369369 COALESCE(lc.cnt, 0),
370370 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
374374 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
376376 ON lc.feed_url = a.feed_url AND lc.article_url = a.url
377377 WHERE l.author_did = ?
378378 ORDER BY l.created_at DESC
@@ -35,17 +35,17 @@ type Like struct {
35 CID sql.NullString35 CID sql.NullString
36 }36 }
37 37
38-func (db *DB) CreateAnnotation(ctx context.Context, a *Annotation) error {38+func (s *ArticleStore) CreateAnnotation(ctx context.Context, a *Annotation) error {
39- return db.BatchCreateAnnotations(ctx, []*Annotation{a})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 a := &Annotation{}43 a := &Annotation{}
44- err := db.QueryRowContext(ctx, `44+ err := s.db.QueryRowContext(ctx, `
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.cid45 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 a46+ FROM articles.annotations a
47 LEFT JOIN users u ON a.author_did = u.did47 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_url48+ LEFT JOIN articles.articles ar ON ar.url = a.article_url AND ar.feed_url = a.feed_url
49 WHERE a.id = ?49 WHERE a.id = ?
50 `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, &a.ArticleID,50 `, id).Scan(&a.ID, &a.URI, &a.AuthorDID, &a.AuthorHandle, &a.FeedURL, &a.ArticleURL, &a.ArticleID,
51 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID)51 &a.Quote, &a.Note, &a.Tags, &a.Rating, &a.CreatedAt, &a.CID)
@@ -55,14 +55,14 @@ func (db *DB) GetAnnotation(ctx context.Context, id int64) (*Annotation, error)
55 return a, nil55 return a, nil
56 }56 }
57 57
58-func (db *DB) DeleteAnnotation(ctx context.Context, uri string) error {58+func (s *ArticleStore) DeleteAnnotation(ctx context.Context, uri string) error {
59- _, err := db.ExecContext(ctx, `DELETE FROM annotations WHERE uri = ?`, uri)59+ _, err := s.db.ExecContext(ctx, `DELETE FROM articles.annotations WHERE uri = ?`, uri)
60 return err60 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 var exists int64 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 if err == sql.ErrNoRows {66 if err == sql.ErrNoRows {
67 return false, nil67 return false, nil
68 }68 }
@@ -72,7 +72,7 @@ func (db *DB) AnnotationExists(ctx context.Context, uri string) (bool, error) {
72 return true, nil72 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 var conds []string76 var conds []string
77 var args []any77 var args []any
78 78
@@ -90,16 +90,16 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
90 }90 }
91 91
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.cid92 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 a93+ FROM articles.annotations a
94 LEFT JOIN users u ON a.author_did = u.did94 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 if len(conds) > 0 {96 if len(conds) > 0 {
97 query += ` WHERE ` + strings.Join(conds, " AND ")97 query += ` WHERE ` + strings.Join(conds, " AND ")
98 }98 }
99 query += ` ORDER BY a.created_at DESC LIMIT ? OFFSET ?`99 query += ` ORDER BY a.created_at DESC LIMIT ? OFFSET ?`
100 args = append(args, limit, offset)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 if err != nil {103 if err != nil {
104 return nil, err104 return nil, err
105 }105 }
@@ -117,18 +117,18 @@ func (db *DB) ListAnnotations(ctx context.Context, feedURL, articleURL, authorDI
117 return annotations, rows.Err()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 if len(likes) == 0 {121 if len(likes) == 0 {
122 return nil122 return nil
123 }123 }
124- tx, err := db.BeginTx(ctx, nil)124+ tx, err := s.db.BeginTx(ctx, nil)
125 if err != nil {125 if err != nil {
126 return err126 return err
127 }127 }
128 defer tx.Rollback()128 defer tx.Rollback()
129 129
130 stmt, err := tx.PrepareContext(ctx, `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 VALUES (?, ?, ?, ?, ?, ?)132 VALUES (?, ?, ?, ?, ?, ?)
133 `)133 `)
134 if err != nil {134 if err != nil {
@@ -144,18 +144,18 @@ func (db *DB) BatchCreateLikes(ctx context.Context, likes []*Like) error {
144 return tx.Commit()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 if len(annotations) == 0 {148 if len(annotations) == 0 {
149 return nil149 return nil
150 }150 }
151- tx, err := db.BeginTx(ctx, nil)151+ tx, err := s.db.BeginTx(ctx, nil)
152 if err != nil {152 if err != nil {
153 return err153 return err
154 }154 }
155 defer tx.Rollback()155 defer tx.Rollback()
156 156
157 stmt, err := tx.PrepareContext(ctx, `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 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)159 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
160 `)160 `)
161 if err != nil {161 if err != nil {
@@ -171,30 +171,30 @@ func (db *DB) BatchCreateAnnotations(ctx context.Context, annotations []*Annotat
171 return tx.Commit()171 return tx.Commit()
172 }172 }
173 173
174-func (db *DB) CreateLike(ctx context.Context, l *Like) error {174+func (s *ArticleStore) CreateLike(ctx context.Context, l *Like) error {
175- exists, err := db.HasLiked(ctx, l.AuthorDID, l.FeedURL, l.ArticleURL)175+ exists, err := s.HasLiked(ctx, l.AuthorDID, l.FeedURL, l.ArticleURL)
176 if err != nil {176 if err != nil {
177 return err177 return err
178 }178 }
179 if exists {179 if exists {
180 return ErrDuplicateLike180 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 {185+func (s *ArticleStore) DeleteLike(ctx context.Context, uri string) error {
186- _, err := db.ExecContext(ctx, `DELETE FROM likes WHERE uri = ?`, uri)186+ _, err := s.db.ExecContext(ctx, `DELETE FROM articles.likes WHERE uri = ?`, uri)
187 return err187 return err
188 }188 }
189 189
190-func (db *DB) DeleteLikeByUserArticle(ctx context.Context, authorDID, feedURL, articleURL string) error {190+func (s *ArticleStore) DeleteLikeByUserArticle(ctx context.Context, authorDID, feedURL, articleURL string) error {
191- _, err := db.ExecContext(ctx, `191+ _, err := s.db.ExecContext(ctx, `
192- DELETE FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ?192+ DELETE FROM articles.likes WHERE author_did = ? AND feed_url = ? AND article_url = ?
193 `, authorDID, feedURL, articleURL)193 `, authorDID, feedURL, articleURL)
194 return err194 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 var conds []string198 var conds []string
199 var args []any199 var args []any
200 200
@@ -207,14 +207,14 @@ func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, o
207 args = append(args, feedURL)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 if len(conds) > 0 {211 if len(conds) > 0 {
212 query += ` WHERE ` + strings.Join(conds, " AND ")212 query += ` WHERE ` + strings.Join(conds, " AND ")
213 }213 }
214 query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`214 query += ` ORDER BY created_at DESC LIMIT ? OFFSET ?`
215 args = append(args, limit, offset)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 if err != nil {218 if err != nil {
219 return nil, err219 return nil, err
220 }220 }
@@ -231,18 +231,18 @@ func (db *DB) ListLikes(ctx context.Context, authorDID, feedURL string, limit, o
231 return likes, rows.Err()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 var count int235 var count int
236- err := db.QueryRowContext(ctx, `236+ err := s.db.QueryRowContext(ctx, `
237- SELECT COUNT(*) FROM likes WHERE feed_url = ? AND article_url = ?237+ SELECT COUNT(*) FROM articles.likes WHERE feed_url = ? AND article_url = ?
238 `, feedURL, articleURL).Scan(&count)238 `, feedURL, articleURL).Scan(&count)
239 return count, err239 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 l := &Like{}243 l := &Like{}
244- err := db.QueryRowContext(ctx, `244+ err := s.db.QueryRowContext(ctx, `
245- SELECT id, uri, author_did, feed_url, article_url, created_at, cid FROM likes245+ SELECT id, uri, author_did, feed_url, article_url, created_at, cid FROM articles.likes
246 WHERE author_did = ? AND feed_url = ? AND article_url = ?246 WHERE author_did = ? AND feed_url = ? AND article_url = ?
247 `, authorDID, feedURL, articleURL).Scan(&l.ID, &l.URI, &l.AuthorDID, &l.FeedURL, &l.ArticleURL, &l.CreatedAt, &l.CID)247 `, authorDID, feedURL, articleURL).Scan(&l.ID, &l.URI, &l.AuthorDID, &l.FeedURL, &l.ArticleURL, &l.CreatedAt, &l.CID)
248 if err != nil {248 if err != nil {
@@ -251,10 +251,10 @@ func (db *DB) GetLike(ctx context.Context, authorDID, feedURL, articleURL string
251 return l, nil251 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 var exists int255 var exists int
256- err := db.QueryRowContext(ctx, `256+ err := s.db.QueryRowContext(ctx, `
257- SELECT 1 FROM likes WHERE author_did = ? AND feed_url = ? AND article_url = ?257+ SELECT 1 FROM articles.likes WHERE author_did = ? AND feed_url = ? AND article_url = ?
258 `, authorDID, feedURL, articleURL).Scan(&exists)258 `, authorDID, feedURL, articleURL).Scan(&exists)
259 if err == sql.ErrNoRows {259 if err == sql.ErrNoRows {
260 return false, nil260 return false, nil
@@ -279,19 +279,19 @@ type TrendingItem struct {
279 HasLiked bool279 HasLiked bool
280 }280 }
281 281
282-func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) {282+func (s *ArticleStore) ListTrendingArticlesForUser(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) {
283- rows, err := db.QueryContext(ctx, `283+ rows, err := s.db.QueryContext(ctx, `
284 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),284 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),
285 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),285 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),
286 COALESCE(f.favicon_url, ''),286 COALESCE(f.favicon_url, ''),
287 COUNT(DISTINCT l.id) AS like_count,287 COUNT(DISTINCT l.id) AS like_count,
288 COUNT(DISTINCT a.id) AS annotation_count,288 COUNT(DISTINCT a.id) AS annotation_count,
289 COALESCE(MAX(CASE WHEN ul.id IS NOT NULL THEN 1 ELSE 0 END), 0)289 COALESCE(MAX(CASE WHEN ul.id IS NOT NULL THEN 1 ELSE 0 END), 0)
290- FROM likes l290+ FROM articles.likes l
291- JOIN articles ar ON ar.url = l.article_url AND ar.feed_url = l.feed_url291+ JOIN articles.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_url292+ LEFT JOIN articles.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 >= ?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 likes ul ON ul.feed_url = l.feed_url AND ul.article_url = l.article_url AND ul.author_did = ?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 WHERE l.created_at >= ?295 WHERE l.created_at >= ?
296 AND l.author_did IN (296 AND l.author_did IN (
297 SELECT CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END297 SELECT CASE WHEN us.user_a = ? THEN us.user_b ELSE us.user_a END
@@ -323,19 +323,19 @@ func (db *DB) ListTrendingArticlesForUser(ctx context.Context, userDID, since st
323 return results, rows.Err()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) {326+func (s *ArticleStore) ListTrendingArticles(ctx context.Context, userDID, since string, limit, offset int) ([]*TrendingItem, error) {
327- rows, err := db.QueryContext(ctx, `327+ rows, err := s.db.QueryContext(ctx, `
328 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),328 SELECT ar.id, ar.title, COALESCE(ar.url, ''), COALESCE(ar.author, ''),
329 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),329 COALESCE(ar.summary, ''), l.feed_url, COALESCE(f.title, ''),
330 COALESCE(f.favicon_url, ''),330 COALESCE(f.favicon_url, ''),
331 COUNT(DISTINCT l.id) AS like_count,331 COUNT(DISTINCT l.id) AS like_count,
332 COUNT(DISTINCT a.id) AS annotation_count,332 COUNT(DISTINCT a.id) AS annotation_count,
333 COALESCE(MAX(CASE WHEN ul.id IS NOT NULL THEN 1 ELSE 0 END), 0)333 COALESCE(MAX(CASE WHEN ul.id IS NOT NULL THEN 1 ELSE 0 END), 0)
334- FROM likes l334+ FROM articles.likes l
335- JOIN articles ar ON ar.url = l.article_url AND ar.feed_url = l.feed_url335+ JOIN articles.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_url336+ LEFT JOIN articles.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 >= ?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 likes ul ON ul.feed_url = l.feed_url AND ul.article_url = l.article_url AND ul.author_did = ?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 WHERE l.created_at >= ?339 WHERE l.created_at >= ?
340 GROUP BY ar.id340 GROUP BY ar.id
341 -- Future-published articles (e.g., scheduled) sort last341 -- Future-published articles (e.g., scheduled) sort last
@@ -360,19 +360,19 @@ func (db *DB) ListTrendingArticles(ctx context.Context, userDID, since string, l
360 return results, rows.Err()360 return results, rows.Err()
361 }361 }
362 362
363-func (db *DB) ListLikedArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) {363+func (s *ArticleStore) ListLikedArticles(ctx context.Context, userDID string, limit, offset int) ([]*Article, error) {
364- rows, err := db.QueryContext(ctx, `364+ rows, err := s.db.QueryContext(ctx, `
365 SELECT DISTINCT a.id, a.feed_url, a.guid, a.title, a.url, a.author, a.summary, a.content,365 SELECT DISTINCT a.id, a.feed_url, a.guid, a.title, a.url, a.author, a.summary, a.content,
366 a.published, a.updated, a.fetched_at,366 a.published, a.updated, a.fetched_at,
367 COALESCE(f.title, ''),367 COALESCE(f.title, ''),
368 COALESCE(r.is_read, 0),368 COALESCE(r.is_read, 0),
369 COALESCE(lc.cnt, 0),369 COALESCE(lc.cnt, 0),
370 1370 1
371- FROM likes l371+ FROM articles.likes l
372- JOIN articles a ON a.url = l.article_url AND a.feed_url = l.feed_url372+ JOIN articles.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_url373+ LEFT JOIN articles.feeds f ON f.feed_url = a.feed_url
374 LEFT JOIN read_state r ON r.user_did = ? AND r.article_id = a.id374 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) lc375+ LEFT JOIN (SELECT feed_url, article_url, COUNT(*) as cnt FROM articles.likes GROUP BY feed_url, article_url) lc
376 ON lc.feed_url = a.feed_url AND lc.article_url = a.url376 ON lc.feed_url = a.feed_url AND lc.article_url = a.url
377 WHERE l.author_did = ?377 WHERE l.author_did = ?
378 ORDER BY l.created_at DESC378 ORDER BY l.created_at DESC
modified internal/db/store.go +8 -8
@@ -8,15 +8,15 @@ import (
88 )
99
1010 type FeedStoreAdapter struct {
11- db *DB
11+ store *ArticleStore
1212 }
1313
14-func NewFeedStoreAdapter(db *DB) *FeedStoreAdapter {
15- return &FeedStoreAdapter{db: db}
14+func NewFeedStoreAdapter(store *ArticleStore) *FeedStoreAdapter {
15+ return &FeedStoreAdapter{store: store}
1616 }
1717
1818 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)
2020 if err != nil {
2121 return nil, err
2222 }
@@ -37,20 +37,20 @@ func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, olderThan time.D
3737 }
3838
3939 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)
4141 }
4242
4343 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 {
4545 return err
4646 }
4747 if len(articles) > 0 {
48- if err := a.db.UpsertArticlesBatch(ctx, articles); err != nil {
48+ if err := a.store.UpsertArticlesBatch(ctx, articles); err != nil {
4949 return err
5050 }
5151 }
5252 if faviconURL != "" {
53- if err := a.db.UpdateFeedFavicon(ctx, feedURL, faviconURL); err != nil {
53+ if err := a.store.UpdateFeedFavicon(ctx, feedURL, faviconURL); err != nil {
5454 return err
5555 }
5656 }
@@ -8,15 +8,15 @@ import (
8 )8 )
9 9
10 type FeedStoreAdapter struct {10 type FeedStoreAdapter struct {
11- db *DB11+ store *ArticleStore
12 }12 }
13 13
14-func NewFeedStoreAdapter(db *DB) *FeedStoreAdapter {14+func NewFeedStoreAdapter(store *ArticleStore) *FeedStoreAdapter {
15- return &FeedStoreAdapter{db: db}15+ return &FeedStoreAdapter{store: store}
16 }16 }
17 17
18 func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, olderThan time.Duration, limit int) ([]*feed.Feed, error) {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 if err != nil {20 if err != nil {
21 return nil, err21 return nil, err
22 }22 }
@@ -37,20 +37,20 @@ func (a *FeedStoreAdapter) GetFeedsToFetch(ctx context.Context, olderThan time.D
37 }37 }
38 38
39 func (a *FeedStoreAdapter) RecordFetchError(ctx context.Context, feedURL, lastError string) error {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 func (a *FeedStoreAdapter) StoreFetchResult(ctx context.Context, feedURL, etag, lastModified string, articles []feed.Article, faviconURL string) error {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 return err45 return err
46 }46 }
47 if len(articles) > 0 {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 return err49 return err
50 }50 }
51 }51 }
52 if faviconURL != "" {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 return err54 return err
55 }55 }
56 }56 }
modified internal/db/user.go +23 -15
@@ -21,11 +21,19 @@ type UserData struct {
2121 AvatarURL string
2222 }
2323
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 {
2533 if len(users) == 0 {
2634 return nil
2735 }
28- tx, err := db.BeginTx(ctx, nil)
36+ tx, err := s.db.BeginTx(ctx, nil)
2937 if err != nil {
3038 return err
3139 }
@@ -53,17 +61,17 @@ func (db *DB) BatchCreateUsers(ctx context.Context, users []UserData) error {
5361 return tx.Commit()
5462 }
5563
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}})
5866 if err != nil {
5967 return nil, err
6068 }
61- return db.GetUser(ctx, did)
69+ return s.GetUser(ctx, did)
6270 }
6371
64-func (db *DB) GetUser(ctx context.Context, did string) (*User, error) {
72+func (s *UserStore) GetUser(ctx context.Context, did string) (*User, error) {
6573 u := &User{}
66- err := db.QueryRowContext(ctx, `
74+ err := s.db.QueryRowContext(ctx, `
6775 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at
6876 FROM users WHERE did = ?
6977 `, did).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt)
@@ -73,9 +81,9 @@ func (db *DB) GetUser(ctx context.Context, did string) (*User, error) {
7381 return u, nil
7482 }
7583
76-func (db *DB) GetUserByHandle(ctx context.Context, handle string) (*User, error) {
84+func (s *UserStore) GetUserByHandle(ctx context.Context, handle string) (*User, error) {
7785 u := &User{}
78- err := db.QueryRowContext(ctx, `
86+ err := s.db.QueryRowContext(ctx, `
7987 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at
8088 FROM users WHERE handle = ?
8189 `, handle).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt)
@@ -85,8 +93,8 @@ func (db *DB) GetUserByHandle(ctx context.Context, handle string) (*User, error)
8593 return u, nil
8694 }
8795
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`)
9098 if err != nil {
9199 return nil, err
92100 }
@@ -103,8 +111,8 @@ func (db *DB) ListUserDIDs(ctx context.Context) (map[string]bool, error) {
103111 return dids, rows.Err()
104112 }
105113
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, `
108116 UPDATE users SET
109117 display_name = COALESCE(NULLIF(?, ''), display_name),
110118 avatar_url = COALESCE(NULLIF(?, ''), avatar_url),
@@ -114,8 +122,8 @@ func (db *DB) UpdateUserProfile(ctx context.Context, did, displayName, avatarURL
114122 return err
115123 }
116124
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, `
119127 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at
120128 FROM users ORDER BY updated_at DESC
121129 `)
@@ -21,11 +21,19 @@ type UserData struct {
21 AvatarURL string21 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 if len(users) == 0 {33 if len(users) == 0 {
26 return nil34 return nil
27 }35 }
28- tx, err := db.BeginTx(ctx, nil)36+ tx, err := s.db.BeginTx(ctx, nil)
29 if err != nil {37 if err != nil {
30 return err38 return err
31 }39 }
@@ -53,17 +61,17 @@ func (db *DB) BatchCreateUsers(ctx context.Context, users []UserData) error {
53 return tx.Commit()61 return tx.Commit()
54 }62 }
55 63
56-func (db *DB) CreateUser(ctx context.Context, did, handle, displayName, avatarURL string) (*User, error) {64+func (s *UserStore) 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}})65+ err := s.BatchCreateUsers(ctx, []UserData{{DID: did, Handle: handle, DisplayName: displayName, AvatarURL: avatarURL}})
58 if err != nil {66 if err != nil {
59 return nil, err67 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 u := &User{}73 u := &User{}
66- err := db.QueryRowContext(ctx, `74+ err := s.db.QueryRowContext(ctx, `
67 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at75 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at
68 FROM users WHERE did = ?76 FROM users WHERE did = ?
69 `, did).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt)77 `, did).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt)
@@ -73,9 +81,9 @@ func (db *DB) GetUser(ctx context.Context, did string) (*User, error) {
73 return u, nil81 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 u := &User{}85 u := &User{}
78- err := db.QueryRowContext(ctx, `86+ err := s.db.QueryRowContext(ctx, `
79 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at87 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at
80 FROM users WHERE handle = ?88 FROM users WHERE handle = ?
81 `, handle).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt)89 `, handle).Scan(&u.DID, &u.Handle, &u.DisplayName, &u.AvatarURL, &u.IndexedAt, &u.UpdatedAt)
@@ -85,8 +93,8 @@ func (db *DB) GetUserByHandle(ctx context.Context, handle string) (*User, error)
85 return u, nil93 return u, nil
86 }94 }
87 95
88-func (db *DB) ListUserDIDs(ctx context.Context) (map[string]bool, error) {96+func (s *UserStore) ListUserDIDs(ctx context.Context) (map[string]bool, error) {
89- rows, err := db.QueryContext(ctx, `SELECT did FROM users`)97+ rows, err := s.db.QueryContext(ctx, `SELECT did FROM users`)
90 if err != nil {98 if err != nil {
91 return nil, err99 return nil, err
92 }100 }
@@ -103,8 +111,8 @@ func (db *DB) ListUserDIDs(ctx context.Context) (map[string]bool, error) {
103 return dids, rows.Err()111 return dids, rows.Err()
104 }112 }
105 113
106-func (db *DB) UpdateUserProfile(ctx context.Context, did, displayName, avatarURL string) error {114+func (s *UserStore) UpdateUserProfile(ctx context.Context, did, displayName, avatarURL string) error {
107- _, err := db.ExecContext(ctx, `115+ _, err := s.db.ExecContext(ctx, `
108 UPDATE users SET116 UPDATE users SET
109 display_name = COALESCE(NULLIF(?, ''), display_name),117 display_name = COALESCE(NULLIF(?, ''), display_name),
110 avatar_url = COALESCE(NULLIF(?, ''), avatar_url),118 avatar_url = COALESCE(NULLIF(?, ''), avatar_url),
@@ -114,8 +122,8 @@ func (db *DB) UpdateUserProfile(ctx context.Context, did, displayName, avatarURL
114 return err122 return err
115 }123 }
116 124
117-func (db *DB) ListUsers(ctx context.Context) ([]*User, error) {125+func (s *UserStore) ListUsers(ctx context.Context) ([]*User, error) {
118- rows, err := db.QueryContext(ctx, `126+ rows, err := s.db.QueryContext(ctx, `
119 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at127 SELECT did, handle, display_name, avatar_url, indexed_at, updated_at
120 FROM users ORDER BY updated_at DESC128 FROM users ORDER BY updated_at DESC
121 `)129 `)
modified internal/db/user_test.go +8 -8
@@ -9,15 +9,15 @@ import (
99
1010 func TestUpdateUserProfile_SetsFields(t *testing.T) {
1111 ctx := context.Background()
12- db := setupTestDB(t)
12+ dbs := setupTestDB(t)
1313
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")
1515 assert.NilError(t, err)
1616
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")
1818 assert.NilError(t, err)
1919
20- u, err := db.GetUser(ctx, "did:test:profile")
20+ u, err := dbs.Users.GetUser(ctx, "did:test:profile")
2121 assert.NilError(t, err)
2222 assert.Equal(t, u.DisplayName.String, "Display Name")
2323 assert.Equal(t, u.AvatarURL.String, "https://cdn.bsky.app/img/avatar.png")
@@ -25,16 +25,16 @@ func TestUpdateUserProfile_SetsFields(t *testing.T) {
2525
2626 func TestUpdateUserProfile_DoesNotOverwriteWithEmpty(t *testing.T) {
2727 ctx := context.Background()
28- db := setupTestDB(t)
28+ dbs := setupTestDB(t)
2929
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 (?, ?, ?, ?)`,
3131 "did:test:profile2", "tester2", "Existing Name", "https://old.avatar/url")
3232 assert.NilError(t, err)
3333
34- err = db.UpdateUserProfile(ctx, "did:test:profile2", "", "")
34+ err = dbs.Users.UpdateUserProfile(ctx, "did:test:profile2", "", "")
3535 assert.NilError(t, err)
3636
37- u, err := db.GetUser(ctx, "did:test:profile2")
37+ u, err := dbs.Users.GetUser(ctx, "did:test:profile2")
3838 assert.NilError(t, err)
3939 assert.Equal(t, u.DisplayName.String, "Existing Name")
4040 assert.Equal(t, u.AvatarURL.String, "https://old.avatar/url")
@@ -9,15 +9,15 @@ import (
9 9
10 func TestUpdateUserProfile_SetsFields(t *testing.T) {10 func TestUpdateUserProfile_SetsFields(t *testing.T) {
11 ctx := context.Background()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 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)21 assert.NilError(t, err)
22 assert.Equal(t, u.DisplayName.String, "Display Name")22 assert.Equal(t, u.DisplayName.String, "Display Name")
23 assert.Equal(t, u.AvatarURL.String, "https://cdn.bsky.app/img/avatar.png")23 assert.Equal(t, u.AvatarURL.String, "https://cdn.bsky.app/img/avatar.png")
@@ -25,16 +25,16 @@ func TestUpdateUserProfile_SetsFields(t *testing.T) {
25 25
26 func TestUpdateUserProfile_DoesNotOverwriteWithEmpty(t *testing.T) {26 func TestUpdateUserProfile_DoesNotOverwriteWithEmpty(t *testing.T) {
27 ctx := context.Background()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 "did:test:profile2", "tester2", "Existing Name", "https://old.avatar/url")31 "did:test:profile2", "tester2", "Existing Name", "https://old.avatar/url")
32 assert.NilError(t, err)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 assert.NilError(t, err)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 assert.NilError(t, err)38 assert.NilError(t, err)
39 assert.Equal(t, u.DisplayName.String, "Existing Name")39 assert.Equal(t, u.DisplayName.String, "Existing Name")
40 assert.Equal(t, u.AvatarURL.String, "https://old.avatar/url")40 assert.Equal(t, u.AvatarURL.String, "https://old.avatar/url")
modified internal/server/server.go +2 -2
@@ -73,7 +73,7 @@ type Server struct {
7373 }
7474
7575 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)
7777
7878 var config oauth.ClientConfig
7979 if clientID == "" {
@@ -202,7 +202,7 @@ func (s *Server) setupRoutes() {
202202 s.router.Post("/auth/logout", s.handleAuthLogout)
203203 s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata)
204204
205- xrpc := atproto.NewXRPCHandler(s.dbs.Articles.DB, s.engine)
205+ xrpc := atproto.NewXRPCHandler(s.dbs.DB(), s.engine)
206206 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
207207 s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)
208208 s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)
@@ -73,7 +73,7 @@ type Server struct {
73 }73 }
74 74
75 func New(dbs *db.Databases, clientID, callbackURL, addr string, scheduler *feed.Scheduler, engine *cluster.Engine, logger *slog.Logger) *Server {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 var config oauth.ClientConfig78 var config oauth.ClientConfig
79 if clientID == "" {79 if clientID == "" {
@@ -202,7 +202,7 @@ func (s *Server) setupRoutes() {
202 s.router.Post("/auth/logout", s.handleAuthLogout)202 s.router.Post("/auth/logout", s.handleAuthLogout)
203 s.router.Get("/oauth/client-metadata", s.handleOAuthClientMetadata)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 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)206 s.router.Get("/xrpc/at.glean.listSubscriptions", xrpc.ListSubscriptions)
207 s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)207 s.router.Get("/xrpc/at.glean.listAnnotations", xrpc.ListAnnotations)
208 s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)208 s.router.Get("/xrpc/at.glean.listLikes", xrpc.ListLikes)
modified main.go +1 -1
@@ -47,7 +47,7 @@ func main() {
4747 storeAdapter := db.NewFeedStoreAdapter(dbs.Articles)
4848 scheduler := feed.NewScheduler(storeAdapter, logger, *fetchInterval, 30*time.Minute)
4949
50- engine := cluster.NewEngine(dbs.Users.DB, logger)
50+ engine := cluster.NewEngine(dbs.DB(), logger)
5151
5252 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, engine, logger)
5353
@@ -47,7 +47,7 @@ func main() {
47 storeAdapter := db.NewFeedStoreAdapter(dbs.Articles)47 storeAdapter := db.NewFeedStoreAdapter(dbs.Articles)
48 scheduler := feed.NewScheduler(storeAdapter, logger, *fetchInterval, 30*time.Minute)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 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, engine, logger)52 srv := server.New(dbs, clientID, callbackURL, *addr, scheduler, engine, logger)
53 53