From 63dd0951323c86bf5d1cd5a214146b6b1e99e0ce Mon Sep 17 00:00:00 2001 From: Zicklag Date: Sun, 1 Mar 2026 17:28:48 +0000 Subject: [PATCH 01/12] feat: add a function for subscribing to events in streams as they are added. --- leaf-stream/src/lib.rs | 51 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 49 insertions(+), 2 deletions(-) diff --git a/leaf-stream/src/lib.rs b/leaf-stream/src/lib.rs index 30d55e5..e3e4ea6 100644 --- a/leaf-stream/src/lib.rs +++ b/leaf-stream/src/lib.rs @@ -55,6 +55,7 @@ struct StreamState { module_event_cursor: i64, query_subscriptions: Arc>>, update_subscriptions: Arc>>>, + event_subscriptions: Arc>>>, worker_sender: Option>, } @@ -324,6 +325,7 @@ impl Stream { module_event_cursor, query_subscriptions: Default::default(), update_subscriptions: Arc::new(Mutex::new(Vec::new())), + event_subscriptions: Arc::new(Mutex::new(Vec::new())), worker_sender: None, })), }) @@ -377,6 +379,25 @@ impl Stream { receiver } + /// Subscribe to receive raw events as they are added to the stream. + /// + /// This method returns a receiver that will receive each [`Event`] as it is added + /// to the stream, in the order they are added. + pub async fn subscribe_events_stream(&self) -> async_channel::Receiver { + let state = self.state.read().await; + let mut event_subscriptions = state.event_subscriptions.lock().await; + + // Take the opportunity to clean up any closed subscriptions + event_subscriptions.retain(|v| !v.is_closed()); + + // TODO: I've heard unbounded channels are evil, but I don't want to deal with freezing the + // stream right now because we hit a buffer limit. Will re-evaluate later. + let (sender, receiver) = async_channel::unbounded(); + event_subscriptions.push(sender); + + receiver + } + /// If this stream needs a Leaf module to be loaded before it can continue processing events, /// then this will return `Some(x)`. If the stream has a specific module it needs, then `x` will /// be `Some(module_cid)`. @@ -605,6 +626,10 @@ impl Stream { tracing::debug!("Starting new transaction"); module_db.execute("begin immediate", ()).await?; + // Collect the events that are added so we can send them to subscribers once they are + // committed. + let mut added_events = Vec::new(); + let result = async { // TODO: we should probably have a separate mechanism for storing the batch signature // once, and we should also include the event indexes in the signature. @@ -646,8 +671,8 @@ impl Stream { module_db, Event { idx, - user: event.user, - payload: event.payload, + user: event.user.clone(), + payload: event.payload.clone(), signature: signature.clone(), }, ) @@ -657,6 +682,14 @@ impl Stream { tracing::debug!("Materialized event"); + // Collect the event for broadcasting + added_events.push(Event { + idx, + user: event.user, + payload: event.payload, + signature: signature.clone(), + }); + // Increment the module event cursor module_db .execute( @@ -683,6 +716,20 @@ impl Stream { module_db.execute("commit", ()).await?; state.latest_event += event_count as i64; state.module_event_cursor += event_count as i64; + + // Send new events to all subscribers + { + let mut event_subscriptions = state.event_subscriptions.lock().await; + // Clean up any closed subscriptions + event_subscriptions.retain(|v| !v.is_closed()); + + // Send each event to all subscribers + for event in &added_events { + for sender in &*event_subscriptions { + sender.try_send(event.clone()).ok(); + } + } + } } // Update query subscriptions From 44e4486fc3bade0aa1dcd1436297fcf33d362da0 Mon Sep 17 00:00:00 2001 From: Zicklag Date: Sun, 1 Mar 2026 18:05:18 +0000 Subject: [PATCH 02/12] ai: implement unread tracking for streams. --- Cargo.lock | 4 + leaf-server/Cargo.toml | 4 + leaf-server/src/http/connection.rs | 265 +++ leaf-server/src/main.rs | 19 + leaf-server/src/streams.rs | 51 + leaf-server/src/unreads.rs | 356 ++++ leaf-server/src/unreads_materializer.rs | 417 +++++ leaf-server/src/unreads_schema.sql | 73 + leaf-stream/src/lib.rs | 2 +- plans/unread-tracking-system-design.md | 2031 +++++++++++++++++++++++ 10 files changed, 3221 insertions(+), 1 deletion(-) create mode 100644 leaf-server/src/unreads.rs create mode 100644 leaf-server/src/unreads_materializer.rs create mode 100644 leaf-server/src/unreads_schema.sql create mode 100644 plans/unread-tracking-system-design.md diff --git a/Cargo.lock b/Cargo.lock index 104acd5..74f9211 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2155,15 +2155,19 @@ name = "leaf-server" version = "0.1.0-alpha.1" dependencies = [ "anyhow", + "async-channel", "async-lock", "atproto-identity", "atproto-oauth", + "atproto-plc", "bytes", "clap", + "dasl", "futures", "futures-util", "k256", "leaf-stream", + "leaf-stream-types", "leaf-utils", "libsql", "opentelemetry", diff --git a/leaf-server/Cargo.toml b/leaf-server/Cargo.toml index d2af638..90af509 100644 --- a/leaf-server/Cargo.toml +++ b/leaf-server/Cargo.toml @@ -14,6 +14,7 @@ path = "src/main.rs" [dependencies] # Leaf leaf-stream = { version = "0.1.0-alpha.1", path = "../leaf-stream" } +leaf-stream-types = { version = "0.1.0-alpha.1", path = "../leaf-stream-types" } leaf-utils = { version = "0.1.0-alpha.1", path = "../leaf-utils" } # Utils @@ -38,6 +39,7 @@ tokio = { version = "1.47.1", features = [ "signal", ] } futures-util = "0.3.31" +async-channel = "2.5.0" # CLI clap = { version = "4.5.45", features = ["derive", "env"] } @@ -58,10 +60,12 @@ tracing-subscriber = "0.3.19" # Storage libsql.workspace = true +dasl.workspace = true # Authentication atproto-oauth = "0.11.2" atproto-identity = "0.11.2" +atproto-plc.workspace = true # Formats & Serialization diff --git a/leaf-server/src/http/connection.rs b/leaf-server/src/http/connection.rs index ba77e5f..068eef9 100644 --- a/leaf-server/src/http/connection.rs +++ b/leaf-server/src/http/connection.rs @@ -25,6 +25,7 @@ use crate::{ error::LogError, storage::STORAGE, streams::STREAMS, + unreads::UnreadsDB, }; pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { @@ -507,6 +508,201 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { .ok(); }, ); + + // ============================================================================ + // Unreads tracking endpoints + // ============================================================================ + + let span_ = span.clone(); + let did_ = did.clone(); + socket.on( + "unreads/get", + async move |TryData::(bytes), ack: AckSender| { + let result = async { + let Some(did_) = did_ else { + anyhow::bail!("Only authenticated users can query unreads"); + }; + + let UnreadsGetArgs { stream_did } = dasl::drisl::from_slice(&bytes?[..])?; + + // Get the stream directory + let data_dir = STORAGE.data_dir()?; + let stream_dir = data_dir.join("streams").join(stream_did.as_str()); + + // Initialize the unreads database for this stream + let unreads_db = UnreadsDB::initialize(&stream_dir).await?; + + // Verify the user is a member of this space + if !unreads_db.is_member(&did_).await? { + anyhow::bail!("User {did_} is not a member of space {stream_did}"); + } + + // Get unreads for the user + let unreads = unreads_db.get_user_unreads(&did_).await?; + + // Convert to response format + let response: Vec = unreads + .into_iter() + .map(|u| UnreadsGetItem { + room_id: u.room_id, + unread_count: u.unread_count as u32, + mention_count: u.mention_count as u32, + }) + .collect(); + + anyhow::Ok(UnreadsGetResp { unreads: response }) + } + .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/get")) + .await; + + ack.send(&response(result)) + .log_error("Internal error sending response") + .ok(); + }, + ); + + let span_ = span.clone(); + let did_ = did.clone(); + socket.on( + "unreads/mark_read", + async move |TryData::(bytes), ack: AckSender| { + let result = async { + let Some(did_) = did_ else { + anyhow::bail!("Only authenticated users can mark items as read"); + }; + + let UnreadsMarkReadArgs { + stream_did, + room_id, + last_read_idx, + } = dasl::drisl::from_slice(&bytes?[..])?; + + // Get the stream directory + let data_dir = STORAGE.data_dir()?; + let stream_dir = data_dir.join("streams").join(stream_did.as_str()); + + // Initialize the unreads database for this stream + let unreads_db = UnreadsDB::initialize(&stream_dir).await?; + + // Verify the user is a member of this space + if !unreads_db.is_member(&did_).await? { + anyhow::bail!("User {did_} is not a member of space {stream_did}"); + } + + if let Some(room_id) = room_id { + // Mark specific room as read + // Use provided last_read_idx or get the latest event index from the stream + let last_read_idx = match last_read_idx { + Some(idx) => idx, + None => { + // Get the stream to fetch the latest event index + let stream = STREAMS.load(stream_did.clone()).await?; + stream.latest_event().await + } + }; + unreads_db + .mark_as_read(&did_, &room_id, last_read_idx) + .await?; + } else { + // Mark all rooms as read + unreads_db.reset_user_unreads(&did_).await?; + } + + anyhow::Ok(UnreadsMarkReadResp { success: true }) + } + .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/mark_read")) + .await; + + ack.send(&response(result)) + .log_error("Internal error sending response") + .ok(); + }, + ); + + let span_ = span.clone(); + let did_ = did.clone(); + socket.on( + "unreads/space_members", + async move |TryData::(bytes), ack: AckSender| { + let result = async { + let Some(did_) = did_ else { + anyhow::bail!("Only authenticated users can query space members"); + }; + + let UnreadsSpaceMembersArgs { stream_did } = dasl::drisl::from_slice(&bytes?[..])?; + + // Get the stream directory + let data_dir = STORAGE.data_dir()?; + let stream_dir = data_dir.join("streams").join(stream_did.as_str()); + + // Initialize the unreads database for this stream + let unreads_db = UnreadsDB::initialize(&stream_dir).await?; + + // Verify the user is a member of this space + if !unreads_db.is_member(&did_).await? { + anyhow::bail!("User {did_} is not a member of space {stream_did}"); + } + + // Get space members + let members = unreads_db.get_space_members().await?; + + // Convert to response format + let response: Vec = members + .into_iter() + .map(|m| UnreadsSpaceMember { + user_did: m.user_did, + joined_at: m.joined_at.to_string(), + }) + .collect(); + + anyhow::Ok(UnreadsSpaceMembersResp { members: response }) + } + .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/space_members")) + .await; + + ack.send(&response(result)) + .log_error("Internal error sending response") + .ok(); + }, + ); + + let span_ = span.clone(); + let did_ = did.clone(); + socket.on( + "unreads/reset_all", + async move |TryData::(bytes), ack: AckSender| { + let result = async { + let Some(did_) = did_ else { + anyhow::bail!("Only authenticated users can reset unreads"); + }; + + let UnreadsResetAllArgs { stream_did } = dasl::drisl::from_slice(&bytes?[..])?; + + // Get the stream directory + let data_dir = STORAGE.data_dir()?; + let stream_dir = data_dir.join("streams").join(stream_did.as_str()); + + // Initialize the unreads database for this stream + let unreads_db = UnreadsDB::initialize(&stream_dir).await?; + + // Verify the user is a member of this space + if !unreads_db.is_member(&did_).await? { + anyhow::bail!("User {did_} is not a member of space {stream_did}"); + } + + // Reset all unreads for the user + unreads_db.reset_user_unreads(&did_).await?; + + anyhow::Ok(UnreadsResetAllResp { success: true }) + } + .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/reset_all")) + .await; + + ack.send(&response(result)) + .log_error("Internal error sending response") + .ok(); + }, + ); } #[derive(Deserialize)] @@ -631,3 +827,72 @@ struct StreamUnsubscribeArgs { struct StreamUnsubscribeResp { was_subscribed: bool, } + +// ============================================================================ +// Unreads tracking types +// ============================================================================ + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnreadsGetArgs { + stream_did: Did, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UnreadsGetItem { + room_id: String, + unread_count: u32, + mention_count: u32, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UnreadsGetResp { + unreads: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnreadsMarkReadArgs { + stream_did: Did, + room_id: Option, + last_read_idx: Option, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UnreadsMarkReadResp { + success: bool, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnreadsSpaceMembersArgs { + stream_did: Did, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UnreadsSpaceMember { + user_did: String, + joined_at: String, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UnreadsSpaceMembersResp { + members: Vec, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UnreadsResetAllArgs { + stream_did: Did, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct UnreadsResetAllResp { + success: bool, +} diff --git a/leaf-server/src/main.rs b/leaf-server/src/main.rs index baaa443..f969e5a 100644 --- a/leaf-server/src/main.rs +++ b/leaf-server/src/main.rs @@ -17,6 +17,8 @@ mod http; mod otel; mod storage; mod streams; +mod unreads; +mod unreads_materializer; #[derive(Default)] struct ExitSignal(Arc); @@ -88,6 +90,17 @@ async fn start_server(server_args: &'static ServerArgs) -> anyhow::Result<()> { ) .await?; + // Start periodic cleanup task for orphaned monitoring tasks + tokio::spawn(async { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(300)); // Every 5 minutes + loop { + interval.tick().await; + if let Err(e) = crate::streams::STREAMS.cleanup_monitoring().await { + tracing::error!("Error during monitoring cleanup: {e}"); + } + } + }); + // Start the web API http::start_api(server_args).await?; @@ -101,4 +114,10 @@ async fn start_server(server_args: &'static ServerArgs) -> anyhow::Result<()> { async fn wait_for_shutdown() { EXIT_SIGNAL.wait_for_exit_signal().await; let _span = tracing::info_span!("server shutdown").entered(); + + // Stop all unreads monitoring + tracing::info!("Stopping unreads materializer"); + crate::unreads_materializer::UNREADS_MATERIALIZER + .stop_all() + .await; } diff --git a/leaf-server/src/streams.rs b/leaf-server/src/streams.rs index 3ea442f..bced693 100644 --- a/leaf-server/src/streams.rs +++ b/leaf-server/src/streams.rs @@ -77,6 +77,14 @@ impl Streams { .await .insert(id.clone(), handle.clone()); + // Start monitoring for unreads tracking + if let Err(e) = crate::unreads_materializer::UNREADS_MATERIALIZER + .start_monitoring(id.clone(), handle.clone()) + .await + { + tracing::warn!("Failed to start unreads monitoring for stream {id}: {e}"); + } + // Return the stream handle Ok(handle) } @@ -94,6 +102,49 @@ impl Streams { Ok(()) } + + /// Stop monitoring a stream for unreads tracking. + /// This is called when a stream is dropped from the cache. + #[tracing::instrument(skip(self))] + pub async fn stop_monitoring(&self, id: &Did) -> anyhow::Result<()> { + // Stop monitoring via the materializer + if let Err(e) = crate::unreads_materializer::UNREADS_MATERIALIZER + .stop_monitoring(id) + .await + { + tracing::warn!("Failed to stop unreads monitoring for stream {id}: {e}"); + } + + Ok(()) + } + + /// Cleanup monitoring tasks for streams that are no longer in the cache. + /// This should be called periodically to prevent memory leaks from orphaned monitoring tasks. + #[tracing::instrument(skip(self))] + pub async fn cleanup_monitoring(&self) -> anyhow::Result<()> { + // Get all stream DIDs currently in the cache + let cached_streams: Vec = { + let streams = self.streams.read().await; + streams.keys().cloned().collect() + }; + + // Get all stream DIDs currently being monitored + let monitored_streams = crate::unreads_materializer::UNREADS_MATERIALIZER + .get_monitored_streams() + .await; + + // Stop monitoring for streams that are not in the cache + for stream_did in monitored_streams { + if !cached_streams.contains(&stream_did) { + tracing::info!("Cleaning up orphaned monitoring task for stream {stream_did}"); + if let Err(e) = self.stop_monitoring(&stream_did).await { + tracing::warn!("Failed to stop monitoring for stream {stream_did}: {e}"); + } + } + } + + Ok(()) + } } pub async fn load_module( diff --git a/leaf-server/src/unreads.rs b/leaf-server/src/unreads.rs new file mode 100644 index 0000000..20b3dcc --- /dev/null +++ b/leaf-server/src/unreads.rs @@ -0,0 +1,356 @@ +//! Unreads tracking database module. +//! +//! This module provides database infrastructure for tracking unread message counts +//! per user per room and space membership on a per-stream basis. + +use std::path::Path; + +use leaf_utils::convert::{ParseRow, ParseRows}; +use libsql::Connection; +use tracing::instrument; + +/// Global SQLite PRAGMA settings for WAL mode and performance +pub static GLOBAL_SQLITE_PRAGMA: &str = "pragma synchronous = normal; pragma journal_mode = wal;"; + +/// Unreads database manager for a single stream +/// +/// Manages the unreads tracking database stored at `{data_dir}/streams/{stream_did}/unreads.db`. +pub struct UnreadsDB { + /// Database connection + db: Connection, +} + +impl UnreadsDB { + /// Initialize the unreads database for a stream + /// + /// Opens the database file at `{stream_dir}/unreads.db`, applies WAL mode settings, + /// and runs the schema migrations. + #[instrument(err)] + pub async fn initialize(stream_dir: &Path) -> anyhow::Result { + // Create the stream directory if it doesn't exist + tokio::fs::create_dir_all(stream_dir).await?; + + // Open the database file + let database = libsql::Builder::new_local(stream_dir.join("unreads.db")) + .build() + .await?; + let c = database.connect()?; + c.execute_batch(GLOBAL_SQLITE_PRAGMA).await?; + tracing::info!( + "Unreads database connected at {}", + stream_dir.join("unreads.db").display() + ); + + // Run migrations + run_database_migrations(&c).await?; + + Ok(UnreadsDB { db: c }) + } + + /// Get the database connection + fn db(&self) -> &Connection { + &self.db + } + + // ============================================================================ + // space_members table operations + // ============================================================================ + + /// Add a member to the space + #[instrument(skip(self), err)] + pub async fn add_member(&self, user_did: &str, event_idx: i64) -> anyhow::Result<()> { + self.db() + .execute( + "insert into space_members (user_did, joined_at, event_idx) values (?, unixepoch(), ?)", + (user_did, event_idx), + ) + .await?; + Ok(()) + } + + /// Remove a member from the space + #[instrument(skip(self), err)] + pub async fn remove_member(&self, user_did: &str, event_idx: i64) -> anyhow::Result<()> { + self.db() + .execute( + "update space_members set left_at = unixepoch(), event_idx = ? where user_did = ? and left_at is null", + (event_idx, user_did), + ) + .await?; + Ok(()) + } + + /// Get all active members of the space + #[instrument(skip(self), err)] + pub async fn get_space_members(&self) -> anyhow::Result> { + let rows: Vec<(String, i64)> = self + .db() + .query( + "select user_did, joined_at from space_members where left_at is null order by joined_at asc", + (), + ) + .await? + .parse_rows() + .await?; + + Ok(rows + .into_iter() + .map(|(user_did, joined_at)| SpaceMember { + user_did, + joined_at, + }) + .collect()) + } + + /// Check if a user is an active member of the space + #[instrument(skip(self), err)] + pub async fn is_member(&self, user_did: &str) -> anyhow::Result { + let mut rows = self + .db() + .query( + "select 1 from space_members where user_did = ? and left_at is null", + [user_did], + ) + .await?; + Ok(rows.next().await?.is_some()) + } + + // ============================================================================ + // room_unreads table operations + // ============================================================================ + + /// Get unreads for a user across all rooms + #[instrument(skip(self), err)] + pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result> { + let rows: Vec<(String, i64, i64, Option, i64)> = self + .db() + .query( + "select room_id, unread_count, mention_count, last_event_idx, updated_at from room_unreads where user_did = ? order by updated_at desc", + [user_did], + ) + .await? + .parse_rows() + .await?; + + Ok(rows + .into_iter() + .map( + |(room_id, unread_count, mention_count, last_event_idx, updated_at)| RoomUnread { + room_id, + unread_count, + mention_count, + last_event_idx, + updated_at, + }, + ) + .collect()) + } + + /// Get unreads for a user in a specific room + #[instrument(skip(self), err)] + pub async fn get_user_unreads_for_room( + &self, + user_did: &str, + room_id: &str, + ) -> anyhow::Result> { + let mut rows = self + .db() + .query( + "select room_id, unread_count, mention_count, last_event_idx, updated_at from room_unreads where user_did = ? and room_id = ?", + (user_did, room_id), + ) + .await?; + + if let Some(row) = rows.next().await? { + let (room_id, unread_count, mention_count, last_event_idx, updated_at): ( + String, + i64, + i64, + Option, + i64, + ) = row.parse_row().await?; + return Ok(Some(RoomUnread { + room_id, + unread_count, + mention_count, + last_event_idx, + updated_at, + })); + } + + Ok(None) + } + + /// Increment unread counts for multiple users + #[instrument(skip(self, increments), err)] + pub async fn increment_unreads(&self, increments: Vec) -> anyhow::Result<()> { + let db = self.db(); + let trans = db.transaction().await?; + + for inc in increments { + trans + .execute( + "insert into room_unreads (room_id, user_did, unread_count, mention_count, last_event_idx, updated_at) + values (?, ?, ?, ?, ?, unixepoch()) + on conflict (room_id, user_did) do update set + unread_count = unread_count + ?, + mention_count = mention_count + ?, + last_event_idx = ?, + updated_at = unixepoch()", + ( + inc.room_id.as_str(), + inc.user_did.as_str(), + inc.unread_delta, + inc.mention_delta, + inc.event_idx, + inc.unread_delta, + inc.mention_delta, + inc.event_idx, + ), + ) + .await?; + } + + trans.commit().await?; + Ok(()) + } + + /// Mark items as read for a user in a specific room + #[instrument(skip(self), err)] + pub async fn mark_as_read( + &self, + user_did: &str, + room_id: &str, + last_read_idx: i64, + ) -> anyhow::Result<()> { + // First check if user is a member + if !self.is_member(user_did).await? { + anyhow::bail!("User {user_did} is not a member of this space"); + } + + self.db() + .execute( + "update room_unreads set unread_count = 0, mention_count = 0, last_event_idx = max(last_event_idx, ?), updated_at = unixepoch() where user_did = ? and room_id = ?", + (last_read_idx, user_did, room_id), + ) + .await?; + Ok(()) + } + + /// Reset all unread counts for a user + #[instrument(skip(self), err)] + pub async fn reset_user_unreads(&self, user_did: &str) -> anyhow::Result<()> { + self.db() + .execute( + "update room_unreads set unread_count = 0, mention_count = 0, updated_at = unixepoch() where user_did = ?", + [user_did], + ) + .await?; + Ok(()) + } + + // ============================================================================ + // materialization_state table operations + // ============================================================================ + + /// Get the materialization state + #[instrument(skip(self), err)] + pub async fn get_materialization_state(&self) -> anyhow::Result { + let mut rows = self + .db() + .query( + "select last_event_idx, last_materialized_at from materialization_state", + (), + ) + .await?; + + if let Some(row) = rows.next().await? { + let (last_event_idx, last_materialized_at): (i64, i64) = row.parse_row().await?; + return Ok(MaterializationState { + last_event_idx, + last_materialized_at, + }); + } + + // Return default state if not found + Ok(MaterializationState { + last_event_idx: 0, + last_materialized_at: 0, + }) + } + + /// Update the materialization state + #[instrument(skip(self), err)] + pub async fn update_materialization_state(&self, last_event_idx: i64) -> anyhow::Result<()> { + self.db() + .execute( + "insert into materialization_state (last_event_idx, last_materialized_at) values (?, unixepoch()) + on conflict do update set + last_event_idx = ?, + last_materialized_at = unixepoch()", + (last_event_idx, last_event_idx), + ) + .await?; + Ok(()) + } +} + +/// Run database migrations +#[instrument(skip(db))] +async fn run_database_migrations(db: &Connection) -> anyhow::Result<()> { + db.execute_transactional_batch(include_str!("unreads_schema.sql")) + .await?; + Ok(()) +} + +// ============================================================================ +// Data types +// ============================================================================ + +/// Represents a space member +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct SpaceMember { + /// The DID of the user + pub user_did: String, + /// When the user joined the space (unix timestamp) + pub joined_at: i64, +} + +/// Represents unread counts for a room +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct RoomUnread { + /// The room ID + pub room_id: String, + /// Count of unread messages + pub unread_count: i64, + /// Count of mentions + pub mention_count: i64, + /// The last event index that was processed + pub last_event_idx: Option, + /// Timestamp of last update + pub updated_at: i64, +} + +/// Increment operation for unreads +#[derive(Debug, Clone)] +pub struct UnreadIncrement { + /// The DID of the user + pub user_did: String, + /// The room ID + pub room_id: String, + /// Delta for unread count + pub unread_delta: i64, + /// Delta for mention count + pub mention_delta: i64, + /// The event index + pub event_idx: i64, +} + +/// Represents the materialization state for the stream +#[derive(Debug, Clone)] +pub struct MaterializationState { + /// The last event index that was materialized + pub last_event_idx: i64, + /// Timestamp of last successful materialization + pub last_materialized_at: i64, +} diff --git a/leaf-server/src/unreads_materializer.rs b/leaf-server/src/unreads_materializer.rs new file mode 100644 index 0000000..3b44514 --- /dev/null +++ b/leaf-server/src/unreads_materializer.rs @@ -0,0 +1,417 @@ +//! Unreads materializer module. +//! +//! This module provides the materialization system that processes events from all streams +//! to track unread message counts per user per room and space membership. + +use std::{collections::HashMap, sync::Arc}; + +use async_channel::Sender; +use atproto_plc::Did; +use dasl::drisl::Value; +use leaf_stream::{ + Stream, drisl_extract::DrislExtractExprSegment, drisl_extract::extract_from_drisl_with_expr, +}; +use tokio::sync::{RwLock, Semaphore}; +use tokio::task::JoinHandle; +use tracing::{debug, error, info, instrument, warn}; + +use crate::unreads::UnreadsDB; + +use std::sync::LazyLock; + +/// Global unreads materializer instance. +pub static UNREADS_MATERIALIZER: LazyLock = + LazyLock::new(UnreadsMaterializer::default); + +/// Unreads materializer that manages per-stream materialization. +pub struct UnreadsMaterializer { + /// Active stream monitors keyed by stream DID + monitors: Arc>>, + /// Semaphore to limit concurrent materialization tasks + semaphore: Arc, +} + +impl Default for UnreadsMaterializer { + fn default() -> Self { + Self { + monitors: Arc::new(RwLock::new(HashMap::new())), + semaphore: Arc::new(Semaphore::new(100)), // Limit to 100 concurrent tasks + } + } +} + +/// Handle to an active stream monitor. +struct StreamMonitorHandle { + /// The stream DID + stream_did: Did, + /// The stream handle + stream: Arc, + /// Join handle for the monitor task + task_handle: JoinHandle<()>, + /// Sender to signal the monitor to stop + stop_tx: Sender<()>, +} + +impl UnreadsMaterializer { + /// Start monitoring a stream for unread tracking. + #[instrument(skip(self, stream))] + pub async fn start_monitoring( + &self, + stream_did: Did, + stream: Arc, + ) -> anyhow::Result<()> { + let mut monitors: tokio::sync::RwLockWriteGuard<'_, HashMap> = + self.monitors.write().await; + + // Check if already monitoring this stream + if monitors.contains_key(&stream_did) { + debug!("Stream {stream_did} is already being monitored"); + return Ok(()); + } + + // Get the data directory for this stream + let data_dir = crate::storage::STORAGE.data_dir()?; + let stream_dir = data_dir.join("streams").join(stream_did.as_str()); + + // Initialize the unreads database for this stream + let unreads_db = UnreadsDB::initialize(&stream_dir).await?; + + // Create event subscription + let event_rx = stream.subscribe_events_stream().await; + + // Create stop channel + let (stop_tx, stop_rx) = async_channel::bounded(1); + + // Spawn monitor task + let stream_did_for_monitor = stream_did.clone(); + let stream_clone = stream.clone(); + let stream_did_for_log = stream_did.clone(); + let stream_did_for_error = stream_did.clone(); + let semaphore = self.semaphore.clone(); + let task_handle = tokio::spawn(async move { + let result = run_stream_monitor( + stream_did_for_monitor, + stream_clone, + unreads_db, + event_rx, + stop_rx, + semaphore, + ) + .await; + + if let Err(e) = result { + error!("Stream monitor for stream {stream_did_for_error} failed: {e}"); + } + }); + + // Store monitor handle + monitors.insert( + stream_did.clone(), + StreamMonitorHandle { + stream_did, + stream, + task_handle, + stop_tx, + }, + ); + + info!("Started monitoring stream {stream_did_for_log} for unread tracking"); + Ok(()) + } + + /// Stop monitoring a stream. + #[instrument(skip(self))] + pub async fn stop_monitoring(&self, stream_did: &Did) -> anyhow::Result<()> { + let mut monitors: tokio::sync::RwLockWriteGuard<'_, HashMap> = + self.monitors.write().await; + + if let Some(handle) = monitors.remove(stream_did) { + // Send stop signal + let _ = handle.stop_tx.send(()).await; + + // Wait for task to finish (with timeout) + let _ = + tokio::time::timeout(tokio::time::Duration::from_secs(5), handle.task_handle).await; + + info!("Stopped monitoring stream {stream_did}"); + } else { + debug!("Stream {stream_did} was not being monitored"); + } + + Ok(()) + } + + /// Get all stream DIDs currently being monitored. + #[instrument(skip(self))] + pub async fn get_monitored_streams(&self) -> Vec { + let monitors = self.monitors.read().await; + monitors.keys().cloned().collect() + } + + /// Stop monitoring all streams. + #[instrument(skip(self))] + pub async fn stop_all(&self) { + let monitors: tokio::sync::RwLockWriteGuard<'_, HashMap> = + self.monitors.write().await; + let stream_dids: Vec = monitors.keys().cloned().collect(); + drop(monitors); + + for stream_did in stream_dids { + if let Err(e) = self.stop_monitoring(&stream_did).await { + error!("Error stopping monitor for {stream_did}: {e}"); + } + } + } +} + +/// Run the stream monitor task. +#[instrument(skip(_stream, unreads_db, event_rx, stop_rx, semaphore))] +async fn run_stream_monitor( + stream_did: Did, + _stream: Arc, + unreads_db: UnreadsDB, + event_rx: async_channel::Receiver, + stop_rx: async_channel::Receiver<()>, + semaphore: Arc, +) -> anyhow::Result<()> { + // Get the last processed event index + let state = unreads_db.get_materialization_state().await?; + let mut last_processed_idx = state.last_event_idx; + + info!("Starting materialization for stream {stream_did} from event index {last_processed_idx}"); + + // Process events until we receive a stop signal + loop { + tokio::select! { + // Check for stop signal + _ = stop_rx.recv() => { + info!("Received stop signal for stream {stream_did}"); + break; + } + + // Process next event + event_result = event_rx.recv() => { + let event = match event_result { + Ok(event) => event, + Err(_) => { + // Channel closed, exit loop + debug!("Event channel closed for stream {stream_did}"); + break; + } + }; + + // Skip events we've already processed + if event.idx <= last_processed_idx { + continue; + } + + // Acquire semaphore permit to limit concurrent processing + let _permit = semaphore.acquire().await; + + // Process the event + if let Err(e) = process_event(&stream_did, &event, &unreads_db).await { + error!("Error processing event {} for stream {stream_did}: {e}", event.idx); + // Continue processing other events even if one fails + continue; + } + + // Update last processed index + last_processed_idx = event.idx; + + // Update materialization state + if let Err(e) = unreads_db.update_materialization_state(last_processed_idx).await { + error!("Error updating materialization state for stream {stream_did}: {e}"); + } + } + } + } + + info!("Materialization stopped for stream {stream_did}"); + Ok(()) +} + +/// Process a single event. +#[instrument(skip(event, unreads_db))] +async fn process_event( + stream_did: &Did, + event: &leaf_stream_types::Event, + unreads_db: &UnreadsDB, +) -> anyhow::Result<()> { + // Parse DRISL payload + let payload = match dasl::drisl::from_slice::(&event.payload) { + Ok(value) => value, + Err(e) => { + warn!( + "Failed to parse DRISL payload for event {} in stream {stream_did}: {e}", + event.idx + ); + // Return Ok to skip this event without stopping the monitor + return Ok(()); + } + }; + + // Extract room ID from payload + let room_id = extract_room_id(&payload); + + // Extract event type (discriminant) + let event_type = extract_event_type(&payload); + + debug!( + "Processing event {} in stream {stream_did}: room_id={:?}, event_type={:?}", + event.idx, room_id, event_type + ); + + // Handle different event types + match event_type.as_deref() { + Some("JoinSpace") | Some("joinSpace") | Some("town.muni.event.JoinSpace") => { + handle_join_space(event, &unreads_db).await?; + } + Some("LeaveSpace") | Some("leaveSpace") | Some("town.muni.event.LeaveSpace") => { + handle_leave_space(event, &unreads_db).await?; + } + _ => { + // For other events with a room ID, increment unreads for all members except sender + if let Some(room_id) = room_id { + handle_regular_event(event, &room_id, &unreads_db).await?; + } + } + } + + Ok(()) +} + +/// Extract room ID from a DRISL payload. +fn extract_room_id(payload: &Value) -> Option { + // Try various paths where roomId might be located + // Note: We can't use const arrays with String::from() in const context, + // so we build the paths dynamically + let paths: Vec> = vec![ + vec![DrislExtractExprSegment::FieldAccess("roomId".to_string())], + vec![DrislExtractExprSegment::FieldAccess("room_id".to_string())], + vec![ + DrislExtractExprSegment::FieldAccess("message".to_string()), + DrislExtractExprSegment::FieldAccess("roomId".to_string()), + ], + vec![ + DrislExtractExprSegment::FieldAccess("message".to_string()), + DrislExtractExprSegment::FieldAccess("room_id".to_string()), + ], + vec![ + DrislExtractExprSegment::FieldAccess("post".to_string()), + DrislExtractExprSegment::FieldAccess("roomId".to_string()), + ], + vec![ + DrislExtractExprSegment::FieldAccess("post".to_string()), + DrislExtractExprSegment::FieldAccess("room_id".to_string()), + ], + ]; + + for path in &paths { + if let Some(Value::Text(room_id)) = extract_from_drisl_with_expr(payload.clone(), path) { + return Some(room_id); + } + } + + None +} + +/// Extract event type (discriminant) from a DRISL payload. +fn extract_event_type(payload: &Value) -> Option { + match payload { + Value::Map(map) => { + // If the map has only one key, it's likely a tagged union discriminant + if map.len() == 1 { + return Some(map.keys().next().unwrap().clone()); + } + // Try to extract from a $type field + if let Some(Value::Text(type_str)) = map.get("$type") { + return Some(type_str.clone()); + } + None + } + Value::Text(text) => Some(text.clone()), + _ => None, + } +} + +/// Handle a JoinSpace event. +#[instrument(skip(event, unreads_db))] +async fn handle_join_space( + event: &leaf_stream_types::Event, + unreads_db: &UnreadsDB, +) -> anyhow::Result<()> { + // Add the user as a member of the space + unreads_db.add_member(&event.user, event.idx).await?; + + debug!( + "Added member {} to space at event index {}", + event.user, event.idx + ); + + Ok(()) +} + +/// Handle a LeaveSpace event. +#[instrument(skip(event, unreads_db))] +async fn handle_leave_space( + event: &leaf_stream_types::Event, + unreads_db: &UnreadsDB, +) -> anyhow::Result<()> { + // Remove the user from the space + unreads_db.remove_member(&event.user, event.idx).await?; + + // Clean up unread records for this user + unreads_db.reset_user_unreads(&event.user).await?; + + debug!( + "Removed member {} from space at event index {} and cleaned up unreads", + event.user, event.idx + ); + + Ok(()) +} + +/// Handle a regular event (not JoinSpace/LeaveSpace) with a room ID. +#[instrument(skip(event, unreads_db))] +async fn handle_regular_event( + event: &leaf_stream_types::Event, + room_id: &str, + unreads_db: &UnreadsDB, +) -> anyhow::Result<()> { + // Get all active members of the space + let members = unreads_db.get_space_members().await?; + + // Filter out the sender + let other_members: Vec<_> = members + .into_iter() + .filter(|member| member.user_did != event.user) + .collect(); + + if other_members.is_empty() { + debug!("No other members to notify for room {room_id}"); + return Ok(()); + } + + // Create increment operations for all other members + let increments: Vec = other_members + .iter() + .map(|member| crate::unreads::UnreadIncrement { + user_did: member.user_did.clone(), + room_id: room_id.to_string(), + unread_delta: 1, + mention_delta: 0, // TODO: Extract mentions from payload + event_idx: event.idx, + }) + .collect(); + + // Increment unreads for all members + unreads_db.increment_unreads(increments).await?; + + debug!( + "Incremented unreads for {} members in room {room_id} at event index {}", + other_members.len(), + event.idx + ); + + Ok(()) +} diff --git a/leaf-server/src/unreads_schema.sql b/leaf-server/src/unreads_schema.sql new file mode 100644 index 0000000..7874d18 --- /dev/null +++ b/leaf-server/src/unreads_schema.sql @@ -0,0 +1,73 @@ +-- ============================================================================ +-- UNREADS DATABASE SCHEMA +-- Location: {data_dir}/streams/{stream_did}/unreads.db +-- Purpose: Track unread counts per user per room and space membership for a single stream +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- Table: space_members +-- Purpose: Track which users are members of this space (stream) +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS space_members ( + -- The DID of the user who is a member + user_did TEXT NOT NULL PRIMARY KEY, + -- When the user joined the space + joined_at INTEGER NOT NULL DEFAULT (unixepoch()), + -- When the user left the space (NULL if still a member) + left_at INTEGER, + -- The event index that caused this membership change + event_idx INTEGER, + + CHECK (left_at IS NULL OR left_at >= joined_at) +) STRICT; + +-- Index for querying active members +CREATE INDEX IF NOT EXISTS idx_space_members_active + ON space_members(user_did) + WHERE left_at IS NULL; + +-- ---------------------------------------------------------------------------- +-- Table: room_unreads +-- Purpose: Track unread counts per user per room within this space (stream) +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS room_unreads ( + -- The room ID (extracted from event payloads) + room_id TEXT NOT NULL, + -- The DID of the user who has unreads + user_did TEXT NOT NULL, + -- Count of unread messages + unread_count INTEGER NOT NULL DEFAULT 0, + -- Count of mentions (messages where user was @mentioned) + mention_count INTEGER NOT NULL DEFAULT 0, + -- The last event index that was processed for this room + last_event_idx INTEGER, + -- Timestamp of last update + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + + PRIMARY KEY (room_id, user_did), + FOREIGN KEY (user_did) + REFERENCES space_members(user_did) + ON DELETE CASCADE, + CHECK (unread_count >= 0), + CHECK (mention_count >= 0) +) STRICT; + +-- Index for querying unreads for a user across all rooms +CREATE INDEX IF NOT EXISTS idx_room_unreads_user + ON room_unreads(user_did, unread_count DESC, mention_count DESC); + +-- Index for querying unreads in a specific room +CREATE INDEX IF NOT EXISTS idx_room_unreads_room + ON room_unreads(room_id) + WHERE unread_count > 0 OR mention_count > 0; + +-- ---------------------------------------------------------------------------- +-- Table: materialization_state +-- Purpose: Track the materialization progress for this stream +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS materialization_state ( + -- The last event index that was materialized + last_event_idx INTEGER NOT NULL DEFAULT 0, + -- Timestamp of last successful materialization + last_materialized_at INTEGER NOT NULL DEFAULT (unixepoch()) +) STRICT; diff --git a/leaf-stream/src/lib.rs b/leaf-stream/src/lib.rs index e3e4ea6..3047294 100644 --- a/leaf-stream/src/lib.rs +++ b/leaf-stream/src/lib.rs @@ -23,7 +23,7 @@ pub type SubscriptionResultSender = pub use module::*; mod module; -mod drisl_extract; +pub mod drisl_extract; pub use atproto_plc; pub use dasl; diff --git a/plans/unread-tracking-system-design.md b/plans/unread-tracking-system-design.md new file mode 100644 index 0000000..faa0414 --- /dev/null +++ b/plans/unread-tracking-system-design.md @@ -0,0 +1,2031 @@ +# Unread Tracking System Design + +## Executive Summary + +This document provides a comprehensive design for an unread tracking system for the leaf-server. The system will track unread message counts per user per room, manage space membership, and expose functionality via socket.io endpoints. The design is focused on leaf-server, separate from the leaf-stream package. + +## Table of Contents + +1. [Architecture Overview](#architecture-overview) +2. [Database Schema](#database-schema) +3. [Materialization Module Architecture](#materialization-module-architecture) +4. [Integration Points](#integration-points) +5. [Socket.io Endpoints](#socket-io-endpoints) +6. [Data Flow](#data-flow) +7. [Error Handling](#error-handling) +8. [Performance & Scalability](#performance--scalability) +9. [Security Considerations](#security-considerations) + +--- + +## Architecture Overview + +### System Components + +```mermaid +graph TB + subgraph "Leaf Server" + HTTP[HTTP/Socket.IO Layer] + Storage[Storage Module] + Streams[Streams Module] + UnreadsDB[(Unreads DB)] + Materialization[Materialization Module] + end + + subgraph "Stream Data" + StreamDB[(Stream DB)] + ModuleDB[(Module DB)] + end + + HTTP -->|Socket.IO| Storage + HTTP -->|Socket.IO| UnreadsDB + Storage --> Streams + Streams -->|subscribe_events_stream| Materialization + Materialization -->|parse & track| UnreadsDB + Materialization --> StreamDB + Materialization --> ModuleDB + + style UnreadsDB fill:#f9f,stroke:#333,stroke-width:4px + style Materialization fill:#bbf,stroke:#333,stroke-width:4px +``` + +### Key Design Decisions + +1. **Separate Database**: The unreads tracking uses a dedicated SQLite database (`unreads.db`) separate from the main `leaf.db` and stream-specific databases. This ensures: + - Isolation of concerns + - Independent scaling potential + - Easier backup/restore operations + - No performance impact on stream operations + +2. **Event-Driven Materialization**: A dedicated materialization module subscribes to all stream events and processes them asynchronously. This: + - Doesn't block event processing + - Provides fault tolerance + - Allows for replay/catch-up scenarios + +3. **DRISL Payload Parsing**: Events contain DRISL-encoded payloads. The materializer: + - Validates DRISL format + - Extracts `roomId` field if present + - Handles parsing errors gracefully + +4. **Membership Tracking**: Space membership is tracked via JoinSpace/LeaveSpace events: + - JoinSpace creates member records + - LeaveSpace removes member records and associated unread counts + - Supports implicit membership (e.g., room creation events) + +--- + +## Database Schema + +### Unreads Database Schema (`unreads.db`) + +```sql +-- ============================================================================ +-- UNREADS DATABASE SCHEMA +-- Location: {data_dir}/unreads.db +-- Purpose: Track unread counts per user per room and space membership +-- ============================================================================ + +-- ---------------------------------------------------------------------------- +-- Table: space_members +-- Purpose: Track which users are members of which spaces +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS space_members ( + -- The DID of the space (stream) + space_did TEXT NOT NULL, + -- The DID of the user who is a member + user_did TEXT NOT NULL, + -- When the user joined the space + joined_at INTEGER NOT NULL DEFAULT (unixepoch()), + -- When the user left the space (NULL if still a member) + left_at INTEGER, + -- The event index that caused this membership change + event_idx INTEGER, + + PRIMARY KEY (space_did, user_did), + CHECK (left_at IS NULL OR left_at >= joined_at) +) STRICT; + +-- Index for querying active members of a space +CREATE INDEX IF NOT EXISTS idx_space_members_active + ON space_members(space_did) + WHERE left_at IS NULL; + +-- Index for querying spaces a user is a member of +CREATE INDEX IF NOT EXISTS idx_space_members_user + ON space_members(user_did) + WHERE left_at IS NULL; + +-- ---------------------------------------------------------------------------- +-- Table: room_unreads +-- Purpose: Track unread counts per user per room within a space +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS room_unreads ( + -- The DID of the space (stream) + space_did TEXT NOT NULL, + -- The room ID (extracted from event payloads) + room_id TEXT NOT NULL, + -- The DID of the user who has unreads + user_did TEXT NOT NULL, + -- Count of unread messages + unread_count INTEGER NOT NULL DEFAULT 0, + -- Count of mentions (messages where user was @mentioned) + mention_count INTEGER NOT NULL DEFAULT 0, + -- The last event index that was processed for this room + last_event_idx INTEGER, + -- Timestamp of last update + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + + PRIMARY KEY (space_did, room_id, user_did), + FOREIGN KEY (space_did, user_did) + REFERENCES space_members(space_did, user_did) + ON DELETE CASCADE, + CHECK (unread_count >= 0), + CHECK (mention_count >= 0) +) STRICT; + +-- Index for querying unreads for a user across all rooms +CREATE INDEX IF NOT EXISTS idx_room_unreads_user + ON room_unreads(user_did, space_did, unread_count DESC, mention_count DESC); + +-- Index for querying unreads in a specific room +CREATE INDEX IF NOT EXISTS idx_room_unreads_room + ON room_unreads(space_did, room_id) + WHERE unread_count > 0 OR mention_count > 0; + +-- Index for querying all unreads in a space +CREATE INDEX IF NOT EXISTS idx_room_unreads_space + ON room_unreads(space_did) + WHERE unread_count > 0 OR mention_count > 0; + +-- ---------------------------------------------------------------------------- +-- Table: materialization_state +-- Purpose: Track the materialization progress for each stream +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS materialization_state ( + -- The DID of the stream + stream_did TEXT NOT NULL PRIMARY KEY, + -- The last event index that was materialized + last_event_idx INTEGER NOT NULL DEFAULT 0, + -- Timestamp of last successful materialization + last_materialized_at INTEGER NOT NULL DEFAULT (unixepoch()), + -- Status of materialization + status TEXT NOT NULL DEFAULT 'active', -- 'active', 'paused', 'error' + -- Error message if status is 'error' + error_message TEXT +) STRICT; + +-- ---------------------------------------------------------------------------- +-- Table: event_processing_log +-- Purpose: Log of processed events for debugging and replay +-- ---------------------------------------------------------------------------- +CREATE TABLE IF NOT EXISTS event_processing_log ( + -- Auto-incrementing ID + id INTEGER PRIMARY KEY AUTOINCREMENT, + -- The DID of the stream + stream_did TEXT NOT NULL, + -- The event index + event_idx INTEGER NOT NULL, + -- The user who sent the event + user_did TEXT NOT NULL, + -- Extracted room_id (NULL if not found) + room_id TEXT, + -- Event type (discriminant from DRISL payload) + event_type TEXT, + -- Whether this event incremented unreads + incremented_unreads INTEGER NOT NULL DEFAULT 0, + -- Timestamp when processed + processed_at INTEGER NOT NULL DEFAULT (unixepoch()), + + UNIQUE (stream_did, event_idx) +) STRICT; + +-- Index for querying processing history +CREATE INDEX IF NOT EXISTS idx_event_processing_log_stream + ON event_processing_log(stream_did, event_idx DESC); + +-- Purge old logs (keep last 10000 per stream) +CREATE TRIGGER IF NOT EXISTS purge_old_logs +AFTER INSERT ON event_processing_log +WHEN (SELECT COUNT(*) FROM event_processing_log + WHERE stream_did = NEW.stream_did) > 10000 +BEGIN + DELETE FROM event_processing_log + WHERE id = ( + SELECT id FROM event_processing_log + WHERE stream_did = NEW.stream_did + ORDER BY id ASC + LIMIT 1 + ); +END; +``` + +### Schema Design Rationale + +#### space_members Table + +- **Purpose**: Central source of truth for space membership +- **Design Choices**: + - Composite primary key ensures one record per user per space + - `left_at` column allows historical tracking and re-join detection + - `event_idx` links membership changes to specific events + - Partial indexes on `left_at IS NULL` optimize active membership queries + +#### room_unreads Table + +- **Purpose**: Track unread counts per user per room +- **Design Choices**: + - Composite primary key ensures one record per user per room + - Foreign key cascade delete ensures cleanup when users leave spaces + - Separate `unread_count` and `mention_count` for different notification types + - `last_event_idx` enables incremental processing and replay + - Partial indexes on counts > 0 optimize unread queries + +#### materialization_state Table + +- **Purpose**: Track materialization progress per stream +- **Design Choices**: + - Single row per stream enables resumption after restart + - Status field supports pausing/resuming materialization + - Error message field aids debugging + +#### event_processing_log Table + +- **Purpose**: Debugging and audit trail +- **Design Choices**: + - Auto-incrementing ID for chronological ordering + - Unique constraint prevents duplicate processing + - Trigger-based cleanup prevents unbounded growth + - Room ID and event type extraction for analysis + +--- + +## Materialization Module Architecture + +### Module Structure + +```mermaid +graph TB + subgraph "Materialization Module" + UnreadsTracker[UnreadsTracker] + StreamMonitor[StreamMonitor] + EventProcessor[EventProcessor] + RoomExtractor[RoomExtractor] + MembershipManager[MembershipManager] + UnreadCounter[UnreadCounter] + end + + subgraph "Dependencies" + UnreadsDB[(Unreads DB)] + Stream[Stream] + end + + StreamMonitor -->|subscribe_events_stream| Stream + StreamMonitor -->|Event| EventProcessor + EventProcessor --> RoomExtractor + EventProcessor --> MembershipManager + RoomExtractor -->|roomId| UnreadCounter + MembershipManager -->|members| UnreadCounter + UnreadCounter -->|increment| UnreadsDB + UnreadCounter -->|update state| UnreadsDB +``` + +### Component Responsibilities + +#### 1. UnreadsTracker (Main Entry Point) + +**File**: `leaf-server/src/unreads/tracker.rs` + +```rust +pub struct UnreadsTracker { + db: Arc, + stream_monitors: Arc>>>, + worker_tx: async_channel::Sender, +} + +pub enum WorkerMessage { + /// A new stream has been loaded and needs monitoring + MonitorStream { stream_did: Did, stream: Arc }, + /// A stream should stop being monitored + UnmonitorStream { stream_did: Did }, + /// Shutdown all monitoring + Shutdown, +} + +impl UnreadsTracker { + /// Initialize the unreads tracker + pub async fn initialize(data_dir: &Path) -> anyhow::Result; + + /// Start monitoring a stream + pub async fn monitor_stream(&self, stream_did: Did, stream: Arc) -> anyhow::Result<()>; + + /// Stop monitoring a stream + pub async fn unmonitor_stream(&self, stream_did: Did) -> anyhow::Result<()>; + + /// Get unreads for a user + pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result>; + + /// Mark items as read + pub async fn mark_as_read( + &self, + user_did: &str, + space_did: &str, + room_id: &str, + last_read_idx: i64, + ) -> anyhow::Result<()>; + + /// Get space members + pub async fn get_space_members(&self, space_did: &str) -> anyhow::Result>; +} +``` + +**Key Responsibilities**: + +- Initialize and manage the unreads database +- Coordinate stream monitoring +- Provide high-level API for unreads operations +- Handle database migrations + +#### 2. StreamMonitor + +**File**: `leaf-server/src/unreads/stream_monitor.rs` + +```rust +pub struct StreamMonitor { + stream_did: Did, + stream: Arc, + event_rx: async_channel::Receiver, + db: Arc, + last_processed_idx: Arc, +} + +impl StreamMonitor { + /// Create a new stream monitor + pub fn new( + stream_did: Did, + stream: Arc, + db: Arc, + ) -> (Self, async_channel::Receiver); + + /// Start the monitoring loop + pub async fn run(&self) -> anyhow::Result<()>; + + /// Catch up on missed events + pub async fn catch_up(&self) -> anyhow::Result; +} +``` + +**Key Responsibilities**: + +- Subscribe to stream events via `stream.subscribe_events_stream()` +- Receive events as they arrive +- Delegate event processing to EventProcessor +- Track last processed event index +- Handle catch-up on stream load + +#### 3. EventProcessor + +**File**: `leaf-server/src/unreads/event_processor.rs` + +```rust +pub struct EventProcessor { + db: Arc, + room_extractor: RoomExtractor, + membership_manager: MembershipManager, + unread_counter: UnreadCounter, +} + +pub struct ProcessedEvent { + pub room_id: Option, + pub event_type: Option, + pub affected_members: Vec, + pub is_join_leave: bool, +} + +impl EventProcessor { + /// Process a single event + pub async fn process_event(&self, event: &Event) -> anyhow::Result; + + /// Handle JoinSpace event + async fn handle_join_space(&self, event: &Event, room_id: &str) -> anyhow::Result<()>; + + /// Handle LeaveSpace event + async fn handle_leave_space(&self, event: &Event, room_id: &str) -> anyhow::Result<()>; + + /// Handle regular message event + async fn handle_message(&self, event: &Event, room_id: &str) -> anyhow::Result<()>; +} +``` + +**Key Responsibilities**: + +- Parse DRISL payload +- Extract event type (discriminant) +- Route to appropriate handler based on event type +- Coordinate with RoomExtractor and MembershipManager +- Update processing log + +#### 4. RoomExtractor + +**File**: `leaf-server/src/unreads/room_extractor.rs` + +```rust +pub struct RoomExtractor; + +impl RoomExtractor { + /// Extract room_id from DRISL payload + pub fn extract_room_id(payload: &[u8]) -> anyhow::Result>; + + /// Extract event type (discriminant) from DRISL payload + pub fn extract_event_type(payload: &[u8]) -> anyhow::Result>; + + /// Check if event is a JoinSpace event + pub fn is_join_space(payload: &[u8]) -> bool; + + /// Check if event is a LeaveSpace event + pub fn is_leave_space(payload: &[u8]) -> bool; + + /// Extract mentions from payload + pub fn extract_mentions(payload: &[u8]) -> anyhow::Result>; +} +``` + +**Key Responsibilities**: + +- Parse DRISL-encoded payloads +- Extract `roomId` field using drisl_extract logic +- Extract event discriminant for type detection +- Handle various payload structures +- Gracefully handle parsing errors + +**Room ID Extraction Logic**: + +The system will attempt to extract `roomId` from payloads using multiple strategies: + +1. **Direct field access**: Try `payload.roomId` (case-sensitive) +2. **Nested access**: Try common nested paths like `payload.message.roomId` +3. **Discriminant-based**: For known event types, use type-specific extraction + +```rust +// Example extraction strategies +const ROOM_ID_PATHS: &[&str] = &[ + ".roomId", + ".room_id", + ".message.roomId", + ".message.room_id", + ".post.roomId", + ".post.room_id", +]; + +const JOIN_SPACE_TYPES: &[&str] = &[ + "JoinSpace", + "joinSpace", + "town.muni.event.JoinSpace", +]; + +const LEAVE_SPACE_TYPES: &[&str] = &[ + "LeaveSpace", + "leaveSpace", + "town.muni.event.LeaveSpace", +]; +``` + +#### 5. MembershipManager + +**File**: `leaf-server/src/unreads/membership_manager.rs` + +```rust +pub struct MembershipManager { + db: Arc, +} + +impl MembershipManager { + /// Add a member to a space + pub async fn add_member( + &self, + space_did: &str, + user_did: &str, + event_idx: i64, + ) -> anyhow::Result<()>; + + /// Remove a member from a space + pub async fn remove_member( + &self, + space_did: &str, + user_did: &str, + event_idx: i64, + ) -> anyhow::Result<()>; + + /// Get all active members of a space + pub async fn get_space_members(&self, space_did: &str) -> anyhow::Result>; + + /// Check if a user is a member of a space + pub async fn is_member(&self, space_did: &str, user_did: &str) -> anyhow::Result; + + /// Clean up unread records when user leaves + pub async fn cleanup_unreads_on_leave( + &self, + space_did: &str, + user_did: &str, + ) -> anyhow::Result<()>; +} +``` + +**Key Responsibilities**: + +- Manage space membership records +- Handle JoinSpace/LeaveSpace events +- Provide membership queries +- Cascade delete unread records on leave + +#### 6. UnreadCounter + +**File**: `leaf-server/src/unreads/counter.rs` + +```rust +pub struct UnreadCounter { + db: Arc, +} + +pub struct UnreadIncrement { + pub user_did: String, + pub space_did: String, + pub room_id: String, + pub unread_delta: i64, + pub mention_delta: i64, + pub event_idx: i64, +} + +impl UnreadCounter { + /// Increment unreads for multiple users + pub async fn increment_unreads( + &self, + increments: Vec, + ) -> anyhow::Result<()>; + + /// Mark items as read for a user + pub async fn mark_as_read( + &self, + user_did: &str, + space_did: &str, + room_id: &str, + last_read_idx: i64, + ) -> anyhow::Result<()>; + + /// Get unreads for a user + pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result>; + + /// Get unreads for a specific room + pub async fn get_room_unreads( + &self, + space_did: &str, + room_id: &str, + ) -> anyhow::Result>; + + /// Reset unread counts for a user + pub async fn reset_user_unreads(&self, user_did: &str) -> anyhow::Result<()>; +} +``` + +**Key Responsibilities**: + +- Increment/decrement unread counts +- Handle mention counting +- Provide unread queries +- Mark items as read +- Batch operations for performance + +### Module Initialization + +```mermaid +sequenceDiagram + participant Main as main.rs + participant Storage as Storage + participant Tracker as UnreadsTracker + participant Streams as Streams + participant Monitor as StreamMonitor + + Main->>Storage: initialize(data_dir) + Storage->>Storage: open leaf.db + Main->>Tracker: initialize(data_dir) + Tracker->>Tracker: open unreads.db + Tracker->>Tracker: run migrations + Tracker->>Tracker: start worker task + Main->>Streams: load(stream_did) + Streams->>Streams: open stream.db + Streams->>Tracker: monitor_stream(stream_did, stream) + Tracker->>Monitor: new(stream_did, stream) + Monitor->>Monitor: subscribe_events_stream() + Monitor->>Monitor: catch_up() + loop + Stream->>Monitor: Event + Monitor->>Monitor: process_event() + end +``` + +--- + +## Integration Points + +### 1. Main Server Initialization + +**File**: `leaf-server/src/main.rs` + +**Changes Required**: + +```rust +// Add new module +mod unreads; + +// In start_server function +async fn start_server(server_args: &'static ServerArgs) -> anyhow::Result<()> { + // ... existing code ... + + // Initialize storage + STORAGE.initialize(&ARGS.data_dir, s3_backup).await?; + + // Initialize unreads tracker (NEW) + unreads::UNREADS_TRACKER.initialize(&ARGS.data_dir).await?; + + // Start the web API + http::start_api(server_args).await?; + + // ... rest of code ... +} +``` + +### 2. Stream Loading Hook + +**File**: `leaf-server/src/streams.rs` + +**Changes Required**: + +```rust +// In Streams::load method +pub async fn load(&self, id: Did) -> anyhow::Result { + // ... existing code to load stream ... + + // After stream is loaded and module is provided + let handle = Arc::new(stream); + self.streams.write().await.insert(id.clone(), handle.clone()); + + // Start monitoring for unreads (NEW) + if let Err(e) = crate::unreads::UNREADS_TRACKER + .monitor_stream(id.clone(), handle.clone()) + .await + { + tracing::warn!("Failed to start unreads monitoring for stream {id}: {e}"); + } + + Ok(handle) +} +``` + +### 3. HTTP/Socket.io Integration + +**File**: `leaf-server/src/http/connection.rs` + +**Changes Required**: + +Add new socket.io handlers: + +```rust +// In setup_socket_handlers function +pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { + // ... existing handlers ... + + // NEW: Unreads query handler + let span_ = span.clone(); + let did_ = did.clone(); + socket.on( + "unreads/get", + async move |TryData::(bytes), ack: AckSender| { + let result = async { + let Some(did_) = did_ else { + anyhow::bail!("Only authenticated users can query unreads"); + }; + let args: UnreadsGetArgs = dasl::drisl::from_slice(&bytes?[..])?; + + let unreads = crate::unreads::UNREADS_TRACKER + .get_user_unreads(&did_) + .await?; + + anyhow::Ok(UnreadsGetResp { unreads }) + } + .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/get")) + .await; + + ack.send(&response(result)) + .log_error("Internal error sending response") + .ok(); + }, + ); + + // NEW: Mark as read handler + let span_ = span.clone(); + let did_ = did.clone(); + socket.on( + "unreads/mark_read", + async move |TryData::(bytes), ack: AckSender| { + let result = async { + let Some(did_) = did_ else { + anyhow::bail!("Only authenticated users can mark items as read"); + }; + let args: UnreadsMarkReadArgs = dasl::drisl::from_slice(&bytes?[..])?; + + crate::unreads::UNREADS_TRACKER + .mark_as_read(&did_, &args.space_did, &args.room_id, args.last_read_idx) + .await?; + + anyhow::Ok(()) + } + .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/mark_read")) + .await; + + ack.send(&response(result)) + .log_error("Internal error sending response") + .ok(); + }, + ); + + // NEW: Get space members handler + let span_ = span.clone(); + let did_ = did.clone(); + socket.on( + "unreads/space_members", + async move |TryData::(bytes), ack: AckSender| { + let result = async { + let Some(did_) = did_ else { + anyhow::bail!("Only authenticated users can query space members"); + }; + let args: SpaceMembersArgs = dasl::drisl::from_slice(&bytes?[..])?; + + let members = crate::unreads::UNREADS_TRACKER + .get_space_members(&args.space_did) + .await?; + + anyhow::Ok(SpaceMembersResp { members }) + } + .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/space_members")) + .await; + + ack.send(&response(result)) + .log_error("Internal error sending response") + .ok(); + }, + ); +} +``` + +### 4. Module Structure + +**New Directory Structure**: + +``` +leaf-server/src/ +├── unreads/ +│ ├── mod.rs # Module exports and UNREADS_TRACKER singleton +│ ├── tracker.rs # UnreadsTracker main implementation +│ ├── stream_monitor.rs # StreamMonitor implementation +│ ├── event_processor.rs # EventProcessor implementation +│ ├── room_extractor.rs # RoomExtractor implementation +│ ├── membership_manager.rs # MembershipManager implementation +│ ├── counter.rs # UnreadCounter implementation +│ └── schema.sql # Database schema +├── main.rs # Add mod unreads +├── streams.rs # Add monitoring hook +└── http/ + └── connection.rs # Add socket.io handlers +``` + +--- + +## Socket.io Endpoints + +### API Specification + +All endpoints use DRISL encoding for requests and responses, consistent with the existing leaf-server API. + +#### 1. Get User Unreads + +**Event**: `unreads/get` + +**Request**: + +```rust +#[derive(Serialize, Deserialize)] +pub struct UnreadsGetArgs { + // Optional: Filter to specific space + pub space_did: Option, + // Optional: Filter to specific room + pub room_id: Option, +} +``` + +**Response**: + +```rust +#[derive(Serialize, Deserialize)] +pub struct UnreadsGetResp { + pub unreads: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct RoomUnread { + pub space_did: String, + pub room_id: String, + pub unread_count: i64, + pub mention_count: i64, + pub last_event_idx: Option, + pub updated_at: i64, +} +``` + +**Behavior**: + +- Returns all unread counts for the authenticated user +- Filters by `space_did` if provided +- Filters by `room_id` if provided (requires `space_did`) +- Ordered by `updated_at DESC` (most recently updated first) + +**Example Usage**: + +```javascript +// Get all unreads +socket.emit( + "unreads/get", + drisl.encode({ + space_did: null, + room_id: null, + }), + (response) => { + if (response.ok) { + console.log("Unreads:", response.value.unreads); + } + }, +); + +// Get unreads for a specific space +socket.emit( + "unreads/get", + drisl.encode({ + space_did: "did:plc:abc123...", + room_id: null, + }), + callback, +); + +// Get unreads for a specific room +socket.emit( + "unreads/get", + drisl.encode({ + space_did: "did:plc:abc123...", + room_id: "room-456", + }), + callback, +); +``` + +#### 2. Mark Items as Read + +**Event**: `unreads/mark_read` + +**Request**: + +```rust +#[derive(Serialize, Deserialize)] +pub struct UnreadsMarkReadArgs { + pub space_did: String, + pub room_id: String, + pub last_read_idx: i64, +} +``` + +**Response**: + +```rust +pub type UnreadsMarkReadResp = (); // Empty success response +``` + +**Behavior**: + +- Sets unread count to 0 for the specified room +- Sets `last_event_idx` to track read position +- If `last_read_idx` is greater than current `last_event_idx`, updates it +- Returns error if user is not a member of the space + +**Example Usage**: + +```javascript +socket.emit( + "unreads/mark_read", + drisl.encode({ + space_did: "did:plc:abc123...", + room_id: "room-456", + last_read_idx: 12345, + }), + (response) => { + if (response.ok) { + console.log("Marked as read"); + } + }, +); +``` + +#### 3. Get Space Members + +**Event**: `unreads/space_members` + +**Request**: + +```rust +#[derive(Serialize, Deserialize)] +pub struct SpaceMembersArgs { + pub space_did: String, +} +``` + +**Response**: + +```rust +#[derive(Serialize, Deserialize)] +pub struct SpaceMembersResp { + pub members: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct SpaceMember { + pub user_did: String, + pub joined_at: i64, +} +``` + +**Behavior**: + +- Returns all active members of a space +- Only includes members where `left_at IS NULL` +- Ordered by `joined_at ASC` (oldest members first) + +**Example Usage**: + +```javascript +socket.emit( + "unreads/space_members", + drisl.encode({ + space_did: "did:plc:abc123...", + }), + (response) => { + if (response.ok) { + console.log("Members:", response.value.members); + } + }, +); +``` + +#### 4. Reset All Unreads (Optional) + +**Event**: `unreads/reset_all` + +**Request**: + +```rust +pub type UnreadsResetAllArgs = (); // Empty request +``` + +**Response**: + +```rust +pub type UnreadsResetAllResp = (); // Empty success response +``` + +**Behavior**: + +- Resets all unread counts for the authenticated user to 0 +- Useful for "mark all as read" functionality +- Returns error if user is not authenticated + +**Example Usage**: + +```javascript +socket.emit("unreads/reset_all", drisl.encode({}), (response) => { + if (response.ok) { + console.log("All unreads reset"); + } +}); +``` + +### Subscription-Based Updates (Future Enhancement) + +**Design for Real-time Updates**: + +```rust +// New endpoint for subscribing to unread updates +socket.on( + "unreads/subscribe", + async move |TryData::(bytes), ack: AckSender| { + let result = async { + let Some(did_) = did_ else { + anyhow::bail!("Only authenticated users can subscribe"); + }; + + // Create subscription channel + let subscription_id = Ulid::new(); + let (tx, rx) = async_channel::bounded(100); + + // Register subscription + UNREADS_TRACKER.register_subscription(did_, subscription_id, tx).await?; + + // Spawn task to send updates + tokio::spawn(async move { + while let Ok(update) = rx.recv().await { + if socket.connected() { + let encoded = dasl::drisl::to_vec(&UnreadUpdate { + subscription_id, + update, + }).unwrap(); + socket.emit("unreads/update", &bytes::Bytes::from_owner(encoded)).ok(); + } else { + break; + } + } + }); + + anyhow::Ok(SubscribeResp { subscription_id }) + }.await; + + ack.send(&response(result)).ok(); + }, +); +``` + +--- + +## Data Flow + +### Event Processing Flow + +```mermaid +sequenceDiagram + participant Stream as Stream + participant Monitor as StreamMonitor + participant Processor as EventProcessor + participant Extractor as RoomExtractor + participant Membership as MembershipManager + participant Counter as UnreadCounter + participant DB as Unreads DB + + Stream->>Monitor: Event(idx: 100, user: alice, payload) + Monitor->>Processor: process_event(event) + Processor->>Extractor: extract_room_id(payload) + Extractor-->>Processor: Some("room-123") + Processor->>Extractor: extract_event_type(payload) + Extractor-->>Processor: Some("Message") + Processor->>Membership: get_space_members("did:plc:...") + Membership-->>Processor: [alice, bob, charlie] + Processor->>Processor: filter out sender (alice) + Processor->>Counter: increment_unreads([ + {user: bob, room: "room-123", delta: 1}, + {user: charlie, room: "room-123", delta: 1} + ]) + Counter->>DB: UPDATE room_unreads SET unread_count = unread_count + 1 + DB-->>Counter: OK + Counter-->>Processor: OK + Processor->>DB: INSERT INTO event_processing_log + DB-->>Processor: OK + Monitor->>DB: UPDATE materialization_state SET last_event_idx = 100 +``` + +### JoinSpace Event Flow + +```mermaid +sequenceDiagram + participant Stream as Stream + participant Monitor as StreamMonitor + participant Processor as EventProcessor + participant Extractor as RoomExtractor + participant Membership as MembershipManager + participant DB as Unreads DB + + Stream->>Monitor: Event(idx: 200, user: alice, payload: JoinSpace) + Monitor->>Processor: process_event(event) + Processor->>Extractor: is_join_space(payload) + Extractor-->>Processor: true + Processor->>Extractor: extract_room_id(payload) + Extractor-->>Processor: Some("room-123") + Processor->>Membership: add_member("did:plc:...", "alice", 200) + Membership->>DB: INSERT INTO space_members + DB-->>Membership: OK + Membership-->>Processor: OK + Processor->>DB: INSERT INTO event_processing_log + DB-->>Processor: OK +``` + +### LeaveSpace Event Flow + +```mermaid +sequenceDiagram + participant Stream as Stream + participant Monitor as StreamMonitor + participant Processor as EventProcessor + participant Extractor as RoomExtractor + participant Membership as MembershipManager + participant Counter as UnreadCounter + participant DB as Unreads DB + + Stream->>Monitor: Event(idx: 300, user: alice, payload: LeaveSpace) + Monitor->>Processor: process_event(event) + Processor->>Extractor: is_leave_space(payload) + Extractor-->>Processor: true + Processor->>Extractor: extract_room_id(payload) + Extractor-->>Processor: Some("room-123") + Processor->>Membership: remove_member("did:plc:...", "alice", 300) + Membership->>DB: UPDATE space_members SET left_at = unixepoch() + DB-->>Membership: OK + Processor->>Membership: cleanup_unreads_on_leave + Membership->>Counter: reset_user_unreads("alice") + Counter->>DB: DELETE FROM room_unreads WHERE user_did = "alice" + DB-->>Counter: OK + Counter-->>Membership: OK + Membership-->>Processor: OK + Processor->>DB: INSERT INTO event_processing_log + DB-->>Processor: OK +``` + +### Query Unreads Flow + +```mermaid +sequenceDiagram + participant Client as Client + participant Socket as Socket.IO + participant Handler as Connection Handler + participant Tracker as UnreadsTracker + participant Counter as UnreadCounter + participant DB as Unreads DB + + Client->>Socket: unreads/get {space_did: "did:plc:..."} + Socket->>Handler: handle_unreads_get + Handler->>Tracker: get_user_unreads("alice") + Tracker->>Counter: get_user_unreads("alice") + Counter->>DB: SELECT * FROM room_unreads WHERE user_did = "alice" + DB-->>Counter: [{space_did, room_id, unread_count, ...}] + Counter-->>Tracker: unreads + Tracker-->>Handler: unreads + Handler->>Socket: response {ok: true, value: {unreads: [...]}} + Socket-->>Client: DRISL-encoded response +``` + +### Mark as Read Flow + +```mermaid +sequenceDiagram + participant Client as Client + participant Socket as Socket.IO + participant Handler as Connection Handler + participant Tracker as UnreadsTracker + participant Counter as UnreadCounter + participant DB as Unreads DB + + Client->>Socket: unreads/mark_read {space_did, room_id, last_read_idx} + Socket->>Handler: handle_mark_read + Handler->>Tracker: mark_as_read("alice", "did:plc:...", "room-123", 500) + Tracker->>Counter: mark_as_read + Counter->>DB: SELECT * FROM space_members WHERE user_did = "alice" + DB-->>Counter: member exists + Counter->>DB: UPDATE room_unreads SET unread_count = 0, last_event_idx = 500 + DB-->>Counter: OK + Counter-->>Tracker: OK + Tracker-->>Handler: OK + Handler->>Socket: response {ok: true} + Socket-->>Client: DRISL-encoded response +``` + +--- + +## Error Handling + +### Error Categories + +#### 1. DRISL Parsing Errors + +**Scenario**: Event payload cannot be parsed as DRISL + +**Handling Strategy**: + +```rust +impl RoomExtractor { + pub fn extract_room_id(payload: &[u8]) -> anyhow::Result> { + match dasl::drisl::from_slice::(payload) { + Ok(value) => { + // Attempt extraction + Self::extract_room_id_from_value(value) + } + Err(e) => { + tracing::warn!( + "Failed to parse DRISL payload: {e}. Payload length: {}", + payload.len() + ); + // Return None instead of error - event is skipped + Ok(None) + } + } + } +} +``` + +**Rationale**: + +- Non-critical: Unread tracking shouldn't block stream operations +- Logged for debugging +- Event is skipped but processing continues + +#### 2. Missing Room ID + +**Scenario**: Event doesn't contain a `roomId` field + +**Handling Strategy**: + +```rust +impl EventProcessor { + pub async fn process_event(&self, event: &Event) -> anyhow::Result { + let room_id = self.room_extractor.extract_room_id(&event.payload)?; + + match room_id { + Some(room_id) => { + // Process as room event + self.handle_room_event(event, &room_id).await + } + None => { + // Log and skip - not all events are room-related + tracing::debug!("Event {} has no room_id, skipping unread tracking", event.idx); + Ok(ProcessedEvent { + room_id: None, + event_type: None, + affected_members: vec![], + is_join_leave: false, + }) + } + } + } +} +``` + +**Rationale**: + +- Many events (e.g., profile updates) don't have room IDs +- Skipping is expected behavior +- No error needed + +#### 3. Database Errors + +**Scenario**: Database write fails (e.g., constraint violation, I/O error) + +**Handling Strategy**: + +```rust +impl UnreadCounter { + pub async fn increment_unreads(&self, increments: Vec) -> anyhow::Result<()> { + let tx = self.db.transaction().await?; + + for increment in &increments { + let result = tx.execute( + r#" + INSERT INTO room_unreads + (space_did, room_id, user_did, unread_count, mention_count, last_event_idx) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (space_did, room_id, user_did) + DO UPDATE SET + unread_count = unread_count + ?, + mention_count = mention_count + ?, + last_event_idx = ?, + updated_at = unixepoch() + "#, + ( + &increment.space_did, + &increment.room_id, + &increment.user_did, + increment.unread_delta, + increment.mention_delta, + increment.event_idx, + increment.unread_delta, + increment.mention_delta, + increment.event_idx, + ), + ).await; + + match result { + Ok(_) => continue, + Err(e) => { + tracing::error!( + "Failed to increment unreads for user {} in room {}: {e}", + increment.user_did, increment.room_id + ); + // Rollback and return error + tx.rollback().await?; + return Err(e.into()); + } + } + } + + tx.commit().await?; + Ok(()) + } +} +``` + +**Rationale**: + +- Use transactions for atomicity +- Rollback on any failure +- Log detailed error information +- Propagate error to caller + +#### 4. Concurrent Update Conflicts + +**Scenario**: Multiple events processed simultaneously for same user/room + +**Handling Strategy**: + +```rust +// Use SQLite's ON CONFLICT clause for atomic increments +INSERT INTO room_unreads (...) VALUES (...) +ON CONFLICT (space_did, room_id, user_did) +DO UPDATE SET + unread_count = unread_count + excluded.unread_count, + mention_count = mention_count + excluded.mention_count, + last_event_idx = max(last_event_idx, excluded.last_event_idx), + updated_at = unixepoch() +``` + +**Rationale**: + +- SQLite handles concurrent updates via WAL mode +- Atomic increment prevents race conditions +- `max()` ensures `last_event_idx` is always the highest + +#### 5. Stream Monitor Failures + +**Scenario**: Stream monitor crashes or encounters error + +**Handling Strategy**: + +```rust +impl StreamMonitor { + pub async fn run(&self) -> anyhow::Result<()> { + loop { + match self.process_next_event().await { + Ok(_) => continue, + Err(e) => { + tracing::error!("Error processing event in stream {}: {e}", self.stream_did); + + // Update status to error + self.db.execute( + "UPDATE materialization_state SET status = 'error', error_message = ? WHERE stream_did = ?", + (&e.to_string(), self.stream_did.as_str()) + ).await?; + + // Wait before retrying + tokio::time::sleep(Duration::from_secs(5)).await; + + // Attempt to continue + continue; + } + } + } + } +} +``` + +**Rationale**: + +- Log error but don't crash +- Update status in database +- Implement backoff/retry logic +- Allows manual intervention + +#### 6. Membership Inconsistencies + +**Scenario**: User receives unread increment but is not a member + +**Handling Strategy**: + +```rust +impl UnreadCounter { + pub async fn increment_unreads(&self, increments: Vec) -> anyhow::Result<()> { + for increment in &increments { + // Verify membership before incrementing + let is_member = self.db.query( + "SELECT 1 FROM space_members WHERE space_did = ? AND user_did = ? AND left_at IS NULL", + (&increment.space_did, &increment.user_did) + ).await?.next().await.is_some(); + + if !is_member { + tracing::warn!( + "User {} is not a member of space {}, skipping unread increment", + increment.user_did, increment.space_did + ); + continue; + } + + // Perform increment + // ... + } + Ok(()) + } +} +``` + +**Rationale**: + +- Defensive programming +- Log inconsistencies +- Skip invalid increments +- Could trigger membership sync in future + +### Error Recovery Mechanisms + +#### 1. Catch-Up on Stream Load + +When a stream is loaded, the materializer catches up on missed events: + +```rust +impl StreamMonitor { + pub async fn catch_up(&self) -> anyhow::Result { + // Get last processed index from database + let last_processed: Option = self.db.query( + "SELECT last_event_idx FROM materialization_state WHERE stream_did = ?", + [self.stream_did.as_str()] + ).await?.next().await?.map(|row| row.get_value(0).unwrap().as_integer().unwrap()); + + let start_idx = last_processed.unwrap_or(0) + 1; + let latest_idx = self.stream.latest_event().await; + + if start_idx > latest_idx { + return Ok(latest_idx); + } + + tracing::info!( + "Catching up stream {} from {} to {}", + self.stream_did, start_idx, latest_idx + ); + + // Fetch and process events in batches + for batch_start in (start_idx..=latest_idx).step_by(100) { + let batch_end = (batch_start + 99).min(latest_idx); + self.process_event_range(batch_start, batch_end).await?; + } + + Ok(latest_idx) + } +} +``` + +#### 2. Replay from Event Log + +If corruption is detected, replay from event log: + +```rust +impl UnreadsTracker { + pub async fn replay_stream(&self, stream_did: &str, from_idx: i64) -> anyhow::Result<()> { + tracing::warn!("Replaying stream {} from event {}", stream_did, from_idx); + + // Delete unread records for this stream after from_idx + self.db.execute( + "DELETE FROM room_unreads WHERE space_did = ? AND last_event_idx >= ?", + [stream_did, from_idx] + ).await?; + + // Reset materialization state + self.db.execute( + "UPDATE materialization_state SET last_event_idx = ? WHERE stream_did = ?", + [from_idx - 1, stream_did] + ).await?; + + // Trigger catch-up + let monitor = self.stream_monitors.read().await.get(stream_did).cloned(); + if let Some(monitor) = monitor { + monitor.catch_up().await?; + } + + Ok(()) + } +} +``` + +--- + +## Performance & Scalability + +### Performance Optimizations + +#### 1. Database-Level Optimizations + +**WAL Mode**: + +```sql +PRAGMA journal_mode = WAL; +PRAGMA synchronous = NORMAL; +PRAGMA cache_size = -64000; -- 64MB cache +PRAGMA temp_store = MEMORY; +``` + +**Rationale**: + +- WAL allows concurrent reads and writes +- Reduces I/O contention +- Better performance for high-throughput scenarios + +**Indexing Strategy**: + +```sql +-- Partial indexes reduce index size +CREATE INDEX idx_room_unreads_user_active + ON room_unreads(user_did, space_did) + WHERE unread_count > 0 OR mention_count > 0; + +-- Covering indexes for common queries +CREATE INDEX idx_space_members_covering + ON space_members(space_did, user_did, joined_at) + WHERE left_at IS NULL; +``` + +**Rationale**: + +- Partial indexes only include relevant rows +- Covering indexes avoid table lookups +- Smaller indexes = faster queries + +#### 2. Batch Processing + +**Batch Event Processing**: + +```rust +impl StreamMonitor { + pub async fn process_event_batch(&self, events: Vec) -> anyhow::Result<()> { + let mut increments = Vec::new(); + let mut membership_changes = Vec::new(); + + for event in &events { + let processed = self.processor.process_event(event).await?; + + if let Some(room_id) = processed.room_id { + for member in processed.affected_members { + increments.push(UnreadIncrement { + user_did: member, + space_did: self.stream_did.to_string(), + room_id: room_id.clone(), + unread_delta: 1, + mention_delta: 0, + event_idx: event.idx, + }); + } + } + + if processed.is_join_leave { + membership_changes.push((event.clone(), processed)); + } + } + + // Batch increment unreads + if !increments.is_empty() { + self.counter.increment_unreads(increments).await?; + } + + // Batch membership changes + for (event, processed) in membership_changes { + // Process membership changes + } + + Ok(()) + } +} +``` + +**Rationale**: + +- Reduces database round trips +- Fewer transactions +- Better throughput + +#### 3. Async Processing + +**Non-blocking Event Processing**: + +```rust +impl StreamMonitor { + pub async fn run(&self) -> Result<(), StreamError> { + let (tx, rx) = async_channel::unbounded(); + + // Spawn event receiver + let event_rx = self.stream.subscribe_events_stream().await; + tokio::spawn(async move { + while let Ok(event) = event_rx.recv().await { + tx.send(event).await.ok(); + } + }); + + // Process events in worker task + loop { + let event = rx.recv().await?; + + // Spawn processing task + let processor = self.processor.clone(); + tokio::spawn(async move { + if let Err(e) = processor.process_event(&event).await { + tracing::error!("Error processing event {}: {e}", event.idx); + } + }); + } + } +} +``` + +**Rationale**: + +- Doesn't block event reception +- Parallel processing of multiple events +- Better throughput under load + +#### 4. Connection Pooling + +**Database Connection Pool**: + +```rust +pub struct UnreadsTracker { + db_pool: Arc, // Use connection pool + // ... +} + +impl UnreadsTracker { + pub async fn initialize(data_dir: &Path) -> anyhow::Result { + let db_path = data_dir.join("unreads.db"); + let pool = sqlx::SqlitePool::connect_with( + sqlx::sqlite::SqliteConnectOptions::new() + .filename(db_path) + .create_if_missing(true) + ).await?; + + // Configure pool + let pool = pool + .max_connections(10) + .min_connections(2) + .acquire_timeout(Duration::from_secs(5)) + .idle_timeout(Duration::from_secs(600)); + + Ok(Self { db_pool: Arc::new(pool), ... }) + } +} +``` + +**Rationale**: + +- Multiple concurrent database operations +- Automatic connection management +- Better resource utilization + +### Scalability Considerations + +#### 1. Vertical Scaling + +**Current Design Supports**: + +- **Users**: Tens of thousands (limited by SQLite file size) +- **Rooms**: Hundreds of thousands per space +- **Events**: Millions per stream (with WAL and proper indexing) +- **Throughput**: Thousands of events per second (with async processing) + +**Bottlenecks**: + +- Single SQLite database file +- Single server instance +- Memory for connection pooling + +#### 2. Horizontal Scaling (Future) + +**Multi-Server Architecture**: + +```mermaid +graph TB + subgraph "Load Balancer" + LB[Load Balancer] + end + + subgraph "Leaf Server 1" + HTTP1[HTTP/Socket.IO] + Storage1[Storage] + Unreads1[Unreads DB] + end + + subgraph "Leaf Server 2" + HTTP2[HTTP/Socket.IO] + Storage2[Storage] + Unreads2[Unreads DB] + end + + subgraph "Shared Storage" + S3[S3 Bucket] + RDS[(PostgreSQL)] + end + + LB --> HTTP1 + LB --> HTTP2 + Storage1 --> S3 + Storage2 --> S3 + Unreads1 --> RDS + Unreads2 --> RDS +``` + +**Migration Path**: + +1. Replace SQLite with PostgreSQL for unreads database +2. Use connection pooling (PgBouncer) +3. Implement consistent hashing for stream-to-server assignment +4. Use pub/sub (Redis) for cross-server unread updates + +#### 3. Data Partitioning + +**By Space**: + +```sql +-- Partition table by space_did (PostgreSQL) +CREATE TABLE room_unreads ( + space_did TEXT, + room_id TEXT, + user_did TEXT, + unread_count INTEGER, + -- ... +) PARTITION BY HASH (space_did); + +CREATE TABLE room_unreads_p0 PARTITION OF room_unreads + FOR VALUES WITH (MODULUS 4, REMAINDER 0); + +CREATE TABLE room_unreads_p1 PARTITION OF room_unreads + FOR VALUES WITH (MODULUS 4, REMAINDER 1); +-- ... etc +``` + +**Rationale**: + +- Distributes data across multiple tables +- Parallel query processing +- Easier maintenance (can drop/rebuild partitions) + +#### 4. Caching Strategy + +**Redis Cache for Unreads**: + +```rust +pub struct CachedUnreadCounter { + counter: UnreadCounter, + redis: Arc, +} + +impl CachedUnreadCounter { + pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result> { + let cache_key = format!("unreads:user:{}", user_did); + + // Try cache first + if let Ok(cached) = self.redis.get(&cache_key).await { + if let Ok(unreads) = serde_json::from_str::>(&cached) { + return Ok(unreads); + } + } + + // Cache miss - query database + let unreads = self.counter.get_user_unreads(user_did).await?; + + // Cache for 5 minutes + let serialized = serde_json::to_string(&unreads)?; + self.redis.set_ex(&cache_key, &serialized, 300).await?; + + Ok(unreads) + } + + pub async fn increment_unreads(&self, increments: Vec) -> anyhow::Result<()> { + // Increment in database + self.counter.increment_unreads(increments.clone()).await?; + + // Invalidate cache for affected users + for increment in &increments { + let cache_key = format!("unreads:user:{}", increment.user_did); + self.redis.del(&cache_key).await.ok(); + } + + Ok(()) + } +} +``` + +**Rationale**: + +- Reduces database load for read-heavy workloads +- Cache invalidation on writes +- TTL-based expiration + +#### 5. Monitoring & Metrics + +**Key Metrics to Track**: + +- Events processed per second +- Average event processing latency +- Database query latency +- Active stream monitors count +- Unread query latency +- Cache hit/miss ratio (if caching implemented) + +**Example Metrics Collection**: + +```rust +use prometheus::{Counter, Histogram, IntGauge}; + +lazy_static! { + static ref EVENTS_PROCESSED: Counter = register_counter!( + "unreads_events_processed_total", + "Total number of events processed" + ).unwrap(); + + static ref EVENT_PROCESSING_LATENCY: Histogram = register_histogram!( + "unreads_event_processing_duration_seconds", + "Event processing latency" + ).unwrap(); + + static ref ACTIVE_MONITORS: IntGauge = register_int_gauge!( + "unreads_active_monitors", + "Number of active stream monitors" + ).unwrap(); +} +``` + +--- + +## Security Considerations + +### 1. Authentication & Authorization + +**Socket.io Authentication**: + +- All unreads endpoints require authentication +- Use existing JWT validation from `http.rs` +- User DID extracted from auth token + +**Authorization Checks**: + +```rust +impl UnreadCounter { + pub async fn mark_as_read( + &self, + user_did: &str, + space_did: &str, + room_id: &str, + last_read_idx: i64, + ) -> anyhow::Result<()> { + // Verify user is a member + let is_member = self.db.query( + "SELECT 1 FROM space_members WHERE space_did = ? AND user_did = ? AND left_at IS NULL", + [space_did, user_did] + ).await?.next().await.is_some(); + + if !is_member { + anyhow::bail!("User {} is not a member of space {}", user_did, space_did); + } + + // Perform update + // ... + } +} +``` + +### 2. Input Validation + +**DRISL Payload Validation**: + +- Validate DRISL format before parsing +- Limit payload size (e.g., 10MB max) +- Sanitize extracted values + +**SQL Injection Prevention**: + +- Use parameterized queries exclusively +- Never concatenate user input into SQL + +### 3. Rate Limiting + +**Per-User Rate Limits**: + +```rust +use tower::ServiceBuilder; +use tower_governor::{Governor, GovernorConfigBuilder}; + +let governor_conf = GovernorConfigBuilder::default() + .per_second(10) + .burst_size(30) + .finish() + .unwrap(); + +let app = Router::new() + .layer(Governor::new(&governor_conf, &SharedState::default())) + .route("/socket.io", get(socket_io_handler)); +``` + +**Rationale**: + +- Prevent abuse of unreads endpoints +- Protect against DoS attacks +- Fair resource allocation + +### 4. Data Privacy + +**User DID Protection**: + +- User DIDs are sensitive identifiers +- Never log full DIDs in production +- Consider hashing for analytics + +**Access Control**: + +- Users can only query their own unreads +- Space members can only query space members +- Admin endpoints require elevated permissions + +### 5. Audit Logging + +**Event Processing Log**: + +- All events logged with timestamp +- Track who sent each event +- Enable forensic analysis + +**Access Log**: + +```rust +impl UnreadsTracker { + pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result> { + tracing::info!(user = %user_did, action = "get_user_unreads"); + + let unreads = self.counter.get_user_unreads(user_did).await?; + + tracing::debug!(user = %user_did, count = unreads.len(), action = "get_user_unreads_result"); + + Ok(unreads) + } +} +``` + +--- + +## Implementation Checklist + +### Phase 1: Core Infrastructure + +- [ ] Create `unreads` module structure +- [ ] Implement database schema (`unreads/schema.sql`) +- [ ] Create `UnreadsTracker` singleton +- [ ] Implement database migrations +- [ ] Initialize tracker in `main.rs` + +### Phase 2: Materialization + +- [ ] Implement `RoomExtractor` (DRISL parsing) +- [ ] Implement `MembershipManager` +- [ ] Implement `UnreadCounter` +- [ ] Implement `EventProcessor` +- [ ] Implement `StreamMonitor` +- [ ] Add monitoring hook in `streams.rs` + +### Phase 3: Socket.io Endpoints + +- [ ] Add `unreads/get` handler +- [ ] Add `unreads/mark_read` handler +- [ ] Add `unreads/space_members` handler +- [ ] Add `unreads/reset_all` handler (optional) +- [ ] Define request/response types + +### Phase 4: Testing + +- [ ] Unit tests for each component +- [ ] Integration tests for event flow +- [ ] Load testing for performance +- [ ] Error handling tests + +### Phase 5: Monitoring & Operations + +- [ ] Add metrics collection +- [ ] Add health check endpoint +- [ ] Document operational procedures +- [ ] Create troubleshooting guide + +--- + +## Conclusion + +This design provides a comprehensive, production-ready unread tracking system for leaf-server. The system: + +- **Separates concerns** with a dedicated database and module +- **Handles events asynchronously** without blocking stream operations +- **Parses DRISL payloads** robustly with graceful error handling +- **Tracks membership** via JoinSpace/LeaveSpace events +- **Exposes functionality** via socket.io endpoints consistent with existing API +- **Scales vertically** to support thousands of users and millions of events +- **Provides hooks** for future horizontal scaling +- **Includes security** measures for authentication, authorization, and rate limiting +- **Supports monitoring** and debugging with comprehensive logging + +The design balances simplicity with extensibility, providing a solid foundation for the unread tracking feature while allowing for future enhancements like real-time subscriptions, caching layers, and horizontal scaling. From 01dea794c2b397b7344b0df161d867809373220a Mon Sep 17 00:00:00 2001 From: Zicklag Date: Sun, 1 Mar 2026 19:57:18 +0000 Subject: [PATCH 03/12] ai: simplify and optimize database schema. --- leaf-server/src/http/connection.rs | 2 - leaf-server/src/unreads.rs | 63 +++++++++--------------------- leaf-server/src/unreads_schema.sql | 31 ++++++--------- 3 files changed, 29 insertions(+), 67 deletions(-) diff --git a/leaf-server/src/http/connection.rs b/leaf-server/src/http/connection.rs index 068eef9..a414deb 100644 --- a/leaf-server/src/http/connection.rs +++ b/leaf-server/src/http/connection.rs @@ -651,7 +651,6 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { .into_iter() .map(|m| UnreadsSpaceMember { user_did: m.user_did, - joined_at: m.joined_at.to_string(), }) .collect(); @@ -876,7 +875,6 @@ struct UnreadsSpaceMembersArgs { #[serde(rename_all = "camelCase")] struct UnreadsSpaceMember { user_did: String, - joined_at: String, } #[derive(Serialize)] diff --git a/leaf-server/src/unreads.rs b/leaf-server/src/unreads.rs index 20b3dcc..a2703a9 100644 --- a/leaf-server/src/unreads.rs +++ b/leaf-server/src/unreads.rs @@ -58,11 +58,11 @@ impl UnreadsDB { /// Add a member to the space #[instrument(skip(self), err)] - pub async fn add_member(&self, user_did: &str, event_idx: i64) -> anyhow::Result<()> { + pub async fn add_member(&self, user_did: &str, _event_idx: i64) -> anyhow::Result<()> { self.db() .execute( - "insert into space_members (user_did, joined_at, event_idx) values (?, unixepoch(), ?)", - (user_did, event_idx), + "insert into space_members (user_did) values (?)", + [user_did], ) .await?; Ok(()) @@ -70,12 +70,9 @@ impl UnreadsDB { /// Remove a member from the space #[instrument(skip(self), err)] - pub async fn remove_member(&self, user_did: &str, event_idx: i64) -> anyhow::Result<()> { + pub async fn remove_member(&self, user_did: &str, _event_idx: i64) -> anyhow::Result<()> { self.db() - .execute( - "update space_members set left_at = unixepoch(), event_idx = ? where user_did = ? and left_at is null", - (event_idx, user_did), - ) + .execute("delete from space_members where user_did = ?", [user_did]) .await?; Ok(()) } @@ -83,22 +80,16 @@ impl UnreadsDB { /// Get all active members of the space #[instrument(skip(self), err)] pub async fn get_space_members(&self) -> anyhow::Result> { - let rows: Vec<(String, i64)> = self + let rows: Vec = self .db() - .query( - "select user_did, joined_at from space_members where left_at is null order by joined_at asc", - (), - ) + .query("select user_did from space_members", ()) .await? .parse_rows() .await?; Ok(rows .into_iter() - .map(|(user_did, joined_at)| SpaceMember { - user_did, - joined_at, - }) + .map(|user_did| SpaceMember { user_did }) .collect()) } @@ -107,10 +98,7 @@ impl UnreadsDB { pub async fn is_member(&self, user_did: &str) -> anyhow::Result { let mut rows = self .db() - .query( - "select 1 from space_members where user_did = ? and left_at is null", - [user_did], - ) + .query("select 1 from space_members where user_did = ?", [user_did]) .await?; Ok(rows.next().await?.is_some()) } @@ -190,16 +178,16 @@ impl UnreadsDB { for inc in increments { trans .execute( - "insert into room_unreads (room_id, user_did, unread_count, mention_count, last_event_idx, updated_at) + "insert into room_unreads (user_did, room_id, unread_count, mention_count, last_event_idx, updated_at) values (?, ?, ?, ?, ?, unixepoch()) - on conflict (room_id, user_did) do update set + on conflict (user_did, room_id) do update set unread_count = unread_count + ?, mention_count = mention_count + ?, last_event_idx = ?, updated_at = unixepoch()", ( - inc.room_id.as_str(), inc.user_did.as_str(), + inc.room_id.as_str(), inc.unread_delta, inc.mention_delta, inc.event_idx, @@ -258,25 +246,16 @@ impl UnreadsDB { pub async fn get_materialization_state(&self) -> anyhow::Result { let mut rows = self .db() - .query( - "select last_event_idx, last_materialized_at from materialization_state", - (), - ) + .query("select last_event_idx from materialization_state", ()) .await?; if let Some(row) = rows.next().await? { - let (last_event_idx, last_materialized_at): (i64, i64) = row.parse_row().await?; - return Ok(MaterializationState { - last_event_idx, - last_materialized_at, - }); + let last_event_idx: i64 = row.parse_row().await?; + return Ok(MaterializationState { last_event_idx }); } // Return default state if not found - Ok(MaterializationState { - last_event_idx: 0, - last_materialized_at: 0, - }) + Ok(MaterializationState { last_event_idx: 0 }) } /// Update the materialization state @@ -284,10 +263,8 @@ impl UnreadsDB { pub async fn update_materialization_state(&self, last_event_idx: i64) -> anyhow::Result<()> { self.db() .execute( - "insert into materialization_state (last_event_idx, last_materialized_at) values (?, unixepoch()) - on conflict do update set - last_event_idx = ?, - last_materialized_at = unixepoch()", + "insert into materialization_state (last_event_idx) values (?) + on conflict do update set last_event_idx = ?", (last_event_idx, last_event_idx), ) .await?; @@ -312,8 +289,6 @@ async fn run_database_migrations(db: &Connection) -> anyhow::Result<()> { pub struct SpaceMember { /// The DID of the user pub user_did: String, - /// When the user joined the space (unix timestamp) - pub joined_at: i64, } /// Represents unread counts for a room @@ -351,6 +326,4 @@ pub struct UnreadIncrement { pub struct MaterializationState { /// The last event index that was materialized pub last_event_idx: i64, - /// Timestamp of last successful materialization - pub last_materialized_at: i64, } diff --git a/leaf-server/src/unreads_schema.sql b/leaf-server/src/unreads_schema.sql index 7874d18..49f36c2 100644 --- a/leaf-server/src/unreads_schema.sql +++ b/leaf-server/src/unreads_schema.sql @@ -10,31 +10,18 @@ -- ---------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS space_members ( -- The DID of the user who is a member - user_did TEXT NOT NULL PRIMARY KEY, - -- When the user joined the space - joined_at INTEGER NOT NULL DEFAULT (unixepoch()), - -- When the user left the space (NULL if still a member) - left_at INTEGER, - -- The event index that caused this membership change - event_idx INTEGER, - - CHECK (left_at IS NULL OR left_at >= joined_at) + user_did TEXT NOT NULL PRIMARY KEY ) STRICT; --- Index for querying active members -CREATE INDEX IF NOT EXISTS idx_space_members_active - ON space_members(user_did) - WHERE left_at IS NULL; - -- ---------------------------------------------------------------------------- -- Table: room_unreads -- Purpose: Track unread counts per user per room within this space (stream) -- ---------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS room_unreads ( - -- The room ID (extracted from event payloads) - room_id TEXT NOT NULL, -- The DID of the user who has unreads user_did TEXT NOT NULL, + -- The room ID (extracted from event payloads) + room_id TEXT NOT NULL, -- Count of unread messages unread_count INTEGER NOT NULL DEFAULT 0, -- Count of mentions (messages where user was @mentioned) @@ -44,7 +31,7 @@ CREATE TABLE IF NOT EXISTS room_unreads ( -- Timestamp of last update updated_at INTEGER NOT NULL DEFAULT (unixepoch()), - PRIMARY KEY (room_id, user_did), + PRIMARY KEY (user_did, room_id), FOREIGN KEY (user_did) REFERENCES space_members(user_did) ON DELETE CASCADE, @@ -56,6 +43,12 @@ CREATE TABLE IF NOT EXISTS room_unreads ( CREATE INDEX IF NOT EXISTS idx_room_unreads_user ON room_unreads(user_did, unread_count DESC, mention_count DESC); +-- Index for querying unreads for a user where unread_count > 0 +-- This composite index efficiently supports queries filtering by both user_did and unread_count > 0 +CREATE INDEX IF NOT EXISTS idx_room_unreads_user_unread + ON room_unreads(user_did, unread_count DESC, room_id) + WHERE unread_count > 0; + -- Index for querying unreads in a specific room CREATE INDEX IF NOT EXISTS idx_room_unreads_room ON room_unreads(room_id) @@ -67,7 +60,5 @@ CREATE INDEX IF NOT EXISTS idx_room_unreads_room -- ---------------------------------------------------------------------------- CREATE TABLE IF NOT EXISTS materialization_state ( -- The last event index that was materialized - last_event_idx INTEGER NOT NULL DEFAULT 0, - -- Timestamp of last successful materialization - last_materialized_at INTEGER NOT NULL DEFAULT (unixepoch()) + last_event_idx INTEGER NOT NULL DEFAULT 0 ) STRICT; From c5c008e83a469fd6f0b56a33e86989faa36ad6b2 Mon Sep 17 00:00:00 2001 From: Zicklag Date: Sun, 1 Mar 2026 20:38:45 +0000 Subject: [PATCH 04/12] ai: make sure that the unreads database is cached while a stream is open. --- leaf-server/src/http/connection.rs | 63 ++++++++++++------------------ leaf-server/src/storage.rs | 7 ++-- leaf-server/src/streams.rs | 33 +++++++++++----- 3 files changed, 53 insertions(+), 50 deletions(-) diff --git a/leaf-server/src/http/connection.rs b/leaf-server/src/http/connection.rs index a414deb..5a6ecdf 100644 --- a/leaf-server/src/http/connection.rs +++ b/leaf-server/src/http/connection.rs @@ -25,7 +25,6 @@ use crate::{ error::LogError, storage::STORAGE, streams::STREAMS, - unreads::UnreadsDB, }; pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { @@ -131,7 +130,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { let StreamInfoArgs { stream_did } = dasl::drisl::from_slice(&bytes?[..])?; let open_streams = open_streams_.upgradable_read().await; - let stream = if let Some(stream) = open_streams.get(&stream_did) { + let s = if let Some(stream) = open_streams.get(&stream_did) { stream.clone() } else { let stream = STREAMS.load(stream_did.clone()).await?; @@ -141,7 +140,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { }; anyhow::Ok(StreamInfoResp { - module_cid: stream.module_cid().await, + module_cid: s.stream.module_cid().await, }) } .instrument(tracing::info_span!(parent: span_.clone(), "handle stream/info")) @@ -184,7 +183,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { anyhow::bail!("Only a stream owner can update its module"); } - STREAMS.update_module(stream, module_cid).await?; + STREAMS.update_module(stream.clone(), module_cid).await?; anyhow::Ok(()) } @@ -224,6 +223,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { let signing_key = STORAGE.get_did_signing_key(stream_did).await?; stream + .stream .add_events( signing_key, events @@ -262,7 +262,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { dasl::drisl::from_slice(&bytes?[..])?; let open_streams = open_streams_.upgradable_read().await; - let stream = if let Some(stream) = open_streams.get(&stream_did) { + let s = if let Some(stream) = open_streams.get(&stream_did) { stream.clone() } else { let stream = STREAMS.load(stream_did.clone()).await?; @@ -271,7 +271,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { stream }; - stream + s.stream .add_state_events( events .into_iter() @@ -310,7 +310,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { let StreamClearStateArgs { stream_did } = dasl::drisl::from_slice(&bytes?[..])?; let open_streams = open_streams_.upgradable_read().await; - let stream = if let Some(stream) = open_streams.get(&stream_did) { + let s = if let Some(stream) = open_streams.get(&stream_did) { stream.clone() } else { let stream = STREAMS.load(stream_did.clone()).await?; @@ -325,7 +325,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { anyhow::bail!("Only a stream owner can set its handle"); } - stream.clear_state_db().await?; + s.stream.clear_state_db().await?; anyhow::Ok(()) } @@ -359,7 +359,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { let subscription_id = Ulid::new(); let open_streams = open_streams_.upgradable_read().await; - let stream = if let Some(stream) = open_streams.get(&stream_did) { + let s = if let Some(stream) = open_streams.get(&stream_did) { stream.clone() } else { let stream = STREAMS.load(stream_did.clone()).await?; @@ -368,7 +368,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { stream }; - let receiver = stream.subscribe_events(did_.clone(), query).await; + let receiver = s.stream.subscribe_events(did_.clone(), query).await; tokio::spawn(async move { let (unsubscribe_tx, unsubscribe_rx) = oneshot::channel(); @@ -454,7 +454,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { let StreamQueryArgs { stream_did, query } = dasl::drisl::from_slice(&bytes?[..])?; let open_streams = open_streams_.upgradable_read().await; - let stream = if let Some(stream) = open_streams.get(&stream_did) { + let s = if let Some(stream) = open_streams.get(&stream_did) { stream.clone() } else { let stream = STREAMS.load(stream_did.clone()).await?; @@ -463,7 +463,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { stream }; - let response = stream.query(did_.clone(), query).await?; + let response = s.stream.query(did_.clone(), query).await?; anyhow::Ok(response) } @@ -525,12 +525,9 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { let UnreadsGetArgs { stream_did } = dasl::drisl::from_slice(&bytes?[..])?; - // Get the stream directory - let data_dir = STORAGE.data_dir()?; - let stream_dir = data_dir.join("streams").join(stream_did.as_str()); - - // Initialize the unreads database for this stream - let unreads_db = UnreadsDB::initialize(&stream_dir).await?; + // Load the stream (which includes the cached unreads_db) + let stream_with_unreads = STREAMS.load(stream_did.clone()).await?; + let unreads_db = &stream_with_unreads.unreads_db; // Verify the user is a member of this space if !unreads_db.is_member(&did_).await? { @@ -577,12 +574,9 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { last_read_idx, } = dasl::drisl::from_slice(&bytes?[..])?; - // Get the stream directory - let data_dir = STORAGE.data_dir()?; - let stream_dir = data_dir.join("streams").join(stream_did.as_str()); - - // Initialize the unreads database for this stream - let unreads_db = UnreadsDB::initialize(&stream_dir).await?; + // Load the stream (which includes the cached unreads_db) + let s = STREAMS.load(stream_did.clone()).await?; + let unreads_db = &s.unreads_db; // Verify the user is a member of this space if !unreads_db.is_member(&did_).await? { @@ -596,8 +590,7 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { Some(idx) => idx, None => { // Get the stream to fetch the latest event index - let stream = STREAMS.load(stream_did.clone()).await?; - stream.latest_event().await + s.stream.latest_event().await } }; unreads_db @@ -631,12 +624,9 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { let UnreadsSpaceMembersArgs { stream_did } = dasl::drisl::from_slice(&bytes?[..])?; - // Get the stream directory - let data_dir = STORAGE.data_dir()?; - let stream_dir = data_dir.join("streams").join(stream_did.as_str()); - - // Initialize the unreads database for this stream - let unreads_db = UnreadsDB::initialize(&stream_dir).await?; + // Load the stream (which includes the cached unreads_db) + let s = STREAMS.load(stream_did.clone()).await?; + let unreads_db = &s.unreads_db; // Verify the user is a member of this space if !unreads_db.is_member(&did_).await? { @@ -677,12 +667,9 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { let UnreadsResetAllArgs { stream_did } = dasl::drisl::from_slice(&bytes?[..])?; - // Get the stream directory - let data_dir = STORAGE.data_dir()?; - let stream_dir = data_dir.join("streams").join(stream_did.as_str()); - - // Initialize the unreads database for this stream - let unreads_db = UnreadsDB::initialize(&stream_dir).await?; + // Load the stream (which includes the cached unreads_db) + let s = STREAMS.load(stream_did.clone()).await?; + let unreads_db = &s.unreads_db; // Verify the user is a member of this space if !unreads_db.is_member(&did_).await? { diff --git a/leaf-server/src/storage.rs b/leaf-server/src/storage.rs index 9793a55..d2eab71 100644 --- a/leaf-server/src/storage.rs +++ b/leaf-server/src/storage.rs @@ -723,6 +723,7 @@ impl Storage { // Get the events that are newer than the latest backed up event. let events = s + .stream .raw_get_events((stream.backup_latest_event.map(|l| l + 1).unwrap_or(1))..) .await?; let events_len = events.len() as i64; @@ -916,7 +917,7 @@ impl Storage { ) .await?; // Create the stream - let stream = self.create_stream(stream_did.clone()).await?; + let s = self.create_stream(stream_did.clone()).await?; // Fetch the list of event archives on S3 let mut ranges_on_s3 = bucket @@ -969,7 +970,7 @@ impl Storage { let archive: EventArchive = dasl::drisl::from_slice(&decompressed)?; // Import the events from the archive - stream.raw_import_events(archive.events).await?; + s.stream.raw_import_events(archive.events).await?; } // Restore the state database if necessary @@ -989,7 +990,7 @@ impl Storage { } // Set the module for the stream and catch it up - stream.raw_set_module(metadata.module_cid).await?; + s.stream.raw_set_module(metadata.module_cid).await?; // TODO: we want to load the module and catch it up now, so it doesn't error when we try // to connect to it later. There's a bug that means modules can end up in an error state diff --git a/leaf-server/src/streams.rs b/leaf-server/src/streams.rs index bced693..d13f0c3 100644 --- a/leaf-server/src/streams.rs +++ b/leaf-server/src/streams.rs @@ -9,15 +9,22 @@ use tokio::sync::RwLock; use weak_table::WeakValueHashMap; use crate::storage::{GLOBAL_SQLITE_PRAGMA, STORAGE}; +use crate::unreads::UnreadsDB; -/// Global cache of open Leaf streams. +/// Global cache of open Leaf streams with their unreads database connections. pub static STREAMS: LazyLock = LazyLock::new(Streams::default); -pub type StreamHandle = Arc; +/// A stream handle with its associated unreads database connection. +pub struct StreamWithUnreads { + pub stream: Arc, + pub unreads_db: UnreadsDB, +} + +pub type StreamHandle = Arc; #[derive(Default)] pub struct Streams { - streams: RwLock>>, + streams: RwLock>>, } impl Streams { @@ -70,8 +77,12 @@ impl Streams { } }); + // Initialize the unreads database for this stream + let unreads_db = UnreadsDB::initialize(&stream_dir).await?; + // Store the stream handle in the cache - let handle = Arc::new(stream); + let stream = Arc::new(stream); + let handle = Arc::new(StreamWithUnreads { stream, unreads_db }); self.streams .write() .await @@ -89,15 +100,19 @@ impl Streams { Ok(handle) } - pub async fn update_module(&self, stream: Arc, module_cid: Cid) -> anyhow::Result<()> { + pub async fn update_module( + &self, + s: Arc, + module_cid: Cid, + ) -> anyhow::Result<()> { let data_dir = STORAGE.data_dir()?; - let stream_dir = data_dir.join("streams").join(stream.id().as_str()); + let stream_dir = data_dir.join("streams").join(s.stream.id().as_str()); - stream.raw_set_module(Some(module_cid)).await?; + s.stream.raw_set_module(Some(module_cid)).await?; let (module, db) = load_module(&stream_dir, module_cid).await?; - stream.provide_module(module, db).await?; + s.stream.provide_module(module, db).await?; STORAGE - .update_stream_module(stream.id().clone(), module_cid) + .update_stream_module(s.stream.id().clone(), module_cid) .await?; Ok(()) From cce0ac8d884754b660cdea3c2b349e72c6bceab0 Mon Sep 17 00:00:00 2001 From: Zicklag Date: Sun, 1 Mar 2026 20:38:45 +0000 Subject: [PATCH 05/12] ai: simplify unread materializer. --- leaf-server/src/main.rs | 18 - leaf-server/src/streams.rs | 53 +-- leaf-server/src/unreads.rs | 280 +++++++++++++++- leaf-server/src/unreads_materializer.rs | 417 ------------------------ 4 files changed, 281 insertions(+), 487 deletions(-) delete mode 100644 leaf-server/src/unreads_materializer.rs diff --git a/leaf-server/src/main.rs b/leaf-server/src/main.rs index f969e5a..1b6cafe 100644 --- a/leaf-server/src/main.rs +++ b/leaf-server/src/main.rs @@ -18,7 +18,6 @@ mod otel; mod storage; mod streams; mod unreads; -mod unreads_materializer; #[derive(Default)] struct ExitSignal(Arc); @@ -90,17 +89,6 @@ async fn start_server(server_args: &'static ServerArgs) -> anyhow::Result<()> { ) .await?; - // Start periodic cleanup task for orphaned monitoring tasks - tokio::spawn(async { - let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(300)); // Every 5 minutes - loop { - interval.tick().await; - if let Err(e) = crate::streams::STREAMS.cleanup_monitoring().await { - tracing::error!("Error during monitoring cleanup: {e}"); - } - } - }); - // Start the web API http::start_api(server_args).await?; @@ -114,10 +102,4 @@ async fn start_server(server_args: &'static ServerArgs) -> anyhow::Result<()> { async fn wait_for_shutdown() { EXIT_SIGNAL.wait_for_exit_signal().await; let _span = tracing::info_span!("server shutdown").entered(); - - // Stop all unreads monitoring - tracing::info!("Stopping unreads materializer"); - crate::unreads_materializer::UNREADS_MATERIALIZER - .stop_all() - .await; } diff --git a/leaf-server/src/streams.rs b/leaf-server/src/streams.rs index d13f0c3..aafb2bc 100644 --- a/leaf-server/src/streams.rs +++ b/leaf-server/src/streams.rs @@ -88,13 +88,9 @@ impl Streams { .await .insert(id.clone(), handle.clone()); - // Start monitoring for unreads tracking - if let Err(e) = crate::unreads_materializer::UNREADS_MATERIALIZER - .start_monitoring(id.clone(), handle.clone()) - .await - { - tracing::warn!("Failed to start unreads monitoring for stream {id}: {e}"); - } + // Spawn the unreads monitor task + // The task will automatically exit when the stream events channel closes + crate::unreads::run_unreads_monitor(handle.clone()); // Return the stream handle Ok(handle) @@ -117,49 +113,6 @@ impl Streams { Ok(()) } - - /// Stop monitoring a stream for unreads tracking. - /// This is called when a stream is dropped from the cache. - #[tracing::instrument(skip(self))] - pub async fn stop_monitoring(&self, id: &Did) -> anyhow::Result<()> { - // Stop monitoring via the materializer - if let Err(e) = crate::unreads_materializer::UNREADS_MATERIALIZER - .stop_monitoring(id) - .await - { - tracing::warn!("Failed to stop unreads monitoring for stream {id}: {e}"); - } - - Ok(()) - } - - /// Cleanup monitoring tasks for streams that are no longer in the cache. - /// This should be called periodically to prevent memory leaks from orphaned monitoring tasks. - #[tracing::instrument(skip(self))] - pub async fn cleanup_monitoring(&self) -> anyhow::Result<()> { - // Get all stream DIDs currently in the cache - let cached_streams: Vec = { - let streams = self.streams.read().await; - streams.keys().cloned().collect() - }; - - // Get all stream DIDs currently being monitored - let monitored_streams = crate::unreads_materializer::UNREADS_MATERIALIZER - .get_monitored_streams() - .await; - - // Stop monitoring for streams that are not in the cache - for stream_did in monitored_streams { - if !cached_streams.contains(&stream_did) { - tracing::info!("Cleaning up orphaned monitoring task for stream {stream_did}"); - if let Err(e) = self.stop_monitoring(&stream_did).await { - tracing::warn!("Failed to stop monitoring for stream {stream_did}: {e}"); - } - } - } - - Ok(()) - } } pub async fn load_module( diff --git a/leaf-server/src/unreads.rs b/leaf-server/src/unreads.rs index a2703a9..3a34af7 100644 --- a/leaf-server/src/unreads.rs +++ b/leaf-server/src/unreads.rs @@ -3,11 +3,17 @@ //! This module provides database infrastructure for tracking unread message counts //! per user per room and space membership on a per-stream basis. -use std::path::Path; +use std::{path::Path, sync::Arc}; +use atproto_plc::Did; +use dasl::drisl::Value; +use leaf_stream::drisl_extract::{DrislExtractExprSegment, extract_from_drisl_with_expr}; use leaf_utils::convert::{ParseRow, ParseRows}; use libsql::Connection; -use tracing::instrument; +use tokio::task::JoinHandle; +use tracing::{debug, error, info, instrument, warn}; + +use crate::streams::StreamWithUnreads; /// Global SQLite PRAGMA settings for WAL mode and performance pub static GLOBAL_SQLITE_PRAGMA: &str = "pragma synchronous = normal; pragma journal_mode = wal;"; @@ -327,3 +333,273 @@ pub struct MaterializationState { /// The last event index that was materialized pub last_event_idx: i64, } + +// ============================================================================ +// Unreads Monitor +// ============================================================================ + +/// Run the unreads monitor task for a stream. +/// +/// This function subscribes to stream events and processes them to track unread counts. +/// It automatically exits when the stream events channel is closed. +/// +/// # Arguments +/// * `stream_with_unreads` - The stream with its associated unreads database +/// +/// # Returns +/// A JoinHandle for the monitor task +#[instrument(skip(stream_with_unreads))] +pub fn run_unreads_monitor(stream_with_unreads: Arc) -> JoinHandle<()> { + tokio::spawn(async move { + let stream_did = stream_with_unreads.stream.id().clone(); + let result = monitor_stream(stream_with_unreads).await; + + if let Err(e) = result { + error!("Unreads monitor for stream {stream_did} failed: {e}"); + } + }) +} + +/// Monitor a stream and process events for unreads tracking. +#[instrument(skip(stream_with_unreads))] +async fn monitor_stream(stream_with_unreads: Arc) -> anyhow::Result<()> { + let stream_did = stream_with_unreads.stream.id().clone(); + + // Subscribe to stream events + let event_rx = stream_with_unreads.stream.subscribe_events_stream().await; + + // Get the last processed event index + let state = stream_with_unreads + .unreads_db + .get_materialization_state() + .await?; + let mut last_processed_idx = state.last_event_idx; + + info!("Starting unreads monitor for stream {stream_did} from event index {last_processed_idx}"); + + // Process events until the channel is closed + loop { + let event = match event_rx.recv().await { + Ok(event) => event, + Err(_) => { + // Channel closed, exit loop + debug!("Event channel closed for stream {stream_did}"); + break; + } + }; + + // Skip events we've already processed + if event.idx <= last_processed_idx { + continue; + } + + // Process the event + if let Err(e) = process_event(&stream_did, &event, &stream_with_unreads.unreads_db).await { + error!( + "Error processing event {} for stream {stream_did}: {e}", + event.idx + ); + // Continue processing other events even if one fails + continue; + } + + // Update last processed index + last_processed_idx = event.idx; + + // Update materialization state + if let Err(e) = stream_with_unreads + .unreads_db + .update_materialization_state(last_processed_idx) + .await + { + error!("Error updating materialization state for stream {stream_did}: {e}"); + } + } + + info!("Unreads monitor stopped for stream {stream_did}"); + Ok(()) +} + +/// Process a single event. +#[instrument(skip(event, unreads_db))] +async fn process_event( + stream_did: &Did, + event: &leaf_stream_types::Event, + unreads_db: &UnreadsDB, +) -> anyhow::Result<()> { + // Parse DRISL payload + let payload = match dasl::drisl::from_slice::(&event.payload) { + Ok(value) => value, + Err(e) => { + warn!( + "Failed to parse DRISL payload for event {} in stream {stream_did}: {e}", + event.idx + ); + // Return Ok to skip this event without stopping the monitor + return Ok(()); + } + }; + + // Extract room ID from payload + let room_id = extract_room_id(&payload); + + // Extract event type (discriminant) + let event_type = extract_event_type(&payload); + + debug!( + "Processing event {} in stream {stream_did}: room_id={:?}, event_type={:?}", + event.idx, room_id, event_type + ); + + // Handle different event types + match event_type.as_deref() { + Some("JoinSpace") | Some("joinSpace") | Some("town.muni.event.JoinSpace") => { + handle_join_space(event, unreads_db).await?; + } + Some("LeaveSpace") | Some("leaveSpace") | Some("town.muni.event.LeaveSpace") => { + handle_leave_space(event, unreads_db).await?; + } + _ => { + // For other events with a room ID, increment unreads for all members except sender + if let Some(room_id) = room_id { + handle_regular_event(event, &room_id, unreads_db).await?; + } + } + } + + Ok(()) +} + +/// Extract room ID from a DRISL payload. +fn extract_room_id(payload: &Value) -> Option { + // Try various paths where roomId might be located + let paths: Vec> = vec![ + vec![DrislExtractExprSegment::FieldAccess("roomId".to_string())], + vec![DrislExtractExprSegment::FieldAccess("room_id".to_string())], + vec![ + DrislExtractExprSegment::FieldAccess("message".to_string()), + DrislExtractExprSegment::FieldAccess("roomId".to_string()), + ], + vec![ + DrislExtractExprSegment::FieldAccess("message".to_string()), + DrislExtractExprSegment::FieldAccess("room_id".to_string()), + ], + vec![ + DrislExtractExprSegment::FieldAccess("post".to_string()), + DrislExtractExprSegment::FieldAccess("roomId".to_string()), + ], + vec![ + DrislExtractExprSegment::FieldAccess("post".to_string()), + DrislExtractExprSegment::FieldAccess("room_id".to_string()), + ], + ]; + + for path in &paths { + if let Some(Value::Text(room_id)) = extract_from_drisl_with_expr(payload.clone(), path) { + return Some(room_id); + } + } + + None +} + +/// Extract event type (discriminant) from a DRISL payload. +fn extract_event_type(payload: &Value) -> Option { + match payload { + Value::Map(map) => { + // If the map has only one key, it's likely a tagged union discriminant + if map.len() == 1 { + return Some(map.keys().next().unwrap().clone()); + } + // Try to extract from a $type field + if let Some(Value::Text(type_str)) = map.get("$type") { + return Some(type_str.clone()); + } + None + } + Value::Text(text) => Some(text.clone()), + _ => None, + } +} + +/// Handle a JoinSpace event. +#[instrument(skip(event, unreads_db))] +async fn handle_join_space( + event: &leaf_stream_types::Event, + unreads_db: &UnreadsDB, +) -> anyhow::Result<()> { + // Add the user as a member of the space + unreads_db.add_member(&event.user, event.idx).await?; + + debug!( + "Added member {} to space at event index {}", + event.user, event.idx + ); + + Ok(()) +} + +/// Handle a LeaveSpace event. +#[instrument(skip(event, unreads_db))] +async fn handle_leave_space( + event: &leaf_stream_types::Event, + unreads_db: &UnreadsDB, +) -> anyhow::Result<()> { + // Remove the user from the space + unreads_db.remove_member(&event.user, event.idx).await?; + + // Clean up unread records for this user + unreads_db.reset_user_unreads(&event.user).await?; + + debug!( + "Removed member {} from space at event index {} and cleaned up unreads", + event.user, event.idx + ); + + Ok(()) +} + +/// Handle a regular event (not JoinSpace/LeaveSpace) with a room ID. +#[instrument(skip(event, unreads_db))] +async fn handle_regular_event( + event: &leaf_stream_types::Event, + room_id: &str, + unreads_db: &UnreadsDB, +) -> anyhow::Result<()> { + // Get all active members of the space + let members = unreads_db.get_space_members().await?; + + // Filter out the sender + let other_members: Vec<_> = members + .into_iter() + .filter(|member| member.user_did != event.user) + .collect(); + + if other_members.is_empty() { + debug!("No other members to notify for room {room_id}"); + return Ok(()); + } + + // Create increment operations for all other members + let increments: Vec = other_members + .iter() + .map(|member| UnreadIncrement { + user_did: member.user_did.clone(), + room_id: room_id.to_string(), + unread_delta: 1, + mention_delta: 0, // TODO: Extract mentions from payload + event_idx: event.idx, + }) + .collect(); + + // Increment unreads for all members + unreads_db.increment_unreads(increments).await?; + + debug!( + "Incremented unreads for {} members in room {room_id} at event index {}", + other_members.len(), + event.idx + ); + + Ok(()) +} diff --git a/leaf-server/src/unreads_materializer.rs b/leaf-server/src/unreads_materializer.rs deleted file mode 100644 index 3b44514..0000000 --- a/leaf-server/src/unreads_materializer.rs +++ /dev/null @@ -1,417 +0,0 @@ -//! Unreads materializer module. -//! -//! This module provides the materialization system that processes events from all streams -//! to track unread message counts per user per room and space membership. - -use std::{collections::HashMap, sync::Arc}; - -use async_channel::Sender; -use atproto_plc::Did; -use dasl::drisl::Value; -use leaf_stream::{ - Stream, drisl_extract::DrislExtractExprSegment, drisl_extract::extract_from_drisl_with_expr, -}; -use tokio::sync::{RwLock, Semaphore}; -use tokio::task::JoinHandle; -use tracing::{debug, error, info, instrument, warn}; - -use crate::unreads::UnreadsDB; - -use std::sync::LazyLock; - -/// Global unreads materializer instance. -pub static UNREADS_MATERIALIZER: LazyLock = - LazyLock::new(UnreadsMaterializer::default); - -/// Unreads materializer that manages per-stream materialization. -pub struct UnreadsMaterializer { - /// Active stream monitors keyed by stream DID - monitors: Arc>>, - /// Semaphore to limit concurrent materialization tasks - semaphore: Arc, -} - -impl Default for UnreadsMaterializer { - fn default() -> Self { - Self { - monitors: Arc::new(RwLock::new(HashMap::new())), - semaphore: Arc::new(Semaphore::new(100)), // Limit to 100 concurrent tasks - } - } -} - -/// Handle to an active stream monitor. -struct StreamMonitorHandle { - /// The stream DID - stream_did: Did, - /// The stream handle - stream: Arc, - /// Join handle for the monitor task - task_handle: JoinHandle<()>, - /// Sender to signal the monitor to stop - stop_tx: Sender<()>, -} - -impl UnreadsMaterializer { - /// Start monitoring a stream for unread tracking. - #[instrument(skip(self, stream))] - pub async fn start_monitoring( - &self, - stream_did: Did, - stream: Arc, - ) -> anyhow::Result<()> { - let mut monitors: tokio::sync::RwLockWriteGuard<'_, HashMap> = - self.monitors.write().await; - - // Check if already monitoring this stream - if monitors.contains_key(&stream_did) { - debug!("Stream {stream_did} is already being monitored"); - return Ok(()); - } - - // Get the data directory for this stream - let data_dir = crate::storage::STORAGE.data_dir()?; - let stream_dir = data_dir.join("streams").join(stream_did.as_str()); - - // Initialize the unreads database for this stream - let unreads_db = UnreadsDB::initialize(&stream_dir).await?; - - // Create event subscription - let event_rx = stream.subscribe_events_stream().await; - - // Create stop channel - let (stop_tx, stop_rx) = async_channel::bounded(1); - - // Spawn monitor task - let stream_did_for_monitor = stream_did.clone(); - let stream_clone = stream.clone(); - let stream_did_for_log = stream_did.clone(); - let stream_did_for_error = stream_did.clone(); - let semaphore = self.semaphore.clone(); - let task_handle = tokio::spawn(async move { - let result = run_stream_monitor( - stream_did_for_monitor, - stream_clone, - unreads_db, - event_rx, - stop_rx, - semaphore, - ) - .await; - - if let Err(e) = result { - error!("Stream monitor for stream {stream_did_for_error} failed: {e}"); - } - }); - - // Store monitor handle - monitors.insert( - stream_did.clone(), - StreamMonitorHandle { - stream_did, - stream, - task_handle, - stop_tx, - }, - ); - - info!("Started monitoring stream {stream_did_for_log} for unread tracking"); - Ok(()) - } - - /// Stop monitoring a stream. - #[instrument(skip(self))] - pub async fn stop_monitoring(&self, stream_did: &Did) -> anyhow::Result<()> { - let mut monitors: tokio::sync::RwLockWriteGuard<'_, HashMap> = - self.monitors.write().await; - - if let Some(handle) = monitors.remove(stream_did) { - // Send stop signal - let _ = handle.stop_tx.send(()).await; - - // Wait for task to finish (with timeout) - let _ = - tokio::time::timeout(tokio::time::Duration::from_secs(5), handle.task_handle).await; - - info!("Stopped monitoring stream {stream_did}"); - } else { - debug!("Stream {stream_did} was not being monitored"); - } - - Ok(()) - } - - /// Get all stream DIDs currently being monitored. - #[instrument(skip(self))] - pub async fn get_monitored_streams(&self) -> Vec { - let monitors = self.monitors.read().await; - monitors.keys().cloned().collect() - } - - /// Stop monitoring all streams. - #[instrument(skip(self))] - pub async fn stop_all(&self) { - let monitors: tokio::sync::RwLockWriteGuard<'_, HashMap> = - self.monitors.write().await; - let stream_dids: Vec = monitors.keys().cloned().collect(); - drop(monitors); - - for stream_did in stream_dids { - if let Err(e) = self.stop_monitoring(&stream_did).await { - error!("Error stopping monitor for {stream_did}: {e}"); - } - } - } -} - -/// Run the stream monitor task. -#[instrument(skip(_stream, unreads_db, event_rx, stop_rx, semaphore))] -async fn run_stream_monitor( - stream_did: Did, - _stream: Arc, - unreads_db: UnreadsDB, - event_rx: async_channel::Receiver, - stop_rx: async_channel::Receiver<()>, - semaphore: Arc, -) -> anyhow::Result<()> { - // Get the last processed event index - let state = unreads_db.get_materialization_state().await?; - let mut last_processed_idx = state.last_event_idx; - - info!("Starting materialization for stream {stream_did} from event index {last_processed_idx}"); - - // Process events until we receive a stop signal - loop { - tokio::select! { - // Check for stop signal - _ = stop_rx.recv() => { - info!("Received stop signal for stream {stream_did}"); - break; - } - - // Process next event - event_result = event_rx.recv() => { - let event = match event_result { - Ok(event) => event, - Err(_) => { - // Channel closed, exit loop - debug!("Event channel closed for stream {stream_did}"); - break; - } - }; - - // Skip events we've already processed - if event.idx <= last_processed_idx { - continue; - } - - // Acquire semaphore permit to limit concurrent processing - let _permit = semaphore.acquire().await; - - // Process the event - if let Err(e) = process_event(&stream_did, &event, &unreads_db).await { - error!("Error processing event {} for stream {stream_did}: {e}", event.idx); - // Continue processing other events even if one fails - continue; - } - - // Update last processed index - last_processed_idx = event.idx; - - // Update materialization state - if let Err(e) = unreads_db.update_materialization_state(last_processed_idx).await { - error!("Error updating materialization state for stream {stream_did}: {e}"); - } - } - } - } - - info!("Materialization stopped for stream {stream_did}"); - Ok(()) -} - -/// Process a single event. -#[instrument(skip(event, unreads_db))] -async fn process_event( - stream_did: &Did, - event: &leaf_stream_types::Event, - unreads_db: &UnreadsDB, -) -> anyhow::Result<()> { - // Parse DRISL payload - let payload = match dasl::drisl::from_slice::(&event.payload) { - Ok(value) => value, - Err(e) => { - warn!( - "Failed to parse DRISL payload for event {} in stream {stream_did}: {e}", - event.idx - ); - // Return Ok to skip this event without stopping the monitor - return Ok(()); - } - }; - - // Extract room ID from payload - let room_id = extract_room_id(&payload); - - // Extract event type (discriminant) - let event_type = extract_event_type(&payload); - - debug!( - "Processing event {} in stream {stream_did}: room_id={:?}, event_type={:?}", - event.idx, room_id, event_type - ); - - // Handle different event types - match event_type.as_deref() { - Some("JoinSpace") | Some("joinSpace") | Some("town.muni.event.JoinSpace") => { - handle_join_space(event, &unreads_db).await?; - } - Some("LeaveSpace") | Some("leaveSpace") | Some("town.muni.event.LeaveSpace") => { - handle_leave_space(event, &unreads_db).await?; - } - _ => { - // For other events with a room ID, increment unreads for all members except sender - if let Some(room_id) = room_id { - handle_regular_event(event, &room_id, &unreads_db).await?; - } - } - } - - Ok(()) -} - -/// Extract room ID from a DRISL payload. -fn extract_room_id(payload: &Value) -> Option { - // Try various paths where roomId might be located - // Note: We can't use const arrays with String::from() in const context, - // so we build the paths dynamically - let paths: Vec> = vec![ - vec![DrislExtractExprSegment::FieldAccess("roomId".to_string())], - vec![DrislExtractExprSegment::FieldAccess("room_id".to_string())], - vec![ - DrislExtractExprSegment::FieldAccess("message".to_string()), - DrislExtractExprSegment::FieldAccess("roomId".to_string()), - ], - vec![ - DrislExtractExprSegment::FieldAccess("message".to_string()), - DrislExtractExprSegment::FieldAccess("room_id".to_string()), - ], - vec![ - DrislExtractExprSegment::FieldAccess("post".to_string()), - DrislExtractExprSegment::FieldAccess("roomId".to_string()), - ], - vec![ - DrislExtractExprSegment::FieldAccess("post".to_string()), - DrislExtractExprSegment::FieldAccess("room_id".to_string()), - ], - ]; - - for path in &paths { - if let Some(Value::Text(room_id)) = extract_from_drisl_with_expr(payload.clone(), path) { - return Some(room_id); - } - } - - None -} - -/// Extract event type (discriminant) from a DRISL payload. -fn extract_event_type(payload: &Value) -> Option { - match payload { - Value::Map(map) => { - // If the map has only one key, it's likely a tagged union discriminant - if map.len() == 1 { - return Some(map.keys().next().unwrap().clone()); - } - // Try to extract from a $type field - if let Some(Value::Text(type_str)) = map.get("$type") { - return Some(type_str.clone()); - } - None - } - Value::Text(text) => Some(text.clone()), - _ => None, - } -} - -/// Handle a JoinSpace event. -#[instrument(skip(event, unreads_db))] -async fn handle_join_space( - event: &leaf_stream_types::Event, - unreads_db: &UnreadsDB, -) -> anyhow::Result<()> { - // Add the user as a member of the space - unreads_db.add_member(&event.user, event.idx).await?; - - debug!( - "Added member {} to space at event index {}", - event.user, event.idx - ); - - Ok(()) -} - -/// Handle a LeaveSpace event. -#[instrument(skip(event, unreads_db))] -async fn handle_leave_space( - event: &leaf_stream_types::Event, - unreads_db: &UnreadsDB, -) -> anyhow::Result<()> { - // Remove the user from the space - unreads_db.remove_member(&event.user, event.idx).await?; - - // Clean up unread records for this user - unreads_db.reset_user_unreads(&event.user).await?; - - debug!( - "Removed member {} from space at event index {} and cleaned up unreads", - event.user, event.idx - ); - - Ok(()) -} - -/// Handle a regular event (not JoinSpace/LeaveSpace) with a room ID. -#[instrument(skip(event, unreads_db))] -async fn handle_regular_event( - event: &leaf_stream_types::Event, - room_id: &str, - unreads_db: &UnreadsDB, -) -> anyhow::Result<()> { - // Get all active members of the space - let members = unreads_db.get_space_members().await?; - - // Filter out the sender - let other_members: Vec<_> = members - .into_iter() - .filter(|member| member.user_did != event.user) - .collect(); - - if other_members.is_empty() { - debug!("No other members to notify for room {room_id}"); - return Ok(()); - } - - // Create increment operations for all other members - let increments: Vec = other_members - .iter() - .map(|member| crate::unreads::UnreadIncrement { - user_did: member.user_did.clone(), - room_id: room_id.to_string(), - unread_delta: 1, - mention_delta: 0, // TODO: Extract mentions from payload - event_idx: event.idx, - }) - .collect(); - - // Increment unreads for all members - unreads_db.increment_unreads(increments).await?; - - debug!( - "Incremented unreads for {} members in room {room_id} at event index {}", - other_members.len(), - event.idx - ); - - Ok(()) -} From a74e44c6a2c6f9213bf6ded1747246a942e9448c Mon Sep 17 00:00:00 2001 From: Zicklag Date: Mon, 2 Mar 2026 15:38:58 +0000 Subject: [PATCH 06/12] feat: remove unneeded stuff from unread db schema. --- leaf-server/src/unreads.rs | 75 +- leaf-server/src/unreads_schema.sql | 11 - plans/unread-tracking-system-design.md | 2031 ------------------------ 3 files changed, 20 insertions(+), 2097 deletions(-) delete mode 100644 plans/unread-tracking-system-design.md diff --git a/leaf-server/src/unreads.rs b/leaf-server/src/unreads.rs index 3a34af7..7eaab29 100644 --- a/leaf-server/src/unreads.rs +++ b/leaf-server/src/unreads.rs @@ -64,7 +64,7 @@ impl UnreadsDB { /// Add a member to the space #[instrument(skip(self), err)] - pub async fn add_member(&self, user_did: &str, _event_idx: i64) -> anyhow::Result<()> { + pub async fn add_member(&self, user_did: &str) -> anyhow::Result<()> { self.db() .execute( "insert into space_members (user_did) values (?)", @@ -76,7 +76,7 @@ impl UnreadsDB { /// Remove a member from the space #[instrument(skip(self), err)] - pub async fn remove_member(&self, user_did: &str, _event_idx: i64) -> anyhow::Result<()> { + pub async fn remove_member(&self, user_did: &str) -> anyhow::Result<()> { self.db() .execute("delete from space_members where user_did = ?", [user_did]) .await?; @@ -116,10 +116,10 @@ impl UnreadsDB { /// Get unreads for a user across all rooms #[instrument(skip(self), err)] pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result> { - let rows: Vec<(String, i64, i64, Option, i64)> = self + let rows: Vec<(String, i64, i64, Option)> = self .db() .query( - "select room_id, unread_count, mention_count, last_event_idx, updated_at from room_unreads where user_did = ? order by updated_at desc", + "select room_id, unread_count, mention_count, last_event_idx from room_unreads where user_did = ?", [user_did], ) .await? @@ -129,52 +129,16 @@ impl UnreadsDB { Ok(rows .into_iter() .map( - |(room_id, unread_count, mention_count, last_event_idx, updated_at)| RoomUnread { + |(room_id, unread_count, mention_count, last_event_idx)| RoomUnread { room_id, unread_count, mention_count, last_event_idx, - updated_at, }, ) .collect()) } - /// Get unreads for a user in a specific room - #[instrument(skip(self), err)] - pub async fn get_user_unreads_for_room( - &self, - user_did: &str, - room_id: &str, - ) -> anyhow::Result> { - let mut rows = self - .db() - .query( - "select room_id, unread_count, mention_count, last_event_idx, updated_at from room_unreads where user_did = ? and room_id = ?", - (user_did, room_id), - ) - .await?; - - if let Some(row) = rows.next().await? { - let (room_id, unread_count, mention_count, last_event_idx, updated_at): ( - String, - i64, - i64, - Option, - i64, - ) = row.parse_row().await?; - return Ok(Some(RoomUnread { - room_id, - unread_count, - mention_count, - last_event_idx, - updated_at, - })); - } - - Ok(None) - } - /// Increment unread counts for multiple users #[instrument(skip(self, increments), err)] pub async fn increment_unreads(&self, increments: Vec) -> anyhow::Result<()> { @@ -184,13 +148,12 @@ impl UnreadsDB { for inc in increments { trans .execute( - "insert into room_unreads (user_did, room_id, unread_count, mention_count, last_event_idx, updated_at) - values (?, ?, ?, ?, ?, unixepoch()) + "insert into room_unreads (user_did, room_id, unread_count, mention_count, last_event_idx) + values (?, ?, ?, ?, ?) on conflict (user_did, room_id) do update set unread_count = unread_count + ?, mention_count = mention_count + ?, - last_event_idx = ?, - updated_at = unixepoch()", + last_event_idx = ?", ( inc.user_did.as_str(), inc.room_id.as_str(), @@ -224,7 +187,7 @@ impl UnreadsDB { self.db() .execute( - "update room_unreads set unread_count = 0, mention_count = 0, last_event_idx = max(last_event_idx, ?), updated_at = unixepoch() where user_did = ? and room_id = ?", + "update room_unreads set unread_count = 0, mention_count = 0, last_event_idx = max(last_event_idx, ?) where user_did = ? and room_id = ?", (last_read_idx, user_did, room_id), ) .await?; @@ -236,7 +199,7 @@ impl UnreadsDB { pub async fn reset_user_unreads(&self, user_did: &str) -> anyhow::Result<()> { self.db() .execute( - "update room_unreads set unread_count = 0, mention_count = 0, updated_at = unixepoch() where user_did = ?", + "update room_unreads set unread_count = 0, mention_count = 0 where user_did = ?", [user_did], ) .await?; @@ -267,13 +230,17 @@ impl UnreadsDB { /// Update the materialization state #[instrument(skip(self), err)] pub async fn update_materialization_state(&self, last_event_idx: i64) -> anyhow::Result<()> { - self.db() + let trans = self.db().transaction().await?; + trans + .execute("delete from materialization_state", ()) + .await?; + trans .execute( - "insert into materialization_state (last_event_idx) values (?) - on conflict do update set last_event_idx = ?", - (last_event_idx, last_event_idx), + "insert into materialization_state (last_event_idx) values (?)", + [last_event_idx], ) .await?; + trans.commit().await?; Ok(()) } } @@ -308,8 +275,6 @@ pub struct RoomUnread { pub mention_count: i64, /// The last event index that was processed pub last_event_idx: Option, - /// Timestamp of last update - pub updated_at: i64, } /// Increment operation for unreads @@ -529,7 +494,7 @@ async fn handle_join_space( unreads_db: &UnreadsDB, ) -> anyhow::Result<()> { // Add the user as a member of the space - unreads_db.add_member(&event.user, event.idx).await?; + unreads_db.add_member(&event.user).await?; debug!( "Added member {} to space at event index {}", @@ -546,7 +511,7 @@ async fn handle_leave_space( unreads_db: &UnreadsDB, ) -> anyhow::Result<()> { // Remove the user from the space - unreads_db.remove_member(&event.user, event.idx).await?; + unreads_db.remove_member(&event.user).await?; // Clean up unread records for this user unreads_db.reset_user_unreads(&event.user).await?; diff --git a/leaf-server/src/unreads_schema.sql b/leaf-server/src/unreads_schema.sql index 49f36c2..8f079f1 100644 --- a/leaf-server/src/unreads_schema.sql +++ b/leaf-server/src/unreads_schema.sql @@ -28,8 +28,6 @@ CREATE TABLE IF NOT EXISTS room_unreads ( mention_count INTEGER NOT NULL DEFAULT 0, -- The last event index that was processed for this room last_event_idx INTEGER, - -- Timestamp of last update - updated_at INTEGER NOT NULL DEFAULT (unixepoch()), PRIMARY KEY (user_did, room_id), FOREIGN KEY (user_did) @@ -39,21 +37,12 @@ CREATE TABLE IF NOT EXISTS room_unreads ( CHECK (mention_count >= 0) ) STRICT; --- Index for querying unreads for a user across all rooms -CREATE INDEX IF NOT EXISTS idx_room_unreads_user - ON room_unreads(user_did, unread_count DESC, mention_count DESC); - -- Index for querying unreads for a user where unread_count > 0 -- This composite index efficiently supports queries filtering by both user_did and unread_count > 0 CREATE INDEX IF NOT EXISTS idx_room_unreads_user_unread ON room_unreads(user_did, unread_count DESC, room_id) WHERE unread_count > 0; --- Index for querying unreads in a specific room -CREATE INDEX IF NOT EXISTS idx_room_unreads_room - ON room_unreads(room_id) - WHERE unread_count > 0 OR mention_count > 0; - -- ---------------------------------------------------------------------------- -- Table: materialization_state -- Purpose: Track the materialization progress for this stream diff --git a/plans/unread-tracking-system-design.md b/plans/unread-tracking-system-design.md deleted file mode 100644 index faa0414..0000000 --- a/plans/unread-tracking-system-design.md +++ /dev/null @@ -1,2031 +0,0 @@ -# Unread Tracking System Design - -## Executive Summary - -This document provides a comprehensive design for an unread tracking system for the leaf-server. The system will track unread message counts per user per room, manage space membership, and expose functionality via socket.io endpoints. The design is focused on leaf-server, separate from the leaf-stream package. - -## Table of Contents - -1. [Architecture Overview](#architecture-overview) -2. [Database Schema](#database-schema) -3. [Materialization Module Architecture](#materialization-module-architecture) -4. [Integration Points](#integration-points) -5. [Socket.io Endpoints](#socket-io-endpoints) -6. [Data Flow](#data-flow) -7. [Error Handling](#error-handling) -8. [Performance & Scalability](#performance--scalability) -9. [Security Considerations](#security-considerations) - ---- - -## Architecture Overview - -### System Components - -```mermaid -graph TB - subgraph "Leaf Server" - HTTP[HTTP/Socket.IO Layer] - Storage[Storage Module] - Streams[Streams Module] - UnreadsDB[(Unreads DB)] - Materialization[Materialization Module] - end - - subgraph "Stream Data" - StreamDB[(Stream DB)] - ModuleDB[(Module DB)] - end - - HTTP -->|Socket.IO| Storage - HTTP -->|Socket.IO| UnreadsDB - Storage --> Streams - Streams -->|subscribe_events_stream| Materialization - Materialization -->|parse & track| UnreadsDB - Materialization --> StreamDB - Materialization --> ModuleDB - - style UnreadsDB fill:#f9f,stroke:#333,stroke-width:4px - style Materialization fill:#bbf,stroke:#333,stroke-width:4px -``` - -### Key Design Decisions - -1. **Separate Database**: The unreads tracking uses a dedicated SQLite database (`unreads.db`) separate from the main `leaf.db` and stream-specific databases. This ensures: - - Isolation of concerns - - Independent scaling potential - - Easier backup/restore operations - - No performance impact on stream operations - -2. **Event-Driven Materialization**: A dedicated materialization module subscribes to all stream events and processes them asynchronously. This: - - Doesn't block event processing - - Provides fault tolerance - - Allows for replay/catch-up scenarios - -3. **DRISL Payload Parsing**: Events contain DRISL-encoded payloads. The materializer: - - Validates DRISL format - - Extracts `roomId` field if present - - Handles parsing errors gracefully - -4. **Membership Tracking**: Space membership is tracked via JoinSpace/LeaveSpace events: - - JoinSpace creates member records - - LeaveSpace removes member records and associated unread counts - - Supports implicit membership (e.g., room creation events) - ---- - -## Database Schema - -### Unreads Database Schema (`unreads.db`) - -```sql --- ============================================================================ --- UNREADS DATABASE SCHEMA --- Location: {data_dir}/unreads.db --- Purpose: Track unread counts per user per room and space membership --- ============================================================================ - --- ---------------------------------------------------------------------------- --- Table: space_members --- Purpose: Track which users are members of which spaces --- ---------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS space_members ( - -- The DID of the space (stream) - space_did TEXT NOT NULL, - -- The DID of the user who is a member - user_did TEXT NOT NULL, - -- When the user joined the space - joined_at INTEGER NOT NULL DEFAULT (unixepoch()), - -- When the user left the space (NULL if still a member) - left_at INTEGER, - -- The event index that caused this membership change - event_idx INTEGER, - - PRIMARY KEY (space_did, user_did), - CHECK (left_at IS NULL OR left_at >= joined_at) -) STRICT; - --- Index for querying active members of a space -CREATE INDEX IF NOT EXISTS idx_space_members_active - ON space_members(space_did) - WHERE left_at IS NULL; - --- Index for querying spaces a user is a member of -CREATE INDEX IF NOT EXISTS idx_space_members_user - ON space_members(user_did) - WHERE left_at IS NULL; - --- ---------------------------------------------------------------------------- --- Table: room_unreads --- Purpose: Track unread counts per user per room within a space --- ---------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS room_unreads ( - -- The DID of the space (stream) - space_did TEXT NOT NULL, - -- The room ID (extracted from event payloads) - room_id TEXT NOT NULL, - -- The DID of the user who has unreads - user_did TEXT NOT NULL, - -- Count of unread messages - unread_count INTEGER NOT NULL DEFAULT 0, - -- Count of mentions (messages where user was @mentioned) - mention_count INTEGER NOT NULL DEFAULT 0, - -- The last event index that was processed for this room - last_event_idx INTEGER, - -- Timestamp of last update - updated_at INTEGER NOT NULL DEFAULT (unixepoch()), - - PRIMARY KEY (space_did, room_id, user_did), - FOREIGN KEY (space_did, user_did) - REFERENCES space_members(space_did, user_did) - ON DELETE CASCADE, - CHECK (unread_count >= 0), - CHECK (mention_count >= 0) -) STRICT; - --- Index for querying unreads for a user across all rooms -CREATE INDEX IF NOT EXISTS idx_room_unreads_user - ON room_unreads(user_did, space_did, unread_count DESC, mention_count DESC); - --- Index for querying unreads in a specific room -CREATE INDEX IF NOT EXISTS idx_room_unreads_room - ON room_unreads(space_did, room_id) - WHERE unread_count > 0 OR mention_count > 0; - --- Index for querying all unreads in a space -CREATE INDEX IF NOT EXISTS idx_room_unreads_space - ON room_unreads(space_did) - WHERE unread_count > 0 OR mention_count > 0; - --- ---------------------------------------------------------------------------- --- Table: materialization_state --- Purpose: Track the materialization progress for each stream --- ---------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS materialization_state ( - -- The DID of the stream - stream_did TEXT NOT NULL PRIMARY KEY, - -- The last event index that was materialized - last_event_idx INTEGER NOT NULL DEFAULT 0, - -- Timestamp of last successful materialization - last_materialized_at INTEGER NOT NULL DEFAULT (unixepoch()), - -- Status of materialization - status TEXT NOT NULL DEFAULT 'active', -- 'active', 'paused', 'error' - -- Error message if status is 'error' - error_message TEXT -) STRICT; - --- ---------------------------------------------------------------------------- --- Table: event_processing_log --- Purpose: Log of processed events for debugging and replay --- ---------------------------------------------------------------------------- -CREATE TABLE IF NOT EXISTS event_processing_log ( - -- Auto-incrementing ID - id INTEGER PRIMARY KEY AUTOINCREMENT, - -- The DID of the stream - stream_did TEXT NOT NULL, - -- The event index - event_idx INTEGER NOT NULL, - -- The user who sent the event - user_did TEXT NOT NULL, - -- Extracted room_id (NULL if not found) - room_id TEXT, - -- Event type (discriminant from DRISL payload) - event_type TEXT, - -- Whether this event incremented unreads - incremented_unreads INTEGER NOT NULL DEFAULT 0, - -- Timestamp when processed - processed_at INTEGER NOT NULL DEFAULT (unixepoch()), - - UNIQUE (stream_did, event_idx) -) STRICT; - --- Index for querying processing history -CREATE INDEX IF NOT EXISTS idx_event_processing_log_stream - ON event_processing_log(stream_did, event_idx DESC); - --- Purge old logs (keep last 10000 per stream) -CREATE TRIGGER IF NOT EXISTS purge_old_logs -AFTER INSERT ON event_processing_log -WHEN (SELECT COUNT(*) FROM event_processing_log - WHERE stream_did = NEW.stream_did) > 10000 -BEGIN - DELETE FROM event_processing_log - WHERE id = ( - SELECT id FROM event_processing_log - WHERE stream_did = NEW.stream_did - ORDER BY id ASC - LIMIT 1 - ); -END; -``` - -### Schema Design Rationale - -#### space_members Table - -- **Purpose**: Central source of truth for space membership -- **Design Choices**: - - Composite primary key ensures one record per user per space - - `left_at` column allows historical tracking and re-join detection - - `event_idx` links membership changes to specific events - - Partial indexes on `left_at IS NULL` optimize active membership queries - -#### room_unreads Table - -- **Purpose**: Track unread counts per user per room -- **Design Choices**: - - Composite primary key ensures one record per user per room - - Foreign key cascade delete ensures cleanup when users leave spaces - - Separate `unread_count` and `mention_count` for different notification types - - `last_event_idx` enables incremental processing and replay - - Partial indexes on counts > 0 optimize unread queries - -#### materialization_state Table - -- **Purpose**: Track materialization progress per stream -- **Design Choices**: - - Single row per stream enables resumption after restart - - Status field supports pausing/resuming materialization - - Error message field aids debugging - -#### event_processing_log Table - -- **Purpose**: Debugging and audit trail -- **Design Choices**: - - Auto-incrementing ID for chronological ordering - - Unique constraint prevents duplicate processing - - Trigger-based cleanup prevents unbounded growth - - Room ID and event type extraction for analysis - ---- - -## Materialization Module Architecture - -### Module Structure - -```mermaid -graph TB - subgraph "Materialization Module" - UnreadsTracker[UnreadsTracker] - StreamMonitor[StreamMonitor] - EventProcessor[EventProcessor] - RoomExtractor[RoomExtractor] - MembershipManager[MembershipManager] - UnreadCounter[UnreadCounter] - end - - subgraph "Dependencies" - UnreadsDB[(Unreads DB)] - Stream[Stream] - end - - StreamMonitor -->|subscribe_events_stream| Stream - StreamMonitor -->|Event| EventProcessor - EventProcessor --> RoomExtractor - EventProcessor --> MembershipManager - RoomExtractor -->|roomId| UnreadCounter - MembershipManager -->|members| UnreadCounter - UnreadCounter -->|increment| UnreadsDB - UnreadCounter -->|update state| UnreadsDB -``` - -### Component Responsibilities - -#### 1. UnreadsTracker (Main Entry Point) - -**File**: `leaf-server/src/unreads/tracker.rs` - -```rust -pub struct UnreadsTracker { - db: Arc, - stream_monitors: Arc>>>, - worker_tx: async_channel::Sender, -} - -pub enum WorkerMessage { - /// A new stream has been loaded and needs monitoring - MonitorStream { stream_did: Did, stream: Arc }, - /// A stream should stop being monitored - UnmonitorStream { stream_did: Did }, - /// Shutdown all monitoring - Shutdown, -} - -impl UnreadsTracker { - /// Initialize the unreads tracker - pub async fn initialize(data_dir: &Path) -> anyhow::Result; - - /// Start monitoring a stream - pub async fn monitor_stream(&self, stream_did: Did, stream: Arc) -> anyhow::Result<()>; - - /// Stop monitoring a stream - pub async fn unmonitor_stream(&self, stream_did: Did) -> anyhow::Result<()>; - - /// Get unreads for a user - pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result>; - - /// Mark items as read - pub async fn mark_as_read( - &self, - user_did: &str, - space_did: &str, - room_id: &str, - last_read_idx: i64, - ) -> anyhow::Result<()>; - - /// Get space members - pub async fn get_space_members(&self, space_did: &str) -> anyhow::Result>; -} -``` - -**Key Responsibilities**: - -- Initialize and manage the unreads database -- Coordinate stream monitoring -- Provide high-level API for unreads operations -- Handle database migrations - -#### 2. StreamMonitor - -**File**: `leaf-server/src/unreads/stream_monitor.rs` - -```rust -pub struct StreamMonitor { - stream_did: Did, - stream: Arc, - event_rx: async_channel::Receiver, - db: Arc, - last_processed_idx: Arc, -} - -impl StreamMonitor { - /// Create a new stream monitor - pub fn new( - stream_did: Did, - stream: Arc, - db: Arc, - ) -> (Self, async_channel::Receiver); - - /// Start the monitoring loop - pub async fn run(&self) -> anyhow::Result<()>; - - /// Catch up on missed events - pub async fn catch_up(&self) -> anyhow::Result; -} -``` - -**Key Responsibilities**: - -- Subscribe to stream events via `stream.subscribe_events_stream()` -- Receive events as they arrive -- Delegate event processing to EventProcessor -- Track last processed event index -- Handle catch-up on stream load - -#### 3. EventProcessor - -**File**: `leaf-server/src/unreads/event_processor.rs` - -```rust -pub struct EventProcessor { - db: Arc, - room_extractor: RoomExtractor, - membership_manager: MembershipManager, - unread_counter: UnreadCounter, -} - -pub struct ProcessedEvent { - pub room_id: Option, - pub event_type: Option, - pub affected_members: Vec, - pub is_join_leave: bool, -} - -impl EventProcessor { - /// Process a single event - pub async fn process_event(&self, event: &Event) -> anyhow::Result; - - /// Handle JoinSpace event - async fn handle_join_space(&self, event: &Event, room_id: &str) -> anyhow::Result<()>; - - /// Handle LeaveSpace event - async fn handle_leave_space(&self, event: &Event, room_id: &str) -> anyhow::Result<()>; - - /// Handle regular message event - async fn handle_message(&self, event: &Event, room_id: &str) -> anyhow::Result<()>; -} -``` - -**Key Responsibilities**: - -- Parse DRISL payload -- Extract event type (discriminant) -- Route to appropriate handler based on event type -- Coordinate with RoomExtractor and MembershipManager -- Update processing log - -#### 4. RoomExtractor - -**File**: `leaf-server/src/unreads/room_extractor.rs` - -```rust -pub struct RoomExtractor; - -impl RoomExtractor { - /// Extract room_id from DRISL payload - pub fn extract_room_id(payload: &[u8]) -> anyhow::Result>; - - /// Extract event type (discriminant) from DRISL payload - pub fn extract_event_type(payload: &[u8]) -> anyhow::Result>; - - /// Check if event is a JoinSpace event - pub fn is_join_space(payload: &[u8]) -> bool; - - /// Check if event is a LeaveSpace event - pub fn is_leave_space(payload: &[u8]) -> bool; - - /// Extract mentions from payload - pub fn extract_mentions(payload: &[u8]) -> anyhow::Result>; -} -``` - -**Key Responsibilities**: - -- Parse DRISL-encoded payloads -- Extract `roomId` field using drisl_extract logic -- Extract event discriminant for type detection -- Handle various payload structures -- Gracefully handle parsing errors - -**Room ID Extraction Logic**: - -The system will attempt to extract `roomId` from payloads using multiple strategies: - -1. **Direct field access**: Try `payload.roomId` (case-sensitive) -2. **Nested access**: Try common nested paths like `payload.message.roomId` -3. **Discriminant-based**: For known event types, use type-specific extraction - -```rust -// Example extraction strategies -const ROOM_ID_PATHS: &[&str] = &[ - ".roomId", - ".room_id", - ".message.roomId", - ".message.room_id", - ".post.roomId", - ".post.room_id", -]; - -const JOIN_SPACE_TYPES: &[&str] = &[ - "JoinSpace", - "joinSpace", - "town.muni.event.JoinSpace", -]; - -const LEAVE_SPACE_TYPES: &[&str] = &[ - "LeaveSpace", - "leaveSpace", - "town.muni.event.LeaveSpace", -]; -``` - -#### 5. MembershipManager - -**File**: `leaf-server/src/unreads/membership_manager.rs` - -```rust -pub struct MembershipManager { - db: Arc, -} - -impl MembershipManager { - /// Add a member to a space - pub async fn add_member( - &self, - space_did: &str, - user_did: &str, - event_idx: i64, - ) -> anyhow::Result<()>; - - /// Remove a member from a space - pub async fn remove_member( - &self, - space_did: &str, - user_did: &str, - event_idx: i64, - ) -> anyhow::Result<()>; - - /// Get all active members of a space - pub async fn get_space_members(&self, space_did: &str) -> anyhow::Result>; - - /// Check if a user is a member of a space - pub async fn is_member(&self, space_did: &str, user_did: &str) -> anyhow::Result; - - /// Clean up unread records when user leaves - pub async fn cleanup_unreads_on_leave( - &self, - space_did: &str, - user_did: &str, - ) -> anyhow::Result<()>; -} -``` - -**Key Responsibilities**: - -- Manage space membership records -- Handle JoinSpace/LeaveSpace events -- Provide membership queries -- Cascade delete unread records on leave - -#### 6. UnreadCounter - -**File**: `leaf-server/src/unreads/counter.rs` - -```rust -pub struct UnreadCounter { - db: Arc, -} - -pub struct UnreadIncrement { - pub user_did: String, - pub space_did: String, - pub room_id: String, - pub unread_delta: i64, - pub mention_delta: i64, - pub event_idx: i64, -} - -impl UnreadCounter { - /// Increment unreads for multiple users - pub async fn increment_unreads( - &self, - increments: Vec, - ) -> anyhow::Result<()>; - - /// Mark items as read for a user - pub async fn mark_as_read( - &self, - user_did: &str, - space_did: &str, - room_id: &str, - last_read_idx: i64, - ) -> anyhow::Result<()>; - - /// Get unreads for a user - pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result>; - - /// Get unreads for a specific room - pub async fn get_room_unreads( - &self, - space_did: &str, - room_id: &str, - ) -> anyhow::Result>; - - /// Reset unread counts for a user - pub async fn reset_user_unreads(&self, user_did: &str) -> anyhow::Result<()>; -} -``` - -**Key Responsibilities**: - -- Increment/decrement unread counts -- Handle mention counting -- Provide unread queries -- Mark items as read -- Batch operations for performance - -### Module Initialization - -```mermaid -sequenceDiagram - participant Main as main.rs - participant Storage as Storage - participant Tracker as UnreadsTracker - participant Streams as Streams - participant Monitor as StreamMonitor - - Main->>Storage: initialize(data_dir) - Storage->>Storage: open leaf.db - Main->>Tracker: initialize(data_dir) - Tracker->>Tracker: open unreads.db - Tracker->>Tracker: run migrations - Tracker->>Tracker: start worker task - Main->>Streams: load(stream_did) - Streams->>Streams: open stream.db - Streams->>Tracker: monitor_stream(stream_did, stream) - Tracker->>Monitor: new(stream_did, stream) - Monitor->>Monitor: subscribe_events_stream() - Monitor->>Monitor: catch_up() - loop - Stream->>Monitor: Event - Monitor->>Monitor: process_event() - end -``` - ---- - -## Integration Points - -### 1. Main Server Initialization - -**File**: `leaf-server/src/main.rs` - -**Changes Required**: - -```rust -// Add new module -mod unreads; - -// In start_server function -async fn start_server(server_args: &'static ServerArgs) -> anyhow::Result<()> { - // ... existing code ... - - // Initialize storage - STORAGE.initialize(&ARGS.data_dir, s3_backup).await?; - - // Initialize unreads tracker (NEW) - unreads::UNREADS_TRACKER.initialize(&ARGS.data_dir).await?; - - // Start the web API - http::start_api(server_args).await?; - - // ... rest of code ... -} -``` - -### 2. Stream Loading Hook - -**File**: `leaf-server/src/streams.rs` - -**Changes Required**: - -```rust -// In Streams::load method -pub async fn load(&self, id: Did) -> anyhow::Result { - // ... existing code to load stream ... - - // After stream is loaded and module is provided - let handle = Arc::new(stream); - self.streams.write().await.insert(id.clone(), handle.clone()); - - // Start monitoring for unreads (NEW) - if let Err(e) = crate::unreads::UNREADS_TRACKER - .monitor_stream(id.clone(), handle.clone()) - .await - { - tracing::warn!("Failed to start unreads monitoring for stream {id}: {e}"); - } - - Ok(handle) -} -``` - -### 3. HTTP/Socket.io Integration - -**File**: `leaf-server/src/http/connection.rs` - -**Changes Required**: - -Add new socket.io handlers: - -```rust -// In setup_socket_handlers function -pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { - // ... existing handlers ... - - // NEW: Unreads query handler - let span_ = span.clone(); - let did_ = did.clone(); - socket.on( - "unreads/get", - async move |TryData::(bytes), ack: AckSender| { - let result = async { - let Some(did_) = did_ else { - anyhow::bail!("Only authenticated users can query unreads"); - }; - let args: UnreadsGetArgs = dasl::drisl::from_slice(&bytes?[..])?; - - let unreads = crate::unreads::UNREADS_TRACKER - .get_user_unreads(&did_) - .await?; - - anyhow::Ok(UnreadsGetResp { unreads }) - } - .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/get")) - .await; - - ack.send(&response(result)) - .log_error("Internal error sending response") - .ok(); - }, - ); - - // NEW: Mark as read handler - let span_ = span.clone(); - let did_ = did.clone(); - socket.on( - "unreads/mark_read", - async move |TryData::(bytes), ack: AckSender| { - let result = async { - let Some(did_) = did_ else { - anyhow::bail!("Only authenticated users can mark items as read"); - }; - let args: UnreadsMarkReadArgs = dasl::drisl::from_slice(&bytes?[..])?; - - crate::unreads::UNREADS_TRACKER - .mark_as_read(&did_, &args.space_did, &args.room_id, args.last_read_idx) - .await?; - - anyhow::Ok(()) - } - .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/mark_read")) - .await; - - ack.send(&response(result)) - .log_error("Internal error sending response") - .ok(); - }, - ); - - // NEW: Get space members handler - let span_ = span.clone(); - let did_ = did.clone(); - socket.on( - "unreads/space_members", - async move |TryData::(bytes), ack: AckSender| { - let result = async { - let Some(did_) = did_ else { - anyhow::bail!("Only authenticated users can query space members"); - }; - let args: SpaceMembersArgs = dasl::drisl::from_slice(&bytes?[..])?; - - let members = crate::unreads::UNREADS_TRACKER - .get_space_members(&args.space_did) - .await?; - - anyhow::Ok(SpaceMembersResp { members }) - } - .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/space_members")) - .await; - - ack.send(&response(result)) - .log_error("Internal error sending response") - .ok(); - }, - ); -} -``` - -### 4. Module Structure - -**New Directory Structure**: - -``` -leaf-server/src/ -├── unreads/ -│ ├── mod.rs # Module exports and UNREADS_TRACKER singleton -│ ├── tracker.rs # UnreadsTracker main implementation -│ ├── stream_monitor.rs # StreamMonitor implementation -│ ├── event_processor.rs # EventProcessor implementation -│ ├── room_extractor.rs # RoomExtractor implementation -│ ├── membership_manager.rs # MembershipManager implementation -│ ├── counter.rs # UnreadCounter implementation -│ └── schema.sql # Database schema -├── main.rs # Add mod unreads -├── streams.rs # Add monitoring hook -└── http/ - └── connection.rs # Add socket.io handlers -``` - ---- - -## Socket.io Endpoints - -### API Specification - -All endpoints use DRISL encoding for requests and responses, consistent with the existing leaf-server API. - -#### 1. Get User Unreads - -**Event**: `unreads/get` - -**Request**: - -```rust -#[derive(Serialize, Deserialize)] -pub struct UnreadsGetArgs { - // Optional: Filter to specific space - pub space_did: Option, - // Optional: Filter to specific room - pub room_id: Option, -} -``` - -**Response**: - -```rust -#[derive(Serialize, Deserialize)] -pub struct UnreadsGetResp { - pub unreads: Vec, -} - -#[derive(Serialize, Deserialize)] -pub struct RoomUnread { - pub space_did: String, - pub room_id: String, - pub unread_count: i64, - pub mention_count: i64, - pub last_event_idx: Option, - pub updated_at: i64, -} -``` - -**Behavior**: - -- Returns all unread counts for the authenticated user -- Filters by `space_did` if provided -- Filters by `room_id` if provided (requires `space_did`) -- Ordered by `updated_at DESC` (most recently updated first) - -**Example Usage**: - -```javascript -// Get all unreads -socket.emit( - "unreads/get", - drisl.encode({ - space_did: null, - room_id: null, - }), - (response) => { - if (response.ok) { - console.log("Unreads:", response.value.unreads); - } - }, -); - -// Get unreads for a specific space -socket.emit( - "unreads/get", - drisl.encode({ - space_did: "did:plc:abc123...", - room_id: null, - }), - callback, -); - -// Get unreads for a specific room -socket.emit( - "unreads/get", - drisl.encode({ - space_did: "did:plc:abc123...", - room_id: "room-456", - }), - callback, -); -``` - -#### 2. Mark Items as Read - -**Event**: `unreads/mark_read` - -**Request**: - -```rust -#[derive(Serialize, Deserialize)] -pub struct UnreadsMarkReadArgs { - pub space_did: String, - pub room_id: String, - pub last_read_idx: i64, -} -``` - -**Response**: - -```rust -pub type UnreadsMarkReadResp = (); // Empty success response -``` - -**Behavior**: - -- Sets unread count to 0 for the specified room -- Sets `last_event_idx` to track read position -- If `last_read_idx` is greater than current `last_event_idx`, updates it -- Returns error if user is not a member of the space - -**Example Usage**: - -```javascript -socket.emit( - "unreads/mark_read", - drisl.encode({ - space_did: "did:plc:abc123...", - room_id: "room-456", - last_read_idx: 12345, - }), - (response) => { - if (response.ok) { - console.log("Marked as read"); - } - }, -); -``` - -#### 3. Get Space Members - -**Event**: `unreads/space_members` - -**Request**: - -```rust -#[derive(Serialize, Deserialize)] -pub struct SpaceMembersArgs { - pub space_did: String, -} -``` - -**Response**: - -```rust -#[derive(Serialize, Deserialize)] -pub struct SpaceMembersResp { - pub members: Vec, -} - -#[derive(Serialize, Deserialize)] -pub struct SpaceMember { - pub user_did: String, - pub joined_at: i64, -} -``` - -**Behavior**: - -- Returns all active members of a space -- Only includes members where `left_at IS NULL` -- Ordered by `joined_at ASC` (oldest members first) - -**Example Usage**: - -```javascript -socket.emit( - "unreads/space_members", - drisl.encode({ - space_did: "did:plc:abc123...", - }), - (response) => { - if (response.ok) { - console.log("Members:", response.value.members); - } - }, -); -``` - -#### 4. Reset All Unreads (Optional) - -**Event**: `unreads/reset_all` - -**Request**: - -```rust -pub type UnreadsResetAllArgs = (); // Empty request -``` - -**Response**: - -```rust -pub type UnreadsResetAllResp = (); // Empty success response -``` - -**Behavior**: - -- Resets all unread counts for the authenticated user to 0 -- Useful for "mark all as read" functionality -- Returns error if user is not authenticated - -**Example Usage**: - -```javascript -socket.emit("unreads/reset_all", drisl.encode({}), (response) => { - if (response.ok) { - console.log("All unreads reset"); - } -}); -``` - -### Subscription-Based Updates (Future Enhancement) - -**Design for Real-time Updates**: - -```rust -// New endpoint for subscribing to unread updates -socket.on( - "unreads/subscribe", - async move |TryData::(bytes), ack: AckSender| { - let result = async { - let Some(did_) = did_ else { - anyhow::bail!("Only authenticated users can subscribe"); - }; - - // Create subscription channel - let subscription_id = Ulid::new(); - let (tx, rx) = async_channel::bounded(100); - - // Register subscription - UNREADS_TRACKER.register_subscription(did_, subscription_id, tx).await?; - - // Spawn task to send updates - tokio::spawn(async move { - while let Ok(update) = rx.recv().await { - if socket.connected() { - let encoded = dasl::drisl::to_vec(&UnreadUpdate { - subscription_id, - update, - }).unwrap(); - socket.emit("unreads/update", &bytes::Bytes::from_owner(encoded)).ok(); - } else { - break; - } - } - }); - - anyhow::Ok(SubscribeResp { subscription_id }) - }.await; - - ack.send(&response(result)).ok(); - }, -); -``` - ---- - -## Data Flow - -### Event Processing Flow - -```mermaid -sequenceDiagram - participant Stream as Stream - participant Monitor as StreamMonitor - participant Processor as EventProcessor - participant Extractor as RoomExtractor - participant Membership as MembershipManager - participant Counter as UnreadCounter - participant DB as Unreads DB - - Stream->>Monitor: Event(idx: 100, user: alice, payload) - Monitor->>Processor: process_event(event) - Processor->>Extractor: extract_room_id(payload) - Extractor-->>Processor: Some("room-123") - Processor->>Extractor: extract_event_type(payload) - Extractor-->>Processor: Some("Message") - Processor->>Membership: get_space_members("did:plc:...") - Membership-->>Processor: [alice, bob, charlie] - Processor->>Processor: filter out sender (alice) - Processor->>Counter: increment_unreads([ - {user: bob, room: "room-123", delta: 1}, - {user: charlie, room: "room-123", delta: 1} - ]) - Counter->>DB: UPDATE room_unreads SET unread_count = unread_count + 1 - DB-->>Counter: OK - Counter-->>Processor: OK - Processor->>DB: INSERT INTO event_processing_log - DB-->>Processor: OK - Monitor->>DB: UPDATE materialization_state SET last_event_idx = 100 -``` - -### JoinSpace Event Flow - -```mermaid -sequenceDiagram - participant Stream as Stream - participant Monitor as StreamMonitor - participant Processor as EventProcessor - participant Extractor as RoomExtractor - participant Membership as MembershipManager - participant DB as Unreads DB - - Stream->>Monitor: Event(idx: 200, user: alice, payload: JoinSpace) - Monitor->>Processor: process_event(event) - Processor->>Extractor: is_join_space(payload) - Extractor-->>Processor: true - Processor->>Extractor: extract_room_id(payload) - Extractor-->>Processor: Some("room-123") - Processor->>Membership: add_member("did:plc:...", "alice", 200) - Membership->>DB: INSERT INTO space_members - DB-->>Membership: OK - Membership-->>Processor: OK - Processor->>DB: INSERT INTO event_processing_log - DB-->>Processor: OK -``` - -### LeaveSpace Event Flow - -```mermaid -sequenceDiagram - participant Stream as Stream - participant Monitor as StreamMonitor - participant Processor as EventProcessor - participant Extractor as RoomExtractor - participant Membership as MembershipManager - participant Counter as UnreadCounter - participant DB as Unreads DB - - Stream->>Monitor: Event(idx: 300, user: alice, payload: LeaveSpace) - Monitor->>Processor: process_event(event) - Processor->>Extractor: is_leave_space(payload) - Extractor-->>Processor: true - Processor->>Extractor: extract_room_id(payload) - Extractor-->>Processor: Some("room-123") - Processor->>Membership: remove_member("did:plc:...", "alice", 300) - Membership->>DB: UPDATE space_members SET left_at = unixepoch() - DB-->>Membership: OK - Processor->>Membership: cleanup_unreads_on_leave - Membership->>Counter: reset_user_unreads("alice") - Counter->>DB: DELETE FROM room_unreads WHERE user_did = "alice" - DB-->>Counter: OK - Counter-->>Membership: OK - Membership-->>Processor: OK - Processor->>DB: INSERT INTO event_processing_log - DB-->>Processor: OK -``` - -### Query Unreads Flow - -```mermaid -sequenceDiagram - participant Client as Client - participant Socket as Socket.IO - participant Handler as Connection Handler - participant Tracker as UnreadsTracker - participant Counter as UnreadCounter - participant DB as Unreads DB - - Client->>Socket: unreads/get {space_did: "did:plc:..."} - Socket->>Handler: handle_unreads_get - Handler->>Tracker: get_user_unreads("alice") - Tracker->>Counter: get_user_unreads("alice") - Counter->>DB: SELECT * FROM room_unreads WHERE user_did = "alice" - DB-->>Counter: [{space_did, room_id, unread_count, ...}] - Counter-->>Tracker: unreads - Tracker-->>Handler: unreads - Handler->>Socket: response {ok: true, value: {unreads: [...]}} - Socket-->>Client: DRISL-encoded response -``` - -### Mark as Read Flow - -```mermaid -sequenceDiagram - participant Client as Client - participant Socket as Socket.IO - participant Handler as Connection Handler - participant Tracker as UnreadsTracker - participant Counter as UnreadCounter - participant DB as Unreads DB - - Client->>Socket: unreads/mark_read {space_did, room_id, last_read_idx} - Socket->>Handler: handle_mark_read - Handler->>Tracker: mark_as_read("alice", "did:plc:...", "room-123", 500) - Tracker->>Counter: mark_as_read - Counter->>DB: SELECT * FROM space_members WHERE user_did = "alice" - DB-->>Counter: member exists - Counter->>DB: UPDATE room_unreads SET unread_count = 0, last_event_idx = 500 - DB-->>Counter: OK - Counter-->>Tracker: OK - Tracker-->>Handler: OK - Handler->>Socket: response {ok: true} - Socket-->>Client: DRISL-encoded response -``` - ---- - -## Error Handling - -### Error Categories - -#### 1. DRISL Parsing Errors - -**Scenario**: Event payload cannot be parsed as DRISL - -**Handling Strategy**: - -```rust -impl RoomExtractor { - pub fn extract_room_id(payload: &[u8]) -> anyhow::Result> { - match dasl::drisl::from_slice::(payload) { - Ok(value) => { - // Attempt extraction - Self::extract_room_id_from_value(value) - } - Err(e) => { - tracing::warn!( - "Failed to parse DRISL payload: {e}. Payload length: {}", - payload.len() - ); - // Return None instead of error - event is skipped - Ok(None) - } - } - } -} -``` - -**Rationale**: - -- Non-critical: Unread tracking shouldn't block stream operations -- Logged for debugging -- Event is skipped but processing continues - -#### 2. Missing Room ID - -**Scenario**: Event doesn't contain a `roomId` field - -**Handling Strategy**: - -```rust -impl EventProcessor { - pub async fn process_event(&self, event: &Event) -> anyhow::Result { - let room_id = self.room_extractor.extract_room_id(&event.payload)?; - - match room_id { - Some(room_id) => { - // Process as room event - self.handle_room_event(event, &room_id).await - } - None => { - // Log and skip - not all events are room-related - tracing::debug!("Event {} has no room_id, skipping unread tracking", event.idx); - Ok(ProcessedEvent { - room_id: None, - event_type: None, - affected_members: vec![], - is_join_leave: false, - }) - } - } - } -} -``` - -**Rationale**: - -- Many events (e.g., profile updates) don't have room IDs -- Skipping is expected behavior -- No error needed - -#### 3. Database Errors - -**Scenario**: Database write fails (e.g., constraint violation, I/O error) - -**Handling Strategy**: - -```rust -impl UnreadCounter { - pub async fn increment_unreads(&self, increments: Vec) -> anyhow::Result<()> { - let tx = self.db.transaction().await?; - - for increment in &increments { - let result = tx.execute( - r#" - INSERT INTO room_unreads - (space_did, room_id, user_did, unread_count, mention_count, last_event_idx) - VALUES (?, ?, ?, ?, ?, ?) - ON CONFLICT (space_did, room_id, user_did) - DO UPDATE SET - unread_count = unread_count + ?, - mention_count = mention_count + ?, - last_event_idx = ?, - updated_at = unixepoch() - "#, - ( - &increment.space_did, - &increment.room_id, - &increment.user_did, - increment.unread_delta, - increment.mention_delta, - increment.event_idx, - increment.unread_delta, - increment.mention_delta, - increment.event_idx, - ), - ).await; - - match result { - Ok(_) => continue, - Err(e) => { - tracing::error!( - "Failed to increment unreads for user {} in room {}: {e}", - increment.user_did, increment.room_id - ); - // Rollback and return error - tx.rollback().await?; - return Err(e.into()); - } - } - } - - tx.commit().await?; - Ok(()) - } -} -``` - -**Rationale**: - -- Use transactions for atomicity -- Rollback on any failure -- Log detailed error information -- Propagate error to caller - -#### 4. Concurrent Update Conflicts - -**Scenario**: Multiple events processed simultaneously for same user/room - -**Handling Strategy**: - -```rust -// Use SQLite's ON CONFLICT clause for atomic increments -INSERT INTO room_unreads (...) VALUES (...) -ON CONFLICT (space_did, room_id, user_did) -DO UPDATE SET - unread_count = unread_count + excluded.unread_count, - mention_count = mention_count + excluded.mention_count, - last_event_idx = max(last_event_idx, excluded.last_event_idx), - updated_at = unixepoch() -``` - -**Rationale**: - -- SQLite handles concurrent updates via WAL mode -- Atomic increment prevents race conditions -- `max()` ensures `last_event_idx` is always the highest - -#### 5. Stream Monitor Failures - -**Scenario**: Stream monitor crashes or encounters error - -**Handling Strategy**: - -```rust -impl StreamMonitor { - pub async fn run(&self) -> anyhow::Result<()> { - loop { - match self.process_next_event().await { - Ok(_) => continue, - Err(e) => { - tracing::error!("Error processing event in stream {}: {e}", self.stream_did); - - // Update status to error - self.db.execute( - "UPDATE materialization_state SET status = 'error', error_message = ? WHERE stream_did = ?", - (&e.to_string(), self.stream_did.as_str()) - ).await?; - - // Wait before retrying - tokio::time::sleep(Duration::from_secs(5)).await; - - // Attempt to continue - continue; - } - } - } - } -} -``` - -**Rationale**: - -- Log error but don't crash -- Update status in database -- Implement backoff/retry logic -- Allows manual intervention - -#### 6. Membership Inconsistencies - -**Scenario**: User receives unread increment but is not a member - -**Handling Strategy**: - -```rust -impl UnreadCounter { - pub async fn increment_unreads(&self, increments: Vec) -> anyhow::Result<()> { - for increment in &increments { - // Verify membership before incrementing - let is_member = self.db.query( - "SELECT 1 FROM space_members WHERE space_did = ? AND user_did = ? AND left_at IS NULL", - (&increment.space_did, &increment.user_did) - ).await?.next().await.is_some(); - - if !is_member { - tracing::warn!( - "User {} is not a member of space {}, skipping unread increment", - increment.user_did, increment.space_did - ); - continue; - } - - // Perform increment - // ... - } - Ok(()) - } -} -``` - -**Rationale**: - -- Defensive programming -- Log inconsistencies -- Skip invalid increments -- Could trigger membership sync in future - -### Error Recovery Mechanisms - -#### 1. Catch-Up on Stream Load - -When a stream is loaded, the materializer catches up on missed events: - -```rust -impl StreamMonitor { - pub async fn catch_up(&self) -> anyhow::Result { - // Get last processed index from database - let last_processed: Option = self.db.query( - "SELECT last_event_idx FROM materialization_state WHERE stream_did = ?", - [self.stream_did.as_str()] - ).await?.next().await?.map(|row| row.get_value(0).unwrap().as_integer().unwrap()); - - let start_idx = last_processed.unwrap_or(0) + 1; - let latest_idx = self.stream.latest_event().await; - - if start_idx > latest_idx { - return Ok(latest_idx); - } - - tracing::info!( - "Catching up stream {} from {} to {}", - self.stream_did, start_idx, latest_idx - ); - - // Fetch and process events in batches - for batch_start in (start_idx..=latest_idx).step_by(100) { - let batch_end = (batch_start + 99).min(latest_idx); - self.process_event_range(batch_start, batch_end).await?; - } - - Ok(latest_idx) - } -} -``` - -#### 2. Replay from Event Log - -If corruption is detected, replay from event log: - -```rust -impl UnreadsTracker { - pub async fn replay_stream(&self, stream_did: &str, from_idx: i64) -> anyhow::Result<()> { - tracing::warn!("Replaying stream {} from event {}", stream_did, from_idx); - - // Delete unread records for this stream after from_idx - self.db.execute( - "DELETE FROM room_unreads WHERE space_did = ? AND last_event_idx >= ?", - [stream_did, from_idx] - ).await?; - - // Reset materialization state - self.db.execute( - "UPDATE materialization_state SET last_event_idx = ? WHERE stream_did = ?", - [from_idx - 1, stream_did] - ).await?; - - // Trigger catch-up - let monitor = self.stream_monitors.read().await.get(stream_did).cloned(); - if let Some(monitor) = monitor { - monitor.catch_up().await?; - } - - Ok(()) - } -} -``` - ---- - -## Performance & Scalability - -### Performance Optimizations - -#### 1. Database-Level Optimizations - -**WAL Mode**: - -```sql -PRAGMA journal_mode = WAL; -PRAGMA synchronous = NORMAL; -PRAGMA cache_size = -64000; -- 64MB cache -PRAGMA temp_store = MEMORY; -``` - -**Rationale**: - -- WAL allows concurrent reads and writes -- Reduces I/O contention -- Better performance for high-throughput scenarios - -**Indexing Strategy**: - -```sql --- Partial indexes reduce index size -CREATE INDEX idx_room_unreads_user_active - ON room_unreads(user_did, space_did) - WHERE unread_count > 0 OR mention_count > 0; - --- Covering indexes for common queries -CREATE INDEX idx_space_members_covering - ON space_members(space_did, user_did, joined_at) - WHERE left_at IS NULL; -``` - -**Rationale**: - -- Partial indexes only include relevant rows -- Covering indexes avoid table lookups -- Smaller indexes = faster queries - -#### 2. Batch Processing - -**Batch Event Processing**: - -```rust -impl StreamMonitor { - pub async fn process_event_batch(&self, events: Vec) -> anyhow::Result<()> { - let mut increments = Vec::new(); - let mut membership_changes = Vec::new(); - - for event in &events { - let processed = self.processor.process_event(event).await?; - - if let Some(room_id) = processed.room_id { - for member in processed.affected_members { - increments.push(UnreadIncrement { - user_did: member, - space_did: self.stream_did.to_string(), - room_id: room_id.clone(), - unread_delta: 1, - mention_delta: 0, - event_idx: event.idx, - }); - } - } - - if processed.is_join_leave { - membership_changes.push((event.clone(), processed)); - } - } - - // Batch increment unreads - if !increments.is_empty() { - self.counter.increment_unreads(increments).await?; - } - - // Batch membership changes - for (event, processed) in membership_changes { - // Process membership changes - } - - Ok(()) - } -} -``` - -**Rationale**: - -- Reduces database round trips -- Fewer transactions -- Better throughput - -#### 3. Async Processing - -**Non-blocking Event Processing**: - -```rust -impl StreamMonitor { - pub async fn run(&self) -> Result<(), StreamError> { - let (tx, rx) = async_channel::unbounded(); - - // Spawn event receiver - let event_rx = self.stream.subscribe_events_stream().await; - tokio::spawn(async move { - while let Ok(event) = event_rx.recv().await { - tx.send(event).await.ok(); - } - }); - - // Process events in worker task - loop { - let event = rx.recv().await?; - - // Spawn processing task - let processor = self.processor.clone(); - tokio::spawn(async move { - if let Err(e) = processor.process_event(&event).await { - tracing::error!("Error processing event {}: {e}", event.idx); - } - }); - } - } -} -``` - -**Rationale**: - -- Doesn't block event reception -- Parallel processing of multiple events -- Better throughput under load - -#### 4. Connection Pooling - -**Database Connection Pool**: - -```rust -pub struct UnreadsTracker { - db_pool: Arc, // Use connection pool - // ... -} - -impl UnreadsTracker { - pub async fn initialize(data_dir: &Path) -> anyhow::Result { - let db_path = data_dir.join("unreads.db"); - let pool = sqlx::SqlitePool::connect_with( - sqlx::sqlite::SqliteConnectOptions::new() - .filename(db_path) - .create_if_missing(true) - ).await?; - - // Configure pool - let pool = pool - .max_connections(10) - .min_connections(2) - .acquire_timeout(Duration::from_secs(5)) - .idle_timeout(Duration::from_secs(600)); - - Ok(Self { db_pool: Arc::new(pool), ... }) - } -} -``` - -**Rationale**: - -- Multiple concurrent database operations -- Automatic connection management -- Better resource utilization - -### Scalability Considerations - -#### 1. Vertical Scaling - -**Current Design Supports**: - -- **Users**: Tens of thousands (limited by SQLite file size) -- **Rooms**: Hundreds of thousands per space -- **Events**: Millions per stream (with WAL and proper indexing) -- **Throughput**: Thousands of events per second (with async processing) - -**Bottlenecks**: - -- Single SQLite database file -- Single server instance -- Memory for connection pooling - -#### 2. Horizontal Scaling (Future) - -**Multi-Server Architecture**: - -```mermaid -graph TB - subgraph "Load Balancer" - LB[Load Balancer] - end - - subgraph "Leaf Server 1" - HTTP1[HTTP/Socket.IO] - Storage1[Storage] - Unreads1[Unreads DB] - end - - subgraph "Leaf Server 2" - HTTP2[HTTP/Socket.IO] - Storage2[Storage] - Unreads2[Unreads DB] - end - - subgraph "Shared Storage" - S3[S3 Bucket] - RDS[(PostgreSQL)] - end - - LB --> HTTP1 - LB --> HTTP2 - Storage1 --> S3 - Storage2 --> S3 - Unreads1 --> RDS - Unreads2 --> RDS -``` - -**Migration Path**: - -1. Replace SQLite with PostgreSQL for unreads database -2. Use connection pooling (PgBouncer) -3. Implement consistent hashing for stream-to-server assignment -4. Use pub/sub (Redis) for cross-server unread updates - -#### 3. Data Partitioning - -**By Space**: - -```sql --- Partition table by space_did (PostgreSQL) -CREATE TABLE room_unreads ( - space_did TEXT, - room_id TEXT, - user_did TEXT, - unread_count INTEGER, - -- ... -) PARTITION BY HASH (space_did); - -CREATE TABLE room_unreads_p0 PARTITION OF room_unreads - FOR VALUES WITH (MODULUS 4, REMAINDER 0); - -CREATE TABLE room_unreads_p1 PARTITION OF room_unreads - FOR VALUES WITH (MODULUS 4, REMAINDER 1); --- ... etc -``` - -**Rationale**: - -- Distributes data across multiple tables -- Parallel query processing -- Easier maintenance (can drop/rebuild partitions) - -#### 4. Caching Strategy - -**Redis Cache for Unreads**: - -```rust -pub struct CachedUnreadCounter { - counter: UnreadCounter, - redis: Arc, -} - -impl CachedUnreadCounter { - pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result> { - let cache_key = format!("unreads:user:{}", user_did); - - // Try cache first - if let Ok(cached) = self.redis.get(&cache_key).await { - if let Ok(unreads) = serde_json::from_str::>(&cached) { - return Ok(unreads); - } - } - - // Cache miss - query database - let unreads = self.counter.get_user_unreads(user_did).await?; - - // Cache for 5 minutes - let serialized = serde_json::to_string(&unreads)?; - self.redis.set_ex(&cache_key, &serialized, 300).await?; - - Ok(unreads) - } - - pub async fn increment_unreads(&self, increments: Vec) -> anyhow::Result<()> { - // Increment in database - self.counter.increment_unreads(increments.clone()).await?; - - // Invalidate cache for affected users - for increment in &increments { - let cache_key = format!("unreads:user:{}", increment.user_did); - self.redis.del(&cache_key).await.ok(); - } - - Ok(()) - } -} -``` - -**Rationale**: - -- Reduces database load for read-heavy workloads -- Cache invalidation on writes -- TTL-based expiration - -#### 5. Monitoring & Metrics - -**Key Metrics to Track**: - -- Events processed per second -- Average event processing latency -- Database query latency -- Active stream monitors count -- Unread query latency -- Cache hit/miss ratio (if caching implemented) - -**Example Metrics Collection**: - -```rust -use prometheus::{Counter, Histogram, IntGauge}; - -lazy_static! { - static ref EVENTS_PROCESSED: Counter = register_counter!( - "unreads_events_processed_total", - "Total number of events processed" - ).unwrap(); - - static ref EVENT_PROCESSING_LATENCY: Histogram = register_histogram!( - "unreads_event_processing_duration_seconds", - "Event processing latency" - ).unwrap(); - - static ref ACTIVE_MONITORS: IntGauge = register_int_gauge!( - "unreads_active_monitors", - "Number of active stream monitors" - ).unwrap(); -} -``` - ---- - -## Security Considerations - -### 1. Authentication & Authorization - -**Socket.io Authentication**: - -- All unreads endpoints require authentication -- Use existing JWT validation from `http.rs` -- User DID extracted from auth token - -**Authorization Checks**: - -```rust -impl UnreadCounter { - pub async fn mark_as_read( - &self, - user_did: &str, - space_did: &str, - room_id: &str, - last_read_idx: i64, - ) -> anyhow::Result<()> { - // Verify user is a member - let is_member = self.db.query( - "SELECT 1 FROM space_members WHERE space_did = ? AND user_did = ? AND left_at IS NULL", - [space_did, user_did] - ).await?.next().await.is_some(); - - if !is_member { - anyhow::bail!("User {} is not a member of space {}", user_did, space_did); - } - - // Perform update - // ... - } -} -``` - -### 2. Input Validation - -**DRISL Payload Validation**: - -- Validate DRISL format before parsing -- Limit payload size (e.g., 10MB max) -- Sanitize extracted values - -**SQL Injection Prevention**: - -- Use parameterized queries exclusively -- Never concatenate user input into SQL - -### 3. Rate Limiting - -**Per-User Rate Limits**: - -```rust -use tower::ServiceBuilder; -use tower_governor::{Governor, GovernorConfigBuilder}; - -let governor_conf = GovernorConfigBuilder::default() - .per_second(10) - .burst_size(30) - .finish() - .unwrap(); - -let app = Router::new() - .layer(Governor::new(&governor_conf, &SharedState::default())) - .route("/socket.io", get(socket_io_handler)); -``` - -**Rationale**: - -- Prevent abuse of unreads endpoints -- Protect against DoS attacks -- Fair resource allocation - -### 4. Data Privacy - -**User DID Protection**: - -- User DIDs are sensitive identifiers -- Never log full DIDs in production -- Consider hashing for analytics - -**Access Control**: - -- Users can only query their own unreads -- Space members can only query space members -- Admin endpoints require elevated permissions - -### 5. Audit Logging - -**Event Processing Log**: - -- All events logged with timestamp -- Track who sent each event -- Enable forensic analysis - -**Access Log**: - -```rust -impl UnreadsTracker { - pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result> { - tracing::info!(user = %user_did, action = "get_user_unreads"); - - let unreads = self.counter.get_user_unreads(user_did).await?; - - tracing::debug!(user = %user_did, count = unreads.len(), action = "get_user_unreads_result"); - - Ok(unreads) - } -} -``` - ---- - -## Implementation Checklist - -### Phase 1: Core Infrastructure - -- [ ] Create `unreads` module structure -- [ ] Implement database schema (`unreads/schema.sql`) -- [ ] Create `UnreadsTracker` singleton -- [ ] Implement database migrations -- [ ] Initialize tracker in `main.rs` - -### Phase 2: Materialization - -- [ ] Implement `RoomExtractor` (DRISL parsing) -- [ ] Implement `MembershipManager` -- [ ] Implement `UnreadCounter` -- [ ] Implement `EventProcessor` -- [ ] Implement `StreamMonitor` -- [ ] Add monitoring hook in `streams.rs` - -### Phase 3: Socket.io Endpoints - -- [ ] Add `unreads/get` handler -- [ ] Add `unreads/mark_read` handler -- [ ] Add `unreads/space_members` handler -- [ ] Add `unreads/reset_all` handler (optional) -- [ ] Define request/response types - -### Phase 4: Testing - -- [ ] Unit tests for each component -- [ ] Integration tests for event flow -- [ ] Load testing for performance -- [ ] Error handling tests - -### Phase 5: Monitoring & Operations - -- [ ] Add metrics collection -- [ ] Add health check endpoint -- [ ] Document operational procedures -- [ ] Create troubleshooting guide - ---- - -## Conclusion - -This design provides a comprehensive, production-ready unread tracking system for leaf-server. The system: - -- **Separates concerns** with a dedicated database and module -- **Handles events asynchronously** without blocking stream operations -- **Parses DRISL payloads** robustly with graceful error handling -- **Tracks membership** via JoinSpace/LeaveSpace events -- **Exposes functionality** via socket.io endpoints consistent with existing API -- **Scales vertically** to support thousands of users and millions of events -- **Provides hooks** for future horizontal scaling -- **Includes security** measures for authentication, authorization, and rate limiting -- **Supports monitoring** and debugging with comprehensive logging - -The design balances simplicity with extensibility, providing a solid foundation for the unread tracking feature while allowing for future enhancements like real-time subscriptions, caching layers, and horizontal scaling. From 2fa73c0c9fef63b0516016b703ffd417a11e4d75 Mon Sep 17 00:00:00 2001 From: Zicklag Date: Mon, 2 Mar 2026 18:45:16 +0000 Subject: [PATCH 07/12] refator: iterate unread tracking. --- leaf-server/src/unreads.rs | 92 +++++++------------------------------- 1 file changed, 17 insertions(+), 75 deletions(-) diff --git a/leaf-server/src/unreads.rs b/leaf-server/src/unreads.rs index 7eaab29..b0a0a7d 100644 --- a/leaf-server/src/unreads.rs +++ b/leaf-server/src/unreads.rs @@ -58,10 +58,6 @@ impl UnreadsDB { &self.db } - // ============================================================================ - // space_members table operations - // ============================================================================ - /// Add a member to the space #[instrument(skip(self), err)] pub async fn add_member(&self, user_did: &str) -> anyhow::Result<()> { @@ -109,10 +105,6 @@ impl UnreadsDB { Ok(rows.next().await?.is_some()) } - // ============================================================================ - // room_unreads table operations - // ============================================================================ - /// Get unreads for a user across all rooms #[instrument(skip(self), err)] pub async fn get_user_unreads(&self, user_did: &str) -> anyhow::Result> { @@ -198,18 +190,11 @@ impl UnreadsDB { #[instrument(skip(self), err)] pub async fn reset_user_unreads(&self, user_did: &str) -> anyhow::Result<()> { self.db() - .execute( - "update room_unreads set unread_count = 0, mention_count = 0 where user_did = ?", - [user_did], - ) + .execute("delete from room_unreads where user_did = ?", [user_did]) .await?; Ok(()) } - // ============================================================================ - // materialization_state table operations - // ============================================================================ - /// Get the materialization state #[instrument(skip(self), err)] pub async fn get_materialization_state(&self) -> anyhow::Result { @@ -340,8 +325,6 @@ async fn monitor_stream(stream_with_unreads: Arc) -> anyhow:: .await?; let mut last_processed_idx = state.last_event_idx; - info!("Starting unreads monitor for stream {stream_did} from event index {last_processed_idx}"); - // Process events until the channel is closed loop { let event = match event_rx.recv().await { @@ -418,16 +401,16 @@ async fn process_event( // Handle different event types match event_type.as_deref() { - Some("JoinSpace") | Some("joinSpace") | Some("town.muni.event.JoinSpace") => { + Some("space.roomy.space.joinSpace.v0") => { handle_join_space(event, unreads_db).await?; } - Some("LeaveSpace") | Some("leaveSpace") | Some("town.muni.event.LeaveSpace") => { + Some("space.roomy.space.leaveSpace.v0") => { handle_leave_space(event, unreads_db).await?; } _ => { // For other events with a room ID, increment unreads for all members except sender if let Some(room_id) = room_id { - handle_regular_event(event, &room_id, unreads_db).await?; + handle_event_with_room(event, &room_id, unreads_db).await?; } } } @@ -437,32 +420,11 @@ async fn process_event( /// Extract room ID from a DRISL payload. fn extract_room_id(payload: &Value) -> Option { - // Try various paths where roomId might be located - let paths: Vec> = vec![ - vec![DrislExtractExprSegment::FieldAccess("roomId".to_string())], - vec![DrislExtractExprSegment::FieldAccess("room_id".to_string())], - vec![ - DrislExtractExprSegment::FieldAccess("message".to_string()), - DrislExtractExprSegment::FieldAccess("roomId".to_string()), - ], - vec![ - DrislExtractExprSegment::FieldAccess("message".to_string()), - DrislExtractExprSegment::FieldAccess("room_id".to_string()), - ], - vec![ - DrislExtractExprSegment::FieldAccess("post".to_string()), - DrislExtractExprSegment::FieldAccess("roomId".to_string()), - ], - vec![ - DrislExtractExprSegment::FieldAccess("post".to_string()), - DrislExtractExprSegment::FieldAccess("room_id".to_string()), - ], - ]; - - for path in &paths { - if let Some(Value::Text(room_id)) = extract_from_drisl_with_expr(payload.clone(), path) { - return Some(room_id); - } + if let Some(Value::Text(room_id)) = extract_from_drisl_with_expr( + payload.clone(), + &[DrislExtractExprSegment::FieldAccess("room".to_string())], + ) { + return Some(room_id); } None @@ -470,21 +432,14 @@ fn extract_room_id(payload: &Value) -> Option { /// Extract event type (discriminant) from a DRISL payload. fn extract_event_type(payload: &Value) -> Option { - match payload { - Value::Map(map) => { - // If the map has only one key, it's likely a tagged union discriminant - if map.len() == 1 { - return Some(map.keys().next().unwrap().clone()); - } - // Try to extract from a $type field - if let Some(Value::Text(type_str)) = map.get("$type") { - return Some(type_str.clone()); - } - None - } - Value::Text(text) => Some(text.clone()), - _ => None, + if let Some(Value::Text(discriminant)) = extract_from_drisl_with_expr( + payload.clone(), + &[DrislExtractExprSegment::FieldAccess("$type".to_string())], + ) { + return Some(discriminant); } + + None } /// Handle a JoinSpace event. @@ -496,11 +451,6 @@ async fn handle_join_space( // Add the user as a member of the space unreads_db.add_member(&event.user).await?; - debug!( - "Added member {} to space at event index {}", - event.user, event.idx - ); - Ok(()) } @@ -513,20 +463,12 @@ async fn handle_leave_space( // Remove the user from the space unreads_db.remove_member(&event.user).await?; - // Clean up unread records for this user - unreads_db.reset_user_unreads(&event.user).await?; - - debug!( - "Removed member {} from space at event index {} and cleaned up unreads", - event.user, event.idx - ); - Ok(()) } /// Handle a regular event (not JoinSpace/LeaveSpace) with a room ID. #[instrument(skip(event, unreads_db))] -async fn handle_regular_event( +async fn handle_event_with_room( event: &leaf_stream_types::Event, room_id: &str, unreads_db: &UnreadsDB, From 191cb35e083291f2ffc3287f3608b72c26ada8bf Mon Sep 17 00:00:00 2001 From: Zicklag Date: Mon, 2 Mar 2026 19:08:02 +0000 Subject: [PATCH 08/12] refactor: greatly simplify unread tracker. --- leaf-server/src/unreads.rs | 188 ++++++++----------------------------- 1 file changed, 38 insertions(+), 150 deletions(-) diff --git a/leaf-server/src/unreads.rs b/leaf-server/src/unreads.rs index b0a0a7d..7203247 100644 --- a/leaf-server/src/unreads.rs +++ b/leaf-server/src/unreads.rs @@ -131,36 +131,25 @@ impl UnreadsDB { .collect()) } - /// Increment unread counts for multiple users - #[instrument(skip(self, increments), err)] - pub async fn increment_unreads(&self, increments: Vec) -> anyhow::Result<()> { - let db = self.db(); - let trans = db.transaction().await?; - - for inc in increments { - trans - .execute( - "insert into room_unreads (user_did, room_id, unread_count, mention_count, last_event_idx) - values (?, ?, ?, ?, ?) - on conflict (user_did, room_id) do update set - unread_count = unread_count + ?, - mention_count = mention_count + ?, - last_event_idx = ?", - ( - inc.user_did.as_str(), - inc.room_id.as_str(), - inc.unread_delta, - inc.mention_delta, - inc.event_idx, - inc.unread_delta, - inc.mention_delta, - inc.event_idx, - ), - ) - .await?; - } - - trans.commit().await?; + /// Increment unreads for all space members except the sender in a single SQL operation. + #[instrument(skip(self), err)] + pub async fn increment_unreads_for_all_members_except( + &self, + room_id: &str, + exclude_user_did: &str, + event_idx: i64, + ) -> anyhow::Result<()> { + self.db() + .execute( + "insert into room_unreads (user_did, room_id, unread_count, mention_count, last_event_idx) + select user_did, ?, 1, 0, ? from space_members where user_did != ? + on conflict (user_did, room_id) do update set + unread_count = unread_count + 1, + mention_count = mention_count + 0, + last_event_idx = ?", + (room_id, event_idx, exclude_user_did, event_idx), + ) + .await?; Ok(()) } @@ -262,21 +251,6 @@ pub struct RoomUnread { pub last_event_idx: Option, } -/// Increment operation for unreads -#[derive(Debug, Clone)] -pub struct UnreadIncrement { - /// The DID of the user - pub user_did: String, - /// The room ID - pub room_id: String, - /// Delta for unread count - pub unread_delta: i64, - /// Delta for mention count - pub mention_delta: i64, - /// The event index - pub event_idx: i64, -} - /// Represents the materialization state for the stream #[derive(Debug, Clone)] pub struct MaterializationState { @@ -383,16 +357,21 @@ async fn process_event( "Failed to parse DRISL payload for event {} in stream {stream_did}: {e}", event.idx ); - // Return Ok to skip this event without stopping the monitor return Ok(()); } }; - // Extract room ID from payload - let room_id = extract_room_id(&payload); - // Extract event type (discriminant) - let event_type = extract_event_type(&payload); + let event_type = extract_from_drisl_with_expr( + payload.clone(), + &[DrislExtractExprSegment::FieldAccess("$type".to_string())], + ); + + // Extract room ID from payload + let room_id = extract_from_drisl_with_expr( + payload.clone(), + &[DrislExtractExprSegment::FieldAccess("room".to_string())], + ); debug!( "Processing event {} in stream {stream_did}: room_id={:?}, event_type={:?}", @@ -400,113 +379,22 @@ async fn process_event( ); // Handle different event types - match event_type.as_deref() { - Some("space.roomy.space.joinSpace.v0") => { - handle_join_space(event, unreads_db).await?; + match event_type { + Some(Value::Text(event_type)) if event_type == "space.roomy.space.joinSpace.v0" => { + unreads_db.add_member(&event.user).await?; } - Some("space.roomy.space.leaveSpace.v0") => { - handle_leave_space(event, unreads_db).await?; + Some(Value::Text(event_type)) if event_type == "space.roomy.space.leaveSpace.v0" => { + unreads_db.remove_member(&event.user).await?; } _ => { // For other events with a room ID, increment unreads for all members except sender - if let Some(room_id) = room_id { - handle_event_with_room(event, &room_id, unreads_db).await?; + if let Some(Value::Text(room_id)) = room_id { + unreads_db + .increment_unreads_for_all_members_except(&room_id, &event.user, event.idx) + .await?; } } } Ok(()) } - -/// Extract room ID from a DRISL payload. -fn extract_room_id(payload: &Value) -> Option { - if let Some(Value::Text(room_id)) = extract_from_drisl_with_expr( - payload.clone(), - &[DrislExtractExprSegment::FieldAccess("room".to_string())], - ) { - return Some(room_id); - } - - None -} - -/// Extract event type (discriminant) from a DRISL payload. -fn extract_event_type(payload: &Value) -> Option { - if let Some(Value::Text(discriminant)) = extract_from_drisl_with_expr( - payload.clone(), - &[DrislExtractExprSegment::FieldAccess("$type".to_string())], - ) { - return Some(discriminant); - } - - None -} - -/// Handle a JoinSpace event. -#[instrument(skip(event, unreads_db))] -async fn handle_join_space( - event: &leaf_stream_types::Event, - unreads_db: &UnreadsDB, -) -> anyhow::Result<()> { - // Add the user as a member of the space - unreads_db.add_member(&event.user).await?; - - Ok(()) -} - -/// Handle a LeaveSpace event. -#[instrument(skip(event, unreads_db))] -async fn handle_leave_space( - event: &leaf_stream_types::Event, - unreads_db: &UnreadsDB, -) -> anyhow::Result<()> { - // Remove the user from the space - unreads_db.remove_member(&event.user).await?; - - Ok(()) -} - -/// Handle a regular event (not JoinSpace/LeaveSpace) with a room ID. -#[instrument(skip(event, unreads_db))] -async fn handle_event_with_room( - event: &leaf_stream_types::Event, - room_id: &str, - unreads_db: &UnreadsDB, -) -> anyhow::Result<()> { - // Get all active members of the space - let members = unreads_db.get_space_members().await?; - - // Filter out the sender - let other_members: Vec<_> = members - .into_iter() - .filter(|member| member.user_did != event.user) - .collect(); - - if other_members.is_empty() { - debug!("No other members to notify for room {room_id}"); - return Ok(()); - } - - // Create increment operations for all other members - let increments: Vec = other_members - .iter() - .map(|member| UnreadIncrement { - user_did: member.user_did.clone(), - room_id: room_id.to_string(), - unread_delta: 1, - mention_delta: 0, // TODO: Extract mentions from payload - event_idx: event.idx, - }) - .collect(); - - // Increment unreads for all members - unreads_db.increment_unreads(increments).await?; - - debug!( - "Incremented unreads for {} members in room {room_id} at event index {}", - other_members.len(), - event.idx - ); - - Ok(()) -} From 5cd8e374ede29e6f686004513818efdf1bec74bb Mon Sep 17 00:00:00 2001 From: Zicklag Date: Mon, 2 Mar 2026 21:01:22 +0000 Subject: [PATCH 09/12] fix: remove unneeded rpc endpoints for unread tracking. --- leaf-server/src/http/connection.rs | 108 ----------------------------- 1 file changed, 108 deletions(-) diff --git a/leaf-server/src/http/connection.rs b/leaf-server/src/http/connection.rs index 5a6ecdf..2011874 100644 --- a/leaf-server/src/http/connection.rs +++ b/leaf-server/src/http/connection.rs @@ -611,84 +611,6 @@ pub fn setup_socket_handlers(socket: &SocketRef, did: Option) { .ok(); }, ); - - let span_ = span.clone(); - let did_ = did.clone(); - socket.on( - "unreads/space_members", - async move |TryData::(bytes), ack: AckSender| { - let result = async { - let Some(did_) = did_ else { - anyhow::bail!("Only authenticated users can query space members"); - }; - - let UnreadsSpaceMembersArgs { stream_did } = dasl::drisl::from_slice(&bytes?[..])?; - - // Load the stream (which includes the cached unreads_db) - let s = STREAMS.load(stream_did.clone()).await?; - let unreads_db = &s.unreads_db; - - // Verify the user is a member of this space - if !unreads_db.is_member(&did_).await? { - anyhow::bail!("User {did_} is not a member of space {stream_did}"); - } - - // Get space members - let members = unreads_db.get_space_members().await?; - - // Convert to response format - let response: Vec = members - .into_iter() - .map(|m| UnreadsSpaceMember { - user_did: m.user_did, - }) - .collect(); - - anyhow::Ok(UnreadsSpaceMembersResp { members: response }) - } - .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/space_members")) - .await; - - ack.send(&response(result)) - .log_error("Internal error sending response") - .ok(); - }, - ); - - let span_ = span.clone(); - let did_ = did.clone(); - socket.on( - "unreads/reset_all", - async move |TryData::(bytes), ack: AckSender| { - let result = async { - let Some(did_) = did_ else { - anyhow::bail!("Only authenticated users can reset unreads"); - }; - - let UnreadsResetAllArgs { stream_did } = dasl::drisl::from_slice(&bytes?[..])?; - - // Load the stream (which includes the cached unreads_db) - let s = STREAMS.load(stream_did.clone()).await?; - let unreads_db = &s.unreads_db; - - // Verify the user is a member of this space - if !unreads_db.is_member(&did_).await? { - anyhow::bail!("User {did_} is not a member of space {stream_did}"); - } - - // Reset all unreads for the user - unreads_db.reset_user_unreads(&did_).await?; - - anyhow::Ok(UnreadsResetAllResp { success: true }) - } - .instrument(tracing::info_span!(parent: span_.clone(), "handle unreads/reset_all")) - .await; - - ack.send(&response(result)) - .log_error("Internal error sending response") - .ok(); - }, - ); } #[derive(Deserialize)] @@ -851,33 +773,3 @@ struct UnreadsMarkReadArgs { struct UnreadsMarkReadResp { success: bool, } - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UnreadsSpaceMembersArgs { - stream_did: Did, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UnreadsSpaceMember { - user_did: String, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UnreadsSpaceMembersResp { - members: Vec, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct UnreadsResetAllArgs { - stream_did: Did, -} - -#[derive(Serialize)] -#[serde(rename_all = "camelCase")] -struct UnreadsResetAllResp { - success: bool, -} From 2980501d53b5c236b25f8cf6e50db30fd825adff Mon Sep 17 00:00:00 2001 From: Zicklag Date: Mon, 2 Mar 2026 21:03:40 +0000 Subject: [PATCH 10/12] feat: add bindings to unread tracking in TS client. Completely untested CLI bindings also included. --- clients/typescript/cli/commands/unreads.ts | 64 ++++++++++++++++++++++ clients/typescript/cli/index.ts | 12 ++++ clients/typescript/src/codec.ts | 30 +++++++++- clients/typescript/src/index.ts | 50 ++++++++++++++++- 4 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 clients/typescript/cli/commands/unreads.ts diff --git a/clients/typescript/cli/commands/unreads.ts b/clients/typescript/cli/commands/unreads.ts new file mode 100644 index 0000000..2a91d60 --- /dev/null +++ b/clients/typescript/cli/commands/unreads.ts @@ -0,0 +1,64 @@ +import { Did } from "../../src/codec.js"; +import { + createClient, + parseGlobalOptions, + outputJson, + outputError, +} from "../utils.js"; + +export async function getUnreads(args: string[]) { + if (args.length < 1) { + throw new Error("Usage: leaf unreads "); + } + + const streamDid = args[0]! as Did; + const options = parseGlobalOptions(args); + + const client = await createClient(options); + + try { + const unreads = await client.getUnreads(streamDid); + + outputJson({ + success: true, + stream_id: streamDid, + unreads, + }); + } catch (error) { + outputError(error instanceof Error ? error.message : String(error)); + throw error; + } finally { + client.disconnect(); + } +} + +export async function markAsRead(args: string[]) { + if (args.length < 1) { + throw new Error( + "Usage: leaf mark-read [room-id] [last-read-idx]", + ); + } + + const streamDid = args[0]! as Did; + const roomId = args[1]; + const lastReadIdx = args[2] ? parseInt(args[2], 10) : undefined; + const options = parseGlobalOptions(args); + + const client = await createClient(options); + + try { + const success = await client.markAsRead(streamDid, roomId, lastReadIdx); + + outputJson({ + success, + stream_id: streamDid, + room_id: roomId, + last_read_idx: lastReadIdx, + }); + } catch (error) { + outputError(error instanceof Error ? error.message : String(error)); + throw error; + } finally { + client.disconnect(); + } +} diff --git a/clients/typescript/cli/index.ts b/clients/typescript/cli/index.ts index 10e7212..366e36d 100644 --- a/clients/typescript/cli/index.ts +++ b/clients/typescript/cli/index.ts @@ -4,6 +4,7 @@ import { query } from "./commands/query.js"; import { sendEvents } from "./commands/send-events.js"; import { createStream } from "./commands/create-stream.js"; import { streamInfo } from "./commands/stream-info.js"; +import { getUnreads, markAsRead } from "./commands/unreads.js"; const HELP_TEXT = ` Leaf CLI - Testing tool for Leaf server @@ -16,6 +17,8 @@ Commands: send-events Send events to a stream from JSON file create-stream Create a new stream from genesis JSON stream-info Get stream information + unreads Get unread counts for a user + mark-read [room-id] Mark items as read (all rooms or specific room) Global Options: --url Leaf server URL (default: http://localhost:5530 or LEAF_URL env var) @@ -33,6 +36,9 @@ Examples: leaf send-events abc123 events.json leaf create-stream a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6 leaf stream-info abc123 + leaf unreads abc123 + leaf mark-read abc123 + leaf mark-read abc123 room123 100 Environment Variables: LEAF_URL Default Leaf server URL @@ -64,6 +70,12 @@ async function main() { case "stream-info": await streamInfo(commandArgs); break; + case "unreads": + await getUnreads(commandArgs); + break; + case "mark-read": + await markAsRead(commandArgs); + break; default: console.error(`Unknown command: ${command}`); console.error("Run 'leaf --help' for usage information"); diff --git a/clients/typescript/src/codec.ts b/clients/typescript/src/codec.ts index 247402c..9c5a763 100644 --- a/clients/typescript/src/codec.ts +++ b/clients/typescript/src/codec.ts @@ -137,4 +137,32 @@ export type StreamStateEventBatchResp = Result; export type StreamClearStateArgs = { streamDid: Did; }; -export type StreamClearStateResp = Result; \ No newline at end of file +export type StreamClearStateResp = Result; + +// ============================================================================ +// Unreads tracking types +// ============================================================================ + +export type UnreadsGetArgs = { + streamDid: Did; +}; + +export type UnreadsGetItem = { + roomId: string; + unreadCount: number; + mentionCount: number; +}; + +export type UnreadsGetResp = Result<{ + unreads: UnreadsGetItem[]; +}>; + +export type UnreadsMarkReadArgs = { + streamDid: Did; + roomId?: string; + lastReadIdx?: number; +}; + +export type UnreadsMarkReadResp = Result<{ + success: boolean; +}>; diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index 5c4e0d2..f3f7a2a 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -37,6 +37,11 @@ import { StreamUpdateModuleResp, SubscribeEventsResp, SubscriptionId, + UnreadsGetArgs, + UnreadsGetItem, + UnreadsGetResp, + UnreadsMarkReadArgs, + UnreadsMarkReadResp, } from "./codec.js"; export * from "./codec.js"; @@ -256,7 +261,10 @@ export class LeafClient { } } - async sendStateEvents(streamDid: string, events: Uint8Array[]): Promise { + async sendStateEvents( + streamDid: string, + events: Uint8Array[], + ): Promise { const data: Uint8Array = await this.socket.emitWithAck( "stream/state_event_batch", toBinary( @@ -275,7 +283,9 @@ export class LeafClient { async clearState(streamDid: string): Promise { const data: Uint8Array = await this.socket.emitWithAck( "stream/clear_state", - toBinary(encode({ streamDid: streamDid as Did } satisfies StreamClearStateArgs)), + toBinary( + encode({ streamDid: streamDid as Did } satisfies StreamClearStateArgs), + ), ); const resp: StreamClearStateResp = decode(fromBinary(data)); if ("Err" in resp) { @@ -357,6 +367,42 @@ export class LeafClient { throw new Error(resp.Err); } } + + async getUnreads(streamDid: string): Promise { + const data: Uint8Array = await this.socket.emitWithAck( + "unreads/get", + toBinary( + encode({ streamDid: streamDid as Did } satisfies UnreadsGetArgs), + ), + ); + const resp: UnreadsGetResp = decode(fromBinary(data)); + if ("Err" in resp) { + throw new Error(resp.Err); + } + return resp.Ok.unreads; + } + + async markAsRead( + streamDid: string, + roomId?: string, + lastReadIdx?: number, + ): Promise { + const data: Uint8Array = await this.socket.emitWithAck( + "unreads/mark_read", + toBinary( + encode({ + streamDid: streamDid as Did, + roomId, + lastReadIdx, + } satisfies UnreadsMarkReadArgs), + ), + ); + const resp: UnreadsMarkReadResp = decode(fromBinary(data)); + if ("Err" in resp) { + throw new Error(resp.Err); + } + return resp.Ok.success; + } } function convertBytesWrappers(t: any): any { From c1fe43d234b327487354f0c1444625fa4862e147 Mon Sep 17 00:00:00 2001 From: Zicklag Date: Mon, 2 Mar 2026 21:08:57 +0000 Subject: [PATCH 11/12] feat: add unreads to explorer. --- .gitignore | 3 +- clients/typescript/src/index.ts | 2 +- explorer/package.json | 4 +- explorer/pnpm-lock.yaml | 3156 ---------------- explorer/src/lib/workers/backendWorker.ts | 10 +- explorer/src/lib/workers/index.ts | 4 +- explorer/src/routes/[[tab]]/+page.svelte | 163 +- pnpm-lock.yaml | 4111 +++++++++++++++++++++ pnpm-workspace.yaml | 3 + 9 files changed, 4264 insertions(+), 3192 deletions(-) delete mode 100644 explorer/pnpm-lock.yaml create mode 100644 pnpm-lock.yaml create mode 100644 pnpm-workspace.yaml diff --git a/.gitignore b/.gitignore index 636bd58..3766feb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ target data -.claude \ No newline at end of file +.claude +**/node_modules diff --git a/clients/typescript/src/index.ts b/clients/typescript/src/index.ts index f3f7a2a..7a2753e 100644 --- a/clients/typescript/src/index.ts +++ b/clients/typescript/src/index.ts @@ -48,7 +48,7 @@ export * from "./codec.js"; type SocketIoBuffer = Buffer | ArrayBuffer; -async function createDaslCid(bytes: Uint8Array): Promise { +async function createDaslCid(bytes: Uint8Array): Promise { return createCid(0x71, bytes); } diff --git a/explorer/package.json b/explorer/package.json index d5a4e95..e68e7e0 100644 --- a/explorer/package.json +++ b/explorer/package.json @@ -51,7 +51,7 @@ "@codemirror/lang-json": "^6.0.2", "@codemirror/lang-sql": "^6.10.0", "@codemirror/theme-one-dark": "^6.1.3", - "@muni-town/leaf-client": "0.1.0-alpha.17", + "@muni-town/leaf-client": "workspace:*", "svelte-codemirror-editor": "^2.1.0" } -} \ No newline at end of file +} diff --git a/explorer/pnpm-lock.yaml b/explorer/pnpm-lock.yaml deleted file mode 100644 index b92d040..0000000 --- a/explorer/pnpm-lock.yaml +++ /dev/null @@ -1,3156 +0,0 @@ -lockfileVersion: '9.0' - -settings: - autoInstallPeers: true - excludeLinksFromLockfile: false - -importers: - - .: - dependencies: - '@atcute/cbor': - specifier: ^2.2.8 - version: 2.2.8 - '@codemirror/lang-json': - specifier: ^6.0.2 - version: 6.0.2 - '@codemirror/lang-sql': - specifier: ^6.10.0 - version: 6.10.0 - '@codemirror/theme-one-dark': - specifier: ^6.1.3 - version: 6.1.3 - '@muni-town/leaf-client': - specifier: 0.1.0-alpha.17 - version: 0.1.0-alpha.17 - svelte-codemirror-editor: - specifier: ^2.1.0 - version: 2.1.0(codemirror@6.0.2)(svelte@5.38.10) - devDependencies: - '@atproto/api': - specifier: ^0.16.9 - version: 0.16.9 - '@atproto/jwk-jose': - specifier: ^0.1.10 - version: 0.1.10 - '@atproto/oauth-client': - specifier: ^0.5.6 - version: 0.5.6 - '@atproto/oauth-client-browser': - specifier: ^0.3.32 - version: 0.3.32 - '@eslint/compat': - specifier: ^1.2.5 - version: 1.3.2(eslint@9.35.0(jiti@2.5.1)) - '@eslint/js': - specifier: ^9.18.0 - version: 9.35.0 - '@sveltejs/adapter-static': - specifier: ^3.0.9 - version: 3.0.9(@sveltejs/kit@2.39.1(@sveltejs/vite-plugin-svelte@6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1))) - '@sveltejs/kit': - specifier: ^2.22.0 - version: 2.39.1(@sveltejs/vite-plugin-svelte@6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)) - '@sveltejs/vite-plugin-svelte': - specifier: ^6.0.0 - version: 6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)) - '@tailwindcss/vite': - specifier: ^4.0.0 - version: 4.1.13(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)) - daisyui: - specifier: ^5.1.10 - version: 5.1.10 - dexie: - specifier: ^4.2.0 - version: 4.2.0 - eslint: - specifier: ^9.18.0 - version: 9.35.0(jiti@2.5.1) - eslint-config-prettier: - specifier: ^10.0.1 - version: 10.1.8(eslint@9.35.0(jiti@2.5.1)) - eslint-plugin-svelte: - specifier: ^3.0.0 - version: 3.12.3(eslint@9.35.0(jiti@2.5.1))(svelte@5.38.10) - globals: - specifier: ^16.0.0 - version: 16.4.0 - prettier: - specifier: ^3.4.2 - version: 3.6.2 - prettier-plugin-svelte: - specifier: ^3.3.3 - version: 3.4.0(prettier@3.6.2)(svelte@5.38.10) - prettier-plugin-tailwindcss: - specifier: ^0.6.11 - version: 0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.38.10))(prettier@3.6.2) - svelte: - specifier: ^5.0.0 - version: 5.38.10 - svelte-check: - specifier: ^4.0.0 - version: 4.3.1(picomatch@4.0.3)(svelte@5.38.10)(typescript@5.9.2) - tailwindcss: - specifier: ^4.0.0 - version: 4.1.13 - typescript: - specifier: ^5.0.0 - version: 5.9.2 - typescript-eslint: - specifier: ^8.20.0 - version: 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - ulidx: - specifier: ^2.4.1 - version: 2.4.1 - vite: - specifier: ^7.0.4 - version: 7.1.5(jiti@2.5.1)(lightningcss@1.30.1) - -packages: - - '@atcute/cbor@2.2.8': - resolution: {integrity: sha512-UzOAN9BuN6JCXgn0ryV8qZuRJUDrNqrbLd6EFM8jc6RYssjRyGRxNy6RZ1NU/07Hd8Tq/0pz8+nQiMu5Zai5uw==} - - '@atcute/cid@2.2.6': - resolution: {integrity: sha512-bTAHHbJ24p+E//V4KCS4xdmd39o211jJswvqQOevj7vk+5IYcgDLx1ryZWZ1sEPOo9x875li/kj5gpKL14RDwQ==} - - '@atcute/multibase@1.1.6': - resolution: {integrity: sha512-HBxuCgYLKPPxETV0Rot4VP9e24vKl8JdzGCZOVsDaOXJgbRZoRIF67Lp0H/OgnJeH/Xpva8Z5ReoTNJE5dn3kg==} - - '@atcute/uint8array@1.0.6': - resolution: {integrity: sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==} - - '@atproto-labs/did-resolver@0.2.1': - resolution: {integrity: sha512-zSoHyqwwRYUtMNLW+RrWsImt1U5S47nJv5FfmAXTmon6wVKjxKD/PFrD1pg/4G6THqJmQHTs1Hj+54XVupYnvQ==} - - '@atproto-labs/fetch@0.2.3': - resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} - - '@atproto-labs/handle-resolver@0.3.1': - resolution: {integrity: sha512-mLZdMNvwomgnn9sffKO1/xr02ctgeiT0FUVw7JekbchTckub2RM7qMu8Rw1mC4bpCpW+i7DXDiOxpoajkppwYQ==} - - '@atproto-labs/identity-resolver@0.3.1': - resolution: {integrity: sha512-jCgotRRqPykPwh4gh0FBLOqeofv1G8OH/DZ5s88HWm7biUZeksZwDrEvL5TnqEFUpXT3O9Hcyp/XEpfCAplRoQ==} - - '@atproto-labs/pipe@0.1.1': - resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} - - '@atproto-labs/simple-store-memory@0.1.4': - resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} - - '@atproto-labs/simple-store@0.3.0': - resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} - - '@atproto/api@0.16.9': - resolution: {integrity: sha512-hXbnBIDEIwXxxyduxxZsf0aP8Z+JKyfG7L47FZqAYOI6uNm8oBTLLrHQ2RmJZZeyMIMM17gvxNtPDoULKQfupw==} - - '@atproto/common-web@0.4.3': - resolution: {integrity: sha512-nRDINmSe4VycJzPo6fP/hEltBcULFxt9Kw7fQk6405FyAWZiTluYHlXOnU7GkQfeUK44OENG1qFTBcmCJ7e8pg==} - - '@atproto/did@0.2.0': - resolution: {integrity: sha512-BskT39KYbwY1DUsWekkHh47xS+wvJpFq5F9acsicNfYniinyAMnNTzGKQEhnjQuG7K0qQItg/SnmC+y0tJXV7Q==} - - '@atproto/jwk-jose@0.1.10': - resolution: {integrity: sha512-Eiu/u4tZHz3IIhHZt0zneYEffSAO3Oqk/ToKwlu1TqKte6sjtPs/4uquSiAAGFYozqgo92JC/AQclWzzkHI5QQ==} - - '@atproto/jwk-webcrypto@0.1.10': - resolution: {integrity: sha512-JZsavs6JiSmw5rgcjkGDwzr1aCJGdybZOjVfYH+m9sXRU1BrUCA30uwNfZY7eFyWXyRAnCFiYiGVZgypXyKotw==} - - '@atproto/jwk@0.5.0': - resolution: {integrity: sha512-Qi2NtEqhkG+uz3CKia4+H05WMV/z//dz3ESo5+cyBKrOnxVTJ5ZubMyltWjoYvy6v/jLhorXdDWcjn07yky7MQ==} - - '@atproto/lexicon@0.5.1': - resolution: {integrity: sha512-y8AEtYmfgVl4fqFxqXAeGvhesiGkxiy3CWoJIfsFDDdTlZUC8DFnZrYhcqkIop3OlCkkljvpSJi1hbeC1tbi8A==} - - '@atproto/oauth-client-browser@0.3.32': - resolution: {integrity: sha512-h6Rsa/LgMnugaVKkMtHbQ1DSlhhIhL4HbjW1egg7z0BQdWJzczJ8nX3guHN5r/YioL6vOigxbMim+p/Z1LeG9g==} - - '@atproto/oauth-client@0.5.6': - resolution: {integrity: sha512-O1S9lPptJxWPcNd2kODaLgWntz+A7PzskU2hP4IFa7hVLs4aEnEt9dKq5wJE97tDli8mgyh/ndPQhxUaCVQ5iQ==} - - '@atproto/oauth-types@0.4.1': - resolution: {integrity: sha512-c5ixf2ZOzcltOu1fDBnO/tok6Wj7JDDK66+Z0q/+bAr8LXgOnxP7zQfJ+DD4gTkB+saTqsqWtVv8qvx/IEtm1g==} - - '@atproto/syntax@0.4.1': - resolution: {integrity: sha512-CJdImtLAiFO+0z3BWTtxwk6aY5w4t8orHTMVJgkf++QRJWTxPbIFko/0hrkADB7n2EruDxDSeAgfUGehpH6ngw==} - - '@atproto/xrpc@0.7.5': - resolution: {integrity: sha512-MUYNn5d2hv8yVegRL0ccHvTHAVj5JSnW07bkbiaz96UH45lvYNRVwt44z+yYVnb0/mvBzyD3/ZQ55TRGt7fHkA==} - - '@codemirror/autocomplete@6.19.1': - resolution: {integrity: sha512-q6NenYkEy2fn9+JyjIxMWcNjzTL/IhwqfzOut1/G3PrIFkrbl4AL7Wkse5tLrQUUyqGoAKU5+Pi5jnnXxH5HGw==} - - '@codemirror/commands@6.10.0': - resolution: {integrity: sha512-2xUIc5mHXQzT16JnyOFkh8PvfeXuIut3pslWGfsGOhxP/lpgRm9HOl/mpzLErgt5mXDovqA0d11P21gofRLb9w==} - - '@codemirror/lang-json@6.0.2': - resolution: {integrity: sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==} - - '@codemirror/lang-sql@6.10.0': - resolution: {integrity: sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==} - - '@codemirror/language@6.11.3': - resolution: {integrity: sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==} - - '@codemirror/lint@6.9.2': - resolution: {integrity: sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==} - - '@codemirror/search@6.5.11': - resolution: {integrity: sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==} - - '@codemirror/state@6.5.2': - resolution: {integrity: sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==} - - '@codemirror/theme-one-dark@6.1.3': - resolution: {integrity: sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==} - - '@codemirror/view@6.38.6': - resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==} - - '@esbuild/aix-ppc64@0.25.9': - resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - - '@esbuild/android-arm64@0.25.9': - resolution: {integrity: sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - - '@esbuild/android-arm@0.25.9': - resolution: {integrity: sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - - '@esbuild/android-x64@0.25.9': - resolution: {integrity: sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - - '@esbuild/darwin-arm64@0.25.9': - resolution: {integrity: sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - - '@esbuild/darwin-x64@0.25.9': - resolution: {integrity: sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - - '@esbuild/freebsd-arm64@0.25.9': - resolution: {integrity: sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - - '@esbuild/freebsd-x64@0.25.9': - resolution: {integrity: sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - - '@esbuild/linux-arm64@0.25.9': - resolution: {integrity: sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - - '@esbuild/linux-arm@0.25.9': - resolution: {integrity: sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - - '@esbuild/linux-ia32@0.25.9': - resolution: {integrity: sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - - '@esbuild/linux-loong64@0.25.9': - resolution: {integrity: sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - - '@esbuild/linux-mips64el@0.25.9': - resolution: {integrity: sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - - '@esbuild/linux-ppc64@0.25.9': - resolution: {integrity: sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - - '@esbuild/linux-riscv64@0.25.9': - resolution: {integrity: sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - - '@esbuild/linux-s390x@0.25.9': - resolution: {integrity: sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - - '@esbuild/linux-x64@0.25.9': - resolution: {integrity: sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - - '@esbuild/netbsd-arm64@0.25.9': - resolution: {integrity: sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - - '@esbuild/netbsd-x64@0.25.9': - resolution: {integrity: sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - - '@esbuild/openbsd-arm64@0.25.9': - resolution: {integrity: sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - - '@esbuild/openbsd-x64@0.25.9': - resolution: {integrity: sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - - '@esbuild/openharmony-arm64@0.25.9': - resolution: {integrity: sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - - '@esbuild/sunos-x64@0.25.9': - resolution: {integrity: sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - - '@esbuild/win32-arm64@0.25.9': - resolution: {integrity: sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - - '@esbuild/win32-ia32@0.25.9': - resolution: {integrity: sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - - '@esbuild/win32-x64@0.25.9': - resolution: {integrity: sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - - '@eslint-community/eslint-utils@4.9.0': - resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - - '@eslint-community/regexpp@4.12.1': - resolution: {integrity: sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==} - engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - - '@eslint/compat@1.3.2': - resolution: {integrity: sha512-jRNwzTbd6p2Rw4sZ1CgWRS8YMtqG15YyZf7zvb6gY2rB2u6n+2Z+ELW0GtL0fQgyl0pr4Y/BzBfng/BdsereRA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.40 || 9 - peerDependenciesMeta: - eslint: - optional: true - - '@eslint/config-array@0.21.0': - resolution: {integrity: sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/config-helpers@0.3.1': - resolution: {integrity: sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/core@0.15.2': - resolution: {integrity: sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/eslintrc@3.3.1': - resolution: {integrity: sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/js@9.35.0': - resolution: {integrity: sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/object-schema@2.1.6': - resolution: {integrity: sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@eslint/plugin-kit@0.3.5': - resolution: {integrity: sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@humanfs/core@0.19.1': - resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} - engines: {node: '>=18.18.0'} - - '@humanfs/node@0.16.7': - resolution: {integrity: sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==} - engines: {node: '>=18.18.0'} - - '@humanwhocodes/module-importer@1.0.1': - resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} - engines: {node: '>=12.22'} - - '@humanwhocodes/retry@0.4.3': - resolution: {integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==} - engines: {node: '>=18.18'} - - '@isaacs/fs-minipass@4.0.1': - resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} - engines: {node: '>=18.0.0'} - - '@jridgewell/gen-mapping@0.3.13': - resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} - - '@jridgewell/remapping@2.3.5': - resolution: {integrity: sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==} - - '@jridgewell/resolve-uri@3.1.2': - resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} - engines: {node: '>=6.0.0'} - - '@jridgewell/sourcemap-codec@1.5.5': - resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} - - '@jridgewell/trace-mapping@0.3.31': - resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - - '@lezer/common@1.3.0': - resolution: {integrity: sha512-L9X8uHCYU310o99L3/MpJKYxPzXPOS7S0NmBaM7UO/x2Kb2WbmMLSkfvdr1KxRIFYOpbY0Jhn7CfLSUDzL8arQ==} - - '@lezer/highlight@1.2.3': - resolution: {integrity: sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==} - - '@lezer/json@1.0.3': - resolution: {integrity: sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==} - - '@lezer/lr@1.4.3': - resolution: {integrity: sha512-yenN5SqAxAPv/qMnpWW0AT7l+SxVrgG+u0tNsRQWqbrz66HIl8DnEbBObvy21J5K7+I1v7gsAnlE2VQ5yYVSeA==} - - '@marijn/find-cluster-break@1.0.2': - resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} - - '@muni-town/leaf-client@0.1.0-alpha.17': - resolution: {integrity: sha512-FDKUT4DIpZblpGwIbQj+UuTWEy4Ud2BSoLDUJ+Axz4/AnveOh0RX/a7cu/L3P+uqphg4wVH1TsQqe2rS8UoELg==} - hasBin: true - - '@nodelib/fs.scandir@2.1.5': - resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} - engines: {node: '>= 8'} - - '@nodelib/fs.stat@2.0.5': - resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} - engines: {node: '>= 8'} - - '@nodelib/fs.walk@1.2.8': - resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} - engines: {node: '>= 8'} - - '@polka/url@1.0.0-next.29': - resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} - - '@rollup/rollup-android-arm-eabi@4.50.1': - resolution: {integrity: sha512-HJXwzoZN4eYTdD8bVV22DN8gsPCAj3V20NHKOs8ezfXanGpmVPR7kalUHd+Y31IJp9stdB87VKPFbsGY3H/2ag==} - cpu: [arm] - os: [android] - - '@rollup/rollup-android-arm64@4.50.1': - resolution: {integrity: sha512-PZlsJVcjHfcH53mOImyt3bc97Ep3FJDXRpk9sMdGX0qgLmY0EIWxCag6EigerGhLVuL8lDVYNnSo8qnTElO4xw==} - cpu: [arm64] - os: [android] - - '@rollup/rollup-darwin-arm64@4.50.1': - resolution: {integrity: sha512-xc6i2AuWh++oGi4ylOFPmzJOEeAa2lJeGUGb4MudOtgfyyjr4UPNK+eEWTPLvmPJIY/pgw6ssFIox23SyrkkJw==} - cpu: [arm64] - os: [darwin] - - '@rollup/rollup-darwin-x64@4.50.1': - resolution: {integrity: sha512-2ofU89lEpDYhdLAbRdeyz/kX3Y2lpYc6ShRnDjY35bZhd2ipuDMDi6ZTQ9NIag94K28nFMofdnKeHR7BT0CATw==} - cpu: [x64] - os: [darwin] - - '@rollup/rollup-freebsd-arm64@4.50.1': - resolution: {integrity: sha512-wOsE6H2u6PxsHY/BeFHA4VGQN3KUJFZp7QJBmDYI983fgxq5Th8FDkVuERb2l9vDMs1D5XhOrhBrnqcEY6l8ZA==} - cpu: [arm64] - os: [freebsd] - - '@rollup/rollup-freebsd-x64@4.50.1': - resolution: {integrity: sha512-A/xeqaHTlKbQggxCqispFAcNjycpUEHP52mwMQZUNqDUJFFYtPHCXS1VAG29uMlDzIVr+i00tSFWFLivMcoIBQ==} - cpu: [x64] - os: [freebsd] - - '@rollup/rollup-linux-arm-gnueabihf@4.50.1': - resolution: {integrity: sha512-54v4okehwl5TaSIkpp97rAHGp7t3ghinRd/vyC1iXqXMfjYUTm7TfYmCzXDoHUPTTf36L8pr0E7YsD3CfB3ZDg==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm-musleabihf@4.50.1': - resolution: {integrity: sha512-p/LaFyajPN/0PUHjv8TNyxLiA7RwmDoVY3flXHPSzqrGcIp/c2FjwPPP5++u87DGHtw+5kSH5bCJz0mvXngYxw==} - cpu: [arm] - os: [linux] - - '@rollup/rollup-linux-arm64-gnu@4.50.1': - resolution: {integrity: sha512-2AbMhFFkTo6Ptna1zO7kAXXDLi7H9fGTbVaIq2AAYO7yzcAsuTNWPHhb2aTA6GPiP+JXh85Y8CiS54iZoj4opw==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-arm64-musl@4.50.1': - resolution: {integrity: sha512-Cgef+5aZwuvesQNw9eX7g19FfKX5/pQRIyhoXLCiBOrWopjo7ycfB292TX9MDcDijiuIJlx1IzJz3IoCPfqs9w==} - cpu: [arm64] - os: [linux] - - '@rollup/rollup-linux-loongarch64-gnu@4.50.1': - resolution: {integrity: sha512-RPhTwWMzpYYrHrJAS7CmpdtHNKtt2Ueo+BlLBjfZEhYBhK00OsEqM08/7f+eohiF6poe0YRDDd8nAvwtE/Y62Q==} - cpu: [loong64] - os: [linux] - - '@rollup/rollup-linux-ppc64-gnu@4.50.1': - resolution: {integrity: sha512-eSGMVQw9iekut62O7eBdbiccRguuDgiPMsw++BVUg+1K7WjZXHOg/YOT9SWMzPZA+w98G+Fa1VqJgHZOHHnY0Q==} - cpu: [ppc64] - os: [linux] - - '@rollup/rollup-linux-riscv64-gnu@4.50.1': - resolution: {integrity: sha512-S208ojx8a4ciIPrLgazF6AgdcNJzQE4+S9rsmOmDJkusvctii+ZvEuIC4v/xFqzbuP8yDjn73oBlNDgF6YGSXQ==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-riscv64-musl@4.50.1': - resolution: {integrity: sha512-3Ag8Ls1ggqkGUvSZWYcdgFwriy2lWo+0QlYgEFra/5JGtAd6C5Hw59oojx1DeqcA2Wds2ayRgvJ4qxVTzCHgzg==} - cpu: [riscv64] - os: [linux] - - '@rollup/rollup-linux-s390x-gnu@4.50.1': - resolution: {integrity: sha512-t9YrKfaxCYe7l7ldFERE1BRg/4TATxIg+YieHQ966jwvo7ddHJxPj9cNFWLAzhkVsbBvNA4qTbPVNsZKBO4NSg==} - cpu: [s390x] - os: [linux] - - '@rollup/rollup-linux-x64-gnu@4.50.1': - resolution: {integrity: sha512-MCgtFB2+SVNuQmmjHf+wfI4CMxy3Tk8XjA5Z//A0AKD7QXUYFMQcns91K6dEHBvZPCnhJSyDWLApk40Iq/H3tA==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-linux-x64-musl@4.50.1': - resolution: {integrity: sha512-nEvqG+0jeRmqaUMuwzlfMKwcIVffy/9KGbAGyoa26iu6eSngAYQ512bMXuqqPrlTyfqdlB9FVINs93j534UJrg==} - cpu: [x64] - os: [linux] - - '@rollup/rollup-openharmony-arm64@4.50.1': - resolution: {integrity: sha512-RDsLm+phmT3MJd9SNxA9MNuEAO/J2fhW8GXk62G/B4G7sLVumNFbRwDL6v5NrESb48k+QMqdGbHgEtfU0LCpbA==} - cpu: [arm64] - os: [openharmony] - - '@rollup/rollup-win32-arm64-msvc@4.50.1': - resolution: {integrity: sha512-hpZB/TImk2FlAFAIsoElM3tLzq57uxnGYwplg6WDyAxbYczSi8O2eQ+H2Lx74504rwKtZ3N2g4bCUkiamzS6TQ==} - cpu: [arm64] - os: [win32] - - '@rollup/rollup-win32-ia32-msvc@4.50.1': - resolution: {integrity: sha512-SXjv8JlbzKM0fTJidX4eVsH+Wmnp0/WcD8gJxIZyR6Gay5Qcsmdbi9zVtnbkGPG8v2vMR1AD06lGWy5FLMcG7A==} - cpu: [ia32] - os: [win32] - - '@rollup/rollup-win32-x64-msvc@4.50.1': - resolution: {integrity: sha512-StxAO/8ts62KZVRAm4JZYq9+NqNsV7RvimNK+YM7ry//zebEH6meuugqW/P5OFUCjyQgui+9fUxT6d5NShvMvA==} - cpu: [x64] - os: [win32] - - '@socket.io/component-emitter@3.1.2': - resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} - - '@standard-schema/spec@1.0.0': - resolution: {integrity: sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==} - - '@sveltejs/acorn-typescript@1.0.5': - resolution: {integrity: sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==} - peerDependencies: - acorn: ^8.9.0 - - '@sveltejs/adapter-static@3.0.9': - resolution: {integrity: sha512-aytHXcMi7lb9ljsWUzXYQ0p5X1z9oWud2olu/EpmH7aCu4m84h7QLvb5Wp+CFirKcwoNnYvYWhyP/L8Vh1ztdw==} - peerDependencies: - '@sveltejs/kit': ^2.0.0 - - '@sveltejs/kit@2.39.1': - resolution: {integrity: sha512-NdgBGHcf/3tXYzPRyQuvsmjI5d3Qp6uhgmlN3uurhyEMN0hMFhdUG83zmWBH8u/QXj6VBmPrKvUn0QXf+Q3/lQ==} - engines: {node: '>=18.13'} - hasBin: true - peerDependencies: - '@opentelemetry/api': ^1.0.0 - '@sveltejs/vite-plugin-svelte': ^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 - svelte: ^4.0.0 || ^5.0.0-next.0 - vite: ^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 - peerDependenciesMeta: - '@opentelemetry/api': - optional: true - - '@sveltejs/vite-plugin-svelte-inspector@5.0.1': - resolution: {integrity: sha512-ubWshlMk4bc8mkwWbg6vNvCeT7lGQojE3ijDh3QTR6Zr/R+GXxsGbyH4PExEPpiFmqPhYiVSVmHBjUcVc1JIrA==} - engines: {node: ^20.19 || ^22.12 || >=24} - peerDependencies: - '@sveltejs/vite-plugin-svelte': ^6.0.0-next.0 - svelte: ^5.0.0 - vite: ^6.3.0 || ^7.0.0 - - '@sveltejs/vite-plugin-svelte@6.2.0': - resolution: {integrity: sha512-nJsV36+o7rZUDlrnSduMNl11+RoDE1cKqOI0yUEBCcqFoAZOk47TwD3dPKS2WmRutke9StXnzsPBslY7prDM9w==} - engines: {node: ^20.19 || ^22.12 || >=24} - peerDependencies: - svelte: ^5.0.0 - vite: ^6.3.0 || ^7.0.0 - - '@tailwindcss/node@4.1.13': - resolution: {integrity: sha512-eq3ouolC1oEFOAvOMOBAmfCIqZBJuvWvvYWh5h5iOYfe1HFC6+GZ6EIL0JdM3/niGRJmnrOc+8gl9/HGUaaptw==} - - '@tailwindcss/oxide-android-arm64@4.1.13': - resolution: {integrity: sha512-BrpTrVYyejbgGo57yc8ieE+D6VT9GOgnNdmh5Sac6+t0m+v+sKQevpFVpwX3pBrM2qKrQwJ0c5eDbtjouY/+ew==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [android] - - '@tailwindcss/oxide-darwin-arm64@4.1.13': - resolution: {integrity: sha512-YP+Jksc4U0KHcu76UhRDHq9bx4qtBftp9ShK/7UGfq0wpaP96YVnnjFnj3ZFrUAjc5iECzODl/Ts0AN7ZPOANQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [darwin] - - '@tailwindcss/oxide-darwin-x64@4.1.13': - resolution: {integrity: sha512-aAJ3bbwrn/PQHDxCto9sxwQfT30PzyYJFG0u/BWZGeVXi5Hx6uuUOQEI2Fa43qvmUjTRQNZnGqe9t0Zntexeuw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [darwin] - - '@tailwindcss/oxide-freebsd-x64@4.1.13': - resolution: {integrity: sha512-Wt8KvASHwSXhKE/dJLCCWcTSVmBj3xhVhp/aF3RpAhGeZ3sVo7+NTfgiN8Vey/Fi8prRClDs6/f0KXPDTZE6nQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [freebsd] - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13': - resolution: {integrity: sha512-mbVbcAsW3Gkm2MGwA93eLtWrwajz91aXZCNSkGTx/R5eb6KpKD5q8Ueckkh9YNboU8RH7jiv+ol/I7ZyQ9H7Bw==} - engines: {node: '>= 10'} - cpu: [arm] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.13': - resolution: {integrity: sha512-wdtfkmpXiwej/yoAkrCP2DNzRXCALq9NVLgLELgLim1QpSfhQM5+ZxQQF8fkOiEpuNoKLp4nKZ6RC4kmeFH0HQ==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-arm64-musl@4.1.13': - resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-gnu@4.1.13': - resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-linux-x64-musl@4.1.13': - resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==} - engines: {node: '>= 10'} - cpu: [x64] - os: [linux] - - '@tailwindcss/oxide-wasm32-wasi@4.1.13': - resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==} - engines: {node: '>=14.0.0'} - cpu: [wasm32] - bundledDependencies: - - '@napi-rs/wasm-runtime' - - '@emnapi/core' - - '@emnapi/runtime' - - '@tybys/wasm-util' - - '@emnapi/wasi-threads' - - tslib - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.13': - resolution: {integrity: sha512-dziTNeQXtoQ2KBXmrjCxsuPk3F3CQ/yb7ZNZNA+UkNTeiTGgfeh+gH5Pi7mRncVgcPD2xgHvkFCh/MhZWSgyQg==} - engines: {node: '>= 10'} - cpu: [arm64] - os: [win32] - - '@tailwindcss/oxide-win32-x64-msvc@4.1.13': - resolution: {integrity: sha512-3+LKesjXydTkHk5zXX01b5KMzLV1xl2mcktBJkje7rhFUpUlYJy7IMOLqjIRQncLTa1WZZiFY/foAeB5nmaiTw==} - engines: {node: '>= 10'} - cpu: [x64] - os: [win32] - - '@tailwindcss/oxide@4.1.13': - resolution: {integrity: sha512-CPgsM1IpGRa880sMbYmG1s4xhAy3xEt1QULgTJGQmZUeNgXFR7s1YxYygmJyBGtou4SyEosGAGEeYqY7R53bIA==} - engines: {node: '>= 10'} - - '@tailwindcss/vite@4.1.13': - resolution: {integrity: sha512-0PmqLQ010N58SbMTJ7BVJ4I2xopiQn/5i6nlb4JmxzQf8zcS5+m2Cv6tqh+sfDwtIdjoEnOvwsGQ1hkUi8QEHQ==} - peerDependencies: - vite: ^5.2.0 || ^6 || ^7 - - '@types/cookie@0.6.0': - resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} - - '@types/estree@1.0.8': - resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} - - '@types/json-schema@7.0.15': - resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} - - '@typescript-eslint/eslint-plugin@8.43.0': - resolution: {integrity: sha512-8tg+gt7ENL7KewsKMKDHXR1vm8tt9eMxjJBYINf6swonlWgkYn5NwyIgXpbbDxTNU5DgpDFfj95prcTq2clIQQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - '@typescript-eslint/parser': ^8.43.0 - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/parser@8.43.0': - resolution: {integrity: sha512-B7RIQiTsCBBmY+yW4+ILd6mF5h1FUwJsVvpqkrgpszYifetQ2Ke+Z4u6aZh0CblkUGIdR59iYVyXqqZGkZ3aBw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/project-service@8.43.0': - resolution: {integrity: sha512-htB/+D/BIGoNTQYffZw4uM4NzzuolCoaA/BusuSIcC8YjmBYQioew5VUZAYdAETPjeed0hqCaW7EHg+Robq8uw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/scope-manager@8.43.0': - resolution: {integrity: sha512-daSWlQ87ZhsjrbMLvpuuMAt3y4ba57AuvadcR7f3nl8eS3BjRc8L9VLxFLk92RL5xdXOg6IQ+qKjjqNEimGuAg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/tsconfig-utils@8.43.0': - resolution: {integrity: sha512-ALC2prjZcj2YqqL5X/bwWQmHA2em6/94GcbB/KKu5SX3EBDOsqztmmX1kMkvAJHzxk7TazKzJfFiEIagNV3qEA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/type-utils@8.43.0': - resolution: {integrity: sha512-qaH1uLBpBuBBuRf8c1mLJ6swOfzCXryhKND04Igr4pckzSEW9JX5Aw9AgW00kwfjWJF0kk0ps9ExKTfvXfw4Qg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/types@8.43.0': - resolution: {integrity: sha512-vQ2FZaxJpydjSZJKiSW/LJsabFFvV7KgLC5DiLhkBcykhQj8iK9BOaDmQt74nnKdLvceM5xmhaTF+pLekrxEkw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - '@typescript-eslint/typescript-estree@8.43.0': - resolution: {integrity: sha512-7Vv6zlAhPb+cvEpP06WXXy/ZByph9iL6BQRBDj4kmBsW98AqEeQHlj/13X+sZOrKSo9/rNKH4Ul4f6EICREFdw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/utils@8.43.0': - resolution: {integrity: sha512-S1/tEmkUeeswxd0GGcnwuVQPFWo8NzZTOMxCvw8BX7OMxnNae+i8Tm7REQen/SwUIPoPqfKn7EaZ+YLpiB3k9g==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - '@typescript-eslint/visitor-keys@8.43.0': - resolution: {integrity: sha512-T+S1KqRD4sg/bHfLwrpF/K3gQLBM1n7Rp7OjjikjTEssI2YJzQpi5WXoynOaQ93ERIuq3O8RBTOUYDKszUCEHw==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - acorn-jsx@5.3.2: - resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} - peerDependencies: - acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - - acorn@8.15.0: - resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==} - engines: {node: '>=0.4.0'} - hasBin: true - - ajv@6.12.6: - resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} - - ansi-styles@4.3.0: - resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} - engines: {node: '>=8'} - - argparse@2.0.1: - resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} - - aria-query@5.3.2: - resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} - engines: {node: '>= 0.4'} - - await-lock@2.2.2: - resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} - - axobject-query@4.1.0: - resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} - engines: {node: '>= 0.4'} - - balanced-match@1.0.2: - resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} - - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} - - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} - - braces@3.0.3: - resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} - engines: {node: '>=8'} - - callsites@3.1.0: - resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} - engines: {node: '>=6'} - - chalk@4.1.2: - resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} - engines: {node: '>=10'} - - chokidar@4.0.3: - resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} - engines: {node: '>= 14.16.0'} - - chownr@3.0.0: - resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} - engines: {node: '>=18'} - - clsx@2.1.1: - resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} - engines: {node: '>=6'} - - codemirror@6.0.2: - resolution: {integrity: sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==} - - color-convert@2.0.1: - resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} - engines: {node: '>=7.0.0'} - - color-name@1.1.4: - resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} - - component-emitter@1.3.1: - resolution: {integrity: sha512-T0+barUSQRTUQASh8bx02dl+DhF54GtIDY13Y3m9oWTklKbb3Wv974meRpeZ3lp1JpLVECWWNHC4vaG2XHXouQ==} - - concat-map@0.0.1: - resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - - cookie@0.6.0: - resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} - engines: {node: '>= 0.6'} - - crelt@1.0.6: - resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - - cssesc@3.0.0: - resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==} - engines: {node: '>=4'} - hasBin: true - - daisyui@5.1.10: - resolution: {integrity: sha512-p1J/HME2WmaSiy6u2alIbeP3gd5PNVft3+6Bdll0BRSm/UdI4084+pD01LxFug/5wGexNewWqbcEL6nB2n2o+Q==} - - debug@4.4.1: - resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - - deep-is@0.1.4: - resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} - - deepmerge@4.3.1: - resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} - engines: {node: '>=0.10.0'} - - detect-libc@2.0.4: - resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==} - engines: {node: '>=8'} - - devalue@5.3.2: - resolution: {integrity: sha512-UDsjUbpQn9kvm68slnrs+mfxwFkIflOhkanmyabZ8zOYk8SMEIbJ3TK+88g70hSIeytu4y18f0z/hYHMTrXIWw==} - - dexie@4.2.0: - resolution: {integrity: sha512-OSeyyWOUetDy9oFWeddJgi83OnRA3hSFh3RrbltmPgqHszE9f24eUCVLI4mPg0ifsWk0lQTdnS+jyGNrPMvhDA==} - - engine.io-client@6.6.4: - resolution: {integrity: sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==} - - engine.io-parser@5.2.3: - resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} - engines: {node: '>=10.0.0'} - - enhanced-resolve@5.18.3: - resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} - engines: {node: '>=10.13.0'} - - esbuild@0.25.9: - resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} - engines: {node: '>=18'} - hasBin: true - - escape-string-regexp@4.0.0: - resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} - engines: {node: '>=10'} - - eslint-config-prettier@10.1.8: - resolution: {integrity: sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==} - hasBin: true - peerDependencies: - eslint: '>=7.0.0' - - eslint-plugin-svelte@3.12.3: - resolution: {integrity: sha512-YVNhKsHZeXVvsjZcSMjnce9gO31frICu453p5JjFiXNszHoG9k8WvsA/LAoLi4K8T69G7DIrgg1AqasDJLpgoQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.1 || ^9.0.0 - svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 - peerDependenciesMeta: - svelte: - optional: true - - eslint-scope@8.4.0: - resolution: {integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint-visitor-keys@3.4.3: - resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - - eslint-visitor-keys@4.2.1: - resolution: {integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - eslint@9.35.0: - resolution: {integrity: sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - hasBin: true - peerDependencies: - jiti: '*' - peerDependenciesMeta: - jiti: - optional: true - - esm-env@1.2.2: - resolution: {integrity: sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==} - - espree@10.4.0: - resolution: {integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - - esquery@1.6.0: - resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} - engines: {node: '>=0.10'} - - esrap@2.1.0: - resolution: {integrity: sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==} - - esrecurse@4.3.0: - resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} - engines: {node: '>=4.0'} - - estraverse@5.3.0: - resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} - engines: {node: '>=4.0'} - - esutils@2.0.3: - resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} - engines: {node: '>=0.10.0'} - - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-glob@3.3.3: - resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} - engines: {node: '>=8.6.0'} - - fast-json-stable-stringify@2.1.0: - resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} - - fast-levenshtein@2.0.6: - resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} - - fastq@1.19.1: - resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} - - fdir@6.5.0: - resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} - engines: {node: '>=12.0.0'} - peerDependencies: - picomatch: ^3 || ^4 - peerDependenciesMeta: - picomatch: - optional: true - - file-entry-cache@8.0.0: - resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} - engines: {node: '>=16.0.0'} - - fill-range@7.1.1: - resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} - engines: {node: '>=8'} - - find-up@5.0.0: - resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} - engines: {node: '>=10'} - - flat-cache@4.0.1: - resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} - engines: {node: '>=16'} - - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} - - fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} - engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} - os: [darwin] - - glob-parent@5.1.2: - resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} - engines: {node: '>= 6'} - - glob-parent@6.0.2: - resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} - engines: {node: '>=10.13.0'} - - globals@14.0.0: - resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} - engines: {node: '>=18'} - - globals@16.4.0: - resolution: {integrity: sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==} - engines: {node: '>=18'} - - graceful-fs@4.2.11: - resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - - graphemer@1.4.0: - resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} - - has-flag@4.0.0: - resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} - engines: {node: '>=8'} - - ignore@5.3.2: - resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} - engines: {node: '>= 4'} - - ignore@7.0.5: - resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} - engines: {node: '>= 4'} - - import-fresh@3.3.1: - resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==} - engines: {node: '>=6'} - - imurmurhash@0.1.4: - resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} - engines: {node: '>=0.8.19'} - - is-extglob@2.1.1: - resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} - engines: {node: '>=0.10.0'} - - is-glob@4.0.3: - resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} - engines: {node: '>=0.10.0'} - - is-number@7.0.0: - resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} - engines: {node: '>=0.12.0'} - - is-reference@3.0.3: - resolution: {integrity: sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==} - - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - - iso-datestring-validator@2.2.2: - resolution: {integrity: sha512-yLEMkBbLZTlVQqOnQ4FiMujR6T4DEcCb1xizmvXS+OxuhwcbtynoosRzdMA69zZCShCNAbi+gJ71FxZBBXx1SA==} - - jiti@2.5.1: - resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} - hasBin: true - - jose@5.10.0: - resolution: {integrity: sha512-s+3Al/p9g32Iq+oqXxkW//7jk2Vig6FF1CFqzVXoTUXt2qz89YWbL+OwS17NFYEvxC35n0FKeGO2LGYSxeM2Gg==} - - js-yaml@4.1.0: - resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} - hasBin: true - - json-buffer@3.0.1: - resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} - - json-schema-traverse@0.4.1: - resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} - - json-stable-stringify-without-jsonify@1.0.1: - resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} - - keyv@4.5.4: - resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} - - kleur@4.1.5: - resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} - engines: {node: '>=6'} - - known-css-properties@0.37.0: - resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} - - layerr@3.0.0: - resolution: {integrity: sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==} - - levn@0.4.1: - resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} - engines: {node: '>= 0.8.0'} - - lightningcss-darwin-arm64@1.30.1: - resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [darwin] - - lightningcss-darwin-x64@1.30.1: - resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [darwin] - - lightningcss-freebsd-x64@1.30.1: - resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [freebsd] - - lightningcss-linux-arm-gnueabihf@1.30.1: - resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==} - engines: {node: '>= 12.0.0'} - cpu: [arm] - os: [linux] - - lightningcss-linux-arm64-gnu@1.30.1: - resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-arm64-musl@1.30.1: - resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [linux] - - lightningcss-linux-x64-gnu@1.30.1: - resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-linux-x64-musl@1.30.1: - resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [linux] - - lightningcss-win32-arm64-msvc@1.30.1: - resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} - engines: {node: '>= 12.0.0'} - cpu: [arm64] - os: [win32] - - lightningcss-win32-x64-msvc@1.30.1: - resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==} - engines: {node: '>= 12.0.0'} - cpu: [x64] - os: [win32] - - lightningcss@1.30.1: - resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==} - engines: {node: '>= 12.0.0'} - - lilconfig@2.1.0: - resolution: {integrity: sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==} - engines: {node: '>=10'} - - locate-character@3.0.0: - resolution: {integrity: sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==} - - locate-path@6.0.0: - resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} - engines: {node: '>=10'} - - lodash.merge@4.6.2: - resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} - - lru-cache@10.4.3: - resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} - - magic-string@0.30.19: - resolution: {integrity: sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw==} - - merge2@1.4.1: - resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} - engines: {node: '>= 8'} - - micromatch@4.0.8: - resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} - engines: {node: '>=8.6'} - - minimatch@3.1.2: - resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} - - minimatch@9.0.5: - resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} - engines: {node: '>=16 || 14 >=14.17'} - - minipass@7.1.2: - resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} - engines: {node: '>=16 || 14 >=14.17'} - - minizlib@3.0.2: - resolution: {integrity: sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==} - engines: {node: '>= 18'} - - mkdirp@3.0.1: - resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==} - engines: {node: '>=10'} - hasBin: true - - mri@1.2.0: - resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} - engines: {node: '>=4'} - - mrmime@2.0.1: - resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} - engines: {node: '>=10'} - - ms@2.1.3: - resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - - multiformats@9.9.0: - resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} - - nanoid@3.3.11: - resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - - natural-compare@1.4.0: - resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - - notepack.io@2.2.0: - resolution: {integrity: sha512-9b5w3t5VSH6ZPosoYnyDONnUTF8o0UkBw7JLA6eBlYJWyGT1Q3vQa8Hmuj1/X6RYvHjjygBDgw6fJhe0JEojfw==} - - optionator@0.9.4: - resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} - engines: {node: '>= 0.8.0'} - - p-limit@3.1.0: - resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} - engines: {node: '>=10'} - - p-locate@5.0.0: - resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} - engines: {node: '>=10'} - - parent-module@1.0.1: - resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} - engines: {node: '>=6'} - - path-exists@4.0.0: - resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} - engines: {node: '>=8'} - - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - - picocolors@1.1.1: - resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} - engines: {node: '>=8.6'} - - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} - engines: {node: '>=12'} - - postcss-load-config@3.1.4: - resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==} - engines: {node: '>= 10'} - peerDependencies: - postcss: '>=8.0.9' - ts-node: '>=9.0.0' - peerDependenciesMeta: - postcss: - optional: true - ts-node: - optional: true - - postcss-safe-parser@7.0.1: - resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} - engines: {node: '>=18.0'} - peerDependencies: - postcss: ^8.4.31 - - postcss-scss@4.0.9: - resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} - engines: {node: '>=12.0'} - peerDependencies: - postcss: ^8.4.29 - - postcss-selector-parser@7.1.0: - resolution: {integrity: sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==} - engines: {node: '>=4'} - - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} - engines: {node: ^10 || ^12 || >=14} - - prelude-ls@1.2.1: - resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} - engines: {node: '>= 0.8.0'} - - prettier-plugin-svelte@3.4.0: - resolution: {integrity: sha512-pn1ra/0mPObzqoIQn/vUTR3ZZI6UuZ0sHqMK5x2jMLGrs53h0sXhkVuDcrlssHwIMk7FYrMjHBPoUSyyEEDlBQ==} - peerDependencies: - prettier: ^3.0.0 - svelte: ^3.2.0 || ^4.0.0-next.0 || ^5.0.0-next.0 - - prettier-plugin-tailwindcss@0.6.14: - resolution: {integrity: sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==} - engines: {node: '>=14.21.3'} - peerDependencies: - '@ianvs/prettier-plugin-sort-imports': '*' - '@prettier/plugin-hermes': '*' - '@prettier/plugin-oxc': '*' - '@prettier/plugin-pug': '*' - '@shopify/prettier-plugin-liquid': '*' - '@trivago/prettier-plugin-sort-imports': '*' - '@zackad/prettier-plugin-twig': '*' - prettier: ^3.0 - prettier-plugin-astro: '*' - prettier-plugin-css-order: '*' - prettier-plugin-import-sort: '*' - prettier-plugin-jsdoc: '*' - prettier-plugin-marko: '*' - prettier-plugin-multiline-arrays: '*' - prettier-plugin-organize-attributes: '*' - prettier-plugin-organize-imports: '*' - prettier-plugin-sort-imports: '*' - prettier-plugin-style-order: '*' - prettier-plugin-svelte: '*' - peerDependenciesMeta: - '@ianvs/prettier-plugin-sort-imports': - optional: true - '@prettier/plugin-hermes': - optional: true - '@prettier/plugin-oxc': - optional: true - '@prettier/plugin-pug': - optional: true - '@shopify/prettier-plugin-liquid': - optional: true - '@trivago/prettier-plugin-sort-imports': - optional: true - '@zackad/prettier-plugin-twig': - optional: true - prettier-plugin-astro: - optional: true - prettier-plugin-css-order: - optional: true - prettier-plugin-import-sort: - optional: true - prettier-plugin-jsdoc: - optional: true - prettier-plugin-marko: - optional: true - prettier-plugin-multiline-arrays: - optional: true - prettier-plugin-organize-attributes: - optional: true - prettier-plugin-organize-imports: - optional: true - prettier-plugin-sort-imports: - optional: true - prettier-plugin-style-order: - optional: true - prettier-plugin-svelte: - optional: true - - prettier@3.6.2: - resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==} - engines: {node: '>=14'} - hasBin: true - - punycode@2.3.1: - resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} - engines: {node: '>=6'} - - queue-microtask@1.2.3: - resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - - readdirp@4.1.2: - resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} - engines: {node: '>= 14.18.0'} - - resolve-from@4.0.0: - resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} - engines: {node: '>=4'} - - reusify@1.1.0: - resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} - engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - - rollup@4.50.1: - resolution: {integrity: sha512-78E9voJHwnXQMiQdiqswVLZwJIzdBKJ1GdI5Zx6XwoFKUIk09/sSrr+05QFzvYb8q6Y9pPV45zzDuYa3907TZA==} - engines: {node: '>=18.0.0', npm: '>=8.0.0'} - hasBin: true - - run-parallel@1.2.0: - resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - - sade@1.8.1: - resolution: {integrity: sha512-xal3CZX1Xlo/k4ApwCFrHVACi9fBqJ7V+mwhBsuf/1IOKbBy098Fex+Wa/5QMubw09pSZ/u8EY8PWgevJsXp1A==} - engines: {node: '>=6'} - - semver@7.7.2: - resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==} - engines: {node: '>=10'} - hasBin: true - - set-cookie-parser@2.7.1: - resolution: {integrity: sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==} - - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - - sirv@3.0.2: - resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} - engines: {node: '>=18'} - - socket.io-client@4.8.3: - resolution: {integrity: sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==} - engines: {node: '>=10.0.0'} - - socket.io-msgpack-parser@3.0.2: - resolution: {integrity: sha512-1e76bJ1PCKi9H+JiYk+S29PBJvknHjQWM7Mtj0hjF2KxDA6b6rQxv3rTsnwBoz/haZOhlCDIMQvPATbqYeuMxg==} - - socket.io-parser@4.2.5: - resolution: {integrity: sha512-bPMmpy/5WWKHea5Y/jYAP6k74A+hvmRCQaJuJB6I/ML5JZq/KfNieUVo/3Mh7SAqn7TyFdIo6wqYHInG1MU1bQ==} - engines: {node: '>=10.0.0'} - - source-map-js@1.2.1: - resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} - engines: {node: '>=0.10.0'} - - strip-json-comments@3.1.1: - resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} - engines: {node: '>=8'} - - style-mod@4.1.3: - resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==} - - supports-color@7.2.0: - resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} - engines: {node: '>=8'} - - svelte-check@4.3.1: - resolution: {integrity: sha512-lkh8gff5gpHLjxIV+IaApMxQhTGnir2pNUAqcNgeKkvK5bT/30Ey/nzBxNLDlkztCH4dP7PixkMt9SWEKFPBWg==} - engines: {node: '>= 18.0.0'} - hasBin: true - peerDependencies: - svelte: ^4.0.0 || ^5.0.0-next.0 - typescript: '>=5.0.0' - - svelte-codemirror-editor@2.1.0: - resolution: {integrity: sha512-WGkSsIYNpVcOVxaQPkmdBQhaGyKLmg6pgaS/b+7guRb4eikrbXYtvNFuW2AzKJi8ZbLhSngrf3SRiZOuwuskrQ==} - peerDependencies: - codemirror: ^6.0.0 - svelte: ^5.0.0 - - svelte-eslint-parser@1.3.2: - resolution: {integrity: sha512-whla4VlUbwJidn/bNyC3Ho3pBrXnR2CBEkuJwtaURW+wfwgKHPaYtZAmwAkp6HWWKCw1ILZL6iKsFdVY11rpDA==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - svelte: ^3.37.0 || ^4.0.0 || ^5.0.0 - peerDependenciesMeta: - svelte: - optional: true - - svelte@5.38.10: - resolution: {integrity: sha512-UY+OhrWK7WI22bCZ00P/M3HtyWgwJPi9IxSRkoAE2MeAy6kl7ZlZWJZ8RaB+X4KD/G+wjis+cGVnVYaoqbzBqg==} - engines: {node: '>=18'} - - tailwindcss@4.1.13: - resolution: {integrity: sha512-i+zidfmTqtwquj4hMEwdjshYYgMbOrPzb9a0M3ZgNa0JMoZeFC6bxZvO8yr8ozS6ix2SDz0+mvryPeBs2TFE+w==} - - tapable@2.2.3: - resolution: {integrity: sha512-ZL6DDuAlRlLGghwcfmSn9sK3Hr6ArtyudlSAiCqQ6IfE+b+HHbydbYDIG15IfS5do+7XQQBdBiubF/cV2dnDzg==} - engines: {node: '>=6'} - - tar@7.4.3: - resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} - engines: {node: '>=18'} - - tinyglobby@0.2.15: - resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} - engines: {node: '>=12.0.0'} - - tlds@1.260.0: - resolution: {integrity: sha512-78+28EWBhCEE7qlyaHA9OR3IPvbCLiDh3Ckla593TksfFc9vfTsgvH7eS+dr3o9qr31gwGbogcI16yN91PoRjQ==} - hasBin: true - - to-regex-range@5.0.1: - resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} - engines: {node: '>=8.0'} - - totalist@3.0.1: - resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} - engines: {node: '>=6'} - - ts-api-utils@2.1.0: - resolution: {integrity: sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==} - engines: {node: '>=18.12'} - peerDependencies: - typescript: '>=4.8.4' - - type-check@0.4.0: - resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} - engines: {node: '>= 0.8.0'} - - typescript-eslint@8.43.0: - resolution: {integrity: sha512-FyRGJKUGvcFekRRcBKFBlAhnp4Ng8rhe8tuvvkR9OiU0gfd4vyvTRQHEckO6VDlH57jbeUQem2IpqPq9kLJH+w==} - engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - peerDependencies: - eslint: ^8.57.0 || ^9.0.0 - typescript: '>=4.8.4 <6.0.0' - - typescript@5.9.2: - resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==} - engines: {node: '>=14.17'} - hasBin: true - - uint8arrays@3.0.0: - resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} - - ulidx@2.4.1: - resolution: {integrity: sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==} - engines: {node: '>=16'} - - uri-js@4.4.1: - resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} - - util-deprecate@1.0.2: - resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - - vite@7.1.5: - resolution: {integrity: sha512-4cKBO9wR75r0BeIWWWId9XK9Lj6La5X846Zw9dFfzMRw38IlTk2iCcUt6hsyiDRcPidc55ZParFYDXi0nXOeLQ==} - engines: {node: ^20.19.0 || >=22.12.0} - hasBin: true - peerDependencies: - '@types/node': ^20.19.0 || >=22.12.0 - jiti: '>=1.21.0' - less: ^4.0.0 - lightningcss: ^1.21.0 - sass: ^1.70.0 - sass-embedded: ^1.70.0 - stylus: '>=0.54.8' - sugarss: ^5.0.0 - terser: ^5.16.0 - tsx: ^4.8.1 - yaml: ^2.4.2 - peerDependenciesMeta: - '@types/node': - optional: true - jiti: - optional: true - less: - optional: true - lightningcss: - optional: true - sass: - optional: true - sass-embedded: - optional: true - stylus: - optional: true - sugarss: - optional: true - terser: - optional: true - tsx: - optional: true - yaml: - optional: true - - vitefu@1.1.1: - resolution: {integrity: sha512-B/Fegf3i8zh0yFbpzZ21amWzHmuNlLlmJT6n7bu5e+pCHUKQIfXSYokrqOBGEMMe9UG2sostKQF9mml/vYaWJQ==} - peerDependencies: - vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 - peerDependenciesMeta: - vite: - optional: true - - w3c-keyname@2.2.8: - resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - - word-wrap@1.2.5: - resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} - engines: {node: '>=0.10.0'} - - ws@8.18.3: - resolution: {integrity: sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==} - engines: {node: '>=10.0.0'} - peerDependencies: - bufferutil: ^4.0.1 - utf-8-validate: '>=5.0.2' - peerDependenciesMeta: - bufferutil: - optional: true - utf-8-validate: - optional: true - - xmlhttprequest-ssl@2.1.2: - resolution: {integrity: sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==} - engines: {node: '>=0.4.0'} - - yallist@5.0.0: - resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} - engines: {node: '>=18'} - - yaml@1.10.2: - resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} - engines: {node: '>= 6'} - - yocto-queue@0.1.0: - resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} - engines: {node: '>=10'} - - zimmerframe@1.1.4: - resolution: {integrity: sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==} - - zod@3.25.76: - resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} - -snapshots: - - '@atcute/cbor@2.2.8': - dependencies: - '@atcute/cid': 2.2.6 - '@atcute/multibase': 1.1.6 - '@atcute/uint8array': 1.0.6 - - '@atcute/cid@2.2.6': - dependencies: - '@atcute/multibase': 1.1.6 - '@atcute/uint8array': 1.0.6 - - '@atcute/multibase@1.1.6': - dependencies: - '@atcute/uint8array': 1.0.6 - - '@atcute/uint8array@1.0.6': {} - - '@atproto-labs/did-resolver@0.2.1': - dependencies: - '@atproto-labs/fetch': 0.2.3 - '@atproto-labs/pipe': 0.1.1 - '@atproto-labs/simple-store': 0.3.0 - '@atproto-labs/simple-store-memory': 0.1.4 - '@atproto/did': 0.2.0 - zod: 3.25.76 - - '@atproto-labs/fetch@0.2.3': - dependencies: - '@atproto-labs/pipe': 0.1.1 - - '@atproto-labs/handle-resolver@0.3.1': - dependencies: - '@atproto-labs/simple-store': 0.3.0 - '@atproto-labs/simple-store-memory': 0.1.4 - '@atproto/did': 0.2.0 - zod: 3.25.76 - - '@atproto-labs/identity-resolver@0.3.1': - dependencies: - '@atproto-labs/did-resolver': 0.2.1 - '@atproto-labs/handle-resolver': 0.3.1 - - '@atproto-labs/pipe@0.1.1': {} - - '@atproto-labs/simple-store-memory@0.1.4': - dependencies: - '@atproto-labs/simple-store': 0.3.0 - lru-cache: 10.4.3 - - '@atproto-labs/simple-store@0.3.0': {} - - '@atproto/api@0.16.9': - dependencies: - '@atproto/common-web': 0.4.3 - '@atproto/lexicon': 0.5.1 - '@atproto/syntax': 0.4.1 - '@atproto/xrpc': 0.7.5 - await-lock: 2.2.2 - multiformats: 9.9.0 - tlds: 1.260.0 - zod: 3.25.76 - - '@atproto/common-web@0.4.3': - dependencies: - graphemer: 1.4.0 - multiformats: 9.9.0 - uint8arrays: 3.0.0 - zod: 3.25.76 - - '@atproto/did@0.2.0': - dependencies: - zod: 3.25.76 - - '@atproto/jwk-jose@0.1.10': - dependencies: - '@atproto/jwk': 0.5.0 - jose: 5.10.0 - - '@atproto/jwk-webcrypto@0.1.10': - dependencies: - '@atproto/jwk': 0.5.0 - '@atproto/jwk-jose': 0.1.10 - zod: 3.25.76 - - '@atproto/jwk@0.5.0': - dependencies: - multiformats: 9.9.0 - zod: 3.25.76 - - '@atproto/lexicon@0.5.1': - dependencies: - '@atproto/common-web': 0.4.3 - '@atproto/syntax': 0.4.1 - iso-datestring-validator: 2.2.2 - multiformats: 9.9.0 - zod: 3.25.76 - - '@atproto/oauth-client-browser@0.3.32': - dependencies: - '@atproto-labs/did-resolver': 0.2.1 - '@atproto-labs/handle-resolver': 0.3.1 - '@atproto-labs/simple-store': 0.3.0 - '@atproto/did': 0.2.0 - '@atproto/jwk': 0.5.0 - '@atproto/jwk-webcrypto': 0.1.10 - '@atproto/oauth-client': 0.5.6 - '@atproto/oauth-types': 0.4.1 - - '@atproto/oauth-client@0.5.6': - dependencies: - '@atproto-labs/did-resolver': 0.2.1 - '@atproto-labs/fetch': 0.2.3 - '@atproto-labs/handle-resolver': 0.3.1 - '@atproto-labs/identity-resolver': 0.3.1 - '@atproto-labs/simple-store': 0.3.0 - '@atproto-labs/simple-store-memory': 0.1.4 - '@atproto/did': 0.2.0 - '@atproto/jwk': 0.5.0 - '@atproto/oauth-types': 0.4.1 - '@atproto/xrpc': 0.7.5 - multiformats: 9.9.0 - zod: 3.25.76 - - '@atproto/oauth-types@0.4.1': - dependencies: - '@atproto/jwk': 0.5.0 - zod: 3.25.76 - - '@atproto/syntax@0.4.1': {} - - '@atproto/xrpc@0.7.5': - dependencies: - '@atproto/lexicon': 0.5.1 - zod: 3.25.76 - - '@codemirror/autocomplete@6.19.1': - dependencies: - '@codemirror/language': 6.11.3 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/common': 1.3.0 - - '@codemirror/commands@6.10.0': - dependencies: - '@codemirror/language': 6.11.3 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/common': 1.3.0 - - '@codemirror/lang-json@6.0.2': - dependencies: - '@codemirror/language': 6.11.3 - '@lezer/json': 1.0.3 - - '@codemirror/lang-sql@6.10.0': - dependencies: - '@codemirror/autocomplete': 6.19.1 - '@codemirror/language': 6.11.3 - '@codemirror/state': 6.5.2 - '@lezer/common': 1.3.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 - - '@codemirror/language@6.11.3': - dependencies: - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/common': 1.3.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 - style-mod: 4.1.3 - - '@codemirror/lint@6.9.2': - dependencies: - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - crelt: 1.0.6 - - '@codemirror/search@6.5.11': - dependencies: - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - crelt: 1.0.6 - - '@codemirror/state@6.5.2': - dependencies: - '@marijn/find-cluster-break': 1.0.2 - - '@codemirror/theme-one-dark@6.1.3': - dependencies: - '@codemirror/language': 6.11.3 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - '@lezer/highlight': 1.2.3 - - '@codemirror/view@6.38.6': - dependencies: - '@codemirror/state': 6.5.2 - crelt: 1.0.6 - style-mod: 4.1.3 - w3c-keyname: 2.2.8 - - '@esbuild/aix-ppc64@0.25.9': - optional: true - - '@esbuild/android-arm64@0.25.9': - optional: true - - '@esbuild/android-arm@0.25.9': - optional: true - - '@esbuild/android-x64@0.25.9': - optional: true - - '@esbuild/darwin-arm64@0.25.9': - optional: true - - '@esbuild/darwin-x64@0.25.9': - optional: true - - '@esbuild/freebsd-arm64@0.25.9': - optional: true - - '@esbuild/freebsd-x64@0.25.9': - optional: true - - '@esbuild/linux-arm64@0.25.9': - optional: true - - '@esbuild/linux-arm@0.25.9': - optional: true - - '@esbuild/linux-ia32@0.25.9': - optional: true - - '@esbuild/linux-loong64@0.25.9': - optional: true - - '@esbuild/linux-mips64el@0.25.9': - optional: true - - '@esbuild/linux-ppc64@0.25.9': - optional: true - - '@esbuild/linux-riscv64@0.25.9': - optional: true - - '@esbuild/linux-s390x@0.25.9': - optional: true - - '@esbuild/linux-x64@0.25.9': - optional: true - - '@esbuild/netbsd-arm64@0.25.9': - optional: true - - '@esbuild/netbsd-x64@0.25.9': - optional: true - - '@esbuild/openbsd-arm64@0.25.9': - optional: true - - '@esbuild/openbsd-x64@0.25.9': - optional: true - - '@esbuild/openharmony-arm64@0.25.9': - optional: true - - '@esbuild/sunos-x64@0.25.9': - optional: true - - '@esbuild/win32-arm64@0.25.9': - optional: true - - '@esbuild/win32-ia32@0.25.9': - optional: true - - '@esbuild/win32-x64@0.25.9': - optional: true - - '@eslint-community/eslint-utils@4.9.0(eslint@9.35.0(jiti@2.5.1))': - dependencies: - eslint: 9.35.0(jiti@2.5.1) - eslint-visitor-keys: 3.4.3 - - '@eslint-community/regexpp@4.12.1': {} - - '@eslint/compat@1.3.2(eslint@9.35.0(jiti@2.5.1))': - optionalDependencies: - eslint: 9.35.0(jiti@2.5.1) - - '@eslint/config-array@0.21.0': - dependencies: - '@eslint/object-schema': 2.1.6 - debug: 4.4.1 - minimatch: 3.1.2 - transitivePeerDependencies: - - supports-color - - '@eslint/config-helpers@0.3.1': {} - - '@eslint/core@0.15.2': - dependencies: - '@types/json-schema': 7.0.15 - - '@eslint/eslintrc@3.3.1': - dependencies: - ajv: 6.12.6 - debug: 4.4.1 - espree: 10.4.0 - globals: 14.0.0 - ignore: 5.3.2 - import-fresh: 3.3.1 - js-yaml: 4.1.0 - minimatch: 3.1.2 - strip-json-comments: 3.1.1 - transitivePeerDependencies: - - supports-color - - '@eslint/js@9.35.0': {} - - '@eslint/object-schema@2.1.6': {} - - '@eslint/plugin-kit@0.3.5': - dependencies: - '@eslint/core': 0.15.2 - levn: 0.4.1 - - '@humanfs/core@0.19.1': {} - - '@humanfs/node@0.16.7': - dependencies: - '@humanfs/core': 0.19.1 - '@humanwhocodes/retry': 0.4.3 - - '@humanwhocodes/module-importer@1.0.1': {} - - '@humanwhocodes/retry@0.4.3': {} - - '@isaacs/fs-minipass@4.0.1': - dependencies: - minipass: 7.1.2 - - '@jridgewell/gen-mapping@0.3.13': - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/remapping@2.3.5': - dependencies: - '@jridgewell/gen-mapping': 0.3.13 - '@jridgewell/trace-mapping': 0.3.31 - - '@jridgewell/resolve-uri@3.1.2': {} - - '@jridgewell/sourcemap-codec@1.5.5': {} - - '@jridgewell/trace-mapping@0.3.31': - dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 - - '@lezer/common@1.3.0': {} - - '@lezer/highlight@1.2.3': - dependencies: - '@lezer/common': 1.3.0 - - '@lezer/json@1.0.3': - dependencies: - '@lezer/common': 1.3.0 - '@lezer/highlight': 1.2.3 - '@lezer/lr': 1.4.3 - - '@lezer/lr@1.4.3': - dependencies: - '@lezer/common': 1.3.0 - - '@marijn/find-cluster-break@1.0.2': {} - - '@muni-town/leaf-client@0.1.0-alpha.17': - dependencies: - '@atcute/cbor': 2.2.8 - '@atcute/cid': 2.2.6 - socket.io-client: 4.8.3 - socket.io-msgpack-parser: 3.0.2 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - '@nodelib/fs.scandir@2.1.5': - dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 - - '@nodelib/fs.stat@2.0.5': {} - - '@nodelib/fs.walk@1.2.8': - dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.19.1 - - '@polka/url@1.0.0-next.29': {} - - '@rollup/rollup-android-arm-eabi@4.50.1': - optional: true - - '@rollup/rollup-android-arm64@4.50.1': - optional: true - - '@rollup/rollup-darwin-arm64@4.50.1': - optional: true - - '@rollup/rollup-darwin-x64@4.50.1': - optional: true - - '@rollup/rollup-freebsd-arm64@4.50.1': - optional: true - - '@rollup/rollup-freebsd-x64@4.50.1': - optional: true - - '@rollup/rollup-linux-arm-gnueabihf@4.50.1': - optional: true - - '@rollup/rollup-linux-arm-musleabihf@4.50.1': - optional: true - - '@rollup/rollup-linux-arm64-gnu@4.50.1': - optional: true - - '@rollup/rollup-linux-arm64-musl@4.50.1': - optional: true - - '@rollup/rollup-linux-loongarch64-gnu@4.50.1': - optional: true - - '@rollup/rollup-linux-ppc64-gnu@4.50.1': - optional: true - - '@rollup/rollup-linux-riscv64-gnu@4.50.1': - optional: true - - '@rollup/rollup-linux-riscv64-musl@4.50.1': - optional: true - - '@rollup/rollup-linux-s390x-gnu@4.50.1': - optional: true - - '@rollup/rollup-linux-x64-gnu@4.50.1': - optional: true - - '@rollup/rollup-linux-x64-musl@4.50.1': - optional: true - - '@rollup/rollup-openharmony-arm64@4.50.1': - optional: true - - '@rollup/rollup-win32-arm64-msvc@4.50.1': - optional: true - - '@rollup/rollup-win32-ia32-msvc@4.50.1': - optional: true - - '@rollup/rollup-win32-x64-msvc@4.50.1': - optional: true - - '@socket.io/component-emitter@3.1.2': {} - - '@standard-schema/spec@1.0.0': {} - - '@sveltejs/acorn-typescript@1.0.5(acorn@8.15.0)': - dependencies: - acorn: 8.15.0 - - '@sveltejs/adapter-static@3.0.9(@sveltejs/kit@2.39.1(@sveltejs/vite-plugin-svelte@6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)))': - dependencies: - '@sveltejs/kit': 2.39.1(@sveltejs/vite-plugin-svelte@6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)) - - '@sveltejs/kit@2.39.1(@sveltejs/vite-plugin-svelte@6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1))': - dependencies: - '@standard-schema/spec': 1.0.0 - '@sveltejs/acorn-typescript': 1.0.5(acorn@8.15.0) - '@sveltejs/vite-plugin-svelte': 6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)) - '@types/cookie': 0.6.0 - acorn: 8.15.0 - cookie: 0.6.0 - devalue: 5.3.2 - esm-env: 1.2.2 - kleur: 4.1.5 - magic-string: 0.30.19 - mrmime: 2.0.1 - sade: 1.8.1 - set-cookie-parser: 2.7.1 - sirv: 3.0.2 - svelte: 5.38.10 - vite: 7.1.5(jiti@2.5.1)(lightningcss@1.30.1) - - '@sveltejs/vite-plugin-svelte-inspector@5.0.1(@sveltejs/vite-plugin-svelte@6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1))': - dependencies: - '@sveltejs/vite-plugin-svelte': 6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)) - debug: 4.4.1 - svelte: 5.38.10 - vite: 7.1.5(jiti@2.5.1)(lightningcss@1.30.1) - transitivePeerDependencies: - - supports-color - - '@sveltejs/vite-plugin-svelte@6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1))': - dependencies: - '@sveltejs/vite-plugin-svelte-inspector': 5.0.1(@sveltejs/vite-plugin-svelte@6.2.0(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)))(svelte@5.38.10)(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)) - debug: 4.4.1 - deepmerge: 4.3.1 - magic-string: 0.30.19 - svelte: 5.38.10 - vite: 7.1.5(jiti@2.5.1)(lightningcss@1.30.1) - vitefu: 1.1.1(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)) - transitivePeerDependencies: - - supports-color - - '@tailwindcss/node@4.1.13': - dependencies: - '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.18.3 - jiti: 2.5.1 - lightningcss: 1.30.1 - magic-string: 0.30.19 - source-map-js: 1.2.1 - tailwindcss: 4.1.13 - - '@tailwindcss/oxide-android-arm64@4.1.13': - optional: true - - '@tailwindcss/oxide-darwin-arm64@4.1.13': - optional: true - - '@tailwindcss/oxide-darwin-x64@4.1.13': - optional: true - - '@tailwindcss/oxide-freebsd-x64@4.1.13': - optional: true - - '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.13': - optional: true - - '@tailwindcss/oxide-linux-arm64-gnu@4.1.13': - optional: true - - '@tailwindcss/oxide-linux-arm64-musl@4.1.13': - optional: true - - '@tailwindcss/oxide-linux-x64-gnu@4.1.13': - optional: true - - '@tailwindcss/oxide-linux-x64-musl@4.1.13': - optional: true - - '@tailwindcss/oxide-wasm32-wasi@4.1.13': - optional: true - - '@tailwindcss/oxide-win32-arm64-msvc@4.1.13': - optional: true - - '@tailwindcss/oxide-win32-x64-msvc@4.1.13': - optional: true - - '@tailwindcss/oxide@4.1.13': - dependencies: - detect-libc: 2.0.4 - tar: 7.4.3 - optionalDependencies: - '@tailwindcss/oxide-android-arm64': 4.1.13 - '@tailwindcss/oxide-darwin-arm64': 4.1.13 - '@tailwindcss/oxide-darwin-x64': 4.1.13 - '@tailwindcss/oxide-freebsd-x64': 4.1.13 - '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.13 - '@tailwindcss/oxide-linux-arm64-gnu': 4.1.13 - '@tailwindcss/oxide-linux-arm64-musl': 4.1.13 - '@tailwindcss/oxide-linux-x64-gnu': 4.1.13 - '@tailwindcss/oxide-linux-x64-musl': 4.1.13 - '@tailwindcss/oxide-wasm32-wasi': 4.1.13 - '@tailwindcss/oxide-win32-arm64-msvc': 4.1.13 - '@tailwindcss/oxide-win32-x64-msvc': 4.1.13 - - '@tailwindcss/vite@4.1.13(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1))': - dependencies: - '@tailwindcss/node': 4.1.13 - '@tailwindcss/oxide': 4.1.13 - tailwindcss: 4.1.13 - vite: 7.1.5(jiti@2.5.1)(lightningcss@1.30.1) - - '@types/cookie@0.6.0': {} - - '@types/estree@1.0.8': {} - - '@types/json-schema@7.0.15': {} - - '@typescript-eslint/eslint-plugin@8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': - dependencies: - '@eslint-community/regexpp': 4.12.1 - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/type-utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/visitor-keys': 8.43.0 - eslint: 9.35.0(jiti@2.5.1) - graphemer: 1.4.0 - ignore: 7.0.5 - natural-compare: 1.4.0 - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': - dependencies: - '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/visitor-keys': 8.43.0 - debug: 4.4.1 - eslint: 9.35.0(jiti@2.5.1) - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/project-service@8.43.0(typescript@5.9.2)': - dependencies: - '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2) - '@typescript-eslint/types': 8.43.0 - debug: 4.4.1 - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/scope-manager@8.43.0': - dependencies: - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/visitor-keys': 8.43.0 - - '@typescript-eslint/tsconfig-utils@8.43.0(typescript@5.9.2)': - dependencies: - typescript: 5.9.2 - - '@typescript-eslint/type-utils@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': - dependencies: - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - debug: 4.4.1 - eslint: 9.35.0(jiti@2.5.1) - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/types@8.43.0': {} - - '@typescript-eslint/typescript-estree@8.43.0(typescript@5.9.2)': - dependencies: - '@typescript-eslint/project-service': 8.43.0(typescript@5.9.2) - '@typescript-eslint/tsconfig-utils': 8.43.0(typescript@5.9.2) - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/visitor-keys': 8.43.0 - debug: 4.4.1 - fast-glob: 3.3.3 - is-glob: 4.0.3 - minimatch: 9.0.5 - semver: 7.7.2 - ts-api-utils: 2.1.0(typescript@5.9.2) - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/utils@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2)': - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1)) - '@typescript-eslint/scope-manager': 8.43.0 - '@typescript-eslint/types': 8.43.0 - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/visitor-keys@8.43.0': - dependencies: - '@typescript-eslint/types': 8.43.0 - eslint-visitor-keys: 4.2.1 - - acorn-jsx@5.3.2(acorn@8.15.0): - dependencies: - acorn: 8.15.0 - - acorn@8.15.0: {} - - ajv@6.12.6: - dependencies: - fast-deep-equal: 3.1.3 - fast-json-stable-stringify: 2.1.0 - json-schema-traverse: 0.4.1 - uri-js: 4.4.1 - - ansi-styles@4.3.0: - dependencies: - color-convert: 2.0.1 - - argparse@2.0.1: {} - - aria-query@5.3.2: {} - - await-lock@2.2.2: {} - - axobject-query@4.1.0: {} - - balanced-match@1.0.2: {} - - brace-expansion@1.1.12: - dependencies: - balanced-match: 1.0.2 - concat-map: 0.0.1 - - brace-expansion@2.0.2: - dependencies: - balanced-match: 1.0.2 - - braces@3.0.3: - dependencies: - fill-range: 7.1.1 - - callsites@3.1.0: {} - - chalk@4.1.2: - dependencies: - ansi-styles: 4.3.0 - supports-color: 7.2.0 - - chokidar@4.0.3: - dependencies: - readdirp: 4.1.2 - - chownr@3.0.0: {} - - clsx@2.1.1: {} - - codemirror@6.0.2: - dependencies: - '@codemirror/autocomplete': 6.19.1 - '@codemirror/commands': 6.10.0 - '@codemirror/language': 6.11.3 - '@codemirror/lint': 6.9.2 - '@codemirror/search': 6.5.11 - '@codemirror/state': 6.5.2 - '@codemirror/view': 6.38.6 - - color-convert@2.0.1: - dependencies: - color-name: 1.1.4 - - color-name@1.1.4: {} - - component-emitter@1.3.1: {} - - concat-map@0.0.1: {} - - cookie@0.6.0: {} - - crelt@1.0.6: {} - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - - cssesc@3.0.0: {} - - daisyui@5.1.10: {} - - debug@4.4.1: - dependencies: - ms: 2.1.3 - - deep-is@0.1.4: {} - - deepmerge@4.3.1: {} - - detect-libc@2.0.4: {} - - devalue@5.3.2: {} - - dexie@4.2.0: {} - - engine.io-client@6.6.4: - dependencies: - '@socket.io/component-emitter': 3.1.2 - debug: 4.4.1 - engine.io-parser: 5.2.3 - ws: 8.18.3 - xmlhttprequest-ssl: 2.1.2 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - engine.io-parser@5.2.3: {} - - enhanced-resolve@5.18.3: - dependencies: - graceful-fs: 4.2.11 - tapable: 2.2.3 - - esbuild@0.25.9: - optionalDependencies: - '@esbuild/aix-ppc64': 0.25.9 - '@esbuild/android-arm': 0.25.9 - '@esbuild/android-arm64': 0.25.9 - '@esbuild/android-x64': 0.25.9 - '@esbuild/darwin-arm64': 0.25.9 - '@esbuild/darwin-x64': 0.25.9 - '@esbuild/freebsd-arm64': 0.25.9 - '@esbuild/freebsd-x64': 0.25.9 - '@esbuild/linux-arm': 0.25.9 - '@esbuild/linux-arm64': 0.25.9 - '@esbuild/linux-ia32': 0.25.9 - '@esbuild/linux-loong64': 0.25.9 - '@esbuild/linux-mips64el': 0.25.9 - '@esbuild/linux-ppc64': 0.25.9 - '@esbuild/linux-riscv64': 0.25.9 - '@esbuild/linux-s390x': 0.25.9 - '@esbuild/linux-x64': 0.25.9 - '@esbuild/netbsd-arm64': 0.25.9 - '@esbuild/netbsd-x64': 0.25.9 - '@esbuild/openbsd-arm64': 0.25.9 - '@esbuild/openbsd-x64': 0.25.9 - '@esbuild/openharmony-arm64': 0.25.9 - '@esbuild/sunos-x64': 0.25.9 - '@esbuild/win32-arm64': 0.25.9 - '@esbuild/win32-ia32': 0.25.9 - '@esbuild/win32-x64': 0.25.9 - - escape-string-regexp@4.0.0: {} - - eslint-config-prettier@10.1.8(eslint@9.35.0(jiti@2.5.1)): - dependencies: - eslint: 9.35.0(jiti@2.5.1) - - eslint-plugin-svelte@3.12.3(eslint@9.35.0(jiti@2.5.1))(svelte@5.38.10): - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1)) - '@jridgewell/sourcemap-codec': 1.5.5 - eslint: 9.35.0(jiti@2.5.1) - esutils: 2.0.3 - globals: 16.4.0 - known-css-properties: 0.37.0 - postcss: 8.5.6 - postcss-load-config: 3.1.4(postcss@8.5.6) - postcss-safe-parser: 7.0.1(postcss@8.5.6) - semver: 7.7.2 - svelte-eslint-parser: 1.3.2(svelte@5.38.10) - optionalDependencies: - svelte: 5.38.10 - transitivePeerDependencies: - - ts-node - - eslint-scope@8.4.0: - dependencies: - esrecurse: 4.3.0 - estraverse: 5.3.0 - - eslint-visitor-keys@3.4.3: {} - - eslint-visitor-keys@4.2.1: {} - - eslint@9.35.0(jiti@2.5.1): - dependencies: - '@eslint-community/eslint-utils': 4.9.0(eslint@9.35.0(jiti@2.5.1)) - '@eslint-community/regexpp': 4.12.1 - '@eslint/config-array': 0.21.0 - '@eslint/config-helpers': 0.3.1 - '@eslint/core': 0.15.2 - '@eslint/eslintrc': 3.3.1 - '@eslint/js': 9.35.0 - '@eslint/plugin-kit': 0.3.5 - '@humanfs/node': 0.16.7 - '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.4.3 - '@types/estree': 1.0.8 - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - chalk: 4.1.2 - cross-spawn: 7.0.6 - debug: 4.4.1 - escape-string-regexp: 4.0.0 - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - esquery: 1.6.0 - esutils: 2.0.3 - fast-deep-equal: 3.1.3 - file-entry-cache: 8.0.0 - find-up: 5.0.0 - glob-parent: 6.0.2 - ignore: 5.3.2 - imurmurhash: 0.1.4 - is-glob: 4.0.3 - json-stable-stringify-without-jsonify: 1.0.1 - lodash.merge: 4.6.2 - minimatch: 3.1.2 - natural-compare: 1.4.0 - optionator: 0.9.4 - optionalDependencies: - jiti: 2.5.1 - transitivePeerDependencies: - - supports-color - - esm-env@1.2.2: {} - - espree@10.4.0: - dependencies: - acorn: 8.15.0 - acorn-jsx: 5.3.2(acorn@8.15.0) - eslint-visitor-keys: 4.2.1 - - esquery@1.6.0: - dependencies: - estraverse: 5.3.0 - - esrap@2.1.0: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - esrecurse@4.3.0: - dependencies: - estraverse: 5.3.0 - - estraverse@5.3.0: {} - - esutils@2.0.3: {} - - fast-deep-equal@3.1.3: {} - - fast-glob@3.3.3: - dependencies: - '@nodelib/fs.stat': 2.0.5 - '@nodelib/fs.walk': 1.2.8 - glob-parent: 5.1.2 - merge2: 1.4.1 - micromatch: 4.0.8 - - fast-json-stable-stringify@2.1.0: {} - - fast-levenshtein@2.0.6: {} - - fastq@1.19.1: - dependencies: - reusify: 1.1.0 - - fdir@6.5.0(picomatch@4.0.3): - optionalDependencies: - picomatch: 4.0.3 - - file-entry-cache@8.0.0: - dependencies: - flat-cache: 4.0.1 - - fill-range@7.1.1: - dependencies: - to-regex-range: 5.0.1 - - find-up@5.0.0: - dependencies: - locate-path: 6.0.0 - path-exists: 4.0.0 - - flat-cache@4.0.1: - dependencies: - flatted: 3.3.3 - keyv: 4.5.4 - - flatted@3.3.3: {} - - fsevents@2.3.3: - optional: true - - glob-parent@5.1.2: - dependencies: - is-glob: 4.0.3 - - glob-parent@6.0.2: - dependencies: - is-glob: 4.0.3 - - globals@14.0.0: {} - - globals@16.4.0: {} - - graceful-fs@4.2.11: {} - - graphemer@1.4.0: {} - - has-flag@4.0.0: {} - - ignore@5.3.2: {} - - ignore@7.0.5: {} - - import-fresh@3.3.1: - dependencies: - parent-module: 1.0.1 - resolve-from: 4.0.0 - - imurmurhash@0.1.4: {} - - is-extglob@2.1.1: {} - - is-glob@4.0.3: - dependencies: - is-extglob: 2.1.1 - - is-number@7.0.0: {} - - is-reference@3.0.3: - dependencies: - '@types/estree': 1.0.8 - - isexe@2.0.0: {} - - iso-datestring-validator@2.2.2: {} - - jiti@2.5.1: {} - - jose@5.10.0: {} - - js-yaml@4.1.0: - dependencies: - argparse: 2.0.1 - - json-buffer@3.0.1: {} - - json-schema-traverse@0.4.1: {} - - json-stable-stringify-without-jsonify@1.0.1: {} - - keyv@4.5.4: - dependencies: - json-buffer: 3.0.1 - - kleur@4.1.5: {} - - known-css-properties@0.37.0: {} - - layerr@3.0.0: {} - - levn@0.4.1: - dependencies: - prelude-ls: 1.2.1 - type-check: 0.4.0 - - lightningcss-darwin-arm64@1.30.1: - optional: true - - lightningcss-darwin-x64@1.30.1: - optional: true - - lightningcss-freebsd-x64@1.30.1: - optional: true - - lightningcss-linux-arm-gnueabihf@1.30.1: - optional: true - - lightningcss-linux-arm64-gnu@1.30.1: - optional: true - - lightningcss-linux-arm64-musl@1.30.1: - optional: true - - lightningcss-linux-x64-gnu@1.30.1: - optional: true - - lightningcss-linux-x64-musl@1.30.1: - optional: true - - lightningcss-win32-arm64-msvc@1.30.1: - optional: true - - lightningcss-win32-x64-msvc@1.30.1: - optional: true - - lightningcss@1.30.1: - dependencies: - detect-libc: 2.0.4 - optionalDependencies: - lightningcss-darwin-arm64: 1.30.1 - lightningcss-darwin-x64: 1.30.1 - lightningcss-freebsd-x64: 1.30.1 - lightningcss-linux-arm-gnueabihf: 1.30.1 - lightningcss-linux-arm64-gnu: 1.30.1 - lightningcss-linux-arm64-musl: 1.30.1 - lightningcss-linux-x64-gnu: 1.30.1 - lightningcss-linux-x64-musl: 1.30.1 - lightningcss-win32-arm64-msvc: 1.30.1 - lightningcss-win32-x64-msvc: 1.30.1 - - lilconfig@2.1.0: {} - - locate-character@3.0.0: {} - - locate-path@6.0.0: - dependencies: - p-locate: 5.0.0 - - lodash.merge@4.6.2: {} - - lru-cache@10.4.3: {} - - magic-string@0.30.19: - dependencies: - '@jridgewell/sourcemap-codec': 1.5.5 - - merge2@1.4.1: {} - - micromatch@4.0.8: - dependencies: - braces: 3.0.3 - picomatch: 2.3.1 - - minimatch@3.1.2: - dependencies: - brace-expansion: 1.1.12 - - minimatch@9.0.5: - dependencies: - brace-expansion: 2.0.2 - - minipass@7.1.2: {} - - minizlib@3.0.2: - dependencies: - minipass: 7.1.2 - - mkdirp@3.0.1: {} - - mri@1.2.0: {} - - mrmime@2.0.1: {} - - ms@2.1.3: {} - - multiformats@9.9.0: {} - - nanoid@3.3.11: {} - - natural-compare@1.4.0: {} - - notepack.io@2.2.0: {} - - optionator@0.9.4: - dependencies: - deep-is: 0.1.4 - fast-levenshtein: 2.0.6 - levn: 0.4.1 - prelude-ls: 1.2.1 - type-check: 0.4.0 - word-wrap: 1.2.5 - - p-limit@3.1.0: - dependencies: - yocto-queue: 0.1.0 - - p-locate@5.0.0: - dependencies: - p-limit: 3.1.0 - - parent-module@1.0.1: - dependencies: - callsites: 3.1.0 - - path-exists@4.0.0: {} - - path-key@3.1.1: {} - - picocolors@1.1.1: {} - - picomatch@2.3.1: {} - - picomatch@4.0.3: {} - - postcss-load-config@3.1.4(postcss@8.5.6): - dependencies: - lilconfig: 2.1.0 - yaml: 1.10.2 - optionalDependencies: - postcss: 8.5.6 - - postcss-safe-parser@7.0.1(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - postcss-scss@4.0.9(postcss@8.5.6): - dependencies: - postcss: 8.5.6 - - postcss-selector-parser@7.1.0: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss@8.5.6: - dependencies: - nanoid: 3.3.11 - picocolors: 1.1.1 - source-map-js: 1.2.1 - - prelude-ls@1.2.1: {} - - prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.38.10): - dependencies: - prettier: 3.6.2 - svelte: 5.38.10 - - prettier-plugin-tailwindcss@0.6.14(prettier-plugin-svelte@3.4.0(prettier@3.6.2)(svelte@5.38.10))(prettier@3.6.2): - dependencies: - prettier: 3.6.2 - optionalDependencies: - prettier-plugin-svelte: 3.4.0(prettier@3.6.2)(svelte@5.38.10) - - prettier@3.6.2: {} - - punycode@2.3.1: {} - - queue-microtask@1.2.3: {} - - readdirp@4.1.2: {} - - resolve-from@4.0.0: {} - - reusify@1.1.0: {} - - rollup@4.50.1: - dependencies: - '@types/estree': 1.0.8 - optionalDependencies: - '@rollup/rollup-android-arm-eabi': 4.50.1 - '@rollup/rollup-android-arm64': 4.50.1 - '@rollup/rollup-darwin-arm64': 4.50.1 - '@rollup/rollup-darwin-x64': 4.50.1 - '@rollup/rollup-freebsd-arm64': 4.50.1 - '@rollup/rollup-freebsd-x64': 4.50.1 - '@rollup/rollup-linux-arm-gnueabihf': 4.50.1 - '@rollup/rollup-linux-arm-musleabihf': 4.50.1 - '@rollup/rollup-linux-arm64-gnu': 4.50.1 - '@rollup/rollup-linux-arm64-musl': 4.50.1 - '@rollup/rollup-linux-loongarch64-gnu': 4.50.1 - '@rollup/rollup-linux-ppc64-gnu': 4.50.1 - '@rollup/rollup-linux-riscv64-gnu': 4.50.1 - '@rollup/rollup-linux-riscv64-musl': 4.50.1 - '@rollup/rollup-linux-s390x-gnu': 4.50.1 - '@rollup/rollup-linux-x64-gnu': 4.50.1 - '@rollup/rollup-linux-x64-musl': 4.50.1 - '@rollup/rollup-openharmony-arm64': 4.50.1 - '@rollup/rollup-win32-arm64-msvc': 4.50.1 - '@rollup/rollup-win32-ia32-msvc': 4.50.1 - '@rollup/rollup-win32-x64-msvc': 4.50.1 - fsevents: 2.3.3 - - run-parallel@1.2.0: - dependencies: - queue-microtask: 1.2.3 - - sade@1.8.1: - dependencies: - mri: 1.2.0 - - semver@7.7.2: {} - - set-cookie-parser@2.7.1: {} - - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - - sirv@3.0.2: - dependencies: - '@polka/url': 1.0.0-next.29 - mrmime: 2.0.1 - totalist: 3.0.1 - - socket.io-client@4.8.3: - dependencies: - '@socket.io/component-emitter': 3.1.2 - debug: 4.4.1 - engine.io-client: 6.6.4 - socket.io-parser: 4.2.5 - transitivePeerDependencies: - - bufferutil - - supports-color - - utf-8-validate - - socket.io-msgpack-parser@3.0.2: - dependencies: - component-emitter: 1.3.1 - notepack.io: 2.2.0 - - socket.io-parser@4.2.5: - dependencies: - '@socket.io/component-emitter': 3.1.2 - debug: 4.4.1 - transitivePeerDependencies: - - supports-color - - source-map-js@1.2.1: {} - - strip-json-comments@3.1.1: {} - - style-mod@4.1.3: {} - - supports-color@7.2.0: - dependencies: - has-flag: 4.0.0 - - svelte-check@4.3.1(picomatch@4.0.3)(svelte@5.38.10)(typescript@5.9.2): - dependencies: - '@jridgewell/trace-mapping': 0.3.31 - chokidar: 4.0.3 - fdir: 6.5.0(picomatch@4.0.3) - picocolors: 1.1.1 - sade: 1.8.1 - svelte: 5.38.10 - typescript: 5.9.2 - transitivePeerDependencies: - - picomatch - - svelte-codemirror-editor@2.1.0(codemirror@6.0.2)(svelte@5.38.10): - dependencies: - codemirror: 6.0.2 - svelte: 5.38.10 - - svelte-eslint-parser@1.3.2(svelte@5.38.10): - dependencies: - eslint-scope: 8.4.0 - eslint-visitor-keys: 4.2.1 - espree: 10.4.0 - postcss: 8.5.6 - postcss-scss: 4.0.9(postcss@8.5.6) - postcss-selector-parser: 7.1.0 - optionalDependencies: - svelte: 5.38.10 - - svelte@5.38.10: - dependencies: - '@jridgewell/remapping': 2.3.5 - '@jridgewell/sourcemap-codec': 1.5.5 - '@sveltejs/acorn-typescript': 1.0.5(acorn@8.15.0) - '@types/estree': 1.0.8 - acorn: 8.15.0 - aria-query: 5.3.2 - axobject-query: 4.1.0 - clsx: 2.1.1 - esm-env: 1.2.2 - esrap: 2.1.0 - is-reference: 3.0.3 - locate-character: 3.0.0 - magic-string: 0.30.19 - zimmerframe: 1.1.4 - - tailwindcss@4.1.13: {} - - tapable@2.2.3: {} - - tar@7.4.3: - dependencies: - '@isaacs/fs-minipass': 4.0.1 - chownr: 3.0.0 - minipass: 7.1.2 - minizlib: 3.0.2 - mkdirp: 3.0.1 - yallist: 5.0.0 - - tinyglobby@0.2.15: - dependencies: - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - - tlds@1.260.0: {} - - to-regex-range@5.0.1: - dependencies: - is-number: 7.0.0 - - totalist@3.0.1: {} - - ts-api-utils@2.1.0(typescript@5.9.2): - dependencies: - typescript: 5.9.2 - - type-check@0.4.0: - dependencies: - prelude-ls: 1.2.1 - - typescript-eslint@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2): - dependencies: - '@typescript-eslint/eslint-plugin': 8.43.0(@typescript-eslint/parser@8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2))(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/parser': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - '@typescript-eslint/typescript-estree': 8.43.0(typescript@5.9.2) - '@typescript-eslint/utils': 8.43.0(eslint@9.35.0(jiti@2.5.1))(typescript@5.9.2) - eslint: 9.35.0(jiti@2.5.1) - typescript: 5.9.2 - transitivePeerDependencies: - - supports-color - - typescript@5.9.2: {} - - uint8arrays@3.0.0: - dependencies: - multiformats: 9.9.0 - - ulidx@2.4.1: - dependencies: - layerr: 3.0.0 - - uri-js@4.4.1: - dependencies: - punycode: 2.3.1 - - util-deprecate@1.0.2: {} - - vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1): - dependencies: - esbuild: 0.25.9 - fdir: 6.5.0(picomatch@4.0.3) - picomatch: 4.0.3 - postcss: 8.5.6 - rollup: 4.50.1 - tinyglobby: 0.2.15 - optionalDependencies: - fsevents: 2.3.3 - jiti: 2.5.1 - lightningcss: 1.30.1 - - vitefu@1.1.1(vite@7.1.5(jiti@2.5.1)(lightningcss@1.30.1)): - optionalDependencies: - vite: 7.1.5(jiti@2.5.1)(lightningcss@1.30.1) - - w3c-keyname@2.2.8: {} - - which@2.0.2: - dependencies: - isexe: 2.0.0 - - word-wrap@1.2.5: {} - - ws@8.18.3: {} - - xmlhttprequest-ssl@2.1.2: {} - - yallist@5.0.0: {} - - yaml@1.10.2: {} - - yocto-queue@0.1.0: {} - - zimmerframe@1.1.4: {} - - zod@3.25.76: {} diff --git a/explorer/src/lib/workers/backendWorker.ts b/explorer/src/lib/workers/backendWorker.ts index 7e957b0..76cd09c 100644 --- a/explorer/src/lib/workers/backendWorker.ts +++ b/explorer/src/lib/workers/backendWorker.ts @@ -56,7 +56,7 @@ class Backend { #leafUrl: string | undefined; #oauthReady: Promise; - #resolveOauthReady: () => void = () => { }; + #resolveOauthReady: () => void = () => {}; get ready() { return state.#oauthReady; } @@ -275,6 +275,14 @@ function connectMessagePort(port: MessagePortApi) { const resp = await state.leafClient.query(streamDid, query); return resp; }, + async getUnreads(streamDid) { + if (!state.leafClient) throw 'Leaf client not initialized'; + return await state.leafClient.getUnreads(streamDid); + }, + async markAsRead(streamDid, roomId, lastReadIdx) { + if (!state.leafClient) throw 'Leaf client not initialized'; + return await state.leafClient.markAsRead(streamDid, roomId, lastReadIdx); + }, async addClient(port) { connectMessagePort(port); } diff --git a/explorer/src/lib/workers/index.ts b/explorer/src/lib/workers/index.ts index f632ff4..8b3cb1a 100644 --- a/explorer/src/lib/workers/index.ts +++ b/explorer/src/lib/workers/index.ts @@ -1,7 +1,7 @@ import type { ProfileViewDetailed } from '@atproto/api/dist/client/types/app/bsky/actor/defs'; import { messagePortInterface, reactiveWorkerState } from './workerMessaging'; import backendWorkerUrl from './backendWorker.ts?worker&url'; -import type { LeafQuery, SqlRows, BasicModule } from '@muni-town/leaf-client'; +import type { LeafQuery, SqlRows, BasicModule, UnreadsGetItem } from '@muni-town/leaf-client'; // Force page reload when hot reloading this file to avoid confusion if the workers get mixed up. if (import.meta.hot) { @@ -39,6 +39,8 @@ export type BackendInterface = { sendStateEvents(streamDid: string, events: Uint8Array[]): Promise; clearState(streamDid: string): Promise; setLeafUrl(url: string): Promise; + getUnreads(streamDid: string): Promise; + markAsRead(streamDid: string, roomId?: string, lastReadIdx?: number): Promise; /** Adds a new message port connection to the backend that can call the backend interface. */ addClient(port: MessagePort): Promise; }; diff --git a/explorer/src/routes/[[tab]]/+page.svelte b/explorer/src/routes/[[tab]]/+page.svelte index 75fad4e..b4e67d0 100644 --- a/explorer/src/routes/[[tab]]/+page.svelte +++ b/explorer/src/routes/[[tab]]/+page.svelte @@ -11,7 +11,8 @@ BytesWrapper, type BasicModule, type LeafQuery, - type SqlValue + type SqlValue, + type UnreadsGetItem } from '@muni-town/leaf-client'; import { page } from '$app/state'; import { encode } from '@atcute/cbor'; @@ -24,6 +25,7 @@ const persistLog = getContext<{ value: boolean }>('persistLog'); let eventMode = $state<'regular' | 'state'>('regular'); + let unreads = $state([]); let streamHandle = $state(''); @@ -161,13 +163,34 @@ await backend.clearState(streamDid.value); events.push('State cleared'); } + + async function getUnreads() { + if (!backendStatus.did) return; + unreads = await backend.getUnreads(streamDid.value); + } + + async function markAllAsRead() { + if (!backendStatus.did) return; + await backend.markAsRead(streamDid.value); + await getUnreads(); + } + + async function markRoomAsRead(roomId: string) { + if (!backendStatus.did) return; + await backend.markAsRead(streamDid.value, roomId); + await getUnreads(); + }
-
+
{#each tabs as tab} - {tab} + {tab} {/each}
{#if currentTab == 'Query'} @@ -255,8 +278,10 @@ - queryParams.splice(i, 1)} + class="btn btn-sm">X
{/each} @@ -264,9 +289,13 @@ {#if subscriptionId} Subscribed: {subscriptionId} - + {:else} - + {/if} @@ -277,7 +306,9 @@ loading = true; try { const hasModule = await backend.hasModule(moduleId); - events.push(hasModule ? `Has module: ${moduleId}` : `No module: ${moduleId}`); + events.push( + hasModule ? `Has module: ${moduleId}` : `No module: ${moduleId}` + ); } catch (e: any) { events.push(e.toString()); } @@ -323,9 +354,52 @@ }} >

Set Handle

- + + + +
+

Unreads

+ + {#if unreads.length > 0} + +
+ {#each unreads as unread} +
+
+ {unread.roomId} + + {unread.unreadCount} unread{unread.unreadCount !== 1 + ? 's' + : ''} + {unread.mentionCount > 0 + ? `, ${unread.mentionCount} mention${unread.mentionCount !== 1 ? 's' : ''}` + : ''} + +
+ +
+ {/each} +
+ {:else} +

No unread messages

+ {/if} +
{:else if currentTab == 'Create Stream'}
@@ -334,7 +408,7 @@ {/if}
-
+
{#if currentTab == 'Query'}

@@ -355,7 +429,9 @@ >

{#if eventMode === 'state'} - + {/if}

Init SQL

- This code will be run to initialize the module database and should be idempotent. + This code will be run to initialize the module database and should be + idempotent.

State Init SQL

-

This code will be run to initialize the state database and should be idempotent.

- The state database is attached to the module database as "state". This SQL is executed - when the stream is first loaded or if the state database is reset. + This code will be run to initialize the state database and should be + idempotent. +

+

+ The state database is attached to the module database as "state". This SQL + is executed when the stream is first loaded or if the state database is + reset.

SQL used to authorize new events before the are accepted into the stream.

- To access the event that is being authorized you can query the user + To access the event that is being authorized you can query the user and payload from the event table.

@@ -412,9 +495,14 @@ />

Materializer SQL

-

SQL used to materialize new events after they have been accepted into the stream.

- To access the event that is being materialized you can query the user + SQL used to materialize new events after they have been accepted into the + stream. +

+

+ To access the event that is being materialized you can query the user and payload from the event table.

@@ -429,11 +517,13 @@

SQL used to materialize state events.

- State events are used for transient, state that doesn't need to be part of the permanent - event log. + State events are used for transient, state that doesn't need to be part of + the permanent event log.

- To access the event that is being materialized you can query the user + To access the event that is being materialized you can query the user and payload from the event table.

@@ -447,7 +537,7 @@ theme={oneDarkTheme} placeholder="-- state event materialization sql" /> -

+

Queries
+ (newStreamModule.queries = newStreamModule.queries.splice( + i, + 0 + ))}>Delete Query

@@ -479,8 +572,8 @@ >$requesting_user placeholder, as well as and - $start and $limit in order to limit results based on the event - index and count. + $start and $limit in order to limit results + based on the event index and count.

{#each query.params as param, i}
- +