feat(db): port SQLite user lookup and search

Signed-off-by: npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je <8cf5a83f590ec0955b11647d1c88f796a98e088c30a492c58e0e46c3026ae7a4@buzz.block.builderlab.xyz>
This commit is contained in:
npub13n66s06epmqf2kc3v373ez8hj65cuzyvxzjf93vwpervxqn2u7jq2qd9je
2026-08-01 15:04:25 -04:00
committed by Brother Darryl
parent 91a698b040
commit b27f702357
2 changed files with 320 additions and 13 deletions

View File

@@ -2940,7 +2940,14 @@ impl Db {
local_part: &str,
domain: &str,
) -> Result<Option<user::UserProfile>> {
user::get_user_by_nip05(self.pg_pool()?, community_id, local_part, domain).await
match &self.backend {
DbBackend::SQLite(pool) => {
sqlite::get_user_by_nip05(pool, community_id, local_part, domain).await
}
DbBackend::Postgres => {
user::get_user_by_nip05(self.pg_pool()?, community_id, local_part, domain).await
}
}
}
/// Search users by display name, NIP-05 handle, or pubkey prefix.
@@ -2950,7 +2957,12 @@ impl Db {
query: &str,
limit: u32,
) -> Result<Vec<user::UserSearchProfile>> {
user::search_users(self.pg_pool()?, community_id, query, limit).await
match &self.backend {
DbBackend::SQLite(pool) => sqlite::search_users(pool, community_id, query, limit).await,
DbBackend::Postgres => {
user::search_users(self.pg_pool()?, community_id, query, limit).await
}
}
}
/// Atomically set agent owner — only if no owner is currently assigned.

View File

