From ea74daca4809e4008dd8d01039db9fff3be659d9 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Wed, 16 Oct 2024 20:14:33 -0400 Subject: Organizational pass on endpoints and routes. --- src/channel/routes/channel/delete.rs | 39 +++++++++++++++ src/channel/routes/channel/mod.rs | 9 ++++ src/channel/routes/channel/post.rs | 47 ++++++++++++++++++ src/channel/routes/channel/test.rs | 94 ++++++++++++++++++++++++++++++++++++ src/channel/routes/mod.rs | 19 ++++++++ src/channel/routes/post.rs | 49 +++++++++++++++++++ src/channel/routes/test.rs | 83 +++++++++++++++++++++++++++++++ src/channel/routes/test/mod.rs | 2 - src/channel/routes/test/on_create.rs | 88 --------------------------------- src/channel/routes/test/on_send.rs | 94 ------------------------------------ 10 files changed, 340 insertions(+), 184 deletions(-) create mode 100644 src/channel/routes/channel/delete.rs create mode 100644 src/channel/routes/channel/mod.rs create mode 100644 src/channel/routes/channel/post.rs create mode 100644 src/channel/routes/channel/test.rs create mode 100644 src/channel/routes/mod.rs create mode 100644 src/channel/routes/post.rs create mode 100644 src/channel/routes/test.rs delete mode 100644 src/channel/routes/test/mod.rs delete mode 100644 src/channel/routes/test/on_create.rs delete mode 100644 src/channel/routes/test/on_send.rs (limited to 'src/channel/routes') diff --git a/src/channel/routes/channel/delete.rs b/src/channel/routes/channel/delete.rs new file mode 100644 index 0000000..efac0c0 --- /dev/null +++ b/src/channel/routes/channel/delete.rs @@ -0,0 +1,39 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + channel::app, + clock::RequestedAt, + error::{Internal, NotFound}, + login::Login, +}; + +pub async fn handler( + State(app): State, + Path(channel): Path, + RequestedAt(deleted_at): RequestedAt, + _: Login, +) -> Result { + app.channels().delete(&channel, &deleted_at).await?; + + Ok(StatusCode::ACCEPTED) +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub app::Error); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + #[allow(clippy::match_wildcard_for_single_variants)] + match error { + app::Error::NotFound(_) => NotFound(error).into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/channel/routes/channel/mod.rs b/src/channel/routes/channel/mod.rs new file mode 100644 index 0000000..31a9142 --- /dev/null +++ b/src/channel/routes/channel/mod.rs @@ -0,0 +1,9 @@ +use crate::channel::Id; + +pub mod delete; +pub mod post; + +#[cfg(test)] +mod test; + +type PathInfo = Id; diff --git a/src/channel/routes/channel/post.rs b/src/channel/routes/channel/post.rs new file mode 100644 index 0000000..a71a3a0 --- /dev/null +++ b/src/channel/routes/channel/post.rs @@ -0,0 +1,47 @@ +use axum::{ + extract::{Json, Path, State}, + http::StatusCode, + response::{IntoResponse, Response}, +}; + +use crate::{ + app::App, + clock::RequestedAt, + error::{Internal, NotFound}, + login::Login, + message::app::SendError, +}; + +pub async fn handler( + State(app): State, + Path(channel): Path, + RequestedAt(sent_at): RequestedAt, + login: Login, + Json(request): Json, +) -> Result { + app.messages() + .send(&channel, &login, &sent_at, &request.body) + .await?; + + Ok(StatusCode::ACCEPTED) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub body: String, +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub struct Error(#[from] pub SendError); + +impl IntoResponse for Error { + fn into_response(self) -> Response { + let Self(error) = self; + #[allow(clippy::match_wildcard_for_single_variants)] + match error { + SendError::ChannelNotFound(_) => NotFound(error).into_response(), + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/channel/routes/channel/test.rs b/src/channel/routes/channel/test.rs new file mode 100644 index 0000000..bc02b20 --- /dev/null +++ b/src/channel/routes/channel/test.rs @@ -0,0 +1,94 @@ +use axum::extract::{Json, Path, State}; +use futures::stream::StreamExt; + +use super::post; +use crate::{ + channel, + event::{self, Sequenced}, + message::{self, app::SendError}, + test::fixtures::{self, future::Immediately as _}, +}; + +#[tokio::test] +async fn messages_in_order() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::login::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + // Call the endpoint (twice) + + let requests = vec![ + (fixtures::now(), fixtures::message::propose()), + (fixtures::now(), fixtures::message::propose()), + ]; + + for (sent_at, body) in &requests { + let request = post::Request { body: body.clone() }; + + post::handler( + State(app.clone()), + Path(channel.id.clone()), + sent_at.clone(), + sender.clone(), + Json(request), + ) + .await + .expect("sending to a valid channel"); + } + + // Verify the semantics + + let events = app + .events() + .subscribe(None) + .await + .expect("subscribing to a valid channel") + .filter(fixtures::filter::messages()) + .take(requests.len()); + + let events = events.collect::>().immediately().await; + + for ((sent_at, message), event) in requests.into_iter().zip(events) { + assert_eq!(*sent_at, event.at()); + assert!(matches!( + event, + event::Event::Message(message::Event::Sent(event)) + if event.message.sender == sender.id + && event.message.body == message + )); + } +} + +#[tokio::test] +async fn nonexistent_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let login = fixtures::login::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let sent_at = fixtures::now(); + let channel = channel::Id::generate(); + let request = post::Request { + body: fixtures::message::propose(), + }; + let post::Error(error) = post::handler( + State(app), + Path(channel.clone()), + sent_at, + login, + Json(request), + ) + .await + .expect_err("sending to a nonexistent channel"); + + // Verify the structure of the response + + assert!(matches!( + error, + SendError::ChannelNotFound(error_channel) if channel == error_channel + )); +} diff --git a/src/channel/routes/mod.rs b/src/channel/routes/mod.rs new file mode 100644 index 0000000..696bd72 --- /dev/null +++ b/src/channel/routes/mod.rs @@ -0,0 +1,19 @@ +use axum::{ + routing::{delete, post}, + Router, +}; + +use crate::app::App; + +mod channel; +mod post; + +#[cfg(test)] +mod test; + +pub fn router() -> Router { + Router::new() + .route("/api/channels", post(post::handler)) + .route("/api/channels/:channel", post(channel::post::handler)) + .route("/api/channels/:channel", delete(channel::delete::handler)) +} diff --git a/src/channel/routes/post.rs b/src/channel/routes/post.rs new file mode 100644 index 0000000..d694f8b --- /dev/null +++ b/src/channel/routes/post.rs @@ -0,0 +1,49 @@ +use axum::{ + extract::{Json, State}, + http::StatusCode, + response::{self, IntoResponse}, +}; + +use crate::{ + app::App, + channel::{app, Channel}, + clock::RequestedAt, + error::Internal, + login::Login, +}; + +pub async fn handler( + State(app): State, + _: Login, // requires auth, but doesn't actually care who you are + RequestedAt(created_at): RequestedAt, + Json(request): Json, +) -> Result, Error> { + let channel = app + .channels() + .create(&request.name, &created_at) + .await + .map_err(Error)?; + + Ok(Json(channel)) +} + +#[derive(serde::Deserialize)] +pub struct Request { + pub name: String, +} + +#[derive(Debug)] +pub struct Error(pub app::CreateError); + +impl IntoResponse for Error { + fn into_response(self) -> response::Response { + let Self(error) = self; + #[allow(clippy::match_wildcard_for_single_variants)] + match error { + app::CreateError::DuplicateName(_) => { + (StatusCode::CONFLICT, error.to_string()).into_response() + } + other => Internal::from(other).into_response(), + } + } +} diff --git a/src/channel/routes/test.rs b/src/channel/routes/test.rs new file mode 100644 index 0000000..81f1465 --- /dev/null +++ b/src/channel/routes/test.rs @@ -0,0 +1,83 @@ +use axum::extract::{Json, State}; +use futures::stream::StreamExt as _; + +use super::post; +use crate::{ + channel::{self, app}, + event, + test::fixtures::{self, future::Immediately as _}, +}; + +#[tokio::test] +async fn new_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::login::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let name = fixtures::channel::propose(); + let request = post::Request { name: name.clone() }; + let Json(response_channel) = + post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect("new channel in an empty app"); + + // Verify the structure of the response + + assert_eq!(name, response_channel.name); + + // Verify the semantics + + let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); + assert!(snapshot + .channels + .iter() + .any(|channel| channel.name == response_channel.name && channel.id == response_channel.id)); + + let mut events = app + .events() + .subscribe(None) + .await + .expect("subscribing never fails") + .filter(fixtures::filter::created()); + + let event = events + .next() + .immediately() + .await + .expect("creation event published"); + + assert!(matches!( + event, + event::Event::Channel(channel::Event::Created(event)) + if event.channel == response_channel + )); +} + +#[tokio::test] +async fn duplicate_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::login::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let request = post::Request { + name: channel.name.clone(), + }; + let post::Error(error) = + post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect_err("duplicate channel name should fail the request"); + + // Verify the structure of the response + + assert!(matches!( + error, + app::CreateError::DuplicateName(name) if channel.name == name + )); +} diff --git a/src/channel/routes/test/mod.rs b/src/channel/routes/test/mod.rs deleted file mode 100644 index 3e5aa17..0000000 --- a/src/channel/routes/test/mod.rs +++ /dev/null @@ -1,2 +0,0 @@ -mod on_create; -mod on_send; diff --git a/src/channel/routes/test/on_create.rs b/src/channel/routes/test/on_create.rs deleted file mode 100644 index eeecc7f..0000000 --- a/src/channel/routes/test/on_create.rs +++ /dev/null @@ -1,88 +0,0 @@ -use axum::extract::{Json, State}; -use futures::stream::StreamExt as _; - -use crate::{ - channel::{self, app, routes}, - event, - test::fixtures::{self, future::Immediately as _}, -}; - -#[tokio::test] -async fn new_channel() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let creator = fixtures::login::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let name = fixtures::channel::propose(); - let request = routes::CreateRequest { name }; - let Json(response_channel) = routes::on_create( - State(app.clone()), - creator, - fixtures::now(), - Json(request.clone()), - ) - .await - .expect("new channel in an empty app"); - - // Verify the structure of the response - - assert_eq!(request.name, response_channel.name); - - // Verify the semantics - - let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); - assert!(snapshot - .channels - .iter() - .any(|channel| channel.name == response_channel.name && channel.id == response_channel.id)); - - let mut events = app - .events() - .subscribe(None) - .await - .expect("subscribing never fails") - .filter(fixtures::filter::created()); - - let event = events - .next() - .immediately() - .await - .expect("creation event published"); - - assert!(matches!( - event, - event::Event::Channel(channel::Event::Created(event)) - if event.channel == response_channel - )); -} - -#[tokio::test] -async fn duplicate_name() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let creator = fixtures::login::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let request = routes::CreateRequest { name: channel.name }; - let routes::CreateError(error) = routes::on_create( - State(app.clone()), - creator, - fixtures::now(), - Json(request.clone()), - ) - .await - .expect_err("duplicate channel name"); - - // Verify the structure of the response - - assert!(matches!( - error, - app::CreateError::DuplicateName(name) if request.name == name - )); -} diff --git a/src/channel/routes/test/on_send.rs b/src/channel/routes/test/on_send.rs deleted file mode 100644 index 293cc56..0000000 --- a/src/channel/routes/test/on_send.rs +++ /dev/null @@ -1,94 +0,0 @@ -use axum::extract::{Json, Path, State}; -use futures::stream::StreamExt; - -use crate::{ - channel, - channel::routes, - event::{self, Sequenced}, - message::{self, app::SendError}, - test::fixtures::{self, future::Immediately as _}, -}; - -#[tokio::test] -async fn messages_in_order() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - // Call the endpoint (twice) - - let requests = vec![ - (fixtures::now(), fixtures::message::propose()), - (fixtures::now(), fixtures::message::propose()), - ]; - - for (sent_at, body) in &requests { - let request = routes::SendRequest { body: body.clone() }; - - routes::on_send( - State(app.clone()), - Path(channel.id.clone()), - sent_at.clone(), - sender.clone(), - Json(request.clone()), - ) - .await - .expect("sending to a valid channel"); - } - - // Verify the semantics - - let events = app - .events() - .subscribe(None) - .await - .expect("subscribing to a valid channel") - .filter(fixtures::filter::messages()) - .take(requests.len()); - - let events = events.collect::>().immediately().await; - - for ((sent_at, message), event) in requests.into_iter().zip(events) { - assert_eq!(*sent_at, event.at()); - assert!(matches!( - event, - event::Event::Message(message::Event::Sent(event)) - if event.message.sender == sender.id - && event.message.body == message - )); - } -} - -#[tokio::test] -async fn nonexistent_channel() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let login = fixtures::login::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let sent_at = fixtures::now(); - let channel = channel::Id::generate(); - let request = routes::SendRequest { - body: fixtures::message::propose(), - }; - let routes::SendErrorResponse(error) = routes::on_send( - State(app), - Path(channel.clone()), - sent_at, - login, - Json(request), - ) - .await - .expect_err("sending to a nonexistent channel"); - - // Verify the structure of the response - - assert!(matches!( - error, - SendError::ChannelNotFound(error_channel) if channel == error_channel - )); -} -- cgit v1.2.3 From 777e4281431a036eb663b5eec70f347b7425737d Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Thu, 17 Oct 2024 01:45:58 -0400 Subject: Retain deleted messages and channels temporarily, to preserve events for replay. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, when a channel (message) was deleted, `hi` would send events to all _connected_ clients to inform them of the deletion, then delete all memory of the channel (message). Any disconnected client, on reconnecting, would not receive the deletion event, and would de-synch with the service. The creation events were also immediately retconned out of the event stream, as well. With this change, `hi` keeps a record of deleted channels (messages). When replaying events, these records are used to replay the deletion event. After 7 days, the retained data is deleted, both to keep storage under control and to conform to users' expectations that deleted means gone. To match users' likely intuitions about what deletion does, deleting a channel (message) _does_ immediately delete some of its associated data. Channels' names are blanked, and messages' bodies are also blanked. When the event stream is replayed, the original channel.created (message.sent) event is "tombstoned", with an additional `deleted_at` field to inform clients. The included client does not use this field, at least yet. The migration is, once again, screamingingly complicated due to sqlite's limited ALTER TABLE … ALTER COLUMN support. This change also contains capabilities that would allow the API to return 410 Gone for deleted channels or messages, instead of 404. I did experiment with this, but it's tricky to do pervasively, especially since most app-level interfaces return an `Option` or `Option`. Redesigning these to return either `Ok(Channel)` (`Ok(Message)`) or `Err(Error::NotFound)` or `Err(Error::Deleted)` is more work than I wanted to take on for this change, and the utility of 410 Gone responses is not obvious to me. We have other, more pressing API design warts to address. --- ...ccdde2e9e4a49e52e74c3d0e28e82a0b01a3c9887f.json | 20 ++ ...86018161718e2a6788413bffffb252de3e1959f341.json | 38 ---- ...7e56e515d121964d3481e48aae3558b53a5123ce7d.json | 50 ----- ...4a4d68d68c8d9f8b9483af48de5d4e09857181d632.json | 20 ++ ...4abfbb9e06a5889460743202ca7956acabf006843e.json | 20 -- ...0c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json | 62 +++++++ ...11f4726612141f7242decad1de5d9d5f166cb04990.json | 20 ++ ...8da75962b278d8191f77ffdea8dad8441f2bf72786.json | 12 ++ ...767190eeaaf479b458c18f79ff62c46b7aac158a77.json | 62 +++++++ ...1fc629c8edba240926d21fbe03d884e2a48b034336.json | 50 +++++ ...89d6e1a0eb2131b6581759a5028afb6a0910284803.json | 12 ++ ...e65e101d0941f2a266679209f793fd27466b9e3719.json | 50 +++++ ...ae7e18ecb51d54d1e304dcbd847de145c0c75423a7.json | 20 ++ ...fc8c187294a4661c18c2d820f4379dfd82138a8f77.json | 38 ---- ...48f3b75adcb3ddf8a88439aa8ddb0c66be2f0b384e.json | 50 ----- ...afabe3524caa9882d0cba0dc4359eb62cf8154c92d.json | 50 ----- ...314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json | 20 ++ ...df5de8c7d9ad11bb09a0d0d243181d97c79d771071.json | 20 -- ...b3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json | 62 +++++++ ...6a6a72a129f9484b4d9b9e62e7c546d965cdde721a.json | 62 +++++++ ...8143f6b5d16dbeb19ad13ac36dcb40851f0af238e8.json | 38 ---- ...55c7cf413825151e1cda939378984611dc2d9a789a.json | 20 ++ ...b2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json | 50 +++++ ...075d18b090751a345ac2e90bb8c116e0fbb5dfbb99.json | 50 ----- ...e32c2cc4f93832948768dcbd1d12abcdbc5089ca8e.json | 50 +++++ ...5294ae908114b03719dc0cb237cec11f807bf757b1.json | 62 +++++++ ...87ec44bfea55ae259cc5fd412bdc30881b98ed12f8.json | 20 ++ ...ad2d2dec42949522f182a61bfb249f13ee78564179.json | 20 -- ...20ac429b55db93ceb5ce501917188e7b2f14975f34.json | 12 ++ ...96a0e7b12a920f9827aff2b05ee0364ff7688a38ae.json | 38 ---- docs/api/channels-messages.md | 12 +- docs/api/events.md | 34 ++-- migrations/20241017005219_retain_deleted.sql | 92 ++++++++++ src/channel/app.rs | 30 ++- src/channel/history.rs | 5 + src/channel/repo.rs | 196 ++++++++++++++------ src/channel/routes/channel/delete.rs | 2 +- src/channel/routes/channel/test.rs | 6 +- src/channel/routes/test.rs | 106 +++++++++-- src/channel/snapshot.rs | 3 + src/event/repo.rs | 5 +- src/event/routes/test.rs | 33 ++-- src/event/sequence.rs | 11 ++ src/expire.rs | 2 + src/login/repo.rs | 15 +- src/message/app.rs | 24 ++- src/message/history.rs | 5 + src/message/repo.rs | 204 ++++++++++++++------- src/message/routes/message.rs | 6 +- src/message/snapshot.rs | 4 +- src/test/fixtures/channel.rs | 23 ++- src/test/fixtures/event.rs | 7 +- src/test/fixtures/filter.rs | 11 -- src/test/fixtures/message.rs | 18 +- src/test/fixtures/mod.rs | 1 - src/token/repo/auth.rs | 5 +- 56 files changed, 1321 insertions(+), 637 deletions(-) create mode 100644 .sqlx/query-15ec7a8aa11908f8b2022accdde2e9e4a49e52e74c3d0e28e82a0b01a3c9887f.json delete mode 100644 .sqlx/query-1654b05159c27f74cb333586018161718e2a6788413bffffb252de3e1959f341.json delete mode 100644 .sqlx/query-191255b9e55c9b36d0fd047e56e515d121964d3481e48aae3558b53a5123ce7d.json create mode 100644 .sqlx/query-2252b49c77ff76a88ee7d24a4d68d68c8d9f8b9483af48de5d4e09857181d632.json delete mode 100644 .sqlx/query-33f9a143409e6f436ed6b64abfbb9e06a5889460743202ca7956acabf006843e.json create mode 100644 .sqlx/query-400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json create mode 100644 .sqlx/query-487092b18b01005fdb446c11f4726612141f7242decad1de5d9d5f166cb04990.json create mode 100644 .sqlx/query-58c17422db7edd8f0462378da75962b278d8191f77ffdea8dad8441f2bf72786.json create mode 100644 .sqlx/query-6c82af3b136a6719d52f49767190eeaaf479b458c18f79ff62c46b7aac158a77.json create mode 100644 .sqlx/query-771d1afa5961bae86112a51fc629c8edba240926d21fbe03d884e2a48b034336.json create mode 100644 .sqlx/query-814400dfe985ef284db22c89d6e1a0eb2131b6581759a5028afb6a0910284803.json create mode 100644 .sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json create mode 100644 .sqlx/query-8eebba45e8267b9eebc7b1ae7e18ecb51d54d1e304dcbd847de145c0c75423a7.json delete mode 100644 .sqlx/query-9386cdaa2cb41f5a7e19d2fc8c187294a4661c18c2d820f4379dfd82138a8f77.json delete mode 100644 .sqlx/query-9f83f0365b174ce61adb6d48f3b75adcb3ddf8a88439aa8ddb0c66be2f0b384e.json delete mode 100644 .sqlx/query-afe4404b44bd8ce6c9a51aafabe3524caa9882d0cba0dc4359eb62cf8154c92d.json create mode 100644 .sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json delete mode 100644 .sqlx/query-b7e05e2b2eb5484c954bcedf5de8c7d9ad11bb09a0d0d243181d97c79d771071.json create mode 100644 .sqlx/query-bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json create mode 100644 .sqlx/query-c90cbe4c836adf7f104b7b6a6a72a129f9484b4d9b9e62e7c546d965cdde721a.json delete mode 100644 .sqlx/query-cda3a4a974eb986ebe26838143f6b5d16dbeb19ad13ac36dcb40851f0af238e8.json create mode 100644 .sqlx/query-d6feb736b61c645649082e55c7cf413825151e1cda939378984611dc2d9a789a.json create mode 100644 .sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json delete mode 100644 .sqlx/query-e476c3fb3f2227b7421ad2075d18b090751a345ac2e90bb8c116e0fbb5dfbb99.json create mode 100644 .sqlx/query-e621d064ba52cd13b196bce32c2cc4f93832948768dcbd1d12abcdbc5089ca8e.json create mode 100644 .sqlx/query-e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1.json create mode 100644 .sqlx/query-ea5d8528095f8885b7e31687ec44bfea55ae259cc5fd412bdc30881b98ed12f8.json delete mode 100644 .sqlx/query-f3a338b9e4a65856decd79ad2d2dec42949522f182a61bfb249f13ee78564179.json create mode 100644 .sqlx/query-f511d351018c908bf303ae20ac429b55db93ceb5ce501917188e7b2f14975f34.json delete mode 100644 .sqlx/query-f6909336ab05b7ad423c7b96a0e7b12a920f9827aff2b05ee0364ff7688a38ae.json create mode 100644 migrations/20241017005219_retain_deleted.sql delete mode 100644 src/test/fixtures/filter.rs (limited to 'src/channel/routes') diff --git a/.sqlx/query-15ec7a8aa11908f8b2022accdde2e9e4a49e52e74c3d0e28e82a0b01a3c9887f.json b/.sqlx/query-15ec7a8aa11908f8b2022accdde2e9e4a49e52e74c3d0e28e82a0b01a3c9887f.json new file mode 100644 index 0000000..167e768 --- /dev/null +++ b/.sqlx/query-15ec7a8aa11908f8b2022accdde2e9e4a49e52e74c3d0e28e82a0b01a3c9887f.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n insert into message_deleted (id, deleted_at, deleted_sequence)\n values ($1, $2, $3)\n returning 1 as \"deleted: bool\"\n ", + "describe": { + "columns": [ + { + "name": "deleted: bool", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + null + ] + }, + "hash": "15ec7a8aa11908f8b2022accdde2e9e4a49e52e74c3d0e28e82a0b01a3c9887f" +} diff --git a/.sqlx/query-1654b05159c27f74cb333586018161718e2a6788413bffffb252de3e1959f341.json b/.sqlx/query-1654b05159c27f74cb333586018161718e2a6788413bffffb252de3e1959f341.json deleted file mode 100644 index cc716ed..0000000 --- a/.sqlx/query-1654b05159c27f74cb333586018161718e2a6788413bffffb252de3e1959f341.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n delete from channel\n where id = $1\n returning\n id as \"id: Id\",\n name,\n created_at as \"created_at: DateTime\",\n created_sequence as \"created_sequence: Sequence\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "1654b05159c27f74cb333586018161718e2a6788413bffffb252de3e1959f341" -} diff --git a/.sqlx/query-191255b9e55c9b36d0fd047e56e515d121964d3481e48aae3558b53a5123ce7d.json b/.sqlx/query-191255b9e55c9b36d0fd047e56e515d121964d3481e48aae3558b53a5123ce7d.json deleted file mode 100644 index fe443f9..0000000 --- a/.sqlx/query-191255b9e55c9b36d0fd047e56e515d121964d3481e48aae3558b53a5123ce7d.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n channel as \"channel: channel::Id\",\n sender as \"sender: login::Id\",\n id as \"id: Id\",\n body,\n sent_at as \"sent_at: DateTime\",\n sent_sequence as \"sent_sequence: Sequence\"\n from message\n where coalesce(sent_sequence <= $2, true)\n order by sent_sequence\n ", - "describe": { - "columns": [ - { - "name": "channel: channel::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "sender: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "id: Id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "body", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "sent_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "sent_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "191255b9e55c9b36d0fd047e56e515d121964d3481e48aae3558b53a5123ce7d" -} diff --git a/.sqlx/query-2252b49c77ff76a88ee7d24a4d68d68c8d9f8b9483af48de5d4e09857181d632.json b/.sqlx/query-2252b49c77ff76a88ee7d24a4d68d68c8d9f8b9483af48de5d4e09857181d632.json new file mode 100644 index 0000000..9975fd1 --- /dev/null +++ b/.sqlx/query-2252b49c77ff76a88ee7d24a4d68d68c8d9f8b9483af48de5d4e09857181d632.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n update channel\n set name = \"\"\n where id = $1\n returning 1 as \"updated: bool\"\n ", + "describe": { + "columns": [ + { + "name": "updated: bool", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + null + ] + }, + "hash": "2252b49c77ff76a88ee7d24a4d68d68c8d9f8b9483af48de5d4e09857181d632" +} diff --git a/.sqlx/query-33f9a143409e6f436ed6b64abfbb9e06a5889460743202ca7956acabf006843e.json b/.sqlx/query-33f9a143409e6f436ed6b64abfbb9e06a5889460743202ca7956acabf006843e.json deleted file mode 100644 index 1480953..0000000 --- a/.sqlx/query-33f9a143409e6f436ed6b64abfbb9e06a5889460743202ca7956acabf006843e.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n delete from message\n where\n id = $1\n returning 1 as \"deleted: i64\"\n ", - "describe": { - "columns": [ - { - "name": "deleted: i64", - "ordinal": 0, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - null - ] - }, - "hash": "33f9a143409e6f436ed6b64abfbb9e06a5889460743202ca7956acabf006843e" -} diff --git a/.sqlx/query-400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json b/.sqlx/query-400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json new file mode 100644 index 0000000..3d77307 --- /dev/null +++ b/.sqlx/query-400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n message.body,\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where coalesce(message.sent_sequence > $1, true)\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "channel: channel::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "body", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91" +} diff --git a/.sqlx/query-487092b18b01005fdb446c11f4726612141f7242decad1de5d9d5f166cb04990.json b/.sqlx/query-487092b18b01005fdb446c11f4726612141f7242decad1de5d9d5f166cb04990.json new file mode 100644 index 0000000..c919eb2 --- /dev/null +++ b/.sqlx/query-487092b18b01005fdb446c11f4726612141f7242decad1de5d9d5f166cb04990.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n with has_messages as (\n select channel\n from message\n group by channel\n )\n delete from channel_deleted\n where deleted_at < $1\n and id not in has_messages\n returning id as \"id: Id\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "487092b18b01005fdb446c11f4726612141f7242decad1de5d9d5f166cb04990" +} diff --git a/.sqlx/query-58c17422db7edd8f0462378da75962b278d8191f77ffdea8dad8441f2bf72786.json b/.sqlx/query-58c17422db7edd8f0462378da75962b278d8191f77ffdea8dad8441f2bf72786.json new file mode 100644 index 0000000..ebfb579 --- /dev/null +++ b/.sqlx/query-58c17422db7edd8f0462378da75962b278d8191f77ffdea8dad8441f2bf72786.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into channel_name_reservation (id, name)\n values ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 2 + }, + "nullable": [] + }, + "hash": "58c17422db7edd8f0462378da75962b278d8191f77ffdea8dad8441f2bf72786" +} diff --git a/.sqlx/query-6c82af3b136a6719d52f49767190eeaaf479b458c18f79ff62c46b7aac158a77.json b/.sqlx/query-6c82af3b136a6719d52f49767190eeaaf479b458c18f79ff62c46b7aac158a77.json new file mode 100644 index 0000000..4251f92 --- /dev/null +++ b/.sqlx/query-6c82af3b136a6719d52f49767190eeaaf479b458c18f79ff62c46b7aac158a77.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body,\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "channel: channel::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "id: Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "body", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "6c82af3b136a6719d52f49767190eeaaf479b458c18f79ff62c46b7aac158a77" +} diff --git a/.sqlx/query-771d1afa5961bae86112a51fc629c8edba240926d21fbe03d884e2a48b034336.json b/.sqlx/query-771d1afa5961bae86112a51fc629c8edba240926d21fbe03d884e2a48b034336.json new file mode 100644 index 0000000..8a92f44 --- /dev/null +++ b/.sqlx/query-771d1afa5961bae86112a51fc629c8edba240926d21fbe03d884e2a48b034336.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n channel.id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n left join message\n where channel.created_at < $1\n and message.id is null\n and deleted.id is null\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "771d1afa5961bae86112a51fc629c8edba240926d21fbe03d884e2a48b034336" +} diff --git a/.sqlx/query-814400dfe985ef284db22c89d6e1a0eb2131b6581759a5028afb6a0910284803.json b/.sqlx/query-814400dfe985ef284db22c89d6e1a0eb2131b6581759a5028afb6a0910284803.json new file mode 100644 index 0000000..4bcf6d0 --- /dev/null +++ b/.sqlx/query-814400dfe985ef284db22c89d6e1a0eb2131b6581759a5028afb6a0910284803.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete from channel\n where id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "814400dfe985ef284db22c89d6e1a0eb2131b6581759a5028afb6a0910284803" +} diff --git a/.sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json b/.sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json new file mode 100644 index 0000000..c09bdc6 --- /dev/null +++ b/.sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence <= $1, true)\n order by channel.name\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719" +} diff --git a/.sqlx/query-8eebba45e8267b9eebc7b1ae7e18ecb51d54d1e304dcbd847de145c0c75423a7.json b/.sqlx/query-8eebba45e8267b9eebc7b1ae7e18ecb51d54d1e304dcbd847de145c0c75423a7.json new file mode 100644 index 0000000..0394bc5 --- /dev/null +++ b/.sqlx/query-8eebba45e8267b9eebc7b1ae7e18ecb51d54d1e304dcbd847de145c0c75423a7.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n update message\n set body = \"\"\n where id = $1\n returning 1 as \"blanked: bool\"\n ", + "describe": { + "columns": [ + { + "name": "blanked: bool", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + null + ] + }, + "hash": "8eebba45e8267b9eebc7b1ae7e18ecb51d54d1e304dcbd847de145c0c75423a7" +} diff --git a/.sqlx/query-9386cdaa2cb41f5a7e19d2fc8c187294a4661c18c2d820f4379dfd82138a8f77.json b/.sqlx/query-9386cdaa2cb41f5a7e19d2fc8c187294a4661c18c2d820f4379dfd82138a8f77.json deleted file mode 100644 index e9c3967..0000000 --- a/.sqlx/query-9386cdaa2cb41f5a7e19d2fc8c187294a4661c18c2d820f4379dfd82138a8f77.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n name,\n created_at as \"created_at: DateTime\",\n created_sequence as \"created_sequence: Sequence\"\n from channel\n where coalesce(created_sequence > $1, true)\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "9386cdaa2cb41f5a7e19d2fc8c187294a4661c18c2d820f4379dfd82138a8f77" -} diff --git a/.sqlx/query-9f83f0365b174ce61adb6d48f3b75adcb3ddf8a88439aa8ddb0c66be2f0b384e.json b/.sqlx/query-9f83f0365b174ce61adb6d48f3b75adcb3ddf8a88439aa8ddb0c66be2f0b384e.json deleted file mode 100644 index c8ce115..0000000 --- a/.sqlx/query-9f83f0365b174ce61adb6d48f3b75adcb3ddf8a88439aa8ddb0c66be2f0b384e.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n channel as \"channel: channel::Id\",\n sender as \"sender: login::Id\",\n id as \"id: Id\",\n body,\n sent_at as \"sent_at: DateTime\",\n sent_sequence as \"sent_sequence: Sequence\"\n from message\n where id = $1\n ", - "describe": { - "columns": [ - { - "name": "channel: channel::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "sender: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "id: Id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "body", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "sent_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "sent_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "9f83f0365b174ce61adb6d48f3b75adcb3ddf8a88439aa8ddb0c66be2f0b384e" -} diff --git a/.sqlx/query-afe4404b44bd8ce6c9a51aafabe3524caa9882d0cba0dc4359eb62cf8154c92d.json b/.sqlx/query-afe4404b44bd8ce6c9a51aafabe3524caa9882d0cba0dc4359eb62cf8154c92d.json deleted file mode 100644 index cc8a074..0000000 --- a/.sqlx/query-afe4404b44bd8ce6c9a51aafabe3524caa9882d0cba0dc4359eb62cf8154c92d.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n channel as \"channel: channel::Id\",\n sender as \"sender: login::Id\",\n id as \"id: Id\",\n body,\n sent_at as \"sent_at: DateTime\",\n sent_sequence as \"sent_sequence: Sequence\"\n from message\n where channel = $1\n and coalesce(sent_sequence <= $2, true)\n order by sent_sequence\n ", - "describe": { - "columns": [ - { - "name": "channel: channel::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "sender: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "id: Id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "body", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "sent_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "sent_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 2 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "afe4404b44bd8ce6c9a51aafabe3524caa9882d0cba0dc4359eb62cf8154c92d" -} diff --git a/.sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json b/.sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json new file mode 100644 index 0000000..5825ce8 --- /dev/null +++ b/.sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n insert into channel_deleted (id, deleted_at, deleted_sequence)\n values ($1, $2, $3)\n returning 1 as \"deleted: bool\"\n ", + "describe": { + "columns": [ + { + "name": "deleted: bool", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 3 + }, + "nullable": [ + null + ] + }, + "hash": "b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0" +} diff --git a/.sqlx/query-b7e05e2b2eb5484c954bcedf5de8c7d9ad11bb09a0d0d243181d97c79d771071.json b/.sqlx/query-b7e05e2b2eb5484c954bcedf5de8c7d9ad11bb09a0d0d243181d97c79d771071.json deleted file mode 100644 index b82727f..0000000 --- a/.sqlx/query-b7e05e2b2eb5484c954bcedf5de8c7d9ad11bb09a0d0d243181d97c79d771071.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n channel.id as \"id: Id\"\n from channel\n left join message\n where created_at < $1\n and message.id is null\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "b7e05e2b2eb5484c954bcedf5de8c7d9ad11bb09a0d0d243181d97c79d771071" -} diff --git a/.sqlx/query-bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json b/.sqlx/query-bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json new file mode 100644 index 0000000..f454fbb --- /dev/null +++ b/.sqlx/query-bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body,\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where coalesce(message.sent_sequence <= $2, true)\n order by message.sent_sequence\n ", + "describe": { + "columns": [ + { + "name": "channel: channel::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "id: Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "body", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d" +} diff --git a/.sqlx/query-c90cbe4c836adf7f104b7b6a6a72a129f9484b4d9b9e62e7c546d965cdde721a.json b/.sqlx/query-c90cbe4c836adf7f104b7b6a6a72a129f9484b4d9b9e62e7c546d965cdde721a.json new file mode 100644 index 0000000..eb41cba --- /dev/null +++ b/.sqlx/query-c90cbe4c836adf7f104b7b6a6a72a129f9484b4d9b9e62e7c546d965cdde721a.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n message.body,\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where message.sent_at < $1\n and deleted.id is null\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "channel: channel::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "body", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "c90cbe4c836adf7f104b7b6a6a72a129f9484b4d9b9e62e7c546d965cdde721a" +} diff --git a/.sqlx/query-cda3a4a974eb986ebe26838143f6b5d16dbeb19ad13ac36dcb40851f0af238e8.json b/.sqlx/query-cda3a4a974eb986ebe26838143f6b5d16dbeb19ad13ac36dcb40851f0af238e8.json deleted file mode 100644 index bce6a88..0000000 --- a/.sqlx/query-cda3a4a974eb986ebe26838143f6b5d16dbeb19ad13ac36dcb40851f0af238e8.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n name,\n created_at as \"created_at: DateTime\",\n created_sequence as \"created_sequence: Sequence\"\n from channel\n where coalesce(created_sequence <= $1, true)\n order by channel.name\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "cda3a4a974eb986ebe26838143f6b5d16dbeb19ad13ac36dcb40851f0af238e8" -} diff --git a/.sqlx/query-d6feb736b61c645649082e55c7cf413825151e1cda939378984611dc2d9a789a.json b/.sqlx/query-d6feb736b61c645649082e55c7cf413825151e1cda939378984611dc2d9a789a.json new file mode 100644 index 0000000..f96ceca --- /dev/null +++ b/.sqlx/query-d6feb736b61c645649082e55c7cf413825151e1cda939378984611dc2d9a789a.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n delete from channel_name_reservation\n where id = $1\n returning 1 as \"deleted: bool\"\n ", + "describe": { + "columns": [ + { + "name": "deleted: bool", + "ordinal": 0, + "type_info": "Null" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + null + ] + }, + "hash": "d6feb736b61c645649082e55c7cf413825151e1cda939378984611dc2d9a789a" +} diff --git a/.sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json b/.sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json new file mode 100644 index 0000000..b8ab769 --- /dev/null +++ b/.sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence > $1, true)\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc" +} diff --git a/.sqlx/query-e476c3fb3f2227b7421ad2075d18b090751a345ac2e90bb8c116e0fbb5dfbb99.json b/.sqlx/query-e476c3fb3f2227b7421ad2075d18b090751a345ac2e90bb8c116e0fbb5dfbb99.json deleted file mode 100644 index 821bd8f..0000000 --- a/.sqlx/query-e476c3fb3f2227b7421ad2075d18b090751a345ac2e90bb8c116e0fbb5dfbb99.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n channel as \"channel: channel::Id\",\n sender as \"sender: login::Id\",\n id as \"id: Id\",\n body,\n sent_at as \"sent_at: DateTime\",\n sent_sequence as \"sent_sequence: Sequence\"\n from message\n where coalesce(message.sent_sequence > $1, true)\n ", - "describe": { - "columns": [ - { - "name": "channel: channel::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "sender: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "id: Id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "body", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "sent_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "sent_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - false - ] - }, - "hash": "e476c3fb3f2227b7421ad2075d18b090751a345ac2e90bb8c116e0fbb5dfbb99" -} diff --git a/.sqlx/query-e621d064ba52cd13b196bce32c2cc4f93832948768dcbd1d12abcdbc5089ca8e.json b/.sqlx/query-e621d064ba52cd13b196bce32c2cc4f93832948768dcbd1d12abcdbc5089ca8e.json new file mode 100644 index 0000000..d56cec0 --- /dev/null +++ b/.sqlx/query-e621d064ba52cd13b196bce32c2cc4f93832948768dcbd1d12abcdbc5089ca8e.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + true, + true + ] + }, + "hash": "e621d064ba52cd13b196bce32c2cc4f93832948768dcbd1d12abcdbc5089ca8e" +} diff --git a/.sqlx/query-e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1.json b/.sqlx/query-e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1.json new file mode 100644 index 0000000..0b48645 --- /dev/null +++ b/.sqlx/query-e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body,\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where message.channel = $1\n and deleted.id is null\n ", + "describe": { + "columns": [ + { + "name": "channel: channel::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "id: Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "body", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1" +} diff --git a/.sqlx/query-ea5d8528095f8885b7e31687ec44bfea55ae259cc5fd412bdc30881b98ed12f8.json b/.sqlx/query-ea5d8528095f8885b7e31687ec44bfea55ae259cc5fd412bdc30881b98ed12f8.json new file mode 100644 index 0000000..68b7155 --- /dev/null +++ b/.sqlx/query-ea5d8528095f8885b7e31687ec44bfea55ae259cc5fd412bdc30881b98ed12f8.json @@ -0,0 +1,20 @@ +{ + "db_name": "SQLite", + "query": "\n delete from message_deleted\n where deleted_at < $1\n returning id as \"id: Id\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false + ] + }, + "hash": "ea5d8528095f8885b7e31687ec44bfea55ae259cc5fd412bdc30881b98ed12f8" +} diff --git a/.sqlx/query-f3a338b9e4a65856decd79ad2d2dec42949522f182a61bfb249f13ee78564179.json b/.sqlx/query-f3a338b9e4a65856decd79ad2d2dec42949522f182a61bfb249f13ee78564179.json deleted file mode 100644 index 92a64a3..0000000 --- a/.sqlx/query-f3a338b9e4a65856decd79ad2d2dec42949522f182a61bfb249f13ee78564179.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"message: Id\"\n from message\n where sent_at < $1\n ", - "describe": { - "columns": [ - { - "name": "message: Id", - "ordinal": 0, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false - ] - }, - "hash": "f3a338b9e4a65856decd79ad2d2dec42949522f182a61bfb249f13ee78564179" -} diff --git a/.sqlx/query-f511d351018c908bf303ae20ac429b55db93ceb5ce501917188e7b2f14975f34.json b/.sqlx/query-f511d351018c908bf303ae20ac429b55db93ceb5ce501917188e7b2f14975f34.json new file mode 100644 index 0000000..69fdf03 --- /dev/null +++ b/.sqlx/query-f511d351018c908bf303ae20ac429b55db93ceb5ce501917188e7b2f14975f34.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete from message\n where id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "f511d351018c908bf303ae20ac429b55db93ceb5ce501917188e7b2f14975f34" +} diff --git a/.sqlx/query-f6909336ab05b7ad423c7b96a0e7b12a920f9827aff2b05ee0364ff7688a38ae.json b/.sqlx/query-f6909336ab05b7ad423c7b96a0e7b12a920f9827aff2b05ee0364ff7688a38ae.json deleted file mode 100644 index ded48e1..0000000 --- a/.sqlx/query-f6909336ab05b7ad423c7b96a0e7b12a920f9827aff2b05ee0364ff7688a38ae.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n name,\n created_at as \"created_at: DateTime\",\n created_sequence as \"created_sequence: Sequence\"\n from channel\n where id = $1\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "f6909336ab05b7ad423c7b96a0e7b12a920f9827aff2b05ee0364ff7688a38ae" -} diff --git a/docs/api/channels-messages.md b/docs/api/channels-messages.md index d032ac1..99a525e 100644 --- a/docs/api/channels-messages.md +++ b/docs/api/channels-messages.md @@ -8,7 +8,7 @@ stateDiagram-v2 [*] --> Active : POST /api/channels Active --> Deleted : DELETE /api/channels/C1234 Active --> Deleted : Expiry - Deleted --> [*] + Deleted --> [*] : Purge ``` ```mermaid @@ -19,14 +19,19 @@ stateDiagram-v2 [*] --> Sent : POST /api/channels/C1234 Sent --> Deleted : DELETE /api/messages/Mabcd Sent --> Deleted : Expiry - Deleted --> [*] + Deleted --> [*] : Purge ``` Messages allow logins to communicate with one another. Channels are the conversations to which those messages are sent. Every channel has a unique name, chosen when the channel is created. -Both channels and messages expire after a time, to prevent the service from consuming unlimited amounts of storage. Messages expire 90 days after being sent. Channels expire 90 days after the last message sent to them, or after creation if no messages are sent in that time. + +## Expiry and purging + +Both channels and messages expire after a time. Messages expire 90 days after being sent. Channels expire 90 days after the last message sent to them, or after creation if no messages are sent in that time. + +Deleted channels and messages, including those that have expired, are temporarily retained by the service, to allow clients that are not connected to receive the corresponding deletion [events](./events.md). To limit storage growth, deleted channels and messages are purged from the service seven days after they were deleted. ## `POST /api/channels` @@ -103,6 +108,7 @@ This endpoint will respond with a status of `202 Accepted` when successful. The This endpoint will respond with a status of `404 Not Found` if the channel ID is not valid. + ## `DELETE /api/channels/:id` Deletes a channel (and all messages in it). diff --git a/docs/api/events.md b/docs/api/events.md index 9fe9ca9..da2f4b6 100644 --- a/docs/api/events.md +++ b/docs/api/events.md @@ -133,11 +133,16 @@ Sent whenever a new channel is created. These events have the `event` field set to `"created"`. They include the following additional fields: -| Field | Type | Description | -|:-------|:----------|:--| -| `at` | timestamp | The moment the channel was created. | -| `id` | string | A unique identifier for the newly-created channel. This can be used to associate the channel with other events, or to make API calls targeting the channel. | -| `name` | string | The channel's name. | +| Field | Type | Description | +|:-------------|:--------------------|:--| +| `at` | timestamp | The moment the channel was created. | +| `id` | string | A unique identifier for the newly-created channel. This can be used to associate the channel with other events, or to make API calls targeting the channel. | +| `name` | string | The channel's name. | +| `deleted_at` | timestamp, optional | If set, the moment the channel was deleted. | + +When a channel is deleted or expires, the `"created"` event is replaced with a tombstone `"created"` event, so that the original channel cannot be trivially recovered from the event stream. Tombstone events have a `deleted_at` field, and a `name` of `""`. + +Once a deleted channel is [purged](./channels-messages.md#expiry-and-purging), these tombstone events are removed from the event stream. ### Channel deleted @@ -184,13 +189,18 @@ Sent whenever a message is sent by a client. These events have the `event` field set to `"sent"`. They include the following additional fields: -| Field | Type | Description | -|:----------|:----------|:--| -| `at` | timestamp | The moment the message was sent. | -| `channel` | string | The ID of the channel the message was sent to. | -| `sender` | string | The ID of the login that sent the message. | -| `id` | string | A unique identifier for the message. This can be used to associate the message with other events, or to make API calls targeting the message. | -| `body` | string | The text of the message. | +| Field | Type | Description | +|:-------------|:--------------------|:--| +| `at` | timestamp | The moment the message was sent. | +| `channel` | string | The ID of the channel the message was sent to. | +| `sender` | string | The ID of the login that sent the message. | +| `id` | string | A unique identifier for the message. This can be used to associate the message with other events, or to make API calls targeting the message. | +| `body` | string | The text of the message. | +| `deleted_at` | timestamp, optional | If set, the moment the message was deleted. | + +When a message is deleted or expires, the `"sent"` event is replaced with a tombstone `"sent"` event, so that the original message cannot be trivially recovered from the event stream. Tombstone events have a `deleted_at` field, and a `body` of `""`. + +Once a deleted message is [purged](./channels-messages.md#expiry-and-purging), these tombstone events are removed from the event stream. ### Message deleted diff --git a/migrations/20241017005219_retain_deleted.sql b/migrations/20241017005219_retain_deleted.sql new file mode 100644 index 0000000..6205482 --- /dev/null +++ b/migrations/20241017005219_retain_deleted.sql @@ -0,0 +1,92 @@ +alter table channel +rename to old_channel; + +alter table message +rename to old_message; + +create table channel ( + id text + not null + primary key, + name text + not null, + created_sequence bigint + unique + not null, + created_at text + not null, + unique (id, name) +); + +insert into channel (id, name, created_sequence, created_at) +select id, name, created_sequence, created_at from old_channel; + +create table channel_name_reservation ( + id text + not null + unique, + name text + not null + unique, + primary key (id, name), + foreign key (id, name) + references channel (id, name) +); + +insert into channel_name_reservation (id, name) +select id, name from old_channel; + +create table channel_deleted ( + id text + not null + primary key + references channel (id), + deleted_sequence bigint + unique + not null, + deleted_at text + not null +); + +create table message ( + id text + not null + primary key, + channel text + not null + references channel (id), + sender text + not null + references login (id), + sent_sequence bigint + unique + not null, + sent_at text + not null, + body text + not null +); + +insert into message (id, channel, sender, sent_sequence, sent_at, body) +select id, channel, sender, sent_sequence, sent_at, body from old_message; + +create table message_deleted ( + id text + not null + primary key + references message (id), + deleted_sequence bigint + unique + not null, + deleted_at text + not null +); + +drop table old_message; +drop table old_channel; + +create index message_sent_at +on message (sent_at); + +create index channel_created_at +on channel (created_at); diff --git a/src/channel/app.rs b/src/channel/app.rs index 46eaba8..0409076 100644 --- a/src/channel/app.rs +++ b/src/channel/app.rs @@ -23,9 +23,9 @@ impl<'a> Channels<'a> { pub async fn create(&self, name: &str, created_at: &DateTime) -> Result { let mut tx = self.db.begin().await?; let created = tx.sequence().next(created_at).await?; - let channel = tx - .channels() - .create(name, &created) + let channel = tx.channels().create(name, &created).await?; + tx.channels() + .reserve_name(&channel, name) .await .duplicate(|| CreateError::DuplicateName(name.into()))?; tx.commit().await?; @@ -43,7 +43,7 @@ impl<'a> Channels<'a> { let channel = tx.channels().by_id(channel).await.optional()?; tx.commit().await?; - Ok(channel.iter().flat_map(History::events).collect()) + Ok(channel.as_ref().and_then(History::as_snapshot)) } pub async fn delete(&self, channel: &Id, deleted_at: &DateTime) -> Result<(), Error> { @@ -54,13 +54,16 @@ impl<'a> Channels<'a> { .by_id(channel) .await .not_found(|| Error::NotFound(channel.clone()))?; + channel + .as_snapshot() + .ok_or_else(|| Error::Deleted(channel.id().clone()))?; let mut events = Vec::new(); - let messages = tx.messages().in_channel(&channel, None).await?; + let messages = tx.messages().live(&channel).await?; for message in messages { let deleted = tx.sequence().next(deleted_at).await?; - let message = tx.messages().delete(message.id(), &deleted).await?; + let message = tx.messages().delete(&message, &deleted).await?; events.extend( message .events() @@ -70,7 +73,7 @@ impl<'a> Channels<'a> { } let deleted = tx.sequence().next(deleted_at).await?; - let channel = tx.channels().delete(channel.id(), &deleted).await?; + let channel = tx.channels().delete(&channel, &deleted).await?; events.extend( channel .events() @@ -115,6 +118,17 @@ impl<'a> Channels<'a> { Ok(()) } + + pub async fn purge(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { + // Somewhat arbitrarily, purge after 7 days. + let purge_at = relative_to.to_owned() - TimeDelta::days(7); + + let mut tx = self.db.begin().await?; + tx.channels().purge(&purge_at).await?; + tx.commit().await?; + + Ok(()) + } } #[derive(Debug, thiserror::Error)] @@ -129,6 +143,8 @@ pub enum CreateError { pub enum Error { #[error("channel {0} not found")] NotFound(Id), + #[error("channel {0} deleted")] + Deleted(Id), #[error(transparent)] Database(#[from] sqlx::Error), } diff --git a/src/channel/history.rs b/src/channel/history.rs index 78b3437..4b9fcc7 100644 --- a/src/channel/history.rs +++ b/src/channel/history.rs @@ -31,6 +31,11 @@ impl History { .filter(Sequence::up_to(resume_point.into())) .collect() } + + // Snapshot of this channel as of all events recorded in this history. + pub fn as_snapshot(&self) -> Option { + self.events().collect() + } } // Event factories diff --git a/src/channel/repo.rs b/src/channel/repo.rs index 2f57581..4b10c54 100644 --- a/src/channel/repo.rs +++ b/src/channel/repo.rs @@ -41,11 +41,9 @@ impl<'c> Channels<'c> { channel: Channel { id: row.id, name: row.name, + deleted_at: None, }, - created: Instant { - at: row.created_at, - sequence: row.created_sequence, - }, + created: Instant::new(row.created_at, row.created_sequence), deleted: None, }) .fetch_one(&mut *self.0) @@ -54,15 +52,35 @@ impl<'c> Channels<'c> { Ok(channel) } + pub async fn reserve_name(&mut self, channel: &History, name: &str) -> Result<(), sqlx::Error> { + let channel = channel.id(); + sqlx::query!( + r#" + insert into channel_name_reservation (id, name) + values ($1, $2) + "#, + channel, + name, + ) + .execute(&mut *self.0) + .await?; + + Ok(()) + } + pub async fn by_id(&mut self, channel: &Id) -> Result { let channel = sqlx::query!( r#" select id as "id: Id", - name, - created_at as "created_at: DateTime", - created_sequence as "created_sequence: Sequence" + channel.name, + channel.created_at as "created_at: DateTime", + channel.created_sequence as "created_sequence: Sequence", + deleted.deleted_at as "deleted_at: DateTime", + deleted.deleted_sequence as "deleted_sequence: Sequence" from channel + left join channel_deleted as deleted + using (id) where id = $1 "#, channel, @@ -71,12 +89,10 @@ impl<'c> Channels<'c> { channel: Channel { id: row.id, name: row.name, + deleted_at: row.deleted_at, }, - created: Instant { - at: row.created_at, - sequence: row.created_sequence, - }, - deleted: None, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) .fetch_one(&mut *self.0) .await?; @@ -89,11 +105,15 @@ impl<'c> Channels<'c> { r#" select id as "id: Id", - name, - created_at as "created_at: DateTime", - created_sequence as "created_sequence: Sequence" + channel.name, + channel.created_at as "created_at: DateTime", + channel.created_sequence as "created_sequence: Sequence", + deleted.deleted_at as "deleted_at: DateTime", + deleted.deleted_sequence as "deleted_sequence: Sequence" from channel - where coalesce(created_sequence <= $1, true) + left join channel_deleted as deleted + using (id) + where coalesce(channel.created_sequence <= $1, true) order by channel.name "#, resume_at, @@ -102,12 +122,10 @@ impl<'c> Channels<'c> { channel: Channel { id: row.id, name: row.name, + deleted_at: row.deleted_at, }, - created: Instant { - at: row.created_at, - sequence: row.created_sequence, - }, - deleted: None, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) .fetch_all(&mut *self.0) .await?; @@ -123,11 +141,15 @@ impl<'c> Channels<'c> { r#" select id as "id: Id", - name, - created_at as "created_at: DateTime", - created_sequence as "created_sequence: Sequence" + channel.name, + channel.created_at as "created_at: DateTime", + channel.created_sequence as "created_sequence: Sequence", + deleted.deleted_at as "deleted_at: DateTime", + deleted.deleted_sequence as "deleted_sequence: Sequence" from channel - where coalesce(created_sequence > $1, true) + left join channel_deleted as deleted + using (id) + where coalesce(channel.created_sequence > $1, true) "#, resume_at, ) @@ -135,12 +157,10 @@ impl<'c> Channels<'c> { channel: Channel { id: row.id, name: row.name, + deleted_at: row.deleted_at, }, - created: Instant { - at: row.created_at, - sequence: row.created_sequence, - }, - deleted: None, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) .fetch_all(&mut *self.0) .await?; @@ -150,50 +170,118 @@ impl<'c> Channels<'c> { pub async fn delete( &mut self, - channel: &Id, + channel: &History, deleted: &Instant, ) -> Result { - let channel = sqlx::query!( + let id = channel.id(); + sqlx::query_scalar!( r#" - delete from channel + delete from channel_name_reservation where id = $1 - returning - id as "id: Id", - name, - created_at as "created_at: DateTime", - created_sequence as "created_sequence: Sequence" + returning 1 as "deleted: bool" "#, - channel, + id, + ) + .fetch_one(&mut *self.0) + .await?; + + sqlx::query_scalar!( + r#" + insert into channel_deleted (id, deleted_at, deleted_sequence) + values ($1, $2, $3) + returning 1 as "deleted: bool" + "#, + id, + deleted.at, + deleted.sequence, + ) + .fetch_one(&mut *self.0) + .await?; + + // Small social responsibility hack here: when a channel is deleted, its name is + // retconned to have been the empty string. Someone reading the event stream + // afterwards, or looking at channels via the API, cannot retrieve the + // "deleted" channel's information by ignoring the deletion event. + sqlx::query_scalar!( + r#" + update channel + set name = "" + where id = $1 + returning 1 as "updated: bool" + "#, + id, ) - .map(|row| History { - channel: Channel { - id: row.id, - name: row.name, - }, - created: Instant { - at: row.created_at, - sequence: row.created_sequence, - }, - deleted: Some(*deleted), - }) .fetch_one(&mut *self.0) .await?; + let channel = self.by_id(id).await?; + Ok(channel) } - pub async fn expired(&mut self, expired_at: &DateTime) -> Result, sqlx::Error> { + pub async fn purge(&mut self, purge_at: &DateTime) -> Result<(), sqlx::Error> { let channels = sqlx::query_scalar!( + r#" + with has_messages as ( + select channel + from message + group by channel + ) + delete from channel_deleted + where deleted_at < $1 + and id not in has_messages + returning id as "id: Id" + "#, + purge_at, + ) + .fetch_all(&mut *self.0) + .await?; + + for channel in channels { + // Wanted: a way to batch these up into one query. + sqlx::query!( + r#" + delete from channel + where id = $1 + "#, + channel, + ) + .execute(&mut *self.0) + .await?; + } + + Ok(()) + } + + pub async fn expired(&mut self, expired_at: &DateTime) -> Result, sqlx::Error> { + let channels = sqlx::query!( r#" select - channel.id as "id: Id" + channel.id as "id: Id", + channel.name, + channel.created_at as "created_at: DateTime", + channel.created_sequence as "created_sequence: Sequence", + deleted.deleted_at as "deleted_at: DateTime", + deleted.deleted_sequence as "deleted_sequence: Sequence" from channel - left join message - where created_at < $1 + left join channel_deleted as deleted + using (id) + left join message + where channel.created_at < $1 and message.id is null + and deleted.id is null "#, expired_at, ) + .map(|row| History { + channel: Channel { + id: row.id, + name: row.name, + deleted_at: row.deleted_at, + }, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + }) .fetch_all(&mut *self.0) .await?; diff --git a/src/channel/routes/channel/delete.rs b/src/channel/routes/channel/delete.rs index efac0c0..5f40ddf 100644 --- a/src/channel/routes/channel/delete.rs +++ b/src/channel/routes/channel/delete.rs @@ -32,7 +32,7 @@ impl IntoResponse for Error { let Self(error) = self; #[allow(clippy::match_wildcard_for_single_variants)] match error { - app::Error::NotFound(_) => NotFound(error).into_response(), + app::Error::NotFound(_) | app::Error::Deleted(_) => NotFound(error).into_response(), other => Internal::from(other).into_response(), } } diff --git a/src/channel/routes/channel/test.rs b/src/channel/routes/channel/test.rs index bc02b20..c6541cd 100644 --- a/src/channel/routes/channel/test.rs +++ b/src/channel/routes/channel/test.rs @@ -4,7 +4,7 @@ use futures::stream::StreamExt; use super::post; use crate::{ channel, - event::{self, Sequenced}, + event::Sequenced, message::{self, app::SendError}, test::fixtures::{self, future::Immediately as _}, }; @@ -45,7 +45,7 @@ async fn messages_in_order() { .subscribe(None) .await .expect("subscribing to a valid channel") - .filter(fixtures::filter::messages()) + .filter_map(fixtures::message::events) .take(requests.len()); let events = events.collect::>().immediately().await; @@ -54,7 +54,7 @@ async fn messages_in_order() { assert_eq!(*sent_at, event.at()); assert!(matches!( event, - event::Event::Message(message::Event::Sent(event)) + message::Event::Sent(event) if event.message.sender == sender.id && event.message.body == message )); diff --git a/src/channel/routes/test.rs b/src/channel/routes/test.rs index 81f1465..ffd8484 100644 --- a/src/channel/routes/test.rs +++ b/src/channel/routes/test.rs @@ -1,10 +1,11 @@ +use std::future; + use axum::extract::{Json, State}; use futures::stream::StreamExt as _; use super::post; use crate::{ - channel::{self, app}, - event, + channel::app, test::fixtures::{self, future::Immediately as _}, }; @@ -19,29 +20,35 @@ async fn new_channel() { let name = fixtures::channel::propose(); let request = post::Request { name: name.clone() }; - let Json(response_channel) = - post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("new channel in an empty app"); + let Json(response) = post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect("creating a channel in an empty app succeeds"); // Verify the structure of the response - assert_eq!(name, response_channel.name); + assert_eq!(name, response.name); // Verify the semantics let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); - assert!(snapshot - .channels - .iter() - .any(|channel| channel.name == response_channel.name && channel.id == response_channel.id)); + assert!(snapshot.channels.iter().any(|channel| channel == &response)); + + let channel = app + .channels() + .get(&response.id) + .await + .expect("searching for channels by ID never fails") + .expect("the newly-created channel exists"); + assert_eq!(response, channel); let mut events = app .events() .subscribe(None) .await .expect("subscribing never fails") - .filter(fixtures::filter::created()); + .filter_map(fixtures::channel::events) + .filter_map(fixtures::channel::created) + .filter(|event| future::ready(event.channel == response)); let event = events .next() @@ -49,11 +56,7 @@ async fn new_channel() { .await .expect("creation event published"); - assert!(matches!( - event, - event::Event::Channel(channel::Event::Created(event)) - if event.channel == response_channel - )); + assert_eq!(event.channel, response); } #[tokio::test] @@ -81,3 +84,72 @@ async fn duplicate_name() { app::CreateError::DuplicateName(name) if channel.name == name )); } + +#[tokio::test] +async fn name_reusable_after_delete() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::login::create(&app, &fixtures::now()).await; + let name = fixtures::channel::propose(); + + // Call the endpoint (first time) + + let request = post::Request { name: name.clone() }; + let Json(response) = post::handler( + State(app.clone()), + creator.clone(), + fixtures::now(), + Json(request), + ) + .await + .expect("new channel in an empty app"); + + // Delete the channel + + app.channels() + .delete(&response.id, &fixtures::now()) + .await + .expect("deleting a newly-created channel succeeds"); + + // Call the endpoint (second time) + + let request = post::Request { name: name.clone() }; + let Json(response) = post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect("new channel in an empty app"); + + // Verify the structure of the response + + assert_eq!(name, response.name); + + // Verify the semantics + + let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); + assert!(snapshot.channels.iter().any(|channel| channel == &response)); + + let channel = app + .channels() + .get(&response.id) + .await + .expect("searching for channels by ID never fails") + .expect("the newly-created channel exists"); + assert_eq!(response, channel); + + let mut events = app + .events() + .subscribe(None) + .await + .expect("subscribing never fails") + .filter_map(fixtures::channel::events) + .filter_map(fixtures::channel::created) + .filter(|event| future::ready(event.channel == response)); + + let event = events + .next() + .immediately() + .await + .expect("creation event published"); + + assert_eq!(event.channel, response); +} diff --git a/src/channel/snapshot.rs b/src/channel/snapshot.rs index d4d1d27..2b7d89a 100644 --- a/src/channel/snapshot.rs +++ b/src/channel/snapshot.rs @@ -2,11 +2,14 @@ use super::{ event::{Created, Event}, Id, }; +use crate::clock::DateTime; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Channel { pub id: Id, pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, } impl Channel { diff --git a/src/event/repo.rs b/src/event/repo.rs index 40d6a53..56beeea 100644 --- a/src/event/repo.rs +++ b/src/event/repo.rs @@ -29,10 +29,7 @@ impl<'c> Sequences<'c> { .fetch_one(&mut *self.0) .await?; - Ok(Instant { - at: *at, - sequence: next, - }) + Ok(Instant::new(*at, next)) } pub async fn current(&mut self) -> Result { diff --git a/src/event/routes/test.rs b/src/event/routes/test.rs index 249f5c2..e6a8b9d 100644 --- a/src/event/routes/test.rs +++ b/src/event/routes/test.rs @@ -31,7 +31,7 @@ async fn includes_historical_message() { // Verify the structure of the response. let event = events - .filter(fixtures::filter::messages()) + .filter_map(fixtures::message::events) .next() .immediately() .await @@ -62,7 +62,7 @@ async fn includes_live_message() { let message = fixtures::message::send(&app, &channel, &sender, &fixtures::now()).await; let event = events - .filter(fixtures::filter::messages()) + .filter_map(fixtures::message::events) .next() .immediately() .await @@ -104,7 +104,7 @@ async fn includes_multiple_channels() { // Verify the structure of the response. let events = events - .filter(fixtures::filter::messages()) + .filter_map(fixtures::message::events) .take(messages.len()) .collect::>() .immediately() @@ -141,13 +141,15 @@ async fn sequential_messages() { // Verify the structure of the response. - let mut events = events.filter(|event| { - future::ready( - messages - .iter() - .any(|message| fixtures::event::message_sent(event, message)), - ) - }); + let mut events = events + .filter_map(fixtures::message::events) + .filter(|event| { + future::ready( + messages + .iter() + .any(|message| fixtures::event::message_sent(event, message)), + ) + }); // Verify delivery in order for message in &messages { @@ -193,7 +195,7 @@ async fn resumes_from() { .expect("subscribe never fails"); let event = events - .filter(fixtures::filter::messages()) + .filter_map(fixtures::message::events) .next() .immediately() .await @@ -217,6 +219,7 @@ async fn resumes_from() { // Verify the structure of the response. let events = resumed + .filter_map(fixtures::message::events) .take(later_messages.len()) .collect::>() .immediately() @@ -275,7 +278,7 @@ async fn serial_resume() { .expect("subscribe never fails"); let events = events - .filter(fixtures::filter::messages()) + .filter_map(fixtures::message::events) .take(initial_messages.len()) .collect::>() .immediately() @@ -313,7 +316,7 @@ async fn serial_resume() { .expect("subscribe never fails"); let events = events - .filter(fixtures::filter::messages()) + .filter_map(fixtures::message::events) .take(resume_messages.len()) .collect::>() .immediately() @@ -351,7 +354,7 @@ async fn serial_resume() { .expect("subscribe never fails"); let events = events - .filter(fixtures::filter::messages()) + .filter_map(fixtures::message::events) .take(final_messages.len()) .collect::>() .immediately() @@ -401,6 +404,7 @@ async fn terminates_on_token_expiry() { ]; assert!(events + .filter_map(fixtures::message::events) .filter(|event| future::ready( messages .iter() @@ -452,6 +456,7 @@ async fn terminates_on_logout() { ]; assert!(events + .filter_map(fixtures::message::events) .filter(|event| future::ready( messages .iter() diff --git a/src/event/sequence.rs b/src/event/sequence.rs index bf6d5b8..9bc399b 100644 --- a/src/event/sequence.rs +++ b/src/event/sequence.rs @@ -10,6 +10,17 @@ pub struct Instant { pub sequence: Sequence, } +impl Instant { + pub fn new(at: DateTime, sequence: Sequence) -> Self { + Self { at, sequence } + } + + pub fn optional(at: Option, sequence: Option) -> Option { + at.zip(sequence) + .map(|(at, sequence)| Self::new(at, sequence)) + } +} + impl From for Sequence { fn from(instant: Instant) -> Self { instant.sequence diff --git a/src/expire.rs b/src/expire.rs index eaedc44..1427a8d 100644 --- a/src/expire.rs +++ b/src/expire.rs @@ -16,6 +16,8 @@ pub async fn middleware( app.tokens().expire(&expired_at).await?; app.invites().expire(&expired_at).await?; app.messages().expire(&expired_at).await?; + app.messages().purge(&expired_at).await?; app.channels().expire(&expired_at).await?; + app.channels().purge(&expired_at).await?; Ok(next.run(req).await) } diff --git a/src/login/repo.rs b/src/login/repo.rs index 6d6510c..7d0fcb1 100644 --- a/src/login/repo.rs +++ b/src/login/repo.rs @@ -49,10 +49,7 @@ impl<'c> Logins<'c> { id: row.id, name: row.name, }, - created: Instant { - at: row.created_at, - sequence: row.created_sequence, - }, + created: Instant::new(row.created_at, row.created_sequence), }) .fetch_one(&mut *self.0) .await?; @@ -79,10 +76,7 @@ impl<'c> Logins<'c> { id: row.id, name: row.name, }, - created: Instant { - at: row.created_at, - sequence: row.created_sequence, - }, + created: Instant::new(row.created_at, row.created_sequence), }) .fetch_all(&mut *self.0) .await?; @@ -107,10 +101,7 @@ impl<'c> Logins<'c> { id: row.id, name: row.name, }, - created: Instant { - at: row.created_at, - sequence: row.created_sequence, - }, + created: Instant::new(row.created_at, row.created_sequence), }) .fetch_all(&mut *self.0) .await?; diff --git a/src/message/app.rs b/src/message/app.rs index c1bcde6..4e50513 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -46,8 +46,17 @@ impl<'a> Messages<'a> { pub async fn delete(&self, message: &Id, deleted_at: &DateTime) -> Result<(), DeleteError> { let mut tx = self.db.begin().await?; + let message = tx + .messages() + .by_id(message) + .await + .not_found(|| DeleteError::NotFound(message.clone()))?; + message + .as_snapshot() + .ok_or_else(|| DeleteError::Deleted(message.id().clone()))?; + let deleted = tx.sequence().next(deleted_at).await?; - let message = tx.messages().delete(message, &deleted).await?; + let message = tx.messages().delete(&message, &deleted).await?; tx.commit().await?; self.events.broadcast( @@ -91,6 +100,17 @@ impl<'a> Messages<'a> { Ok(()) } + + pub async fn purge(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { + // Somewhat arbitrarily, purge after 6 hours. + let purge_at = relative_to.to_owned() - TimeDelta::hours(6); + + let mut tx = self.db.begin().await?; + tx.messages().purge(&purge_at).await?; + tx.commit().await?; + + Ok(()) + } } #[derive(Debug, thiserror::Error)] @@ -107,6 +127,8 @@ pub enum DeleteError { ChannelNotFound(channel::Id), #[error("message {0} not found")] NotFound(Id), + #[error("message {0} deleted")] + Deleted(Id), #[error(transparent)] Database(#[from] sqlx::Error), } diff --git a/src/message/history.rs b/src/message/history.rs index 09e69b7..0424d0d 100644 --- a/src/message/history.rs +++ b/src/message/history.rs @@ -30,6 +30,11 @@ impl History { .filter(Sequence::up_to(resume_point.into())) .collect() } + + // Snapshot of this message as of all events recorded in this history. + pub fn as_snapshot(&self) -> Option { + self.events().collect() + } } // Events interface diff --git a/src/message/repo.rs b/src/message/repo.rs index 71c6d10..14ff7bf 100644 --- a/src/message/repo.rs +++ b/src/message/repo.rs @@ -53,14 +53,12 @@ impl<'c> Messages<'c> { ) .map(|row| History { message: Message { - sent: Instant { - at: row.sent_at, - sequence: row.sent_sequence, - }, + sent: Instant::new(row.sent_at, row.sent_sequence), channel: row.channel, sender: row.sender, id: row.id, body: row.body, + deleted_at: None, }, deleted: None, }) @@ -70,41 +68,37 @@ impl<'c> Messages<'c> { Ok(message) } - pub async fn in_channel( - &mut self, - channel: &channel::History, - resume_at: ResumePoint, - ) -> Result, sqlx::Error> { + pub async fn live(&mut self, channel: &channel::History) -> Result, sqlx::Error> { let channel_id = channel.id(); let messages = sqlx::query!( r#" select - channel as "channel: channel::Id", - sender as "sender: login::Id", + message.channel as "channel: channel::Id", + message.sender as "sender: login::Id", id as "id: Id", - body, - sent_at as "sent_at: DateTime", - sent_sequence as "sent_sequence: Sequence" + message.body, + message.sent_at as "sent_at: DateTime", + message.sent_sequence as "sent_sequence: Sequence", + deleted.deleted_at as "deleted_at: DateTime", + deleted.deleted_sequence as "deleted_sequence: Sequence" from message - where channel = $1 - and coalesce(sent_sequence <= $2, true) - order by sent_sequence + left join message_deleted as deleted + using (id) + where message.channel = $1 + and deleted.id is null "#, channel_id, - resume_at, ) .map(|row| History { message: Message { - sent: Instant { - at: row.sent_at, - sequence: row.sent_sequence, - }, + sent: Instant::new(row.sent_at, row.sent_sequence), channel: row.channel, sender: row.sender, id: row.id, body: row.body, + deleted_at: row.deleted_at, }, - deleted: None, + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) .fetch_all(&mut *self.0) .await?; @@ -116,30 +110,32 @@ impl<'c> Messages<'c> { let messages = sqlx::query!( r#" select - channel as "channel: channel::Id", - sender as "sender: login::Id", + message.channel as "channel: channel::Id", + message.sender as "sender: login::Id", id as "id: Id", - body, - sent_at as "sent_at: DateTime", - sent_sequence as "sent_sequence: Sequence" + message.body, + message.sent_at as "sent_at: DateTime", + message.sent_sequence as "sent_sequence: Sequence", + deleted.deleted_at as "deleted_at: DateTime", + deleted.deleted_sequence as "deleted_sequence: Sequence" from message - where coalesce(sent_sequence <= $2, true) - order by sent_sequence + left join message_deleted as deleted + using (id) + where coalesce(message.sent_sequence <= $2, true) + order by message.sent_sequence "#, resume_at, ) .map(|row| History { message: Message { - sent: Instant { - at: row.sent_at, - sequence: row.sent_sequence, - }, + sent: Instant::new(row.sent_at, row.sent_sequence), channel: row.channel, sender: row.sender, id: row.id, body: row.body, + deleted_at: row.deleted_at, }, - deleted: None, + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) .fetch_all(&mut *self.0) .await?; @@ -147,33 +143,35 @@ impl<'c> Messages<'c> { Ok(messages) } - async fn by_id(&mut self, message: &Id) -> Result { + pub async fn by_id(&mut self, message: &Id) -> Result { let message = sqlx::query!( r#" select - channel as "channel: channel::Id", - sender as "sender: login::Id", + message.channel as "channel: channel::Id", + message.sender as "sender: login::Id", id as "id: Id", - body, - sent_at as "sent_at: DateTime", - sent_sequence as "sent_sequence: Sequence" + message.body, + message.sent_at as "sent_at: DateTime", + message.sent_sequence as "sent_sequence: Sequence", + deleted.deleted_at as "deleted_at: DateTime", + deleted.deleted_sequence as "deleted_sequence: Sequence" from message + left join message_deleted as deleted + using (id) where id = $1 "#, message, ) .map(|row| History { message: Message { - sent: Instant { - at: row.sent_at, - sequence: row.sent_sequence, - }, + sent: Instant::new(row.sent_at, row.sent_sequence), channel: row.channel, sender: row.sender, id: row.id, body: row.body, + deleted_at: row.deleted_at, }, - deleted: None, + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) .fetch_one(&mut *self.0) .await?; @@ -183,39 +181,103 @@ impl<'c> Messages<'c> { pub async fn delete( &mut self, - message: &Id, + message: &History, deleted: &Instant, ) -> Result { - let history = self.by_id(message).await?; + let id = message.id(); sqlx::query_scalar!( r#" - delete from message - where - id = $1 - returning 1 as "deleted: i64" + insert into message_deleted (id, deleted_at, deleted_sequence) + values ($1, $2, $3) + returning 1 as "deleted: bool" "#, - history.message.id, + id, + deleted.at, + deleted.sequence, ) .fetch_one(&mut *self.0) .await?; - Ok(History { - deleted: Some(*deleted), - ..history - }) + // Small social responsibility hack here: when a message is deleted, its body is + // retconned to have been the empty string. Someone reading the event stream + // afterwards, or looking at messages in the channel, cannot retrieve the + // "deleted" message by ignoring the deletion event. + sqlx::query_scalar!( + r#" + update message + set body = "" + where id = $1 + returning 1 as "blanked: bool" + "#, + id, + ) + .fetch_one(&mut *self.0) + .await?; + + let message = self.by_id(id).await?; + + Ok(message) } - pub async fn expired(&mut self, expire_at: &DateTime) -> Result, sqlx::Error> { + pub async fn purge(&mut self, purge_at: &DateTime) -> Result<(), sqlx::Error> { let messages = sqlx::query_scalar!( + r#" + delete from message_deleted + where deleted_at < $1 + returning id as "id: Id" + "#, + purge_at, + ) + .fetch_all(&mut *self.0) + .await?; + + for message in messages { + sqlx::query!( + r#" + delete from message + where id = $1 + "#, + message, + ) + .execute(&mut *self.0) + .await?; + } + + Ok(()) + } + + pub async fn expired(&mut self, expire_at: &DateTime) -> Result, sqlx::Error> { + let messages = sqlx::query!( r#" select - id as "message: Id" + id as "id: Id", + message.channel as "channel: channel::Id", + message.sender as "sender: login::Id", + message.sent_at as "sent_at: DateTime", + message.sent_sequence as "sent_sequence: Sequence", + message.body, + deleted.deleted_at as "deleted_at: DateTime", + deleted.deleted_sequence as "deleted_sequence: Sequence" from message - where sent_at < $1 + left join message_deleted as deleted + using (id) + where message.sent_at < $1 + and deleted.id is null "#, expire_at, ) + .map(|row| History { + message: Message { + sent: Instant::new(row.sent_at, row.sent_sequence), + id: row.id, + channel: row.channel, + sender: row.sender, + body: row.body, + deleted_at: row.deleted_at, + }, + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + }) .fetch_all(&mut *self.0) .await?; @@ -226,29 +288,31 @@ impl<'c> Messages<'c> { let messages = sqlx::query!( r#" select - channel as "channel: channel::Id", - sender as "sender: login::Id", id as "id: Id", - body, - sent_at as "sent_at: DateTime", - sent_sequence as "sent_sequence: Sequence" + message.channel as "channel: channel::Id", + message.sender as "sender: login::Id", + message.sent_at as "sent_at: DateTime", + message.sent_sequence as "sent_sequence: Sequence", + message.body, + deleted.deleted_at as "deleted_at: DateTime", + deleted.deleted_sequence as "deleted_sequence: Sequence" from message + left join message_deleted as deleted + using (id) where coalesce(message.sent_sequence > $1, true) "#, resume_at, ) .map(|row| History { message: Message { - sent: Instant { - at: row.sent_at, - sequence: row.sent_sequence, - }, + sent: Instant::new(row.sent_at, row.sent_sequence), channel: row.channel, sender: row.sender, id: row.id, body: row.body, + deleted_at: row.deleted_at, }, - deleted: None, + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), }) .fetch_all(&mut *self.0) .await?; diff --git a/src/message/routes/message.rs b/src/message/routes/message.rs index 059b8c1..fbef35a 100644 --- a/src/message/routes/message.rs +++ b/src/message/routes/message.rs @@ -33,9 +33,9 @@ pub mod delete { let Self(error) = self; #[allow(clippy::match_wildcard_for_single_variants)] match error { - DeleteError::ChannelNotFound(_) | DeleteError::NotFound(_) => { - NotFound(error).into_response() - } + DeleteError::ChannelNotFound(_) + | DeleteError::NotFound(_) + | DeleteError::Deleted(_) => NotFound(error).into_response(), other => Internal::from(other).into_response(), } } diff --git a/src/message/snapshot.rs b/src/message/snapshot.rs index 0eb37bb..7300918 100644 --- a/src/message/snapshot.rs +++ b/src/message/snapshot.rs @@ -2,7 +2,7 @@ use super::{ event::{Event, Sent}, Id, }; -use crate::{channel, event::Instant, login}; +use crate::{channel, clock::DateTime, event::Instant, login}; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Message { @@ -12,6 +12,8 @@ pub struct Message { pub sender: login::Id, pub id: Id, pub body: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub deleted_at: Option, } impl Message { diff --git a/src/test/fixtures/channel.rs b/src/test/fixtures/channel.rs index b678717..a1dda61 100644 --- a/src/test/fixtures/channel.rs +++ b/src/test/fixtures/channel.rs @@ -1,10 +1,17 @@ +use std::future; + use faker_rand::{ en_us::{addresses::CityName, names::FullName}, faker_impl_from_templates, }; use rand; -use crate::{app::App, channel::Channel, clock::RequestedAt}; +use crate::{ + app::App, + channel::{self, Channel}, + clock::RequestedAt, + event::Event, +}; pub async fn create(app: &App, created_at: &RequestedAt) -> Channel { let name = propose(); @@ -22,3 +29,17 @@ struct Name(String); faker_impl_from_templates! { Name; "{} {}", CityName, FullName; } + +pub fn events(event: Event) -> future::Ready> { + future::ready(match event { + Event::Channel(channel) => Some(channel), + _ => None, + }) +} + +pub fn created(event: channel::Event) -> future::Ready> { + future::ready(match event { + channel::Event::Created(event) => Some(event), + channel::Event::Deleted(_) => None, + }) +} diff --git a/src/test/fixtures/event.rs b/src/test/fixtures/event.rs index 7fe2bf3..fa4fbc0 100644 --- a/src/test/fixtures/event.rs +++ b/src/test/fixtures/event.rs @@ -1,11 +1,8 @@ -use crate::{ - event::Event, - message::{Event::Sent, Message}, -}; +use crate::message::{Event, Message}; pub fn message_sent(event: &Event, message: &Message) -> bool { matches!( &event, - Event::Message(Sent(event)) if message == &event.into() + Event::Sent(event) if message == &event.into() ) } diff --git a/src/test/fixtures/filter.rs b/src/test/fixtures/filter.rs deleted file mode 100644 index 84d27b0..0000000 --- a/src/test/fixtures/filter.rs +++ /dev/null @@ -1,11 +0,0 @@ -use futures::future; - -use crate::{channel::Event::Created, event::Event, message::Event::Sent}; - -pub fn messages() -> impl FnMut(&Event) -> future::Ready { - |event| future::ready(matches!(event, Event::Message(Sent(_)))) -} - -pub fn created() -> impl FnMut(&Event) -> future::Ready { - |event| future::ready(matches!(event, Event::Channel(Created(_)))) -} diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs index 381b10b..eb00e7c 100644 --- a/src/test/fixtures/message.rs +++ b/src/test/fixtures/message.rs @@ -1,6 +1,15 @@ +use std::future; + use faker_rand::lorem::Paragraphs; -use crate::{app::App, channel::Channel, clock::RequestedAt, login::Login, message::Message}; +use crate::{ + app::App, + channel::Channel, + clock::RequestedAt, + event::Event, + login::Login, + message::{self, Message}, +}; pub async fn send(app: &App, channel: &Channel, login: &Login, sent_at: &RequestedAt) -> Message { let body = propose(); @@ -14,3 +23,10 @@ pub async fn send(app: &App, channel: &Channel, login: &Login, sent_at: &Request pub fn propose() -> String { rand::random::().to_string() } + +pub fn events(event: Event) -> future::Ready> { + future::ready(match event { + Event::Message(event) => Some(event), + _ => None, + }) +} diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs index 41f7e13..9658831 100644 --- a/src/test/fixtures/mod.rs +++ b/src/test/fixtures/mod.rs @@ -4,7 +4,6 @@ use crate::{app::App, clock::RequestedAt, db}; pub mod channel; pub mod event; -pub mod filter; pub mod future; pub mod identity; pub mod login; diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs index 9aee81f..88d0878 100644 --- a/src/token/repo/auth.rs +++ b/src/token/repo/auth.rs @@ -40,10 +40,7 @@ impl<'t> Auth<'t> { id: row.id, name: row.name, }, - created: Instant { - at: row.created_at, - sequence: row.created_sequence, - }, + created: Instant::new(row.created_at, row.created_sequence), }, row.password_hash, ) -- cgit v1.2.3 From ad00b553d845dba8af7b0e9fa2930209aee1dd62 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sat, 19 Oct 2024 00:57:20 -0400 Subject: Make the responses for various data creation requests more consistent. In general: * If the client can only assume the response is immediately valid (mostly, login creation, where the client cannot monitor the event stream), then 200 Okay, with data describing the server's view of the request. * If the client can monitor for completion by watching the event stream, then 202 Accepted, with data describing the server's view of the request. This comes on the heels of a comment I made on Discord: > hrm > > creating a login: 204 No Content, no body > sending a message: 202 Accepted, no body > creating a channel: 200 Okay, has a body > > past me, what were you on There wasn't any principled reason for this inconsistency; it happened as the endpoints were written at different times and with different states of mind. --- docs/api/authentication.md | 16 +++++++++++++++- docs/api/channels-messages.md | 38 +++++++++++++++++++++++++++++++++----- docs/api/events.md | 5 +++++ docs/api/initial-setup.md | 16 +++++++++++++++- docs/api/invitations.md | 16 +++++++++++++++- src/channel/routes/channel/post.rs | 23 +++++++++++++++++------ src/channel/routes/channel/test.rs | 2 +- src/channel/routes/post.rs | 14 ++++++++++++-- src/channel/routes/test.rs | 16 +++++++++------- src/invite/app.rs | 4 ++-- src/invite/routes/invite/post.rs | 8 ++++---- src/login/history.rs | 6 +++++- src/login/routes/login/post.rs | 10 +++++----- src/login/routes/login/test.rs | 22 +++++++++++----------- src/setup/app.rs | 6 +++--- src/setup/routes/post.rs | 12 ++++++++---- src/test/fixtures/identity.rs | 12 ++++++------ src/test/fixtures/login.rs | 7 ++++--- src/token/app.rs | 6 ++++-- 19 files changed, 174 insertions(+), 65 deletions(-) (limited to 'src/channel/routes') diff --git a/docs/api/authentication.md b/docs/api/authentication.md index d4c1f70..7e05443 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -56,7 +56,21 @@ The request must have the following fields: -This endpoint will respond with a status of `204 No Content` when successful. +This endpoint will respond with a status of `200 Okay` when successful. The body of the response will be a JSON object describing the authenticated login: + +```json +{ + "id": "Labcd1234", + "name": "Andrea" +} +``` + +The response will include the following fields: + +| Field | Type | Description | +|:------------|:-------|:--| +| `id` | string | The authenticated login's ID. | +| `name` | string | The authenticated login's name. | The response will include a `Set-Cookie` header for the `identity` cookie, providing the client with a newly-minted identity token associated with the login identified in the request. This token's value must be kept confidential. diff --git a/docs/api/channels-messages.md b/docs/api/channels-messages.md index 99a525e..69cadcb 100644 --- a/docs/api/channels-messages.md +++ b/docs/api/channels-messages.md @@ -54,12 +54,12 @@ The request must have the following fields: ### Success -This endpoint will respond with a status of `200 Okay` when successful. The body of the response will be a JSON object describing the new channel: +This endpoint will respond with a status of `202 Accepted` when successful. The body of the response will be a JSON object describing the new channel: ```json { - "name": "a unique channel name", "id": "C9876cyyz" + "name": "a unique channel name", } ``` @@ -67,8 +67,10 @@ The response will have the following fields: | Field | Type | Description | |:-------|:-------|:--| +| `id` | string | A unique identifier for the channel. This can be used to associate the channel with events, or to make API calls targeting the channel. | | `name` | string | The channel's name. | -| `id` | string | A unique identifier for the channel. This can be used to associate the channel with other events, or to make API calls targeting the channel. | + +When completed, the service will emit a [channel created](events.md#channel-created) event with the channel's ID. ### Duplicate channel name @@ -101,14 +103,35 @@ The request must have the following fields: ### Success -This endpoint will respond with a status of `202 Accepted` when successful. The response will not include a body. +This endpoint will respond with a status of `202 Accepted` when successful. The body of the response will be a JSON object describing the newly-sent message: + +```json +{ + "at": "2024-10-19T04:37:09.467325Z", + "channel": "Cfqdn1234", + "sender": "Labcd1234", + "id": "Mgh98yp75", + "body": "an elaborate example message" +} +``` + +The response will have the following fields: + +| Field | Type | Description | +|:----------|:----------|:--| +| `at` | timestamp | The moment the message was sent. | +| `channel` | string | The ID of the channel the message was sent to. | +| `sender` | string | The ID of the login that sent the message. | +| `id` | string | A unique identifier for the message. This can be used to associate the message with events, or to make API calls targeting the message. | +| `body` | string | The message's body. | + +When completed, the service will emit a [message sent](events.md#message-sent) event with the message's ID. ### Invalid channel ID This endpoint will respond with a status of `404 Not Found` if the channel ID is not valid. - ## `DELETE /api/channels/:id` Deletes a channel (and all messages in it). @@ -123,6 +146,9 @@ This endpoint requires the following path parameter: This endpoint will respond with a status of `202 Accepted` when successful. The response will not include a body. +When completed, the service will emit a [channel deleted](events.md#channel-deleted) event with the channel's ID. + + ### Invalid channel ID This endpoint will respond with a status of `404 Not Found` if the channel ID is not valid. @@ -142,6 +168,8 @@ This endpoint requires the following path parameter: This endpoint will respond with a status of `202 Accepted` when successful. The response will not include a body. +When completed, the service will emit a [message deleted](events.md#message-deleted) event with the channel's ID. + ### Invalid message ID This endpoint will respond with a status of `404 Not Found` if the message ID is not valid. diff --git a/docs/api/events.md b/docs/api/events.md index 2f48df1..b08e971 100644 --- a/docs/api/events.md +++ b/docs/api/events.md @@ -31,6 +31,11 @@ sequenceDiagram The core of the service is to facilitate conversations between logins. Conversational activity is delivered to clients using _events_. Each event notifies interested clients of activity sent to the service through its API. +## Asynchronous completion + +A number of endpoints return `202 Accepted` responses. The actions performed by those endpoints will be completed before events are delivered. To await the completion of an operation which returns this response, clients must monitor the event stream for the corresponding event. + + ## `GET /api/events` Subscribes to events. diff --git a/docs/api/initial-setup.md b/docs/api/initial-setup.md index ad57089..3c5a8a6 100644 --- a/docs/api/initial-setup.md +++ b/docs/api/initial-setup.md @@ -55,7 +55,21 @@ The request must have the following fields: -This endpoint will respond with a status of `204 No Content` when successful. +This endpoint will respond with a status of `200 Okay` when successful. The body of the response will be a JSON object describing the newly-created login: + +```json +{ + "id": "Labcd1234", + "name": "Andrea" +} +``` + +The response will include the following fields: + +| Field | Type | Description | +|:------------|:-------|:--| +| `id` | string | A unique identifier for the newly-created login. This can be used to associate the login with other events, or to make API calls targeting the login. | +| `name` | string | The login's name. | The response will include a `Set-Cookie` header for the `identity` cookie, providing the client with a newly-minted identity token associated with the initial login created for this request. See the [authentication](./authentication) section for details on how this cookie may be used. diff --git a/docs/api/invitations.md b/docs/api/invitations.md index f75e30b..d3431d7 100644 --- a/docs/api/invitations.md +++ b/docs/api/invitations.md @@ -134,7 +134,21 @@ The request must have the following fields: -This endpoint will respond with a status of `204 No Content` when successful. +This endpoint will respond with a status of `200 Okay` when successful. The body of the response will be a JSON object describing the newly-created login: + +```json +{ + "id": "Labcd1234", + "name": "Andrea" +} +``` + +The response will include the following fields: + +| Field | Type | Description | +|:------------|:-------|:--| +| `id` | string | A unique identifier for the newly-created login. This can be used to associate the login with other events, or to make API calls targeting the login. | +| `name` | string | The login's name. | The response will include a `Set-Cookie` header for the `identity` cookie, providing the client with a newly-minted identity token associated with the login created for this request. See the [authentication](./authentication.md) section for details on how this cookie may be used. diff --git a/src/channel/routes/channel/post.rs b/src/channel/routes/channel/post.rs index a71a3a0..b489a77 100644 --- a/src/channel/routes/channel/post.rs +++ b/src/channel/routes/channel/post.rs @@ -1,7 +1,7 @@ use axum::{ extract::{Json, Path, State}, http::StatusCode, - response::{IntoResponse, Response}, + response::{self, IntoResponse}, }; use crate::{ @@ -9,7 +9,7 @@ use crate::{ clock::RequestedAt, error::{Internal, NotFound}, login::Login, - message::app::SendError, + message::{app::SendError, Message}, }; pub async fn handler( @@ -18,12 +18,13 @@ pub async fn handler( RequestedAt(sent_at): RequestedAt, login: Login, Json(request): Json, -) -> Result { - app.messages() +) -> Result { + let message = app + .messages() .send(&channel, &login, &sent_at, &request.body) .await?; - Ok(StatusCode::ACCEPTED) + Ok(Response(message)) } #[derive(serde::Deserialize)] @@ -31,12 +32,22 @@ pub struct Request { pub body: String, } +#[derive(Debug)] +pub struct Response(pub Message); + +impl IntoResponse for Response { + fn into_response(self) -> response::Response { + let Self(message) = self; + (StatusCode::ACCEPTED, Json(message)).into_response() + } +} + #[derive(Debug, thiserror::Error)] #[error(transparent)] pub struct Error(#[from] pub SendError); impl IntoResponse for Error { - fn into_response(self) -> Response { + fn into_response(self) -> response::Response { let Self(error) = self; #[allow(clippy::match_wildcard_for_single_variants)] match error { diff --git a/src/channel/routes/channel/test.rs b/src/channel/routes/channel/test.rs index c6541cd..b895b69 100644 --- a/src/channel/routes/channel/test.rs +++ b/src/channel/routes/channel/test.rs @@ -27,7 +27,7 @@ async fn messages_in_order() { for (sent_at, body) in &requests { let request = post::Request { body: body.clone() }; - post::handler( + let _ = post::handler( State(app.clone()), Path(channel.id.clone()), sent_at.clone(), diff --git a/src/channel/routes/post.rs b/src/channel/routes/post.rs index d694f8b..a05c312 100644 --- a/src/channel/routes/post.rs +++ b/src/channel/routes/post.rs @@ -17,14 +17,14 @@ pub async fn handler( _: Login, // requires auth, but doesn't actually care who you are RequestedAt(created_at): RequestedAt, Json(request): Json, -) -> Result, Error> { +) -> Result { let channel = app .channels() .create(&request.name, &created_at) .await .map_err(Error)?; - Ok(Json(channel)) + Ok(Response(channel)) } #[derive(serde::Deserialize)] @@ -32,6 +32,16 @@ pub struct Request { pub name: String, } +#[derive(Debug)] +pub struct Response(pub Channel); + +impl IntoResponse for Response { + fn into_response(self) -> response::Response { + let Self(channel) = self; + (StatusCode::ACCEPTED, Json(channel)).into_response() + } +} + #[derive(Debug)] pub struct Error(pub app::CreateError); diff --git a/src/channel/routes/test.rs b/src/channel/routes/test.rs index ffd8484..7879ba0 100644 --- a/src/channel/routes/test.rs +++ b/src/channel/routes/test.rs @@ -20,9 +20,10 @@ async fn new_channel() { let name = fixtures::channel::propose(); let request = post::Request { name: name.clone() }; - let Json(response) = post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("creating a channel in an empty app succeeds"); + let post::Response(response) = + post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect("creating a channel in an empty app succeeds"); // Verify the structure of the response @@ -96,7 +97,7 @@ async fn name_reusable_after_delete() { // Call the endpoint (first time) let request = post::Request { name: name.clone() }; - let Json(response) = post::handler( + let post::Response(response) = post::handler( State(app.clone()), creator.clone(), fixtures::now(), @@ -115,9 +116,10 @@ async fn name_reusable_after_delete() { // Call the endpoint (second time) let request = post::Request { name: name.clone() }; - let Json(response) = post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) - .await - .expect("new channel in an empty app"); + let post::Response(response) = + post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect("new channel in an empty app"); // Verify the structure of the response diff --git a/src/invite/app.rs b/src/invite/app.rs index 4162470..ee7f74f 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -45,7 +45,7 @@ impl<'a> Invites<'a> { name: &str, password: &Password, accepted_at: &DateTime, - ) -> Result { + ) -> Result<(Login, Secret), AcceptError> { let mut tx = self.db.begin().await?; let invite = tx .invites() @@ -72,7 +72,7 @@ impl<'a> Invites<'a> { let secret = tx.tokens().issue(&login, accepted_at).await?; tx.commit().await?; - Ok(secret) + Ok((login.as_created(), secret)) } pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs index 12c2e21..c072929 100644 --- a/src/invite/routes/invite/post.rs +++ b/src/invite/routes/invite/post.rs @@ -9,7 +9,7 @@ use crate::{ clock::RequestedAt, error::{Internal, NotFound}, invite::app, - login::Password, + login::{Login, Password}, token::extract::IdentityToken, }; @@ -19,14 +19,14 @@ pub async fn handler( identity: IdentityToken, Path(invite): Path, Json(request): Json, -) -> Result<(IdentityToken, StatusCode), Error> { - let secret = app +) -> Result<(IdentityToken, Json), Error> { + let (login, secret) = app .invites() .accept(&invite, &request.name, &request.password, &accepted_at) .await .map_err(Error)?; let identity = identity.set(secret); - Ok((identity, StatusCode::NO_CONTENT)) + Ok((identity, Json(login))) } #[derive(serde::Deserialize)] diff --git a/src/login/history.rs b/src/login/history.rs index f8d81bb..daad579 100644 --- a/src/login/history.rs +++ b/src/login/history.rs @@ -20,7 +20,6 @@ impl History { // if this returns a redacted or modified version of the login. If we implement // renames by redacting the original name, then this should return the edited // login, not the original, even if that's not how it was "as created.") - #[cfg(test)] pub fn as_created(&self) -> Login { self.login.clone() } @@ -30,6 +29,11 @@ impl History { .filter(Sequence::up_to(resume_point.into())) .collect() } + + // Snapshot of this login, as of all events recorded in this history. + pub fn as_snapshot(&self) -> Option { + self.events().collect() + } } // Events interface diff --git a/src/login/routes/login/post.rs b/src/login/routes/login/post.rs index e33acad..67eaa6d 100644 --- a/src/login/routes/login/post.rs +++ b/src/login/routes/login/post.rs @@ -8,7 +8,7 @@ use crate::{ app::App, clock::RequestedAt, error::Internal, - login::Password, + login::{Login, Password}, token::{app, extract::IdentityToken}, }; @@ -17,14 +17,14 @@ pub async fn handler( RequestedAt(now): RequestedAt, identity: IdentityToken, Json(request): Json, -) -> Result<(IdentityToken, StatusCode), Error> { - let token = app +) -> Result<(IdentityToken, Json), Error> { + let (login, secret) = app .tokens() .login(&request.name, &request.password, &now) .await .map_err(Error)?; - let identity = identity.set(token); - Ok((identity, StatusCode::NO_CONTENT)) + let identity = identity.set(secret); + Ok((identity, Json(login))) } #[derive(serde::Deserialize)] diff --git a/src/login/routes/login/test.rs b/src/login/routes/login/test.rs index d431612..c94f14c 100644 --- a/src/login/routes/login/test.rs +++ b/src/login/routes/login/test.rs @@ -1,7 +1,4 @@ -use axum::{ - extract::{Json, State}, - http::StatusCode, -}; +use axum::extract::{Json, State}; use super::post; use crate::{test::fixtures, token::app}; @@ -11,24 +8,24 @@ async fn correct_credentials() { // Set up the environment let app = fixtures::scratch_app().await; - let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; + let (login, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; // Call the endpoint let identity = fixtures::identity::not_logged_in(); let logged_in_at = fixtures::now(); let request = post::Request { - name: name.clone(), + name: login.name.clone(), password, }; - let (identity, status) = + let (identity, Json(response)) = post::handler(State(app.clone()), logged_in_at, identity, Json(request)) .await .expect("logged in with valid credentials"); // Verify the return value's basic structure - assert_eq!(StatusCode::NO_CONTENT, status); + assert_eq!(login, response); let secret = identity.secret().expect("logged in with valid credentials"); // Verify the semantics @@ -40,7 +37,7 @@ async fn correct_credentials() { .await .expect("identity secret is valid"); - assert_eq!(name, validated_login.name); + assert_eq!(login, validated_login); } #[tokio::test] @@ -98,13 +95,16 @@ async fn token_expires() { // Set up the environment let app = fixtures::scratch_app().await; - let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; + let (login, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; // Call the endpoint let logged_in_at = fixtures::ancient(); let identity = fixtures::identity::not_logged_in(); - let request = post::Request { name, password }; + let request = post::Request { + name: login.name.clone(), + password, + }; let (identity, _) = post::handler(State(app.clone()), logged_in_at, identity, Json(request)) .await .expect("logged in with valid credentials"); diff --git a/src/setup/app.rs b/src/setup/app.rs index 24e0010..d015813 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -4,7 +4,7 @@ use super::repo::Provider as _; use crate::{ clock::DateTime, event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, Password}, + login::{repo::Provider as _, Login, Password}, token::{repo::Provider as _, Secret}, }; @@ -23,7 +23,7 @@ impl<'a> Setup<'a> { name: &str, password: &Password, created_at: &DateTime, - ) -> Result { + ) -> Result<(Login, Secret), Error> { let password_hash = password.hash()?; let mut tx = self.db.begin().await?; @@ -39,7 +39,7 @@ impl<'a> Setup<'a> { self.events .broadcast(login.events().map(Event::from).collect::>()); - Ok(secret) + Ok((login.as_created(), secret)) } pub async fn completed(&self) -> Result { diff --git a/src/setup/routes/post.rs b/src/setup/routes/post.rs index 9e6776f..34f4ed2 100644 --- a/src/setup/routes/post.rs +++ b/src/setup/routes/post.rs @@ -5,7 +5,11 @@ use axum::{ }; use crate::{ - app::App, clock::RequestedAt, error::Internal, login::Password, setup::app, + app::App, + clock::RequestedAt, + error::Internal, + login::{Login, Password}, + setup::app, token::extract::IdentityToken, }; @@ -14,14 +18,14 @@ pub async fn handler( RequestedAt(setup_at): RequestedAt, identity: IdentityToken, Json(request): Json, -) -> Result<(IdentityToken, StatusCode), Error> { - let secret = app +) -> Result<(IdentityToken, Json), Error> { + let (login, secret) = app .setup() .initial(&request.name, &request.password, &setup_at) .await .map_err(Error)?; let identity = identity.set(secret); - Ok((identity, StatusCode::NO_CONTENT)) + Ok((identity, Json(login))) } #[derive(serde::Deserialize)] diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs index 56b4ffa..c434473 100644 --- a/src/test/fixtures/identity.rs +++ b/src/test/fixtures/identity.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::{ app::App, clock::RequestedAt, - login::Password, + login::{Login, Password}, token::{ extract::{Identity, IdentityToken}, Secret, @@ -14,11 +14,11 @@ pub fn not_logged_in() -> IdentityToken { IdentityToken::new() } -pub async fn logged_in(app: &App, login: &(String, Password), now: &RequestedAt) -> IdentityToken { - let (name, password) = login; - let token = app +pub async fn logged_in(app: &App, login: &(Login, Password), now: &RequestedAt) -> IdentityToken { + let (login, password) = login; + let (_, token) = app .tokens() - .login(name, password, now) + .login(&login.name, password, now) .await .expect("should succeed given known-valid credentials"); @@ -36,7 +36,7 @@ pub async fn from_token(app: &App, token: &IdentityToken, issued_at: &RequestedA Identity { token, login } } -pub async fn identity(app: &App, login: &(String, Password), issued_at: &RequestedAt) -> Identity { +pub async fn identity(app: &App, login: &(Login, Password), issued_at: &RequestedAt) -> Identity { let secret = logged_in(app, login, issued_at).await; from_token(app, &secret, issued_at).await } diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs index e5ac716..b6766fe 100644 --- a/src/test/fixtures/login.rs +++ b/src/test/fixtures/login.rs @@ -7,14 +7,15 @@ use crate::{ login::{self, Login, Password}, }; -pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (String, Password) { +pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Login, Password) { let (name, password) = propose(); - app.logins() + let login = app + .logins() .create(&name, &password, created_at) .await .expect("should always succeed if the login is actually new"); - (name, password) + (login, password) } pub async fn create(app: &App, created_at: &RequestedAt) -> Login { diff --git a/src/token/app.rs b/src/token/app.rs index cb5d75f..0dc1a46 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -30,7 +30,7 @@ impl<'a> Tokens<'a> { name: &str, password: &Password, login_at: &DateTime, - ) -> Result { + ) -> Result<(Login, Secret), LoginError> { let mut tx = self.db.begin().await?; let (login, stored_hash) = tx .auth() @@ -45,6 +45,8 @@ impl<'a> Tokens<'a> { // if the account is deleted during that time. tx.commit().await?; + let snapshot = login.as_snapshot().ok_or(LoginError::Rejected)?; + let token = if stored_hash.verify(password)? { let mut tx = self.db.begin().await?; let token = tx.tokens().issue(&login, login_at).await?; @@ -54,7 +56,7 @@ impl<'a> Tokens<'a> { Err(LoginError::Rejected)? }; - Ok(token) + Ok((snapshot, token)) } pub async fn validate( -- cgit v1.2.3 From 379e97c2cb145bc3a495aa14746273d83b508214 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Sat, 19 Oct 2024 01:51:30 -0400 Subject: Unicode normalization on input. This normalizes the following values: * login names * passwords * channel names * message bodies, because why not The goal here is to have a canonical representation of these values, so that, for example, the service does not inadvertently host two channels whose names are semantically identical but differ in the specifics of how diacritics are encoded, or two users whose names are identical. Normalization is done on input from the wire, using Serde hooks, and when reading from the database. The `crate::nfc::String` type implements these normalizations (as well as normalizing whenever converted from a `std::string::String` generally). This change does not cover: * Trying to cope with passwords that were created as non-normalized strings, which are now non-verifiable as all the paths to verify passwords normalize the input. * Trying to ensure that non-normalized data in the database compares reasonably to normalized data. Fortunately, we don't _do_ very many string comparisons (I think only login names), so this isn't a huge deal at this stage. Login names will probably have to Get Fixed later on, when we figure out how to handle case folding for login name verification. --- ...a9eb3578281dc70941faf05d53f8525034446ae52f.json | 50 ++++++++++ ...32389259b91bbffa182e32b224635031eead2fa82d.json | 50 ++++++++++ ...568c112d0f6fe2aa136f0758a68f1eb66787db52f0.json | 50 ---------- ...1314f70d13e8b0ca22b7652f1063ec7796cf307269.json | 62 +++++++++++++ ...e6856eff74a544f0a1c3692766e48a3182df5ada98.json | 62 +++++++++++++ ...1ef99603f820d3b391f212c2c0883506a53ee757ca.json | 62 ------------- ...6c00151035dcf174cec137326b63e0cb0e4ae5cb60.json | 38 -------- ...0c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json | 62 ------------- ...701b86eddb5a1114202ba385f66f131b1601feec11.json | 62 +++++++++++++ ...c7b1d9f6a2215c7c135720c1222170a1f6692c3a8a.json | 38 -------- ...61b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e.json | 32 +++++++ ...2e34ec512dfc9109b338eea475c209e5a005ae20c6.json | 50 ++++++++++ ...46d2ea19a3007558abe81a1635c64ef7960acd30b2.json | 50 ---------- ...0e83b4ef41bad03b57937a27f98aebbef5268ef5f5.json | 62 +++++++++++++ ...65327d32a68f41258a27249027f9200b2bbba047cb.json | 50 ++++++++++ ...9e603b3087a17906db95d14a35034889a253f23418.json | 50 ---------- ...e65e101d0941f2a266679209f793fd27466b9e3719.json | 50 ---------- ...31ca611395405093dbbed738579d715b2d623921be.json | 62 ------------- ...eb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659.json | 38 ++++++++ ...283b4094fa9dfa65dc95991f689355889606ab1c46.json | 38 -------- ...54a251e76e93d3a43d1447faa4ec4085f7b3f60404.json | 32 ------- ...1de1fa77f2a18537d30e61ccbdcc947d0dba2b3da3.json | 38 -------- ...fa157b6ee50f32e1136ad9cbd91caa0add2ab3cdaa.json | 44 --------- ...b3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json | 62 ------------- ...90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e.json | 44 +++++++++ ...3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f.json | 38 ++++++++ ...ebb2c0efd1c5718e721fe906765c28d040e446acd7.json | 38 ++++++++ ...79ae0758568f732d837c923cfe1b142181fb5d83f3.json | 38 ++++++++ ...ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6.json | 50 ++++++++++ ...b2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json | 50 ---------- ...5294ae908114b03719dc0cb237cec11f807bf757b1.json | 62 ------------- ...0599914331682d58ace57214bfa26ccaa089592a24.json | 62 +++++++++++++ Cargo.lock | 1 + Cargo.toml | 1 + docs/api/channels-messages.md | 4 + docs/api/initial-setup.md | 4 + docs/api/invitations.md | 4 + src/channel/app.rs | 8 +- src/channel/mod.rs | 5 +- src/channel/name.rs | 30 ++++++ src/channel/repo.rs | 14 +-- src/channel/routes/channel/post.rs | 4 +- src/channel/routes/post.rs | 4 +- src/channel/snapshot.rs | 4 +- src/invite/app.rs | 8 +- src/invite/routes/invite/post.rs | 4 +- src/lib.rs | 1 + src/login/app.rs | 4 +- src/login/mod.rs | 4 +- src/login/name.rs | 28 ++++++ src/login/password.rs | 7 +- src/login/repo.rs | 10 +- src/login/routes/login/post.rs | 4 +- src/login/snapshot.rs | 4 +- src/message/app.rs | 4 +- src/message/body.rs | 30 ++++++ src/message/mod.rs | 5 +- src/message/repo.rs | 28 +++--- src/message/snapshot.rs | 4 +- src/nfc.rs | 103 +++++++++++++++++++++ src/setup/app.rs | 4 +- src/setup/routes/post.rs | 4 +- src/test/fixtures/channel.rs | 10 +- src/test/fixtures/login.rs | 12 +-- src/test/fixtures/message.rs | 6 +- src/token/app.rs | 4 +- src/token/repo/auth.rs | 6 +- src/token/repo/token.rs | 4 +- 68 files changed, 1086 insertions(+), 871 deletions(-) create mode 100644 .sqlx/query-0caf898537dd38eea00322a9eb3578281dc70941faf05d53f8525034446ae52f.json create mode 100644 .sqlx/query-0f0e4a6ac32b39f3bd7f4832389259b91bbffa182e32b224635031eead2fa82d.json delete mode 100644 .sqlx/query-152cbd56cdce2ee7e94ae2568c112d0f6fe2aa136f0758a68f1eb66787db52f0.json create mode 100644 .sqlx/query-24bc0257eff3357322481e1314f70d13e8b0ca22b7652f1063ec7796cf307269.json create mode 100644 .sqlx/query-2e0aa3126267465ee1ae01e6856eff74a544f0a1c3692766e48a3182df5ada98.json delete mode 100644 .sqlx/query-34d3bc2b18bc0813cbaafc1ef99603f820d3b391f212c2c0883506a53ee757ca.json delete mode 100644 .sqlx/query-3fbec32aeb32c49e088f246c00151035dcf174cec137326b63e0cb0e4ae5cb60.json delete mode 100644 .sqlx/query-400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json create mode 100644 .sqlx/query-40e0310af2814435cca882701b86eddb5a1114202ba385f66f131b1601feec11.json delete mode 100644 .sqlx/query-4224d5c1c4009e0d31b96bc7b1d9f6a2215c7c135720c1222170a1f6692c3a8a.json create mode 100644 .sqlx/query-4392eb4ec7257676acdce461b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e.json create mode 100644 .sqlx/query-48884b1c153cbf6e10eed72e34ec512dfc9109b338eea475c209e5a005ae20c6.json delete mode 100644 .sqlx/query-647c3387fd01747b6318b946d2ea19a3007558abe81a1635c64ef7960acd30b2.json create mode 100644 .sqlx/query-78d24fa907f3dcc0c129880e83b4ef41bad03b57937a27f98aebbef5268ef5f5.json create mode 100644 .sqlx/query-7f5bbd935941210ba2f25e65327d32a68f41258a27249027f9200b2bbba047cb.json delete mode 100644 .sqlx/query-836a37fa3fbcacbc880c7c9e603b3087a17906db95d14a35034889a253f23418.json delete mode 100644 .sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json delete mode 100644 .sqlx/query-8e816ccef8ecee937d88ff31ca611395405093dbbed738579d715b2d623921be.json create mode 100644 .sqlx/query-91df35ca0c610b05fb353eeb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659.json delete mode 100644 .sqlx/query-9da5b746de5d481eeb0755283b4094fa9dfa65dc95991f689355889606ab1c46.json delete mode 100644 .sqlx/query-ae9d1c997891f7e917cde554a251e76e93d3a43d1447faa4ec4085f7b3f60404.json delete mode 100644 .sqlx/query-b09438f4b1247a4e3991751de1fa77f2a18537d30e61ccbdcc947d0dba2b3da3.json delete mode 100644 .sqlx/query-b991b34b491306780a1b6efa157b6ee50f32e1136ad9cbd91caa0add2ab3cdaa.json delete mode 100644 .sqlx/query-bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json create mode 100644 .sqlx/query-be33d1fb2f71093ed73efd90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e.json create mode 100644 .sqlx/query-d693a55bf9394ea79a892c3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f.json create mode 100644 .sqlx/query-db77e97937167d1edbbe88ebb2c0efd1c5718e721fe906765c28d040e446acd7.json create mode 100644 .sqlx/query-dbbc785bc45173db773f9179ae0758568f732d837c923cfe1b142181fb5d83f3.json create mode 100644 .sqlx/query-dd613246fc1e87039f0a12ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6.json delete mode 100644 .sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json delete mode 100644 .sqlx/query-e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1.json create mode 100644 .sqlx/query-fce8f4fbd59a8b3b8531e10599914331682d58ace57214bfa26ccaa089592a24.json create mode 100644 src/channel/name.rs create mode 100644 src/login/name.rs create mode 100644 src/message/body.rs create mode 100644 src/nfc.rs (limited to 'src/channel/routes') diff --git a/.sqlx/query-0caf898537dd38eea00322a9eb3578281dc70941faf05d53f8525034446ae52f.json b/.sqlx/query-0caf898537dd38eea00322a9eb3578281dc70941faf05d53f8525034446ae52f.json new file mode 100644 index 0000000..8990436 --- /dev/null +++ b/.sqlx/query-0caf898537dd38eea00322a9eb3578281dc70941faf05d53f8525034446ae52f.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n channel.name as \"name: Name\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence <= $1, true)\n order by channel.name\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name: Name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + true, + true + ] + }, + "hash": "0caf898537dd38eea00322a9eb3578281dc70941faf05d53f8525034446ae52f" +} diff --git a/.sqlx/query-0f0e4a6ac32b39f3bd7f4832389259b91bbffa182e32b224635031eead2fa82d.json b/.sqlx/query-0f0e4a6ac32b39f3bd7f4832389259b91bbffa182e32b224635031eead2fa82d.json new file mode 100644 index 0000000..fd5a165 --- /dev/null +++ b/.sqlx/query-0f0e4a6ac32b39f3bd7f4832389259b91bbffa182e32b224635031eead2fa82d.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n insert into message\n (id, channel, sender, sent_at, sent_sequence, body)\n values ($1, $2, $3, $4, $5, $6)\n returning\n id as \"id: Id\",\n channel as \"channel: channel::Id\",\n sender as \"sender: login::Id\",\n sent_at as \"sent_at: DateTime\",\n sent_sequence as \"sent_sequence: Sequence\",\n body as \"body: Body\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "channel: channel::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "body: Body", + "ordinal": 5, + "type_info": "Text" + } + ], + "parameters": { + "Right": 6 + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "0f0e4a6ac32b39f3bd7f4832389259b91bbffa182e32b224635031eead2fa82d" +} diff --git a/.sqlx/query-152cbd56cdce2ee7e94ae2568c112d0f6fe2aa136f0758a68f1eb66787db52f0.json b/.sqlx/query-152cbd56cdce2ee7e94ae2568c112d0f6fe2aa136f0758a68f1eb66787db52f0.json deleted file mode 100644 index 2c7666f..0000000 --- a/.sqlx/query-152cbd56cdce2ee7e94ae2568c112d0f6fe2aa136f0758a68f1eb66787db52f0.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where id = $1\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at?: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence?: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - false, - false - ] - }, - "hash": "152cbd56cdce2ee7e94ae2568c112d0f6fe2aa136f0758a68f1eb66787db52f0" -} diff --git a/.sqlx/query-24bc0257eff3357322481e1314f70d13e8b0ca22b7652f1063ec7796cf307269.json b/.sqlx/query-24bc0257eff3357322481e1314f70d13e8b0ca22b7652f1063ec7796cf307269.json new file mode 100644 index 0000000..9f09a28 --- /dev/null +++ b/.sqlx/query-24bc0257eff3357322481e1314f70d13e8b0ca22b7652f1063ec7796cf307269.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n message.body as \"body: Body\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where coalesce(message.sent_sequence > $1, true)\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "channel: channel::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "body: Body", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true + ] + }, + "hash": "24bc0257eff3357322481e1314f70d13e8b0ca22b7652f1063ec7796cf307269" +} diff --git a/.sqlx/query-2e0aa3126267465ee1ae01e6856eff74a544f0a1c3692766e48a3182df5ada98.json b/.sqlx/query-2e0aa3126267465ee1ae01e6856eff74a544f0a1c3692766e48a3182df5ada98.json new file mode 100644 index 0000000..227d242 --- /dev/null +++ b/.sqlx/query-2e0aa3126267465ee1ae01e6856eff74a544f0a1c3692766e48a3182df5ada98.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n message.body as \"body: Body\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where message.sent_at < $1\n and deleted.id is null\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "channel: channel::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "body: Body", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "2e0aa3126267465ee1ae01e6856eff74a544f0a1c3692766e48a3182df5ada98" +} diff --git a/.sqlx/query-34d3bc2b18bc0813cbaafc1ef99603f820d3b391f212c2c0883506a53ee757ca.json b/.sqlx/query-34d3bc2b18bc0813cbaafc1ef99603f820d3b391f212c2c0883506a53ee757ca.json deleted file mode 100644 index 63ff012..0000000 --- a/.sqlx/query-34d3bc2b18bc0813cbaafc1ef99603f820d3b391f212c2c0883506a53ee757ca.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n message.body,\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where message.sent_at < $1\n and deleted.id is null\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "channel: channel::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "sender: login::Id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "sent_at: DateTime", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "sent_sequence: Sequence", - "ordinal": 4, - "type_info": "Integer" - }, - { - "name": "body", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "deleted_at?: DateTime", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "deleted_sequence?: Sequence", - "ordinal": 7, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - true, - false, - false - ] - }, - "hash": "34d3bc2b18bc0813cbaafc1ef99603f820d3b391f212c2c0883506a53ee757ca" -} diff --git a/.sqlx/query-3fbec32aeb32c49e088f246c00151035dcf174cec137326b63e0cb0e4ae5cb60.json b/.sqlx/query-3fbec32aeb32c49e088f246c00151035dcf174cec137326b63e0cb0e4ae5cb60.json deleted file mode 100644 index 01e72b2..0000000 --- a/.sqlx/query-3fbec32aeb32c49e088f246c00151035dcf174cec137326b63e0cb0e4ae5cb60.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into login (id, name, password_hash, created_sequence, created_at)\n values ($1, $2, $3, $4, $5)\n returning\n id as \"id: Id\",\n name,\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 5 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "3fbec32aeb32c49e088f246c00151035dcf174cec137326b63e0cb0e4ae5cb60" -} diff --git a/.sqlx/query-400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json b/.sqlx/query-400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json deleted file mode 100644 index 0caa950..0000000 --- a/.sqlx/query-400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n message.body,\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where coalesce(message.sent_sequence > $1, true)\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "channel: channel::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "sender: login::Id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "sent_at: DateTime", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "sent_sequence: Sequence", - "ordinal": 4, - "type_info": "Integer" - }, - { - "name": "body", - "ordinal": 5, - "type_info": "Text" - }, - { - "name": "deleted_at: DateTime", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "deleted_sequence: Sequence", - "ordinal": 7, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false, - true, - true, - true - ] - }, - "hash": "400de8e1dca27c48362b060c5b16e9ae908c4ea30e2d682d59d4720699b8de91" -} diff --git a/.sqlx/query-40e0310af2814435cca882701b86eddb5a1114202ba385f66f131b1601feec11.json b/.sqlx/query-40e0310af2814435cca882701b86eddb5a1114202ba385f66f131b1601feec11.json new file mode 100644 index 0000000..3147d7f --- /dev/null +++ b/.sqlx/query-40e0310af2814435cca882701b86eddb5a1114202ba385f66f131b1601feec11.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body as \"body: Body\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where message.channel = $1\n and deleted.id is null\n ", + "describe": { + "columns": [ + { + "name": "channel: channel::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "id: Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "body: Body", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true, + true + ] + }, + "hash": "40e0310af2814435cca882701b86eddb5a1114202ba385f66f131b1601feec11" +} diff --git a/.sqlx/query-4224d5c1c4009e0d31b96bc7b1d9f6a2215c7c135720c1222170a1f6692c3a8a.json b/.sqlx/query-4224d5c1c4009e0d31b96bc7b1d9f6a2215c7c135720c1222170a1f6692c3a8a.json deleted file mode 100644 index 767c217..0000000 --- a/.sqlx/query-4224d5c1c4009e0d31b96bc7b1d9f6a2215c7c135720c1222170a1f6692c3a8a.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n name,\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(login.created_sequence > $1, true)\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "4224d5c1c4009e0d31b96bc7b1d9f6a2215c7c135720c1222170a1f6692c3a8a" -} diff --git a/.sqlx/query-4392eb4ec7257676acdce461b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e.json b/.sqlx/query-4392eb4ec7257676acdce461b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e.json new file mode 100644 index 0000000..1dd685b --- /dev/null +++ b/.sqlx/query-4392eb4ec7257676acdce461b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e.json @@ -0,0 +1,32 @@ +{ + "db_name": "SQLite", + "query": "\n select\n token.id as \"token_id: Id\",\n login.id as \"login_id: login::Id\",\n login.name as \"login_name: Name\"\n from login\n join token on login.id = token.login\n where token.secret = $1\n ", + "describe": { + "columns": [ + { + "name": "token_id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "login_id: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "login_name: Name", + "ordinal": 2, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "4392eb4ec7257676acdce461b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e" +} diff --git a/.sqlx/query-48884b1c153cbf6e10eed72e34ec512dfc9109b338eea475c209e5a005ae20c6.json b/.sqlx/query-48884b1c153cbf6e10eed72e34ec512dfc9109b338eea475c209e5a005ae20c6.json new file mode 100644 index 0000000..b8d7303 --- /dev/null +++ b/.sqlx/query-48884b1c153cbf6e10eed72e34ec512dfc9109b338eea475c209e5a005ae20c6.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n channel.name as \"name: Name\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name: Name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + false, + false + ] + }, + "hash": "48884b1c153cbf6e10eed72e34ec512dfc9109b338eea475c209e5a005ae20c6" +} diff --git a/.sqlx/query-647c3387fd01747b6318b946d2ea19a3007558abe81a1635c64ef7960acd30b2.json b/.sqlx/query-647c3387fd01747b6318b946d2ea19a3007558abe81a1635c64ef7960acd30b2.json deleted file mode 100644 index f706f81..0000000 --- a/.sqlx/query-647c3387fd01747b6318b946d2ea19a3007558abe81a1635c64ef7960acd30b2.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n channel.id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n left join message\n where channel.created_at < $1\n and message.id is null\n and deleted.id is null\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at?: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence?: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - false, - false - ] - }, - "hash": "647c3387fd01747b6318b946d2ea19a3007558abe81a1635c64ef7960acd30b2" -} diff --git a/.sqlx/query-78d24fa907f3dcc0c129880e83b4ef41bad03b57937a27f98aebbef5268ef5f5.json b/.sqlx/query-78d24fa907f3dcc0c129880e83b4ef41bad03b57937a27f98aebbef5268ef5f5.json new file mode 100644 index 0000000..09440ca --- /dev/null +++ b/.sqlx/query-78d24fa907f3dcc0c129880e83b4ef41bad03b57937a27f98aebbef5268ef5f5.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body as \"body: Body\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "channel: channel::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "id: Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "body: Body", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "78d24fa907f3dcc0c129880e83b4ef41bad03b57937a27f98aebbef5268ef5f5" +} diff --git a/.sqlx/query-7f5bbd935941210ba2f25e65327d32a68f41258a27249027f9200b2bbba047cb.json b/.sqlx/query-7f5bbd935941210ba2f25e65327d32a68f41258a27249027f9200b2bbba047cb.json new file mode 100644 index 0000000..83e730d --- /dev/null +++ b/.sqlx/query-7f5bbd935941210ba2f25e65327d32a68f41258a27249027f9200b2bbba047cb.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n channel.id as \"id: Id\",\n channel.name as \"name: Name\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n left join message\n where channel.created_at < $1\n and message.id is null\n and deleted.id is null\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name: Name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + false, + false + ] + }, + "hash": "7f5bbd935941210ba2f25e65327d32a68f41258a27249027f9200b2bbba047cb" +} diff --git a/.sqlx/query-836a37fa3fbcacbc880c7c9e603b3087a17906db95d14a35034889a253f23418.json b/.sqlx/query-836a37fa3fbcacbc880c7c9e603b3087a17906db95d14a35034889a253f23418.json deleted file mode 100644 index 4770b7e..0000000 --- a/.sqlx/query-836a37fa3fbcacbc880c7c9e603b3087a17906db95d14a35034889a253f23418.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\t\t\tinsert into message\n\t\t\t\t\t(id, channel, sender, sent_at, sent_sequence, body)\n\t\t\t\tvalues ($1, $2, $3, $4, $5, $6)\n\t\t\t\treturning\n\t\t\t\t\tid as \"id: Id\",\n channel as \"channel: channel::Id\",\n sender as \"sender: login::Id\",\n sent_at as \"sent_at: DateTime\",\n sent_sequence as \"sent_sequence: Sequence\",\n\t\t\t\t\tbody\n\t\t\t", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "channel: channel::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "sender: login::Id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "sent_at: DateTime", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "sent_sequence: Sequence", - "ordinal": 4, - "type_info": "Integer" - }, - { - "name": "body", - "ordinal": 5, - "type_info": "Text" - } - ], - "parameters": { - "Right": 6 - }, - "nullable": [ - false, - false, - false, - false, - false, - true - ] - }, - "hash": "836a37fa3fbcacbc880c7c9e603b3087a17906db95d14a35034889a253f23418" -} diff --git a/.sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json b/.sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json deleted file mode 100644 index 4d28006..0000000 --- a/.sqlx/query-8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence <= $1, true)\n order by channel.name\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - true, - true - ] - }, - "hash": "8d9b83fb53e4191ee58b68e65e101d0941f2a266679209f793fd27466b9e3719" -} diff --git a/.sqlx/query-8e816ccef8ecee937d88ff31ca611395405093dbbed738579d715b2d623921be.json b/.sqlx/query-8e816ccef8ecee937d88ff31ca611395405093dbbed738579d715b2d623921be.json deleted file mode 100644 index 5a038ac..0000000 --- a/.sqlx/query-8e816ccef8ecee937d88ff31ca611395405093dbbed738579d715b2d623921be.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body,\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where id = $1\n ", - "describe": { - "columns": [ - { - "name": "channel: channel::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "sender: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "id: Id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "body", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "sent_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "sent_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - }, - { - "name": "deleted_at?: DateTime", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "deleted_sequence?: Sequence", - "ordinal": 7, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - true, - false, - false, - false, - false - ] - }, - "hash": "8e816ccef8ecee937d88ff31ca611395405093dbbed738579d715b2d623921be" -} diff --git a/.sqlx/query-91df35ca0c610b05fb353eeb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659.json b/.sqlx/query-91df35ca0c610b05fb353eeb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659.json new file mode 100644 index 0000000..6d2cb52 --- /dev/null +++ b/.sqlx/query-91df35ca0c610b05fb353eeb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n name as \"name: Name\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(created_sequence <= $1, true)\n order by created_sequence\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name: Name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "91df35ca0c610b05fb353eeb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659" +} diff --git a/.sqlx/query-9da5b746de5d481eeb0755283b4094fa9dfa65dc95991f689355889606ab1c46.json b/.sqlx/query-9da5b746de5d481eeb0755283b4094fa9dfa65dc95991f689355889606ab1c46.json deleted file mode 100644 index 56a8498..0000000 --- a/.sqlx/query-9da5b746de5d481eeb0755283b4094fa9dfa65dc95991f689355889606ab1c46.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into channel (id, name, created_at, created_sequence)\n values ($1, $2, $3, $4)\n returning\n id as \"id: Id\",\n name as \"name!\", -- known non-null as we just set it\n created_at as \"created_at: DateTime\",\n created_sequence as \"created_sequence: Sequence\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name!", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 4 - }, - "nullable": [ - false, - true, - false, - false - ] - }, - "hash": "9da5b746de5d481eeb0755283b4094fa9dfa65dc95991f689355889606ab1c46" -} diff --git a/.sqlx/query-ae9d1c997891f7e917cde554a251e76e93d3a43d1447faa4ec4085f7b3f60404.json b/.sqlx/query-ae9d1c997891f7e917cde554a251e76e93d3a43d1447faa4ec4085f7b3f60404.json deleted file mode 100644 index cb345dc..0000000 --- a/.sqlx/query-ae9d1c997891f7e917cde554a251e76e93d3a43d1447faa4ec4085f7b3f60404.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n token.id as \"token_id: Id\",\n login.id as \"login_id: login::Id\",\n login.name as \"login_name\"\n from login\n join token on login.id = token.login\n where token.secret = $1\n ", - "describe": { - "columns": [ - { - "name": "token_id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "login_id: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "login_name", - "ordinal": 2, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "ae9d1c997891f7e917cde554a251e76e93d3a43d1447faa4ec4085f7b3f60404" -} diff --git a/.sqlx/query-b09438f4b1247a4e3991751de1fa77f2a18537d30e61ccbdcc947d0dba2b3da3.json b/.sqlx/query-b09438f4b1247a4e3991751de1fa77f2a18537d30e61ccbdcc947d0dba2b3da3.json deleted file mode 100644 index 7c83aa1..0000000 --- a/.sqlx/query-b09438f4b1247a4e3991751de1fa77f2a18537d30e61ccbdcc947d0dba2b3da3.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n name,\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(created_sequence <= $1, true)\n order by created_sequence\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "b09438f4b1247a4e3991751de1fa77f2a18537d30e61ccbdcc947d0dba2b3da3" -} diff --git a/.sqlx/query-b991b34b491306780a1b6efa157b6ee50f32e1136ad9cbd91caa0add2ab3cdaa.json b/.sqlx/query-b991b34b491306780a1b6efa157b6ee50f32e1136ad9cbd91caa0add2ab3cdaa.json deleted file mode 100644 index 3901207..0000000 --- a/.sqlx/query-b991b34b491306780a1b6efa157b6ee50f32e1136ad9cbd91caa0add2ab3cdaa.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\t\t\tselect\n\t\t\t\t\tid as \"id: login::Id\",\n\t\t\t\t\tname,\n\t\t\t\t\tpassword_hash as \"password_hash: StoredHash\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n\t\t\t\tfrom login\n\t\t\t\twhere name = $1\n\t\t\t", - "describe": { - "columns": [ - { - "name": "id: login::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "password_hash: StoredHash", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 4, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "b991b34b491306780a1b6efa157b6ee50f32e1136ad9cbd91caa0add2ab3cdaa" -} diff --git a/.sqlx/query-bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json b/.sqlx/query-bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json deleted file mode 100644 index abc1851..0000000 --- a/.sqlx/query-bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body,\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where coalesce(message.sent_sequence <= $2, true)\n order by message.sent_sequence\n ", - "describe": { - "columns": [ - { - "name": "channel: channel::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "sender: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "id: Id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "body", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "sent_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "sent_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - }, - { - "name": "deleted_at: DateTime", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "deleted_sequence: Sequence", - "ordinal": 7, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - true, - false, - false, - true, - true - ] - }, - "hash": "bddc3b0d75f6048c36630db3abb8945a49ce18fb715d249bc9d93fc7d10e817d" -} diff --git a/.sqlx/query-be33d1fb2f71093ed73efd90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e.json b/.sqlx/query-be33d1fb2f71093ed73efd90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e.json new file mode 100644 index 0000000..4b69943 --- /dev/null +++ b/.sqlx/query-be33d1fb2f71093ed73efd90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\t\tselect\n\t\t\t\t\tid as \"id: login::Id\",\n\t\t\t\t\tname as \"name: Name\",\n\t\t\t\t\tpassword_hash as \"password_hash: StoredHash\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n\t\t\t\tfrom login\n\t\t\t\twhere name = $1\n\t\t\t", + "describe": { + "columns": [ + { + "name": "id: login::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name: Name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "password_hash: StoredHash", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "be33d1fb2f71093ed73efd90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e" +} diff --git a/.sqlx/query-d693a55bf9394ea79a892c3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f.json b/.sqlx/query-d693a55bf9394ea79a892c3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f.json new file mode 100644 index 0000000..4291236 --- /dev/null +++ b/.sqlx/query-d693a55bf9394ea79a892c3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n insert\n into login (id, name, password_hash, created_sequence, created_at)\n values ($1, $2, $3, $4, $5)\n returning\n id as \"id: Id\",\n name as \"name: Name\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name: Name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 5 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "d693a55bf9394ea79a892c3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f" +} diff --git a/.sqlx/query-db77e97937167d1edbbe88ebb2c0efd1c5718e721fe906765c28d040e446acd7.json b/.sqlx/query-db77e97937167d1edbbe88ebb2c0efd1c5718e721fe906765c28d040e446acd7.json new file mode 100644 index 0000000..0f2045f --- /dev/null +++ b/.sqlx/query-db77e97937167d1edbbe88ebb2c0efd1c5718e721fe906765c28d040e446acd7.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n insert\n into channel (id, name, created_at, created_sequence)\n values ($1, $2, $3, $4)\n returning\n id as \"id: Id\",\n name as \"name!: Name\", -- known non-null as we just set it\n created_at as \"created_at: DateTime\",\n created_sequence as \"created_sequence: Sequence\"\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name!: Name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 4 + }, + "nullable": [ + false, + true, + false, + false + ] + }, + "hash": "db77e97937167d1edbbe88ebb2c0efd1c5718e721fe906765c28d040e446acd7" +} diff --git a/.sqlx/query-dbbc785bc45173db773f9179ae0758568f732d837c923cfe1b142181fb5d83f3.json b/.sqlx/query-dbbc785bc45173db773f9179ae0758568f732d837c923cfe1b142181fb5d83f3.json new file mode 100644 index 0000000..7b5ac51 --- /dev/null +++ b/.sqlx/query-dbbc785bc45173db773f9179ae0758568f732d837c923cfe1b142181fb5d83f3.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n name as \"name: Name\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(login.created_sequence > $1, true)\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name: Name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 2, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "dbbc785bc45173db773f9179ae0758568f732d837c923cfe1b142181fb5d83f3" +} diff --git a/.sqlx/query-dd613246fc1e87039f0a12ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6.json b/.sqlx/query-dd613246fc1e87039f0a12ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6.json new file mode 100644 index 0000000..fe8be3f --- /dev/null +++ b/.sqlx/query-dd613246fc1e87039f0a12ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n channel.name as \"name: Name\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence > $1, true)\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "name: Name", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + false, + false, + true, + true + ] + }, + "hash": "dd613246fc1e87039f0a12ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6" +} diff --git a/.sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json b/.sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json deleted file mode 100644 index 1883035..0000000 --- a/.sqlx/query-e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n channel.name,\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence > $1, true)\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - true, - true - ] - }, - "hash": "e2686f26f8646b4cd31beeb2060b9a6d6e0bbcb4cf8d01c48b297e6f0a950ebc" -} diff --git a/.sqlx/query-e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1.json b/.sqlx/query-e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1.json deleted file mode 100644 index ba35bb9..0000000 --- a/.sqlx/query-e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1.json +++ /dev/null @@ -1,62 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body,\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where message.channel = $1\n and deleted.id is null\n ", - "describe": { - "columns": [ - { - "name": "channel: channel::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "sender: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "id: Id", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "body", - "ordinal": 3, - "type_info": "Text" - }, - { - "name": "sent_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "sent_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - }, - { - "name": "deleted_at: DateTime", - "ordinal": 6, - "type_info": "Text" - }, - { - "name": "deleted_sequence: Sequence", - "ordinal": 7, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - true, - false, - false, - true, - true - ] - }, - "hash": "e702a0e7ff9a0a9808f8d45294ae908114b03719dc0cb237cec11f807bf757b1" -} diff --git a/.sqlx/query-fce8f4fbd59a8b3b8531e10599914331682d58ace57214bfa26ccaa089592a24.json b/.sqlx/query-fce8f4fbd59a8b3b8531e10599914331682d58ace57214bfa26ccaa089592a24.json new file mode 100644 index 0000000..7aab764 --- /dev/null +++ b/.sqlx/query-fce8f4fbd59a8b3b8531e10599914331682d58ace57214bfa26ccaa089592a24.json @@ -0,0 +1,62 @@ +{ + "db_name": "SQLite", + "query": "\n select\n message.channel as \"channel: channel::Id\",\n message.sender as \"sender: login::Id\",\n id as \"id: Id\",\n message.body as \"body: Body\",\n message.sent_at as \"sent_at: DateTime\",\n message.sent_sequence as \"sent_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from message\n left join message_deleted as deleted\n using (id)\n where coalesce(message.sent_sequence <= $2, true)\n order by message.sent_sequence\n ", + "describe": { + "columns": [ + { + "name": "channel: channel::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "sender: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "id: Id", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "body: Body", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "sent_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "sent_sequence: Sequence", + "ordinal": 5, + "type_info": "Integer" + }, + { + "name": "deleted_at: DateTime", + "ordinal": 6, + "type_info": "Text" + }, + { + "name": "deleted_sequence: Sequence", + "ordinal": 7, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true, + true + ] + }, + "hash": "fce8f4fbd59a8b3b8531e10599914331682d58ace57214bfa26ccaa089592a24" +} diff --git a/Cargo.lock b/Cargo.lock index 2c0556a..c4454e3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -825,6 +825,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "unicode-normalization", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index a131601..fe8fdfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ sqlx = { version = "=0.8.2", features = ["chrono", "runtime-tokio", "sqlite"] } thiserror = "1.0.64" tokio = { version = "1.40.0", features = ["rt", "macros", "rt-multi-thread"] } tokio-stream = { version = "0.1.16", features = ["sync"] } +unicode-normalization = "0.1.24" uuid = { version = "1.11.0", features = ["v4"] } [dev-dependencies] diff --git a/docs/api/channels-messages.md b/docs/api/channels-messages.md index 1ff037d..a441f52 100644 --- a/docs/api/channels-messages.md +++ b/docs/api/channels-messages.md @@ -70,6 +70,8 @@ The response will have the following fields: | `id` | string | A unique identifier for the channel. This can be used to associate the channel with events, or to make API calls targeting the channel. | | `name` | string | The channel's name. | +The returned name may not be identical to the name requested, as the name will be converted to [normalization form C](http://www.unicode.org/reports/tr15/) automatically. The returned name will include this normalization; the service will use the normalized name elsewhere, and does not store the originally requested name. + When completed, the service will emit a [channel created](events.md#channel-created) event with the channel's ID. ### Duplicate channel name @@ -125,6 +127,8 @@ The response will have the following fields: | `id` | string | A unique identifier for the message. This can be used to associate the message with events, or to make API calls targeting the message. | | `body` | string | The message's body. | +The returned message body may not be identical to the body as sent, as the body will be converted to [normalization form C](http://www.unicode.org/reports/tr15/) automatically. The returned body will include this normalization; the service will use the normalized body elsewhere, and does not store the originally submitted body. + When completed, the service will emit a [message sent](events.md#message-sent) event with the message's ID. ### Invalid channel ID diff --git a/docs/api/initial-setup.md b/docs/api/initial-setup.md index 3c5a8a6..306d798 100644 --- a/docs/api/initial-setup.md +++ b/docs/api/initial-setup.md @@ -71,6 +71,10 @@ The response will include the following fields: | `id` | string | A unique identifier for the newly-created login. This can be used to associate the login with other events, or to make API calls targeting the login. | | `name` | string | The login's name. | +The returned name may not be identical to the name requested, as the name will be converted to [normalization form C](http://www.unicode.org/reports/tr15/) automatically. The returned name will include this normalization; the service will use the normalized name elsewhere, and does not store the originally requested name. + +The provided password will also be converted to normalization form C. However, the normalized password is not returned to the client. + The response will include a `Set-Cookie` header for the `identity` cookie, providing the client with a newly-minted identity token associated with the initial login created for this request. See the [authentication](./authentication) section for details on how this cookie may be used. The cookie will expire if it is not used regularly. diff --git a/docs/api/invitations.md b/docs/api/invitations.md index d3431d7..ddbef8a 100644 --- a/docs/api/invitations.md +++ b/docs/api/invitations.md @@ -150,6 +150,10 @@ The response will include the following fields: | `id` | string | A unique identifier for the newly-created login. This can be used to associate the login with other events, or to make API calls targeting the login. | | `name` | string | The login's name. | +The returned name may not be identical to the name requested, as the name will be converted to [normalization form C](http://www.unicode.org/reports/tr15/) automatically. The returned name will include this normalization; the service will use the normalized name elsewhere, and does not store the originally requested name. + +The provided password will also be converted to normalization form C. However, the normalized password is not returned to the client. + The response will include a `Set-Cookie` header for the `identity` cookie, providing the client with a newly-minted identity token associated with the login created for this request. See the [authentication](./authentication.md) section for details on how this cookie may be used. The cookie will expire if it is not used regularly. diff --git a/src/channel/app.rs b/src/channel/app.rs index 75c662d..ea60943 100644 --- a/src/channel/app.rs +++ b/src/channel/app.rs @@ -2,7 +2,7 @@ use chrono::TimeDelta; use itertools::Itertools; use sqlx::sqlite::SqlitePool; -use super::{repo::Provider as _, Channel, History, Id}; +use super::{repo::Provider as _, Channel, History, Id, Name}; use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, @@ -20,14 +20,14 @@ impl<'a> Channels<'a> { Self { db, events } } - pub async fn create(&self, name: &str, created_at: &DateTime) -> Result { + pub async fn create(&self, name: &Name, created_at: &DateTime) -> Result { let mut tx = self.db.begin().await?; let created = tx.sequence().next(created_at).await?; let channel = tx .channels() .create(name, &created) .await - .duplicate(|| CreateError::DuplicateName(name.into()))?; + .duplicate(|| CreateError::DuplicateName(name.clone()))?; tx.commit().await?; self.events @@ -134,7 +134,7 @@ impl<'a> Channels<'a> { #[derive(Debug, thiserror::Error)] pub enum CreateError { #[error("channel named {0} already exists")] - DuplicateName(String), + DuplicateName(Name), #[error(transparent)] Database(#[from] sqlx::Error), } diff --git a/src/channel/mod.rs b/src/channel/mod.rs index eb8200b..fb13e92 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -2,8 +2,11 @@ pub mod app; pub mod event; mod history; mod id; +mod name; pub mod repo; mod routes; mod snapshot; -pub use self::{event::Event, history::History, id::Id, routes::router, snapshot::Channel}; +pub use self::{ + event::Event, history::History, id::Id, name::Name, routes::router, snapshot::Channel, +}; diff --git a/src/channel/name.rs b/src/channel/name.rs new file mode 100644 index 0000000..fc82dec --- /dev/null +++ b/src/channel/name.rs @@ -0,0 +1,30 @@ +use std::fmt; + +use crate::nfc; + +#[derive( + Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize, sqlx::Type, +)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Name(nfc::String); + +impl fmt::Display for Name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self(name) = self; + name.fmt(f) + } +} + +impl From for Name { + fn from(name: String) -> Self { + Self(name.into()) + } +} + +impl From for String { + fn from(name: Name) -> Self { + let Name(name) = name; + name.into() + } +} diff --git a/src/channel/repo.rs b/src/channel/repo.rs index 27d35f0..3353bfd 100644 --- a/src/channel/repo.rs +++ b/src/channel/repo.rs @@ -1,7 +1,7 @@ use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use crate::{ - channel::{Channel, History, Id}, + channel::{Channel, History, Id, Name}, clock::DateTime, event::{Instant, ResumePoint, Sequence}, }; @@ -19,7 +19,7 @@ impl<'c> Provider for Transaction<'c, Sqlite> { pub struct Channels<'t>(&'t mut SqliteConnection); impl<'c> Channels<'c> { - pub async fn create(&mut self, name: &str, created: &Instant) -> Result { + pub async fn create(&mut self, name: &Name, created: &Instant) -> Result { let id = Id::generate(); let channel = sqlx::query!( r#" @@ -28,7 +28,7 @@ impl<'c> Channels<'c> { values ($1, $2, $3, $4) returning id as "id: Id", - name as "name!", -- known non-null as we just set it + name as "name!: Name", -- known non-null as we just set it created_at as "created_at: DateTime", created_sequence as "created_sequence: Sequence" "#, @@ -57,7 +57,7 @@ impl<'c> Channels<'c> { r#" select id as "id: Id", - channel.name, + channel.name as "name: Name", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", deleted.deleted_at as "deleted_at?: DateTime", @@ -89,7 +89,7 @@ impl<'c> Channels<'c> { r#" select id as "id: Id", - channel.name, + channel.name as "name: Name", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", deleted.deleted_at as "deleted_at: DateTime", @@ -125,7 +125,7 @@ impl<'c> Channels<'c> { r#" select id as "id: Id", - channel.name, + channel.name as "name: Name", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", deleted.deleted_at as "deleted_at: DateTime", @@ -235,7 +235,7 @@ impl<'c> Channels<'c> { r#" select channel.id as "id: Id", - channel.name, + channel.name as "name: Name", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", deleted.deleted_at as "deleted_at?: DateTime", diff --git a/src/channel/routes/channel/post.rs b/src/channel/routes/channel/post.rs index b489a77..d0cae05 100644 --- a/src/channel/routes/channel/post.rs +++ b/src/channel/routes/channel/post.rs @@ -9,7 +9,7 @@ use crate::{ clock::RequestedAt, error::{Internal, NotFound}, login::Login, - message::{app::SendError, Message}, + message::{app::SendError, Body, Message}, }; pub async fn handler( @@ -29,7 +29,7 @@ pub async fn handler( #[derive(serde::Deserialize)] pub struct Request { - pub body: String, + pub body: Body, } #[derive(Debug)] diff --git a/src/channel/routes/post.rs b/src/channel/routes/post.rs index a05c312..d354f79 100644 --- a/src/channel/routes/post.rs +++ b/src/channel/routes/post.rs @@ -6,7 +6,7 @@ use axum::{ use crate::{ app::App, - channel::{app, Channel}, + channel::{app, Channel, Name}, clock::RequestedAt, error::Internal, login::Login, @@ -29,7 +29,7 @@ pub async fn handler( #[derive(serde::Deserialize)] pub struct Request { - pub name: String, + pub name: Name, } #[derive(Debug)] diff --git a/src/channel/snapshot.rs b/src/channel/snapshot.rs index 2b7d89a..dc2894d 100644 --- a/src/channel/snapshot.rs +++ b/src/channel/snapshot.rs @@ -1,13 +1,13 @@ use super::{ event::{Created, Event}, - Id, + Id, Name, }; use crate::clock::DateTime; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Channel { pub id: Id, - pub name: String, + pub name: Name, #[serde(skip_serializing_if = "Option::is_none")] pub deleted_at: Option, } diff --git a/src/invite/app.rs b/src/invite/app.rs index ee7f74f..285a819 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -6,7 +6,7 @@ use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, event::repo::Provider as _, - login::{repo::Provider as _, Login, Password}, + login::{repo::Provider as _, Login, Name, Password}, token::{repo::Provider as _, Secret}, }; @@ -42,7 +42,7 @@ impl<'a> Invites<'a> { pub async fn accept( &self, invite: &Id, - name: &str, + name: &Name, password: &Password, accepted_at: &DateTime, ) -> Result<(Login, Secret), AcceptError> { @@ -68,7 +68,7 @@ impl<'a> Invites<'a> { .logins() .create(name, &password_hash, &created) .await - .duplicate(|| AcceptError::DuplicateLogin(name.into()))?; + .duplicate(|| AcceptError::DuplicateLogin(name.clone()))?; let secret = tx.tokens().issue(&login, accepted_at).await?; tx.commit().await?; @@ -92,7 +92,7 @@ pub enum AcceptError { #[error("invite not found: {0}")] NotFound(Id), #[error("name in use: {0}")] - DuplicateLogin(String), + DuplicateLogin(Name), #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs index c072929..8160465 100644 --- a/src/invite/routes/invite/post.rs +++ b/src/invite/routes/invite/post.rs @@ -9,7 +9,7 @@ use crate::{ clock::RequestedAt, error::{Internal, NotFound}, invite::app, - login::{Login, Password}, + login::{Login, Name, Password}, token::extract::IdentityToken, }; @@ -31,7 +31,7 @@ pub async fn handler( #[derive(serde::Deserialize)] pub struct Request { - pub name: String, + pub name: Name, pub password: Password, } diff --git a/src/lib.rs b/src/lib.rs index 73a2cb0..4d0d9b9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,6 +16,7 @@ mod id; mod invite; mod login; mod message; +mod nfc; mod setup; #[cfg(test)] mod test; diff --git a/src/login/app.rs b/src/login/app.rs index b6f7e1c..ebc1c00 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -1,6 +1,6 @@ use sqlx::sqlite::SqlitePool; -use super::{repo::Provider as _, Login, Password}; +use super::{repo::Provider as _, Login, Name, Password}; use crate::{ clock::DateTime, event::{repo::Provider as _, Broadcaster, Event}, @@ -18,7 +18,7 @@ impl<'a> Logins<'a> { pub async fn create( &self, - name: &str, + name: &Name, password: &Password, created_at: &DateTime, ) -> Result { diff --git a/src/login/mod.rs b/src/login/mod.rs index 98cc3d7..71d5bfc 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -4,11 +4,13 @@ pub mod event; pub mod extract; mod history; mod id; +mod name; pub mod password; pub mod repo; mod routes; mod snapshot; pub use self::{ - event::Event, history::History, id::Id, password::Password, routes::router, snapshot::Login, + event::Event, history::History, id::Id, name::Name, password::Password, routes::router, + snapshot::Login, }; diff --git a/src/login/name.rs b/src/login/name.rs new file mode 100644 index 0000000..d882ff9 --- /dev/null +++ b/src/login/name.rs @@ -0,0 +1,28 @@ +use std::fmt; + +use crate::nfc; + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, sqlx::Type)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Name(nfc::String); + +impl fmt::Display for Name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self(name) = self; + name.fmt(f) + } +} + +impl From for Name { + fn from(name: String) -> Self { + Self(name.into()) + } +} + +impl From for String { + fn from(name: Name) -> Self { + let Name(name) = name; + name.into() + } +} diff --git a/src/login/password.rs b/src/login/password.rs index 14fd981..f9ecf37 100644 --- a/src/login/password.rs +++ b/src/login/password.rs @@ -4,6 +4,8 @@ use argon2::Argon2; use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use rand_core::OsRng; +use crate::nfc; + #[derive(sqlx::Type)] #[sqlx(transparent)] pub struct StoredHash(String); @@ -31,7 +33,7 @@ impl fmt::Debug for StoredHash { #[derive(serde::Deserialize)] #[serde(transparent)] -pub struct Password(String); +pub struct Password(nfc::String); impl Password { pub fn hash(&self) -> Result { @@ -56,9 +58,8 @@ impl fmt::Debug for Password { } } -#[cfg(test)] impl From for Password { fn from(password: String) -> Self { - Self(password) + Password(password.into()) } } diff --git a/src/login/repo.rs b/src/login/repo.rs index 7d0fcb1..204329f 100644 --- a/src/login/repo.rs +++ b/src/login/repo.rs @@ -3,7 +3,7 @@ use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use crate::{ clock::DateTime, event::{Instant, ResumePoint, Sequence}, - login::{password::StoredHash, History, Id, Login}, + login::{password::StoredHash, History, Id, Login, Name}, }; pub trait Provider { @@ -21,7 +21,7 @@ pub struct Logins<'t>(&'t mut SqliteConnection); impl<'c> Logins<'c> { pub async fn create( &mut self, - name: &str, + name: &Name, password_hash: &StoredHash, created: &Instant, ) -> Result { @@ -34,7 +34,7 @@ impl<'c> Logins<'c> { values ($1, $2, $3, $4, $5) returning id as "id: Id", - name, + name as "name: Name", created_sequence as "created_sequence: Sequence", created_at as "created_at: DateTime" "#, @@ -62,7 +62,7 @@ impl<'c> Logins<'c> { r#" select id as "id: Id", - name, + name as "name: Name", created_sequence as "created_sequence: Sequence", created_at as "created_at: DateTime" from login @@ -88,7 +88,7 @@ impl<'c> Logins<'c> { r#" select id as "id: Id", - name, + name as "name: Name", created_sequence as "created_sequence: Sequence", created_at as "created_at: DateTime" from login diff --git a/src/login/routes/login/post.rs b/src/login/routes/login/post.rs index 67eaa6d..7a685e2 100644 --- a/src/login/routes/login/post.rs +++ b/src/login/routes/login/post.rs @@ -8,7 +8,7 @@ use crate::{ app::App, clock::RequestedAt, error::Internal, - login::{Login, Password}, + login::{Login, Name, Password}, token::{app, extract::IdentityToken}, }; @@ -29,7 +29,7 @@ pub async fn handler( #[derive(serde::Deserialize)] pub struct Request { - pub name: String, + pub name: Name, pub password: Password, } diff --git a/src/login/snapshot.rs b/src/login/snapshot.rs index 1a92f5c..85800e4 100644 --- a/src/login/snapshot.rs +++ b/src/login/snapshot.rs @@ -1,6 +1,6 @@ use super::{ event::{Created, Event}, - Id, + Id, Name, }; // This also implements FromRequestParts (see `./extract.rs`). As a result, it @@ -10,7 +10,7 @@ use super::{ #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Login { pub id: Id, - pub name: String, + pub name: Name, // The omission of the hashed password is deliberate, to minimize the // chance that it ends up tangled up in debug output or in some other chunk // of logic elsewhere. diff --git a/src/message/app.rs b/src/message/app.rs index 4e50513..af87553 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -2,7 +2,7 @@ use chrono::TimeDelta; use itertools::Itertools; use sqlx::sqlite::SqlitePool; -use super::{repo::Provider as _, Id, Message}; +use super::{repo::Provider as _, Body, Id, Message}; use crate::{ channel::{self, repo::Provider as _}, clock::DateTime, @@ -26,7 +26,7 @@ impl<'a> Messages<'a> { channel: &channel::Id, sender: &Login, sent_at: &DateTime, - body: &str, + body: &Body, ) -> Result { let mut tx = self.db.begin().await?; let channel = tx diff --git a/src/message/body.rs b/src/message/body.rs new file mode 100644 index 0000000..a415f85 --- /dev/null +++ b/src/message/body.rs @@ -0,0 +1,30 @@ +use std::fmt; + +use crate::nfc; + +#[derive( + Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize, sqlx::Type, +)] +#[serde(transparent)] +#[sqlx(transparent)] +pub struct Body(nfc::String); + +impl fmt::Display for Body { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self(body) = self; + body.fmt(f) + } +} + +impl From for Body { + fn from(body: String) -> Self { + Self(body.into()) + } +} + +impl From for String { + fn from(body: Body) -> Self { + let Body(body) = body; + body.into() + } +} diff --git a/src/message/mod.rs b/src/message/mod.rs index a8f51ab..c2687bc 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -1,4 +1,5 @@ pub mod app; +mod body; pub mod event; mod history; mod id; @@ -6,4 +7,6 @@ pub mod repo; mod routes; mod snapshot; -pub use self::{event::Event, history::History, id::Id, routes::router, snapshot::Message}; +pub use self::{ + body::Body, event::Event, history::History, id::Id, routes::router, snapshot::Message, +}; diff --git a/src/message/repo.rs b/src/message/repo.rs index 85a69fc..4cfefec 100644 --- a/src/message/repo.rs +++ b/src/message/repo.rs @@ -1,6 +1,6 @@ use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; -use super::{snapshot::Message, History, Id}; +use super::{snapshot::Message, Body, History, Id}; use crate::{ channel, clock::DateTime, @@ -26,24 +26,24 @@ impl<'c> Messages<'c> { channel: &channel::History, sender: &Login, sent: &Instant, - body: &str, + body: &Body, ) -> Result { let id = Id::generate(); let channel_id = channel.id(); let message = sqlx::query!( r#" - insert into message - (id, channel, sender, sent_at, sent_sequence, body) - values ($1, $2, $3, $4, $5, $6) - returning - id as "id: Id", + insert into message + (id, channel, sender, sent_at, sent_sequence, body) + values ($1, $2, $3, $4, $5, $6) + returning + id as "id: Id", channel as "channel: channel::Id", sender as "sender: login::Id", sent_at as "sent_at: DateTime", sent_sequence as "sent_sequence: Sequence", - body - "#, + body as "body: Body" + "#, id, channel_id, sender.id, @@ -76,7 +76,7 @@ impl<'c> Messages<'c> { message.channel as "channel: channel::Id", message.sender as "sender: login::Id", id as "id: Id", - message.body, + message.body as "body: Body", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", deleted.deleted_at as "deleted_at: DateTime", @@ -113,7 +113,7 @@ impl<'c> Messages<'c> { message.channel as "channel: channel::Id", message.sender as "sender: login::Id", id as "id: Id", - message.body, + message.body as "body: Body", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", deleted.deleted_at as "deleted_at: DateTime", @@ -150,7 +150,7 @@ impl<'c> Messages<'c> { message.channel as "channel: channel::Id", message.sender as "sender: login::Id", id as "id: Id", - message.body, + message.body as "body: Body", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", deleted.deleted_at as "deleted_at?: DateTime", @@ -256,7 +256,7 @@ impl<'c> Messages<'c> { message.sender as "sender: login::Id", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", - message.body, + message.body as "body: Body", deleted.deleted_at as "deleted_at?: DateTime", deleted.deleted_sequence as "deleted_sequence?: Sequence" from message @@ -293,7 +293,7 @@ impl<'c> Messages<'c> { message.sender as "sender: login::Id", message.sent_at as "sent_at: DateTime", message.sent_sequence as "sent_sequence: Sequence", - message.body, + message.body as "body: Body", deleted.deleted_at as "deleted_at: DateTime", deleted.deleted_sequence as "deleted_sequence: Sequence" from message diff --git a/src/message/snapshot.rs b/src/message/snapshot.rs index 7300918..53b7176 100644 --- a/src/message/snapshot.rs +++ b/src/message/snapshot.rs @@ -1,6 +1,6 @@ use super::{ event::{Event, Sent}, - Id, + Body, Id, }; use crate::{channel, clock::DateTime, event::Instant, login}; @@ -11,7 +11,7 @@ pub struct Message { pub channel: channel::Id, pub sender: login::Id, pub id: Id, - pub body: String, + pub body: Body, #[serde(skip_serializing_if = "Option::is_none")] pub deleted_at: Option, } diff --git a/src/nfc.rs b/src/nfc.rs new file mode 100644 index 0000000..70e936c --- /dev/null +++ b/src/nfc.rs @@ -0,0 +1,103 @@ +use std::{fmt, string::String as StdString}; + +use sqlx::{ + encode::{Encode, IsNull}, + Database, Decode, Type, +}; +use unicode_normalization::UnicodeNormalization as _; + +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] +#[serde(from = "StdString", into = "StdString")] +pub struct String(StdString); + +impl fmt::Display for String { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self(value) = self; + value.fmt(f) + } +} + +impl From for String { + fn from(value: StdString) -> Self { + let value = value.nfc().collect(); + + Self(value) + } +} + +impl From for StdString { + fn from(value: String) -> Self { + let String(value) = value; + value + } +} + +impl std::ops::Deref for String { + type Target = StdString; + + fn deref(&self) -> &Self::Target { + let Self(value) = self; + value + } +} + +// Type is manually implemented so that we can implement Decode to do +// normalization on read. Implementation is otherwise based on +// `#[derive(sqlx::Type)]` with the `#[sqlx(transparent)]` attribute. +impl Type for String +where + DB: Database, + StdString: Type, +{ + fn type_info() -> ::TypeInfo { + >::type_info() + } + + fn compatible(ty: &::TypeInfo) -> bool { + >::compatible(ty) + } +} + +impl<'r, DB> Decode<'r, DB> for String +where + DB: Database, + StdString: Decode<'r, DB>, +{ + fn decode(value: ::ValueRef<'r>) -> Result { + let value = StdString::decode(value)?; + let value = value.nfc().collect(); + Ok(Self(value)) + } +} + +impl<'q, DB> Encode<'q, DB> for String +where + DB: Database, + StdString: Encode<'q, DB>, +{ + fn encode_by_ref( + &self, + buf: &mut ::ArgumentBuffer<'q>, + ) -> Result { + let Self(value) = self; + value.encode_by_ref(buf) + } + + fn encode( + self, + buf: &mut ::ArgumentBuffer<'q>, + ) -> Result { + let Self(value) = self; + value.encode(buf) + } + + fn produces(&self) -> Option<::TypeInfo> { + let Self(value) = self; + value.produces() + } + + fn size_hint(&self) -> usize { + let Self(value) = self; + value.size_hint() + } +} diff --git a/src/setup/app.rs b/src/setup/app.rs index d015813..9fbcf6d 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -4,7 +4,7 @@ use super::repo::Provider as _; use crate::{ clock::DateTime, event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, Login, Password}, + login::{repo::Provider as _, Login, Name, Password}, token::{repo::Provider as _, Secret}, }; @@ -20,7 +20,7 @@ impl<'a> Setup<'a> { pub async fn initial( &self, - name: &str, + name: &Name, password: &Password, created_at: &DateTime, ) -> Result<(Login, Secret), Error> { diff --git a/src/setup/routes/post.rs b/src/setup/routes/post.rs index 34f4ed2..6a3fa11 100644 --- a/src/setup/routes/post.rs +++ b/src/setup/routes/post.rs @@ -8,7 +8,7 @@ use crate::{ app::App, clock::RequestedAt, error::Internal, - login::{Login, Password}, + login::{Login, Name, Password}, setup::app, token::extract::IdentityToken, }; @@ -30,7 +30,7 @@ pub async fn handler( #[derive(serde::Deserialize)] pub struct Request { - pub name: String, + pub name: Name, pub password: Password, } diff --git a/src/test/fixtures/channel.rs b/src/test/fixtures/channel.rs index a1dda61..024ac1b 100644 --- a/src/test/fixtures/channel.rs +++ b/src/test/fixtures/channel.rs @@ -8,7 +8,7 @@ use rand; use crate::{ app::App, - channel::{self, Channel}, + channel::{self, Channel, Name}, clock::RequestedAt, event::Event, }; @@ -21,13 +21,13 @@ pub async fn create(app: &App, created_at: &RequestedAt) -> Channel { .expect("should always succeed if the channel is actually new") } -pub fn propose() -> String { - rand::random::().to_string() +pub fn propose() -> Name { + rand::random::().to_string().into() } -struct Name(String); +struct NameTemplate(String); faker_impl_from_templates! { - Name; "{} {}", CityName, FullName; + NameTemplate; "{} {}", CityName, FullName; } pub fn events(event: Event) -> future::Ready> { diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs index b6766fe..0a42320 100644 --- a/src/test/fixtures/login.rs +++ b/src/test/fixtures/login.rs @@ -4,7 +4,7 @@ use uuid::Uuid; use crate::{ app::App, clock::RequestedAt, - login::{self, Login, Password}, + login::{self, Login, Name, Password}, }; pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Login, Password) { @@ -29,16 +29,16 @@ pub async fn create(app: &App, created_at: &RequestedAt) -> Login { pub fn fictitious() -> Login { Login { id: login::Id::generate(), - name: name(), + name: propose_name(), } } -pub fn propose() -> (String, Password) { - (name(), propose_password()) +pub fn propose() -> (Name, Password) { + (propose_name(), propose_password()) } -fn name() -> String { - rand::random::().to_string() +fn propose_name() -> Name { + rand::random::().to_string().into() } pub fn propose_password() -> Password { diff --git a/src/test/fixtures/message.rs b/src/test/fixtures/message.rs index eb00e7c..c450bce 100644 --- a/src/test/fixtures/message.rs +++ b/src/test/fixtures/message.rs @@ -8,7 +8,7 @@ use crate::{ clock::RequestedAt, event::Event, login::Login, - message::{self, Message}, + message::{self, Body, Message}, }; pub async fn send(app: &App, channel: &Channel, login: &Login, sent_at: &RequestedAt) -> Message { @@ -20,8 +20,8 @@ pub async fn send(app: &App, channel: &Channel, login: &Login, sent_at: &Request .expect("should succeed if the channel exists") } -pub fn propose() -> String { - rand::random::().to_string() +pub fn propose() -> Body { + rand::random::().to_string().into() } pub fn events(event: Event) -> future::Ready> { diff --git a/src/token/app.rs b/src/token/app.rs index 0dc1a46..d4dd1a0 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -12,7 +12,7 @@ use super::{ use crate::{ clock::DateTime, db::NotFound as _, - login::{Login, Password}, + login::{Login, Name, Password}, }; pub struct Tokens<'a> { @@ -27,7 +27,7 @@ impl<'a> Tokens<'a> { pub async fn login( &self, - name: &str, + name: &Name, password: &Password, login_at: &DateTime, ) -> Result<(Login, Secret), LoginError> { diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs index 88d0878..c621b65 100644 --- a/src/token/repo/auth.rs +++ b/src/token/repo/auth.rs @@ -3,7 +3,7 @@ use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use crate::{ clock::DateTime, event::{Instant, Sequence}, - login::{self, password::StoredHash, History, Login}, + login::{self, password::StoredHash, History, Login, Name}, }; pub trait Provider { @@ -19,12 +19,12 @@ impl<'c> Provider for Transaction<'c, Sqlite> { pub struct Auth<'t>(&'t mut SqliteConnection); impl<'t> Auth<'t> { - pub async fn for_name(&mut self, name: &str) -> Result<(History, StoredHash), sqlx::Error> { + pub async fn for_name(&mut self, name: &Name) -> Result<(History, StoredHash), sqlx::Error> { let found = sqlx::query!( r#" select id as "id: login::Id", - name, + name as "name: Name", password_hash as "password_hash: StoredHash", created_sequence as "created_sequence: Sequence", created_at as "created_at: DateTime" diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs index c592dcd..960bb72 100644 --- a/src/token/repo/token.rs +++ b/src/token/repo/token.rs @@ -3,7 +3,7 @@ use uuid::Uuid; use crate::{ clock::DateTime, - login::{self, History, Login}, + login::{self, History, Login, Name}, token::{Id, Secret}, }; @@ -128,7 +128,7 @@ impl<'c> Tokens<'c> { select token.id as "token_id: Id", login.id as "login_id: login::Id", - login.name as "login_name" + login.name as "login_name: Name" from login join token on login.id = token.login where token.secret = $1 -- cgit v1.2.3 From 3f9648eed48cd8b6cd35d0ae2ee5bbe25fa735ac Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Mon, 21 Oct 2024 00:36:44 -0400 Subject: Canonicalize login and channel names. Canonicalization does two things: * It prevents duplicate names that differ only by case or only by normalization/encoding sequence; and * It makes certain name-based comparisons "case-insensitive" (generalizing via Unicode's case-folding rules). This change is complicated, as it means that every name now needs to be stored in two forms. Unfortunately, this is _very likely_ a breaking schema change. The migrations in this commit perform a best-effort attempt to canonicalize existing channel or login names, but it's likely any existing channels or logins with non-ASCII characters will not be canonicalize correctly. Since clients look at all channel names and all login names on boot, and since the code in this commit verifies canonicalization when reading from the database, this will effectively make the server un-usuable until any incorrectly-canonicalized values are either manually canonicalized, or removed It might be possible to do better with [the `icu` sqlite3 extension][icu], but (a) I'm not convinced of that and (b) this commit is already huge; adding database extension support would make it far larger. [icu]: https://sqlite.org/src/dir/ext/icu For some references on why it's worth storing usernames this way, see and the refernced talk, as well as . Bennett's treatment of this issue is, to my eye, much more readable than the referenced Unicode technical reports, and I'm inclined to trust his opinion given that he maintains a widely-used, internet-facing user registration library for Django. --- ...a9eb3578281dc70941faf05d53f8525034446ae52f.json | 50 ----- ...8ec70ecce6408cb241c9ac5f76bb484b214e720cb9.json | 56 ++++++ ...f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json | 12 ++ ...b0d1f81953cd5f584b76ca59ece17d9ef2ce29ee9c.json | 44 +++++ ...61b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e.json | 32 ---- ...cf421524e41055cddf76dd4e4016142f61e0a4903f.json | 12 ++ ...2e34ec512dfc9109b338eea475c209e5a005ae20c6.json | 50 ----- ...b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json | 12 ++ ...7ef7a26f6b14e1d30c3fb44ece8fb149c92317fa91.json | 26 +++ ...65327d32a68f41258a27249027f9200b2bbba047cb.json | 50 ----- ...72b013b1ac76a28471e63d0492132b9c12c63a1f9c.json | 12 -- ...0717bf42e5d43b041acb198710e75417ac40991ec6.json | 50 +++++ ...eb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659.json | 38 ---- ...f05ffc52cb1712545ff539df49bbd47df194012f79.json | 56 ++++++ ...c2e965e2294c1a6932d72a8b04d258d06d0f8938bd.json | 56 ++++++ ...4730699683e63ae1ea404418a17c6af60148f03fe8.json | 44 +++++ ...314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json | 20 -- ...90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e.json | 44 ----- ...d188879a394d36e66f90edb27d2665f081ff95087f.json | 44 +++++ ...bb69067dd15b2e7602aafcfaf04aa1f41e118a1f87.json | 20 -- ...ae5ca13b72436960622863420d3f1d73a422fe5b42.json | 12 ++ ...3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f.json | 38 ---- ...c19a4eed48252247f15a046098d63359c6d9047c9a.json | 38 ---- ...ebb2c0efd1c5718e721fe906765c28d040e446acd7.json | 38 ---- ...79ae0758568f732d837c923cfe1b142181fb5d83f3.json | 38 ---- ...ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6.json | 50 ----- ...5518d6364c8dbfdb45f205fecf0734fe272be493b0.json | 12 ++ ...6c38657da87439340801d0765f8d9f64f8fbbbfb6b.json | 38 ++++ ...5010f2f77b943ec225244fcfc24013b12a48084a1e.json | 56 ++++++ Cargo.lock | 7 + Cargo.toml | 1 + docs/api/authentication.md | 19 +- docs/api/channels-messages.md | 12 ++ migrations/20241019191531_canonical_names.sql | 189 ++++++++++++++++++ src/boot/app.rs | 34 +++- src/channel/app.rs | 49 ++++- src/channel/mod.rs | 5 +- src/channel/name.rs | 30 --- src/channel/repo.rs | 212 +++++++++++++-------- src/channel/routes/post.rs | 3 +- src/channel/snapshot.rs | 4 +- src/db/mod.rs | 15 +- src/event/app.rs | 30 ++- src/event/routes/get.rs | 4 +- src/invite/app.rs | 3 +- src/invite/mod.rs | 4 +- src/invite/repo.rs | 3 +- src/invite/routes/invite/post.rs | 3 +- src/lib.rs | 3 +- src/login/app.rs | 3 +- src/login/mod.rs | 4 +- src/login/name.rs | 28 --- src/login/password.rs | 2 +- src/login/repo.rs | 98 +++++----- src/login/routes/login/post.rs | 3 +- src/login/routes/logout/test.rs | 1 - src/login/snapshot.rs | 3 +- src/message/app.rs | 13 ++ src/message/body.rs | 2 +- src/name.rs | 85 +++++++++ src/nfc.rs | 103 ---------- src/normalize/mod.rs | 36 ++++ src/normalize/string.rs | 112 +++++++++++ src/setup/app.rs | 3 +- src/setup/routes/post.rs | 3 +- src/test/fixtures/channel.rs | 3 +- src/test/fixtures/login.rs | 3 +- src/token/app.rs | 37 +++- src/token/repo/auth.rs | 68 ++++--- src/token/repo/mod.rs | 2 +- src/token/repo/token.rs | 68 +++++-- 71 files changed, 1463 insertions(+), 895 deletions(-) delete mode 100644 .sqlx/query-0caf898537dd38eea00322a9eb3578281dc70941faf05d53f8525034446ae52f.json create mode 100644 .sqlx/query-10a8d7ebb8a228c297f38e8ec70ecce6408cb241c9ac5f76bb484b214e720cb9.json create mode 100644 .sqlx/query-2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json create mode 100644 .sqlx/query-2f4def5e6aa14d9bc4ca5db0d1f81953cd5f584b76ca59ece17d9ef2ce29ee9c.json delete mode 100644 .sqlx/query-4392eb4ec7257676acdce461b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e.json create mode 100644 .sqlx/query-454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f.json delete mode 100644 .sqlx/query-48884b1c153cbf6e10eed72e34ec512dfc9109b338eea475c209e5a005ae20c6.json create mode 100644 .sqlx/query-4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json create mode 100644 .sqlx/query-6d34d8232e7247155c697f7ef7a26f6b14e1d30c3fb44ece8fb149c92317fa91.json delete mode 100644 .sqlx/query-7f5bbd935941210ba2f25e65327d32a68f41258a27249027f9200b2bbba047cb.json delete mode 100644 .sqlx/query-873b8b58360d717ea2099272b013b1ac76a28471e63d0492132b9c12c63a1f9c.json create mode 100644 .sqlx/query-903e7ec4fafd5ce124a0e40717bf42e5d43b041acb198710e75417ac40991ec6.json delete mode 100644 .sqlx/query-91df35ca0c610b05fb353eeb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659.json create mode 100644 .sqlx/query-947df87da285e68a710443f05ffc52cb1712545ff539df49bbd47df194012f79.json create mode 100644 .sqlx/query-a40496319887752f5845c0c2e965e2294c1a6932d72a8b04d258d06d0f8938bd.json create mode 100644 .sqlx/query-ac7ab464e44e4412cd83744730699683e63ae1ea404418a17c6af60148f03fe8.json delete mode 100644 .sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json delete mode 100644 .sqlx/query-be33d1fb2f71093ed73efd90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e.json create mode 100644 .sqlx/query-c31c02aa8c4e615c463835d188879a394d36e66f90edb27d2665f081ff95087f.json delete mode 100644 .sqlx/query-c83af5b462071927d05664bb69067dd15b2e7602aafcfaf04aa1f41e118a1f87.json create mode 100644 .sqlx/query-d1c869c323d1ab45216279ae5ca13b72436960622863420d3f1d73a422fe5b42.json delete mode 100644 .sqlx/query-d693a55bf9394ea79a892c3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f.json delete mode 100644 .sqlx/query-d9c772a28d1b5b2ca3b0a2c19a4eed48252247f15a046098d63359c6d9047c9a.json delete mode 100644 .sqlx/query-db77e97937167d1edbbe88ebb2c0efd1c5718e721fe906765c28d040e446acd7.json delete mode 100644 .sqlx/query-dbbc785bc45173db773f9179ae0758568f732d837c923cfe1b142181fb5d83f3.json delete mode 100644 .sqlx/query-dd613246fc1e87039f0a12ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6.json create mode 100644 .sqlx/query-e01508e57cc9cecc83640a5518d6364c8dbfdb45f205fecf0734fe272be493b0.json create mode 100644 .sqlx/query-e06cbe45408d593a081b566c38657da87439340801d0765f8d9f64f8fbbbfb6b.json create mode 100644 .sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json create mode 100644 migrations/20241019191531_canonical_names.sql delete mode 100644 src/channel/name.rs delete mode 100644 src/login/name.rs create mode 100644 src/name.rs delete mode 100644 src/nfc.rs create mode 100644 src/normalize/mod.rs create mode 100644 src/normalize/string.rs (limited to 'src/channel/routes') diff --git a/.sqlx/query-0caf898537dd38eea00322a9eb3578281dc70941faf05d53f8525034446ae52f.json b/.sqlx/query-0caf898537dd38eea00322a9eb3578281dc70941faf05d53f8525034446ae52f.json deleted file mode 100644 index 8990436..0000000 --- a/.sqlx/query-0caf898537dd38eea00322a9eb3578281dc70941faf05d53f8525034446ae52f.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n channel.name as \"name: Name\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence <= $1, true)\n order by channel.name\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name: Name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - true, - true - ] - }, - "hash": "0caf898537dd38eea00322a9eb3578281dc70941faf05d53f8525034446ae52f" -} diff --git a/.sqlx/query-10a8d7ebb8a228c297f38e8ec70ecce6408cb241c9ac5f76bb484b214e720cb9.json b/.sqlx/query-10a8d7ebb8a228c297f38e8ec70ecce6408cb241c9ac5f76bb484b214e720cb9.json new file mode 100644 index 0000000..f8dd228 --- /dev/null +++ b/.sqlx/query-10a8d7ebb8a228c297f38e8ec70ecce6408cb241c9ac5f76bb484b214e720cb9.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n select\n channel.id as \"id: Id\",\n name.display_name as \"display_name: String\",\n name.canonical_name as \"canonical_name: String\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_name as name\n using (id)\n left join channel_deleted as deleted\n using (id)\n left join message\n on channel.id = message.channel\n where channel.created_at < $1\n and message.id is null\n and deleted.id is null\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Null" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Null" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "10a8d7ebb8a228c297f38e8ec70ecce6408cb241c9ac5f76bb484b214e720cb9" +} diff --git a/.sqlx/query-2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json b/.sqlx/query-2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json new file mode 100644 index 0000000..2de9614 --- /dev/null +++ b/.sqlx/query-2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into channel_deleted (id, deleted_at, deleted_sequence)\n values ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "2773f12ef58c251596dd00f0bd048975fb06f090a11622cc74ced47a3d7ad44a" +} diff --git a/.sqlx/query-2f4def5e6aa14d9bc4ca5db0d1f81953cd5f584b76ca59ece17d9ef2ce29ee9c.json b/.sqlx/query-2f4def5e6aa14d9bc4ca5db0d1f81953cd5f584b76ca59ece17d9ef2ce29ee9c.json new file mode 100644 index 0000000..c1da170 --- /dev/null +++ b/.sqlx/query-2f4def5e6aa14d9bc4ca5db0d1f81953cd5f584b76ca59ece17d9ef2ce29ee9c.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(created_sequence <= $1, true)\n order by created_sequence\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "2f4def5e6aa14d9bc4ca5db0d1f81953cd5f584b76ca59ece17d9ef2ce29ee9c" +} diff --git a/.sqlx/query-4392eb4ec7257676acdce461b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e.json b/.sqlx/query-4392eb4ec7257676acdce461b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e.json deleted file mode 100644 index 1dd685b..0000000 --- a/.sqlx/query-4392eb4ec7257676acdce461b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n token.id as \"token_id: Id\",\n login.id as \"login_id: login::Id\",\n login.name as \"login_name: Name\"\n from login\n join token on login.id = token.login\n where token.secret = $1\n ", - "describe": { - "columns": [ - { - "name": "token_id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "login_id: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "login_name: Name", - "ordinal": 2, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false - ] - }, - "hash": "4392eb4ec7257676acdce461b3bd7892ca01c2e3a2e0b1abfd8d7a57cbbf265e" -} diff --git a/.sqlx/query-454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f.json b/.sqlx/query-454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f.json new file mode 100644 index 0000000..ebddeb8 --- /dev/null +++ b/.sqlx/query-454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n delete from channel_name\n where id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 1 + }, + "nullable": [] + }, + "hash": "454ca70caa83eee2dac585cf421524e41055cddf76dd4e4016142f61e0a4903f" +} diff --git a/.sqlx/query-48884b1c153cbf6e10eed72e34ec512dfc9109b338eea475c209e5a005ae20c6.json b/.sqlx/query-48884b1c153cbf6e10eed72e34ec512dfc9109b338eea475c209e5a005ae20c6.json deleted file mode 100644 index b8d7303..0000000 --- a/.sqlx/query-48884b1c153cbf6e10eed72e34ec512dfc9109b338eea475c209e5a005ae20c6.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n channel.name as \"name: Name\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where id = $1\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name: Name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at?: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence?: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - false, - false - ] - }, - "hash": "48884b1c153cbf6e10eed72e34ec512dfc9109b338eea475c209e5a005ae20c6" -} diff --git a/.sqlx/query-4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json b/.sqlx/query-4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json new file mode 100644 index 0000000..8962098 --- /dev/null +++ b/.sqlx/query-4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert into channel_name (id, display_name, canonical_name)\n values ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "4c27c8f7ed9b6315433dc2b5f787a61d82693fe0426b46f9971fcde5e44f38b0" +} diff --git a/.sqlx/query-6d34d8232e7247155c697f7ef7a26f6b14e1d30c3fb44ece8fb149c92317fa91.json b/.sqlx/query-6d34d8232e7247155c697f7ef7a26f6b14e1d30c3fb44ece8fb149c92317fa91.json new file mode 100644 index 0000000..93a4093 --- /dev/null +++ b/.sqlx/query-6d34d8232e7247155c697f7ef7a26f6b14e1d30c3fb44ece8fb149c92317fa91.json @@ -0,0 +1,26 @@ +{ + "db_name": "SQLite", + "query": "\n update token\n set last_used_at = $1\n where secret = $2\n returning\n id as \"token: Id\",\n login as \"login: login::Id\"\n ", + "describe": { + "columns": [ + { + "name": "token: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "login: login::Id", + "ordinal": 1, + "type_info": "Text" + } + ], + "parameters": { + "Right": 2 + }, + "nullable": [ + false, + false + ] + }, + "hash": "6d34d8232e7247155c697f7ef7a26f6b14e1d30c3fb44ece8fb149c92317fa91" +} diff --git a/.sqlx/query-7f5bbd935941210ba2f25e65327d32a68f41258a27249027f9200b2bbba047cb.json b/.sqlx/query-7f5bbd935941210ba2f25e65327d32a68f41258a27249027f9200b2bbba047cb.json deleted file mode 100644 index 83e730d..0000000 --- a/.sqlx/query-7f5bbd935941210ba2f25e65327d32a68f41258a27249027f9200b2bbba047cb.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n channel.id as \"id: Id\",\n channel.name as \"name: Name\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n left join message\n where channel.created_at < $1\n and message.id is null\n and deleted.id is null\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name: Name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at?: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence?: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - false, - false - ] - }, - "hash": "7f5bbd935941210ba2f25e65327d32a68f41258a27249027f9200b2bbba047cb" -} diff --git a/.sqlx/query-873b8b58360d717ea2099272b013b1ac76a28471e63d0492132b9c12c63a1f9c.json b/.sqlx/query-873b8b58360d717ea2099272b013b1ac76a28471e63d0492132b9c12c63a1f9c.json deleted file mode 100644 index edd3825..0000000 --- a/.sqlx/query-873b8b58360d717ea2099272b013b1ac76a28471e63d0492132b9c12c63a1f9c.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update token\n set last_used_at = $1\n where secret = $2\n ", - "describe": { - "columns": [], - "parameters": { - "Right": 2 - }, - "nullable": [] - }, - "hash": "873b8b58360d717ea2099272b013b1ac76a28471e63d0492132b9c12c63a1f9c" -} diff --git a/.sqlx/query-903e7ec4fafd5ce124a0e40717bf42e5d43b041acb198710e75417ac40991ec6.json b/.sqlx/query-903e7ec4fafd5ce124a0e40717bf42e5d43b041acb198710e75417ac40991ec6.json new file mode 100644 index 0000000..cf1afec --- /dev/null +++ b/.sqlx/query-903e7ec4fafd5ce124a0e40717bf42e5d43b041acb198710e75417ac40991ec6.json @@ -0,0 +1,50 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: login::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\",\n password_hash as \"password_hash: StoredHash\"\n from login\n where canonical_name = $1\n ", + "describe": { + "columns": [ + { + "name": "id: login::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + }, + { + "name": "password_hash: StoredHash", + "ordinal": 5, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "903e7ec4fafd5ce124a0e40717bf42e5d43b041acb198710e75417ac40991ec6" +} diff --git a/.sqlx/query-91df35ca0c610b05fb353eeb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659.json b/.sqlx/query-91df35ca0c610b05fb353eeb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659.json deleted file mode 100644 index 6d2cb52..0000000 --- a/.sqlx/query-91df35ca0c610b05fb353eeb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n name as \"name: Name\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(created_sequence <= $1, true)\n order by created_sequence\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name: Name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "91df35ca0c610b05fb353eeb7b7ef2f4fca0cd1c43ef45f32c9ce069d37fe659" -} diff --git a/.sqlx/query-947df87da285e68a710443f05ffc52cb1712545ff539df49bbd47df194012f79.json b/.sqlx/query-947df87da285e68a710443f05ffc52cb1712545ff539df49bbd47df194012f79.json new file mode 100644 index 0000000..d4bb122 --- /dev/null +++ b/.sqlx/query-947df87da285e68a710443f05ffc52cb1712545ff539df49bbd47df194012f79.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n name.display_name as \"display_name: String\",\n name.canonical_name as \"canonical_name: String\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_name as name\n using (id)\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence <= $1, true)\n order by name.canonical_name\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "947df87da285e68a710443f05ffc52cb1712545ff539df49bbd47df194012f79" +} diff --git a/.sqlx/query-a40496319887752f5845c0c2e965e2294c1a6932d72a8b04d258d06d0f8938bd.json b/.sqlx/query-a40496319887752f5845c0c2e965e2294c1a6932d72a8b04d258d06d0f8938bd.json new file mode 100644 index 0000000..647e5ad --- /dev/null +++ b/.sqlx/query-a40496319887752f5845c0c2e965e2294c1a6932d72a8b04d258d06d0f8938bd.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n name.display_name as \"display_name: String\",\n name.canonical_name as \"canonical_name: String\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_name as name\n using (id)\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence > $1, true)\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + true, + true, + false, + false, + true, + true + ] + }, + "hash": "a40496319887752f5845c0c2e965e2294c1a6932d72a8b04d258d06d0f8938bd" +} diff --git a/.sqlx/query-ac7ab464e44e4412cd83744730699683e63ae1ea404418a17c6af60148f03fe8.json b/.sqlx/query-ac7ab464e44e4412cd83744730699683e63ae1ea404418a17c6af60148f03fe8.json new file mode 100644 index 0000000..7d9bbbc --- /dev/null +++ b/.sqlx/query-ac7ab464e44e4412cd83744730699683e63ae1ea404418a17c6af60148f03fe8.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(login.created_sequence > $1, true)\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "ac7ab464e44e4412cd83744730699683e63ae1ea404418a17c6af60148f03fe8" +} diff --git a/.sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json b/.sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json deleted file mode 100644 index 5825ce8..0000000 --- a/.sqlx/query-b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert into channel_deleted (id, deleted_at, deleted_sequence)\n values ($1, $2, $3)\n returning 1 as \"deleted: bool\"\n ", - "describe": { - "columns": [ - { - "name": "deleted: bool", - "ordinal": 0, - "type_info": "Null" - } - ], - "parameters": { - "Right": 3 - }, - "nullable": [ - null - ] - }, - "hash": "b1ba724a62135a6fbcb7ad314ba09dbe4e42fd49b2007e47bcfb07b5ba9001b0" -} diff --git a/.sqlx/query-be33d1fb2f71093ed73efd90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e.json b/.sqlx/query-be33d1fb2f71093ed73efd90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e.json deleted file mode 100644 index 4b69943..0000000 --- a/.sqlx/query-be33d1fb2f71093ed73efd90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e.json +++ /dev/null @@ -1,44 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\t\t\tselect\n\t\t\t\t\tid as \"id: login::Id\",\n\t\t\t\t\tname as \"name: Name\",\n\t\t\t\t\tpassword_hash as \"password_hash: StoredHash\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n\t\t\t\tfrom login\n\t\t\t\twhere name = $1\n\t\t\t", - "describe": { - "columns": [ - { - "name": "id: login::Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name: Name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "password_hash: StoredHash", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 4, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false, - false - ] - }, - "hash": "be33d1fb2f71093ed73efd90c8d4dfe599c70b36607a5dc436f28ba5b2ea9b2e" -} diff --git a/.sqlx/query-c31c02aa8c4e615c463835d188879a394d36e66f90edb27d2665f081ff95087f.json b/.sqlx/query-c31c02aa8c4e615c463835d188879a394d36e66f90edb27d2665f081ff95087f.json new file mode 100644 index 0000000..aa20875 --- /dev/null +++ b/.sqlx/query-c31c02aa8c4e615c463835d188879a394d36e66f90edb27d2665f081ff95087f.json @@ -0,0 +1,44 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: login::Id\",\n display_name as \"display_name: String\",\n canonical_name as \"canonical_name: String\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: login::Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name: String", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "canonical_name: String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 3, + "type_info": "Integer" + }, + { + "name": "created_at: DateTime", + "ordinal": 4, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "c31c02aa8c4e615c463835d188879a394d36e66f90edb27d2665f081ff95087f" +} diff --git a/.sqlx/query-c83af5b462071927d05664bb69067dd15b2e7602aafcfaf04aa1f41e118a1f87.json b/.sqlx/query-c83af5b462071927d05664bb69067dd15b2e7602aafcfaf04aa1f41e118a1f87.json deleted file mode 100644 index 01bb071..0000000 --- a/.sqlx/query-c83af5b462071927d05664bb69067dd15b2e7602aafcfaf04aa1f41e118a1f87.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n update channel\n set name = null\n where id = $1\n returning 1 as \"updated: bool\"\n ", - "describe": { - "columns": [ - { - "name": "updated: bool", - "ordinal": 0, - "type_info": "Null" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - null - ] - }, - "hash": "c83af5b462071927d05664bb69067dd15b2e7602aafcfaf04aa1f41e118a1f87" -} diff --git a/.sqlx/query-d1c869c323d1ab45216279ae5ca13b72436960622863420d3f1d73a422fe5b42.json b/.sqlx/query-d1c869c323d1ab45216279ae5ca13b72436960622863420d3f1d73a422fe5b42.json new file mode 100644 index 0000000..658728c --- /dev/null +++ b/.sqlx/query-d1c869c323d1ab45216279ae5ca13b72436960622863420d3f1d73a422fe5b42.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert\n into channel (id, created_at, created_sequence)\n values ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 3 + }, + "nullable": [] + }, + "hash": "d1c869c323d1ab45216279ae5ca13b72436960622863420d3f1d73a422fe5b42" +} diff --git a/.sqlx/query-d693a55bf9394ea79a892c3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f.json b/.sqlx/query-d693a55bf9394ea79a892c3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f.json deleted file mode 100644 index 4291236..0000000 --- a/.sqlx/query-d693a55bf9394ea79a892c3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into login (id, name, password_hash, created_sequence, created_at)\n values ($1, $2, $3, $4, $5)\n returning\n id as \"id: Id\",\n name as \"name: Name\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name: Name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 5 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "d693a55bf9394ea79a892c3a5ed7d651ce7c5b3c7e8960458af03f1b533e3b1f" -} diff --git a/.sqlx/query-d9c772a28d1b5b2ca3b0a2c19a4eed48252247f15a046098d63359c6d9047c9a.json b/.sqlx/query-d9c772a28d1b5b2ca3b0a2c19a4eed48252247f15a046098d63359c6d9047c9a.json deleted file mode 100644 index 39e9231..0000000 --- a/.sqlx/query-d9c772a28d1b5b2ca3b0a2c19a4eed48252247f15a046098d63359c6d9047c9a.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n\t\t\t\tselect\n invite.id as \"invite_id: Id\",\n\t\t\t\t\tissuer.id as \"issuer_id: login::Id\",\n\t\t\t\t\tissuer.name as \"issuer_name\",\n\t\t\t\t\tinvite.issued_at as \"invite_issued_at: DateTime\"\n\t\t\t\tfrom invite\n\t\t\t\tjoin login as issuer on (invite.issuer = issuer.id)\n\t\t\t\twhere invite.id = $1\n\t\t\t", - "describe": { - "columns": [ - { - "name": "invite_id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "issuer_id: login::Id", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "issuer_name", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "invite_issued_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "d9c772a28d1b5b2ca3b0a2c19a4eed48252247f15a046098d63359c6d9047c9a" -} diff --git a/.sqlx/query-db77e97937167d1edbbe88ebb2c0efd1c5718e721fe906765c28d040e446acd7.json b/.sqlx/query-db77e97937167d1edbbe88ebb2c0efd1c5718e721fe906765c28d040e446acd7.json deleted file mode 100644 index 0f2045f..0000000 --- a/.sqlx/query-db77e97937167d1edbbe88ebb2c0efd1c5718e721fe906765c28d040e446acd7.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n insert\n into channel (id, name, created_at, created_sequence)\n values ($1, $2, $3, $4)\n returning\n id as \"id: Id\",\n name as \"name!: Name\", -- known non-null as we just set it\n created_at as \"created_at: DateTime\",\n created_sequence as \"created_sequence: Sequence\"\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name!: Name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 4 - }, - "nullable": [ - false, - true, - false, - false - ] - }, - "hash": "db77e97937167d1edbbe88ebb2c0efd1c5718e721fe906765c28d040e446acd7" -} diff --git a/.sqlx/query-dbbc785bc45173db773f9179ae0758568f732d837c923cfe1b142181fb5d83f3.json b/.sqlx/query-dbbc785bc45173db773f9179ae0758568f732d837c923cfe1b142181fb5d83f3.json deleted file mode 100644 index 7b5ac51..0000000 --- a/.sqlx/query-dbbc785bc45173db773f9179ae0758568f732d837c923cfe1b142181fb5d83f3.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n name as \"name: Name\",\n created_sequence as \"created_sequence: Sequence\",\n created_at as \"created_at: DateTime\"\n from login\n where coalesce(login.created_sequence > $1, true)\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name: Name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 2, - "type_info": "Integer" - }, - { - "name": "created_at: DateTime", - "ordinal": 3, - "type_info": "Text" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - false, - false, - false - ] - }, - "hash": "dbbc785bc45173db773f9179ae0758568f732d837c923cfe1b142181fb5d83f3" -} diff --git a/.sqlx/query-dd613246fc1e87039f0a12ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6.json b/.sqlx/query-dd613246fc1e87039f0a12ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6.json deleted file mode 100644 index fe8be3f..0000000 --- a/.sqlx/query-dd613246fc1e87039f0a12ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "db_name": "SQLite", - "query": "\n select\n id as \"id: Id\",\n channel.name as \"name: Name\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence: Sequence\"\n from channel\n left join channel_deleted as deleted\n using (id)\n where coalesce(channel.created_sequence > $1, true)\n ", - "describe": { - "columns": [ - { - "name": "id: Id", - "ordinal": 0, - "type_info": "Text" - }, - { - "name": "name: Name", - "ordinal": 1, - "type_info": "Text" - }, - { - "name": "created_at: DateTime", - "ordinal": 2, - "type_info": "Text" - }, - { - "name": "created_sequence: Sequence", - "ordinal": 3, - "type_info": "Integer" - }, - { - "name": "deleted_at: DateTime", - "ordinal": 4, - "type_info": "Text" - }, - { - "name": "deleted_sequence: Sequence", - "ordinal": 5, - "type_info": "Integer" - } - ], - "parameters": { - "Right": 1 - }, - "nullable": [ - false, - true, - false, - false, - true, - true - ] - }, - "hash": "dd613246fc1e87039f0a12ca8e2fa7c9cee66d2dfc3e516064982609cdcb3ff6" -} diff --git a/.sqlx/query-e01508e57cc9cecc83640a5518d6364c8dbfdb45f205fecf0734fe272be493b0.json b/.sqlx/query-e01508e57cc9cecc83640a5518d6364c8dbfdb45f205fecf0734fe272be493b0.json new file mode 100644 index 0000000..4efac0c --- /dev/null +++ b/.sqlx/query-e01508e57cc9cecc83640a5518d6364c8dbfdb45f205fecf0734fe272be493b0.json @@ -0,0 +1,12 @@ +{ + "db_name": "SQLite", + "query": "\n insert\n into login (id, display_name, canonical_name, password_hash, created_sequence, created_at)\n values ($1, $2, $3, $4, $5, $6)\n ", + "describe": { + "columns": [], + "parameters": { + "Right": 6 + }, + "nullable": [] + }, + "hash": "e01508e57cc9cecc83640a5518d6364c8dbfdb45f205fecf0734fe272be493b0" +} diff --git a/.sqlx/query-e06cbe45408d593a081b566c38657da87439340801d0765f8d9f64f8fbbbfb6b.json b/.sqlx/query-e06cbe45408d593a081b566c38657da87439340801d0765f8d9f64f8fbbbfb6b.json new file mode 100644 index 0000000..662e8c2 --- /dev/null +++ b/.sqlx/query-e06cbe45408d593a081b566c38657da87439340801d0765f8d9f64f8fbbbfb6b.json @@ -0,0 +1,38 @@ +{ + "db_name": "SQLite", + "query": "\n\t\t\t\tselect\n invite.id as \"invite_id: Id\",\n\t\t\t\t\tissuer.id as \"issuer_id: login::Id\",\n\t\t\t\t\tissuer.display_name as \"issuer_name: nfc::String\",\n\t\t\t\t\tinvite.issued_at as \"invite_issued_at: DateTime\"\n\t\t\t\tfrom invite\n\t\t\t\tjoin login as issuer on (invite.issuer = issuer.id)\n\t\t\t\twhere invite.id = $1\n\t\t\t", + "describe": { + "columns": [ + { + "name": "invite_id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "issuer_id: login::Id", + "ordinal": 1, + "type_info": "Text" + }, + { + "name": "issuer_name: nfc::String", + "ordinal": 2, + "type_info": "Text" + }, + { + "name": "invite_issued_at: DateTime", + "ordinal": 3, + "type_info": "Text" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "e06cbe45408d593a081b566c38657da87439340801d0765f8d9f64f8fbbbfb6b" +} diff --git a/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json b/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json new file mode 100644 index 0000000..6700c43 --- /dev/null +++ b/.sqlx/query-e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e.json @@ -0,0 +1,56 @@ +{ + "db_name": "SQLite", + "query": "\n select\n id as \"id: Id\",\n name.display_name as \"display_name?: String\",\n name.canonical_name as \"canonical_name?: String\",\n channel.created_at as \"created_at: DateTime\",\n channel.created_sequence as \"created_sequence: Sequence\",\n deleted.deleted_at as \"deleted_at?: DateTime\",\n deleted.deleted_sequence as \"deleted_sequence?: Sequence\"\n from channel\n left join channel_name as name\n using (id)\n left join channel_deleted as deleted\n using (id)\n where id = $1\n ", + "describe": { + "columns": [ + { + "name": "id: Id", + "ordinal": 0, + "type_info": "Text" + }, + { + "name": "display_name?: String", + "ordinal": 1, + "type_info": "Null" + }, + { + "name": "canonical_name?: String", + "ordinal": 2, + "type_info": "Null" + }, + { + "name": "created_at: DateTime", + "ordinal": 3, + "type_info": "Text" + }, + { + "name": "created_sequence: Sequence", + "ordinal": 4, + "type_info": "Integer" + }, + { + "name": "deleted_at?: DateTime", + "ordinal": 5, + "type_info": "Text" + }, + { + "name": "deleted_sequence?: Sequence", + "ordinal": 6, + "type_info": "Integer" + } + ], + "parameters": { + "Right": 1 + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false + ] + }, + "hash": "e36d7cd00f040807c3994b5010f2f77b943ec225244fcfc24013b12a48084a1e" +} diff --git a/Cargo.lock b/Cargo.lock index c4454e3..1cb656d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -825,6 +825,7 @@ dependencies = [ "thiserror", "tokio", "tokio-stream", + "unicode-casefold", "unicode-normalization", "uuid", ] @@ -2108,6 +2109,12 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5ab17db44d7388991a428b2ee655ce0c212e862eff1768a455c58f9aad6e7893" +[[package]] +name = "unicode-casefold" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f66b1c8f8caa2ab31dc6d3f35386f16efdab89668f93411e565ac368908e8f" + [[package]] name = "unicode-ident" version = "1.0.13" diff --git a/Cargo.toml b/Cargo.toml index fe8fdfd..30a209d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ sqlx = { version = "=0.8.2", features = ["chrono", "runtime-tokio", "sqlite"] } thiserror = "1.0.64" tokio = { version = "1.40.0", features = ["rt", "macros", "rt-multi-thread"] } tokio-stream = { version = "0.1.16", features = ["sync"] } +unicode-casefold = "0.2.0" unicode-normalization = "0.1.24" uuid = { version = "1.11.0", features = ["v4"] } diff --git a/docs/api/authentication.md b/docs/api/authentication.md index 7e05443..135e91b 100644 --- a/docs/api/authentication.md +++ b/docs/api/authentication.md @@ -13,6 +13,23 @@ stateDiagram-v2 Authentication associates each authenticated request with a login. +To create logins, see [initial setup](./initial-setup.md) and [invitations](./invitations.md). + + +## Names + + +The service handles login names using two separate forms. + +The first form is as given in the request used to create the login. This form of the login name is used throughout the API, and the service will preserve the name as entered (other than applying normalization), so that users' preferences around capitalization and accent marks are preserved. + +The second form is a "canonical" form, used internally by the service to control uniqueness and match names to logins. The canonical form is both case-folded and normalized. + +The canonical form is not available to API clients, but its use has practical consequences: + +* Names that differ only by case or only by code point sequence are treated as the same name. If the name is in use, changing the capitalization or changing the sequence of combining marks will not allow the creation of a second "identical" login. +* The login API accepts any name that canonicalizes to the form stored in the database, making login names effectively case-insensitive. + ## Identity tokens @@ -32,8 +49,6 @@ Unless the endpoint's documentation says otherwise, all endpoints require authen Authenticates the user using their login name and password. The login must exist before calling this endpoint. -To create logins, see [initial setup](./initial-setup.md) and [invitations](./invitations.md). - **This endpoint does not require an `identity` cookie.** ### Request diff --git a/docs/api/channels-messages.md b/docs/api/channels-messages.md index a441f52..9ef4e66 100644 --- a/docs/api/channels-messages.md +++ b/docs/api/channels-messages.md @@ -27,6 +27,18 @@ Messages allow logins to communicate with one another. Channels are the conversa Every channel has a unique name, chosen when the channel is created. +## Names + + +The service handles channel names using two separate forms. + +The first form is as given in the request used to create the channel. This form of the channel name is used throughout the API, and the service will preserve the name as entered (other than applying normalization), so that users' preferences around capitalization and accent marks are preserved. + +The second form is a "canonical" form, used internally by the service to control uniqueness and match names to channels. The canonical form is both case-folded and normalized. + +The canonical form is not available to API clients, but its use has practical consequences. Names that differ only by case or only by code point sequence are treated as the same name. If the name is in use, changing the capitalization or changing the sequence of combining marks will not allow the creation of a second "identical" channel. + + ## Expiry and purging Both channels and messages expire after a time. Messages expire 90 days after being sent. Channels expire 90 days after the last message sent to them, or after creation if no messages are sent in that time. diff --git a/migrations/20241019191531_canonical_names.sql b/migrations/20241019191531_canonical_names.sql new file mode 100644 index 0000000..ab7cbf4 --- /dev/null +++ b/migrations/20241019191531_canonical_names.sql @@ -0,0 +1,189 @@ +alter table login +rename to old_login; +alter table token +rename to old_token; +alter table invite +rename to old_invite; +alter table channel +rename to old_channel; +alter table channel_deleted +rename to old_channel_deleted; +alter table message +rename to old_message; +alter table message_deleted +rename to old_message_deleted; + +create table login ( + id text + not null + primary key, + display_name text + not null, + canonical_name text + not null + unique, + password_hash text + not null, + created_sequence bigint + unique + not null, + created_at text + not null +); + +insert into login (id, display_name, canonical_name, password_hash, created_sequence, created_at) +select + id, + -- This isn't strictly correct, as existing names are not guaranteed to be + -- normalized, and as sqlite's built-in `lower` only operates on ASCII, but + -- without any way to do case folding and normalization in sqlite3, and + -- without any way to call out into Rust to do the work, this is the best we + -- can do. Normalization issues will produce errors at runtime when the + -- relevant rows are loaded. + name as display_name, + lower(name) as canonical_name, + password_hash, + created_sequence, + created_at +from old_login; + +create table token ( + id text + not null + primary key, + secret text + not null + unique, + login text + not null + references login (id), + issued_at text + not null, + last_used_at text + not null +); + +insert into token (id, secret, login, issued_at, last_used_at) +select id, secret, login, issued_at, last_used_at from old_token; + +create table invite ( + id text + primary key + not null, + issuer text + not null + references login (id), + issued_at text + not null +); + +insert into invite (id, issuer, issued_at) +select id, issuer, issued_at +from old_invite; + +create table channel ( + id text + not null + primary key, + created_sequence bigint + unique + not null, + created_at text + not null +); + +create table channel_name ( + id text + not null + primary key + references channel (id), + display_name + not null, + canonical_name + not null + unique +); + +insert into channel (id, created_sequence, created_at) +select id, created_sequence, created_at from old_channel; + +insert into channel_name (id, display_name, canonical_name) +select + id, + -- This isn't strictly correct, for the same reasons as above. + name as display_name, + name as canonical_name +from old_channel +where name is not null; + +create table channel_deleted ( + id text + not null + primary key + references channel (id), + deleted_sequence bigint + unique + not null, + deleted_at text + not null +); + +insert into channel_deleted (id, deleted_sequence, deleted_at) +select id, deleted_sequence, deleted_at +from old_channel_deleted; + +create table message ( + id text + not null + primary key, + -- Starting from the code changes in this rev, values in this column will be + -- in NFC. However, we don't actually need to normalize historical values; + -- they're delivered to clients "verbatim" with respect to how they were sent, + -- which causes no harm. + channel text + not null + references channel (id), + sender text + not null + references login (id), + sent_sequence bigint + unique + not null, + sent_at text + not null, + body text + null +); + +insert into message (id, channel, sender, sent_sequence, sent_at, body) +select id, channel, sender, sent_sequence, sent_at, body +from old_message; + +create table message_deleted ( + id text + not null + primary key + references message (id), + deleted_sequence bigint + unique + not null, + deleted_at text + not null +); + +insert into message_deleted (id, deleted_sequence, deleted_at) +select id, deleted_sequence, deleted_at +from old_message_deleted; + +drop table old_message_deleted; +drop table old_message; +drop table old_channel_deleted; +drop table old_channel; +drop table old_invite; +drop table old_token; +drop table old_login; + +create index token_issued_at +on token (issued_at); +create index message_sent_at +on message (sent_at); diff --git a/src/boot/app.rs b/src/boot/app.rs index ef48b2f..1d88608 100644 --- a/src/boot/app.rs +++ b/src/boot/app.rs @@ -2,8 +2,11 @@ use sqlx::sqlite::SqlitePool; use super::Snapshot; use crate::{ - channel::repo::Provider as _, event::repo::Provider as _, login::repo::Provider as _, + channel::{self, repo::Provider as _}, + event::repo::Provider as _, + login::{self, repo::Provider as _}, message::repo::Provider as _, + name, }; pub struct Boot<'a> { @@ -15,7 +18,7 @@ impl<'a> Boot<'a> { Self { db } } - pub async fn snapshot(&self) -> Result { + pub async fn snapshot(&self) -> Result { let mut tx = self.db.begin().await?; let resume_point = tx.sequence().current().await?; @@ -48,3 +51,30 @@ impl<'a> Boot<'a> { }) } } + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum Error { + Name(#[from] name::Error), + Database(#[from] sqlx::Error), +} + +impl From for Error { + fn from(error: login::repo::LoadError) -> Self { + use login::repo::LoadError; + match error { + LoadError::Name(error) => error.into(), + LoadError::Database(error) => error.into(), + } + } +} + +impl From for Error { + fn from(error: channel::repo::LoadError) -> Self { + use channel::repo::LoadError; + match error { + LoadError::Name(error) => error.into(), + LoadError::Database(error) => error.into(), + } + } +} diff --git a/src/channel/app.rs b/src/channel/app.rs index ea60943..b8ceeb0 100644 --- a/src/channel/app.rs +++ b/src/channel/app.rs @@ -2,12 +2,16 @@ use chrono::TimeDelta; use itertools::Itertools; use sqlx::sqlite::SqlitePool; -use super::{repo::Provider as _, Channel, History, Id, Name}; +use super::{ + repo::{LoadError, Provider as _}, + Channel, History, Id, +}; use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, event::{repo::Provider as _, Broadcaster, Event, Sequence}, message::repo::Provider as _, + name::{self, Name}, }; pub struct Channels<'a> { @@ -38,7 +42,7 @@ impl<'a> Channels<'a> { // This function is careless with respect to time, and gets you the channel as // it exists in the specific moment when you call it. - pub async fn get(&self, channel: &Id) -> Result, sqlx::Error> { + pub async fn get(&self, channel: &Id) -> Result, Error> { let mut tx = self.db.begin().await?; let channel = tx.channels().by_id(channel).await.optional()?; tx.commit().await?; @@ -88,7 +92,7 @@ impl<'a> Channels<'a> { Ok(()) } - pub async fn expire(&self, relative_to: &DateTime) -> Result<(), sqlx::Error> { + pub async fn expire(&self, relative_to: &DateTime) -> Result<(), ExpireError> { // Somewhat arbitrarily, expire after 90 days. let expire_at = relative_to.to_owned() - TimeDelta::days(90); @@ -137,6 +141,17 @@ pub enum CreateError { DuplicateName(Name), #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From for CreateError { + fn from(error: LoadError) -> Self { + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } } #[derive(Debug, thiserror::Error)] @@ -147,4 +162,32 @@ pub enum Error { Deleted(Id), #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From for Error { + fn from(error: LoadError) -> Self { + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum ExpireError { + #[error(transparent)] + Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From for ExpireError { + fn from(error: LoadError) -> Self { + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } } diff --git a/src/channel/mod.rs b/src/channel/mod.rs index fb13e92..eb8200b 100644 --- a/src/channel/mod.rs +++ b/src/channel/mod.rs @@ -2,11 +2,8 @@ pub mod app; pub mod event; mod history; mod id; -mod name; pub mod repo; mod routes; mod snapshot; -pub use self::{ - event::Event, history::History, id::Id, name::Name, routes::router, snapshot::Channel, -}; +pub use self::{event::Event, history::History, id::Id, routes::router, snapshot::Channel}; diff --git a/src/channel/name.rs b/src/channel/name.rs deleted file mode 100644 index fc82dec..0000000 --- a/src/channel/name.rs +++ /dev/null @@ -1,30 +0,0 @@ -use std::fmt; - -use crate::nfc; - -#[derive( - Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize, sqlx::Type, -)] -#[serde(transparent)] -#[sqlx(transparent)] -pub struct Name(nfc::String); - -impl fmt::Display for Name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let Self(name) = self; - name.fmt(f) - } -} - -impl From for Name { - fn from(name: String) -> Self { - Self(name.into()) - } -} - -impl From for String { - fn from(name: Name) -> Self { - let Name(name) = name; - name.into() - } -} diff --git a/src/channel/repo.rs b/src/channel/repo.rs index 3353bfd..4baa95b 100644 --- a/src/channel/repo.rs +++ b/src/channel/repo.rs @@ -1,9 +1,12 @@ +use futures::stream::{StreamExt as _, TryStreamExt as _}; use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use crate::{ - channel::{Channel, History, Id, Name}, + channel::{Channel, History, Id}, clock::DateTime, + db::NotFound, event::{Instant, ResumePoint, Sequence}, + name::{self, Name}, }; pub trait Provider { @@ -21,132 +24,160 @@ pub struct Channels<'t>(&'t mut SqliteConnection); impl<'c> Channels<'c> { pub async fn create(&mut self, name: &Name, created: &Instant) -> Result { let id = Id::generate(); - let channel = sqlx::query!( + let name = name.clone(); + let display_name = name.display(); + let canonical_name = name.canonical(); + let created = *created; + + sqlx::query!( r#" insert - into channel (id, name, created_at, created_sequence) - values ($1, $2, $3, $4) - returning - id as "id: Id", - name as "name!: Name", -- known non-null as we just set it - created_at as "created_at: DateTime", - created_sequence as "created_sequence: Sequence" + into channel (id, created_at, created_sequence) + values ($1, $2, $3) "#, id, - name, created.at, created.sequence, ) - .map(|row| History { + .execute(&mut *self.0) + .await?; + + sqlx::query!( + r#" + insert into channel_name (id, display_name, canonical_name) + values ($1, $2, $3) + "#, + id, + display_name, + canonical_name, + ) + .execute(&mut *self.0) + .await?; + + let channel = History { channel: Channel { - id: row.id, - name: row.name, + id, + name: name.clone(), deleted_at: None, }, - created: Instant::new(row.created_at, row.created_sequence), + created, deleted: None, - }) - .fetch_one(&mut *self.0) - .await?; + }; Ok(channel) } - pub async fn by_id(&mut self, channel: &Id) -> Result { + pub async fn by_id(&mut self, channel: &Id) -> Result { let channel = sqlx::query!( r#" select id as "id: Id", - channel.name as "name: Name", + name.display_name as "display_name?: String", + name.canonical_name as "canonical_name?: String", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", deleted.deleted_at as "deleted_at?: DateTime", deleted.deleted_sequence as "deleted_sequence?: Sequence" from channel + left join channel_name as name + using (id) left join channel_deleted as deleted using (id) where id = $1 "#, channel, ) - .map(|row| History { - channel: Channel { - id: row.id, - name: row.name.unwrap_or_default(), - deleted_at: row.deleted_at, - }, - created: Instant::new(row.created_at, row.created_sequence), - deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + .map(|row| { + Ok::<_, name::Error>(History { + channel: Channel { + id: row.id, + name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), + deleted_at: row.deleted_at, + }, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + }) }) .fetch_one(&mut *self.0) - .await?; + .await??; Ok(channel) } - pub async fn all(&mut self, resume_at: ResumePoint) -> Result, sqlx::Error> { + pub async fn all(&mut self, resume_at: ResumePoint) -> Result, LoadError> { let channels = sqlx::query!( r#" select id as "id: Id", - channel.name as "name: Name", + name.display_name as "display_name: String", + name.canonical_name as "canonical_name: String", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", - deleted.deleted_at as "deleted_at: DateTime", - deleted.deleted_sequence as "deleted_sequence: Sequence" + deleted.deleted_at as "deleted_at?: DateTime", + deleted.deleted_sequence as "deleted_sequence?: Sequence" from channel + left join channel_name as name + using (id) left join channel_deleted as deleted using (id) where coalesce(channel.created_sequence <= $1, true) - order by channel.name + order by name.canonical_name "#, resume_at, ) - .map(|row| History { - channel: Channel { - id: row.id, - name: row.name.unwrap_or_default(), - deleted_at: row.deleted_at, - }, - created: Instant::new(row.created_at, row.created_sequence), - deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + .map(|row| { + Ok::<_, name::Error>(History { + channel: Channel { + id: row.id, + name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), + deleted_at: row.deleted_at, + }, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + }) }) - .fetch_all(&mut *self.0) + .fetch(&mut *self.0) + .map(|res| Ok::<_, LoadError>(res??)) + .try_collect() .await?; Ok(channels) } - pub async fn replay( - &mut self, - resume_at: Option, - ) -> Result, sqlx::Error> { + pub async fn replay(&mut self, resume_at: Option) -> Result, LoadError> { let channels = sqlx::query!( r#" select id as "id: Id", - channel.name as "name: Name", + name.display_name as "display_name: String", + name.canonical_name as "canonical_name: String", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", - deleted.deleted_at as "deleted_at: DateTime", - deleted.deleted_sequence as "deleted_sequence: Sequence" + deleted.deleted_at as "deleted_at?: DateTime", + deleted.deleted_sequence as "deleted_sequence?: Sequence" from channel + left join channel_name as name + using (id) left join channel_deleted as deleted using (id) where coalesce(channel.created_sequence > $1, true) "#, resume_at, ) - .map(|row| History { - channel: Channel { - id: row.id, - name: row.name.unwrap_or_default(), - deleted_at: row.deleted_at, - }, - created: Instant::new(row.created_at, row.created_sequence), - deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + .map(|row| { + Ok::<_, name::Error>(History { + channel: Channel { + id: row.id, + name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), + deleted_at: row.deleted_at, + }, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + }) }) - .fetch_all(&mut *self.0) + .fetch(&mut *self.0) + .map(|res| Ok::<_, LoadError>(res??)) + .try_collect() .await?; Ok(channels) @@ -156,19 +187,18 @@ impl<'c> Channels<'c> { &mut self, channel: &History, deleted: &Instant, - ) -> Result { + ) -> Result { let id = channel.id(); - sqlx::query_scalar!( + sqlx::query!( r#" insert into channel_deleted (id, deleted_at, deleted_sequence) values ($1, $2, $3) - returning 1 as "deleted: bool" "#, id, deleted.at, deleted.sequence, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; // Small social responsibility hack here: when a channel is deleted, its name is @@ -179,16 +209,14 @@ impl<'c> Channels<'c> { // This also avoids the need for a separate name reservation table to ensure // that live channels have unique names, since the `channel` table's name field // is unique over non-null values. - sqlx::query_scalar!( + sqlx::query!( r#" - update channel - set name = null + delete from channel_name where id = $1 - returning 1 as "updated: bool" "#, id, ) - .fetch_one(&mut *self.0) + .execute(&mut *self.0) .await?; let channel = self.by_id(id).await?; @@ -230,38 +258,66 @@ impl<'c> Channels<'c> { Ok(()) } - pub async fn expired(&mut self, expired_at: &DateTime) -> Result, sqlx::Error> { + pub async fn expired(&mut self, expired_at: &DateTime) -> Result, LoadError> { let channels = sqlx::query!( r#" select channel.id as "id: Id", - channel.name as "name: Name", + name.display_name as "display_name: String", + name.canonical_name as "canonical_name: String", channel.created_at as "created_at: DateTime", channel.created_sequence as "created_sequence: Sequence", deleted.deleted_at as "deleted_at?: DateTime", deleted.deleted_sequence as "deleted_sequence?: Sequence" from channel + left join channel_name as name + using (id) left join channel_deleted as deleted using (id) left join message + on channel.id = message.channel where channel.created_at < $1 and message.id is null and deleted.id is null "#, expired_at, ) - .map(|row| History { - channel: Channel { - id: row.id, - name: row.name.unwrap_or_default(), - deleted_at: row.deleted_at, - }, - created: Instant::new(row.created_at, row.created_sequence), - deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + .map(|row| { + Ok::<_, name::Error>(History { + channel: Channel { + id: row.id, + name: Name::optional(row.display_name, row.canonical_name)?.unwrap_or_default(), + deleted_at: row.deleted_at, + }, + created: Instant::new(row.created_at, row.created_sequence), + deleted: Instant::optional(row.deleted_at, row.deleted_sequence), + }) }) - .fetch_all(&mut *self.0) + .fetch(&mut *self.0) + .map(|res| Ok::<_, LoadError>(res??)) + .try_collect() .await?; Ok(channels) } } + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl NotFound for Result { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } + } +} diff --git a/src/channel/routes/post.rs b/src/channel/routes/post.rs index d354f79..9781dd7 100644 --- a/src/channel/routes/post.rs +++ b/src/channel/routes/post.rs @@ -6,10 +6,11 @@ use axum::{ use crate::{ app::App, - channel::{app, Channel, Name}, + channel::{app, Channel}, clock::RequestedAt, error::Internal, login::Login, + name::Name, }; pub async fn handler( diff --git a/src/channel/snapshot.rs b/src/channel/snapshot.rs index dc2894d..129c0d6 100644 --- a/src/channel/snapshot.rs +++ b/src/channel/snapshot.rs @@ -1,8 +1,8 @@ use super::{ event::{Created, Event}, - Id, Name, + Id, }; -use crate::clock::DateTime; +use crate::{clock::DateTime, name::Name}; #[derive(Clone, Debug, Eq, PartialEq, serde::Serialize)] pub struct Channel { diff --git a/src/db/mod.rs b/src/db/mod.rs index 6005813..e0522d4 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -130,14 +130,17 @@ pub enum Error { Rejected(String, String), } -pub trait NotFound { +pub trait NotFound: Sized { type Ok; type Error; fn not_found(self, map: F) -> Result where E: From, - F: FnOnce() -> E; + F: FnOnce() -> E, + { + self.optional()?.ok_or_else(map) + } fn optional(self) -> Result, Self::Error>; } @@ -153,14 +156,6 @@ impl NotFound for Result { Err(other) => Err(other), } } - - fn not_found(self, map: F) -> Result - where - E: From, - F: FnOnce() -> E, - { - self.optional()?.ok_or_else(map) - } } pub trait Duplicate { diff --git a/src/event/app.rs b/src/event/app.rs index 951ce25..c754388 100644 --- a/src/event/app.rs +++ b/src/event/app.rs @@ -11,6 +11,7 @@ use crate::{ channel::{self, repo::Provider as _}, login::{self, repo::Provider as _}, message::{self, repo::Provider as _}, + name, }; pub struct Events<'a> { @@ -26,7 +27,7 @@ impl<'a> Events<'a> { pub async fn subscribe( &self, resume_at: impl Into, - ) -> Result + std::fmt::Debug, sqlx::Error> { + ) -> Result + std::fmt::Debug, Error> { let resume_at = resume_at.into(); // Subscribe before retrieving, to catch messages broadcast while we're // querying the DB. We'll prune out duplicates later. @@ -81,3 +82,30 @@ impl<'a> Events<'a> { move |event| future::ready(filter(event)) } } + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum Error { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl From for Error { + fn from(error: login::repo::LoadError) -> Self { + use login::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + +impl From for Error { + fn from(error: channel::repo::LoadError) -> Self { + use channel::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} diff --git a/src/event/routes/get.rs b/src/event/routes/get.rs index 357845a..22e8762 100644 --- a/src/event/routes/get.rs +++ b/src/event/routes/get.rs @@ -12,7 +12,7 @@ use futures::stream::{Stream, StreamExt as _}; use crate::{ app::App, error::{Internal, Unauthorized}, - event::{extract::LastEventId, Event, ResumePoint, Sequence, Sequenced as _}, + event::{app, extract::LastEventId, Event, ResumePoint, Sequence, Sequenced as _}, token::{app::ValidateError, extract::Identity}, }; @@ -69,7 +69,7 @@ impl TryFrom for sse::Event { #[derive(Debug, thiserror::Error)] #[error(transparent)] pub enum Error { - Database(#[from] sqlx::Error), + Subscribe(#[from] app::Error), Validate(#[from] ValidateError), } diff --git a/src/invite/app.rs b/src/invite/app.rs index 285a819..64ba753 100644 --- a/src/invite/app.rs +++ b/src/invite/app.rs @@ -6,7 +6,8 @@ use crate::{ clock::DateTime, db::{Duplicate as _, NotFound as _}, event::repo::Provider as _, - login::{repo::Provider as _, Login, Name, Password}, + login::{repo::Provider as _, Login, Password}, + name::Name, token::{repo::Provider as _, Secret}, }; diff --git a/src/invite/mod.rs b/src/invite/mod.rs index abf1c3a..d59fb9c 100644 --- a/src/invite/mod.rs +++ b/src/invite/mod.rs @@ -3,7 +3,7 @@ mod id; mod repo; mod routes; -use crate::{clock::DateTime, login}; +use crate::{clock::DateTime, login, normalize::nfc}; pub use self::{id::Id, routes::router}; @@ -17,6 +17,6 @@ pub struct Invite { #[derive(serde::Serialize)] pub struct Summary { pub id: Id, - pub issuer: String, + pub issuer: nfc::String, pub issued_at: DateTime, } diff --git a/src/invite/repo.rs b/src/invite/repo.rs index 643f5b7..02f4e42 100644 --- a/src/invite/repo.rs +++ b/src/invite/repo.rs @@ -4,6 +4,7 @@ use super::{Id, Invite, Summary}; use crate::{ clock::DateTime, login::{self, Login}, + normalize::nfc, }; pub trait Provider { @@ -70,7 +71,7 @@ impl<'c> Invites<'c> { select invite.id as "invite_id: Id", issuer.id as "issuer_id: login::Id", - issuer.name as "issuer_name", + issuer.display_name as "issuer_name: nfc::String", invite.issued_at as "invite_issued_at: DateTime" from invite join login as issuer on (invite.issuer = issuer.id) diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs index 8160465..a41207a 100644 --- a/src/invite/routes/invite/post.rs +++ b/src/invite/routes/invite/post.rs @@ -9,7 +9,8 @@ use crate::{ clock::RequestedAt, error::{Internal, NotFound}, invite::app, - login::{Login, Name, Password}, + login::{Login, Password}, + name::Name, token::extract::IdentityToken, }; diff --git a/src/lib.rs b/src/lib.rs index 4d0d9b9..84b8dfc 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,7 +16,8 @@ mod id; mod invite; mod login; mod message; -mod nfc; +mod name; +mod normalize; mod setup; #[cfg(test)] mod test; diff --git a/src/login/app.rs b/src/login/app.rs index ebc1c00..37f1249 100644 --- a/src/login/app.rs +++ b/src/login/app.rs @@ -1,9 +1,10 @@ use sqlx::sqlite::SqlitePool; -use super::{repo::Provider as _, Login, Name, Password}; +use super::{repo::Provider as _, Login, Password}; use crate::{ clock::DateTime, event::{repo::Provider as _, Broadcaster, Event}, + name::Name, }; pub struct Logins<'a> { diff --git a/src/login/mod.rs b/src/login/mod.rs index 71d5bfc..98cc3d7 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -4,13 +4,11 @@ pub mod event; pub mod extract; mod history; mod id; -mod name; pub mod password; pub mod repo; mod routes; mod snapshot; pub use self::{ - event::Event, history::History, id::Id, name::Name, password::Password, routes::router, - snapshot::Login, + event::Event, history::History, id::Id, password::Password, routes::router, snapshot::Login, }; diff --git a/src/login/name.rs b/src/login/name.rs deleted file mode 100644 index d882ff9..0000000 --- a/src/login/name.rs +++ /dev/null @@ -1,28 +0,0 @@ -use std::fmt; - -use crate::nfc; - -#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, sqlx::Type)] -#[serde(transparent)] -#[sqlx(transparent)] -pub struct Name(nfc::String); - -impl fmt::Display for Name { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let Self(name) = self; - name.fmt(f) - } -} - -impl From for Name { - fn from(name: String) -> Self { - Self(name.into()) - } -} - -impl From for String { - fn from(name: Name) -> Self { - let Name(name) = name; - name.into() - } -} diff --git a/src/login/password.rs b/src/login/password.rs index f9ecf37..c27c950 100644 --- a/src/login/password.rs +++ b/src/login/password.rs @@ -4,7 +4,7 @@ use argon2::Argon2; use password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}; use rand_core::OsRng; -use crate::nfc; +use crate::normalize::nfc; #[derive(sqlx::Type)] #[sqlx(transparent)] diff --git a/src/login/repo.rs b/src/login/repo.rs index 204329f..6021f26 100644 --- a/src/login/repo.rs +++ b/src/login/repo.rs @@ -1,9 +1,11 @@ +use futures::stream::{StreamExt as _, TryStreamExt as _}; use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use crate::{ clock::DateTime, event::{Instant, ResumePoint, Sequence}, - login::{password::StoredHash, History, Id, Login, Name}, + login::{password::StoredHash, History, Id, Login}, + name::{self, Name}, }; pub trait Provider { @@ -26,43 +28,43 @@ impl<'c> Logins<'c> { created: &Instant, ) -> Result { let id = Id::generate(); + let display_name = name.display(); + let canonical_name = name.canonical(); - let login = sqlx::query!( + sqlx::query!( r#" insert - into login (id, name, password_hash, created_sequence, created_at) - values ($1, $2, $3, $4, $5) - returning - id as "id: Id", - name as "name: Name", - created_sequence as "created_sequence: Sequence", - created_at as "created_at: DateTime" + into login (id, display_name, canonical_name, password_hash, created_sequence, created_at) + values ($1, $2, $3, $4, $5, $6) "#, id, - name, + display_name, + canonical_name, password_hash, created.sequence, created.at, ) - .map(|row| History { + .execute(&mut *self.0) + .await?; + + let login = History { + created: *created, login: Login { - id: row.id, - name: row.name, + id, + name: name.clone(), }, - created: Instant::new(row.created_at, row.created_sequence), - }) - .fetch_one(&mut *self.0) - .await?; + }; Ok(login) } - pub async fn all(&mut self, resume_at: ResumePoint) -> Result, sqlx::Error> { - let channels = sqlx::query!( + pub async fn all(&mut self, resume_at: ResumePoint) -> Result, LoadError> { + let logins = sqlx::query!( r#" select id as "id: Id", - name as "name: Name", + display_name as "display_name: String", + canonical_name as "canonical_name: String", created_sequence as "created_sequence: Sequence", created_at as "created_at: DateTime" from login @@ -71,24 +73,29 @@ impl<'c> Logins<'c> { "#, resume_at, ) - .map(|row| History { - login: Login { - id: row.id, - name: row.name, - }, - created: Instant::new(row.created_at, row.created_sequence), + .map(|row| { + Ok::<_, LoadError>(History { + login: Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }) }) - .fetch_all(&mut *self.0) + .fetch(&mut *self.0) + .map(|res| res?) + .try_collect() .await?; - Ok(channels) + Ok(logins) } - pub async fn replay(&mut self, resume_at: ResumePoint) -> Result, sqlx::Error> { - let messages = sqlx::query!( + pub async fn replay(&mut self, resume_at: ResumePoint) -> Result, LoadError> { + let logins = sqlx::query!( r#" select id as "id: Id", - name as "name: Name", + display_name as "display_name: String", + canonical_name as "canonical_name: String", created_sequence as "created_sequence: Sequence", created_at as "created_at: DateTime" from login @@ -96,22 +103,27 @@ impl<'c> Logins<'c> { "#, resume_at, ) - .map(|row| History { - login: Login { - id: row.id, - name: row.name, - }, - created: Instant::new(row.created_at, row.created_sequence), + .map(|row| { + Ok::<_, name::Error>(History { + login: Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }) }) - .fetch_all(&mut *self.0) + .fetch(&mut *self.0) + .map(|res| Ok::<_, LoadError>(res??)) + .try_collect() .await?; - Ok(messages) + Ok(logins) } } -impl<'t> From<&'t mut SqliteConnection> for Logins<'t> { - fn from(tx: &'t mut SqliteConnection) -> Self { - Self(tx) - } +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), } diff --git a/src/login/routes/login/post.rs b/src/login/routes/login/post.rs index 7a685e2..20430db 100644 --- a/src/login/routes/login/post.rs +++ b/src/login/routes/login/post.rs @@ -8,7 +8,8 @@ use crate::{ app::App, clock::RequestedAt, error::Internal, - login::{Login, Name, Password}, + login::{Login, Password}, + name::Name, token::{app, extract::IdentityToken}, }; diff --git a/src/login/routes/logout/test.rs b/src/login/routes/logout/test.rs index 0e70e4c..91837fe 100644 --- a/src/login/routes/logout/test.rs +++ b/src/login/routes/logout/test.rs @@ -33,7 +33,6 @@ async fn successful() { assert_eq!(StatusCode::NO_CONTENT, response_status); // Verify the semantics - let error = app .tokens() .validate(&secret, &now) diff --git a/src/login/snapshot.rs b/src/login/snapshot.rs index 85800e4..e1eb96c 100644 --- a/src/login/snapshot.rs +++ b/src/login/snapshot.rs @@ -1,7 +1,8 @@ use super::{ event::{Created, Event}, - Id, Name, + Id, }; +use crate::name::Name; // This also implements FromRequestParts (see `./extract.rs`). As a result, it // can be used as an extractor for endpoints that want to require login, or for diff --git a/src/message/app.rs b/src/message/app.rs index af87553..852b958 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -9,6 +9,7 @@ use crate::{ db::NotFound as _, event::{repo::Provider as _, Broadcaster, Event, Sequence}, login::Login, + name, }; pub struct Messages<'a> { @@ -119,6 +120,18 @@ pub enum SendError { ChannelNotFound(channel::Id), #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From for SendError { + fn from(error: channel::repo::LoadError) -> Self { + use channel::repo::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } } #[derive(Debug, thiserror::Error)] diff --git a/src/message/body.rs b/src/message/body.rs index a415f85..6dd224c 100644 --- a/src/message/body.rs +++ b/src/message/body.rs @@ -1,6 +1,6 @@ use std::fmt; -use crate::nfc; +use crate::normalize::nfc; #[derive( Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize, sqlx::Type, diff --git a/src/name.rs b/src/name.rs new file mode 100644 index 0000000..9187d33 --- /dev/null +++ b/src/name.rs @@ -0,0 +1,85 @@ +use std::fmt; + +use crate::normalize::{ident, nfc}; + +#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize, sqlx::Type)] +#[serde(from = "String", into = "String")] +pub struct Name { + display: nfc::String, + canonical: ident::String, +} + +impl Name { + pub fn new(display: D, canonical: C) -> Result + where + D: AsRef, + C: AsRef, + { + let name = Self::from(display); + + if name.canonical.as_str() == canonical.as_ref() { + Ok(name) + } else { + Err(Error::CanonicalMismatch( + canonical.as_ref().into(), + name.canonical, + name.display, + )) + } + } + + pub fn optional(display: Option, canonical: Option) -> Result, Error> + where + D: AsRef, + C: AsRef, + { + display + .zip(canonical) + .map(|(display, canonical)| Self::new(display, canonical)) + .transpose() + } + + pub fn display(&self) -> &nfc::String { + &self.display + } + + pub fn canonical(&self) -> &ident::String { + &self.canonical + } +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("stored canonical form {0:#?} does not match computed canonical form {:#?} for name {:#?}", .1.as_str(), .2.as_str())] + CanonicalMismatch(String, ident::String, nfc::String), +} + +impl Default for Name { + fn default() -> Self { + Self::from(String::default()) + } +} + +impl fmt::Display for Name { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + self.display.fmt(f) + } +} + +impl From for Name +where + S: AsRef, +{ + fn from(name: S) -> Self { + let display = nfc::String::from(&name); + let canonical = ident::String::from(&name); + + Self { display, canonical } + } +} + +impl From for String { + fn from(name: Name) -> Self { + name.display.into() + } +} diff --git a/src/nfc.rs b/src/nfc.rs deleted file mode 100644 index 70e936c..0000000 --- a/src/nfc.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::{fmt, string::String as StdString}; - -use sqlx::{ - encode::{Encode, IsNull}, - Database, Decode, Type, -}; -use unicode_normalization::UnicodeNormalization as _; - -#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Deserialize, serde::Serialize)] -#[serde(from = "StdString", into = "StdString")] -pub struct String(StdString); - -impl fmt::Display for String { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - let Self(value) = self; - value.fmt(f) - } -} - -impl From for String { - fn from(value: StdString) -> Self { - let value = value.nfc().collect(); - - Self(value) - } -} - -impl From for StdString { - fn from(value: String) -> Self { - let String(value) = value; - value - } -} - -impl std::ops::Deref for String { - type Target = StdString; - - fn deref(&self) -> &Self::Target { - let Self(value) = self; - value - } -} - -// Type is manually implemented so that we can implement Decode to do -// normalization on read. Implementation is otherwise based on -// `#[derive(sqlx::Type)]` with the `#[sqlx(transparent)]` attribute. -impl Type for String -where - DB: Database, - StdString: Type, -{ - fn type_info() -> ::TypeInfo { - >::type_info() - } - - fn compatible(ty: &::TypeInfo) -> bool { - >::compatible(ty) - } -} - -impl<'r, DB> Decode<'r, DB> for String -where - DB: Database, - StdString: Decode<'r, DB>, -{ - fn decode(value: ::ValueRef<'r>) -> Result { - let value = StdString::decode(value)?; - let value = value.nfc().collect(); - Ok(Self(value)) - } -} - -impl<'q, DB> Encode<'q, DB> for String -where - DB: Database, - StdString: Encode<'q, DB>, -{ - fn encode_by_ref( - &self, - buf: &mut ::ArgumentBuffer<'q>, - ) -> Result { - let Self(value) = self; - value.encode_by_ref(buf) - } - - fn encode( - self, - buf: &mut ::ArgumentBuffer<'q>, - ) -> Result { - let Self(value) = self; - value.encode(buf) - } - - fn produces(&self) -> Option<::TypeInfo> { - let Self(value) = self; - value.produces() - } - - fn size_hint(&self) -> usize { - let Self(value) = self; - value.size_hint() - } -} diff --git a/src/normalize/mod.rs b/src/normalize/mod.rs new file mode 100644 index 0000000..6294201 --- /dev/null +++ b/src/normalize/mod.rs @@ -0,0 +1,36 @@ +mod string; + +pub mod nfc { + use std::string::String as StdString; + + use unicode_normalization::UnicodeNormalization as _; + + pub type String = super::string::String; + + #[derive(Clone, Debug, Default, Eq, PartialEq)] + pub struct Nfc; + + impl super::string::Normalize for Nfc { + fn normalize(&self, value: &str) -> StdString { + value.nfc().collect() + } + } +} + +pub mod ident { + use std::string::String as StdString; + + use unicode_casefold::UnicodeCaseFold as _; + use unicode_normalization::UnicodeNormalization as _; + + pub type String = super::string::String; + + #[derive(Clone, Debug, Default, Eq, PartialEq)] + pub struct Ident; + + impl super::string::Normalize for Ident { + fn normalize(&self, value: &str) -> StdString { + value.case_fold().nfkc().collect() + } + } +} diff --git a/src/normalize/string.rs b/src/normalize/string.rs new file mode 100644 index 0000000..a0d178c --- /dev/null +++ b/src/normalize/string.rs @@ -0,0 +1,112 @@ +use std::{fmt, string::String as StdString}; + +use sqlx::{ + encode::{Encode, IsNull}, + Database, Decode, Type, +}; + +pub trait Normalize: Clone + Default { + fn normalize(&self, value: &str) -> StdString; +} + +#[derive(Clone, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)] +#[serde(into = "StdString", from = "StdString")] +#[serde(bound = "N: Normalize")] +pub struct String(StdString, N); + +impl fmt::Display for String { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let Self(value, _) = self; + value.fmt(f) + } +} + +impl From for String +where + S: AsRef, + N: Normalize, +{ + fn from(value: S) -> Self { + let normalizer = N::default(); + let value = normalizer.normalize(value.as_ref()); + + Self(value, normalizer) + } +} + +impl From> for StdString { + fn from(value: String) -> Self { + let String(value, _) = value; + value + } +} + +impl std::ops::Deref for String { + type Target = StdString; + + fn deref(&self) -> &Self::Target { + let Self(value, _) = self; + value + } +} + +// Type is manually implemented so that we can implement Decode to do +// normalization on read. Implementation is otherwise based on +// `#[derive(sqlx::Type)]` with the `#[sqlx(transparent)]` attribute. +impl Type for String +where + DB: Database, + StdString: Type, +{ + fn type_info() -> ::TypeInfo { + >::type_info() + } + + fn compatible(ty: &::TypeInfo) -> bool { + >::compatible(ty) + } +} + +impl<'r, DB, N> Decode<'r, DB> for String +where + DB: Database, + StdString: Decode<'r, DB>, + N: Normalize, +{ + fn decode(value: ::ValueRef<'r>) -> Result { + let value = StdString::decode(value)?; + Ok(Self::from(value)) + } +} + +impl<'q, DB, N> Encode<'q, DB> for String +where + DB: Database, + StdString: Encode<'q, DB>, +{ + fn encode_by_ref( + &self, + buf: &mut ::ArgumentBuffer<'q>, + ) -> Result { + let Self(value, _) = self; + value.encode_by_ref(buf) + } + + fn encode( + self, + buf: &mut ::ArgumentBuffer<'q>, + ) -> Result { + let Self(value, _) = self; + value.encode(buf) + } + + fn produces(&self) -> Option<::TypeInfo> { + let Self(value, _) = self; + value.produces() + } + + fn size_hint(&self) -> usize { + let Self(value, _) = self; + value.size_hint() + } +} diff --git a/src/setup/app.rs b/src/setup/app.rs index 9fbcf6d..030b5f6 100644 --- a/src/setup/app.rs +++ b/src/setup/app.rs @@ -4,7 +4,8 @@ use super::repo::Provider as _; use crate::{ clock::DateTime, event::{repo::Provider as _, Broadcaster, Event}, - login::{repo::Provider as _, Login, Name, Password}, + login::{repo::Provider as _, Login, Password}, + name::Name, token::{repo::Provider as _, Secret}, }; diff --git a/src/setup/routes/post.rs b/src/setup/routes/post.rs index 6a3fa11..fb2280a 100644 --- a/src/setup/routes/post.rs +++ b/src/setup/routes/post.rs @@ -8,7 +8,8 @@ use crate::{ app::App, clock::RequestedAt, error::Internal, - login::{Login, Name, Password}, + login::{Login, Password}, + name::Name, setup::app, token::extract::IdentityToken, }; diff --git a/src/test/fixtures/channel.rs b/src/test/fixtures/channel.rs index 024ac1b..3831c82 100644 --- a/src/test/fixtures/channel.rs +++ b/src/test/fixtures/channel.rs @@ -8,9 +8,10 @@ use rand; use crate::{ app::App, - channel::{self, Channel, Name}, + channel::{self, Channel}, clock::RequestedAt, event::Event, + name::Name, }; pub async fn create(app: &App, created_at: &RequestedAt) -> Channel { diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs index 0a42320..714b936 100644 --- a/src/test/fixtures/login.rs +++ b/src/test/fixtures/login.rs @@ -4,7 +4,8 @@ use uuid::Uuid; use crate::{ app::App, clock::RequestedAt, - login::{self, Login, Name, Password}, + login::{self, Login, Password}, + name::Name, }; pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Login, Password) { diff --git a/src/token/app.rs b/src/token/app.rs index d4dd1a0..c19d6a0 100644 --- a/src/token/app.rs +++ b/src/token/app.rs @@ -7,12 +7,14 @@ use futures::{ use sqlx::sqlite::SqlitePool; use super::{ - repo::auth::Provider as _, repo::Provider as _, Broadcaster, Event as TokenEvent, Id, Secret, + repo::{self, auth::Provider as _, Provider as _}, + Broadcaster, Event as TokenEvent, Id, Secret, }; use crate::{ clock::DateTime, db::NotFound as _, - login::{Login, Name, Password}, + login::{Login, Password}, + name::{self, Name}, }; pub struct Tokens<'a> { @@ -65,14 +67,16 @@ impl<'a> Tokens<'a> { used_at: &DateTime, ) -> Result<(Id, Login), ValidateError> { let mut tx = self.db.begin().await?; - let login = tx + let (token, login) = tx .tokens() .validate(secret, used_at) .await .not_found(|| ValidateError::InvalidToken)?; tx.commit().await?; - Ok(login) + let login = login.as_snapshot().ok_or(ValidateError::LoginDeleted)?; + + Ok((token, login)) } pub async fn limit_stream( @@ -162,15 +166,40 @@ pub enum LoginError { #[error(transparent)] Database(#[from] sqlx::Error), #[error(transparent)] + Name(#[from] name::Error), + #[error(transparent)] PasswordHash(#[from] password_hash::Error), } +impl From for LoginError { + fn from(error: repo::auth::LoadError) -> Self { + use repo::auth::LoadError; + match error { + LoadError::Database(error) => error.into(), + LoadError::Name(error) => error.into(), + } + } +} + #[derive(Debug, thiserror::Error)] pub enum ValidateError { #[error("invalid token")] InvalidToken, + #[error("login deleted")] + LoginDeleted, #[error(transparent)] Database(#[from] sqlx::Error), + #[error(transparent)] + Name(#[from] name::Error), +} + +impl From for ValidateError { + fn from(error: repo::LoadError) -> Self { + match error { + repo::LoadError::Database(error) => error.into(), + repo::LoadError::Name(error) => error.into(), + } + } } #[derive(Debug)] diff --git a/src/token/repo/auth.rs b/src/token/repo/auth.rs index c621b65..bdc4c33 100644 --- a/src/token/repo/auth.rs +++ b/src/token/repo/auth.rs @@ -2,8 +2,10 @@ use sqlx::{sqlite::Sqlite, SqliteConnection, Transaction}; use crate::{ clock::DateTime, + db::NotFound, event::{Instant, Sequence}, - login::{self, password::StoredHash, History, Login, Name}, + login::{self, password::StoredHash, History, Login}, + name::{self, Name}, }; pub trait Provider { @@ -19,35 +21,53 @@ impl<'c> Provider for Transaction<'c, Sqlite> { pub struct Auth<'t>(&'t mut SqliteConnection); impl<'t> Auth<'t> { - pub async fn for_name(&mut self, name: &Name) -> Result<(History, StoredHash), sqlx::Error> { - let found = sqlx::query!( + pub async fn for_name(&mut self, name: &Name) -> Result<(History, StoredHash), LoadError> { + let name = name.canonical(); + let row = sqlx::query!( r#" - select - id as "id: login::Id", - name as "name: Name", - password_hash as "password_hash: StoredHash", + select + id as "id: login::Id", + display_name as "display_name: String", + canonical_name as "canonical_name: String", created_sequence as "created_sequence: Sequence", - created_at as "created_at: DateTime" - from login - where name = $1 - "#, + created_at as "created_at: DateTime", + password_hash as "password_hash: StoredHash" + from login + where canonical_name = $1 + "#, name, ) - .map(|row| { - ( - History { - login: Login { - id: row.id, - name: row.name, - }, - created: Instant::new(row.created_at, row.created_sequence), - }, - row.password_hash, - ) - }) .fetch_one(&mut *self.0) .await?; - Ok(found) + let login = History { + login: Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, + }, + created: Instant::new(row.created_at, row.created_sequence), + }; + + Ok((login, row.password_hash)) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl NotFound for Result { + type Ok = T; + type Error = LoadError; + + fn optional(self) -> Result, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } } } diff --git a/src/token/repo/mod.rs b/src/token/repo/mod.rs index 9169743..d8463eb 100644 --- a/src/token/repo/mod.rs +++ b/src/token/repo/mod.rs @@ -1,4 +1,4 @@ pub mod auth; mod token; -pub use self::token::Provider; +pub use self::token::{LoadError, Provider}; diff --git a/src/token/repo/token.rs b/src/token/repo/token.rs index 960bb72..35ea385 100644 --- a/src/token/repo/token.rs +++ b/src/token/repo/token.rs @@ -3,7 +3,10 @@ use uuid::Uuid; use crate::{ clock::DateTime, - login::{self, History, Login, Name}, + db::NotFound, + event::{Instant, Sequence}, + login::{self, History, Login}, + name::{self, Name}, token::{Id, Secret}, }; @@ -100,53 +103,78 @@ impl<'c> Tokens<'c> { } // Validate a token by its secret, retrieving the associated Login record. - // Will return [None] if the token is not valid. The token's last-used - // timestamp will be set to `used_at`. + // Will return an error if the token is not valid. If successful, the + // retrieved token's last-used timestamp will be set to `used_at`. pub async fn validate( &mut self, secret: &Secret, used_at: &DateTime, - ) -> Result<(Id, Login), sqlx::Error> { + ) -> Result<(Id, History), LoadError> { // I would use `update … returning` to do this in one query, but // sqlite3, as of this writing, does not allow an update's `returning` // clause to reference columns from tables joined into the update. Two // queries is fine, but it feels untidy. - sqlx::query!( + let (token, login) = sqlx::query!( r#" update token set last_used_at = $1 where secret = $2 + returning + id as "token: Id", + login as "login: login::Id" "#, used_at, secret, ) - .execute(&mut *self.0) + .map(|row| (row.token, row.login)) + .fetch_one(&mut *self.0) .await?; let login = sqlx::query!( r#" select - token.id as "token_id: Id", - login.id as "login_id: login::Id", - login.name as "login_name: Name" + id as "id: login::Id", + display_name as "display_name: String", + canonical_name as "canonical_name: String", + created_sequence as "created_sequence: Sequence", + created_at as "created_at: DateTime" from login - join token on login.id = token.login - where token.secret = $1 + where id = $1 "#, - secret, + login, ) .map(|row| { - ( - row.token_id, - Login { - id: row.login_id, - name: row.login_name, + Ok::<_, name::Error>(History { + login: Login { + id: row.id, + name: Name::new(row.display_name, row.canonical_name)?, }, - ) + created: Instant::new(row.created_at, row.created_sequence), + }) }) .fetch_one(&mut *self.0) - .await?; + .await??; + + Ok((token, login)) + } +} + +#[derive(Debug, thiserror::Error)] +#[error(transparent)] +pub enum LoadError { + Database(#[from] sqlx::Error), + Name(#[from] name::Error), +} + +impl NotFound for Result { + type Ok = T; + type Error = LoadError; - Ok(login) + fn optional(self) -> Result, LoadError> { + match self { + Ok(value) => Ok(Some(value)), + Err(LoadError::Database(sqlx::Error::RowNotFound)) => Ok(None), + Err(other) => Err(other), + } } } -- cgit v1.2.3 From 01f9f3549c76702fd56e58d44c5180fecddb4bfa Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Tue, 22 Oct 2024 23:25:24 -0400 Subject: Sort out the naming of the various parts of an identity. * A `cookie::Identity` (`IdentityCookie`) is a specialized CookieJar for working with identities. * An `Identity` is a token/login pair. I hope for this to be a bit more legible. In service of this, `Login` is no longer extractable. You have to get an identity. --- src/boot/routes/get.rs | 9 ++-- src/boot/routes/test.rs | 6 +-- src/channel/routes/channel/delete.rs | 4 +- src/channel/routes/channel/post.rs | 6 +-- src/channel/routes/channel/test.rs | 52 +++++++++++++++++--- src/channel/routes/post.rs | 4 +- src/channel/routes/test.rs | 6 +-- src/event/routes/test.rs | 26 +++------- src/invite/routes/invite/post.rs | 6 +-- src/invite/routes/post.rs | 10 ++-- src/login/extract.rs | 15 ------ src/login/mod.rs | 1 - src/login/routes/login/post.rs | 6 +-- src/login/routes/login/test.rs | 29 ++++++----- src/login/routes/logout/post.rs | 6 +-- src/login/routes/logout/test.rs | 14 +++--- src/message/app.rs | 2 - src/message/routes/message.rs | 10 ++-- src/setup/routes/post.rs | 6 +-- src/test/fixtures/cookie.rs | 37 ++++++++++++++ src/test/fixtures/identity.rs | 48 ++++++++---------- src/test/fixtures/login.rs | 4 +- src/test/fixtures/mod.rs | 1 + src/token/extract/cookie.rs | 94 ++++++++++++++++++++++++++++++++++++ src/token/extract/identity.rs | 6 +-- src/token/extract/identity_token.rs | 94 ------------------------------------ src/token/extract/mod.rs | 4 +- src/ui/routes/ch/channel.rs | 6 +-- src/ui/routes/get.rs | 6 +-- 29 files changed, 281 insertions(+), 237 deletions(-) delete mode 100644 src/login/extract.rs create mode 100644 src/test/fixtures/cookie.rs create mode 100644 src/token/extract/cookie.rs delete mode 100644 src/token/extract/identity_token.rs (limited to 'src/channel/routes') diff --git a/src/boot/routes/get.rs b/src/boot/routes/get.rs index 737b479..563fbf1 100644 --- a/src/boot/routes/get.rs +++ b/src/boot/routes/get.rs @@ -3,11 +3,14 @@ use axum::{ response::{self, IntoResponse}, }; -use crate::{app::App, boot::Snapshot, error::Internal, login::Login}; +use crate::{app::App, boot::Snapshot, error::Internal, login::Login, token::extract::Identity}; -pub async fn handler(State(app): State, login: Login) -> Result { +pub async fn handler(State(app): State, identity: Identity) -> Result { let snapshot = app.boot().snapshot().await?; - Ok(Response { login, snapshot }) + Ok(Response { + login: identity.login, + snapshot, + }) } #[derive(serde::Serialize)] diff --git a/src/boot/routes/test.rs b/src/boot/routes/test.rs index 4023753..0430854 100644 --- a/src/boot/routes/test.rs +++ b/src/boot/routes/test.rs @@ -6,10 +6,10 @@ use crate::test::fixtures; #[tokio::test] async fn returns_identity() { let app = fixtures::scratch_app().await; - let login = fixtures::login::fictitious(); - let response = get::handler(State(app), login.clone()) + let identity = fixtures::identity::fictitious(); + let response = get::handler(State(app), identity.clone()) .await .expect("boot always succeeds"); - assert_eq!(login, response.login); + assert_eq!(identity.login, response.login); } diff --git a/src/channel/routes/channel/delete.rs b/src/channel/routes/channel/delete.rs index 5f40ddf..91eb506 100644 --- a/src/channel/routes/channel/delete.rs +++ b/src/channel/routes/channel/delete.rs @@ -9,14 +9,14 @@ use crate::{ channel::app, clock::RequestedAt, error::{Internal, NotFound}, - login::Login, + token::extract::Identity, }; pub async fn handler( State(app): State, Path(channel): Path, RequestedAt(deleted_at): RequestedAt, - _: Login, + _: Identity, ) -> Result { app.channels().delete(&channel, &deleted_at).await?; diff --git a/src/channel/routes/channel/post.rs b/src/channel/routes/channel/post.rs index d0cae05..b51e691 100644 --- a/src/channel/routes/channel/post.rs +++ b/src/channel/routes/channel/post.rs @@ -8,20 +8,20 @@ use crate::{ app::App, clock::RequestedAt, error::{Internal, NotFound}, - login::Login, message::{app::SendError, Body, Message}, + token::extract::Identity, }; pub async fn handler( State(app): State, Path(channel): Path, RequestedAt(sent_at): RequestedAt, - login: Login, + identity: Identity, Json(request): Json, ) -> Result { let message = app .messages() - .send(&channel, &login, &sent_at, &request.body) + .send(&channel, &identity.login, &sent_at, &request.body) .await?; Ok(Response(message)) diff --git a/src/channel/routes/channel/test.rs b/src/channel/routes/channel/test.rs index b895b69..9a2227d 100644 --- a/src/channel/routes/channel/test.rs +++ b/src/channel/routes/channel/test.rs @@ -14,7 +14,7 @@ async fn messages_in_order() { // Set up the environment let app = fixtures::scratch_app().await; - let sender = fixtures::login::create(&app, &fixtures::now()).await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; // Call the endpoint (twice) @@ -35,7 +35,7 @@ async fn messages_in_order() { Json(request), ) .await - .expect("sending to a valid channel"); + .expect("sending to a valid channel succeeds"); } // Verify the semantics @@ -44,7 +44,7 @@ async fn messages_in_order() { .events() .subscribe(None) .await - .expect("subscribing to a valid channel") + .expect("subscribing to a valid channel succeeds") .filter_map(fixtures::message::events) .take(requests.len()); @@ -55,7 +55,7 @@ async fn messages_in_order() { assert!(matches!( event, message::Event::Sent(event) - if event.message.sender == sender.id + if event.message.sender == sender.login.id && event.message.body == message )); } @@ -66,7 +66,7 @@ async fn nonexistent_channel() { // Set up the environment let app = fixtures::scratch_app().await; - let login = fixtures::login::create(&app, &fixtures::now()).await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; // Call the endpoint @@ -79,11 +79,49 @@ async fn nonexistent_channel() { State(app), Path(channel.clone()), sent_at, - login, + sender, Json(request), ) .await - .expect_err("sending to a nonexistent channel"); + .expect_err("sending to a nonexistent channel fails"); + + // Verify the structure of the response + + assert!(matches!( + error, + SendError::ChannelNotFound(error_channel) if channel == error_channel + )); +} + +#[tokio::test] +async fn deleted_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + app.channels() + .delete(&channel.id, &fixtures::now()) + .await + .expect("deleting a new channel succeeds"); + + // Call the endpoint + + let sent_at = fixtures::now(); + let channel = channel::Id::generate(); + let request = post::Request { + body: fixtures::message::propose(), + }; + let post::Error(error) = post::handler( + State(app), + Path(channel.clone()), + sent_at, + sender, + Json(request), + ) + .await + .expect_err("sending to a deleted channel fails"); // Verify the structure of the response diff --git a/src/channel/routes/post.rs b/src/channel/routes/post.rs index 9781dd7..810445c 100644 --- a/src/channel/routes/post.rs +++ b/src/channel/routes/post.rs @@ -9,13 +9,13 @@ use crate::{ channel::{app, Channel}, clock::RequestedAt, error::Internal, - login::Login, name::Name, + token::extract::Identity, }; pub async fn handler( State(app): State, - _: Login, // requires auth, but doesn't actually care who you are + _: Identity, // requires auth, but doesn't actually care who you are RequestedAt(created_at): RequestedAt, Json(request): Json, ) -> Result { diff --git a/src/channel/routes/test.rs b/src/channel/routes/test.rs index 7879ba0..77f283b 100644 --- a/src/channel/routes/test.rs +++ b/src/channel/routes/test.rs @@ -14,7 +14,7 @@ async fn new_channel() { // Set up the environment let app = fixtures::scratch_app().await; - let creator = fixtures::login::create(&app, &fixtures::now()).await; + let creator = fixtures::identity::create(&app, &fixtures::now()).await; // Call the endpoint @@ -65,7 +65,7 @@ async fn duplicate_name() { // Set up the environment let app = fixtures::scratch_app().await; - let creator = fixtures::login::create(&app, &fixtures::now()).await; + let creator = fixtures::identity::create(&app, &fixtures::now()).await; let channel = fixtures::channel::create(&app, &fixtures::now()).await; // Call the endpoint @@ -91,7 +91,7 @@ async fn name_reusable_after_delete() { // Set up the environment let app = fixtures::scratch_app().await; - let creator = fixtures::login::create(&app, &fixtures::now()).await; + let creator = fixtures::identity::create(&app, &fixtures::now()).await; let name = fixtures::channel::propose(); // Call the endpoint (first time) diff --git a/src/event/routes/test.rs b/src/event/routes/test.rs index e6a8b9d..49f8094 100644 --- a/src/event/routes/test.rs +++ b/src/event/routes/test.rs @@ -22,8 +22,7 @@ async fn includes_historical_message() { // Call the endpoint - let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; - let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::now()).await; + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; let get::Response(events) = get::handler(State(app), subscriber, None, Query::default()) .await .expect("subscribe never fails"); @@ -49,8 +48,7 @@ async fn includes_live_message() { // Call the endpoint - let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; - let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::now()).await; + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; let get::Response(events) = get::handler(State(app.clone()), subscriber, None, Query::default()) .await @@ -95,8 +93,7 @@ async fn includes_multiple_channels() { // Call the endpoint - let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; - let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::now()).await; + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; let get::Response(events) = get::handler(State(app), subscriber, None, Query::default()) .await .expect("subscribe never fails"); @@ -133,8 +130,7 @@ async fn sequential_messages() { // Call the endpoint - let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; - let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::now()).await; + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; let get::Response(events) = get::handler(State(app), subscriber, None, Query::default()) .await .expect("subscribe never fails"); @@ -180,8 +176,7 @@ async fn resumes_from() { // Call the endpoint - let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; - let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::now()).await; + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; let resume_at = { // First subscription @@ -258,8 +253,7 @@ async fn serial_resume() { // Call the endpoint - let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; - let subscriber = fixtures::identity::identity(&app, &subscriber_creds, &fixtures::now()).await; + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; let resume_at = { let initial_messages = [ @@ -382,7 +376,7 @@ async fn terminates_on_token_expiry() { let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; let subscriber = - fixtures::identity::identity(&app, &subscriber_creds, &fixtures::ancient()).await; + fixtures::identity::logged_in(&app, &subscriber_creds, &fixtures::ancient()).await; let get::Response(events) = get::handler(State(app.clone()), subscriber, None, Query::default()) @@ -426,11 +420,7 @@ async fn terminates_on_logout() { // Subscribe via the endpoint - let subscriber_creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; - let subscriber_token = - fixtures::identity::logged_in(&app, &subscriber_creds, &fixtures::now()).await; - let subscriber = - fixtures::identity::from_token(&app, &subscriber_token, &fixtures::now()).await; + let subscriber = fixtures::identity::create(&app, &fixtures::now()).await; let get::Response(events) = get::handler( State(app.clone()), diff --git a/src/invite/routes/invite/post.rs b/src/invite/routes/invite/post.rs index a41207a..3ca4e6b 100644 --- a/src/invite/routes/invite/post.rs +++ b/src/invite/routes/invite/post.rs @@ -11,16 +11,16 @@ use crate::{ invite::app, login::{Login, Password}, name::Name, - token::extract::IdentityToken, + token::extract::IdentityCookie, }; pub async fn handler( State(app): State, RequestedAt(accepted_at): RequestedAt, - identity: IdentityToken, + identity: IdentityCookie, Path(invite): Path, Json(request): Json, -) -> Result<(IdentityToken, Json), Error> { +) -> Result<(IdentityCookie, Json), Error> { let (login, secret) = app .invites() .accept(&invite, &request.name, &request.password, &accepted_at) diff --git a/src/invite/routes/post.rs b/src/invite/routes/post.rs index 80b1c27..eb7d706 100644 --- a/src/invite/routes/post.rs +++ b/src/invite/routes/post.rs @@ -1,17 +1,19 @@ use axum::extract::{Json, State}; -use crate::{app::App, clock::RequestedAt, error::Internal, invite::Invite, login::Login}; +use crate::{ + app::App, clock::RequestedAt, error::Internal, invite::Invite, token::extract::Identity, +}; pub async fn handler( State(app): State, RequestedAt(issued_at): RequestedAt, - login: Login, - // Require `{}` as the only valid request for this endpoint. + identity: Identity, _: Json, ) -> Result, Internal> { - let invite = app.invites().create(&login, &issued_at).await?; + let invite = app.invites().create(&identity.login, &issued_at).await?; Ok(Json(invite)) } +// Require `{}` as the only valid request for this endpoint. #[derive(Default, serde::Deserialize)] pub struct Request {} diff --git a/src/login/extract.rs b/src/login/extract.rs deleted file mode 100644 index c2d97f2..0000000 --- a/src/login/extract.rs +++ /dev/null @@ -1,15 +0,0 @@ -use axum::{extract::FromRequestParts, http::request::Parts}; - -use super::Login; -use crate::{app::App, token::extract::Identity}; - -#[async_trait::async_trait] -impl FromRequestParts for Login { - type Rejection = >::Rejection; - - async fn from_request_parts(parts: &mut Parts, state: &App) -> Result { - let identity = Identity::from_request_parts(parts, state).await?; - - Ok(identity.login) - } -} diff --git a/src/login/mod.rs b/src/login/mod.rs index 64a3698..279e9a6 100644 --- a/src/login/mod.rs +++ b/src/login/mod.rs @@ -1,6 +1,5 @@ pub mod app; pub mod event; -pub mod extract; mod history; mod id; pub mod password; diff --git a/src/login/routes/login/post.rs b/src/login/routes/login/post.rs index 20430db..96da5c5 100644 --- a/src/login/routes/login/post.rs +++ b/src/login/routes/login/post.rs @@ -10,15 +10,15 @@ use crate::{ error::Internal, login::{Login, Password}, name::Name, - token::{app, extract::IdentityToken}, + token::{app, extract::IdentityCookie}, }; pub async fn handler( State(app): State, RequestedAt(now): RequestedAt, - identity: IdentityToken, + identity: IdentityCookie, Json(request): Json, -) -> Result<(IdentityToken, Json), Error> { +) -> Result<(IdentityCookie, Json), Error> { let (login, secret) = app .tokens() .login(&request.name, &request.password, &now) diff --git a/src/login/routes/login/test.rs b/src/login/routes/login/test.rs index c94f14c..7399796 100644 --- a/src/login/routes/login/test.rs +++ b/src/login/routes/login/test.rs @@ -8,14 +8,14 @@ async fn correct_credentials() { // Set up the environment let app = fixtures::scratch_app().await; - let (login, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; + let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; // Call the endpoint - let identity = fixtures::identity::not_logged_in(); + let identity = fixtures::cookie::not_logged_in(); let logged_in_at = fixtures::now(); let request = post::Request { - name: login.name.clone(), + name: name.clone(), password, }; let (identity, Json(response)) = @@ -25,8 +25,10 @@ async fn correct_credentials() { // Verify the return value's basic structure - assert_eq!(login, response); - let secret = identity.secret().expect("logged in with valid credentials"); + assert_eq!(name, response.name); + let secret = identity + .secret() + .expect("logged in with valid credentials issues an identity cookie"); // Verify the semantics @@ -37,7 +39,7 @@ async fn correct_credentials() { .await .expect("identity secret is valid"); - assert_eq!(login, validated_login); + assert_eq!(response, validated_login); } #[tokio::test] @@ -48,7 +50,7 @@ async fn invalid_name() { // Call the endpoint - let identity = fixtures::identity::not_logged_in(); + let identity = fixtures::cookie::not_logged_in(); let logged_in_at = fixtures::now(); let (name, password) = fixtures::login::propose(); let request = post::Request { @@ -58,7 +60,7 @@ async fn invalid_name() { let post::Error(error) = post::handler(State(app.clone()), logged_in_at, identity, Json(request)) .await - .expect_err("logged in with an incorrect password"); + .expect_err("logged in with an incorrect password fails"); // Verify the return value's basic structure @@ -75,7 +77,7 @@ async fn incorrect_password() { // Call the endpoint let logged_in_at = fixtures::now(); - let identity = fixtures::identity::not_logged_in(); + let identity = fixtures::cookie::not_logged_in(); let request = post::Request { name: login.name, password: fixtures::login::propose_password(), @@ -95,16 +97,13 @@ async fn token_expires() { // Set up the environment let app = fixtures::scratch_app().await; - let (login, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; + let (name, password) = fixtures::login::create_with_password(&app, &fixtures::now()).await; // Call the endpoint let logged_in_at = fixtures::ancient(); - let identity = fixtures::identity::not_logged_in(); - let request = post::Request { - name: login.name.clone(), - password, - }; + let identity = fixtures::cookie::not_logged_in(); + let request = post::Request { name, password }; let (identity, _) = post::handler(State(app.clone()), logged_in_at, identity, Json(request)) .await .expect("logged in with valid credentials"); diff --git a/src/login/routes/logout/post.rs b/src/login/routes/logout/post.rs index 6b7a62a..bb09b9f 100644 --- a/src/login/routes/logout/post.rs +++ b/src/login/routes/logout/post.rs @@ -8,15 +8,15 @@ use crate::{ app::App, clock::RequestedAt, error::{Internal, Unauthorized}, - token::{app, extract::IdentityToken}, + token::{app, extract::IdentityCookie}, }; pub async fn handler( State(app): State, RequestedAt(now): RequestedAt, - identity: IdentityToken, + identity: IdentityCookie, Json(_): Json, -) -> Result<(IdentityToken, StatusCode), Error> { +) -> Result<(IdentityCookie, StatusCode), Error> { if let Some(secret) = identity.secret() { let (token, _) = app.tokens().validate(&secret, &now).await?; app.tokens().logout(&token).await?; diff --git a/src/login/routes/logout/test.rs b/src/login/routes/logout/test.rs index 91837fe..775fa9f 100644 --- a/src/login/routes/logout/test.rs +++ b/src/login/routes/logout/test.rs @@ -12,9 +12,9 @@ async fn successful() { let app = fixtures::scratch_app().await; let now = fixtures::now(); - let login = fixtures::login::create_with_password(&app, &fixtures::now()).await; - let identity = fixtures::identity::logged_in(&app, &login, &now).await; - let secret = fixtures::identity::secret(&identity); + let creds = fixtures::login::create_with_password(&app, &fixtures::now()).await; + let identity = fixtures::cookie::logged_in(&app, &creds, &now).await; + let secret = fixtures::cookie::secret(&identity); // Call the endpoint @@ -49,10 +49,10 @@ async fn no_identity() { // Call the endpoint - let identity = fixtures::identity::not_logged_in(); + let identity = fixtures::cookie::not_logged_in(); let (identity, status) = post::handler(State(app), fixtures::now(), identity, Json::default()) .await - .expect("logged out with no token"); + .expect("logged out with no token succeeds"); // Verify the return value's basic structure @@ -68,10 +68,10 @@ async fn invalid_token() { // Call the endpoint - let identity = fixtures::identity::fictitious(); + let identity = fixtures::cookie::fictitious(); let post::Error(error) = post::handler(State(app), fixtures::now(), identity, Json::default()) .await - .expect_err("logged out with an invalid token"); + .expect_err("logged out with an invalid token fails"); // Verify the return value's basic structure diff --git a/src/message/app.rs b/src/message/app.rs index 852b958..eed6ba4 100644 --- a/src/message/app.rs +++ b/src/message/app.rs @@ -136,8 +136,6 @@ impl From for SendError { #[derive(Debug, thiserror::Error)] pub enum DeleteError { - #[error("channel {0} not found")] - ChannelNotFound(channel::Id), #[error("message {0} not found")] NotFound(Id), #[error("message {0} deleted")] diff --git a/src/message/routes/message.rs b/src/message/routes/message.rs index fbef35a..f83cb39 100644 --- a/src/message/routes/message.rs +++ b/src/message/routes/message.rs @@ -9,15 +9,15 @@ pub mod delete { app::App, clock::RequestedAt, error::{Internal, NotFound}, - login::Login, message::{self, app::DeleteError}, + token::extract::Identity, }; pub async fn handler( State(app): State, Path(message): Path, RequestedAt(deleted_at): RequestedAt, - _: Login, + _: Identity, ) -> Result { app.messages().delete(&message, &deleted_at).await?; @@ -33,9 +33,9 @@ pub mod delete { let Self(error) = self; #[allow(clippy::match_wildcard_for_single_variants)] match error { - DeleteError::ChannelNotFound(_) - | DeleteError::NotFound(_) - | DeleteError::Deleted(_) => NotFound(error).into_response(), + DeleteError::NotFound(_) | DeleteError::Deleted(_) => { + NotFound(error).into_response() + } other => Internal::from(other).into_response(), } } diff --git a/src/setup/routes/post.rs b/src/setup/routes/post.rs index fb2280a..f7b256e 100644 --- a/src/setup/routes/post.rs +++ b/src/setup/routes/post.rs @@ -11,15 +11,15 @@ use crate::{ login::{Login, Password}, name::Name, setup::app, - token::extract::IdentityToken, + token::extract::IdentityCookie, }; pub async fn handler( State(app): State, RequestedAt(setup_at): RequestedAt, - identity: IdentityToken, + identity: IdentityCookie, Json(request): Json, -) -> Result<(IdentityToken, Json), Error> { +) -> Result<(IdentityCookie, Json), Error> { let (login, secret) = app .setup() .initial(&request.name, &request.password, &setup_at) diff --git a/src/test/fixtures/cookie.rs b/src/test/fixtures/cookie.rs new file mode 100644 index 0000000..58777c8 --- /dev/null +++ b/src/test/fixtures/cookie.rs @@ -0,0 +1,37 @@ +use uuid::Uuid; + +use crate::{ + app::App, + clock::RequestedAt, + login::Password, + name::Name, + token::{extract::IdentityCookie, Secret}, +}; + +pub fn not_logged_in() -> IdentityCookie { + IdentityCookie::new() +} + +pub async fn logged_in( + app: &App, + credentials: &(Name, Password), + now: &RequestedAt, +) -> IdentityCookie { + let (name, password) = credentials; + let (_, token) = app + .tokens() + .login(name, password, now) + .await + .expect("should succeed given known-valid credentials"); + + IdentityCookie::new().set(token) +} + +pub fn secret(identity: &IdentityCookie) -> Secret { + identity.secret().expect("identity contained a secret") +} + +pub fn fictitious() -> IdentityCookie { + let token = Uuid::new_v4().to_string(); + IdentityCookie::new().set(token) +} diff --git a/src/test/fixtures/identity.rs b/src/test/fixtures/identity.rs index c434473..e438f2b 100644 --- a/src/test/fixtures/identity.rs +++ b/src/test/fixtures/identity.rs @@ -1,31 +1,21 @@ -use uuid::Uuid; - use crate::{ app::App, clock::RequestedAt, - login::{Login, Password}, + login::Password, + name::Name, + test::fixtures, token::{ - extract::{Identity, IdentityToken}, - Secret, + self, + extract::{Identity, IdentityCookie}, }, }; -pub fn not_logged_in() -> IdentityToken { - IdentityToken::new() -} - -pub async fn logged_in(app: &App, login: &(Login, Password), now: &RequestedAt) -> IdentityToken { - let (login, password) = login; - let (_, token) = app - .tokens() - .login(&login.name, password, now) - .await - .expect("should succeed given known-valid credentials"); - - IdentityToken::new().set(token) +pub async fn create(app: &App, created_at: &RequestedAt) -> Identity { + let credentials = fixtures::login::create_with_password(app, created_at).await; + logged_in(app, &credentials, created_at).await } -pub async fn from_token(app: &App, token: &IdentityToken, issued_at: &RequestedAt) -> Identity { +pub async fn from_cookie(app: &App, token: &IdentityCookie, issued_at: &RequestedAt) -> Identity { let secret = token.secret().expect("identity token has a secret"); let (token, login) = app .tokens() @@ -36,16 +26,18 @@ pub async fn from_token(app: &App, token: &IdentityToken, issued_at: &RequestedA Identity { token, login } } -pub async fn identity(app: &App, login: &(Login, Password), issued_at: &RequestedAt) -> Identity { - let secret = logged_in(app, login, issued_at).await; - from_token(app, &secret, issued_at).await +pub async fn logged_in( + app: &App, + credentials: &(Name, Password), + issued_at: &RequestedAt, +) -> Identity { + let secret = fixtures::cookie::logged_in(app, credentials, issued_at).await; + from_cookie(app, &secret, issued_at).await } -pub fn secret(identity: &IdentityToken) -> Secret { - identity.secret().expect("identity contained a secret") -} +pub fn fictitious() -> Identity { + let token = token::Id::generate(); + let login = fixtures::login::fictitious(); -pub fn fictitious() -> IdentityToken { - let token = Uuid::new_v4().to_string(); - IdentityToken::new().set(token) + Identity { token, login } } diff --git a/src/test/fixtures/login.rs b/src/test/fixtures/login.rs index 714b936..e308289 100644 --- a/src/test/fixtures/login.rs +++ b/src/test/fixtures/login.rs @@ -8,7 +8,7 @@ use crate::{ name::Name, }; -pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Login, Password) { +pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Name, Password) { let (name, password) = propose(); let login = app .logins() @@ -16,7 +16,7 @@ pub async fn create_with_password(app: &App, created_at: &RequestedAt) -> (Login .await .expect("should always succeed if the login is actually new"); - (login, password) + (login.name, password) } pub async fn create(app: &App, created_at: &RequestedAt) -> Login { diff --git a/src/test/fixtures/mod.rs b/src/test/fixtures/mod.rs index 9658831..5609ebc 100644 --- a/src/test/fixtures/mod.rs +++ b/src/test/fixtures/mod.rs @@ -3,6 +3,7 @@ use chrono::{TimeDelta, Utc}; use crate::{app::App, clock::RequestedAt, db}; pub mod channel; +pub mod cookie; pub mod event; pub mod future; pub mod identity; diff --git a/src/token/extract/cookie.rs b/src/token/extract/cookie.rs new file mode 100644 index 0000000..af5787d --- /dev/null +++ b/src/token/extract/cookie.rs @@ -0,0 +1,94 @@ +use std::fmt; + +use axum::{ + extract::FromRequestParts, + http::request::Parts, + response::{IntoResponseParts, ResponseParts}, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar}; + +use crate::token::Secret; + +// The usage pattern here - receive the extractor as an argument, return it in +// the response - is heavily modelled after CookieJar's own intended usage. +#[derive(Clone)] +pub struct Identity { + cookies: CookieJar, +} + +impl fmt::Debug for Identity { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("IdentityCookie") + .field("identity", &self.secret()) + .finish() + } +} + +impl Identity { + const COOKIE_NAME: &str = "identity"; + + // Creates a new, unpopulated identity token store. + #[cfg(test)] + pub fn new() -> Self { + Self { + cookies: CookieJar::new(), + } + } + + // Get the identity secret sent in the request, if any. If the identity + // was not sent, or if it has previously been [clear]ed, then this will + // return [None]. If the identity has previously been [set], then this + // will return that secret, regardless of what the request originally + // included. + pub fn secret(&self) -> Option { + self.cookies + .get(Self::COOKIE_NAME) + .map(Cookie::value) + .map(Secret::from) + } + + // Positively set the identity secret, and ensure that it will be sent + // back to the client when this extractor is included in a response. + pub fn set(self, secret: impl Into) -> Self { + let secret = secret.into().reveal(); + let identity_cookie = Cookie::build((Self::COOKIE_NAME, secret)) + .http_only(true) + .path("/") + .permanent() + .build(); + + Self { + cookies: self.cookies.add(identity_cookie), + } + } + + // Remove the identity secret and ensure that it will be cleared when this + // extractor is included in a response. + pub fn clear(self) -> Self { + Self { + cookies: self.cookies.remove(Self::COOKIE_NAME), + } + } +} + +#[async_trait::async_trait] +impl FromRequestParts for Identity +where + S: Send + Sync, +{ + type Rejection = >::Rejection; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + let cookies = CookieJar::from_request_parts(parts, state).await?; + Ok(Self { cookies }) + } +} + +impl IntoResponseParts for Identity { + type Error = ::Error; + + fn into_response_parts(self, res: ResponseParts) -> Result { + let Self { cookies } = self; + cookies.into_response_parts(res) + } +} diff --git a/src/token/extract/identity.rs b/src/token/extract/identity.rs index 8b3cd94..a69f509 100644 --- a/src/token/extract/identity.rs +++ b/src/token/extract/identity.rs @@ -4,7 +4,7 @@ use axum::{ response::{IntoResponse, Response}, }; -use super::IdentityToken; +use super::IdentityCookie; use crate::{ app::App, @@ -25,10 +25,10 @@ impl FromRequestParts for Identity { type Rejection = LoginError; async fn from_request_parts(parts: &mut Parts, state: &App) -> Result { - let Ok(identity_token) = IdentityToken::from_request_parts(parts, state).await; + let Ok(cookie) = IdentityCookie::from_request_parts(parts, state).await; let RequestedAt(used_at) = RequestedAt::from_request_parts(parts, state).await?; - let secret = identity_token.secret().ok_or(LoginError::Unauthorized)?; + let secret = cookie.secret().ok_or(LoginError::Unauthorized)?; let app = State::::from_request_parts(parts, state).await?; match app.tokens().validate(&secret, &used_at).await { diff --git a/src/token/extract/identity_token.rs b/src/token/extract/identity_token.rs deleted file mode 100644 index a1e900e..0000000 --- a/src/token/extract/identity_token.rs +++ /dev/null @@ -1,94 +0,0 @@ -use std::fmt; - -use axum::{ - extract::FromRequestParts, - http::request::Parts, - response::{IntoResponseParts, ResponseParts}, -}; -use axum_extra::extract::cookie::{Cookie, CookieJar}; - -use crate::token::Secret; - -// The usage pattern here - receive the extractor as an argument, return it in -// the response - is heavily modelled after CookieJar's own intended usage. -#[derive(Clone)] -pub struct IdentityToken { - cookies: CookieJar, -} - -impl fmt::Debug for IdentityToken { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.debug_struct("IdentityToken") - .field("identity", &self.secret()) - .finish() - } -} - -impl IdentityToken { - // Creates a new, unpopulated identity token store. - #[cfg(test)] - pub fn new() -> Self { - Self { - cookies: CookieJar::new(), - } - } - - // Get the identity secret sent in the request, if any. If the identity - // was not sent, or if it has previously been [clear]ed, then this will - // return [None]. If the identity has previously been [set], then this - // will return that secret, regardless of what the request originally - // included. - pub fn secret(&self) -> Option { - self.cookies - .get(IDENTITY_COOKIE) - .map(Cookie::value) - .map(Secret::from) - } - - // Positively set the identity secret, and ensure that it will be sent - // back to the client when this extractor is included in a response. - pub fn set(self, secret: impl Into) -> Self { - let secret = secret.into().reveal(); - let identity_cookie = Cookie::build((IDENTITY_COOKIE, secret)) - .http_only(true) - .path("/") - .permanent() - .build(); - - Self { - cookies: self.cookies.add(identity_cookie), - } - } - - // Remove the identity secret and ensure that it will be cleared when this - // extractor is included in a response. - pub fn clear(self) -> Self { - Self { - cookies: self.cookies.remove(IDENTITY_COOKIE), - } - } -} - -const IDENTITY_COOKIE: &str = "identity"; - -#[async_trait::async_trait] -impl FromRequestParts for IdentityToken -where - S: Send + Sync, -{ - type Rejection = >::Rejection; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let cookies = CookieJar::from_request_parts(parts, state).await?; - Ok(Self { cookies }) - } -} - -impl IntoResponseParts for IdentityToken { - type Error = ::Error; - - fn into_response_parts(self, res: ResponseParts) -> Result { - let Self { cookies } = self; - cookies.into_response_parts(res) - } -} diff --git a/src/token/extract/mod.rs b/src/token/extract/mod.rs index b4800ae..fc0f52b 100644 --- a/src/token/extract/mod.rs +++ b/src/token/extract/mod.rs @@ -1,4 +1,4 @@ +mod cookie; mod identity; -mod identity_token; -pub use self::{identity::Identity, identity_token::IdentityToken}; +pub use self::{cookie::Identity as IdentityCookie, identity::Identity}; diff --git a/src/ui/routes/ch/channel.rs b/src/ui/routes/ch/channel.rs index 353d000..a338f1f 100644 --- a/src/ui/routes/ch/channel.rs +++ b/src/ui/routes/ch/channel.rs @@ -8,7 +8,7 @@ pub mod get { app::App, channel, error::Internal, - login::Login, + token::extract::Identity, ui::{ assets::{Asset, Assets}, error::NotFound, @@ -17,10 +17,10 @@ pub mod get { pub async fn handler( State(app): State, - login: Option, + identity: Option, Path(channel): Path, ) -> Result { - login.ok_or(Error::NotLoggedIn)?; + let _ = identity.ok_or(Error::NotLoggedIn)?; app.channels() .get(&channel) .await diff --git a/src/ui/routes/get.rs b/src/ui/routes/get.rs index 97737e1..2fcb51c 100644 --- a/src/ui/routes/get.rs +++ b/src/ui/routes/get.rs @@ -2,12 +2,12 @@ use axum::response::{self, IntoResponse, Redirect}; use crate::{ error::Internal, - login::Login, + token::extract::Identity, ui::assets::{Asset, Assets}, }; -pub async fn handler(login: Option) -> Result { - login.ok_or(Error::NotLoggedIn)?; +pub async fn handler(identity: Option) -> Result { + let _ = identity.ok_or(Error::NotLoggedIn)?; Assets::index().map_err(Error::Internal) } -- cgit v1.2.3 From 08a2f47bcdd774a1e52ca77dc2377c8ca5f8a5f0 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Wed, 23 Oct 2024 01:21:59 -0400 Subject: Channel creation tests for expiry, conflicting names --- src/channel/routes/test.rs | 98 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 17 deletions(-) (limited to 'src/channel/routes') diff --git a/src/channel/routes/test.rs b/src/channel/routes/test.rs index 77f283b..216eba1 100644 --- a/src/channel/routes/test.rs +++ b/src/channel/routes/test.rs @@ -6,6 +6,7 @@ use futures::stream::StreamExt as _; use super::post; use crate::{ channel::app, + name::Name, test::fixtures::{self, future::Immediately as _}, }; @@ -86,6 +87,39 @@ async fn duplicate_name() { )); } +#[tokio::test] +async fn conflicting_canonical_name() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let creator = fixtures::identity::create(&app, &fixtures::now()).await; + + let existing_name = Name::from("rijksmuseum"); + app.channels() + .create(&existing_name, &fixtures::now()) + .await + .expect("creating a channel in an empty environment succeeds"); + + let conflicting_name = Name::from("r\u{0133}ksmuseum"); + + // Call the endpoint + + let request = post::Request { + name: conflicting_name.clone(), + }; + let post::Error(error) = + post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect_err("duplicate channel name should fail the request"); + + // Verify the structure of the response + + assert!(matches!( + error, + app::CreateError::DuplicateName(name) if conflicting_name == name + )); +} + #[tokio::test] async fn name_reusable_after_delete() { // Set up the environment @@ -119,7 +153,7 @@ async fn name_reusable_after_delete() { let post::Response(response) = post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) .await - .expect("new channel in an empty app"); + .expect("creation succeeds after original channel deleted"); // Verify the structure of the response @@ -127,9 +161,6 @@ async fn name_reusable_after_delete() { // Verify the semantics - let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); - assert!(snapshot.channels.iter().any(|channel| channel == &response)); - let channel = app .channels() .get(&response.id) @@ -137,21 +168,54 @@ async fn name_reusable_after_delete() { .expect("searching for channels by ID never fails") .expect("the newly-created channel exists"); assert_eq!(response, channel); +} - let mut events = app - .events() - .subscribe(None) - .await - .expect("subscribing never fails") - .filter_map(fixtures::channel::events) - .filter_map(fixtures::channel::created) - .filter(|event| future::ready(event.channel == response)); +#[tokio::test] +async fn name_reusable_after_expiry() { + // Set up the environment - let event = events - .next() - .immediately() + let app = fixtures::scratch_app().await; + let creator = fixtures::identity::create(&app, &fixtures::ancient()).await; + let name = fixtures::channel::propose(); + + // Call the endpoint (first time) + + let request = post::Request { name: name.clone() }; + let post::Response(_) = post::handler( + State(app.clone()), + creator.clone(), + fixtures::ancient(), + Json(request), + ) + .await + .expect("new channel in an empty app"); + + // Delete the channel + + app.channels() + .expire(&fixtures::now()) .await - .expect("creation event published"); + .expect("expiry always succeeds"); - assert_eq!(event.channel, response); + // Call the endpoint (second time) + + let request = post::Request { name: name.clone() }; + let post::Response(response) = + post::handler(State(app.clone()), creator, fixtures::now(), Json(request)) + .await + .expect("creation succeeds after original channel expired"); + + // Verify the structure of the response + + assert_eq!(name, response.name); + + // Verify the semantics + + let channel = app + .channels() + .get(&response.id) + .await + .expect("searching for channels by ID never fails") + .expect("the newly-created channel exists"); + assert_eq!(response, channel); } -- cgit v1.2.3 From 461814e5174cef1be3e07b4e4069314e9bcbedd6 Mon Sep 17 00:00:00 2001 From: Owen Jacobson Date: Wed, 23 Oct 2024 18:25:56 -0400 Subject: Tests for channel delete endpoint --- src/channel/routes/channel/test.rs | 132 ------------------------- src/channel/routes/channel/test/delete.rs | 154 ++++++++++++++++++++++++++++++ src/channel/routes/channel/test/mod.rs | 2 + src/channel/routes/channel/test/post.rs | 131 +++++++++++++++++++++++++ src/test/fixtures/channel.rs | 4 + 5 files changed, 291 insertions(+), 132 deletions(-) delete mode 100644 src/channel/routes/channel/test.rs create mode 100644 src/channel/routes/channel/test/delete.rs create mode 100644 src/channel/routes/channel/test/mod.rs create mode 100644 src/channel/routes/channel/test/post.rs (limited to 'src/channel/routes') diff --git a/src/channel/routes/channel/test.rs b/src/channel/routes/channel/test.rs deleted file mode 100644 index 9a2227d..0000000 --- a/src/channel/routes/channel/test.rs +++ /dev/null @@ -1,132 +0,0 @@ -use axum::extract::{Json, Path, State}; -use futures::stream::StreamExt; - -use super::post; -use crate::{ - channel, - event::Sequenced, - message::{self, app::SendError}, - test::fixtures::{self, future::Immediately as _}, -}; - -#[tokio::test] -async fn messages_in_order() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - // Call the endpoint (twice) - - let requests = vec![ - (fixtures::now(), fixtures::message::propose()), - (fixtures::now(), fixtures::message::propose()), - ]; - - for (sent_at, body) in &requests { - let request = post::Request { body: body.clone() }; - - let _ = post::handler( - State(app.clone()), - Path(channel.id.clone()), - sent_at.clone(), - sender.clone(), - Json(request), - ) - .await - .expect("sending to a valid channel succeeds"); - } - - // Verify the semantics - - let events = app - .events() - .subscribe(None) - .await - .expect("subscribing to a valid channel succeeds") - .filter_map(fixtures::message::events) - .take(requests.len()); - - let events = events.collect::>().immediately().await; - - for ((sent_at, message), event) in requests.into_iter().zip(events) { - assert_eq!(*sent_at, event.at()); - assert!(matches!( - event, - message::Event::Sent(event) - if event.message.sender == sender.login.id - && event.message.body == message - )); - } -} - -#[tokio::test] -async fn nonexistent_channel() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::identity::create(&app, &fixtures::now()).await; - - // Call the endpoint - - let sent_at = fixtures::now(); - let channel = channel::Id::generate(); - let request = post::Request { - body: fixtures::message::propose(), - }; - let post::Error(error) = post::handler( - State(app), - Path(channel.clone()), - sent_at, - sender, - Json(request), - ) - .await - .expect_err("sending to a nonexistent channel fails"); - - // Verify the structure of the response - - assert!(matches!( - error, - SendError::ChannelNotFound(error_channel) if channel == error_channel - )); -} - -#[tokio::test] -async fn deleted_channel() { - // Set up the environment - - let app = fixtures::scratch_app().await; - let sender = fixtures::identity::create(&app, &fixtures::now()).await; - let channel = fixtures::channel::create(&app, &fixtures::now()).await; - - app.channels() - .delete(&channel.id, &fixtures::now()) - .await - .expect("deleting a new channel succeeds"); - - // Call the endpoint - - let sent_at = fixtures::now(); - let channel = channel::Id::generate(); - let request = post::Request { - body: fixtures::message::propose(), - }; - let post::Error(error) = post::handler( - State(app), - Path(channel.clone()), - sent_at, - sender, - Json(request), - ) - .await - .expect_err("sending to a deleted channel fails"); - - // Verify the structure of the response - - assert!(matches!( - error, - SendError::ChannelNotFound(error_channel) if channel == error_channel - )); -} diff --git a/src/channel/routes/channel/test/delete.rs b/src/channel/routes/channel/test/delete.rs new file mode 100644 index 0000000..e9af12f --- /dev/null +++ b/src/channel/routes/channel/test/delete.rs @@ -0,0 +1,154 @@ +use axum::{ + extract::{Path, State}, + http::StatusCode, +}; + +use crate::{ + channel::{app, routes::channel::delete}, + test::fixtures, +}; + +#[tokio::test] +pub async fn delete_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let response = delete::handler( + State(app.clone()), + Path(channel.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect("deleting a valid channel succeeds"); + + // Verify the response + + assert_eq!(response, StatusCode::ACCEPTED); + + // Verify the semantics + + let snapshot = app.boot().snapshot().await.expect("boot always succeeds"); + assert!(!snapshot.channels.contains(&channel)); +} + +#[tokio::test] +pub async fn delete_invalid_channel_id() { + // Set up the environment + + let app = fixtures::scratch_app().await; + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::fictitious(); + let delete::Error(error) = delete::handler( + State(app.clone()), + Path(channel.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting a nonexistent channel fails"); + + // Verify the response + + assert!(matches!(error, app::Error::NotFound(id) if id == channel)); +} + +#[tokio::test] +pub async fn delete_deleted() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + app.channels() + .delete(&channel.id, &fixtures::now()) + .await + .expect("deleting a recently-sent channel succeeds"); + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let delete::Error(error) = delete::handler( + State(app.clone()), + Path(channel.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting a deleted channel fails"); + + // Verify the response + + assert!(matches!(error, app::Error::Deleted(id) if id == channel.id)); +} + +#[tokio::test] +pub async fn delete_expired() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + + app.channels() + .expire(&fixtures::now()) + .await + .expect("expiring channels always succeeds"); + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let delete::Error(error) = delete::handler( + State(app.clone()), + Path(channel.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting an expired channel fails"); + + // Verify the response + + assert!(matches!(error, app::Error::Deleted(id) if id == channel.id)); +} + +#[tokio::test] +pub async fn delete_purged() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let channel = fixtures::channel::create(&app, &fixtures::ancient()).await; + + app.channels() + .expire(&fixtures::old()) + .await + .expect("expiring channels always succeeds"); + + app.channels() + .purge(&fixtures::now()) + .await + .expect("purging channels always succeeds"); + + // Send the request + + let deleter = fixtures::identity::create(&app, &fixtures::now()).await; + let delete::Error(error) = delete::handler( + State(app.clone()), + Path(channel.id.clone()), + fixtures::now(), + deleter, + ) + .await + .expect_err("deleting a purged channel fails"); + + // Verify the response + + assert!(matches!(error, app::Error::NotFound(id) if id == channel.id)); +} diff --git a/src/channel/routes/channel/test/mod.rs b/src/channel/routes/channel/test/mod.rs new file mode 100644 index 0000000..78bf86e --- /dev/null +++ b/src/channel/routes/channel/test/mod.rs @@ -0,0 +1,2 @@ +mod delete; +mod post; diff --git a/src/channel/routes/channel/test/post.rs b/src/channel/routes/channel/test/post.rs new file mode 100644 index 0000000..67e7d36 --- /dev/null +++ b/src/channel/routes/channel/test/post.rs @@ -0,0 +1,131 @@ +use axum::extract::{Json, Path, State}; +use futures::stream::StreamExt; + +use crate::{ + channel::{self, routes::channel::post}, + event::Sequenced, + message::{self, app::SendError}, + test::fixtures::{self, future::Immediately as _}, +}; + +#[tokio::test] +async fn messages_in_order() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + // Call the endpoint (twice) + + let requests = vec![ + (fixtures::now(), fixtures::message::propose()), + (fixtures::now(), fixtures::message::propose()), + ]; + + for (sent_at, body) in &requests { + let request = post::Request { body: body.clone() }; + + let _ = post::handler( + State(app.clone()), + Path(channel.id.clone()), + sent_at.clone(), + sender.clone(), + Json(request), + ) + .await + .expect("sending to a valid channel succeeds"); + } + + // Verify the semantics + + let events = app + .events() + .subscribe(None) + .await + .expect("subscribing to a valid channel succeeds") + .filter_map(fixtures::message::events) + .take(requests.len()); + + let events = events.collect::>().immediately().await; + + for ((sent_at, message), event) in requests.into_iter().zip(events) { + assert_eq!(*sent_at, event.at()); + assert!(matches!( + event, + message::Event::Sent(event) + if event.message.sender == sender.login.id + && event.message.body == message + )); + } +} + +#[tokio::test] +async fn nonexistent_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; + + // Call the endpoint + + let sent_at = fixtures::now(); + let channel = channel::Id::generate(); + let request = post::Request { + body: fixtures::message::propose(), + }; + let post::Error(error) = post::handler( + State(app), + Path(channel.clone()), + sent_at, + sender, + Json(request), + ) + .await + .expect_err("sending to a nonexistent channel fails"); + + // Verify the structure of the response + + assert!(matches!( + error, + SendError::ChannelNotFound(error_channel) if channel == error_channel + )); +} + +#[tokio::test] +async fn deleted_channel() { + // Set up the environment + + let app = fixtures::scratch_app().await; + let sender = fixtures::identity::create(&app, &fixtures::now()).await; + let channel = fixtures::channel::create(&app, &fixtures::now()).await; + + app.channels() + .delete(&channel.id, &fixtures::now()) + .await + .expect("deleting a new channel succeeds"); + + // Call the endpoint + + let sent_at = fixtures::now(); + let channel = channel::Id::generate(); + let request = post::Request { + body: fixtures::message::propose(), + }; + let post::Error(error) = post::handler( + State(app), + Path(channel.clone()), + sent_at, + sender, + Json(request), + ) + .await + .expect_err("sending to a deleted channel fails"); + + // Verify the structure of the response + + assert!(matches!( + error, + SendError::ChannelNotFound(error_channel) if channel == error_channel + )); +} diff --git a/src/test/fixtures/channel.rs b/src/test/fixtures/channel.rs index 3831c82..8cb38ae 100644 --- a/src/test/fixtures/channel.rs +++ b/src/test/fixtures/channel.rs @@ -44,3 +44,7 @@ pub fn created(event: channel::Event) -> future::Ready None, }) } + +pub fn fictitious() -> channel::Id { + channel::Id::generate() +} -- cgit v1.2.3