Compare commits

..

2 commits

Author SHA1 Message Date
781a56028f
Atproto, types overhaul and error handling
Breaks off from Atrium-rs's types because they are implemented
inconsistently, which makes them harder to use. Additionally, I wanted
sqlx support so I decided I'd need to reimplement them to some extent
anyways.

This was done with reference to the atproto documentation but
specifically not the atrium-rs codebase so I wouldn't have to think
about licenses.

This adds the types and error module in atproto. It also touches
Cargo.toml for some new dependencies and some shared dependencies. It
required thiserror, so I looped that into the workspace meaning that
this commit touches db.

some things to keep in mind:
- There is no CID parsing
- None of this is tested, nor are there any tests written. We're playing
  fast and loose baby~
2025-06-16 15:29:27 -07:00
1abdb7f133
Db working & Migrations 2025-06-13 10:02:01 -07:00
19 changed files with 504 additions and 116 deletions

View file

@ -7,6 +7,7 @@ async-trait = "0.1.88"
atproto = { path = "./atproto" } atproto = { path = "./atproto" }
serde = "1.0.219" serde = "1.0.219"
serde_json = "1.0.140" serde_json = "1.0.140"
thiserror = "2.0.12"
tokio = { version = "1.45.0", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.45.0", features = ["macros", "rt-multi-thread"] }
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"

View file

@ -8,5 +8,7 @@ atrium-api = { version = "0.25.3", default-features = false }
lazy-regex = "3.4.1" lazy-regex = "3.4.1"
serde.workspace = true serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
time = { version = "0.3.41", features = ["parsing", "formatting"] }
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
tracing.workspace = true tracing.workspace = true
thiserror.workspace = true

31
atproto/src/error.rs Normal file
View file

@ -0,0 +1,31 @@
use thiserror::Error as ThisError;
#[non_exhaustive]
#[derive(Debug, ThisError)]
pub enum Error {
#[error("Error while parsing")]
Parse { err: ParseError, object: String },
#[error("Error while formatting")]
Format { err: FormatError, object: String },
}
#[non_exhaustive]
#[derive(Debug, ThisError)]
pub enum FormatError {
#[error("Time Parse Error: {0}")]
Datetime(#[from] time::error::Format),
}
#[non_exhaustive]
#[derive(Debug, ThisError)]
pub enum ParseError {
#[error("Time Parse Error: {0}")]
Datetime(#[from] time::error::Parse),
#[error("Length of parsed object too long, max: {max:?}, got: {got:?}.")]
Length { max: usize, got: usize },
#[error("Currently Did is enforced, cannot use handle, {handle:?}")]
ForceDid { handle: String },
#[error("Incorrectly formatted")]
Format,
}
pub type Result<T> = std::result::Result<T, Error>;

View file

@ -1,80 +1,3 @@
use lazy_regex::regex_captures;
use core::str::FromStr;
use std::fmt::{
Display, Formatter, Result as FmtResult
};
pub use atrium_api::types::{
Collection,
string::{
AtIdentifier as Authority,
Datetime,
Did,
Nsid,
RecordKey,
Tid,
Handle,
}
};
pub mod lexicons; pub mod lexicons;
pub mod types;
pub struct Cid(String); pub mod error;
impl Display for Cid {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "{}", self.0)
}
}
pub struct StrongRef<T> {
pub content: T,
pub cid: Cid,
}
pub struct Uri {
whole: String,
// These fields could be useful in the future,
// so I'm leaving the code for them.
// authority: Authority,
// collection: Option<Nsid>,
// rkey: Option<RecordKey>,
}
impl FromStr for Uri {
type Err = &'static str;
fn from_str(uri: &str) -> Result<Self, Self::Err> {
if uri.len() > 8000 {
return Err("Uri too long")
}
let Some((
whole, unchecked_authority, unchecked_collection, unchecked_rkey
)) = regex_captures!(
r"/^at:\/\/([\w\.\-_~:]+)(?:\/([\w\.\-_~:]+)(?:)\/([\w\.\-_~:]+))?$/i",
uri,
) else {
return Err("Invalid Uri");
};
// This parsing is required, but the values don't need to be used yet.
// No compute cost to use them, just storage cost
let _authority = Authority::from_str(unchecked_authority)?;
let _collection = if unchecked_collection.is_empty() { None }
else { Some(Nsid::new(unchecked_collection.to_string())?) };
let _rkey = if unchecked_rkey.is_empty() { None }
else { Some(RecordKey::new(unchecked_rkey.to_string())?) };
// Ok(Uri{ whole: whole.to_string(), authority, collection, rkey })
Ok(Uri { whole: whole.to_string() })
}
}
impl Uri {
pub fn as_str(&self) -> &str {
self.whole.as_str()
}
}

70
atproto/src/types.rs Normal file
View file

@ -0,0 +1,70 @@
use crate::error::{Error, ParseError};
macro_rules! basic_string_type {
($name:ident, $regex:literal, $max_len:literal) => {
pub struct $name { value: String, }
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.value)
}
}
impl std::str::FromStr for $name {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() > $max_len {
return Err(Error::Parse {
err: ParseError::Length { max: s.len(), got: $max_len },
object: stringify!($name).to_string(),
});
}
if ! lazy_regex::regex_is_match!(
$regex,
s
) {
return Err(Error::Parse {
err: ParseError::Format,
object: stringify!($name).to_string(),
});
}
Ok(Self {
value: s.to_string(),
})
}
}
}
}
mod did;
pub use did::Did;
mod cid;
pub use cid::Cid;
mod authority;
pub use authority::Authority;
mod datetime;
pub use datetime::Datetime;
mod record_key;
pub use record_key::RecordKey;
mod uri;
pub use uri::Uri;
basic_string_type!(Handle,
r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$",
253
);
basic_string_type!(Nsid,
r"^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z0-9]{0,62})?)$",
317
);
basic_string_type!(Tid,
r"^[234567abcdefghij][234567abcdefghijklmnopqrstuvwxyz]{12}$",
13
);
pub struct StrongRef<T> {
content: T,
cid: Cid,
}

