Serve actors by ID
This commit is contained in:
parent
7ea8938c49
commit
bb26926edb
13 changed files with 633 additions and 464 deletions
2
Cargo.lock
generated
2
Cargo.lock
generated
|
@ -478,6 +478,7 @@ checksum = "658bd65b1cf4c852a3cc96f18a8ce7b5640f6b703f905c7d74532294c2a63984"
|
|||
name = "fetch"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"derive_more",
|
||||
"reqwest",
|
||||
"serde_json",
|
||||
"sigh",
|
||||
|
@ -1441,6 +1442,7 @@ version = "0.0.0"
|
|||
dependencies = [
|
||||
"axum",
|
||||
"puppy",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
use puppy::{
|
||||
mixin_ap_actor,
|
||||
model::{schema, Bite, FollowRequest, Follows, Profile, Username},
|
||||
post::Author,
|
||||
store::{self, Error},
|
||||
store::{self, Error, OK},
|
||||
Key, Store,
|
||||
};
|
||||
|
||||
|
@ -11,6 +12,11 @@ fn main() -> store::Result<()> {
|
|||
println!("creating actors");
|
||||
let riley = get_or_create_actor(&db, "riley")?;
|
||||
let linen = get_or_create_actor(&db, "linen")?;
|
||||
// db.run(|tx| {
|
||||
// mixin_ap_actor(tx, riley, "test.pup.riley.lgbt")?;
|
||||
// mixin_ap_actor(tx, linen, "test.pup.riley.lgbt")?;
|
||||
// OK
|
||||
// })?;
|
||||
if true {
|
||||
println!("creating posts");
|
||||
puppy::post::create_post(&db, riley, "@linen <3")?;
|
||||
|
@ -72,7 +78,7 @@ fn get_or_create_actor(db: &Store, username: &str) -> Result<Key, Error> {
|
|||
}
|
||||
Ok(None) => {
|
||||
println!("'{username}' doesn't exist yet, creating");
|
||||
let r = puppy::create_actor(&db, username);
|
||||
let r = puppy::create_local_actor(&db, username, "test.pup.riley.lgbt");
|
||||
if let Ok(ref key) = r {
|
||||
println!("created '{username}' with key {key}");
|
||||
}
|
||||
|
|
|
@ -6,3 +6,4 @@ edition = "2021"
|
|||
puppy = { path = "../../lib/puppy" }
|
||||
tokio = { version = "*", features = ["full"] }
|
||||
axum = "*"
|
||||
serde_json = "*"
|
||||
|
|
|
@ -1,8 +1,17 @@
|
|||
use axum::{routing::get, Router};
|
||||
use axum::{extract::Path, routing::get, Json, Router};
|
||||
use puppy::{get_local_ap_object, model::schema, Key, Store};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let app = Router::new().route("/", get(|| async { "Hello, World!" }));
|
||||
let db = Store::open(".state", schema()).unwrap();
|
||||
let app = Router::new().route(
|
||||
"/o/:ulid",
|
||||
get(|Path(raw_object_id): Path<String>| async move {
|
||||
let object_id = raw_object_id.parse::<Key>().unwrap();
|
||||
let obj = get_local_ap_object(&db, object_id).unwrap().to_json_ld();
|
||||
Json(obj)
|
||||
}),
|
||||
);
|
||||
let sock = tokio::net::TcpListener::bind("0.0.0.0:1312").await.unwrap();
|
||||
axum::serve(sock, app).await.unwrap();
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ edition = "2021"
|
|||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
reqwest = "*"
|
||||
sigh = "*"
|
||||
serde_json = "*"
|
||||
reqwest = "*"
|
||||
sigh = "*"
|
||||
serde_json = "*"
|
||||
derive_more = "*"
|
||||
|
|
|
@ -0,0 +1,49 @@
|
|||
use derive_more::{Display, From, Into};
|
||||
use serde_json::{json, Value};
|
||||
|
||||
pub enum Object {
|
||||
Activity(Activity),
|
||||
Actor(Actor),
|
||||
Object { id: Id },
|
||||
}
|
||||
|
||||
impl Object {
|
||||
pub fn to_json(self) -> Value {
|
||||
match self {
|
||||
Object::Activity(_) => todo!(),
|
||||
Object::Actor(a) => json!({
|
||||
"id": a.id.to_string(),
|
||||
"inbox": a.inbox.to_string(),
|
||||
"preferredUsername": a.account_name,
|
||||
"name": a.display_name,
|
||||
}),
|
||||
Object::Object { id } => json!({
|
||||
"id": id.to_string()
|
||||
}),
|
||||
}
|
||||
}
|
||||
pub fn to_json_ld(self) -> Value {
|
||||
let mut json = self.to_json();
|
||||
json["@context"] = json!([]);
|
||||
json
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Activity {
|
||||
pub id: Id,
|
||||
}
|
||||
|
||||
/// An actor is an entity capable of producing Takes.
|
||||
pub struct Actor {
|
||||
/// The URL pointing to this object.
|
||||
pub id: Id,
|
||||
/// Where others should send activities.
|
||||
pub inbox: Id,
|
||||
/// Note: this maps to the `preferredUsername` property.
|
||||
pub account_name: String,
|
||||
/// Note: this maps to the `name` property.
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Display, From, Into, Debug, Clone)]
|
||||
pub struct Id(String);
|
|
@ -49,7 +49,7 @@ fn make_alias_impl(name: &syn::Ident, field: &syn::Field) -> TokenStream {
|
|||
})
|
||||
}
|
||||
|
||||
#[proc_macro_derive(Mixin)]
|
||||
#[proc_macro_derive(Mixin, attributes(index))]
|
||||
pub fn mixin(item: TokenStream) -> TokenStream {
|
||||
let input = syn::parse_macro_input!(item as syn::DeriveInput);
|
||||
|
||||
|
|
28
lib/puppy/src/bites.rs
Normal file
28
lib/puppy/src/bites.rs
Normal file
|
@ -0,0 +1,28 @@
|
|||
//! The most essential feature of any social network.
|
||||
|
||||
use store::{Arrow, Key, Store};
|
||||
|
||||
/// *Bites you*
|
||||
#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub struct Bite {
|
||||
#[identity]
|
||||
pub id: Key,
|
||||
#[origin]
|
||||
pub biter: Key,
|
||||
#[target]
|
||||
pub victim: Key,
|
||||
}
|
||||
|
||||
pub fn bite_actor(db: &Store, biter: Key, victim: Key) -> store::Result<Key> {
|
||||
db.run(|tx| {
|
||||
let id = Key::gen();
|
||||
// TODO: Create an `Activity` arrow with the same ID.
|
||||
tx.create(Bite { id, biter, victim })?;
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Who has bitten `victim`?
|
||||
pub fn bites_on(db: &Store, victim: Key) -> store::Result<Vec<Bite>> {
|
||||
db.incoming::<Bite>(victim).try_collect()
|
||||
}
|
234
lib/puppy/src/follows.rs
Normal file
234
lib/puppy/src/follows.rs
Normal file
|
@ -0,0 +1,234 @@
|
|||
//! Follow requests and related stuff.
|
||||
|
||||
use bincode::{Decode, Encode};
|
||||
use store::{util::IterExt, Arrow, Error, Key, Mixin, Store, OK};
|
||||
|
||||
/// A predicate; `follower` "follows" `followed`.
|
||||
#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub struct Follows {
|
||||
#[origin]
|
||||
pub follower: Key,
|
||||
#[target]
|
||||
pub followed: Key,
|
||||
}
|
||||
|
||||
/// An instance of a request from some `origin` user to follow a `target` user.
|
||||
///
|
||||
/// This should not be used to determine whether two actors are following each other. For that, use
|
||||
/// [`Follows`], a basic arrow for exactly this purpose. *This* arrow is used to identify specific
|
||||
/// instances of *requests*, and serves mostly as a historical reference and for synchronizing with
|
||||
/// other servers.
|
||||
///
|
||||
/// Mixins always present for the `id`:
|
||||
///
|
||||
/// - [`Status`], carrying the status of the request.
|
||||
#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub struct FollowRequest {
|
||||
/// The unique ID of this particular request.
|
||||
#[identity]
|
||||
pub id: Key,
|
||||
/// The "follower", the user that made the request.
|
||||
pub origin: Key,
|
||||
/// The one the request is made to.
|
||||
pub target: Key,
|
||||
}
|
||||
|
||||
impl FollowRequest {
|
||||
/// Determine if this follow request is pending.
|
||||
pub fn is_pending(&self, db: &Store) -> store::Result<bool> {
|
||||
// The status is stored as a mixin, so we need to get it.
|
||||
let Some(st) = db.get_mixin::<Status>(self.id)? else {
|
||||
// If we don't have a status for a follow request, something is borked.
|
||||
return Err(Error::Missing);
|
||||
};
|
||||
// If the status of the follow request is pending, it can't also be true that the follows
|
||||
// relation already exists.
|
||||
debug_assert! {
|
||||
!(st == Status::Pending)
|
||||
|| db.exists::<Follows>(self.origin, self.target).map(|x| !x)?,
|
||||
"fr.is_pending -> !(fr.origin follows fr.target)"
|
||||
};
|
||||
Ok(st == Status::Pending)
|
||||
}
|
||||
}
|
||||
|
||||
/// The status of a [`FollowRequest`].
|
||||
///
|
||||
/// Valid state transitions:
|
||||
///
|
||||
/// ```text
|
||||
/// ┌──────────────▶ Rejected
|
||||
/// │
|
||||
/// │
|
||||
/// │
|
||||
///
|
||||
/// None ─────────▶ Pending ────────▶ Accepted
|
||||
///
|
||||
/// │ │
|
||||
/// │ │
|
||||
/// │ │
|
||||
/// ▼ │
|
||||
/// Withdrawn ◀────────────┘
|
||||
/// ```
|
||||
///
|
||||
/// In addition, a follow request will be deleted if either endpoint is removed from the graph.
|
||||
#[derive(Mixin, Encode, Decode, Eq, PartialEq, Clone)]
|
||||
pub enum Status {
|
||||
/// The follow request was previously pending or accepted, but since withdrawn.
|
||||
///
|
||||
/// This can happen when someone cancels their follow request or unfollows the target.
|
||||
Withdrawn,
|
||||
/// The follow request was accepted.
|
||||
Accepted,
|
||||
/// The follow request was denied.
|
||||
Rejected,
|
||||
/// The follow request is still under review.
|
||||
Pending,
|
||||
}
|
||||
|
||||
/// Request to follow another actor.
|
||||
pub fn request(db: &Store, requester: Key, target: Key) -> store::Result<FollowRequest> {
|
||||
db.run(|tx| {
|
||||
let req = FollowRequest {
|
||||
id: Key::gen(),
|
||||
origin: requester,
|
||||
target,
|
||||
};
|
||||
tx.create(req)?;
|
||||
tx.add_mixin(req.id, Status::Pending)?;
|
||||
Ok(req)
|
||||
})
|
||||
}
|
||||
|
||||
/// Accept the open follow request from `requester` to `target`, if one exists.
|
||||
pub fn accept(db: &Store, requester: Key, target: Key) -> store::Result<()> {
|
||||
db.run(|tx| {
|
||||
// TODO: This logic is a little broken but it'll do for now. i'll fix it later.
|
||||
let fr = tx
|
||||
.between::<FollowRequest>(requester, target)
|
||||
.filter(|fr| fr.as_ref().is_ok_and(|f| f.target == target))
|
||||
// We'll want the latest one, because that one was inserted last so it'll be the most
|
||||
// recent
|
||||
.last()
|
||||
.ok_or_else(|| Error::Missing)??;
|
||||
// Only apply the update if the last follow request is still in a pending state.
|
||||
if let Some(Status::Pending) = db.get_mixin(fr.id)? {
|
||||
tx.update(fr.id, |_| Status::Accepted)?;
|
||||
tx.create(Follows {
|
||||
follower: requester,
|
||||
followed: target,
|
||||
})?;
|
||||
}
|
||||
OK
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reject(db: &Store, request: Key) -> store::Result<()> {
|
||||
db.run(|tx| {
|
||||
tx.update(request, |_| Status::Rejected)?;
|
||||
OK
|
||||
})
|
||||
}
|
||||
|
||||
/// List all pending follow requests for a user.
|
||||
pub fn list_pending(db: &Store, target: Key) -> store::Result<Vec<FollowRequest>> {
|
||||
db.incoming::<FollowRequest>(target)
|
||||
.filter_bind_results(|req| Ok(if req.is_pending(db)? { Some(req) } else { None }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all actors followed by `actor`.
|
||||
pub fn following_of(db: &Store, actor: Key) -> store::Result<Vec<Key>> {
|
||||
db.outgoing::<Follows>(actor)
|
||||
.map_ok(|a| a.followed)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all actors following `actor`.
|
||||
pub fn followers_of(db: &Store, actor: Key) -> store::Result<Vec<Key>> {
|
||||
db.incoming::<Follows>(actor)
|
||||
.map_ok(|a| a.follower)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use store::{Key, Store, OK};
|
||||
|
||||
use crate::{
|
||||
create_actor,
|
||||
model::{schema, FollowRequest, Follows},
|
||||
};
|
||||
|
||||
fn make_test_actors(db: &Store) -> store::Result<(Key, Key)> {
|
||||
let alice = create_actor(&db, "alice")?;
|
||||
let bob = create_actor(&db, "bob")?;
|
||||
eprintln!("alice={alice}, bob={bob}");
|
||||
Ok((alice, bob))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_fr() -> store::Result<()> {
|
||||
Store::test(schema(), |db| {
|
||||
let (alice, bob) = make_test_actors(&db)?;
|
||||
super::request(&db, alice, bob)?;
|
||||
assert!(
|
||||
db.exists::<FollowRequest>(alice, bob)?,
|
||||
"(alice -> bob) ∈ follow-requested"
|
||||
);
|
||||
assert!(
|
||||
!db.exists::<Follows>(alice, bob)?,
|
||||
"(alice -> bob) ∉ follows"
|
||||
);
|
||||
let pending_for_bob = super::list_pending(&db, bob)?
|
||||
.into_iter()
|
||||
.map(|fr| fr.origin)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(pending_for_bob, vec![alice], "bob.pending = {{alice}}");
|
||||
OK
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_fr() -> store::Result<()> {
|
||||
Store::test(schema(), |db| {
|
||||
let (alice, bob) = make_test_actors(&db)?;
|
||||
super::request(&db, alice, bob)?;
|
||||
super::accept(&db, alice, bob)?;
|
||||
|
||||
assert!(
|
||||
db.exists::<Follows>(alice, bob)?,
|
||||
"(alice -> bob) ∈ follows"
|
||||
);
|
||||
assert!(
|
||||
!db.exists::<Follows>(bob, alice)?,
|
||||
"(bob -> alice) ∉ follows"
|
||||
);
|
||||
|
||||
let pending_for_bob = super::list_pending(&db, bob)?;
|
||||
assert!(pending_for_bob.is_empty(), "bob.pending = ∅");
|
||||
|
||||
let followers_of_bob = super::followers_of(&db, bob)?;
|
||||
assert_eq!(followers_of_bob, vec![alice], "bob.followers = {{alice}}");
|
||||
|
||||
OK
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_follow_relations() -> store::Result<()> {
|
||||
Store::test(schema(), |db| {
|
||||
let (alice, bob) = make_test_actors(&db)?;
|
||||
super::request(&db, alice, bob)?;
|
||||
super::accept(&db, alice, bob)?;
|
||||
|
||||
let followers_of_bob = super::followers_of(&db, bob)?;
|
||||
assert_eq!(followers_of_bob, vec![alice], "bob.followers = {{alice}}");
|
||||
|
||||
let following_of_alice = super::following_of(&db, alice)?;
|
||||
assert_eq!(following_of_alice, vec![bob], "alice.following = {{bob}}");
|
||||
|
||||
OK
|
||||
})
|
||||
}
|
||||
}
|
|
@ -1,6 +1,7 @@
|
|||
#![feature(iterator_try_collect, try_blocks)]
|
||||
use model::{Profile, Username};
|
||||
use model::{Channel, Id, Object, ObjectKind, Profile, Username};
|
||||
pub use store::{self, Key, Store};
|
||||
use store::{Error, Transaction, OK};
|
||||
|
||||
pub mod model {
|
||||
use bincode::{Decode, Encode};
|
||||
|
@ -22,6 +23,7 @@ pub mod model {
|
|||
/// How many posts has this user made?
|
||||
pub post_count: usize,
|
||||
/// The name used for the profile's handle.
|
||||
#[index] // <- currently doesnt do anything but i have an idea
|
||||
pub account_name: Username,
|
||||
/// The name displayed above their posts.
|
||||
pub display_name: Option<String>,
|
||||
|
@ -31,6 +33,46 @@ pub mod model {
|
|||
pub about_fields: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// Properties of ActivityPub objects.
|
||||
#[derive(Mixin, Encode, Decode, Debug, Clone)]
|
||||
pub struct Object {
|
||||
#[index]
|
||||
pub id: Id,
|
||||
pub kind: ObjectKind,
|
||||
}
|
||||
|
||||
/// Allows case analysis on the type of ActivityPub objects.
|
||||
#[derive(Encode, Decode, Debug, Clone)]
|
||||
pub enum ObjectKind {
|
||||
Actor,
|
||||
Activity(ActivityKind),
|
||||
Notelike(String),
|
||||
}
|
||||
|
||||
/// The type of an activity.
|
||||
#[derive(Encode, Decode, Debug, Clone)]
|
||||
pub enum ActivityKind {
|
||||
/// Used for posting stuff!
|
||||
Create = 0,
|
||||
/// Represents a follow request.
|
||||
Follow = 1,
|
||||
/// Used to signal that a follow request was accepted.
|
||||
Accept = 2,
|
||||
/// Used to reject a follow request.
|
||||
Reject = 3,
|
||||
/// See [`bites`](crate::bites).
|
||||
Bite = 4,
|
||||
}
|
||||
|
||||
#[derive(Mixin, Encode, Decode, Debug, Clone)]
|
||||
pub struct Channel {
|
||||
pub inbox: String,
|
||||
}
|
||||
|
||||
/// An ActivityPub ID, used to look up remote objects by their canonical URL.
|
||||
#[derive(Alias, Encode, Decode, Clone, PartialEq, Eq, Debug, Hash, Display)]
|
||||
pub struct Id(pub String);
|
||||
|
||||
/// A unique name for an actor that is part of their "handle".
|
||||
#[derive(Alias, Encode, Decode, Clone, PartialEq, Eq, Debug, Hash, Display)]
|
||||
pub struct Username(pub String);
|
||||
|
@ -42,8 +84,11 @@ pub mod model {
|
|||
.has::<Profile>()
|
||||
.has::<Content>()
|
||||
.has::<Status>()
|
||||
.has::<Object>()
|
||||
.has::<Channel>()
|
||||
// Aliases
|
||||
.has::<Username>()
|
||||
.has::<Id>()
|
||||
// Arrows
|
||||
.has::<Bite>()
|
||||
.has::<FollowRequest>()
|
||||
|
@ -52,10 +97,43 @@ pub mod model {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn create_actor(db: &Store, username: impl ToString) -> store::Result<Key> {
|
||||
/// Retrieve an ActivityPub object from the database.
|
||||
///
|
||||
/// Fails with `Error::Missing` if the required properties are not present.
|
||||
pub fn get_local_ap_object(db: &Store, key: Key) -> store::Result<fetch::Object> {
|
||||
let Some(obj) = db.get_mixin::<Object>(key)? else {
|
||||
return Err(Error::Missing);
|
||||
};
|
||||
match obj.kind {
|
||||
ObjectKind::Actor => {
|
||||
let Some(Profile { account_name, display_name, .. }) = db.get_mixin(key)? else {
|
||||
return Err(Error::Missing);
|
||||
};
|
||||
let Some(Channel { inbox }) = db.get_mixin(key)? else {
|
||||
return Err(Error::Missing);
|
||||
};
|
||||
Ok(fetch::Object::Actor(fetch::Actor {
|
||||
id: obj.id.0.into(),
|
||||
inbox: inbox.into(),
|
||||
account_name: account_name.0,
|
||||
display_name,
|
||||
}))
|
||||
}
|
||||
ObjectKind::Activity(_) => {
|
||||
todo!()
|
||||
}
|
||||
ObjectKind::Notelike(_) => todo!(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fresh local actor.
|
||||
pub fn create_local_actor(db: &Store, domain: &str, username: impl ToString) -> store::Result<Key> {
|
||||
let key = Key::gen();
|
||||
db.run(|tx| {
|
||||
let username: Username = username.to_string().into();
|
||||
// Federation stuff
|
||||
mixin_ap_actor(tx, key, domain)?;
|
||||
// Social properties
|
||||
tx.add_alias(key, username.clone())?;
|
||||
tx.add_mixin(key, Profile {
|
||||
post_count: 0,
|
||||
|
@ -68,440 +146,15 @@ pub fn create_actor(db: &Store, username: impl ToString) -> store::Result<Key> {
|
|||
})
|
||||
}
|
||||
|
||||
pub mod bites {
|
||||
//! The most essential feature of any social network.
|
||||
|
||||
use store::{Arrow, Key, Store};
|
||||
|
||||
/// *Bites you*
|
||||
#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub struct Bite {
|
||||
#[identity]
|
||||
pub id: Key,
|
||||
#[origin]
|
||||
pub biter: Key,
|
||||
#[target]
|
||||
pub victim: Key,
|
||||
}
|
||||
|
||||
pub fn bite_actor(db: &Store, biter: Key, victim: Key) -> store::Result<Key> {
|
||||
db.run(|tx| {
|
||||
let id = Key::gen();
|
||||
tx.create(Bite { id, biter, victim })?;
|
||||
Ok(id)
|
||||
})
|
||||
}
|
||||
|
||||
/// Who has bitten `victim`?
|
||||
pub fn bites_on(db: &Store, victim: Key) -> store::Result<Vec<Bite>> {
|
||||
db.incoming::<Bite>(victim).try_collect()
|
||||
}
|
||||
/// Add properties related to ActivityPub actors to a vertex.
|
||||
pub fn mixin_ap_actor(tx: &Transaction<'_>, vertex: Key, domain: &str) -> store::Result<()> {
|
||||
let id = Id(format!("http://{domain}/o/{vertex}"));
|
||||
tx.add_alias(vertex, id.clone())?;
|
||||
tx.add_mixin(vertex, Channel { inbox: format!("{id}/inbox") })?;
|
||||
tx.add_mixin(vertex, Object { id, kind: ObjectKind::Actor })?;
|
||||
OK
|
||||
}
|
||||
|
||||
pub mod post {
|
||||
//! Timelines: where you go to view the posts.
|
||||
|
||||
use std::ops::RangeBounds;
|
||||
|
||||
use bincode::{Decode, Encode};
|
||||
use chrono::{DateTime, Utc};
|
||||
use either::Either::{Left, Right};
|
||||
use store::{util::IterExt as _, Arrow, Error, Key, Mixin, Result, Store, Transaction};
|
||||
|
||||
use crate::model::Profile;
|
||||
|
||||
/// The contents of a post.
|
||||
#[derive(Mixin, Encode, Decode, Debug, Clone, Default)]
|
||||
pub struct Content {
|
||||
/// Main post body.
|
||||
pub content: Option<String>,
|
||||
/// Content warning for the post.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&str> for Content {
|
||||
fn from(value: &str) -> Self {
|
||||
value.to_string().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Content {
|
||||
fn from(value: String) -> Self {
|
||||
Content {
|
||||
content: Some(value),
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The relation that `author` has constructed and published `object`.
|
||||
#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub struct AuthorOf {
|
||||
#[origin]
|
||||
pub author: Key,
|
||||
#[target]
|
||||
pub object: Key,
|
||||
}
|
||||
|
||||
/// A piece of content posted by someone.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Post {
|
||||
/// The post's internal ID.
|
||||
pub id: Key,
|
||||
/// The actual post contents.
|
||||
pub content: Content,
|
||||
/// Metadata about the post's author.
|
||||
pub author: Author,
|
||||
}
|
||||
|
||||
/// Information about a [`Post`]'s author.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Author {
|
||||
/// The identifier of the author.
|
||||
pub id: Key,
|
||||
/// The name to display along with the post.
|
||||
pub display_name: String,
|
||||
/// An informal identifier for a particular author.
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
/// An ordered list of [`Post`]s for viewing.
|
||||
#[derive(Debug)]
|
||||
pub struct Timeline {
|
||||
items: Vec<Item>,
|
||||
}
|
||||
|
||||
/// Discrete events that can be displayed to a user as part of a timeline.
|
||||
#[derive(Debug)]
|
||||
enum Item {
|
||||
Post(Post),
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Get the timeline item if it is a [`Post`].
|
||||
pub fn as_post(&self) -> Option<&Post> {
|
||||
match self {
|
||||
Item::Post(ref post) => Some(post),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
/// Get all the posts in the timeline.
|
||||
pub fn posts(&self) -> impl Iterator<Item = &Post> {
|
||||
self.items.iter().filter_map(|x| x.as_post())
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets at most `limit` of the posts known to the instance that were inserted within `time_range`.
|
||||
pub fn fetch_timeline(
|
||||
db: &Store,
|
||||
time_range: impl RangeBounds<DateTime<Utc>>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Timeline> {
|
||||
let posts = db.run(|tx| {
|
||||
// Get all post content entries (the argument passed here is a range of chrono datetimes).
|
||||
let iter = tx.range::<Content>(time_range);
|
||||
let iter = match limit {
|
||||
Some(n) => Left(iter.take(n)),
|
||||
None => Right(iter),
|
||||
};
|
||||
// Then, we're gonna map each of them to their author, and get the profile information needed to
|
||||
// render the post (mostly display name and handle).
|
||||
iter.bind_results(|(id, content)| {
|
||||
// Take the first author. There is nothing stopping a post from having multiple authors, but
|
||||
// let's take it one step at a time.
|
||||
let (author, Some(Profile { display_name, account_name, .. })) = tx
|
||||
.join_on(|a: AuthorOf| a.author, tx.incoming(id))?
|
||||
.swap_remove(0)
|
||||
else {
|
||||
// We expect all posts to have at least one author, so we should complain if there is one
|
||||
// that doesn't (for now). For robustness, the `.collect()` down there should be replaced
|
||||
// with a strategy where we log a warning instead of failing, but in the current state of
|
||||
// the project, failing fast is a good thing.
|
||||
return Err(Error::Missing);
|
||||
};
|
||||
Ok(Item::Post(Post {
|
||||
id,
|
||||
author: Author {
|
||||
id: author,
|
||||
handle: format!("@{account_name}"),
|
||||
display_name: display_name.unwrap_or(account_name.0),
|
||||
},
|
||||
content,
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
})?;
|
||||
Ok(Timeline { items: posts })
|
||||
}
|
||||
|
||||
/// Create a new post.
|
||||
pub fn create_post(db: &Store, author: Key, content: impl Into<Content>) -> store::Result<Key> {
|
||||
db.run(|tx| mixin_post(tx, Key::gen(), author, content))
|
||||
}
|
||||
|
||||
/// Add a post's mixins and predicates to an existing `node`.
|
||||
pub fn mixin_post(
|
||||
tx: &Transaction<'_>,
|
||||
node: Key,
|
||||
author: Key,
|
||||
content: impl Into<Content>,
|
||||
) -> store::Result<Key> {
|
||||
tx.update::<Profile>(author, |mut profile| {
|
||||
profile.post_count += 1;
|
||||
profile
|
||||
})?;
|
||||
tx.add_mixin(node, content.into())?;
|
||||
tx.create(AuthorOf { author, object: node })?;
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
pub fn list_posts_by_author(db: &Store, author: Key) -> store::Result<Vec<(Key, Content)>> {
|
||||
db.run(|tx| {
|
||||
let posts = tx
|
||||
.join_on(|a: AuthorOf| a.object, tx.outgoing(author))?
|
||||
.into_iter()
|
||||
.filter_map(|(k, opt)| try { (k, opt?) })
|
||||
.collect();
|
||||
Ok(posts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub mod follows {
|
||||
//! Follow requests and related stuff.
|
||||
|
||||
use bincode::{Decode, Encode};
|
||||
use store::{util::IterExt, Arrow, Error, Key, Mixin, Store, OK};
|
||||
|
||||
/// A predicate; `follower` "follows" `followed`.
|
||||
#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub struct Follows {
|
||||
#[origin]
|
||||
pub follower: Key,
|
||||
#[target]
|
||||
pub followed: Key,
|
||||
}
|
||||
|
||||
/// An instance of a request from some `origin` user to follow a `target` user.
|
||||
///
|
||||
/// This should not be used to determine whether two actors are following each other. For that, use
|
||||
/// [`Follows`], a basic arrow for exactly this purpose. *This* arrow is used to identify specific
|
||||
/// instances of *requests*, and serves mostly as a historical reference and for synchronizing with
|
||||
/// other servers.
|
||||
///
|
||||
/// Mixins always present for the `id`:
|
||||
///
|
||||
/// - [`Status`], carrying the status of the request.
|
||||
#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub struct FollowRequest {
|
||||
/// The unique ID of this particular request.
|
||||
#[identity]
|
||||
pub id: Key,
|
||||
/// The "follower", the user that made the request.
|
||||
pub origin: Key,
|
||||
/// The one the request is made to.
|
||||
pub target: Key,
|
||||
}
|
||||
|
||||
impl FollowRequest {
|
||||
/// Determine if this follow request is pending.
|
||||
pub fn is_pending(&self, db: &Store) -> store::Result<bool> {
|
||||
// The status is stored as a mixin, so we need to get it.
|
||||
let Some(st) = db.get_mixin::<Status>(self.id)? else {
|
||||
// If we don't have a status for a follow request, something is borked.
|
||||
return Err(Error::Missing);
|
||||
};
|
||||
// If the status of the follow request is pending, it can't also be true that the follows
|
||||
// relation already exists.
|
||||
debug_assert! {
|
||||
!(st == Status::Pending)
|
||||
|| db.exists::<Follows>(self.origin, self.target).map(|x| !x)?,
|
||||
"fr.is_pending -> !(fr.origin follows fr.target)"
|
||||
};
|
||||
Ok(st == Status::Pending)
|
||||
}
|
||||
}
|
||||
|
||||
/// The status of a [`FollowRequest`].
|
||||
///
|
||||
/// Valid state transitions:
|
||||
///
|
||||
/// ```text
|
||||
/// ┌──────────────▶ Rejected
|
||||
/// │
|
||||
/// │
|
||||
/// │
|
||||
///
|
||||
/// None ─────────▶ Pending ────────▶ Accepted
|
||||
///
|
||||
/// │ │
|
||||
/// │ │
|
||||
/// │ │
|
||||
/// ▼ │
|
||||
/// Withdrawn ◀────────────┘
|
||||
/// ```
|
||||
///
|
||||
/// In addition, a follow request will be deleted if either endpoint is removed from the graph.
|
||||
#[derive(Mixin, Encode, Decode, Eq, PartialEq, Clone)]
|
||||
pub enum Status {
|
||||
/// The follow request was previously pending or accepted, but since withdrawn.
|
||||
///
|
||||
/// This can happen when someone cancels their follow request or unfollows the target.
|
||||
Withdrawn,
|
||||
/// The follow request was accepted.
|
||||
Accepted,
|
||||
/// The follow request was denied.
|
||||
Rejected,
|
||||
/// The follow request is still under review.
|
||||
Pending,
|
||||
}
|
||||
|
||||
/// Request to follow another actor.
|
||||
pub fn request(db: &Store, requester: Key, target: Key) -> store::Result<FollowRequest> {
|
||||
db.run(|tx| {
|
||||
let req = FollowRequest {
|
||||
id: Key::gen(),
|
||||
origin: requester,
|
||||
target,
|
||||
};
|
||||
tx.create(req)?;
|
||||
tx.add_mixin(req.id, Status::Pending)?;
|
||||
Ok(req)
|
||||
})
|
||||
}
|
||||
|
||||
/// Accept the open follow request from `requester` to `target`, if one exists.
|
||||
pub fn accept(db: &Store, requester: Key, target: Key) -> store::Result<()> {
|
||||
db.run(|tx| {
|
||||
// TODO: This logic is a little broken but it'll do for now. i'll fix it later.
|
||||
let fr = tx
|
||||
.between::<FollowRequest>(requester, target)
|
||||
.filter(|fr| fr.as_ref().is_ok_and(|f| f.target == target))
|
||||
// We'll want the latest one, because that one was inserted last so it'll be the most
|
||||
// recent
|
||||
.last()
|
||||
.ok_or_else(|| Error::Missing)??;
|
||||
// Only apply the update if the last follow request is still in a pending state.
|
||||
if let Some(Status::Pending) = db.get_mixin(fr.id)? {
|
||||
tx.update(fr.id, |_| Status::Accepted)?;
|
||||
tx.create(Follows {
|
||||
follower: requester,
|
||||
followed: target,
|
||||
})?;
|
||||
}
|
||||
OK
|
||||
})
|
||||
}
|
||||
|
||||
pub fn reject(db: &Store, request: Key) -> store::Result<()> {
|
||||
db.run(|tx| {
|
||||
tx.update(request, |_| Status::Rejected)?;
|
||||
OK
|
||||
})
|
||||
}
|
||||
|
||||
/// List all pending follow requests for a user.
|
||||
pub fn list_pending(db: &Store, target: Key) -> store::Result<Vec<FollowRequest>> {
|
||||
db.incoming::<FollowRequest>(target)
|
||||
.filter_bind_results(|req| Ok(if req.is_pending(db)? { Some(req) } else { None }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all actors followed by `actor`.
|
||||
pub fn following_of(db: &Store, actor: Key) -> store::Result<Vec<Key>> {
|
||||
db.outgoing::<Follows>(actor)
|
||||
.map_ok(|a| a.followed)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Get all actors following `actor`.
|
||||
pub fn followers_of(db: &Store, actor: Key) -> store::Result<Vec<Key>> {
|
||||
db.incoming::<Follows>(actor)
|
||||
.map_ok(|a| a.follower)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use store::{Key, Store, OK};
|
||||
|
||||
use crate::{
|
||||
create_actor,
|
||||
model::{schema, FollowRequest, Follows},
|
||||
};
|
||||
|
||||
fn make_test_actors(db: &Store) -> store::Result<(Key, Key)> {
|
||||
let alice = create_actor(&db, "alice")?;
|
||||
let bob = create_actor(&db, "bob")?;
|
||||
eprintln!("alice={alice}, bob={bob}");
|
||||
Ok((alice, bob))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn create_fr() -> store::Result<()> {
|
||||
Store::test(schema(), |db| {
|
||||
let (alice, bob) = make_test_actors(&db)?;
|
||||
super::request(&db, alice, bob)?;
|
||||
assert!(
|
||||
db.exists::<FollowRequest>(alice, bob)?,
|
||||
"(alice -> bob) ∈ follow-requested"
|
||||
);
|
||||
assert!(
|
||||
!db.exists::<Follows>(alice, bob)?,
|
||||
"(alice -> bob) ∉ follows"
|
||||
);
|
||||
let pending_for_bob = super::list_pending(&db, bob)?
|
||||
.into_iter()
|
||||
.map(|fr| fr.origin)
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(pending_for_bob, vec![alice], "bob.pending = {{alice}}");
|
||||
OK
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_fr() -> store::Result<()> {
|
||||
Store::test(schema(), |db| {
|
||||
let (alice, bob) = make_test_actors(&db)?;
|
||||
super::request(&db, alice, bob)?;
|
||||
super::accept(&db, alice, bob)?;
|
||||
|
||||
assert!(
|
||||
db.exists::<Follows>(alice, bob)?,
|
||||
"(alice -> bob) ∈ follows"
|
||||
);
|
||||
assert!(
|
||||
!db.exists::<Follows>(bob, alice)?,
|
||||
"(bob -> alice) ∉ follows"
|
||||
);
|
||||
|
||||
let pending_for_bob = super::list_pending(&db, bob)?;
|
||||
assert!(pending_for_bob.is_empty(), "bob.pending = ∅");
|
||||
|
||||
let followers_of_bob = super::followers_of(&db, bob)?;
|
||||
assert_eq!(followers_of_bob, vec![alice], "bob.followers = {{alice}}");
|
||||
|
||||
OK
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listing_follow_relations() -> store::Result<()> {
|
||||
Store::test(schema(), |db| {
|
||||
let (alice, bob) = make_test_actors(&db)?;
|
||||
super::request(&db, alice, bob)?;
|
||||
super::accept(&db, alice, bob)?;
|
||||
|
||||
let followers_of_bob = super::followers_of(&db, bob)?;
|
||||
assert_eq!(followers_of_bob, vec![alice], "bob.followers = {{alice}}");
|
||||
|
||||
let following_of_alice = super::following_of(&db, alice)?;
|
||||
assert_eq!(following_of_alice, vec![bob], "alice.following = {{bob}}");
|
||||
|
||||
OK
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
pub mod bites;
|
||||
pub mod post;
|
||||
pub mod follows;
|
||||
|
|
168
lib/puppy/src/post.rs
Normal file
168
lib/puppy/src/post.rs
Normal file
|
@ -0,0 +1,168 @@
|
|||
//! Timelines: where you go to view the posts.
|
||||
|
||||
use std::ops::RangeBounds;
|
||||
|
||||
use bincode::{Decode, Encode};
|
||||
use chrono::{DateTime, Utc};
|
||||
use either::Either::{Left, Right};
|
||||
use store::{util::IterExt as _, Arrow, Error, Key, Mixin, Result, Store, Transaction};
|
||||
|
||||
use crate::model::Profile;
|
||||
|
||||
/// The contents of a post.
|
||||
#[derive(Mixin, Encode, Decode, Debug, Clone, Default)]
|
||||
pub struct Content {
|
||||
/// Main post body.
|
||||
pub content: Option<String>,
|
||||
/// Content warning for the post.
|
||||
pub warning: Option<String>,
|
||||
}
|
||||
|
||||
impl From<&str> for Content {
|
||||
fn from(value: &str) -> Self {
|
||||
value.to_string().into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for Content {
|
||||
fn from(value: String) -> Self {
|
||||
Content {
|
||||
content: Some(value),
|
||||
warning: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The relation that `author` has constructed and published `object`.
|
||||
#[derive(Arrow, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub struct AuthorOf {
|
||||
#[origin]
|
||||
pub author: Key,
|
||||
#[target]
|
||||
pub object: Key,
|
||||
}
|
||||
|
||||
/// A piece of content posted by someone.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Post {
|
||||
/// The post's internal ID.
|
||||
pub id: Key,
|
||||
/// The actual post contents.
|
||||
pub content: Content,
|
||||
/// Metadata about the post's author.
|
||||
pub author: Author,
|
||||
}
|
||||
|
||||
/// Information about a [`Post`]'s author.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Author {
|
||||
/// The identifier of the author.
|
||||
pub id: Key,
|
||||
/// The name to display along with the post.
|
||||
pub display_name: String,
|
||||
/// An informal identifier for a particular author.
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
/// An ordered list of [`Post`]s for viewing.
|
||||
#[derive(Debug)]
|
||||
pub struct Timeline {
|
||||
items: Vec<Item>,
|
||||
}
|
||||
|
||||
/// Discrete events that can be displayed to a user as part of a timeline.
|
||||
#[derive(Debug)]
|
||||
enum Item {
|
||||
Post(Post),
|
||||
}
|
||||
|
||||
impl Item {
|
||||
/// Get the timeline item if it is a [`Post`].
|
||||
pub fn as_post(&self) -> Option<&Post> {
|
||||
match self {
|
||||
Item::Post(ref post) => Some(post),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Timeline {
|
||||
/// Get all the posts in the timeline.
|
||||
pub fn posts(&self) -> impl Iterator<Item = &Post> {
|
||||
self.items.iter().filter_map(|x| x.as_post())
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets at most `limit` of the posts known to the instance that were inserted within `time_range`.
|
||||
pub fn fetch_timeline(
|
||||
db: &Store,
|
||||
time_range: impl RangeBounds<DateTime<Utc>>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<Timeline> {
|
||||
let posts = db.run(|tx| {
|
||||
// Get all post content entries (the argument passed here is a range of chrono datetimes).
|
||||
let iter = tx.range::<Content>(time_range);
|
||||
let iter = match limit {
|
||||
Some(n) => Left(iter.take(n)),
|
||||
None => Right(iter),
|
||||
};
|
||||
// Then, we're gonna map each of them to their author, and get the profile information needed to
|
||||
// render the post (mostly display name and handle).
|
||||
iter.bind_results(|(id, content)| {
|
||||
// Take the first author. There is nothing stopping a post from having multiple authors, but
|
||||
// let's take it one step at a time.
|
||||
let (author, Some(Profile { display_name, account_name, .. })) = tx
|
||||
.join_on(|a: AuthorOf| a.author, tx.incoming(id))?
|
||||
.swap_remove(0)
|
||||
else {
|
||||
// We expect all posts to have at least one author, so we should complain if there is one
|
||||
// that doesn't (for now). For robustness, the `.collect()` down there should be replaced
|
||||
// with a strategy where we log a warning instead of failing, but in the current state of
|
||||
// the project, failing fast is a good thing.
|
||||
return Err(Error::Missing);
|
||||
};
|
||||
Ok(Item::Post(Post {
|
||||
id,
|
||||
author: Author {
|
||||
id: author,
|
||||
handle: format!("@{account_name}"),
|
||||
display_name: display_name.unwrap_or(account_name.0),
|
||||
},
|
||||
content,
|
||||
}))
|
||||
})
|
||||
.collect()
|
||||
})?;
|
||||
Ok(Timeline { items: posts })
|
||||
}
|
||||
|
||||
/// Create a new post.
|
||||
pub fn create_post(db: &Store, author: Key, content: impl Into<Content>) -> store::Result<Key> {
|
||||
db.run(|tx| mixin_post(tx, Key::gen(), author, content))
|
||||
}
|
||||
|
||||
/// Add a post's mixins and predicates to an existing `node`.
|
||||
pub fn mixin_post(
|
||||
tx: &Transaction<'_>,
|
||||
node: Key,
|
||||
author: Key,
|
||||
content: impl Into<Content>,
|
||||
) -> store::Result<Key> {
|
||||
tx.update::<Profile>(author, |mut profile| {
|
||||
profile.post_count += 1;
|
||||
profile
|
||||
})?;
|
||||
tx.add_mixin(node, content.into())?;
|
||||
tx.create(AuthorOf { author, object: node })?;
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
pub fn list_posts_by_author(db: &Store, author: Key) -> store::Result<Vec<(Key, Content)>> {
|
||||
db.run(|tx| {
|
||||
let posts = tx
|
||||
.join_on(|a: AuthorOf| a.object, tx.outgoing(author))?
|
||||
.into_iter()
|
||||
.filter_map(|(k, opt)| try { (k, opt?) })
|
||||
.collect();
|
||||
Ok(posts)
|
||||
})
|
||||
}
|
|
@ -1,35 +1,23 @@
|
|||
use std::fmt::{Debug, Display};
|
||||
use std::{
|
||||
fmt::{Debug, Display},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use ulid::Ulid;
|
||||
|
||||
use crate::arrow::{ArrowKind, Basic, Multi};
|
||||
use crate::Error;
|
||||
|
||||
/// A unique identifier for vertices in the database.
|
||||
#[derive(Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
|
||||
pub struct Key(pub(crate) [u8; 16]);
|
||||
|
||||
impl Display for Key {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
Display::fmt(&Ulid::from_bytes(self.0), f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for Key {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Key({})", Ulid::from_bytes(self.0))
|
||||
}
|
||||
}
|
||||
|
||||
impl Key {
|
||||
/// Generate a new node identifier.
|
||||
pub fn gen() -> Key {
|
||||
Key(ulid::Ulid::new().to_bytes())
|
||||
}
|
||||
pub(crate) fn from_slice(buf: &[u8]) -> Key {
|
||||
let mut key = [0; 16];
|
||||
key.copy_from_slice(&buf);
|
||||
Key(key)
|
||||
}
|
||||
/// Get the time at which this key was generated.
|
||||
pub fn timestamp(self) -> DateTime<Utc> {
|
||||
let ms = self.to_ulid().timestamp_ms();
|
||||
DateTime::from_timestamp_millis(ms as i64).unwrap()
|
||||
|
@ -41,11 +29,17 @@ impl Key {
|
|||
buf[16..].copy_from_slice(&other.0);
|
||||
buf
|
||||
}
|
||||
pub(crate) fn from_slice(buf: &[u8]) -> Key {
|
||||
let mut key = [0; 16];
|
||||
key.copy_from_slice(&buf);
|
||||
Key(key)
|
||||
}
|
||||
pub(crate) fn split(buf: &[u8]) -> (Key, Key) {
|
||||
let tail = Key::from_slice(&buf[..16]);
|
||||
let head = Key::from_slice(&buf[16..]);
|
||||
(tail, head)
|
||||
}
|
||||
// TODO: This doesn't belong here lmao
|
||||
pub(crate) fn range(ts: DateTime<Utc>) -> ([u8; 16], [u8; 16]) {
|
||||
let min = Ulid::from_parts(ts.timestamp_millis() as u64, u128::MIN).to_bytes();
|
||||
let max = Ulid::from_parts(ts.timestamp_millis() as u64, u128::MAX).to_bytes();
|
||||
|
@ -61,3 +55,25 @@ impl AsRef<[u8]> for Key {
|
|||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Key {
|
||||
type Err = crate::Error;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
s.parse::<Ulid>()
|
||||
.map(|x| Key(x.to_bytes()))
|
||||
.map_err(|err| Error::BadKey(err))
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for Key {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
Display::fmt(&self.to_ulid(), f)
|
||||
}
|
||||
}
|
||||
|
||||
impl Debug for Key {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Key({})", Ulid::from_bytes(self.0))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -151,6 +151,8 @@ pub enum Error {
|
|||
/// Returned if there is a conflict; for example, if the uniqueness property of an alias would
|
||||
/// be violated by inserting one.
|
||||
Conflict,
|
||||
/// A node key couldn't be decoded.
|
||||
BadKey(ulid::DecodeError),
|
||||
/// Signals a failure related to the data store's backend.
|
||||
Internal(rocksdb::Error),
|
||||
Encoding(bincode::error::EncodeError),
|
||||
|
|
Loading…
Reference in a new issue