···12121313func (e *Engine) DismissFeed(ctx context.Context, userDID, feedURL, reason string) error {
1414 _, err := e.db.ExecContext(ctx, `
1515- INSERT INTO dismissed_recommendations (user_did, target_type, target_id, reason)
1515+ INSERT INTO recs.dismissed_recommendations (user_did, target_type, target_id, reason)
1616 VALUES (?, 'feed', ?, ?)
1717 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP
1818 `, userDID, feedURL, reason)
···21212222func (e *Engine) DismissArticle(ctx context.Context, userDID, articleURL, reason string) error {
2323 _, err := e.db.ExecContext(ctx, `
2424- INSERT INTO dismissed_recommendations (user_did, target_type, target_id, reason)
2424+ INSERT INTO recs.dismissed_recommendations (user_did, target_type, target_id, reason)
2525 VALUES (?, 'article', ?, ?)
2626 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET reason = excluded.reason, dismissed_at = CURRENT_TIMESTAMP
2727 `, userDID, articleURL, reason)
···37373838 for _, imp := range impressions {
3939 _, err := tx.ExecContext(ctx, `
4040- INSERT INTO recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count)
4040+ INSERT INTO recs.recommendation_impressions (user_did, target_type, target_id, first_shown_at, last_shown_at, shown_count)
4141 VALUES (?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 1)
4242 ON CONFLICT(user_did, target_type, target_id) DO UPDATE SET
4343 last_shown_at = CURRENT_TIMESTAMP,
···52525353func (e *Engine) MarkImpressionActed(ctx context.Context, userDID, targetType, targetID string) error {
5454 _, err := e.db.ExecContext(ctx, `
5555- UPDATE recommendation_impressions SET acted = 1
5555+ UPDATE recs.recommendation_impressions SET acted = 1
5656 WHERE user_did = ? AND target_type = ? AND target_id = ?
5757 `, userDID, targetType, targetID)
5858 return err
···6262 cutoff := time.Now().AddDate(0, 0, -maxAgeDays).Format(time.RFC3339)
63636464 _, err := e.db.ExecContext(ctx, `
6565- INSERT OR IGNORE INTO dismissed_recommendations (user_did, target_type, target_id, reason, dismissed_at)
6565+ INSERT OR IGNORE INTO recs.dismissed_recommendations (user_did, target_type, target_id, reason, dismissed_at)
6666 SELECT user_did, target_type, target_id, 'auto_stale', CURRENT_TIMESTAMP
6767- FROM recommendation_impressions
6767+ FROM recs.recommendation_impressions
6868 WHERE acted = 0
6969 AND shown_count >= ?
7070 AND first_shown_at < ?
···7575func (e *Engine) IsFeedDismissed(ctx context.Context, userDID, feedURL string) (bool, error) {
7676 var count int
7777 err := e.db.QueryRowContext(ctx, `
7878- SELECT COUNT(1) FROM dismissed_recommendations
7878+ SELECT COUNT(1) FROM recs.dismissed_recommendations
7979 WHERE user_did = ? AND target_type = 'feed' AND target_id = ?
8080 `, userDID, feedURL).Scan(&count)
8181 return count > 0, err
8282-}
8282+}
+39-39
internal/cluster/jaccard.go
···4242 }
4343 defer func() { _ = tx.Rollback() }()
44444545- if _, err := tx.ExecContext(ctx, `DELETE FROM feed_similarity`); err != nil {
4545+ if _, err := tx.ExecContext(ctx, `DELETE FROM recs.feed_similarity`); err != nil {
4646 return err
4747 }
48484949 _, err = tx.ExecContext(ctx, `
5050- INSERT INTO feed_similarity (feed_a, feed_b, jaccard)
5050+ INSERT INTO recs.feed_similarity (feed_a, feed_b, jaccard)
5151 SELECT
5252 s1.feed_url,
5353 s2.feed_url,
5454 CAST(COUNT(*) AS REAL) / (f1.subscriber_count + f2.subscriber_count - CAST(COUNT(*) AS REAL))
5555- FROM subscriptions s1
5656- JOIN subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
5757- JOIN feeds f1 ON f1.feed_url = s1.feed_url
5858- JOIN feeds f2 ON f2.feed_url = s2.feed_url
5555+ FROM articles.subscriptions s1
5656+ JOIN articles.subscriptions s2 ON s1.user_did = s2.user_did AND s1.feed_url < s2.feed_url
5757+ JOIN articles.feeds f1 ON f1.feed_url = s1.feed_url
5858+ JOIN articles.feeds f2 ON f2.feed_url = s2.feed_url
5959 GROUP BY s1.feed_url, s2.feed_url
6060 `)
6161 if err != nil {
···8282 INSERT INTO _feed_words (feed_url, word)
8383 WITH feed_tokens AS (
8484 SELECT feed_url, LOWER(TRIM(value)) AS word
8585- FROM feeds,
8585+ FROM articles.feeds,
8686 json_each('["' || REPLACE(LOWER(COALESCE(description, '')), ' ', '","') || '"]')
8787 WHERE description IS NOT NULL AND description != ''
8888 )
···140140 }
141141142142 descInsert := `
143143- INSERT OR IGNORE INTO feed_similarity (feed_a, feed_b, jaccard)
143143+ INSERT OR IGNORE INTO recs.feed_similarity (feed_a, feed_b, jaccard)
144144 SELECT feed_a, feed_b, 0 FROM _word_overlap
145145 `
146146 if _, err := tx.ExecContext(ctx, descInsert); err != nil {
···148148 }
149149150150 descUpdate := fmt.Sprintf(`
151151- UPDATE feed_similarity SET
151151+ UPDATE recs.feed_similarity SET
152152 jaccard = jaccard + %g * CAST(_word_overlap.common AS REAL) / NULLIF(
153153- (SELECT cnt FROM _feed_word_counts WHERE feed_url = feed_similarity.feed_a) +
154154- (SELECT cnt FROM _feed_word_counts WHERE feed_url = feed_similarity.feed_b) -
153153+ (SELECT cnt FROM _feed_word_counts WHERE feed_url = recs.feed_similarity.feed_a) +
154154+ (SELECT cnt FROM _feed_word_counts WHERE feed_url = recs.feed_similarity.feed_b) -
155155 CAST(_word_overlap.common AS REAL),
156156 0
157157 )
158158 FROM _word_overlap
159159- WHERE feed_similarity.feed_a = _word_overlap.feed_a
160160- AND feed_similarity.feed_b = _word_overlap.feed_b
159159+ WHERE recs.feed_similarity.feed_a = _word_overlap.feed_a
160160+ AND recs.feed_similarity.feed_b = _word_overlap.feed_b
161161 `, e.config.DescriptionWeight)
162162163163 if _, err := tx.ExecContext(ctx, descUpdate); err != nil {
···174174 }
175175 defer func() { _ = tx.Rollback() }()
176176177177- if _, err := tx.ExecContext(ctx, `DELETE FROM user_similarity`); err != nil {
177177+ if _, err := tx.ExecContext(ctx, `DELETE FROM recs.user_similarity`); err != nil {
178178 return err
179179 }
180180181181 _, err = tx.ExecContext(ctx, `
182182- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds)
182182+ INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds)
183183 SELECT
184184 s1.user_did,
185185 s2.user_did,
186186 CAST(COUNT(*) AS REAL) / (
187187- (SELECT COUNT(*) FROM subscriptions WHERE user_did = s1.user_did) +
188188- (SELECT COUNT(*) FROM subscriptions WHERE user_did = s2.user_did) -
187187+ (SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = s1.user_did) +
188188+ (SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = s2.user_did) -
189189 CAST(COUNT(*) AS REAL)
190190 ),
191191 COUNT(*)
192192- FROM subscriptions s1
193193- JOIN subscriptions s2 ON s1.feed_url = s2.feed_url AND s1.user_did < s2.user_did
192192+ FROM articles.subscriptions s1
193193+ JOIN articles.subscriptions s2 ON s1.feed_url = s2.feed_url AND s1.user_did < s2.user_did
194194 GROUP BY s1.user_did, s2.user_did
195195 `)
196196 if err != nil {
···207207 }
208208 if _, err := tx.ExecContext(ctx, `
209209 INSERT INTO _likes_count (author_did, cnt)
210210- SELECT author_did, COUNT(*) FROM likes GROUP BY author_did
210210+ SELECT author_did, COUNT(*) FROM articles.likes GROUP BY author_did
211211 `); err != nil {
212212 return err
213213 }
···227227 EXP(-0.023 * CAST(julianday('now') - julianday(l1.created_at) AS REAL))
228228 * EXP(-0.023 * CAST(julianday('now') - julianday(l2.created_at) AS REAL))
229229 ) AS INTEGER)
230230- FROM likes l1
231231- JOIN likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
230230+ FROM articles.likes l1
231231+ JOIN articles.likes l2 ON l1.feed_url = l2.feed_url AND l1.article_url = l2.article_url
232232 AND l1.author_did < l2.author_did
233233 WHERE l1.created_at IS NOT NULL AND l2.created_at IS NOT NULL
234234 GROUP BY l1.author_did, l2.author_did
···237237 }
238238239239 likesUpdate := fmt.Sprintf(`
240240- UPDATE user_similarity SET
240240+ UPDATE recs.user_similarity SET
241241 jaccard = jaccard + %g * CAST(_likes_overlap.common AS REAL) / NULLIF(
242242- (SELECT cnt FROM _likes_count WHERE author_did = user_similarity.user_a) +
243243- (SELECT cnt FROM _likes_count WHERE author_did = user_similarity.user_b) -
242242+ (SELECT cnt FROM _likes_count WHERE author_did = recs.user_similarity.user_a) +
243243+ (SELECT cnt FROM _likes_count WHERE author_did = recs.user_similarity.user_b) -
244244 CAST(_likes_overlap.common AS REAL),
245245 0
246246 ),
247247 common_likes = _likes_overlap.common
248248 FROM _likes_overlap
249249- WHERE user_similarity.user_a = _likes_overlap.user_a
250250- AND user_similarity.user_b = _likes_overlap.user_b
249249+ WHERE recs.user_similarity.user_a = _likes_overlap.user_a
250250+ AND recs.user_similarity.user_b = _likes_overlap.user_b
251251 `, e.config.LikesWeight)
252252253253 if _, err := tx.ExecContext(ctx, likesUpdate); err != nil {
···255255 }
256256257257 likesInsert := fmt.Sprintf(`
258258- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_likes)
258258+ INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_likes)
259259 SELECT sub.user_a, sub.user_b, sub.jaccard, 0, sub.common
260260 FROM (
261261 SELECT
···289289 _, err = tx.ExecContext(ctx, `
290290 INSERT INTO _tag_overlap (user_a, user_b, common)
291291 WITH user_tags AS (
292292- SELECT author_did, TRIM(value) AS tag FROM annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
292292+ SELECT author_did, TRIM(value) AS tag FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
293293 WHERE tags IS NOT NULL AND tags != ''
294294 )
295295 SELECT t1.author_did, t2.author_did, COUNT(DISTINCT t1.tag)
···312312 if _, err := tx.ExecContext(ctx, `
313313 INSERT INTO _tag_count (author_did, cnt)
314314 WITH user_tags AS (
315315- SELECT author_did, TRIM(value) AS tag FROM annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
315315+ SELECT author_did, TRIM(value) AS tag FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
316316 WHERE tags IS NOT NULL AND tags != ''
317317 )
318318 SELECT author_did, COUNT(DISTINCT tag) FROM user_tags GROUP BY author_did
···321321 }
322322323323 _, err = tx.ExecContext(ctx, `
324324- INSERT OR IGNORE INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_tags)
324324+ INSERT OR IGNORE INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_tags)
325325 SELECT user_a, user_b, 0, 0, 0 FROM _tag_overlap
326326 `)
327327 if err != nil {
···329329 }
330330331331 tagsUpdate := fmt.Sprintf(`
332332- UPDATE user_similarity SET
332332+ UPDATE recs.user_similarity SET
333333 jaccard = jaccard + %g * CAST(_tag_overlap.common AS REAL) / NULLIF(
334334- (SELECT cnt FROM _tag_count WHERE author_did = user_similarity.user_a) +
335335- (SELECT cnt FROM _tag_count WHERE author_did = user_similarity.user_b) -
334334+ (SELECT cnt FROM _tag_count WHERE author_did = recs.user_similarity.user_a) +
335335+ (SELECT cnt FROM _tag_count WHERE author_did = recs.user_similarity.user_b) -
336336 CAST(_tag_overlap.common AS REAL),
337337 0
338338 ),
339339 common_tags = _tag_overlap.common
340340 FROM _tag_overlap
341341- WHERE user_similarity.user_a = _tag_overlap.user_a
342342- AND user_similarity.user_b = _tag_overlap.user_b
341341+ WHERE recs.user_similarity.user_a = _tag_overlap.user_a
342342+ AND recs.user_similarity.user_b = _tag_overlap.user_b
343343 `, e.config.TagsWeight)
344344345345 if _, err := tx.ExecContext(ctx, tagsUpdate); err != nil {
···347347 }
348348349349 followQuery := fmt.Sprintf(`
350350- INSERT INTO user_similarity (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
350350+ INSERT INTO recs.user_similarity (user_a, user_b, jaccard, common_feeds, common_likes, common_tags)
351351 SELECT
352352 MIN(f.user_did, f.target_did),
353353 MAX(f.user_did, f.target_did),
354354 %g,
355355 0, 0, 0
356356- FROM follows f
356356+ FROM main.follows f
357357 WHERE f.user_did != f.target_did
358358 GROUP BY MIN(f.user_did, f.target_did), MAX(f.user_did, f.target_did)
359359 ON CONFLICT(user_a, user_b) DO UPDATE SET
···366366367367 e.logger.Info("user similarity computed")
368368 return tx.Commit()
369369-}
369369+}
+49-50
internal/cluster/scoring.go
···41414242func (e *Engine) GetFeedRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
4343 subCount := 0
4444- _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)
4444+ _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)
45454646 if subCount < 5 {
4747 recs, err := e.ColdStartRecommendations(ctx, userDID, limit*2)
···9191 var dbW SignalWeights
9292 err := e.db.QueryRowContext(ctx, `
9393 SELECT w_sub, w_like, w_tag, w_social, w_pop, w_category
9494- FROM user_signal_weights WHERE user_did = ?
9494+ FROM recs.user_signal_weights WHERE user_did = ?
9595 `, userDID).Scan(&dbW.WSub, &dbW.WLike, &dbW.WTag, &dbW.WSocial, &dbW.WPop, &dbW.WCategory)
9696 if err == nil {
9797 return dbW
···104104105105 rows, err := e.db.QueryContext(ctx, `
106106 WITH similar_users AS (
107107- SELECT user_b AS peer, jaccard FROM user_similarity WHERE user_a = ? AND jaccard > 0.15
107107+ SELECT user_b AS peer, jaccard FROM recs.user_similarity WHERE user_a = ? AND jaccard > 0.15
108108 UNION ALL
109109- SELECT user_a AS peer, jaccard FROM user_similarity WHERE user_b = ? AND jaccard > 0.15
109109+ SELECT user_a AS peer, jaccard FROM recs.user_similarity WHERE user_b = ? AND jaccard > 0.15
110110 ),
111111 candidate_feeds AS (
112112 SELECT s.feed_url,
113113 SUM(su.jaccard) AS sub_signal
114114 FROM similar_users su
115115- JOIN subscriptions s ON s.user_did = su.peer
116116- WHERE s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
117117- AND s.feed_url NOT IN (SELECT target_id FROM dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
115115+ JOIN articles.subscriptions s ON s.user_did = su.peer
116116+ WHERE s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
117117+ AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
118118 GROUP BY s.feed_url
119119 ),
120120 like_signals AS (
121121 SELECT s.feed_url,
122122 SUM(su.jaccard * EXP(-0.023 * CAST(julianday('now') - julianday(l.created_at) AS REAL))) AS like_signal
123123 FROM similar_users su
124124- JOIN likes l ON l.author_did = su.peer
125125- JOIN subscriptions s ON s.feed_url = l.feed_url
126126- WHERE s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
127127- AND s.feed_url NOT IN (SELECT target_id FROM dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
124124+ JOIN articles.likes l ON l.author_did = su.peer
125125+ JOIN articles.subscriptions s ON s.feed_url = l.feed_url
126126+ WHERE s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
127127+ AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
128128 GROUP BY s.feed_url
129129 ),
130130 social_boost AS (
131131 SELECT s.feed_url,
132132 SUM(CASE WHEN fd.distance = 1 THEN 1.0 ELSE 0.3 END) AS social
133133- FROM follow_distances fd
134134- JOIN subscriptions s ON s.user_did = fd.user_b
133133+ FROM recs.follow_distances fd
134134+ JOIN articles.subscriptions s ON s.user_did = fd.user_b
135135 WHERE fd.user_a = ?
136136- AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
137137- AND s.feed_url NOT IN (SELECT target_id FROM dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
136136+ AND s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
137137+ AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
138138 GROUP BY s.feed_url
139139 ),
140140 category_counts AS (
141141 SELECT category, COUNT(*) AS cnt
142142- FROM subscriptions WHERE user_did = ? AND category IS NOT NULL AND category != ''
142142+ FROM articles.subscriptions WHERE user_did = ? AND category IS NOT NULL AND category != ''
143143 GROUP BY category
144144 ),
145145 max_subs AS (
146146- SELECT CAST(COALESCE(MAX(subscriber_count), 1) AS REAL) AS m FROM feeds
146146+ SELECT CAST(COALESCE(MAX(subscriber_count), 1) AS REAL) AS m FROM articles.feeds
147147 )
148148 SELECT cf.feed_url, COALESCE(f.title, ''), COALESCE(f.site_url, ''),
149149 COALESCE(f.description, ''), f.subscriber_count, COALESCE(f.favicon_url, ''),
···157157 ) THEN ? ELSE 0 END
158158 AS score
159159 FROM candidate_feeds cf
160160- JOIN feeds f ON f.feed_url = cf.feed_url
160160+ JOIN articles.feeds f ON f.feed_url = cf.feed_url
161161 LEFT JOIN like_signals ls ON ls.feed_url = cf.feed_url
162162 LEFT JOIN social_boost sb ON sb.feed_url = cf.feed_url
163163 CROSS JOIN max_subs ms
···187187188188 rows, err := e.db.QueryContext(ctx, `
189189 WITH similar_users AS (
190190- SELECT user_b AS peer, jaccard FROM user_similarity WHERE user_a = ? AND jaccard > 0.15
190190+ SELECT user_b AS peer, jaccard FROM recs.user_similarity WHERE user_a = ? AND jaccard > 0.15
191191 UNION ALL
192192- SELECT user_a AS peer, jaccard FROM user_similarity WHERE user_b = ? AND jaccard > 0.15
192192+ SELECT user_a AS peer, jaccard FROM recs.user_similarity WHERE user_b = ? AND jaccard > 0.15
193193 ),
194194 liked_articles AS (
195195 SELECT l.feed_url, l.article_url,
196196 SUM(su.jaccard * EXP(-0.023 * CAST(julianday('now') - julianday(l.created_at) AS REAL))) AS like_signal
197197 FROM similar_users su
198198- JOIN likes l ON l.author_did = su.peer
198198+ JOIN articles.likes l ON l.author_did = su.peer
199199 WHERE NOT EXISTS (
200200- SELECT 1 FROM likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url
200200+ SELECT 1 FROM articles.likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url
201201 )
202202 AND NOT EXISTS (
203203- SELECT 1 FROM dismissed_recommendations d WHERE d.user_did = ? AND d.target_type = 'article' AND d.target_id = l.article_url
203203+ SELECT 1 FROM recs.dismissed_recommendations d WHERE d.user_did = ? AND d.target_type = 'article' AND d.target_id = l.article_url
204204 )
205205 GROUP BY l.feed_url, l.article_url
206206 ),
207207 social_likes AS (
208208 SELECT l.feed_url, l.article_url,
209209 SUM(CASE WHEN fd.distance = 1 THEN 1.0 ELSE 0.3 END) AS social
210210- FROM follow_distances fd
211211- JOIN likes l ON l.author_did = fd.user_b
210210+ FROM recs.follow_distances fd
211211+ JOIN articles.likes l ON l.author_did = fd.user_b
212212 WHERE fd.user_a = ?
213213 AND NOT EXISTS (
214214- SELECT 1 FROM likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url
214214+ SELECT 1 FROM articles.likes ul WHERE ul.author_did = ? AND ul.feed_url = l.feed_url AND ul.article_url = l.article_url
215215 )
216216 GROUP BY l.feed_url, l.article_url
217217 )
···223223 + EXP(-0.023 * CAST(julianday('now') - julianday(a.published) AS REAL)) * 0.2
224224 AS score
225225 FROM liked_articles la
226226- JOIN articles a ON a.feed_url = la.feed_url AND a.url = la.article_url
227227- LEFT JOIN feeds f ON f.feed_url = la.feed_url
226226+ JOIN articles.articles a ON a.feed_url = la.feed_url AND a.url = la.article_url
227227+ LEFT JOIN articles.feeds f ON f.feed_url = la.feed_url
228228 LEFT JOIN social_likes sl ON sl.feed_url = la.feed_url AND sl.article_url = la.article_url
229229- -- Future-published articles (e.g., scheduled) sort last
230229 ORDER BY score DESC, (CASE WHEN a.published > 'now' THEN 1 ELSE 0 END), a.published DESC
231230 LIMIT ?
232231 `, userDID, userDID, userDID, userDID, userDID, userDID, w.WLike, w.WSocial, limit)
···252251 SELECT u.did, u.handle, COALESCE(u.display_name, ''), COALESCE(u.avatar_url, ''),
253252 sim.jaccard, sim.common_feeds, COALESCE(sim.common_likes, 0), COALESCE(sim.common_tags, 0)
254253 FROM (
255255- SELECT user_b AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM user_similarity WHERE user_a = ?
254254+ SELECT user_b AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM recs.user_similarity WHERE user_a = ?
256255 UNION ALL
257257- SELECT user_a AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM user_similarity WHERE user_b = ?
256256+ SELECT user_a AS peer_did, jaccard, common_feeds, common_likes, common_tags FROM recs.user_similarity WHERE user_b = ?
258257 ) sim
259259- JOIN users u ON u.did = sim.peer_did
258258+ JOIN main.users u ON u.did = sim.peer_did
260259 WHERE u.handle IS NOT NULL AND u.handle != ''
261261- AND EXISTS (SELECT 1 FROM subscriptions s JOIN feeds f ON s.feed_url = f.feed_url WHERE s.user_did = u.did AND f.subscriber_count > 0)
260260+ AND EXISTS (SELECT 1 FROM articles.subscriptions s JOIN articles.feeds f ON s.feed_url = f.feed_url WHERE s.user_did = u.did AND f.subscriber_count > 0)
262261 ORDER BY sim.jaccard DESC
263262 LIMIT ?
264263 `, userDID, userDID, limit)
···286285 }
287286 defer func() { _ = tx.Rollback() }()
288287289289- if _, err := tx.ExecContext(ctx, `DELETE FROM user_signal_profiles`); err != nil {
288288+ if _, err := tx.ExecContext(ctx, `DELETE FROM recs.user_signal_profiles`); err != nil {
290289 return err
291290 }
292291···299298 return err
300299 }
301300 if _, err := tx.ExecContext(ctx, `
302302- INSERT INTO _user_like_counts SELECT author_did, COUNT(*) FROM likes GROUP BY author_did
301301+ INSERT INTO _user_like_counts SELECT author_did, COUNT(*) FROM articles.likes GROUP BY author_did
303302 `); err != nil {
304303 return err
305304 }
···316315 INSERT INTO _user_tag_counts
317316 WITH user_tags AS (
318317 SELECT author_did, TRIM(value) AS tag
319319- FROM annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
318318+ FROM articles.annotations, json_each('["' || REPLACE(tags, ',', '","') || '"]')
320319 WHERE tags IS NOT NULL AND tags != ''
321320 )
322321 SELECT author_did, COUNT(DISTINCT tag) FROM user_tags GROUP BY author_did
···337336 SELECT user_did, '[' || GROUP_CONCAT('{"c":"' || category || '","n":"' || CAST(cnt AS TEXT) || '}') || ']'
338337 FROM (
339338 SELECT user_did, category, COUNT(*) AS cnt
340340- FROM subscriptions
339339+ FROM articles.subscriptions
341340 WHERE category IS NOT NULL AND category != ''
342341 GROUP BY user_did, category
343342 ORDER BY COUNT(*) DESC
···349348 }
350349351350 _, err = tx.ExecContext(ctx, `
352352- INSERT INTO user_signal_profiles (user_did, total_likes, total_tags, top_categories)
351351+ INSERT INTO recs.user_signal_profiles (user_did, total_likes, total_tags, top_categories)
353352 SELECT
354353 u.did,
355354 COALESCE(lc.cnt, 0),
356355 COALESCE(tc.cnt, 0),
357356 COALESCE(cc.categories, '[]')
358358- FROM users u
357357+ FROM main.users u
359358 LEFT JOIN _user_like_counts lc ON lc.user_did = u.did
360359 LEFT JOIN _user_tag_counts tc ON tc.user_did = u.did
361360 LEFT JOIN _user_top_categories cc ON cc.user_did = u.did
···370369371370func (e *Engine) ColdStartRecommendations(ctx context.Context, userDID string, limit int) ([]*FeedRecommendation, error) {
372371 subCount := 0
373373- _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)
372372+ _ = e.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM articles.subscriptions WHERE user_did = ?`, userDID).Scan(&subCount)
374373 if subCount >= 5 {
375374 return nil, nil
376375 }
···378377 rows, err := e.db.QueryContext(ctx, `
379378 WITH followed_feeds AS (
380379 SELECT s.feed_url, 1.0 AS weight
381381- FROM follow_distances fd
382382- JOIN subscriptions s ON s.user_did = fd.user_b
380380+ FROM recs.follow_distances fd
381381+ JOIN articles.subscriptions s ON s.user_did = fd.user_b
383382 WHERE fd.user_a = ? AND fd.distance = 1
384384- AND s.feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
385385- AND s.feed_url NOT IN (SELECT target_id FROM dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
383383+ AND s.feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
384384+ AND s.feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
386385 ),
387386 popular_feeds AS (
388387 SELECT feed_url, subscriber_count,
389389- LOG(1 + CAST(subscriber_count AS REAL)) / LOG(1 + CAST((SELECT COALESCE(MAX(subscriber_count), 1) FROM feeds) AS REAL)) AS pop_score
390390- FROM feeds
388388+ LOG(1 + CAST(subscriber_count AS REAL)) / LOG(1 + CAST((SELECT COALESCE(MAX(subscriber_count), 1) FROM articles.feeds) AS REAL)) AS pop_score
389389+ FROM articles.feeds
391390 WHERE subscriber_count > 0
392392- AND feed_url NOT IN (SELECT feed_url FROM subscriptions WHERE user_did = ?)
393393- AND feed_url NOT IN (SELECT target_id FROM dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
391391+ AND feed_url NOT IN (SELECT feed_url FROM articles.subscriptions WHERE user_did = ?)
392392+ AND feed_url NOT IN (SELECT target_id FROM recs.dismissed_recommendations WHERE user_did = ? AND target_type = 'feed')
394393 ORDER BY subscriber_count DESC
395394 LIMIT 50
396395 ),
···410409 COALESCE(f.favicon_url, ''),
411410 ac.weight AS score
412411 FROM all_candidates ac
413413- JOIN feeds f ON f.feed_url = ac.feed_url
412412+ JOIN articles.feeds f ON f.feed_url = ac.feed_url
414413 ORDER BY score DESC
415414 LIMIT ?
416415 `, userDID, userDID, userDID, userDID, userDID, limit)
···429428 results = append(results, rec)
430429 }
431430 return results, rows.Err()
432432-}
431431+}
+8-8
internal/cluster/social.go
···1111 }
1212 defer func() { _ = tx.Rollback() }()
13131414- if _, err := tx.ExecContext(ctx, `DELETE FROM follow_distances`); err != nil {
1414+ if _, err := tx.ExecContext(ctx, `DELETE FROM recs.follow_distances`); err != nil {
1515 return err
1616 }
17171818 _, err = tx.ExecContext(ctx, `
1919- INSERT INTO follow_distances (user_a, user_b, distance)
1919+ INSERT INTO recs.follow_distances (user_a, user_b, distance)
2020 SELECT user_a, user_b, MIN(distance) FROM (
2121- SELECT user_did AS user_a, target_did AS user_b, 1 AS distance FROM follows WHERE user_did != target_did
2121+ SELECT user_did AS user_a, target_did AS user_b, 1 AS distance FROM main.follows WHERE user_did != target_did
2222 UNION ALL
2323 SELECT f1.user_did, f2.target_did, 2
2424- FROM follows f1
2525- JOIN follows f2 ON f1.target_did = f2.user_did
2424+ FROM main.follows f1
2525+ JOIN main.follows f2 ON f1.target_did = f2.user_did
2626 WHERE f1.user_did != f2.target_did
2727 ) GROUP BY user_a, user_b
2828 `)
···3737func (e *Engine) ComputeFollowDistancesIncremental(ctx context.Context) error {
3838 var maxFollowed string
3939 err := e.db.QueryRowContext(ctx, `
4040- SELECT COALESCE(MAX(followed_at), '1970-01-01') FROM follows
4040+ SELECT COALESCE(MAX(followed_at), '1970-01-01') FROM main.follows
4141 `).Scan(&maxFollowed)
4242 if err != nil {
4343 return err
···45454646 var lastComputed string
4747 err = e.db.QueryRowContext(ctx, `
4848- SELECT COALESCE(MAX(computed_at), '1970-01-01') FROM user_similarity
4848+ SELECT COALESCE(MAX(computed_at), '1970-01-01') FROM recs.user_similarity
4949 `).Scan(&lastComputed)
5050 if err != nil {
5151 return err
···5656 }
57575858 return e.ComputeFollowDistances(ctx)
5959-}
5959+}
+5-5
internal/cluster/weights.go
···2222func (e *Engine) adjustWeight(ctx context.Context, userDID string, signal string, delta float64) {
2323 var actedCount int
2424 _ = e.db.QueryRowContext(ctx, `
2525- SELECT COUNT(*) FROM recommendation_impressions WHERE user_did = ? AND acted = 1
2525+ SELECT COUNT(*) FROM recs.recommendation_impressions WHERE user_did = ? AND acted = 1
2626 `, userDID).Scan(&actedCount)
2727 if actedCount < minActionsTune {
2828 return
2929 }
30303131 var exists int
3232- _ = e.db.QueryRowContext(ctx, `SELECT 1 FROM user_signal_weights WHERE user_did = ?`, userDID).Scan(&exists)
3232+ _ = e.db.QueryRowContext(ctx, `SELECT 1 FROM recs.user_signal_weights WHERE user_did = ?`, userDID).Scan(&exists)
33333434 if exists == 0 {
3535 _, _ = e.db.ExecContext(ctx, `
3636- INSERT INTO user_signal_weights (user_did, w_sub, w_like, w_tag, w_social, w_pop, w_category)
3636+ INSERT INTO recs.user_signal_weights (user_did, w_sub, w_like, w_tag, w_social, w_pop, w_category)
3737 VALUES (?, 1.0, 0.5, 0.3, 0.7, 0.2, 0.4)
3838 `, userDID)
3939 }
···45454646 adj := learningRate * delta
4747 _, _ = e.db.ExecContext(ctx, `
4848- UPDATE user_signal_weights SET
4848+ UPDATE recs.user_signal_weights SET
4949 `+column+` = MAX(?, MIN(?, `+column+` * (1 + ?))),
5050 updated_at = CURRENT_TIMESTAMP
5151 WHERE user_did = ?
···9090 }
9191 }
9292 return best
9393-}
9393+}
+43-46
internal/db/db.go
···99 "github.com/mattn/go-sqlite3"
1010)
11111212+const DSN = "_journal_mode=WAL&_busy_timeout=5000&_synchronous=NORMAL&_cache=shared"
1313+1214func init() {
1315 sql.Register("sqlite3_glean", &sqlite3.SQLiteDriver{
1416 ConnectHook: func(conn *sqlite3.SQLiteConn) error {
···6163}
62646365func Open(path string) (*DB, error) {
6464- db, err := sql.Open("sqlite3_glean", path+"?_journal_mode=WAL&_busy_timeout=30000&_synchronous=NORMAL&_cache_size=-64000&_stmt_cache_size=64&_mutex=no")
6666+ db, err := sql.Open("sqlite3_glean", path+"?cache=shared&"+DSN)
6567 if err != nil {
6668 return nil, err
6769 }
···7072 db.SetMaxIdleConns(5)
7173 db.SetConnMaxLifetime(30 * time.Minute)
72747373- if err := initSchema(db); err != nil {
7575+ wrapped := &DB{db}
7676+ if err := initSchema(wrapped); err != nil {
7477 db.Close()
7578 return nil, err
7679 }
77807878- return &DB{db}, nil
8181+ return wrapped, nil
7982}
80838181-func initSchema(db *sql.DB) error {
8484+func initSchema(db *DB) error {
8285 for _, s := range schema {
8386 if _, err := db.Exec(s); err != nil {
8487 return err
···115118 )`,
116119 `CREATE TABLE IF NOT EXISTS subscriptions (
117120 id INTEGER PRIMARY KEY AUTOINCREMENT,
118118- user_did TEXT NOT NULL REFERENCES users(did),
119119- feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
121121+ user_did TEXT NOT NULL,
122122+ feed_url TEXT NOT NULL,
120123 title TEXT,
121124 category TEXT,
122125 added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
···126129 )`,
127130 `CREATE TABLE IF NOT EXISTS articles (
128131 id INTEGER PRIMARY KEY AUTOINCREMENT,
129129- feed_url TEXT NOT NULL REFERENCES feeds(feed_url),
132132+ feed_url TEXT NOT NULL,
130133 guid TEXT NOT NULL,
131134 title TEXT NOT NULL DEFAULT '',
132135 url TEXT,
···140143 UNIQUE(feed_url, guid)
141144 )`,
142145 `CREATE TABLE IF NOT EXISTS read_state (
143143- user_did TEXT NOT NULL REFERENCES users(did),
144144- article_id INTEGER NOT NULL REFERENCES articles(id),
146146+ user_did TEXT NOT NULL,
147147+ article_id INTEGER NOT NULL,
145148 is_read BOOLEAN NOT NULL DEFAULT 0,
146149 read_at DATETIME,
147150 PRIMARY KEY (user_did, article_id)
···149152 `CREATE TABLE IF NOT EXISTS annotations (
150153 id INTEGER PRIMARY KEY AUTOINCREMENT,
151154 uri TEXT NOT NULL UNIQUE,
152152- author_did TEXT NOT NULL REFERENCES users(did),
155155+ author_did TEXT NOT NULL,
153156 feed_url TEXT NOT NULL,
154157 article_url TEXT NOT NULL,
155158 quote TEXT,
···162165 `CREATE TABLE IF NOT EXISTS likes (
163166 id INTEGER PRIMARY KEY AUTOINCREMENT,
164167 uri TEXT NOT NULL UNIQUE,
165165- author_did TEXT NOT NULL REFERENCES users(did),
168168+ author_did TEXT NOT NULL,
166169 feed_url TEXT NOT NULL,
167170 article_url TEXT NOT NULL,
168171 created_at DATETIME NOT NULL,
···170173 UNIQUE(author_did, feed_url, article_url)
171174 )`,
172175 `CREATE TABLE IF NOT EXISTS feed_similarity (
173173- feed_a TEXT NOT NULL REFERENCES feeds(feed_url),
174174- feed_b TEXT NOT NULL REFERENCES feeds(feed_url),
176176+ feed_a TEXT NOT NULL,
177177+ feed_b TEXT NOT NULL,
175178 jaccard REAL NOT NULL,
176179 computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
177180 PRIMARY KEY (feed_a, feed_b),
178181 CHECK(feed_a < feed_b)
179182 )`,
180183 `CREATE TABLE IF NOT EXISTS user_similarity (
181181- user_a TEXT NOT NULL REFERENCES users(did),
182182- user_b TEXT NOT NULL REFERENCES users(did),
184184+ user_a TEXT NOT NULL,
185185+ user_b TEXT NOT NULL,
183186 jaccard REAL NOT NULL,
184187 common_feeds INTEGER NOT NULL,
185188 common_likes INTEGER NOT NULL DEFAULT 0,
···189192 CHECK(user_a < user_b)
190193 )`,
191194 `CREATE TABLE IF NOT EXISTS follows (
192192- user_did TEXT NOT NULL REFERENCES users(did),
195195+ user_did TEXT NOT NULL,
193196 target_did TEXT NOT NULL,
194197 uri TEXT,
195198 cid TEXT,
···206209 data TEXT NOT NULL,
207210 PRIMARY KEY (account_did, session_id)
208211 )`,
209209- `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`,
210210- `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)`,
211211- `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,
212212- `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`,
213213- `CREATE INDEX IF NOT EXISTS idx_likes_author_feed ON likes(author_did, feed_url, created_at)`,
214214- `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`,
215215- `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,
216216- `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`,
217217- `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`,
218218- `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,
219219- `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`,
220220- `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`,
221221- `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,
222222- `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,
223223- `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`,
224224- `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,
225225- `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
226226- `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
227227- `CREATE INDEX IF NOT EXISTS idx_user_similarity_b ON user_similarity(user_b)`,
228228- `CREATE INDEX IF NOT EXISTS idx_user_similarity_a ON user_similarity(user_a)`,
229229-230212 `CREATE TABLE IF NOT EXISTS dismissed_recommendations (
231231- user_did TEXT NOT NULL REFERENCES users(did),
213213+ user_did TEXT NOT NULL,
232214 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
233215 target_id TEXT NOT NULL,
234216 reason TEXT,
235217 dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
236218 PRIMARY KEY (user_did, target_type, target_id)
237219 )`,
238238-239220 `CREATE TABLE IF NOT EXISTS recommendation_impressions (
240240- user_did TEXT NOT NULL REFERENCES users(did),
221221+ user_did TEXT NOT NULL,
241222 target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
242223 target_id TEXT NOT NULL,
243224 first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
···246227 acted BOOLEAN NOT NULL DEFAULT 0,
247228 PRIMARY KEY (user_did, target_type, target_id)
248229 )`,
249249-250230 `CREATE TABLE IF NOT EXISTS follow_distances (
251231 user_a TEXT NOT NULL,
252232 user_b TEXT NOT NULL,
253233 distance INTEGER NOT NULL CHECK(distance IN (1, 2)),
254234 PRIMARY KEY (user_a, user_b)
255235 )`,
256256-257236 `CREATE TABLE IF NOT EXISTS user_signal_weights (
258258- user_did TEXT PRIMARY KEY REFERENCES users(did),
237237+ user_did TEXT PRIMARY KEY,
259238 w_sub REAL NOT NULL DEFAULT 1.0,
260239 w_like REAL NOT NULL DEFAULT 0.5,
261240 w_tag REAL NOT NULL DEFAULT 0.3,
···264243 w_category REAL NOT NULL DEFAULT 0.4,
265244 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
266245 )`,
267267-268246 `CREATE TABLE IF NOT EXISTS user_signal_profiles (
269269- user_did TEXT PRIMARY KEY REFERENCES users(did),
247247+ user_did TEXT PRIMARY KEY,
270248 total_likes INTEGER NOT NULL DEFAULT 0,
271249 total_tags INTEGER NOT NULL DEFAULT 0,
272250 top_categories TEXT,
273251 updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
274252 )`,
275275-253253+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`,
254254+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)`,
255255+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,
256256+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`,
257257+ `CREATE INDEX IF NOT EXISTS idx_likes_author_feed ON likes(author_did, feed_url, created_at)`,
258258+ `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`,
259259+ `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,
260260+ `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`,
261261+ `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`,
262262+ `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,
263263+ `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`,
264264+ `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`,
265265+ `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,
266266+ `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,
267267+ `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`,
268268+ `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,
269269+ `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
270270+ `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
271271+ `CREATE INDEX IF NOT EXISTS idx_user_similarity_b ON user_similarity(user_b)`,
272272+ `CREATE INDEX IF NOT EXISTS idx_user_similarity_a ON user_similarity(user_a)`,
276273 `CREATE INDEX IF NOT EXISTS idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`,
277274 `CREATE INDEX IF NOT EXISTS idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`,
278275 `CREATE INDEX IF NOT EXISTS idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`,
+367
internal/db/multi.go
···11+package db
22+33+import (
44+ "database/sql"
55+ "fmt"
66+ "math"
77+ "sync/atomic"
88+ "time"
99+1010+ "github.com/mattn/go-sqlite3"
1111+)
1212+1313+type Databases struct {
1414+ Users *DB
1515+ Articles *DB
1616+ Recs *DB
1717+}
1818+1919+var multiDriverSeq int64
2020+2121+func OpenAll(basePath string) (*Databases, error) {
2222+ articlesPath := basePath + "_articles"
2323+ recsPath := basePath + "_recs"
2424+2525+ seq := atomic.AddInt64(&multiDriverSeq, 1)
2626+ driverName := fmt.Sprintf("sqlite3_glean_multi_%d", seq)
2727+2828+ sql.Register(driverName, &sqlite3.SQLiteDriver{
2929+ ConnectHook: func(conn *sqlite3.SQLiteConn) error {
3030+ if err := conn.RegisterFunc("exp", func(x float64) float64 { return math.Exp(x) }, true); err != nil {
3131+ return err
3232+ }
3333+ if err := conn.RegisterFunc("log", func(x float64) float64 { return math.Log(x) }, true); err != nil {
3434+ return err
3535+ }
3636+ for _, p := range []string{
3737+ `PRAGMA wal_autocheckpoint = 1000`,
3838+ `PRAGMA temp_store = MEMORY`,
3939+ `PRAGMA mmap_size = 268435456`,
4040+ } {
4141+ if _, err := conn.Exec(p, nil); err != nil {
4242+ return err
4343+ }
4444+ }
4545+ if _, err := conn.Exec(fmt.Sprintf("ATTACH DATABASE '%s' AS articles", articlesPath), nil); err != nil {
4646+ return err
4747+ }
4848+ if _, err := conn.Exec(fmt.Sprintf("ATTACH DATABASE '%s' AS recs", recsPath), nil); err != nil {
4949+ return err
5050+ }
5151+ return nil
5252+ },
5353+ })
5454+5555+ usersDB, err := sql.Open(driverName, basePath+"_users?cache=shared&"+DSN)
5656+ if err != nil {
5757+ return nil, err
5858+ }
5959+ usersDB.SetMaxOpenConns(10)
6060+ usersDB.SetMaxIdleConns(5)
6161+ usersDB.SetConnMaxLifetime(30 * time.Minute)
6262+ users := &DB{usersDB}
6363+6464+ articles, err := Open(articlesPath)
6565+ if err != nil {
6666+ users.Close()
6767+ return nil, err
6868+ }
6969+7070+ recs, err := Open(recsPath)
7171+ if err != nil {
7272+ users.Close()
7373+ articles.Close()
7474+ return nil, err
7575+ }
7676+7777+ if err := initUsersSchema(users); err != nil {
7878+ users.Close()
7979+ articles.Close()
8080+ recs.Close()
8181+ return nil, err
8282+ }
8383+8484+ if err := initArticlesSchema(articles); err != nil {
8585+ users.Close()
8686+ articles.Close()
8787+ recs.Close()
8888+ return nil, err
8989+ }
9090+9191+ if err := initRecsSchema(recs); err != nil {
9292+ users.Close()
9393+ articles.Close()
9494+ recs.Close()
9595+ return nil, err
9696+ }
9797+9898+ return &Databases{
9999+ Users: users,
100100+ Articles: articles,
101101+ Recs: recs,
102102+ }, nil
103103+}
104104+105105+func (d *Databases) Close() error {
106106+ if d.Users != nil {
107107+ _ = d.Users.Close()
108108+ }
109109+ if d.Articles != nil {
110110+ _ = d.Articles.Close()
111111+ }
112112+ if d.Recs != nil {
113113+ _ = d.Recs.Close()
114114+ }
115115+ return nil
116116+}
117117+118118+func initUsersSchema(db *DB) error {
119119+ for _, s := range usersSchema {
120120+ if _, err := db.Exec(s); err != nil {
121121+ return err
122122+ }
123123+ }
124124+ return nil
125125+}
126126+127127+func initArticlesSchema(db *DB) error {
128128+ for _, s := range articlesSchema {
129129+ if _, err := db.Exec(s); err != nil {
130130+ return err
131131+ }
132132+ }
133133+ return nil
134134+}
135135+136136+func initRecsSchema(db *DB) error {
137137+ for _, s := range recsSchema {
138138+ if _, err := db.Exec(s); err != nil {
139139+ return err
140140+ }
141141+ }
142142+ return nil
143143+}
144144+145145+var usersSchema = []string{
146146+ `CREATE TABLE IF NOT EXISTS users (
147147+ did TEXT PRIMARY KEY,
148148+ handle TEXT NOT NULL,
149149+ display_name TEXT,
150150+ avatar_url TEXT,
151151+ indexed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
152152+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
153153+ )`,
154154+155155+ `CREATE TABLE IF NOT EXISTS follows (
156156+ user_did TEXT NOT NULL,
157157+ target_did TEXT NOT NULL,
158158+ uri TEXT,
159159+ cid TEXT,
160160+ followed_at DATETIME,
161161+ PRIMARY KEY (user_did, target_did)
162162+ )`,
163163+164164+ `CREATE TABLE IF NOT EXISTS oauth_auth_requests (
165165+ state TEXT PRIMARY KEY,
166166+ data TEXT NOT NULL
167167+ )`,
168168+169169+ `CREATE TABLE IF NOT EXISTS oauth_sessions (
170170+ account_did TEXT NOT NULL,
171171+ session_id TEXT NOT NULL,
172172+ data TEXT NOT NULL,
173173+ PRIMARY KEY (account_did, session_id)
174174+ )`,
175175+176176+ `CREATE INDEX IF NOT EXISTS idx_follows_user ON follows(user_did)`,
177177+ `CREATE INDEX IF NOT EXISTS idx_follows_target ON follows(target_did)`,
178178+ `CREATE INDEX IF NOT EXISTS idx_follows_uri ON follows(uri)`,
179179+ `CREATE INDEX IF NOT EXISTS idx_follows_followed_at ON follows(followed_at)`,
180180+ `CREATE INDEX IF NOT EXISTS idx_users_handle ON users(handle)`,
181181+}
182182+183183+var articlesSchema = []string{
184184+ `CREATE TABLE IF NOT EXISTS feeds (
185185+ feed_url TEXT PRIMARY KEY,
186186+ title TEXT,
187187+ site_url TEXT,
188188+ description TEXT,
189189+ feed_type TEXT CHECK(feed_type IN ('rss', 'atom', 'json')),
190190+ last_fetched_at DATETIME,
191191+ last_error TEXT,
192192+ subscriber_count INTEGER NOT NULL DEFAULT 0,
193193+ etag TEXT,
194194+ last_modified TEXT,
195195+ fetch_interval_minutes INTEGER NOT NULL DEFAULT 30,
196196+ next_fetch_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
197197+ consecutive_empty_fetches INTEGER NOT NULL DEFAULT 0,
198198+ error_count INTEGER NOT NULL DEFAULT 0,
199199+ favicon_url TEXT
200200+ )`,
201201+202202+ `CREATE TABLE IF NOT EXISTS subscriptions (
203203+ id INTEGER PRIMARY KEY AUTOINCREMENT,
204204+ user_did TEXT NOT NULL,
205205+ feed_url TEXT NOT NULL,
206206+ title TEXT,
207207+ category TEXT,
208208+ added_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
209209+ uri TEXT,
210210+ cid TEXT,
211211+ UNIQUE(user_did, feed_url)
212212+ )`,
213213+214214+ `CREATE TABLE IF NOT EXISTS articles (
215215+ id INTEGER PRIMARY KEY AUTOINCREMENT,
216216+ feed_url TEXT NOT NULL,
217217+ guid TEXT NOT NULL,
218218+ title TEXT NOT NULL DEFAULT '',
219219+ url TEXT,
220220+ author TEXT,
221221+ summary TEXT,
222222+ content TEXT,
223223+ full_content TEXT,
224224+ published DATETIME,
225225+ updated DATETIME,
226226+ fetched_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
227227+ UNIQUE(feed_url, guid)
228228+ )`,
229229+230230+ `CREATE TABLE IF NOT EXISTS read_state (
231231+ user_did TEXT NOT NULL,
232232+ article_id INTEGER NOT NULL,
233233+ is_read BOOLEAN NOT NULL DEFAULT 0,
234234+ read_at DATETIME,
235235+ PRIMARY KEY (user_did, article_id)
236236+ )`,
237237+238238+ `CREATE TABLE IF NOT EXISTS annotations (
239239+ id INTEGER PRIMARY KEY AUTOINCREMENT,
240240+ uri TEXT NOT NULL UNIQUE,
241241+ author_did TEXT NOT NULL,
242242+ feed_url TEXT NOT NULL,
243243+ article_url TEXT NOT NULL,
244244+ quote TEXT,
245245+ note TEXT,
246246+ tags TEXT,
247247+ rating INTEGER,
248248+ created_at DATETIME NOT NULL,
249249+ cid TEXT
250250+ )`,
251251+252252+ `CREATE TABLE IF NOT EXISTS likes (
253253+ id INTEGER PRIMARY KEY AUTOINCREMENT,
254254+ uri TEXT NOT NULL UNIQUE,
255255+ author_did TEXT NOT NULL,
256256+ feed_url TEXT NOT NULL,
257257+ article_url TEXT NOT NULL,
258258+ created_at DATETIME NOT NULL,
259259+ cid TEXT,
260260+ UNIQUE(author_did, feed_url, article_url)
261261+ )`,
262262+263263+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed ON subscriptions(feed_url)`,
264264+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_feed_user ON subscriptions(feed_url, user_did)`,
265265+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_did)`,
266266+ `CREATE INDEX IF NOT EXISTS idx_subscriptions_uri ON subscriptions(uri)`,
267267+ `CREATE INDEX IF NOT EXISTS idx_likes_author_feed ON likes(author_did, feed_url, created_at)`,
268268+ `CREATE INDEX IF NOT EXISTS idx_articles_feed ON articles(feed_url)`,
269269+ `CREATE INDEX IF NOT EXISTS idx_articles_published ON articles(published DESC)`,
270270+ `CREATE INDEX IF NOT EXISTS idx_articles_url ON articles(url)`,
271271+ `CREATE INDEX IF NOT EXISTS idx_read_state_unread ON read_state(user_did, is_read) WHERE is_read = 0`,
272272+ `CREATE INDEX IF NOT EXISTS idx_annotations_article ON annotations(article_url)`,
273273+ `CREATE INDEX IF NOT EXISTS idx_annotations_author ON annotations(author_did)`,
274274+ `CREATE INDEX IF NOT EXISTS idx_annotations_created_at ON annotations(created_at DESC)`,
275275+ `CREATE INDEX IF NOT EXISTS idx_likes_article ON likes(feed_url, article_url)`,
276276+ `CREATE INDEX IF NOT EXISTS idx_likes_author ON likes(author_did)`,
277277+ `CREATE INDEX IF NOT EXISTS idx_likes_created_at ON likes(created_at DESC)`,
278278+279279+ `CREATE VIRTUAL TABLE IF NOT EXISTS articles_fts USING fts5(title, summary, content, author, content=articles, content_rowid=id)`,
280280+ `CREATE TRIGGER IF NOT EXISTS articles_ai AFTER INSERT ON articles BEGIN
281281+ INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
282282+ END`,
283283+ `CREATE TRIGGER IF NOT EXISTS articles_ad AFTER DELETE ON articles BEGIN
284284+ INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
285285+ END`,
286286+ `CREATE TRIGGER IF NOT EXISTS articles_au AFTER UPDATE ON articles BEGIN
287287+ INSERT INTO articles_fts(articles_fts, rowid, title, summary, content, author) VALUES('delete', old.id, old.title, old.summary, old.content, old.author);
288288+ INSERT INTO articles_fts(rowid, title, summary, content, author) VALUES (new.id, new.title, new.summary, new.content, new.author);
289289+ END`,
290290+}
291291+292292+var recsSchema = []string{
293293+ `CREATE TABLE IF NOT EXISTS feed_similarity (
294294+ feed_a TEXT NOT NULL,
295295+ feed_b TEXT NOT NULL,
296296+ jaccard REAL NOT NULL,
297297+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
298298+ PRIMARY KEY (feed_a, feed_b),
299299+ CHECK(feed_a < feed_b)
300300+ )`,
301301+302302+ `CREATE TABLE IF NOT EXISTS user_similarity (
303303+ user_a TEXT NOT NULL,
304304+ user_b TEXT NOT NULL,
305305+ jaccard REAL NOT NULL,
306306+ common_feeds INTEGER NOT NULL,
307307+ common_likes INTEGER NOT NULL DEFAULT 0,
308308+ common_tags INTEGER NOT NULL DEFAULT 0,
309309+ computed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
310310+ PRIMARY KEY (user_a, user_b),
311311+ CHECK(user_a < user_b)
312312+ )`,
313313+314314+ `CREATE TABLE IF NOT EXISTS dismissed_recommendations (
315315+ user_did TEXT NOT NULL,
316316+ target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
317317+ target_id TEXT NOT NULL,
318318+ reason TEXT,
319319+ dismissed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
320320+ PRIMARY KEY (user_did, target_type, target_id)
321321+ )`,
322322+323323+ `CREATE TABLE IF NOT EXISTS recommendation_impressions (
324324+ user_did TEXT NOT NULL,
325325+ target_type TEXT NOT NULL CHECK(target_type IN ('feed', 'article')),
326326+ target_id TEXT NOT NULL,
327327+ first_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
328328+ last_shown_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
329329+ shown_count INTEGER NOT NULL DEFAULT 1,
330330+ acted BOOLEAN NOT NULL DEFAULT 0,
331331+ PRIMARY KEY (user_did, target_type, target_id)
332332+ )`,
333333+334334+ `CREATE TABLE IF NOT EXISTS follow_distances (
335335+ user_a TEXT NOT NULL,
336336+ user_b TEXT NOT NULL,
337337+ distance INTEGER NOT NULL CHECK(distance IN (1, 2)),
338338+ PRIMARY KEY (user_a, user_b)
339339+ )`,
340340+341341+ `CREATE TABLE IF NOT EXISTS user_signal_weights (
342342+ user_did TEXT PRIMARY KEY,
343343+ w_sub REAL NOT NULL DEFAULT 1.0,
344344+ w_like REAL NOT NULL DEFAULT 0.5,
345345+ w_tag REAL NOT NULL DEFAULT 0.3,
346346+ w_social REAL NOT NULL DEFAULT 0.7,
347347+ w_pop REAL NOT NULL DEFAULT 0.2,
348348+ w_category REAL NOT NULL DEFAULT 0.4,
349349+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
350350+ )`,
351351+352352+ `CREATE TABLE IF NOT EXISTS user_signal_profiles (
353353+ user_did TEXT PRIMARY KEY,
354354+ total_likes INTEGER NOT NULL DEFAULT 0,
355355+ total_tags INTEGER NOT NULL DEFAULT 0,
356356+ top_categories TEXT,
357357+ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
358358+ )`,
359359+360360+ `CREATE INDEX IF NOT EXISTS idx_dismissed_user_type ON dismissed_recommendations(user_did, target_type)`,
361361+ `CREATE INDEX IF NOT EXISTS idx_impressions_user_unacted ON recommendation_impressions(user_did, acted, shown_count)`,
362362+ `CREATE INDEX IF NOT EXISTS idx_impressions_last_shown ON recommendation_impressions(last_shown_at)`,
363363+ `CREATE INDEX IF NOT EXISTS idx_follow_distances_b ON follow_distances(user_b)`,
364364+ `CREATE INDEX IF NOT EXISTS idx_follow_distances_a_dist ON follow_distances(user_a, distance)`,
365365+ `CREATE INDEX IF NOT EXISTS idx_user_similarity_b ON user_similarity(user_b)`,
366366+ `CREATE INDEX IF NOT EXISTS idx_user_similarity_a ON user_similarity(user_a)`,
367367+}
+3
internal/feed/fetcher.go
···101101 ticker := time.NewTicker(s.tickInterval)
102102 defer ticker.Stop()
103103104104+ // fetch all at startup
105105+ s.fetchAll(ctx)
106106+104107 for {
105108 select {
106109 case <-ctx.Done():