View file

@ -0,0 +1,38 @@
use crate::{
types::{Did, Handle},
error::{Error, ParseError},
};
use std::{
fmt::{Display, Formatter, Result as FmtResult},
str::FromStr,
};
pub enum Authority {
Did(Did),
Handle(Handle),
}
impl Display for Authority {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "{}", match self {
Authority::Did(did) => did.to_string(),
Authority::Handle(handle) => handle.to_string(),
})
}
}
impl FromStr for Authority {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(did) = s.parse::<Did>() {
return Ok(Authority::Did(did));
}
if let Ok(did) = s.parse::<Handle>() {
return Ok(Authority::Handle(did));
}
Err(Error::Parse {
err: ParseError::Format,
object: "Authority".to_string(),
})
}
}

16
atproto/src/types/cid.rs Normal file
View file

@ -0,0 +1,16 @@
use crate::error::Error;
pub struct Cid { value: String, }
impl std::fmt::Display for Cid {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.value)
}
}
impl std::str::FromStr for Cid {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Ok(Self { value: s.to_string() })
}
}

View file

@ -0,0 +1,63 @@
use crate::error::{Error, ParseError, FormatError};
use time::{
UtcDateTime,
format_description::well_known::{Rfc3339, Iso8601},
};
use std::{
fmt::{Display, Formatter, Result as FmtResult},
str::FromStr,
};
pub use time::{Date, Time};
pub struct Datetime {
time: UtcDateTime,
derived_string: String,
}
impl Datetime {
pub fn now() -> Result<Self, Error> {
Datetime::from_utc(UtcDateTime::now())
}
pub fn new(date: Date, time: Time) -> Result<Self, Error> {
Datetime::from_utc(UtcDateTime::new(date, time))
}
fn from_utc(utc: UtcDateTime) -> Result<Self, Error> {
Ok(Datetime {
time: utc,
derived_string: match utc.format(&Rfc3339) {
Ok(ds) => ds,
Err(e) => return Err(Error::Format {
err: FormatError::Datetime(e),
object: "Datetime".to_string(),
}),
},
})
}
}
impl Display for Datetime {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "{}", self.derived_string)
}
}
impl FromStr for Datetime {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
// Parse as both Rfc3339 and Iso8601 to ensure intersection
let dt_rfc3339 = UtcDateTime::parse(s, &Rfc3339);
let dt_iso8601 = UtcDateTime::parse(s, &Iso8601::DEFAULT);
let datetime = dt_iso8601
.and(dt_rfc3339)
.map_err(|e| Error::Parse {
err: ParseError::Datetime(e),
object: "Datetime".to_string(),
})?;
Ok(Datetime { time: datetime, derived_string: s.to_string() })
}
}

