Postgres, step 2c: inbox threads by window functions, RETURNING, lower() 97b18869acf5
The inbox took each thread's newest notification from SQLite's bare columns beside MAX(at_ms), which Postgres refuses; it now numbers each thread's rows with ROW_NUMBER() and counts with window sums, which both engines run. The participating and watching filters lose their last boolean SUM. A new session reads its id with RETURNING, not last_insert_rowid, and actor and assignee names are compared and sorted with lower() instead of COLLATE NOCASE. Refs #211
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-16 11:45 UTC
- Signature
- ✔ signed by Claude Code
- Hash
97b18869acf56b26684db983c1feea231157a7a1f11c42b2098e71080c934344- Depends on
- 0024ccb1b050 18e381ea88c2 19bb31ff1f5c 25ab8c73514a 5d90e0e045e2 cb71e18eb6f9 ea14c4ec5c3f eb0c44a9ad37
- Depended on by
- a22e4aec2e28 3b1bc27fe805
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 13 atoms, 0 tree operations, 4 files
Browse the tree at this change
modified crates/hub/src/db/accounts.rs +1 −1
| … | 439 unchanged lines |
||
| 440 | 440 | UNION SELECT account_id FROM collaborators WHERE repo_id = $2 AND account_id IS NOT NULL \ |
|
| 441 | 441 | UNION SELECT tm.account_id FROM team_members tm JOIN collaborators c ON c.team_id = tm.team_id WHERE c.repo_id = $2 \ |
|
| 442 | 442 | UNION SELECT m.account_id FROM org_members m JOIN organisations o ON o.id = m.org_id WHERE o.name = $1) \ |
|
| 443 | - | ORDER BY username COLLATE NOCASE", |
|
| 443 | + | ORDER BY lower(username), username", |
|
| 444 | 444 | ) |
|
| 445 | 445 | .bind(owner) |
|
| 446 | 446 | .bind(repo_id) |
|
| … | 512 unchanged lines |
modified crates/hub/src/db/activity.rs +1 −1
| … | 54 unchanged lines |
||
| 55 | 55 | UNION ALL SELECT i.repo_id, e.at_ms, e.actor, 'issue', e.event, i.title, i.number FROM issue_events e JOIN issues i ON i.id = e.issue_id) \ |
|
| 56 | 56 | SELECT r.owner, r.name, ev.at_ms, ev.actor, ev.kind, ev.event, ev.title, ev.number \ |
|
| 57 | 57 | FROM ev JOIN repositories r ON r.id = ev.repo_id WHERE ev.repo_id IN (SELECT id FROM seen) \ |
|
| 58 | - | AND ($2 IS NULL OR ev.actor = $3 COLLATE NOCASE) \ |
|
| 58 | + | AND ($2 IS NULL OR lower(ev.actor) = lower($3)) \ |
|
| 59 | 59 | ORDER BY ev.at_ms DESC LIMIT $4", |
|
| 60 | 60 | ) |
|
| 61 | 61 | .bind(ids_json(repo_ids)) |
|
| … | 139 unchanged lines |
modified crates/hub/src/db/notify.rs +15 −8
| … | 106 unchanged lines |
||
| 107 | 107 | } |
|
| 108 | 108 | ||
| 109 | 109 | pub async fn threads(pool: &Pool, account_id: i64, f: &Filter, limit: i64) -> sqlx::Result<Vec<Thread>> { |
|
| 110 | - | // SQLite takes bare columns from the row holding MAX(at_ms): the newest. |
|
| 110 | + | // Each thread is shown as its newest notification (row 1 by time) with |
|
| 111 | + | // counts over the whole thread: window functions, so the same SQL runs on |
|
| 112 | + | // SQLite and Postgres. |
|
| 111 | 113 | let mut sql = String::from( |
|
| 112 | - | "SELECT n.id, MAX(n.at_ms) AS at_ms, n.kind, n.title, n.url, n.actor, n.thread, n.repo_id, \ |
|
| 113 | - | (SELECT r.owner || '/' || r.name FROM repositories r WHERE r.id = n.repo_id) AS repo, \ |
|
| 114 | - | COUNT(*) AS count, SUM(CASE WHEN n.read THEN 0 ELSE 1 END) AS unread, SUM(CASE WHEN n.reason = 'participating' THEN 1 ELSE 0 END) > 0 AS participating, n.done \ |
|
| 114 | + | "SELECT id, at_ms, kind, title, url, actor, thread, repo_id, \ |
|
| 115 | + | (SELECT r.owner || '/' || r.name FROM repositories r WHERE r.id = t.repo_id) AS repo, \ |
|
| 116 | + | count, unread, participating > 0 AS participating, done FROM (\ |
|
| 117 | + | SELECT n.id, n.at_ms, n.kind, n.title, n.url, n.actor, n.thread, n.repo_id, n.done, \ |
|
| 118 | + | ROW_NUMBER() OVER (PARTITION BY n.thread ORDER BY n.at_ms DESC, n.id DESC) AS newest, \ |
|
| 119 | + | COUNT(*) OVER (PARTITION BY n.thread) AS count, \ |
|
| 120 | + | SUM(CASE WHEN n.read THEN 0 ELSE 1 END) OVER (PARTITION BY n.thread) AS unread, \ |
|
| 121 | + | SUM(CASE WHEN n.reason = 'participating' THEN 1 ELSE 0 END) OVER (PARTITION BY n.thread) AS participating \ |
|
| 115 | 122 | FROM notifications n WHERE n.account_id = $1 AND n.done = $2", |
|
| 116 | 123 | ); |
|
| 117 | 124 | // Placeholders numbered as they are added: $1 and $2 above. |
|
| … | 6 unchanged lines |
||
| 124 | 131 | sql.push_str(&format!(" AND n.repo_id = ${next}")); |
|
| 125 | 132 | next += 1; |
|
| 126 | 133 | } |
|
| 127 | - | sql.push_str(" GROUP BY n.thread HAVING 1"); |
|
| 134 | + | sql.push_str(") t WHERE newest = 1"); |
|
| 128 | 135 | if f.unread_only { |
|
| 129 | - | sql.push_str(" AND SUM(CASE WHEN n.read THEN 0 ELSE 1 END) > 0"); |
|
| 136 | + | sql.push_str(" AND unread > 0"); |
|
| 130 | 137 | } |
|
| 131 | 138 | match f.reason.as_deref() { |
|
| 132 | - | Some("participating") => sql.push_str(" AND SUM(n.reason = 'participating') > 0"), |
|
| 133 | - | Some("watching") => sql.push_str(" AND SUM(n.reason = 'participating') = 0"), |
|
| 139 | + | Some("participating") => sql.push_str(" AND participating > 0"), |
|
| 140 | + | Some("watching") => sql.push_str(" AND participating = 0"), |
|
| 134 | 141 | _ => {} |
|
| 135 | 142 | } |
|
| 136 | 143 | sql.push_str(&format!(" ORDER BY at_ms DESC LIMIT ${next}")); |
|
| … | 209 unchanged lines |
modified crates/hub/src/db/sessions.rs +3 −4
| … | 31 unchanged lines |
||
| 32 | 32 | ||
| 33 | 33 | pub async fn create(pool: &Pool, account_id: i64, hash: &str, salt: &str) -> sqlx::Result<i64> { |
|
| 34 | 34 | let now = now_ms(); |
|
| 35 | - | let r = sqlx::query("INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms) VALUES ($1, $2, $3, $4, $5)") |
|
| 35 | + | sqlx::query_scalar("INSERT INTO sessions (account_id, hash, salt, created_ms, seen_ms) VALUES ($1, $2, $3, $4, $5) RETURNING id") |
|
| 36 | 36 | .bind(account_id) |
|
| 37 | 37 | .bind(hash) |
|
| 38 | 38 | .bind(salt) |
|
| 39 | 39 | .bind(now) |
|
| 40 | 40 | .bind(now) |
|
| 41 | - | .execute(pool) |
|
| 42 | - | .await?; |
|
| 43 | - | Ok(r.last_insert_rowid()) |
|
| 41 | + | .fetch_one(pool) |
|
| 42 | + | .await |
|
| 44 | 43 | } |
|
| 45 | 44 | ||
| 46 | 45 | pub async fn by_hash(pool: &Pool, hash: &str) -> sqlx::Result<Option<Session>> { |
|
| … | 78 unchanged lines |