Skip to content

Commit 5ef0c60

Browse files
allow users of mdk to choose whether to sort by createdAt or processedAt first (marmot-protocol#171)
Co-authored-by: JeffG <202880+erskingardner@users.noreply.github.com>
1 parent 5952e36 commit 5ef0c60

16 files changed

Lines changed: 990 additions & 41 deletions

File tree

crates/mdk-core/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141

4242
### Added
4343

44+
- **Custom Message Sort Order**: `get_messages()` now supports custom sort orders via the `Pagination::sort_order` field. Added `get_last_message(group_id, sort_order)` method to retrieve the most recent message under a given sort order, enabling clients using `ProcessedAtFirst` ordering to get a consistent "last message" value. ([#171](https://github.com/marmot-protocol/mdk/pull/171))
4445
- **`create_key_package_for_event_with_options`**: New function that allows specifying whether to include the NIP-70 protected tag. Use this if you need to publish to relays that accept protected events. ([#173](https://github.com/marmot-protocol/mdk/pull/173), related: [#168](https://github.com/marmot-protocol/mdk/issues/168))
4546
- **MIP-04 Epoch Fallback for Media Decryption**: `decrypt_from_download` now resolves the correct decryption key via an O(1) epoch hint lookup instead of only using the current epoch's exporter secret. Added `NoExporterSecretForEpoch` variant to `EncryptedMediaError` for programmatic error matching. ([#167](https://github.com/marmot-protocol/mdk/pull/167))
4647
- **`PreviouslyFailed` Result Variant**: Added `MessageProcessingResult::PreviouslyFailed` variant to handle cases where a previously failed message arrives again but the MLS group ID cannot be extracted. This prevents crashes in client applications by returning a result instead of throwing an error. ([#165](https://github.com/marmot-protocol/mdk/pull/165), fixes [#154](https://github.com/marmot-protocol/mdk/issues/154), [#159](https://github.com/marmot-protocol/mdk/issues/159))

crates/mdk-core/src/messages/mod.rs

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ mod process;
2020
mod proposal;
2121
mod validation;
2222

23-
use mdk_storage_traits::groups::Pagination;
2423
use mdk_storage_traits::groups::types as group_types;
24+
use mdk_storage_traits::groups::{MessageSortOrder, Pagination};
2525
use mdk_storage_traits::messages::types as message_types;
2626
use mdk_storage_traits::{GroupId, MdkStorageProvider};
2727
use nostr::{EventId, Timestamp};
@@ -226,6 +226,34 @@ where
226226
.map_err(|_e| Error::Message("Storage error while getting messages".to_string()))
227227
}
228228

229+
/// Returns the most recent message in a group according to the given sort order.
230+
///
231+
/// This is useful for clients that use [`MessageSortOrder::ProcessedAtFirst`] and
232+
/// need a "last message" value that is consistent with their [`get_messages()`](Self::get_messages)
233+
/// ordering. The cached [`Group::last_message_id`](group_types::Group::last_message_id) always
234+
/// reflects [`MessageSortOrder::CreatedAtFirst`], so clients using a different sort order
235+
/// can call this method instead.
236+
///
237+
/// # Arguments
238+
///
239+
/// * `mls_group_id` - The MLS group ID
240+
/// * `sort_order` - The sort order to use when determining the "last" message
241+
///
242+
/// # Returns
243+
///
244+
/// * `Ok(Some(Message))` - The most recent message under the given ordering
245+
/// * `Ok(None)` - If the group has no messages
246+
/// * `Err(Error)` - If the group does not exist or a storage error occurs
247+
pub fn get_last_message(
248+
&self,
249+
mls_group_id: &GroupId,
250+
sort_order: MessageSortOrder,
251+
) -> Result<Option<message_types::Message>> {
252+
self.storage()
253+
.last_message(mls_group_id, sort_order)
254+
.map_err(|_e| Error::Message("Storage error while getting last message".to_string()))
255+
}
256+
229257
// =========================================================================
230258
// Storage Save Helpers
231259
// =========================================================================

crates/mdk-memory-storage/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727

2828
### Added
2929

30+
- **Custom Message Sort Order**: `messages()` now respects the `sort_order` field in `Pagination`, supporting both `CreatedAtFirst` (default) and `ProcessedAtFirst` orderings. ([#171](https://github.com/marmot-protocol/mdk/pull/171))
31+
- **Last Message by Sort Order**: Implemented `last_message()` to return the most recent message under a given sort order. ([#171](https://github.com/marmot-protocol/mdk/pull/171))
3032
- **Epoch Lookup by Tag Content**: Implemented `find_message_epoch_by_tag_content` for in-memory storage, scanning cached group messages and matching serialized tags. ([#167](https://github.com/marmot-protocol/mdk/pull/167))
3133
- **Retryable Message Support**: Updated storage implementation to handle `ProcessedMessageState::Retryable` transitions and persistence. ([#161](https://github.com/marmot-protocol/mdk/pull/161))
3234

crates/mdk-memory-storage/src/groups.rs

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::collections::BTreeSet;
55
use mdk_storage_traits::GroupId;
66
use mdk_storage_traits::groups::error::GroupError;
77
use mdk_storage_traits::groups::types::*;
8-
use mdk_storage_traits::groups::{GroupStorage, MAX_MESSAGE_LIMIT, Pagination};
8+
use mdk_storage_traits::groups::{GroupStorage, MAX_MESSAGE_LIMIT, MessageSortOrder, Pagination};
99
use mdk_storage_traits::messages::types::Message;
1010
use nostr::{PublicKey, RelayUrl};
1111

@@ -116,14 +116,23 @@ impl GroupStorage for MdkMemoryStorage {
116116
return Err(GroupError::InvalidParameters("Group not found".to_string()));
117117
}
118118

119+
let sort_order = pagination.sort_order();
120+
119121
match inner.messages_by_group_cache.peek(mls_group_id) {
120122
Some(messages_map) => {
121123
// Collect values from HashMap into a Vec for sorting
122124
let mut messages: Vec<Message> = messages_map.values().cloned().collect();
123125

124-
// Sort by created_at DESC, processed_at DESC, id DESC (newest first).
125-
// Uses the centralized display_order_cmp (reversed for DESC).
126-
messages.sort_by(|a, b| b.display_order_cmp(a));
126+
// Sort newest-first using the requested sort order.
127+
// Both comparators are called with (b, a) to get DESC ordering.
128+
match sort_order {
129+
MessageSortOrder::CreatedAtFirst => {
130+
messages.sort_by(|a, b| b.display_order_cmp(a));
131+
}
132+
MessageSortOrder::ProcessedAtFirst => {
133+
messages.sort_by(|a, b| b.processed_at_order_cmp(a));
134+
}
135+
}
127136

128137
// Apply pagination
129138
let start = offset.min(messages.len());
@@ -136,6 +145,35 @@ impl GroupStorage for MdkMemoryStorage {
136145
}
137146
}
138147

148+
fn last_message(
149+
&self,
150+
mls_group_id: &GroupId,
151+
sort_order: MessageSortOrder,
152+
) -> Result<Option<Message>, GroupError> {
153+
let inner = self.inner.read();
154+
155+
if inner.groups_cache.peek(mls_group_id).is_none() {
156+
return Err(GroupError::InvalidParameters("Group not found".to_string()));
157+
}
158+
159+
match inner.messages_by_group_cache.peek(mls_group_id) {
160+
Some(messages_map) if !messages_map.is_empty() => {
161+
// Find the maximum element under the requested ordering.
162+
// Both comparators compare (b, a) to find the DESC-first element via max_by.
163+
let winner = match sort_order {
164+
MessageSortOrder::CreatedAtFirst => {
165+
messages_map.values().max_by(|a, b| a.display_order_cmp(b))
166+
}
167+
MessageSortOrder::ProcessedAtFirst => messages_map
168+
.values()
169+
.max_by(|a, b| a.processed_at_order_cmp(b)),
170+
};
171+
Ok(winner.cloned())
172+
}
173+
_ => Ok(None),
174+
}
175+
}
176+
139177
fn admins(&self, mls_group_id: &GroupId) -> Result<BTreeSet<PublicKey>, GroupError> {
140178
match self.find_group_by_mls_group_id(mls_group_id)? {
141179
Some(group) => Ok(group.admin_pubkeys.clone()),

crates/mdk-sqlite-storage/CHANGELOG.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@
2727

2828
### Added
2929

30-
- **Group `last_message_processed_at` Column**: Added `last_message_processed_at` column to the `groups` table via V003 migration to track when the last message was processed/received by this client. This enables consistent ordering between `group.last_message_id` and `get_messages()[0].id`. Existing groups are backfilled with their `last_message_at` value as a reasonable default. ([#166](https://github.com/marmot-protocol/mdk/pull/166))
30+
- **Custom Message Sort Order**: `messages()` now respects the `sort_order` field in `Pagination`, supporting both `CreatedAtFirst` (default) and `ProcessedAtFirst` orderings via different SQL `ORDER BY` clauses. ([#171](https://github.com/marmot-protocol/mdk/pull/171))
31+
- **Last Message by Sort Order**: Implemented `last_message()` to return the most recent message under a given sort order via `SELECT ... ORDER BY ... LIMIT 1`. ([#171](https://github.com/marmot-protocol/mdk/pull/171))
32+
- **Processed-At-First Sort Index**: Added V003 migration creating `idx_messages_sorting_processed_at` composite index on `messages(mls_group_id, processed_at DESC, created_at DESC, id DESC)` for efficient `ProcessedAtFirst` queries. ([#171](https://github.com/marmot-protocol/mdk/pull/171))
33+
- **Group `last_message_processed_at` Column**: Added `last_message_processed_at` column to the `groups` table via V002 migration to track when the last message was processed/received by this client. This enables consistent ordering between `group.last_message_id` and `get_messages()[0].id`. Existing groups are backfilled with their `last_message_at` value as a reasonable default. ([#166](https://github.com/marmot-protocol/mdk/pull/166))
3134

3235
- **Message `processed_at` Column**: Added `processed_at` column to the `messages` table via V002 migration to store when messages were processed/received by the client. Existing messages are backfilled with their `created_at` value as a reasonable default. ([#166](https://github.com/marmot-protocol/mdk/pull/166))
3336

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
-- Add a composite index for the processed_at-first sort order
2+
-- (processed_at DESC, created_at DESC, id DESC) scoped to mls_group_id.
3+
-- This allows efficient queries when clients request MessageSortOrder::ProcessedAtFirst,
4+
-- which prioritises local reception time over the sender's timestamp.
5+
CREATE INDEX IF NOT EXISTS idx_messages_sorting_processed_at
6+
ON messages(mls_group_id, processed_at DESC, created_at DESC, id DESC);

crates/mdk-sqlite-storage/src/groups.rs

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use std::collections::BTreeSet;
55
use mdk_storage_traits::GroupId;
66
use mdk_storage_traits::groups::error::GroupError;
77
use mdk_storage_traits::groups::types::{Group, GroupExporterSecret, GroupRelay};
8-
use mdk_storage_traits::groups::{GroupStorage, MAX_MESSAGE_LIMIT, Pagination};
8+
use mdk_storage_traits::groups::{GroupStorage, MAX_MESSAGE_LIMIT, MessageSortOrder, Pagination};
99
use mdk_storage_traits::messages::types::Message;
1010
use nostr::{PublicKey, RelayUrl};
1111
use rusqlite::{OptionalExtension, params};
@@ -182,12 +182,23 @@ impl GroupStorage for MdkSqliteStorage {
182182
return Err(GroupError::InvalidParameters("Group not found".to_string()));
183183
}
184184

185+
let sort_order = pagination.sort_order();
186+
185187
self.with_connection(|conn| {
186-
let mut stmt = conn
187-
.prepare(
188-
"SELECT * FROM messages WHERE mls_group_id = ? ORDER BY created_at DESC, processed_at DESC, id DESC LIMIT ? OFFSET ?",
189-
)
190-
.map_err(into_group_err)?;
188+
let query = match sort_order {
189+
MessageSortOrder::CreatedAtFirst => {
190+
"SELECT * FROM messages WHERE mls_group_id = ? \
191+
ORDER BY created_at DESC, processed_at DESC, id DESC \
192+
LIMIT ? OFFSET ?"
193+
}
194+
MessageSortOrder::ProcessedAtFirst => {
195+
"SELECT * FROM messages WHERE mls_group_id = ? \
196+
ORDER BY processed_at DESC, created_at DESC, id DESC \
197+
LIMIT ? OFFSET ?"
198+
}
199+
};
200+
201+
let mut stmt = conn.prepare(query).map_err(into_group_err)?;
191202

192203
let messages_iter = stmt
193204
.query_map(
@@ -207,6 +218,37 @@ impl GroupStorage for MdkSqliteStorage {
207218
})
208219
}
209220

221+
fn last_message(
222+
&self,
223+
mls_group_id: &GroupId,
224+
sort_order: MessageSortOrder,
225+
) -> Result<Option<Message>, GroupError> {
226+
if self.find_group_by_mls_group_id(mls_group_id)?.is_none() {
227+
return Err(GroupError::InvalidParameters("Group not found".to_string()));
228+
}
229+
230+
self.with_connection(|conn| {
231+
let query = match sort_order {
232+
MessageSortOrder::CreatedAtFirst => {
233+
"SELECT * FROM messages WHERE mls_group_id = ? \
234+
ORDER BY created_at DESC, processed_at DESC, id DESC \
235+
LIMIT 1"
236+
}
237+
MessageSortOrder::ProcessedAtFirst => {
238+
"SELECT * FROM messages WHERE mls_group_id = ? \
239+
ORDER BY processed_at DESC, created_at DESC, id DESC \
240+
LIMIT 1"
241+
}
242+
};
243+
244+
conn.prepare(query)
245+
.map_err(into_group_err)?
246+
.query_row(params![mls_group_id.as_slice()], db::row_to_message)
247+
.optional()
248+
.map_err(into_group_err)
249+
})
250+
}
251+
210252
fn admins(&self, mls_group_id: &GroupId) -> Result<BTreeSet<PublicKey>, GroupError> {
211253
// Get the group which contains the admin_pubkeys
212254
match self.find_group_by_mls_group_id(mls_group_id)? {

crates/mdk-storage-traits/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@
2727

2828
### Added
2929

30+
- **Custom Message Sort Order**: Added `MessageSortOrder` enum with `CreatedAtFirst` (default) and `ProcessedAtFirst` variants to allow clients to choose how messages are ordered. Added `sort_order` field to `Pagination` struct and `Pagination::with_sort_order()` constructor. Added `Message::processed_at_order_cmp()` and `Message::compare_processed_at_keys()` comparison methods for the processed-at-first ordering. ([#171](https://github.com/marmot-protocol/mdk/pull/171))
31+
- **Last Message by Sort Order**: Added `GroupStorage::last_message()` method to query the most recent message in a group according to a given sort order. This allows clients using `ProcessedAtFirst` ordering to get a "last message" consistent with their `messages()` call, independent of the cached `Group::last_message_id` (which always reflects `CreatedAtFirst`). ([#171](https://github.com/marmot-protocol/mdk/pull/171))
3032
- **Epoch Lookup by Tag Content**: Added `find_message_epoch_by_tag_content` method to `MessageStorage` trait for looking up a message's epoch by searching serialized tag content. Used for MIP-04 media decryption epoch hint resolution. ([#167](https://github.com/marmot-protocol/mdk/pull/167))
3133

3234
### Breaking changes

0 commit comments

Comments
 (0)