72
atproto/src/types/did.rs Normal file
View file

@ -0,0 +1,72 @@
use crate::error::{Error, ParseError};
use std::{
fmt::{Display, Formatter, Result as FmtResult},
str::FromStr,
};
use lazy_regex::regex_captures;
enum DidMethod {
Web,
Plc,
}
impl Display for DidMethod {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "{}", match self {
DidMethod::Web => String::from("web"),
DidMethod::Plc => String::from("plc"),
})
}
}
impl FromStr for DidMethod {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"web" => Ok(DidMethod::Web),
"plc" => Ok(DidMethod::Plc),
_ => Err(Error::Parse {
err: ParseError::Format,
object: "DidMethod".to_string(),
}),
}
}
}
pub struct Did {
method: DidMethod,
identifier: String,
}
impl Display for Did {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "did:{}:{}", self.method, self.identifier)
}
}
impl FromStr for Did {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() > 2048 {
return Err(Error::Parse {
err: ParseError::Length { max: s.len(), got: 2048 },
object: "Did".to_string(),
});
}
let Some((
_whole, unchecked_method, identifier
)) = regex_captures!(
r"^did:([a-z]+):([a-zA-Z0-9._:%-]*[a-zA-Z0-9._-])$",
s,
) else {
return Err(Error::Parse {
err: ParseError::Format,
object: "Did".to_string(),
});
};
Ok(Self {
method: unchecked_method.parse::<DidMethod>()?,
identifier: identifier.to_string(),
})
}
}

View file

@ -0,0 +1,63 @@
use crate::{
types::{Nsid, Tid},
error::{Error, ParseError},
};
use std::{
fmt::{Display, Formatter, Result as FmtResult},
str::FromStr,
};
use lazy_regex::regex_is_match;
pub enum RecordKey {
Tid(Tid),
Nsid(Nsid),
Literal(String),
Any(String),
}
impl Display for RecordKey {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "{}", match self {
RecordKey::Tid(tid) => tid.to_string(),
RecordKey::Nsid(nsid) => nsid.to_string(),
RecordKey::Literal(literal) => literal.to_string(),
RecordKey::Any(any) => any.to_string(),
})
}
}
impl FromStr for RecordKey {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() > 512 {
return Err(Error::Parse {
err: ParseError::Length { max: 512, got: s.len() },
object: "RecordKey".to_string(),
});
}
if !(
regex_is_match!(
r"^[a-zA-Z0-9`.-_:~]+$",
s,
)
&& s != "."
&& s != ".."
) {
return Err(Error::Parse {
err: ParseError::Format,
object: "RecordKey".to_string(),
});
}
// Valid record key, now decide type
if s.starts_with("literal:") {
return Ok(RecordKey::Literal(s.to_string()));
}
if let Ok(tid) = s.parse::<Tid>() {
return Ok(RecordKey::Tid(tid));
}
if let Ok(nsid) = s.parse::<Nsid>() {
return Ok(RecordKey::Nsid(nsid));
}
Ok(RecordKey::Any(s.to_string()))
}
}

79
atproto/src/types/uri.rs Normal file
View file

