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;
35mod labels;35mod labels;
36mod lfs;36mod lfs;
37mod mirrors;37mod mirrors;
38mod notifications;
38mod releases;39mod releases;
39mod repos;40mod repos;
40mod sessions;41mod sessions;
@@ -54,6 +55,7 @@ pub use crate::invites::NewInvite;
54pub use crate::issues::StateCounts;55pub use crate::issues::StateCounts;
55pub use crate::keys::NewKey;56pub use crate::keys::NewKey;
56pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror};57pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror};
58pub use crate::notifications::{NewNotification, Notification};
57pub use crate::releases::{NewRelease, Release, ReleaseAsset};59pub use crate::releases::{NewRelease, Release, ReleaseAsset};
58pub use crate::repos::{Collaborator, NewRepo};60pub use crate::repos::{Collaborator, NewRepo};
59pub use crate::sessions::NewSession;61pub 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
9use sqlx::any::AnyRow;
10use sqlx::{AssertSqlSafe, Row};
11
12use crate::{Store, StoreError, new_id, now_ms};
13
14/// A pending notification.
15#[derive(Debug, Clone, PartialEq, Eq)]
16pub 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)]
37pub 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
50const NOTIF_COLUMNS: &str = "id, user_id, actor_id, kind, repo_id, issue_id, read_at, created_at";
51
52impl 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.
3CREATE 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);
13CREATE 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.
3CREATE 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);
13CREATE INDEX idx_notifications_user ON notifications (user_id, read_at);