fabrica

hanna/fabrica

feat(store): notifications schema and queries

885e0b2 · hanna committed on 2026-07-26

Add the notifications table (migration 0020) and store layer: create
(self-directed events are skipped), list, unread count, and mark-read /
mark-all-read.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +193 −0UnifiedSplit
crates/store/src/lib.rs +2 −0
@@ -35,6 +35,7 @@ mod keys;
3535 mod labels;
3636 mod lfs;
3737 mod mirrors;
38+mod notifications;
3839 mod releases;
3940 mod repos;
4041 mod sessions;
@@ -54,6 +55,7 @@ pub use crate::invites::NewInvite;
5455 pub use crate::issues::StateCounts;
5556 pub use crate::keys::NewKey;
5657 pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror};
58+pub use crate::notifications::{NewNotification, Notification};
5759 pub use crate::releases::{NewRelease, Release, ReleaseAsset};
5860 pub use crate::repos::{Collaborator, NewRepo};
5961 pub use crate::sessions::NewSession;
crates/store/src/notifications.rs +165 −0
@@ -0,0 +1,165 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Notifications: an event (an @mention, an assignment) directed at a user. The
6+//! row stores foreign keys only; the web layer resolves actor/repo/issue at
7+//! display time.
8+
9+use sqlx::any::AnyRow;
10+use sqlx::{AssertSqlSafe, Row};
11+
12+use crate::{Store, StoreError, new_id, now_ms};
13+
14+/// A pending notification.
15+#[derive(Debug, Clone, PartialEq, Eq)]
16+pub struct Notification {
17+ /// ULID primary key.
18+ pub id: String,
19+ /// The recipient.
20+ pub user_id: String,
21+ /// Who triggered it, if still present.
22+ pub actor_id: Option<String>,
23+ /// The kind (`mention`, `assign`).
24+ pub kind: String,
25+ /// The repo it concerns, if any.
26+ pub repo_id: Option<String>,
27+ /// The issue/PR it concerns, if any.
28+ pub issue_id: Option<String>,
29+ /// When it was read, if it has been.
30+ pub read_at: Option<i64>,
31+ /// Creation time.
32+ pub created_at: i64,
33+}
34+
35+/// Fields to create a notification.
36+#[derive(Debug, Clone)]
37+pub struct NewNotification {
38+ /// The recipient.
39+ pub user_id: String,
40+ /// Who triggered it.
41+ pub actor_id: String,
42+ /// The kind.
43+ pub kind: String,
44+ /// The repo it concerns.
45+ pub repo_id: Option<String>,
46+ /// The issue/PR it concerns.
47+ pub issue_id: Option<String>,
48+}
49+
50+const NOTIF_COLUMNS: &str = "id, user_id, actor_id, kind, repo_id, issue_id, read_at, created_at";
51+
52+impl Store {
53+ fn map_notification(row: &AnyRow) -> Result<Notification, sqlx::Error> {
54+ Ok(Notification {
55+ id: row.try_get("id")?,
56+ user_id: row.try_get("user_id")?,
57+ actor_id: row.try_get("actor_id")?,
58+ kind: row.try_get("kind")?,
59+ repo_id: row.try_get("repo_id")?,
60+ issue_id: row.try_get("issue_id")?,
61+ read_at: row.try_get("read_at")?,
62+ created_at: row.try_get("created_at")?,
63+ })
64+ }
65+
66+ /// Create a notification. A self-directed notification (actor == recipient)
67+ /// is skipped — you are not notified of your own actions.
68+ ///
69+ /// # Errors
70+ ///
71+ /// Returns [`StoreError::Query`] on a database failure.
72+ pub async fn create_notification(&self, new: NewNotification) -> Result<(), StoreError> {
73+ if new.user_id == new.actor_id {
74+ return Ok(());
75+ }
76+ sqlx::query(
77+ "INSERT INTO notifications \
78+ (id, user_id, actor_id, kind, repo_id, issue_id, read_at, created_at) \
79+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)",
80+ )
81+ .bind(new_id())
82+ .bind(&new.user_id)
83+ .bind(&new.actor_id)
84+ .bind(&new.kind)
85+ .bind(&new.repo_id)
86+ .bind(&new.issue_id)
87+ .bind(Option::<i64>::None)
88+ .bind(now_ms())
89+ .execute(&self.pool)
90+ .await?;
91+ Ok(())
92+ }
93+
94+ /// A user's notifications, newest first, capped at `limit`.
95+ ///
96+ /// # Errors
97+ ///
98+ /// Returns [`StoreError::Query`] on a database failure.
99+ pub async fn list_notifications(
100+ &self,
101+ user_id: &str,
102+ limit: i64,
103+ ) -> Result<Vec<Notification>, StoreError> {
104+ let sql = format!(
105+ "SELECT {NOTIF_COLUMNS} FROM notifications WHERE user_id = $1 \
106+ ORDER BY created_at DESC LIMIT $2"
107+ );
108+ let rows = sqlx::query(AssertSqlSafe(sql))
109+ .bind(user_id)
110+ .bind(limit)
111+ .fetch_all(&self.pool)
112+ .await?;
113+ rows.iter()
114+ .map(Self::map_notification)
115+ .collect::<Result<_, _>>()
116+ .map_err(Into::into)
117+ }
118+
119+ /// The number of unread notifications for a user.
120+ ///
121+ /// # Errors
122+ ///
123+ /// Returns [`StoreError::Query`] on a database failure.
124+ pub async fn count_unread_notifications(&self, user_id: &str) -> Result<i64, StoreError> {
125+ let n: i64 = sqlx::query(
126+ "SELECT COUNT(*) AS n FROM notifications WHERE user_id = $1 AND read_at IS NULL",
127+ )
128+ .bind(user_id)
129+ .fetch_one(&self.pool)
130+ .await?
131+ .try_get("n")?;
132+ Ok(n)
133+ }
134+
135+ /// Mark one notification read (scoped to its owner).
136+ ///
137+ /// # Errors
138+ ///
139+ /// Returns [`StoreError::Query`] on a database failure.
140+ pub async fn mark_notification_read(&self, id: &str, user_id: &str) -> Result<(), StoreError> {
141+ sqlx::query(
142+ "UPDATE notifications SET read_at = $1 WHERE id = $2 AND user_id = $3 AND read_at IS NULL",
143+ )
144+ .bind(now_ms())
145+ .bind(id)
146+ .bind(user_id)
147+ .execute(&self.pool)
148+ .await?;
149+ Ok(())
150+ }
151+
152+ /// Mark all of a user's notifications read.
153+ ///
154+ /// # Errors
155+ ///
156+ /// Returns [`StoreError::Query`] on a database failure.
157+ pub async fn mark_all_notifications_read(&self, user_id: &str) -> Result<(), StoreError> {
158+ sqlx::query("UPDATE notifications SET read_at = $1 WHERE user_id = $2 AND read_at IS NULL")
159+ .bind(now_ms())
160+ .bind(user_id)
161+ .execute(&self.pool)
162+ .await?;
163+ Ok(())
164+ }
165+}
migrations/postgres/0020_notifications.sql +13 −0
@@ -0,0 +1,13 @@
1+-- Notifications: an actor did something (mention, assignment) that a user should
2+-- see. Rendered by resolving the linked actor/repo/issue at display time.
3+CREATE TABLE notifications (
4+ id TEXT PRIMARY KEY,
5+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6+ actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
7+ kind TEXT NOT NULL,
8+ repo_id TEXT REFERENCES repos(id) ON DELETE CASCADE,
9+ issue_id TEXT REFERENCES issues(id) ON DELETE CASCADE,
10+ read_at BIGINT,
11+ created_at BIGINT NOT NULL
12+);
13+CREATE INDEX idx_notifications_user ON notifications (user_id, read_at);
migrations/sqlite/0020_notifications.sql +13 −0
@@ -0,0 +1,13 @@
1+-- Notifications: an actor did something (mention, assignment) that a user should
2+-- see. Rendered by resolving the linked actor/repo/issue at display time.
3+CREATE TABLE notifications (
4+ id TEXT PRIMARY KEY,
5+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6+ actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
7+ kind TEXT NOT NULL,
8+ repo_id TEXT REFERENCES repos(id) ON DELETE CASCADE,
9+ issue_id TEXT REFERENCES issues(id) ON DELETE CASCADE,
10+ read_at BIGINT,
11+ created_at BIGINT NOT NULL
12+);
13+CREATE INDEX idx_notifications_user ON notifications (user_id, read_at);