@@ -3492,30 +3492,138 @@ pub(crate) async fn update_user_profile(
Ok(())
}
fn user_profile(row: sqlx::sqlite::SqliteRow) -> Result<crate::user::UserProfile> {
Ok(crate::user::UserProfile {
pubkey: row.try_get("pubkey")?,
display_name: row.try_get("display_name")?,
avatar_url: row.try_get("avatar_url")?,
about: row.try_get("about")?,
nip05_handle: row.try_get("nip05_handle")?,
})
}
pub(crate) async fn get_user(
pool: &SqlitePool,
community: CommunityId,
pubkey: &[u8],
) -> Result<Option<crate::user::UserProfile>> {
let row = sqlx::query(
sqlx::query(
"SELECT pubkey, display_name, avatar_url, about, nip05_handle FROM users WHERE community_id = ?1 AND pubkey = ?2",
)
.bind(community.as_uuid().to_string())
.bind(pubkey)
.fetch_optional(pool)
.await?;
row.map(|row| {
Ok(crate::user::UserProfile {
pubkey: row.try_get("pubkey")?,
display_name: row.try_get("display_name")?,
avatar_url: row.try_get("avatar_url")?,
about: row.try_get("about")?,
nip05_handle: row.try_get("nip05_handle")?,
})
})
.await?
.map(user_profile)
.transpose()
}
pub(crate) async fn get_user_by_nip05(
pool: &SqlitePool,
community: CommunityId,
local_part: &str,
domain: &str,
) -> Result<Option<crate::user::UserProfile>> {
let handle = format!("{local_part}@{domain}").to_lowercase();
let rows = sqlx::query(
"SELECT pubkey, display_name, avatar_url, about, nip05_handle FROM users WHERE community_id = ?1 AND nip05_handle IS NOT NULL",
)
.bind(community.as_uuid().to_string())
.fetch_all(pool)
.await?;
rows.into_iter()
.find(|row| {
row.try_get::<String, _>("nip05_handle")
.is_ok_and(|stored| stored.to_lowercase() == handle)
})
.map(user_profile)
.transpose()
}
pub(crate) async fn search_users(
pool: &SqlitePool,
community: CommunityId,
query: &str,
limit: u32,
) -> Result<Vec<crate::user::UserSearchProfile>> {
let normalized = query.trim().to_lowercase();
if normalized.is_empty() {
return Ok(Vec::new());
}
let rows = sqlx::query(
"SELECT pubkey, display_name, avatar_url, nip05_handle FROM users WHERE community_id = ?1",
)
.bind(community.as_uuid().to_string())
.fetch_all(pool)
.await?;
let mut matches = rows
.into_iter()
.map(|row| {
let profile = crate::user::UserSearchProfile {
pubkey: row.try_get("pubkey")?,
display_name: row.try_get("display_name")?,
avatar_url: row.try_get("avatar_url")?,
nip05_handle: row.try_get("nip05_handle")?,
};
let display_name = profile
.display_name
.as_deref()
.unwrap_or_default()
.to_lowercase();
let nip05_handle = profile
.nip05_handle
.as_deref()
.unwrap_or_default()
.to_lowercase();
let pubkey = hex::encode(&profile.pubkey);
let rank = if display_name == normalized {
0
} else if nip05_handle == normalized {
1
} else if pubkey == normalized {
2
} else if display_name.starts_with(&normalized) {
3
} else if nip05_handle.starts_with(&normalized) {
4
} else if pubkey.starts_with(&normalized) {
5
} else {
6
};
let sort_name = profile
.display_name
.as_deref()
.filter(|value| !value.is_empty())
.or_else(|| {
profile
.nip05_handle
.as_deref()
.filter(|value| !value.is_empty())
})
.map(str::to_owned)
.unwrap_or_else(|| pubkey.clone());
Ok((
display_name.contains(&normalized)
|| nip05_handle.contains(&normalized)
|| pubkey.contains(&normalized),
rank,
sort_name,
profile,
))
})
.collect::<Result<Vec<_>>>()?;
matches.retain(|(is_match, _, _, _)| *is_match);
matches.sort_by(|left, right| left.1.cmp(&right.1).then_with(|| left.2.cmp(&right.2)));
Ok(matches
.into_iter()
.take(limit.clamp(1, 500) as usize)
.map(|(_, _, _, profile)| profile)
.collect())
}
pub(crate) async fn set_agent_owner(
pool: &SqlitePool,
community: CommunityId,
@@ -4051,6 +4159,193 @@ mod tests {
std::fs::remove_file(path).unwrap();
}
#[tokio::test]
async fn user_nip05_and_search_match_scope_ranking_and_literal_like_contracts() {
let pool = connect("sqlite::memory:").await.unwrap();
let a = ensure_configured_community(&pool, "users-a.example")
.await
.unwrap()
.id;
let b = ensure_configured_community(&pool, "users-b.example")
.await
.unwrap()
.id;
let exact_name = vec![0x10_u8; 32];
let exact_nip05 = vec![0x20_u8; 32];
let prefix_name = vec![0x30_u8; 32];
let wildcard_name = vec![0x40_u8; 32];
let other_tenant = vec![0x50_u8; 32];
let unicode_name = vec![0x60_u8; 32];
let backslash_name = vec![0x70_u8; 32];
for pubkey in [
&exact_name,
&exact_nip05,
&prefix_name,
&wildcard_name,
&unicode_name,
&backslash_name,
] {
ensure_user(&pool, a, pubkey).await.unwrap();
}
ensure_user(&pool, b, &other_tenant).await.unwrap();
update_user_profile(
&pool,
a,
&exact_name,
Some("Alice"),
Some("alice.png"),
Some("first"),
Some("name@example.com"),
)
.await
.unwrap();
update_user_profile(
&pool,
a,
&exact_nip05,
Some("Elsewhere"),
None,
None,
Some("alice"),
)
.await
.unwrap();
update_user_profile(
&pool,
a,
&prefix_name,
Some("Alice Cooper"),
None,
None,
None,
)
.await
.unwrap();
update_user_profile(
&pool,
a,
&wildcard_name,
Some("100%_literal"),
None,
None,
None,
)
.await
.unwrap();
update_user_profile(
&pool,
a,
&unicode_name,
Some("Älice"),
None,
None,
Some("Üser@example.com"),
)
.await
.unwrap();
update_user_profile(
&pool,
a,
&backslash_name,
Some(r"path\user"),
None,
None,
None,
)
.await
.unwrap();
update_user_profile(
&pool,
b,
&other_tenant,
Some("Other Tenant"),
None,
None,
Some("only-other@example.com"),
)
.await
.unwrap();
let nip05 = get_user_by_nip05(&pool, a, "NAME", "EXAMPLE.COM")
.await
.unwrap()
.unwrap();
assert_eq!(nip05.pubkey, exact_name);
assert_eq!(nip05.about.as_deref(), Some("first"));
assert_eq!(
get_user_by_nip05(&pool, a, "ÜSER", "EXAMPLE.COM")
.await
.unwrap()
.unwrap()
.pubkey,
unicode_name
);
assert!(get_user_by_nip05(&pool, a, "only-other", "example.com")
.await
.unwrap()
.is_none());
assert_eq!(
get_user_by_nip05(&pool, b, "only-other", "example.com")
.await
.unwrap()
.unwrap()
.pubkey,
other_tenant
);
let ranked = search_users(&pool, a, " ALICE ", 20).await.unwrap();
assert_eq!(
ranked
.iter()
.map(|profile| profile.pubkey.clone())
.collect::<Vec<_>>(),
vec![exact_name.clone(), exact_nip05.clone(), prefix_name]
);
assert!(search_users(&pool, a, " ", 20).await.unwrap().is_empty());
assert_eq!(
search_users(&pool, a, "%_", 20)
.await
.unwrap()
.into_iter()
.map(|profile| profile.pubkey)
.collect::<Vec<_>>(),
vec![wildcard_name]
);
assert_eq!(
search_users(&pool, a, "ÄLICE", 20)
.await
.unwrap()
.into_iter()
.map(|profile| profile.pubkey)
.collect::<Vec<_>>(),
vec![unicode_name]
);
assert_eq!(
search_users(&pool, a, r"path\user", 20)
.await
.unwrap()
.into_iter()
.map(|profile| profile.pubkey)
.collect::<Vec<_>>(),
vec![backslash_name]
);
assert!(search_users(&pool, a, "other tenant", 20)
.await
.unwrap()
.is_empty());
assert_eq!(
search_users(&pool, a, "1010", 20)
.await
.unwrap()
.into_iter()
.map(|profile| profile.pubkey)
.collect::<Vec<_>>(),
vec![exact_name]
);
assert_eq!(search_users(&pool, a, "alice", 0).await.unwrap().len(), 1);
}
#[tokio::test]
async fn dm_create_list_hide_and_scope_match_contract() {
let pool = connect("sqlite::memory:").await.unwrap();