update backend

This commit is contained in:
2026-09-22 14:33:41 +07:00
parent 7e7d13cbfc
commit 8914685c3d
17 changed files with 2575 additions and 971 deletions
+288
View File
@@ -0,0 +1,288 @@
use std::collections::BTreeMap;
use std::sync::LazyLock;
use anyhow::{Result, anyhow};
use concord::cord01::OpenedStream;
use concord::state::{CommunityState, STATE_PREFIX, state_identifier};
use concord::{ChannelId, CommunityId, cord03};
use nostr_sdk::prelude::*;
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(|| {
Keys::new(SecretKey::from_slice(&[0x43; 32]).expect("a fixed 32-byte scalar is a valid key"))
});
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
const MARK_VALUE: &str = "concord";
const WRAP_TAG: &str = "e";
const KIND_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_K;
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
pub async fn cache_rumor(
client: &Client,
channel: &ChannelId,
opened: &OpenedStream,
) -> Result<bool> {
let at = Timestamp::from_secs(opened.at_ms / 1000);
if cord03::expiration_of(&opened.rumor)?
.is_some_and(|expiration| expiration <= Timestamp::now())
{
return Ok(false);
}
let tags = vec![
Tag::identifier(opened.rumor_id),
Tag::custom(KIND_TAG.as_str(), [opened.rumor.kind.to_string()]),
Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
Tag::public_key(opened.author),
];
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
.tags(tags)
.custom_created_at(at)
.finalize_async(&*LOCAL_KEYS)
.await?;
client.database().save_event(&event).await?;
Ok(true)
}
pub async fn purge_expired(client: &Client, channel: &ChannelId, now: Timestamp) -> Result<usize> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
let mut expired = Vec::new();
for event in client.database().query(filter).await? {
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
continue;
};
let Ok(Some(expiration)) = cord03::expiration_of(&rumor) else {
continue;
};
if expiration <= now {
expired.push(event.id);
}
}
let purged = expired.len();
if purged > 0 {
client.database().delete(Filter::new().ids(expired)).await?;
}
Ok(purged)
}
/// Cached rumors for `channel`, newest first, deduplicated by rumor id.
pub async fn query_rumors(
client: &Client,
channel: &ChannelId,
until: Option<Timestamp>,
limit: usize,
kinds: Option<&[u16]>,
) -> Result<Vec<UnsignedEvent>> {
let mut filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
if let Some(kinds) = kinds {
filter = filter.custom_tags(KIND_TAG, kinds.iter().map(u16::to_string));
}
if let Some(until) = until {
filter = filter.until(until);
}
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
for event in client.database().query(filter).await? {
let Some(rumor_id) = event.tags.identifier() else {
continue;
};
match newest.get(&rumor_id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(rumor_id, event);
}
}
}
let mut events: Vec<Event> = newest.into_values().collect();
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
events.truncate(limit);
let mut rumors = Vec::with_capacity(events.len());
for event in events {
let rumor = UnsignedEvent::from_json(event.content)
.map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
rumors.push(rumor);
}
Ok(rumors)
}
pub async fn save_state(client: &Client, state: &CommunityState) -> Result<()> {
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(state.identifier())])
.finalize_async(&*LOCAL_KEYS)
.await?;
client.database().save_event(&event).await?;
Ok(())
}
pub async fn load_state(client: &Client, id: &CommunityId) -> Result<Option<CommunityState>> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(state_identifier(id))
.limit(1);
match client.database().query(filter).await?.into_iter().next() {
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
None => Ok(None),
}
}
/// The newest state document per community carried in the local database.
pub async fn load_states(client: &Client) -> Result<Vec<CommunityState>> {
let filter = Filter::new().kind(Kind::ApplicationSpecificData);
let mut newest: BTreeMap<CommunityId, Event> = BTreeMap::new();
for event in client.database().query(filter).await? {
let Some(id) = state_document_of(&event) else {
continue;
};
match newest.get(&id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(id, event);
}
}
}
let mut states = Vec::with_capacity(newest.len());
for event in newest.into_values() {
match serde_json::from_str::<CommunityState>(&event.content) {
Ok(state) => states.push(state),
Err(error) => log::warn!("ignoring malformed community state {}: {error}", event.id),
}
}
Ok(states)
}
fn state_document_of(event: &Event) -> Option<CommunityId> {
let identifier = event.tags.identifier()?;
let hex = identifier.strip_prefix(STATE_PREFIX)?;
hex.parse().ok()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use concord::Epoch;
use nostr_memory::MemoryDatabase;
use super::*;
fn client() -> Client {
ClientBuilder::default()
.database(MemoryDatabase::unbounded())
.build()
}
#[test]
fn load_states_reads_one_document_per_community_and_ignores_other_documents() {
smol::block_on(async {
let client = client();
let state = CommunityState {
id: CommunityId::from_bytes([0x42; 32]),
name: Some("Anime and Manga".to_owned()),
owner: Keys::generate().public_key(),
owner_salt: [0x01; 32],
community_root: [0x02; 32],
root_epoch: Epoch(0),
control_root: None,
control_pks: BTreeMap::new(),
channels: Vec::new(),
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
dissolved: false,
added_at_ms: 7,
};
save_state(&client, &state).await.expect("saves");
// A cached rumor is also an application-specific document, but not a
// state document, so the prefix keeps it out of the state scan.
let other = EventBuilder::new(Kind::ApplicationSpecificData, "{}")
.tags([Tag::identifier("deadbeef")])
.finalize(&*LOCAL_KEYS)
.expect("builds");
client.database().save_event(&other).await.expect("saves");
let loaded = load_states(&client).await.expect("loads");
assert_eq!(loaded, vec![state]);
});
}
#[test]
fn caching_the_same_rumor_twice_leaves_one_row() {
smol::block_on(async {
let client = client();
let channel = ChannelId::from_bytes([0x9c; 32]);
let keys = Keys::generate();
let group = concord::derive::channel_group_key(&[0x07; 32], &channel, Epoch(0))
.expect("a group key");
let rumor = concord::cord03::build_message(
keys.public_key(),
&channel,
Epoch(0),
"twice",
None,
1_700_000_000_000,
None,
);
let (wrap, _) = concord::cord03::seal_rumor(&rumor, &group, &keys, false)
.await
.expect("seals");
let (opened, _) =
concord::cord03::open(&wrap, &group, &channel, Epoch(0)).expect("opens");
assert!(
cache_rumor(&client, &channel, &opened)
.await
.expect("caches")
);
assert!(
cache_rumor(&client, &channel, &opened)
.await
.expect("caches")
);
let cached = query_rumors(&client, &channel, None, 10, None)
.await
.expect("reads");
assert_eq!(cached.len(), 1);
});
}
}