0
2
grain-serve keeps when an account last saw its notifications
Only what this pull adds. It is built on grain runs on a Rust server modelled on knotless, without hatk.
crates/grain-serve/src/index/import/tests.rs+46−5
| @@ -125,7 +125,11 @@ INSERT INTO _push_tokens VALUES | ||
('did:plc:bob', '{"endpoint":"https://push.example/1"}', 'web', '2025-01-03T00:00:00.000Z'); | ||
INSERT INTO _preferences VALUES | ||
('did:plc:alice', 'lastSeenNotifications', '"2025-01-01T00:00:00.000Z"', '2025-01-01T00:00:00.000Z'), | ||
('did:plc:alice', 'contentLabels', '{"nudity":"warn"}', '2025-01-02 00:00:00'); | ||
('did:plc:alice', 'contentLabels', '{"nudity":"warn"}', '2025-01-02 00:00:00'), | ||
('did:plc:alice', 'notificationPrefs', '{"favorites":{"push":true,"inApp":false,"from":"all"},"follows":{"push":false,"inApp":true,"from":"follows"},"comments":{"push":true,"inApp":true,"from":"all"}}', '2025-01-03T00:00:00.000Z'), | ||
('did:plc:carol', 'lastSeenNotifications', '"2025-01-04T05:30:00+05:30"', '2025-01-04T00:00:00.000Z'), | ||
('did:plc:carol', 'notificationPrefs', '{"mentions":{"inApp":false},"favorites":"off","later":{"push":false}}', '2025-01-05T00:00:00.000Z'), | ||
('did:plc:dave', 'notificationPrefs', 'not json', '2025-01-06T00:00:00.000Z'); | ||
INSERT INTO _mutes VALUES | ||
('did:plc:alice', 'did:plc:bob', '2025-01-01T00:00:00.000Z'), | ||
('did:plc:carol', 'did:plc:bob', '2025-01-02T00:00:00.000Z'); | ||
| @@ -362,9 +366,27 @@ fn every_row_of_what_the_network_cannot_rebuild_is_imported() { | ||
); | ||
assert_eq!( | ||
rows("SELECT did, key, value, updated_at FROM preference ORDER BY key"), | ||
[r#"did:plc:alice|contentLabels|{"nudity":"warn"}|2025-01-02 00:00:00"#] | ||
); | ||
// Notifications' preferences, tables of their own: a seen time in | ||
// UTC, and a row a kind, as hatk read each. | ||
assert_eq!( | ||
rows("SELECT did, seen_at FROM notification_seen ORDER BY did"), | ||
[ | ||
"did:plc:alice|2025-01-01T00:00:00.000Z", | ||
"did:plc:carol|2025-01-04T00:00:00.000Z", | ||
] | ||
); | ||
assert_eq!( | ||
rows( | ||
"SELECT did, kind, push, in_app, from_follows FROM notification_pref ORDER BY did, kind" | ||
), | ||
[ | ||
r#"did:plc:alice|contentLabels|{"nudity":"warn"}|2025-01-02 00:00:00"#, | ||
r#"did:plc:alice|lastSeenNotifications|"2025-01-01T00:00:00.000Z"|2025-01-01T00:00:00.000Z"#, | ||
"did:plc:alice|comments|1|1|0", | ||
"did:plc:alice|favorites|1|0|0", | ||
"did:plc:alice|follows|0|1|1", | ||
// Left out, a kind is on. | ||
"did:plc:carol|mentions|1|0|0", | ||
] | ||
); | ||
assert_eq!( | ||
| @@ -415,7 +437,9 @@ fn every_row_of_what_the_network_cannot_rebuild_is_imported() { | ||
("label", 4, 4), | ||
("report", 2, 2), | ||
("mute", 2, 2), | ||
("preference", 2, 2), | ||
("preference", 1, 1), | ||
("notification_seen", 2, 2), | ||
("notification_pref", 4, 4), | ||
("push_token", 3, 3), | ||
("story_view", 2, 2), | ||
("space_invite", 2, 2), | ||
| @@ -633,7 +657,7 @@ fn a_row_the_index_does_not_take_fails_the_import() { | ||
let summary = import(&old, &into, false).unwrap(); | ||
assert!(!summary.written); | ||
let preference = kept(&summary, "preference"); | ||
assert_eq!((preference.source, preference.imported), (3, 2)); | ||
assert_eq!((preference.source, preference.imported), (2, 1)); | ||
assert_eq!(summary.failed.len(), 1, "{:?}", summary.failed); | ||
assert!(summary.failed[0].starts_with("preference Text(\"did:plc:erin\")")); | ||
let said = summary.to_string(); | ||
| @@ -656,6 +680,23 @@ fn a_row_the_index_does_not_take_fails_the_import() { | ||
assert_eq!(main(&args, |_| None), 1); | ||
} | ||
| ||
#[test] | ||
fn a_seen_time_that_is_no_time_fails_the_import() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let odd = "INSERT INTO _preferences VALUES | ||
('did:plc:erin', 'lastSeenNotifications', '\"yesterday\"', '2025-01-01T00:00:00.000Z');"; | ||
let old = hatk(dir.path(), &format!("{KEPT_ROWS}{odd}")); | ||
let summary = import(&old, &dir.path().join("index.db"), false).unwrap(); | ||
assert!(!summary.written); | ||
let seen = kept(&summary, "notification_seen"); | ||
assert_eq!((seen.source, seen.imported), (3, 2)); | ||
assert!( | ||
summary.failed[0].starts_with("notification_seen Text(\"did:plc:erin\")"), | ||
"{:?}", | ||
summary.failed | ||
); | ||
} | ||
| ||
#[test] | ||
fn hatk_with_its_repos_alone_imports() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
crates/grain-serve/src/index/import.rs+41−3
| @@ -39,6 +39,15 @@ | ||
//! `pending` if not. A status hatk never wrote is `pending`, and named in | ||
//! the summary and in `last_error`. | ||
//! | ||
//! Of `_preferences`, the two keys notifications kept are tables of their | ||
//! own: `lastSeenNotifications` is `notification_seen`, its time in UTC | ||
//! as [`grain::keys::iso`] writes it, and `notificationPrefs` is a row of | ||
//! `notification_pref` a kind of notification it set, as hatk read it: a | ||
//! kind is pushed, or shown in the app, unless it says `false`, and from | ||
//! anyone unless it says `from: "follows"`. A kind it kept as anything | ||
//! but an object, or a value that is not JSON, said nothing hatk read, and | ||
//! is not counted. The rest of `_preferences` is `preference`, as it was. | ||
//! | ||
//! `_cursor`'s Jetstream rows (`jetstream:<url>`) are `stream_cursor`'s, | ||
//! so the stream resumes where hatk left it. | ||
//! | ||
| @@ -325,11 +334,40 @@ pub const KEPT: &[Kept] = &[ | ||
Kept { | ||
table: "preference", | ||
source: "_preferences", | ||
from: "_preferences", | ||
count: "SELECT count(*) FROM _preferences", | ||
select: "SELECT did, key, value, updated_at FROM _preferences", | ||
from: "_preferences, other keys", | ||
count: "SELECT count(*) FROM _preferences | ||
WHERE key NOT IN ('lastSeenNotifications', 'notificationPrefs')", | ||
select: "SELECT did, key, value, updated_at FROM _preferences | ||
WHERE key NOT IN ('lastSeenNotifications', 'notificationPrefs')", | ||
insert: "INSERT INTO preference (did, key, value, updated_at) VALUES (?1, ?2, ?3, ?4)", | ||
}, | ||
Kept { | ||
table: "notification_seen", | ||
source: "_preferences", | ||
from: "_preferences, lastSeenNotifications", | ||
count: "SELECT count(*) FROM _preferences WHERE key = 'lastSeenNotifications'", | ||
// A time that is not one is null, which the index does not take. | ||
select: "SELECT did, CASE WHEN json_valid(value) | ||
THEN strftime('%Y-%m-%dT%H:%M:%fZ', value ->> '$') END | ||
FROM _preferences WHERE key = 'lastSeenNotifications'", | ||
insert: "INSERT INTO notification_seen (did, seen_at) VALUES (?1, ?2)", | ||
}, | ||
Kept { | ||
table: "notification_pref", | ||
source: "_preferences", | ||
from: "_preferences, notificationPrefs, a row a kind", | ||
count: "SELECT count(*) FROM _preferences p, json_each(CASE WHEN json_valid(p.value) THEN p.value ELSE '{}' END) k | ||
WHERE p.key = 'notificationPrefs' AND k.type = 'object' | ||
AND k.key IN ('favorites', 'follows', 'comments', 'mentions')", | ||
select: "SELECT p.did, k.key, coalesce(json_type(k.value, '$.push') <> 'false', 1), | ||
coalesce(json_type(k.value, '$.inApp') <> 'false', 1), | ||
coalesce(k.value ->> '$.from' = 'follows', 0) | ||
FROM _preferences p, json_each(CASE WHEN json_valid(p.value) THEN p.value ELSE '{}' END) k | ||
WHERE p.key = 'notificationPrefs' AND k.type = 'object' | ||
AND k.key IN ('favorites', 'follows', 'comments', 'mentions')", | ||
insert: "INSERT INTO notification_pref (did, kind, push, in_app, from_follows) | ||
VALUES (?1, ?2, ?3, ?4, ?5)", | ||
}, | ||
Kept { | ||
table: "push_token", | ||
source: "_push_tokens", | ||
crates/grain-serve/src/index/mod.rs+22−0
| @@ -431,6 +431,28 @@ impl Index { | ||
.exists([did]) | ||
} | ||
| ||
/// Marks `did`'s notifications seen as of `seen_at`, an RFC 3339 | ||
/// time, kept in UTC to the millisecond as SQLite's `strftime` writes | ||
/// it, so that it compares as text with the times records are kept | ||
/// at. A later call replaces it, earlier or not, as Bluesky's | ||
/// `updateSeen` does. Whether it was kept: not where it is a time | ||
/// UTC cannot write in four digits of year, such as the first moments | ||
/// of year 0 east of Greenwich. | ||
pub fn set_seen(&self, did: &str, seen_at: &str) -> rusqlite::Result<bool> { | ||
let db = self.db.lock().unwrap(); | ||
let sql = "SELECT strftime('%Y-%m-%dT%H:%M:%fZ', ?1)"; | ||
let utc: Option<String> = db.query_row(sql, [seen_at], |r| r.get(0))?; | ||
let Some(utc) = utc.filter(|t| lexicon::valid_datetime(t)) else { | ||
return Ok(false); | ||
}; | ||
db.execute( | ||
"INSERT INTO notification_seen VALUES (?1, ?2) | ||
ON CONFLICT (did) DO UPDATE SET seen_at = excluded.seen_at", | ||
[did, &utc], | ||
)?; | ||
Ok(true) | ||
} | ||
| ||
/// The last sequence number read from the stream `source`. | ||
pub fn cursor(&self, source: &str) -> rusqlite::Result<Option<i64>> { | ||
let db = self.db.lock().unwrap(); | ||
crates/grain-serve/src/index/schema.rs+33−2
| @@ -57,7 +57,13 @@ | ||
//! has them; one on an account names it by its bare DID, not | ||
//! `at://<did>`, as Bluesky's do; | ||
//! - `report`, `mute`, `preference`, `push_token`, `story_view`, | ||
//! `space_invite`, `space_support`. | ||
//! `space_invite`, `space_support`; | ||
//! - `notification_seen`, when each account last saw its notifications | ||
//! (`updateSeen`'s `seenAt`, in UTC as [`grain::keys::iso`] writes it), | ||
//! and `notification_pref`, how each kind of notification reaches it | ||
//! (`favorites`, `follows`, `comments`, `mentions`): pushed or not, | ||
//! shown in the app or not, and from anyone or only from accounts it | ||
//! follows. A kind with no row reaches it every way, from anyone. | ||
//! | ||
//! A migration may drop and remake a record table, which backfill refills; | ||
//! these it only alters in place. | ||
| @@ -71,7 +77,7 @@ | ||
| ||
/// Each step of the schema, run once, in order, by `PRAGMA user_version`. | ||
/// A step is only ever appended: one that has run is never changed. | ||
pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4]; | ||
pub const MIGRATIONS: &[&str] = &[V1, V2, V3, V4, V5]; | ||
| ||
const V1: &str = " | ||
CREATE TABLE gallery ( | ||
| @@ -331,6 +337,31 @@ INSERT INTO gallery_search (rowid, handle, title, description, place) | ||
FROM gallery g JOIN search_doc d ON d.uri = g.uri LEFT JOIN repo r ON r.did = g.did; | ||
"; | ||
| ||
/// Notifications' own state, apart from `preference`, and what an index | ||
/// imported before it kept there moved over: hatk's `lastSeenNotifications` | ||
/// and `notificationPrefs` preferences. A kind of notification hatk kept | ||
/// as anything but an object said nothing, and a seen time that is no | ||
/// time is dropped. | ||
const V5: &str = " | ||
CREATE TABLE notification_seen (did TEXT PRIMARY KEY, seen_at TEXT NOT NULL) WITHOUT ROWID; | ||
CREATE TABLE notification_pref ( | ||
did TEXT NOT NULL, kind TEXT NOT NULL, push INTEGER NOT NULL, in_app INTEGER NOT NULL, | ||
from_follows INTEGER NOT NULL, PRIMARY KEY (did, kind)) WITHOUT ROWID; | ||
| ||
INSERT INTO notification_seen (did, seen_at) | ||
SELECT did, strftime('%Y-%m-%dT%H:%M:%fZ', value ->> '$') FROM preference | ||
WHERE key = 'lastSeenNotifications' AND json_valid(value) | ||
AND strftime('%Y-%m-%dT%H:%M:%fZ', value ->> '$') IS NOT NULL; | ||
INSERT INTO notification_pref (did, kind, push, in_app, from_follows) | ||
SELECT p.did, k.key, coalesce(json_type(k.value, '$.push') <> 'false', 1), | ||
coalesce(json_type(k.value, '$.inApp') <> 'false', 1), | ||
coalesce(k.value ->> '$.from' = 'follows', 0) | ||
FROM preference p, json_each(CASE WHEN json_valid(p.value) THEN p.value ELSE '{}' END) k | ||
WHERE p.key = 'notificationPrefs' AND k.type = 'object' | ||
AND k.key IN ('favorites', 'follows', 'comments', 'mentions'); | ||
DELETE FROM preference WHERE key IN ('lastSeenNotifications', 'notificationPrefs'); | ||
"; | ||
| ||
/// The label values that leave what carries them out of feeds: the | ||
/// imperative ones, and those whose default is to hide. | ||
pub const HIDDEN_LABELS: [&str; 9] = [ | ||
crates/grain-serve/src/index/tests.rs+37−0
| @@ -447,6 +447,43 @@ fn migrations() { | ||
assert!(failed.starts_with("opening "), "{failed}"); | ||
} | ||
| ||
#[test] | ||
fn notifications_preferences_move_to_their_own_tables() { | ||
let dir = tempfile::tempdir().unwrap(); | ||
let path = dir.path().join("index.db"); | ||
let before = &schema::MIGRATIONS[..4]; | ||
drop(Index::with(Connection::open(&path).unwrap(), before).unwrap()); | ||
let db = Connection::open(&path).unwrap(); | ||
db.execute_batch( | ||
r#"INSERT INTO preference VALUES | ||
('did:plc:a', 'lastSeenNotifications', '"2026-10-31T12:00:00Z"', 'x'), | ||
('did:plc:b', 'lastSeenNotifications', '"never"', 'x'), | ||
('did:plc:c', 'lastSeenNotifications', 'not json', 'x'), | ||
('did:plc:a', 'notificationPrefs', '{"follows":{"push":false,"inApp":true,"from":"follows"},"comments":[]}', 'x'), | ||
('did:plc:b', 'notificationPrefs', 'not json', 'x'), | ||
('did:plc:a', 'pinnedFeeds', '[]', 'x');"#, | ||
) | ||
.unwrap(); | ||
drop(db); | ||
let index = Index::open(&path).unwrap(); | ||
let rows = |sql: &str| -> Vec<String> { | ||
index.read(|db| { | ||
let mut stmt = db.prepare(sql).unwrap(); | ||
let rows = stmt.query_map([], |r| r.get(0)).unwrap(); | ||
rows.collect::<rusqlite::Result<_>>().unwrap() | ||
}) | ||
}; | ||
assert_eq!( | ||
rows("SELECT did || ' ' || seen_at FROM notification_seen"), | ||
["did:plc:a 2026-10-31T12:00:00.000Z"] | ||
); | ||
assert_eq!( | ||
rows("SELECT concat_ws(' ', did, kind, push, in_app, from_follows) FROM notification_pref"), | ||
["did:plc:a follows 0 1 1"] | ||
); | ||
assert_eq!(rows("SELECT key FROM preference"), ["pinnedFeeds"]); | ||
} | ||
| ||
#[test] | ||
fn the_queries_still_to_port_read_by_index() { | ||
let index = Index::memory().unwrap(); | ||
crates/grain-serve/src/read/mod.rs+1−4
| @@ -234,10 +234,7 @@ pub fn list<S: AsRef<str>>(items: &[S]) -> String { | ||
impl Reader<'_> { | ||
/// The caller, for a method only an account may call. | ||
pub fn signed_in(&self) -> Result<&str, Failure> { | ||
self.viewer.ok_or_else(|| { | ||
let why = "a service token is required"; | ||
Failure::new(StatusCode::UNAUTHORIZED, "AuthenticationRequired", why) | ||
}) | ||
self.viewer.ok_or_else(Failure::authentication_required) | ||
} | ||
| ||
/// The DID `actor`, a DID or a handle, names, if the index knows the | ||
crates/grain-serve/src/lib.rs+8−2
| @@ -1,12 +1,14 @@ | ||
//! grain's server. It answers atproto's health check, | ||
//! `social.grain.sync.indexRecords` ([`sync`]) and the queries that read | ||
//! the index alone ([`read`]); the feeds, login, writes and push join it | ||
//! `social.grain.sync.indexRecords` ([`sync`]), the queries that read | ||
//! the index alone ([`read`]) and what an account has seen of its | ||
//! notifications ([`notification`]); the feeds, login, writes and push join it | ||
//! pull by pull on `feature/rust`, as the plan orders them. | ||
| ||
pub mod backfill; | ||
pub mod cdn; | ||
pub mod index; | ||
pub mod jetstream; | ||
pub mod notification; | ||
pub mod read; | ||
pub mod sync; | ||
pub mod viewer; | ||
| @@ -164,6 +166,10 @@ pub fn router(app: Arc<App>) -> Router { | ||
"/xrpc/social.grain.sync.indexRecords", | ||
post(sync::index_records), | ||
) | ||
.route( | ||
&format!("/xrpc/{}", notification::UPDATE_SEEN), | ||
post(notification::update_seen), | ||
) | ||
.route("/xrpc/{*method}", any(not_implemented)) | ||
.with_state(app) | ||
} | ||
crates/grain-serve/src/notification.rsnew+56−0
| @@ -0,0 +1,56 @@ | ||
//! What an account has seen of its notifications: | ||
//! `social.grain.notification.updateSeen`, as Bluesky's | ||
//! `app.bsky.notification.updateSeen` takes it. The time is kept in | ||
//! `notification_seen` ([`Index::set_seen`]), which the index cannot | ||
//! rebuild from the network. | ||
//! | ||
//! [`Index::set_seen`]: crate::index::Index::set_seen | ||
| ||
use crate::App; | ||
use crate::xrpc::Failure; | ||
use axum::Json; | ||
use axum::body::Bytes; | ||
use axum::extract::State; | ||
use axum::http::{HeaderMap, StatusCode}; | ||
use grain::lexicon; | ||
use serde::Deserialize; | ||
use serde_json::{Value, json}; | ||
use std::sync::Arc; | ||
| ||
/// The method, as a service token for it names it in `lxm`. | ||
pub const UPDATE_SEEN: &str = "social.grain.notification.updateSeen"; | ||
| ||
#[derive(Deserialize)] | ||
#[serde(rename_all = "camelCase")] | ||
struct Seen { | ||
seen_at: String, | ||
} | ||
| ||
/// `social.grain.notification.updateSeen`: the caller's notifications | ||
/// marked seen as of `seenAt`, which only an account may do. | ||
pub async fn update_seen( | ||
State(app): State<Arc<App>>, | ||
headers: HeaderMap, | ||
body: Bytes, | ||
) -> Result<Json<Value>, Failure> { | ||
let Some(index) = &app.index else { | ||
let why = "this server keeps no index"; | ||
return Err(Failure::new( | ||
StatusCode::NOT_IMPLEMENTED, | ||
"MethodNotImplemented", | ||
why, | ||
)); | ||
}; | ||
let viewer = crate::viewer::viewer(&app, &headers, UPDATE_SEEN).await?; | ||
let did = viewer.ok_or_else(Failure::authentication_required)?; | ||
let input: Seen = serde_json::from_slice(&body) | ||
.map_err(|e| Failure::invalid(format!("the input is not {{seenAt}}: {e}")))?; | ||
let invalid = || Failure::invalid("seenAt must be a valid datetime"); | ||
if !lexicon::valid_datetime(&input.seen_at) { | ||
return Err(invalid()); | ||
} | ||
match index.set_seen(&did, &input.seen_at)? { | ||
true => Ok(Json(json!({}))), | ||
false => Err(invalid()), | ||
} | ||
} | ||
crates/grain-serve/src/sync.rs+1−4
| @@ -224,10 +224,7 @@ pub async fn index_records( | ||
.get(header::AUTHORIZATION) | ||
.and_then(|v| v.to_str().ok()) | ||
.and_then(|v| v.strip_prefix("Bearer ")) | ||
.ok_or_else(|| { | ||
let why = "a service token is required"; | ||
Failure::new(StatusCode::UNAUTHORIZED, "AuthenticationRequired", why) | ||
})?; | ||
.ok_or_else(Failure::authentication_required)?; | ||
let did = grain::auth::verify(&app.ctx, token, aud, LXM) | ||
.await | ||
.map_err(|e| Failure::new(StatusCode::UNAUTHORIZED, "InvalidToken", format!("{e:#}")))?; | ||
crates/grain-serve/src/xrpc.rs+7−0
| @@ -25,6 +25,13 @@ impl Failure { | ||
} | ||
} | ||
| ||
/// A method only an account may call, called by no one: 401 | ||
/// `AuthenticationRequired`. | ||
pub fn authentication_required() -> Failure { | ||
let why = "a service token is required"; | ||
Failure::new(StatusCode::UNAUTHORIZED, "AuthenticationRequired", why) | ||
} | ||
| ||
/// A request that does not say what it must: 400 `InvalidRequest`. | ||
pub fn invalid(message: impl Into<String>) -> Failure { | ||
Failure::new(StatusCode::BAD_REQUEST, "InvalidRequest", message) | ||