@ -0,0 +1,79 @@
use crate::{
types::{Did, Authority, Nsid, RecordKey},
error::{Error, ParseError},
};
use std::{
fmt::{Display, Formatter, Result as FmtResult},
str::FromStr,
};
use lazy_regex::regex_captures;
pub struct Uri {
authority: Did,
collection: Option<Nsid>,
rkey: Option<RecordKey>,
}
impl Display for Uri {
fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
write!(f, "at://{}", self.authority)?;
if let Some(collection) = &self.collection {
write!(f, "/{}", collection)?;
if let Some(rkey) = &self.rkey {
write!(f, "/{}", rkey)?;
}
}
Ok(())
}
}
impl FromStr for Uri {
type Err = Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
if s.len() > 8000 {
return Err(Error::Parse {
err: ParseError::Length { max: 8000, got: s.len() },
object: "Did".to_string(),
});
}
let Some((
_whole, unchecked_authority, unchecked_collection, unchecked_rkey
)) = regex_captures!(
r"/^at:\/\/([\w\.\-_~:]+)(?:\/([\w\.\-_~:]+)(?:)\/([\w\.\-_~:]+))?$/i",
s,
) else {
return Err(Error::Parse {
err: ParseError::Format,
object: "Uri".to_string(),
});
};
let did = match Authority::from_str(unchecked_authority)? {
Authority::Handle(h) =>
return Err(Error::Parse {
err: ParseError::ForceDid { handle: h.to_string() },
object: "Uri".to_string(),
}),
Authority::Did(d) => d,
};
let collection = if unchecked_collection.is_empty() { None }
else { Some(unchecked_collection.parse::<Nsid>()?) };
let rkey = if unchecked_rkey.is_empty() { None }
else { Some(unchecked_rkey.parse::<RecordKey>()?) };
Ok(Uri { authority: did, collection, rkey })
}
}
impl Uri {
pub fn authority_as_did(&self) -> &Did {
&self.authority
}
}

View file

@ -4,8 +4,8 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
thiserror = "2.0.12"
atproto.workspace = true atproto.workspace = true
async-trait.workspace = true async-trait.workspace = true
sqlx = { version = "0.8.6", features = ["postgres", "runtime-tokio"] } sqlx.workspace = true
thiserror.workspace = true
tokio.workspace = true tokio.workspace = true

View file

@ -0,0 +1,45 @@
-- Add migration script here
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE TABLE actor (
did VARCHAR PRIMARY KEY,
handle VARCHAR UNIQUE,
indexed_at VARCHAR NOT NULL
);
CREATE INDEX actor_handle_trgm_idx ON actor USING gist (handle gist_trgm_ops);
CREATE TABLE session (
uri VARCHAR PRIMARY KEY,
cid VARCHAR NOT NULL,
owner VARCHAR NOT NULL,
content VARCHAR NOT NULL,
contentcid VARCHAR NOT NULL,
label VARCHAR,
-- Participants in participant
created_at VARCHAR NOT NULL,
indexed_at VARCHAR NOT NULL,
sort_at VARCHAR GENERATED ALWAYS AS (LEAST(created_at,indexed_at)) STORED NOT NULL
);
CREATE TABLE activity (
uri VARCHAR PRIMARY KEY,
cid VARCHAR NOT NULL,
session VARCHAR,
sessioncid VARCHAR,
-- Progress in progress
performed_at VARCHAR,
created_at VARCHAR NOT NULL,
indexed_at VARCHAR NOT NULL,
sort_at VARCHAR GENERATED ALWAYS AS (LEAST(created_at,indexed_at)) STORED NOT NULL
);
CREATE TABLE participant (
participantdid VARCHAR NOT NULL,
session VARCHAR NOT NULL,
role VARCHAR NOT NULL
);

View file

