Select the types of activity you want to include in your feed.
Remove Rust appview crate, replaced by Elixir appview [CL-290]
Delete crates/opake-appview/ (~3100 LOC Rust), Containerfile.appview, and workspace member. Update CONTRIBUTING.md to reflect the new Elixir appview at appview/.
···11-use rusqlite::{params, Connection};
22-33-use crate::error::Result;
44-55-/// Jetstream cursors are unix microsecond timestamps.
66-pub const MICROS_PER_SECOND: i64 = 1_000_000;
77-88-/// Save the Jetstream cursor (unix microseconds timestamp).
99-/// Uses upsert into the singleton row (id = 1).
1010-pub fn save_cursor(conn: &Connection, time_us: i64) -> Result<()> {
1111- let now = chrono::Utc::now().to_rfc3339();
1212- conn.execute(
1313- "INSERT INTO cursor (id, time_us, updated_at)
1414- VALUES (1, ?1, ?2)
1515- ON CONFLICT(id) DO UPDATE SET
1616- time_us = excluded.time_us,
1717- updated_at = excluded.updated_at",
1818- params![time_us, now],
1919- )?;
2020- Ok(())
2121-}
2222-2323-/// Load the last saved Jetstream cursor, if any.
2424-pub fn load_cursor(conn: &Connection) -> Result<Option<i64>> {
2525- let mut stmt = conn.prepare("SELECT time_us FROM cursor WHERE id = 1")?;
2626- let result = stmt.query_row([], |row| row.get::<_, i64>(0));
2727- match result {
2828- Ok(time_us) => Ok(Some(time_us)),
2929- Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
3030- Err(e) => Err(e.into()),
3131- }
3232-}
-233
crates/opake-appview/src/db/db_tests.rs
···11-use super::*;
22-use grants::IndexedGrant;
33-44-fn test_db() -> Database {
55- Database::open_in_memory().unwrap()
66-}
77-88-fn make_grant(uri: &str, recipient: &str, owner: &str, doc_uri: &str) -> IndexedGrant {
99- IndexedGrant {
1010- uri: uri.into(),
1111- owner_did: owner.into(),
1212- recipient_did: recipient.into(),
1313- document_uri: doc_uri.into(),
1414- created_at: "2026-03-01T12:00:00Z".into(),
1515- indexed_at: "2026-03-01T12:00:01Z".into(),
1616- }
1717-}
1818-1919-#[test]
2020-fn grant_upsert_and_query() {
2121- let db = test_db();
2222- let grant = make_grant(
2323- "at://did:plc:owner/app.opake.grant/3abc",
2424- "did:plc:recipient",
2525- "did:plc:owner",
2626- "at://did:plc:owner/app.opake.document/3xyz",
2727- );
2828-2929- db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap();
3030-3131- let inbox = db
3232- .with_conn(|c| grants::list_inbox(c, "did:plc:recipient", 50, None))
3333- .unwrap();
3434- assert_eq!(inbox.len(), 1);
3535- assert_eq!(inbox[0].uri, grant.uri);
3636- assert_eq!(inbox[0].document_uri, grant.document_uri);
3737-}
3838-3939-#[test]
4040-fn grant_upsert_overwrites() {
4141- let db = test_db();
4242- let mut grant = make_grant(
4343- "at://did:plc:owner/app.opake.grant/3abc",
4444- "did:plc:recipient",
4545- "did:plc:owner",
4646- "at://did:plc:owner/app.opake.document/3xyz",
4747- );
4848- db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap();
4949-5050- grant.document_uri = "at://did:plc:owner/app.opake.document/updated".into();
5151- db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap();
5252-5353- let inbox = db
5454- .with_conn(|c| grants::list_inbox(c, "did:plc:recipient", 50, None))
5555- .unwrap();
5656- assert_eq!(inbox.len(), 1);
5757- assert_eq!(
5858- inbox[0].document_uri,
5959- "at://did:plc:owner/app.opake.document/updated"
6060- );
6161-}
6262-6363-#[test]
6464-fn grant_delete() {
6565- let db = test_db();
6666- let grant = make_grant(
6767- "at://did:plc:owner/app.opake.grant/3abc",
6868- "did:plc:recipient",
6969- "did:plc:owner",
7070- "at://did:plc:owner/app.opake.document/3xyz",
7171- );
7272- db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap();
7373- db.with_conn(|c| grants::delete_grant(c, &grant.uri))
7474- .unwrap();
7575-7676- let inbox = db
7777- .with_conn(|c| grants::list_inbox(c, "did:plc:recipient", 50, None))
7878- .unwrap();
7979- assert!(inbox.is_empty());
8080-}
8181-8282-#[test]
8383-fn grant_pagination() {
8484- let db = test_db();
8585-8686- for i in 0..5 {
8787- let grant = IndexedGrant {
8888- uri: format!("at://did:plc:owner/app.opake.grant/{i}"),
8989- owner_did: "did:plc:owner".into(),
9090- recipient_did: "did:plc:me".into(),
9191- document_uri: format!("at://did:plc:owner/app.opake.document/{i}"),
9292- created_at: "2026-03-01T12:00:00Z".into(),
9393- indexed_at: format!("2026-03-01T12:00:0{i}Z"),
9494- };
9595- db.with_conn(|c| grants::upsert_grant(c, &grant)).unwrap();
9696- }
9797-9898- // First page: 2 items
9999- let page1 = db
100100- .with_conn(|c| grants::list_inbox(c, "did:plc:me", 2, None))
101101- .unwrap();
102102- assert_eq!(page1.len(), 2);
103103- // Newest first
104104- assert!(page1[0].indexed_at > page1[1].indexed_at);
105105-106106- // Second page using cursor from last item of page 1
107107- let cursor = grants::encode_cursor(&page1[1]);
108108- let page2 = db
109109- .with_conn(|c| grants::list_inbox(c, "did:plc:me", 2, Some(&cursor)))
110110- .unwrap();
111111- assert_eq!(page2.len(), 2);
112112- assert!(page2[0].indexed_at < page1[1].indexed_at);
113113-}
114114-115115-#[test]
116116-fn keyring_upsert_and_query() {
117117- let db = test_db();
118118- let members = vec!["did:plc:alice".to_string(), "did:plc:bob".to_string()];
119119-120120- db.with_conn(|c| {
121121- keyrings::upsert_keyring_members(
122122- c,
123123- "at://did:plc:owner/app.opake.keyring/3def",
124124- "did:plc:owner",
125125- &members,
126126- "2026-03-01T12:00:00Z",
127127- )
128128- })
129129- .unwrap();
130130-131131- let alice_keyrings = db
132132- .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:alice", 50, None))
133133- .unwrap();
134134- assert_eq!(alice_keyrings.len(), 1);
135135-136136- let bob_keyrings = db
137137- .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:bob", 50, None))
138138- .unwrap();
139139- assert_eq!(bob_keyrings.len(), 1);
140140-141141- // Charlie is not a member
142142- let charlie_keyrings = db
143143- .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:charlie", 50, None))
144144- .unwrap();
145145- assert!(charlie_keyrings.is_empty());
146146-}
147147-148148-#[test]
149149-fn keyring_update_replaces_members() {
150150- let db = test_db();
151151- let uri = "at://did:plc:owner/app.opake.keyring/3def";
152152-153153- // Initially: alice + bob
154154- db.with_conn(|c| {
155155- keyrings::upsert_keyring_members(
156156- c,
157157- uri,
158158- "did:plc:owner",
159159- &["did:plc:alice".into(), "did:plc:bob".into()],
160160- "2026-03-01T12:00:00Z",
161161- )
162162- })
163163- .unwrap();
164164-165165- // Update: bob removed, charlie added
166166- db.with_conn(|c| {
167167- keyrings::upsert_keyring_members(
168168- c,
169169- uri,
170170- "did:plc:owner",
171171- &["did:plc:alice".into(), "did:plc:charlie".into()],
172172- "2026-03-01T13:00:00Z",
173173- )
174174- })
175175- .unwrap();
176176-177177- // Bob should no longer see it
178178- let bob = db
179179- .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:bob", 50, None))
180180- .unwrap();
181181- assert!(bob.is_empty());
182182-183183- // Charlie should see it
184184- let charlie = db
185185- .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:charlie", 50, None))
186186- .unwrap();
187187- assert_eq!(charlie.len(), 1);
188188-}
189189-190190-#[test]
191191-fn keyring_delete() {
192192- let db = test_db();
193193- let uri = "at://did:plc:owner/app.opake.keyring/3def";
194194-195195- db.with_conn(|c| {
196196- keyrings::upsert_keyring_members(
197197- c,
198198- uri,
199199- "did:plc:owner",
200200- &["did:plc:alice".into()],
201201- "2026-03-01T12:00:00Z",
202202- )
203203- })
204204- .unwrap();
205205-206206- db.with_conn(|c| keyrings::delete_keyring(c, uri)).unwrap();
207207-208208- let alice = db
209209- .with_conn(|c| keyrings::list_keyrings_for_member(c, "did:plc:alice", 50, None))
210210- .unwrap();
211211- assert!(alice.is_empty());
212212-}
213213-214214-#[test]
215215-fn cursor_roundtrip() {
216216- let db = test_db();
217217-218218- // No cursor initially
219219- let initial = db.with_conn(cursor::load_cursor).unwrap();
220220- assert!(initial.is_none());
221221-222222- // Save and load
223223- db.with_conn(|c| cursor::save_cursor(c, 1709330400000000))
224224- .unwrap();
225225- let loaded = db.with_conn(cursor::load_cursor).unwrap();
226226- assert_eq!(loaded, Some(1709330400000000));
227227-228228- // Update
229229- db.with_conn(|c| cursor::save_cursor(c, 1709330500000000))
230230- .unwrap();
231231- let updated = db.with_conn(cursor::load_cursor).unwrap();
232232- assert_eq!(updated, Some(1709330500000000));
233233-}
-158
crates/opake-appview/src/db/grants.rs
···11-use rusqlite::{params, Connection};
22-33-use crate::error::Result;
44-55-/// A grant row as stored in the index.
66-#[derive(Debug, Clone)]
77-pub struct IndexedGrant {
88- pub uri: String,
99- pub owner_did: String,
1010- pub recipient_did: String,
1111- pub document_uri: String,
1212- pub created_at: String,
1313- pub indexed_at: String,
1414-}
1515-1616-pub fn upsert_grant(conn: &Connection, grant: &IndexedGrant) -> Result<()> {
1717- conn.execute(
1818- "INSERT INTO grants (uri, owner_did, recipient_did, document_uri, created_at, indexed_at)
1919- VALUES (?1, ?2, ?3, ?4, ?5, ?6)
2020- ON CONFLICT(uri) DO UPDATE SET
2121- owner_did = excluded.owner_did,
2222- recipient_did = excluded.recipient_did,
2323- document_uri = excluded.document_uri,
2424- created_at = excluded.created_at,
2525- indexed_at = excluded.indexed_at",
2626- params![
2727- grant.uri,
2828- grant.owner_did,
2929- grant.recipient_did,
3030- grant.document_uri,
3131- grant.created_at,
3232- grant.indexed_at,
3333- ],
3434- )?;
3535- Ok(())
3636-}
3737-3838-pub fn delete_grant(conn: &Connection, uri: &str) -> Result<()> {
3939- conn.execute("DELETE FROM grants WHERE uri = ?1", params![uri])?;
4040- Ok(())
4141-}
4242-4343-/// Paginated inbox query: grants for a recipient DID, newest first.
4444-/// Cursor is a composite `indexed_at::uri` string.
4545-pub fn list_inbox(
4646- conn: &Connection,
4747- recipient_did: &str,
4848- limit: u32,
4949- cursor: Option<&str>,
5050-) -> Result<Vec<IndexedGrant>> {
5151- let mut grants = Vec::new();
5252-5353- if let Some(cursor) = cursor {
5454- let (cursor_time, cursor_uri) = parse_cursor(cursor);
5555- let mut stmt = conn.prepare(
5656- "SELECT uri, owner_did, recipient_did, document_uri, created_at, indexed_at
5757- FROM grants
5858- WHERE recipient_did = ?1
5959- AND (indexed_at < ?2 OR (indexed_at = ?2 AND uri < ?3))
6060- ORDER BY indexed_at DESC, uri DESC
6161- LIMIT ?4",
6262- )?;
6363- let rows = stmt.query_map(
6464- params![recipient_did, cursor_time, cursor_uri, limit],
6565- row_to_grant,
6666- )?;
6767- for row in rows {
6868- grants.push(row?);
6969- }
7070- } else {
7171- let mut stmt = conn.prepare(
7272- "SELECT uri, owner_did, recipient_did, document_uri, created_at, indexed_at
7373- FROM grants
7474- WHERE recipient_did = ?1
7575- ORDER BY indexed_at DESC, uri DESC
7676- LIMIT ?2",
7777- )?;
7878- let rows = stmt.query_map(params![recipient_did, limit], row_to_grant)?;
7979- for row in rows {
8080- grants.push(row?);
8181- }
8282- }
8383-8484- Ok(grants)
8585-}
8686-8787-/// List grants created by an owner DID, newest first.
8888-#[allow(dead_code)]
8989-pub fn list_grants_by_owner(
9090- conn: &Connection,
9191- owner_did: &str,
9292- limit: u32,
9393- cursor: Option<&str>,
9494-) -> Result<Vec<IndexedGrant>> {
9595- let mut grants = Vec::new();
9696-9797- if let Some(cursor) = cursor {
9898- let (cursor_time, cursor_uri) = parse_cursor(cursor);
9999- let mut stmt = conn.prepare(
100100- "SELECT uri, owner_did, recipient_did, document_uri, created_at, indexed_at
101101- FROM grants
102102- WHERE owner_did = ?1
103103- AND (indexed_at < ?2 OR (indexed_at = ?2 AND uri < ?3))
104104- ORDER BY indexed_at DESC, uri DESC
105105- LIMIT ?4",
106106- )?;
107107- let rows = stmt.query_map(
108108- params![owner_did, cursor_time, cursor_uri, limit],
109109- row_to_grant,
110110- )?;
111111- for row in rows {
112112- grants.push(row?);
113113- }
114114- } else {
115115- let mut stmt = conn.prepare(
116116- "SELECT uri, owner_did, recipient_did, document_uri, created_at, indexed_at
117117- FROM grants
118118- WHERE owner_did = ?1
119119- ORDER BY indexed_at DESC, uri DESC
120120- LIMIT ?2",
121121- )?;
122122- let rows = stmt.query_map(params![owner_did, limit], row_to_grant)?;
123123- for row in rows {
124124- grants.push(row?);
125125- }
126126- }
127127-128128- Ok(grants)
129129-}
130130-131131-fn row_to_grant(row: &rusqlite::Row) -> rusqlite::Result<IndexedGrant> {
132132- Ok(IndexedGrant {
133133- uri: row.get(0)?,
134134- owner_did: row.get(1)?,
135135- recipient_did: row.get(2)?,
136136- document_uri: row.get(3)?,
137137- created_at: row.get(4)?,
138138- indexed_at: row.get(5)?,
139139- })
140140-}
141141-142142-/// Build a cursor string from an indexed grant.
143143-pub fn encode_cursor(grant: &IndexedGrant) -> String {
144144- format!("{}::{}", grant.indexed_at, grant.uri)
145145-}
146146-147147-/// Count total grants in the index.
148148-pub fn count_grants(conn: &Connection) -> Result<i64> {
149149- let count = conn.query_row("SELECT COUNT(*) FROM grants", [], |row| row.get(0))?;
150150- Ok(count)
151151-}
152152-153153-fn parse_cursor(cursor: &str) -> (&str, &str) {
154154- match cursor.split_once("::") {
155155- Some((time, uri)) => (time, uri),
156156- None => (cursor, ""),
157157- }
158158-}
-124
crates/opake-appview/src/db/keyrings.rs
···11-use rusqlite::{params, Connection};
22-33-use crate::error::Result;
44-55-/// A keyring membership row as stored in the index.
66-#[derive(Debug, Clone)]
77-#[allow(dead_code)]
88-pub struct IndexedKeyringMember {
99- pub keyring_uri: String,
1010- pub member_did: String,
1111- pub owner_did: String,
1212- pub indexed_at: String,
1313-}
1414-1515-/// Replace all members for a keyring (delete-and-reinsert).
1616-/// This handles both create and update events correctly — on update,
1717-/// the member list may have changed, so we wipe and rewrite.
1818-pub fn upsert_keyring_members(
1919- conn: &Connection,
2020- keyring_uri: &str,
2121- owner_did: &str,
2222- member_dids: &[String],
2323- indexed_at: &str,
2424-) -> Result<()> {
2525- conn.execute(
2626- "DELETE FROM keyring_members WHERE keyring_uri = ?1",
2727- params![keyring_uri],
2828- )?;
2929-3030- let mut stmt = conn.prepare(
3131- "INSERT INTO keyring_members (keyring_uri, member_did, owner_did, indexed_at)
3232- VALUES (?1, ?2, ?3, ?4)",
3333- )?;
3434-3535- for did in member_dids {
3636- stmt.execute(params![keyring_uri, did, owner_did, indexed_at])?;
3737- }
3838-3939- Ok(())
4040-}
4141-4242-/// Delete all member rows for a keyring (when the record is deleted).
4343-pub fn delete_keyring(conn: &Connection, keyring_uri: &str) -> Result<()> {
4444- conn.execute(
4545- "DELETE FROM keyring_members WHERE keyring_uri = ?1",
4646- params![keyring_uri],
4747- )?;
4848- Ok(())
4949-}
5050-5151-/// Paginated query: keyrings where a DID is a member, newest first.
5252-/// Returns one row per unique keyring (not per member).
5353-pub fn list_keyrings_for_member(
5454- conn: &Connection,
5555- member_did: &str,
5656- limit: u32,
5757- cursor: Option<&str>,
5858-) -> Result<Vec<IndexedKeyringMember>> {
5959- let mut keyrings = Vec::new();
6060-6161- if let Some(cursor) = cursor {
6262- let (cursor_time, cursor_uri) = parse_cursor(cursor);
6363- let mut stmt = conn.prepare(
6464- "SELECT keyring_uri, member_did, owner_did, indexed_at
6565- FROM keyring_members
6666- WHERE member_did = ?1
6767- AND (indexed_at < ?2 OR (indexed_at = ?2 AND keyring_uri < ?3))
6868- ORDER BY indexed_at DESC, keyring_uri DESC
6969- LIMIT ?4",
7070- )?;
7171- let rows = stmt.query_map(
7272- params![member_did, cursor_time, cursor_uri, limit],
7373- row_to_member,
7474- )?;
7575- for row in rows {
7676- keyrings.push(row?);
7777- }
7878- } else {
7979- let mut stmt = conn.prepare(
8080- "SELECT keyring_uri, member_did, owner_did, indexed_at
8181- FROM keyring_members
8282- WHERE member_did = ?1
8383- ORDER BY indexed_at DESC, keyring_uri DESC
8484- LIMIT ?2",
8585- )?;
8686- let rows = stmt.query_map(params![member_did, limit], row_to_member)?;
8787- for row in rows {
8888- keyrings.push(row?);
8989- }
9090- }
9191-9292- Ok(keyrings)
9393-}
9494-9595-fn row_to_member(row: &rusqlite::Row) -> rusqlite::Result<IndexedKeyringMember> {
9696- Ok(IndexedKeyringMember {
9797- keyring_uri: row.get(0)?,
9898- member_did: row.get(1)?,
9999- owner_did: row.get(2)?,
100100- indexed_at: row.get(3)?,
101101- })
102102-}
103103-104104-/// Build a cursor string from an indexed keyring member.
105105-pub fn encode_cursor(member: &IndexedKeyringMember) -> String {
106106- format!("{}::{}", member.indexed_at, member.keyring_uri)
107107-}
108108-109109-/// Count unique keyrings in the index.
110110-pub fn count_unique_keyrings(conn: &Connection) -> Result<i64> {
111111- let count = conn.query_row(
112112- "SELECT COUNT(DISTINCT keyring_uri) FROM keyring_members",
113113- [],
114114- |row| row.get(0),
115115- )?;
116116- Ok(count)
117117-}
118118-119119-fn parse_cursor(cursor: &str) -> (&str, &str) {
120120- match cursor.split_once("::") {
121121- Some((time, uri)) => (time, uri),
122122- None => (cursor, ""),
123123- }
124124-}
-57
crates/opake-appview/src/db/mod.rs
···11-pub mod cursor;
22-pub mod grants;
33-pub mod keyrings;
44-mod schema;
55-66-use std::path::Path;
77-use std::sync::Mutex;
88-99-use rusqlite::Connection;
1010-1111-use crate::error::Result;
1212-1313-/// Thread-safe database handle. Axum handlers and the indexer share this.
1414-pub struct Database {
1515- conn: Mutex<Connection>,
1616-}
1717-1818-impl Database {
1919- /// Open (or create) the SQLite database at the given path and run migrations.
2020- pub fn open(path: &Path) -> Result<Self> {
2121- if let Some(parent) = path.parent() {
2222- if !parent.exists() {
2323- std::fs::create_dir_all(parent)?;
2424- }
2525- }
2626- let conn = Connection::open(path)?;
2727- conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;")?;
2828- conn.execute_batch(schema::SCHEMA)?;
2929- Ok(Self {
3030- conn: Mutex::new(conn),
3131- })
3232- }
3333-3434- /// Create an in-memory database for testing.
3535- #[cfg(test)]
3636- pub fn open_in_memory() -> Result<Self> {
3737- let conn = Connection::open_in_memory()?;
3838- conn.execute_batch(schema::SCHEMA)?;
3939- Ok(Self {
4040- conn: Mutex::new(conn),
4141- })
4242- }
4343-4444- /// Run a closure with a reference to the connection.
4545- /// Panics if the mutex is poisoned (unrecoverable).
4646- pub fn with_conn<F, T>(&self, f: F) -> T
4747- where
4848- F: FnOnce(&Connection) -> T,
4949- {
5050- let conn = self.conn.lock().expect("database mutex poisoned");
5151- f(&conn)
5252- }
5353-}
5454-5555-#[cfg(test)]
5656-#[path = "db_tests.rs"]
5757-mod tests;
-28
crates/opake-appview/src/db/schema.rs
···11-/// SQL statements to initialize the database schema.
22-pub const SCHEMA: &str = "
33-CREATE TABLE IF NOT EXISTS cursor (
44- id INTEGER PRIMARY KEY CHECK (id = 1),
55- time_us INTEGER NOT NULL,
66- updated_at TEXT NOT NULL
77-);
88-99-CREATE TABLE IF NOT EXISTS grants (
1010- uri TEXT PRIMARY KEY,
1111- owner_did TEXT NOT NULL,
1212- recipient_did TEXT NOT NULL,
1313- document_uri TEXT NOT NULL,
1414- created_at TEXT NOT NULL,
1515- indexed_at TEXT NOT NULL
1616-);
1717-CREATE INDEX IF NOT EXISTS idx_grants_recipient ON grants (recipient_did);
1818-CREATE INDEX IF NOT EXISTS idx_grants_owner ON grants (owner_did);
1919-2020-CREATE TABLE IF NOT EXISTS keyring_members (
2121- keyring_uri TEXT NOT NULL,
2222- member_did TEXT NOT NULL,
2323- owner_did TEXT NOT NULL,
2424- indexed_at TEXT NOT NULL,
2525- PRIMARY KEY (keyring_uri, member_did)
2626-);
2727-CREATE INDEX IF NOT EXISTS idx_keyring_members_did ON keyring_members (member_did);
2828-";