@ -1,15 +1,3 @@
use sqlx::{
query,
Database,
Pool,
Postgres,
pool::PoolOptions,
postgres::{
PgConnectOptions,
PgSslMode,
},
Result,
};
use std::string::ToString; use std::string::ToString;
pub struct Db<Db: Database> { pub struct Db<Db: Database> {
@ -39,13 +27,6 @@ pub struct Session {
impl Db<Postgres> { impl Db<Postgres> {
async fn connect() -> Result<Self> { async fn connect() -> Result<Self> {
let conn = PgConnectOptions::new()
.host("localhost")
.port(5432)
.username("postgres")
.password("062217")
.database("anisky")
.ssl_mode(PgSslMode::Disable);
let pool = match PoolOptions::new().connect_with(conn).await { let pool = match PoolOptions::new().connect_with(conn).await {
Ok(p) => p, Ok(p) => p,

8
db/src/error.rs Normal file
View file

@ -0,0 +1,8 @@
#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Database Implementation Error: {0}")]
Backend(#[from] sqlx::Error),
}
pub type Result<T> = std::result::Result<T, Error>;

View file

@ -1,4 +1,4 @@
use crate::DbError; use crate::Error;
use atproto::{ use atproto::{
Cid, Cid,
Uri, Uri,
@ -56,15 +56,15 @@ pub enum Progress {
pub async fn ingest_session( pub async fn ingest_session(
db: PgPool, session: Session db: PgPool, session: Session
) -> Result<(), DbError> { ) -> Result<(), Error> {
let mut transaction = db.begin().await?; let mut transaction = db.begin().await?;
write_session(&mut transaction, session).await?; write_session(&mut transaction, session).await?;
transaction.commit().await.map_err(DbError::Backend) transaction.commit().await.map_err(Error::Backend)
} }
async fn write_session( async fn write_session(
tr: &mut PgTransaction<'_>, session: Session tr: &mut PgTransaction<'_>, session: Session
) -> Result<(), DbError> { ) -> Result<(), Error> {
let (contenturi, contentcid): (Option<Content>, String) = let (contenturi, contentcid): (Option<Content>, String) =
match session.content { match session.content {
@ -94,7 +94,7 @@ async fn write_session(
async fn write_participant( async fn write_participant(
tr: &mut PgTransaction<'_>, participant: Participant, sessionuri: Uri tr: &mut PgTransaction<'_>, participant: Participant, sessionuri: Uri
) -> Result<(), DbError> { ) -> Result<(), Error> {
let (participantType, user): (String, User) = match participant { let (participantType, user): (String, User) = match participant {
Participant::Owner(user) => ("Owner".to_string(), user), Participant::Owner(user) => ("Owner".to_string(), user),
Participant::Added(user) => ("Participant".to_string(), user), Participant::Added(user) => ("Participant".to_string(), user),

View file

@ -1,12 +1,7 @@
use thiserror::Error;
pub mod interfaces; pub mod interfaces;
pub mod error;
pub use crate::error::Error;
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum DbError {
#[error("Database Implementation Error: {0}")]
Backend(#[from] sqlx::Error),
}
// pub struct db; // pub struct db;

6
flake.lock generated
View file

@ -41,11 +41,11 @@
"nixpkgs": "nixpkgs_2" "nixpkgs": "nixpkgs_2"
}, },
"locked": { "locked": {
"lastModified": 1746585402, "lastModified": 1749695868,
"narHash": "sha256-Pf+ufu6bYNA1+KQKHnGMNEfTwpD9ZIcAeLoE2yPWIP0=", "narHash": "sha256-debjTLOyqqsYOUuUGQsAHskFXH5+Kx2t3dOo/FCoNRA=",
"owner": "oxalica", "owner": "oxalica",
"repo": "rust-overlay", "repo": "rust-overlay",
"rev": "72dd969389583664f87aa348b3458f2813693617", "rev": "55f914d5228b5c8120e9e0f9698ed5b7214d09cd",
"type": "github" "type": "github"
}, },
"original": { "original": {

View file

@ -42,6 +42,7 @@
packages = (with pkgs; [ packages = (with pkgs; [
# The package provided by our custom overlay. Includes cargo, Clippy, cargo-fmt, # The package provided by our custom overlay. Includes cargo, Clippy, cargo-fmt,
# rustdoc, rustfmt, and other tools. # rustdoc, rustfmt, and other tools.
sqlx-cli
rustToolchain rustToolchain
]) ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs; [ libiconv ]); ]) ++ pkgs.lib.optionals pkgs.stdenv.isDarwin (with pkgs; [ libiconv ]);
}; };